ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PB03蓝牙5.2二次开发:SDK环境、透传与GATT实战

PB03蓝牙5.2二次开发:SDK环境、透传与GATT实战

简介:PB03蓝牙5.2二次开发资料包面向使用安信可PB-03模块(PHY6252 SoC)的物联网、可穿戴与智能家居开发者,适合具备单片机C语言基础、希望从AT指令应用过渡到二次开发的人群。包内共1163个文件,以C源码与头文件为主体,辅以Keil工程文件、链接脚本、INI配置、汇编启动文件、批处理编译脚本及PDF说明文档;其中源码与头文件覆盖协议栈、外设驱动和示例工程,工程文件用于搭建Windows下的编译环境,配置与脚本负责初始化,文档则补充开发指引,压缩包约66.23MB,目录按模块组织便于检索。已有2248人学习下载。读者可据此熟悉64KB SRAM、256KB Flash资源约束下的低功耗蓝牙开发,验证UART、GPIO、ADC、PWM、I2C、SPI等接口,参考示例与升级工具完成串口本地升级、FOTA调试及多种休眠模式配置,减少环境搭建、协议栈入门中的反复试错,并提升调试效率。

1. 拿到 PB03 蓝牙 5.2 二次开发资料先别急着编译:多数人第一周都耗在环境上

硬件同事把模块焊上板子,串口助手打开一片空白;有人说透传固件烧进去就能用,你烧进去连广播都扫不到。问题多半不在模块,而在资料的使用顺序。PB03 蓝牙 5.2 二次开发资料这套东西,本质是给你一个能改的起点:SDK 源码、AT 指令手册、原理图与天线布局建议、烧录打包工具,以及几套可以直接编译的示例工程,覆盖透传、主从一体、HID、Beacon 和 OTA 升级这几种最常见的落地形态。适合两类人:一类是产品形态已经定了,只想把模块塞进去、把数据链路跑通的;另一类是要在模块上写业务逻辑、做私有 GATT 服务的。前者盯着串口和 AT 指令就够,后者必须啃协议栈和工程结构,两条路的入口完全不同,混着走会浪费很多时间。

2. 资料包全景与工程结构:从目录树到协议栈分层

2.1 按“能不能直接跑”给资料分类

拿到一个压缩包,最忌讳从头翻到尾。我一般按能不能立即产生反馈来分三堆:文档、代码、工具。

文档这堆里,AT 指令手册和硬件设计指南是必读的,前者决定你能不能在不写一行代码的情况下验证硬件是好的,后者决定你的天线能不能打出标称距离。协议栈说明和 OTA 流程文档可以先扫一遍目录,等真正要改的时候再回来查具体章节。原理图、PCB 封装、BOM 属于配单阶段就要用的东西,和写代码的节奏不在一条线上。

代码这堆只有 SDK 和示例工程两部分。示例工程的价值不在功能多花哨,而在于它是官方验证过能跑起来的最小闭环——编译能过、烧录能进、串口有输出、手机能连上。任何后续改动都应该建立在这个闭环之上,而不是自己在空工程里从零搭。

工具这堆包括烧录工具、串口日志工具、固件打包脚本。打包脚本容易被忽略,等到做 OTA 才发现生成的 bin 没有头部校验、校验和算法对不上,那就是纯粹的返工。

提示:先花二十分钟把三堆东西分清楚,比上来就make省下的时间多得多。

2.2 SDK 目录树与构建产物的对应关系

不同版本的组织方式会有出入,但骨架大体一致,下面这份是我拆过几次之后总结出来的典型布局:

