3. 协议栈源码结构:SDK目录树解析、核心库与示例工程、链接脚本与内存布局、编译系统

好,咱们进入第三章。这一章我打算带你把协议栈的「骨架」摸清楚。说白了,就是拿到一个陌生的SDK,你该怎么下手?哪些文件夹是核心,哪些是凑数的?链接脚本那玩意儿到底在干嘛?还有那个让人头疼的Makefile,到底怎么改才能跑起来?

嗯,别急。我一个一个给你拆开讲。这些都是我当年踩坑踩出来的经验。

3.1 SDK目录树解析:别被文件夹吓到

我第一次拿到某大厂的蓝牙SDK时,打开压缩包,好家伙,七八层目录,上百个文件夹。当时我就懵了。后来我总结出一个规律:再复杂的SDK,核心也就那么几个目录

一般来说,一个典型的低功耗蓝牙协议栈SDK,目录结构长这样:

ble_sdk/
├── components/          # 核心组件库
│   ├── ble/             # 蓝牙协议栈核心
│   │   ├── include/     # 头文件(API声明)
│   │   ├── src/         # 源码(大部分是库,不给你看)
│   │   └── lib/         # 预编译的 .a 或 .lib 文件
│   ├── drivers/         # 外设驱动(GPIO、UART、SPI等)
│   └── os/              # 操作系统抽象层(FreeRTOS或裸机)
├── examples/            # 示例工程
│   ├── ble_heart_rate/  # 心率手环示例
│   ├── ble_hid/         # 键盘鼠标示例
│   └── ble_beacon/      # 信标示例
├── tools/               # 工具链(烧录脚本、打包工具)
├── docs/                # 文档(别指望太多,通常很简略)
└── projects/            # 用户工程目录(你自己写的代码放这)

我个人习惯,拿到SDK第一件事,就是先看 examples/ 目录。为什么?因为示例工程是官方帮你搭好的「样板间」。你直接编译它,能跑通,说明你的工具链和环境没问题。然后你再照着它的结构,去改自己的工程。

小技巧: 如果你发现某个示例工程编译报错,先别急着改代码。八成是路径没配对,或者芯片型号选错了。我在项目中遇到过好几次,折腾半天,最后发现是宏定义没打开。

3.2 核心库与示例工程:哪些是「真」代码?

你打开 components/ble/lib/ 目录,里面通常是一堆 .a.lib 文件。这些就是协议栈的「核心库」。说白了,芯片原厂把最底层的蓝牙协议栈实现编译好了,只给你留了几个API接口。你调用就行,别想着去改它。

那什么是「示例工程」?就是官方给你写好的一个完整应用。比如一个心率手环的demo,它包含了:

  • 初始化流程:芯片上电后,先初始化时钟、GPIO、然后初始化蓝牙协议栈。
  • 广播与扫描:手环作为外设,广播自己的心率数据。
  • 连接与配对:手机连上手环,进行配对绑定。
  • 数据传输:通过GATT协议,把心率值发给手机。

你想想看,如果让你从零开始写这些,光蓝牙协议栈的初始化参数配置,就能让你看文档看到吐。但有了示例工程,你只需要改几个回调函数里的业务逻辑就行。

举个例子,这是我从一个心率示例里摘出来的关键代码:

// 初始化蓝牙协议栈
void ble_stack_init(void)
{
    ble_cfg_t cfg;
    memset(&cfg, 0, sizeof(cfg));
    
    // 配置广播参数
    cfg.adv_cfg.adv_type = BLE_ADV_TYPE_IND;
    cfg.adv_cfg.interval = 100;  // 100ms广播间隔
    cfg.adv_cfg.channel_map = BLE_ADV_CH_ALL;
    
    // 调用协议栈初始化API
    ble_stack_init(&cfg);
    
    // 注册回调函数
    ble_gap_register_cb(gap_event_handler);
    ble_gatt_register_cb(gatt_event_handler);
}

你看,核心就这几行。剩下的都是业务逻辑。所以我的建议是:先跑通示例,再动手改。别一上来就自己新建工程,那是给自己找麻烦。

注意: 有些SDK的示例工程里,会包含一些「过期」的代码。比如某个API在新版本里已经废弃了,但示例里还在用。编译时会报warning,但不影响功能。我曾经因为这个排查了一整天,最后发现是官方文档没更新。所以,遇到warning,先查一下芯片的勘误手册。