pb03_sdk/ ├── app/ # 应用层,一个子目录一个可编译目标 │ ├── ble_transparent/ # 透传示例,通常是最先跑通的那个 │ ├── ble_peripheral/ # 从机,自定义服务 │ ├── ble_central/ # 主机,扫描连接外设 │ ├── ble_hid/ # 键鼠类,HID 描述符在这里 │ └── ble_beacon/ # 纯广播,最低功耗形态 ├── components/ # 组件层,和具体应用无关 │ ├── ble/ # 协议栈的薄封装:GAP/GATT/SM 的入口 │ ├── driver/ # UART/SPI/I2C/ADC/GPIO 外设驱动 │ ├── os/ # 轻量任务与定时器,一般是协作式调度 │ └── utils/ # 环形缓冲、日志、CRC、内存池 ├── config/ │ ├── board/ # 板级配置:引脚复用、时钟、射频参数 │ └── linker/ # 链接脚本,分区地址写在这里 ├── doc/ # AT 手册、硬件手册、OTA 说明 ├── tools/ # 烧录、打包、串口脚本 ├── output/ # 构建产物:.elf/.bin/.map └── Makefile # 顶层入口,通常只做分发

关键在两条对应关系。第一条:app/下面每个目录对应output/里一套同名产物,顶层 Makefile 通过APP=xxx变量切换,不要试图在同一个目录里编出两个应用,链接脚本里的分区地址会打架。第二条:config/linker/里的地址决定了 OTA 能不能做,如果你的固件从0x00000000开始且没有给 bootloader 留位置,后面想加 OTA 就只能改分区重烧,那时候产线上已经有货了。

.map文件值得养成看的习惯。它告诉你哪个函数占了最多 Flash、RAM 里静态分配了多少、栈顶离堆底还有多少余量。蓝牙协议栈本身占的 ROM 不小,一个透传应用编出来几万字节是常态,如果突然发现比上次大了十几 K,多半是某个日志宏没关。

2.3 协议栈分层与二次开发的三个切口

蓝牙协议栈从下往上大致是 Controller、L2CAP、SM、ATT、GATT、GAP。二次开发真正能下手的只有三个位置,认清自己在哪一层,能省掉大量无效搜索。

最上层是 AT 层。模块出厂固件把常用能力都做成了 AT 指令,改名字、改广播间隔、改连接参数、开关通知,全在串口上完成,一行代码不用写。适合产品需求稳定、不需要私有协议的场景。它的天花板也很明确:做不了自定义数据格式,做不了多服务并发,做不了精细的功耗调度。

中间层是 GATT 层。这一层是绝大多数项目的实际战场:自己定义服务 UUID、自己排属性表、自己处理读写回调和 CCCD 开关。协议栈把 ATT 的收发包、重传、分片都做完了,你只需要关心“收到数据怎么办”和“有数据怎么发出去”。

最下层是协议栈钩子。少数场景需要干预连接建立、配对流程、射频参数,SDK 一般会留几个弱函数给你重写,比如连接参数更新请求的回调、配对过程中的密钥分发钩子。这一层改动风险最高,一旦和协议栈内部状态机冲突,表现出来就是玄学掉线,抓包都不一定看得出原因。

注意:先在 GATT 层把业务跑通,再考虑往下动。倒过来做的项目,十个里有八个最后要靠推倒重来收场。

3. 把第一个固件跑起来:工具链、编译和烧录的完整链路

3.1 工具链版本对齐这一步不能省

SDK 里通常会注明依赖的编译器版本区间,这个信息不是摆设。不同版本的 arm-none-eabi-gcc 在优化策略、链接时对 section 的排序、甚至内联汇编的语法容忍度上都有差别。我见过最典型的一次翻车:换了一版工具链,编译零警告,烧进去在初始化阶段就挂,最后定位到是某个延时循环被优化掉了。

# 工具链解压到 SDK 同级目录,加进 PATH 后先验版本,别急着编 export PATH=$PWD/toolchain/bin:$PATH arm-none-eabi-gcc --version | head -n 1 # 输出形如 arm-none-eabi-gcc (GNU Toolchain) 10.3.1 20210824 # 对照 SDK 文档里写的版本区间,超出去就先换回来 # 确认 make 版本,老 SDK 的 Makefile 用了某些新语法会直接报错 make --version | head -n 1

参数说明:head -n 1只取第一行输出,因为--version后面还会跟版权信息,脚本里做版本判断时多行文本不好匹配。export PATH只对当前 shell 生效,想持久化就写进 shell 的配置文件,但在多个项目之间来回切的时候,临时 export 反而更安全,避免 A 项目的工具链污染 B 项目。

编译器就位之后先别动代码,把示例工程原样编一遍。这一步的目的是确认工程本身是自洽的,如果这时候就报错,问题在环境不在你。

3.2 从零编译一个透传固件

透传示例是最适合作为起点的目标:逻辑简单、现象明显、串口上立刻能看到反馈。

# 进示例目录,先 clean 再 build。增量编译会掩盖头文件搜索路径的问题 cd app/ble_transparent make clean make -j8 V=1 2>&1 | tee build.log # 编完先看产物大小和段布局,确认没把 OTA 分区写穿 arm-none-eabi-size output/ble_transparent.elf # text data bss dec hex filename # 41236 1024 6218 48478 bd5e output/ble_transparent.elf

参数说明:-j8是并行编译的 job 数,一般取 CPU 核心数,但老 SDK 的依赖关系写得不够严谨时并行会出错,报错就退回-j1重试一次,能过就说明是依赖问题不是代码问题。V=1打开详细输出,第一次编译务必加,否则你只能看到一行CC xxx.c,出问题时完全不知道用了哪些宏。tee build.log把输出同时写到文件和终端,后面搜error和warning都方便。

text段是 Flash 占用,data是初始化的全局变量(既占 Flash 又占 RAM),bss是未初始化或清零的全局变量(只占 RAM)。蓝牙协议栈的 RAM 消耗主要落在bss上,如果bss接近链接脚本里划的 RAM 上限,后面加任何功能都会链接失败。这时候的后悔药只有两个:砍功能,或者换更大 RAM 的型号。

3.3 烧录与串口联调

烧录这一步的坑集中在两处:进下载模式的时序,以及串口设备名。不同操作系统枚举出来的名字完全不一样,脚本里写死一个名字,换台机器就跑不了。

# 1. 先看串口枚举出来没有,有些板子要插上电才会出现 ls /dev/ttyUSB* /dev/ttyACM* 2>/dev/null # 2. 进下载模式:拉低 BOOT 引脚 -> 复位 -> 松手,1 秒内必须开始烧 python3 tools/pb03_dl.py --port /dev/ttyUSB0 --baud 921600 \ --addr 0x00000000 \ --bin output/ble_transparent.bin \ --verify # 3. 烧完复位,抓启动日志确认版本号和初始化状态 python3 tools/serial_log.py --port /dev/ttyUSB0 --baud 115200 --timeout 5

参数说明:--addr必须和链接脚本里的起始地址一致,不一致的结果是烧进去了但跳转过去是乱码,现象上很像模块坏了。--baud是烧录波特率,921600 在长排线或者 USB 转串口质量一般的情况下会丢包,遇到校验失败先降到 115200 排除线路问题。--verify让工具回读校验,多花几秒,但能把“以为烧成功了”这种最浪费时间的情况挡掉。

串口日志一定要在烧录之后立刻抓一次。里面会有固件版本、射频校准结果、广播是否启动成功这几条关键信息。射频校准失败是个隐蔽的坑——它不会让程序崩溃,只会让距离变得特别近,几米就断,很容易被误判成天线设计问题。

3.4 用 AT 指令先验证链路通不通

在写任何业务代码之前,用 AT 指令把整条链路走一遍,是成本最低的验证方式。如果 AT 能连通而你的代码连不通,问题一定在代码;如果 AT 也连不通,问题在硬件或者烧录。

> AT < OK > AT+NAME=PB03-UART < OK > AT+ADVPARAM=100,0,0 < OK > AT+MTU=247 < OK > AT+SAVE < OK