3.3 链接脚本与内存布局:你的代码到底放哪了?

链接脚本,就是 .ld 文件。很多初学者觉得这东西高深莫测,其实说白了,它就是一张「地图」,告诉编译器:你的代码该放在Flash的哪个地址,变量该放在RAM的哪个地址。

一个典型的链接脚本,核心内容就这几块:

段(Section) 存放内容 存放位置 说明
.text 代码指令 Flash 你的函数、中断向量表都在这
.rodata 只读数据 Flash 字符串常量、const变量
.data 已初始化全局变量 Flash(运行时拷贝到RAM) 上电后从Flash拷贝到RAM
.bss 未初始化全局变量 RAM 上电后清零
.heap 堆空间 RAM malloc/free用
.stack 栈空间 RAM 函数调用、局部变量用

嗯,这里要注意一点:蓝牙协议栈的库,通常会被强制链接到特定的Flash地址。为什么?因为协议栈里有些代码是位置相关的,比如中断向量表、或者某些跳转指令。如果你改了链接脚本,把协议栈库放到了错误的地址,那系统一启动就死机。

我在项目中遇到过一个问题:手环跑着跑着就死机,复位后又能跑一会儿。排查了三天,最后发现是链接脚本里,协议栈库的 .text 段和用户代码的 .text 段重叠了。说白了,就是Flash空间不够,两个段挤到一起了。解决方案很简单:要么换大容量Flash的芯片,要么优化代码体积。

核心原则: 链接脚本不要随便改。除非你非常清楚自己在做什么。如果非要改,请先备份原文件,然后每次只改一个参数,编译测试。

3.4 编译系统:Makefile 与 CMake 的抉择

编译系统,说白了就是告诉编译器「怎么编译、链接哪些文件、生成什么格式的输出」。目前主流的就两种:MakefileCMake

Makefile 是「老前辈」了。它的语法比较原始,说白了就是一堆变量定义和规则。比如:

# 定义编译器
CC = arm-none-eabi-gcc

# 定义编译选项
CFLAGS = -mcpu=cortex-m4 -mthumb -O2 -Wall

# 定义源文件
SRCS = main.c ble_stack.c app_heart_rate.c

# 定义目标文件
OBJS = $(SRCS:.c=.o)

# 链接规则
$(TARGET).elf: $(OBJS)
    $(CC) $(CFLAGS) -T $(LINKER_SCRIPT) -o $@ $^

你看,Makefile 的优点是直接、透明。你写的每一行,编译器都会照做。但缺点也很明显:跨平台能力差。你换个编译器,或者换个操作系统,Makefile 可能就得重写。

CMake 是后来者。它不直接生成可执行文件,而是先生成 Makefile(或者 Ninja 构建文件),然后再调用编译器。它的语法更高级,支持跨平台。比如:

cmake_minimum_required(VERSION 3.10)
project(ble_handring C ASM)

# 设置芯片型号
set(MCU cortex-m4)

# 添加源文件
add_executable(ble_handring
    main.c
    ble_stack.c
    app_heart_rate.c
)

# 设置链接脚本
target_link_options(ble_handring PRIVATE
    -T ${CMAKE_SOURCE_DIR}/linker.ld
)

我个人更推荐用 CMake。为什么?因为现在很多芯片原厂都开始用 CMake 了。你想想看,如果你以后换芯片,CMake 的移植成本比 Makefile 低得多。你只需要改几行配置,就能适配新的工具链。

不过,如果你用的是比较老的芯片,或者原厂只提供了 Makefile 示例,那也别纠结。Makefile 也能用,只是维护起来稍微麻烦点。

避坑指南: 我曾经在移植一个 Nordic 的协议栈时,发现它的 CMake 脚本里有个隐藏的坑——它默认开启了 LTO(链接时优化)。LTO 确实能减小代码体积,但也会导致某些调试信息丢失。如果你在调试时发现变量值不对,先关掉 LTO 试试。

好了,这一章的内容就这些。总结一下:

  • SDK目录树:核心看 components/examples/,其他都是辅助。
  • 核心库与示例:先跑通示例,再改业务逻辑。别自己从头搭工程。
  • 链接脚本:别乱改。改之前先备份,改之后要测试。
  • 编译系统:推荐 CMake,但 Makefile 也能用。关键是理解它的逻辑。

下一章,咱们开始动手移植。我会带你一步步把协议栈跑起来。到时候,你就能感受到「从零到一」的成就感了。