指令说明:AT是心跳,回OK说明串口参数对了、固件活着。AT+NAME改设备名,手机上扫描列表里显示的就是它,调试阶段建议带上版本号,比如PB03-v1.2,多块板子同时上电时能一眼分辨。AT+ADVPARAM三个参数依次是广播间隔(单位通常是 0.625ms 的倍数,写 100 对应 62.5ms 左右,具体看手册的换算表)、广播类型、过滤策略,间隔越小越容易被扫到但功耗越高。AT+MTU=247把最大传输单元设到上限,直接影响后面单包能带多少数据。AT+SAVE写进非易失存储,不写的话掉电就回默认值——这条忘了做,后面每次重启都要重新配一遍。

链路验证通过之后再回去看代码,心里就有底了。

4. 核心模块拆解:广播包、GATT 属性表和透传数据通路

4.1 广播与扫描响应的字节构造

广播包是设备的门面,手机能不能扫到你、扫到之后显示什么、带不带自定义信息,全在这 31 个字节里。传统广播的载荷上限是 31 字节,扩展广播才能放开,但扩展广播需要手机侧支持,兼容性上要打折。

/* ble_adv.c —— 广播包与扫描响应的组装,重点在长度校验 */ #include <string.h> #include "ble_adv.h" #define ADV_MAX_LEN 31 /* 传统广播 PDU 载荷上限 */ /* 把若干 AD 结构体顺序拼进 buf,返回实际长度;超限就截断 */ static uint8_t adv_pack(uint8_t *buf, uint8_t buf_len, const adv_item_t *items, uint8_t cnt) { uint8_t off = 0; for (uint8_t i = 0; i < cnt; i++) { uint8_t need = items[i].len + 2; /* 1 字节长度 + 1 字节类型 */ if (off + need > buf_len) { log_warn("adv overflow at item %d, need %d, left %d", i, need, buf_len - off); break; /* 宁可少字段,绝不越界写 */ } buf[off++] = items[i].len + 1; /* 长度字节包含类型字节本身 */ buf[off++] = items[i].type; memcpy(&buf[off], items[i].data, items[i].len); off += items[i].len; } return off; } void adv_start(void) { adv_item_t items[] = { /* Flags:LE General Discoverable + BR/EDR Not Supported */ { .type = 0x01, .len = 1, .data = (uint8_t[]){0x06} }, /* 完整设备名,注意别超过 248 字节,实际还受总长限制 */ { .type = 0x09, .len = 6, .data = (uint8_t*)"PB03X" }, /* 厂商自定义数据:0x5A 是自定义标识,后面跟版本和状态 */ { .type = 0xFF, .len = 4, .data = (uint8_t[]){0x5A, 0x01, 0x02, 0x03} }, }; uint8_t adv[ADV_MAX_LEN]; uint8_t len = adv_pack(adv, sizeof(adv), items, sizeof(items) / sizeof(items[0])); /* 广播数据和扫描响应分开设,扫描响应也是 31 字节独立额度 */ ble_gap_adv_data_set(adv, len, NULL, 0); ble_gap_adv_start(ADV_INTERVAL_100MS); /* 100ms:功耗与可发现性的折中 */ }

逻辑说明:AD 结构是[长度][类型][数据...]三部分,长度字节的值是“类型字节 + 数据字节”的总数,也就是数据长度加 1,这是新手最容易算错的地方,算错一位后面的所有字段都会错位,手机侧解析出来就是乱码。

参数说明:ADV_MAX_LEN写成 31 是传统广播的硬约束,超了协议栈会直接返回错误。设备名放在广播包里会挤占其他字段的空间,如果自定义数据较多,把名字挪到扫描响应里,扫描时手机依然能读到。ADV_INTERVAL_100MS是我一般会用的默认值,100ms 左右既能保证手机快速发现,静态功耗也不会太难看;电池供电且只要求“能被发现”的产品,可以拉到 500ms 以上。

4.2 自定义 GATT 服务的属性表注册

GATT 属性表决定了服务和特征的组织方式,也决定了 handle 的分配。协议栈基本都靠“表内偏移”来算 handle,所以表的顺序不能乱动。

/* gatt_svc.c —— 自定义串口服务,两个特征一收一发 */ static uint8_t rx_value[247]; /* 手机写入方向 */ static uint8_t tx_value[247]; /* 模块通知方向 */ static uint16_t tx_cccd; /* 客户端配置描述符,开关通知用 */ /* 属性表按 handle 升序排列,协议栈据此递增分配 handle */ static const gatt_attr_t uart_svc_attrs[] = { /* 0: 服务声明 */ { .uuid = UUID_PRIMARY_SERVICE, .perm = RD, .value = (uint8_t[])SVC_UUID_16(0xFFF0), .len = 2 }, /* 1: 特征声明 —— RX,属性为可写 */ { .uuid = UUID_CHARACTERISTIC, .perm = RD, .value = (uint8_t[]){0x08, 0x03, 0x00, 0xF1, 0xFF}, .len = 5 }, /* 2: RX 值,带应答的写请求走这里,客户端能确认送达 */ { .uuid = UUID_16(0xFFF1), .perm = WRITE | WRITE_REQ, .value = rx_value, .len = 0, .max_len = sizeof(rx_value), .on_write = uart_on_rx_write }, /* 3: 特征声明 —— TX,属性为通知 */ { .uuid = UUID_CHARACTERISTIC, .perm = RD, .value = (uint8_t[]){0x10, 0x04, 0x00, 0xF2, 0xFF}, .len = 5 }, /* 4: TX 值,只能由协议栈推送,应用侧不能直接读改 */ { .uuid = UUID_16(0xFFF2), .perm = NOTIFY, .value = tx_value, .len = 0, .max_len = sizeof(tx_value) }, /* 5: CCCD,手机写 0x0001 开通知,0x0000 关通知 */ { .uuid = UUID_CLIENT_CHAR_CFG, .perm = RD | WRITE, .value = (uint8_t*)&tx_cccd, .len = 2 }, }; void uart_svc_init(void) { gatt_service_register(0xFFF0, uart_svc_attrs, sizeof(uart_svc_attrs) / sizeof(uart_svc_attrs[0])); }

逻辑说明:特征声明那 5 个字节里,第一个是属性位图,第二个和第三个是特征值的 handle(很多 SDK 允许填 0 由协议栈回填),后两个是 UUID。属性位图的取值要和下面那条值的perm对上:声明里写了可写、值上却只给了RD,手机写的时候会收到“权限不足”,现象是写操作静默失败,非常难查。

参数说明:WRITE_REQ是带应答的写,客户端能拿到确认,适合指令类数据;如果走WRITE_CMD(无应答写),吞吐高但丢了不知道,适合音频流这类可以容忍丢包的场景。NOTIFY和INDICATE的区别在于后者要求客户端回确认,可靠性高但吞吐减半。max_len要和 MTU 对齐,MTU 没协商上去的时候单包有效载荷只有 20 字节,写 247 只是允许的上限,不代表一次能发这么多。

tx_cccd一定要做成静态变量而不是局部变量,协议栈会长期持有这个指针,局部变量出栈之后那块内存被复用,表现就是“通知开关时灵时不灵”。

4.3 透传数据通路:环形缓冲与流控

串口的速度和 BLE 的空口速度不匹配,中间必须有一层缓冲,还得有流控,否则要么丢数据要么把协议栈的发送缓冲撑爆。

/* uart_pipe.c —— 串口到 BLE 的单向搬运,带在途包数限制 */ #define PIPE_SIZE 1024 static uint8_t ring[PIPE_SIZE]; static volatile uint16_t head, tail; /* 单生产者单消费者,不需要加锁 */ static volatile uint8_t inflight; /* 已交给协议栈、还没收到完成事件的包数 */ void uart_isr_rx(uint8_t byte) { uint16_t next = (head + 1) % PIPE_SIZE; if (next == tail) { /* 满了:丢新字节并计数,不阻塞中断 */ pipe_stat.overflow++; return; } ring[head] = byte; head = next; } static uint16_t pipe_pop(uint8_t *dst, uint16_t max) { uint16_t n = 0; while (n < max && tail != head) { dst[n++] = ring[tail]; tail = (tail + 1) % PIPE_SIZE; } return n; } /* 协议栈发完一包回调这里,归还一个在途额度并立刻续发 */ void ble_tx_complete_cb(void) { if (inflight) inflight--; pipe_kick(); } void pipe_kick(void) { /* MTU 大就允许多压几包,MTU 小就收着点,避免占满协议栈缓冲 */ uint8_t mtu = ble_get_mtu(); uint8_t max_inflight = (mtu >= 67) ? 4 : 2; while (inflight < max_inflight && tail != head) { uint8_t buf[247]; uint16_t n = pipe_pop(buf, mtu - 3); /* ATT 头占 3 字节 */ if (ble_notify(TX_HANDLE, buf, n) == 0) { inflight++; } else { break; /* 协议栈缓冲满,下轮再试 */ } } }

逻辑说明:环形缓冲用head和tail两个游标,中断里只动head,任务上下文只动tail,天然是单生产者单消费者模型,不需要关中断也不需要互斥锁。缓冲区满的时候选择丢新字节而不是覆盖旧数据,因为旧数据往往是一个完整协议帧的前半段,覆盖掉会让整帧作废。

参数说明:PIPE_SIZE取 1024 是个经验值,太小会在串口突发时频繁溢出,太大浪费 RAM——蓝牙应用里 RAM 是稀缺资源。mtu - 3里的 3 是 ATT 头(1 字节 opcode + 2 字节 handle),这个数字写错会导致每次 notify 都返回长度错误。max_inflight控制同时在飞的包数,压太多会把协议栈内部缓冲占满,反过来影响连接事件的响应速度,表现出来就是连接参数更新变慢。ble_tx_complete_cb里立刻调pipe_kick而不是等定时器轮询,能把吞吐拉高一大截,代价是要保证这个回调本身足够轻。

5. 常见问题排查:从烧不进去到连上就掉的五类故障

5.1 烧录阶段的三个硬故障

现象一:烧录工具报“握手超时”,串口能打开但没有任何响应。原因通常是没进下载模式,或者进模式的时序不对——BOOT 拉低和复位释放之间的间隔有要求,太快太慢都不认。解决办法是把时序固定下来:先拉低 BOOT,保持 200ms 以上,再给复位脉冲,松手后 1 秒内启动烧录脚本。如果还是不行,把波特率降到 115200 再试一次,长排线在高速率下握手包容易丢。

现象二:烧录成功但复位后串口一片空白。原因多半是起始地址和链接脚本不一致,程序被烧到了错误的偏移,CPU 跳过去执行的是无效指令。解决办法是打开.map文件确认__flash_start的值,和烧录命令里的--addr逐位对比。另一个可能是时钟配置错了,外部晶振没起振,程序卡在时钟初始化里,这时候串口连初始化都没走完。

现象三:烧完之后能跑,重新上电就不跑了。原因是固件没有正确写入非易失存储区,或者存储区的擦除顺序有问题——先写后擦是常见的逻辑错误。解决办法是在写入流程里加回读校验,写完之后立刻读出来比对,不一致就重试一次,连续失败就上报错误而不是静默忽略。

5.2 连接阶段的兼容性问题

现象四:部分手机能连上,部分手机在连接阶段直接超时。原因通常是广播包里的字段有冲突,比如同时声明了可连接和不可连接,或者设备名长度字段和实际内容对不上,个别协议栈严格的手机就会直接丢弃这个广播。解决办法是拿一台能连上的和一台连不上的做对比,用抓包工具看广播原始字节,逐字段核对长度。再一个常见原因是连接参数请求太激进,比如要求 7.5ms 的连接间隔,部分系统会拒绝并断开,改成 15ms 到 30ms 之间通常都能接受。

现象五:连上之后几秒内必定断开,重连又能撑几秒。原因是连接参数更新和业务数据发送撞在了一起,协议栈在参数更新期间对发包有限制,连续发包会触发内部错误导致链路断开。解决办法是在参数更新完成事件之前,把发送队列暂停,等更新回调回来再恢复。这个问题的隐蔽之处在于,日志里往往只看到一句“链路断开”,没有任何错误码,完全靠时序推断。

5.3 运行阶段的稳定性与功耗

现象六:跑几个小时之后 RAM 越来越少,最后死机。原因是某个回调里做了动态内存分配但没释放,蓝牙事件回调触发频率很高,泄漏会累积得很快。解决办法是把所有malloc从回调里挪出去,改成启动时一次性分配的静态池,回调里只做取用和归还。

现象七:标称能跑几个月的电池,实际两周就没电。原因通常是广播间隔设得太短,或者连接间隔协商失败退化到了默认值。还有一个容易被忽略的点:休眠前没有关掉没用的外设时钟,串口和 ADC 的时钟一直开着,静态电流能高出几十微安。解决办法是拿电流表测一遍各个阶段的电流,广播态、连接态、空闲态分别看,哪个阶段超标就查那个阶段的配置。

注意:功耗问题不要靠猜,一定要有实测数据,否则改了半天可能改的是不相干的地方。

6. 二次开发改造:把透传 Demo 改成产品固件的四条路径

透传 Demo 能跑通,离产品固件还有一段距离。这段距离怎么走,取决于你要动的是哪一块。

第一条路是换协议格式。透传是纯字节流,产品往往需要带帧头、长度、命令字和校验的结构化协议。改动点集中在uart_on_rx_write回调和发送侧的分包逻辑上。我的做法是在环形缓冲和 GATT 之间插一层解析器,串口收到的字节先过状态机,凑齐一帧再交给上层,避免半帧数据被直接推给手机。

第二条路是加多服务。透传只有一个自定义服务,实际产品可能要同时挂电池服务、设备信息服务、OTA 服务。属性表是可以拼接的,把每张表的长度和指针放进一个数组,注册时依次调用即可。要注意 handle 是全局递增的,拼接顺序会影响最终的 handle 值,已经出货的设备如果改顺序,手机端缓存的 handle 就会全部失效,必须配合服务变更标志位做兼容。

改造方向主要改动文件需要同步验证的点
私有协议封装uart_pipe.c、新增frame_parse.c半帧、粘包、校验失败丢弃
多服务拼接gatt_svc.c、服务注册处handle 变化、服务变更标志
OTA 分区config/linker/、打包脚本双区切换、断电续传、回滚
低功耗调度广播参数、休眠钩子各阶段实测电流、唤醒延迟

第三条路是做 OTA。这一条最容易被低估。分区表要提前规划,bootloader 要能校验固件完整性,升级过程中断电要能回滚。打包脚本生成的固件头部至少要有版本号、长度和 CRC,缺一样都会在升级失败的时候让你无从排查。

第四条路是功耗调度。把广播间隔、连接间隔、休眠策略当成一组联动参数来调,不要单独改某一个。改完必须实测,电流表上的数字比任何推算都可靠。

验证方法我一般固定三步:先用 AT 指令确认链路正常,再用抓包工具看空口行为符合预期,最后用电流表跑满 24 小时看有没有缓慢劣化。三步都过才算改完。

血泪经验是有一回为了省事,直接在产品固件上改 GATT 表顺序,编译烧录一路顺畅,测试也没问题,结果已经发出去的一批设备升级之后,老版本 App 全部连不上——handle 变了,客户端按旧 handle 读写,全部返回属性不存在。从那以后我每次动属性表,都强制走一遍新旧版本 App 的交叉兼容测试,哪怕只加了一个描述符。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进