十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32WB55RG BLE开发实战:从GATT服务到通知收发的完整指南

STM32WB55RG BLE开发实战:从GATT服务到通知收发的完整指南 简介面向 STM32WB55RG 与蓝牙低功耗开发者的完整工程资源包配套 CSDN 图文教程与 B 站视频解决从 CubeMX 生成 BLE 工程到手机 APP 连接调试的全流程问题。资源共 335 个文件压缩包 37.29MB核心源码以 138 个 h 头文件、56 个 c 源文件为主体另有 uvprojx/uvoptx 工程配置、ioc 图形化配置、axf/hex 烧录文件及 PDF 文档与 APK 手机端工具可满足代码阅读、编译下载和真机验证多环节需要。已有 167 人学习下载适合刚接触 STM32WB 无线系列的开发者按教程逐步上手。内容包含 RCC、RTC、RF、IPCC、HSEM 等关键模块初始化BLE 协议栈事件处理与 HCI 层代码同时提供 stm32wbxx_hal 驱动示例和 ST BLE Toolbox 调试 APP能够帮助读者快速搭建 BLE 透传或远程控制应用并理解 STM32WB 双核架构下的时钟与无线协同工作机制。1. 从串口到 GATT把 STM32WB55RG 变成手机能发现的 BLE 外设很多人第一次拿到 STM32WB55RG以为写 BLE 程序就是调一个send()函数把数据推给手机实际跑起来才发现手机扫描不到设备、连接后立刻断开、GATT 读写返回错误码这才是常态。BLE 不是无线串口它是一套有服务、特征、描述符和 ATT 权限模型的协议栈而 STM32WB55RG 这颗芯片的特殊之处在于它内部有双核一个 Cortex-M4 跑应用代码一个 Cortex-M0 专门跑蓝牙协议栈。所谓“生成 BLE 程序”在 CubeMX 里生成的其实是一套协议栈上下文、GATT 数据库模板和事件回调框架你要做的是往这个框架里填业务逻辑。这篇文章面向已经在 STM32 上写过外设驱动、但对 BLE 协议只有模糊概念的开发工程师目标是让你在一天内跑通“板子发数据、手机 App 能读到”的最小闭环并且理解每一个参数为什么这样设、改错了会发生什么。整个过程中你需要先动手把开发环境搭对用 STM32CubeMX 生成第一份可编译的工程再解析生成的代码里 GATT 服务是怎么注册的最后通过 STM32CubeMonitor-RF 或 Android 侧的心率监测类 App 验证连接行为。2. 开发环境三板斧CubeMX 固件包、编译器与烧录器的配套关系2.1 为什么 STM32WB55RG 的工程生成方式和普通 STM32 不一样STM32WB55RG 是双核 MCU这意味着你在 CubeMX 里创建一个工程时会看到它同时生成两个项目的引用一个给 M4 应用核一个给 M0 无线核。M0 上运行的是一套预编译的蓝牙协议栈二进制文件称为 FUS / BLE Stack 固件STM32 出厂时 Flash 里可能已经有 FUS但 BLE 协议栈的版本不一定是新的需要你先通过烧录器把stm32wb5x_BLE_HCILayer_fw.bin或类似命名的无线核固件刷进去。很多人在这一步就卡住了用 ST-Link 直接把编译好的 M4 程序烧进芯片上电后手机能搜到广播但连接请求一到就复位或者广播名是乱码——原因通常是 M0 核的协议栈没升级到和 CubeMX 生成的代码匹配的版本。2.2 STM32CubeMX 里必须选择的选项清单打开 STM32CubeMX新建工程选择 STM32WB55RGVx 这颗料在Connectivity分类下找到IPs里的RADIO勾选 BLE 作为激活的协议栈。关键的一步在Toolchain设置界面Firmware Package版本要和你安装的 STM32CubeWB 固件库一致否则生成出来的app_ble.c里的配置结构体字段会和新版协议栈不兼容。我一般会在Project Manager→Project→Toolchain里选择STM32CubeIDE这样生成的工程直接可编译不需要再手工迁移链接脚本。另外RADIO配置页里有一个Payload Size参数默认是 27 字节这是链路层的 MTU 限制如果你后续要发超过 20 字节单包数据就得把这个值往上调比如改成 251同时手机端也要在连接后协商 MTU否则 GATT 层的 MTU 依然会在 23 字节左右。这个参数在后面验证大包发送时会反复用到建议一开始就设成 251。2.3 烧录顺序先无线核再应用核顺序反了会白烧编译完成后你手上会有两个工程产物M4 核的.elf文件以及 CubeMX 生成时自动从固件包里拷贝出来的 M0 协议栈镜像。在 STM32CubeProgrammer 里需要分两次连接芯片第一次选Firmware Upgrade模式把 BLE 协议栈镜像写入无线核的专用 Flash 分区第二次以正常的 SWD 模式连接烧录 M4 应用。如果先烧 M4后烧 M0那么 M0 的复位向量会被新协议栈覆盖应用核启动时通过 IPCC 发起的握手信号就会因版本不匹配被拒绝。判断协议栈是否已经就绪有一个简单方法在 M4 的main()里调用CFG_HW_Init()之前先读一下CFG_HW_BLE_GetVersion()的返回值如果版本号全是 0xFFFFFFFF说明无线核没跑起来。这里有个常见的坑STM32CubeProgrammer 在烧录无线核固件时如果芯片处于低功耗模式或者调试口被复用烧录会直接失败所以烧录前按住板子上的复位键点击烧录后在松开成功率会高很多。STM32_Programmer_CLI -c portSWD modeHOTPLUG STM32_Programmer_CLI -c portSWD modeHOTPLUG -fwupgrade binstm32wb5x_BLE_HCILayer_fw.bin第一行命令先探测调试口连接是否正常第二行把 BLE 协议栈镜像写入无线核。注意使用 HOTPLUG 模式可以避免芯片自身固件把 SWD 引脚复用掉导致连接失败。-fwupgrade参数会强制擦除并重写无线核的固件分区如果芯片里已经有旧版协议栈不加这个参数会提示区域被占用。整个烧录过程中M4 核必须处于复位状态或空芯片状态否则 M4 上跑的程序如果对 IPC 外设有抢占操作可能导致无线核 Flash 写入时序被打断造成协议栈镜像损坏。3. CubeMX 生成工程后的第一件事把广播数据和 GATT 服务改成自己的3.1 找到app_ble.c里的关键结构体hci_init和aci_gap_init生成工程默认会创建一个名叫BLE_App的示例配置文件里面包含了完整的连接管理循环但广播名是默认的STM32WBGATT 服务也是空的只有一个 Generic Access Service。你需要在app_ble.c的BLE_Init()函数里找到两行核心代码hci_init(); // 初始化主机控制器接口建立 M4 与 M0 之间的 IPC 通信通道 aci_gap_init(0, 0, 0x07, 0x00, gap_service_handle, gap_dev_name_char_handle);hci_init()内部会通过 IPCC 协议向 M0 发送 HCI Reset 命令确保无线核回到已知状态。aci_gap_init()的第一个参数是角色0 表示外设Peripheral第二个参数是是否启用隐私这里用 0 关闭第三个参数0x07是 GAP 服务支持的特性掩码展开为二进制是 0b111分别代表设备名称、外观特征、以及 LE 地址解析支持。gap_service_handle和gap_dev_name_char_handle是两个传出参数协议栈会填充分配好的句柄值这两个值后续操作 GATT 数据库时经常要用到不要直接忽略。3.2 修改广播包aci_gap_set_discoverable的参数语义要让手机能搜到板子必须设置可发现模式和广播数据。默认生成的调用了aci_gap_set_discoverable()但广播数据长度是 0。你需要在调用前填充一个adv_data数组uint8_t adv_data[] { 0x02, 0x01, 0x06, // Flags0x06 表示 LE General Discoverable BR/EDR Not Supported 0x03, 0x02, 0xFB, 0x34, // 完整的 16-bit Service UUID这里填 0x34FB自定义服务 0x0C, 0x09, M, Y, -, W, B, 5, 5, R, G }; aci_gap_set_discoverable( ADV_IND, // 可连接的无向广播 0x00, // 不限制广播时长 0x00, // 不限制单个广播事件时长 0x07, // 广播间隔 7 * 1.25ms 8.75ms adv_data, sizeof(adv_data), NULL, 0 // 无扫描响应数据 );广播数据里每一条 AD Structure 的第一个字节是长度包含类型字节本身第二个字节是类型后面才是数据。0x34FB是我随手定义的一个自定义服务 UUID 的低 16 位你可以换成自己注册的 16 位 UUID0x09类型代表 Complete Local Name手机蓝牙列表里显示的名字就是这个。广播间隔 7 表示 8.75 毫秒这个值非常激进实际设备里一般设在 100ms 到 500ms 之间广播间隔越短手机扫描到设备的延迟越低但功耗越高。用手机 App 扫描时你应该能在列表里看到一个名字为MY-WB55RG、含一个未知服务 UUID 的设备。3.3 新增自定义 GATT 服务用aci_gatt_add_serv注册服务BLE 设备之所以能被手机 App 解析出数据靠的是 GATT 数据库里有结构化的属性表——服务、特征、描述符每一层都对应 ATT 协议里的一段属性。CubeMX 生成的代码里没有现成的自定义服务你需要在BLE_Init()里自己注册static uint8_t svc_uuid[16] { 0xFB, 0x34, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; uint16_t custom_service_handle; aci_gatt_add_serv( PRIMARY_SERVICE, // 服务类型主服务 svc_uuid, // 128-bit UUID小端序 3, // 属性数量特征声明 特征值 客户端特征配置描述符 custom_service_handle );aci_gatt_add_serv的第三个参数是预留给这个服务的属性记录数量。为什么是 3因为一个可通知的 Notify 特征在 GATT 数据库中至少占 3 条属性第一条属性存储特征声明表征 UUID、属性权限和特征值句柄第二条属性存储特征值本身第三条属性是客户端特征配置描述符CCCD用来让手机端订阅或取消订阅通知。如果你少算了属性数量协议栈会返回BLE_STATUS_INVALID_PARAMETER而且不会给出具体是哪一个参数错排查时只能逐个参数试所以一开始就按 3 个预留比较稳妥。3.4 特征值为什么要单独设置权限才不会被手机读写失败服务注册完成并不代表特征能直接用你还得显式添加特征static uint8_t char_uuid[16] { 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; uint16_t char_handle, cccd_handle; aci_gatt_add_char( custom_service_handle, 0x02, // 特征属性读 写 char_uuid, 20, // 特征值最大长度 ​0x00, // 特征值初始权限无加密要求 ATTR_PERMISSION_NONE, // 特征值访问权限允许任意客户端访问 0x00, 0x00, // 扩展属性 0x00, 0x00, // 写描述符权限 0x00, 0x00, // 读描述符权限 char_handle, cccd_handle );这里最容易被忽略的是权限参数。0x02对应 GATT 特征属性的GATT_CHAR_PROP_READ | GATT_CHAR_PROP_WRITE只声明了可读可写但没有声明GATT_CHAR_PROP_NOTIFY所以手机 App 即使找到这个服务也收不到通知必须等你在下面添加 CCCD 后往特征值里写数据。如果把权限全填成ATTR_PERMISSION_READ_ONLY手机端写数据时会立刻收到错误响应0x03Write Not Permitted这是初学者最常踩的坑也是为什么要把ATTR_PERMISSION_NONE单独列出来讲的原因。4. 连接事件循环蓝牙协议栈的回调机制与数据收发状态机4.1BLE_Status的结构和hci_le_connection_update的调用时机BLE 协议栈和 M4 应用之间不是共享内存的裸调用关系而是通过事件回调驱动的异步模型。当手机发起连接、断开、或某个 GATT 特征被写入时M0 核会把事件打包通过 IPCC 中断通知 M4M4 在BLE_Status()这个回调函数里根据event_code分发处理。打开生成工程的app_ble.c你会看到一个巨大的switch结构void BLE_Status(void *pData) { hci_event_pckt *event_pckt (hci_event_pckt *)pData; tHciDataPacket *event_data (tHciDataPacket *)event_pckt-data; switch (event_pckt-ecode) { case HCI_DISCONNECTION_COMPLETE_EVT_CODE: // 连接断开后重新进入可发现状态否则手机第二次搜不到 aci_gap_set_discoverable(ADV_IND, 0, 0, 0x80, adv_data, sizeof(adv_data), NULL, 0); break; case HCI_LE_CONNECTION_UPDATE_COMPLETE_EVT_CODE: // 连接参数协商完成后可以在此读取当前连接间隔 break; default: break; } }HCI_DISCONNECTION_COMPLETE_EVT_CODE事件触发时角色为外设的 STM32WB55RG 默认不会自动恢复广播你必须重新调用aci_gap_set_discoverable()否则手机 App 断开一次之后就无法再搜到设备。HCI_LE_CONNECTION_UPDATE_COMPLETE事件则对应连接参数更新请求的完成状态如果你调用hci_le_connection_update()想修改连接间隔实际生效的间隔和请求值不一定相同要以这个事件的回调参数为准。很多产品在手机锁屏后丢数据就是因为连接间隔在请求值偏小、手机侧拒绝了更新回调里又没有读取实际协商结果代码里拿着错误的连接间隔去计算超时。4.2 手机写数据到板子解析aci_gatt_write_without_response事件数据最常见的双向通信场景是手机 App 给板子发一条指令板子解析后通过另一个特征回传状态。从手机写入的数据到达 M4 应用层时事件码是ACI_GATT_ATTRIBUTE_WRITTEN_EVT对应的数据结构里包含特征句柄和写入的数据长度case ACI_GATT_ATTRIBUTE_WRITTEN_EVT: { aci_gatt_attribute_written_event_rp0 *evt (aci_gatt_attribute_written_event_rp0 *)event_data-data; if (evt-AttrHandle char_handle) { // 收到的是通往特征值句柄的写请求 uint8_t rx_len evt-DataLength; uint8_t *rx_buf evt-Data; // 在这里解析指令例如 rx_buf[0] 是 LED 开关命令 if (rx_len 1 rx_buf[0] 0x01) { // 执行动作后通过通知回传 ACK uint8_t ack 0x06; aci_gatt_update_char_value(custom_service_handle, notify_char_handle, 0, 1, ack); } } break; }aci_gatt_update_char_value是板子主动向手机发送通知数据的标准接口前两个参数分别是服务句柄和特征值句柄第三个参数是偏移量多包传输大数据时才会用到单包传 20 字节填 0第四个参数是数据长度最后一个参数是数据指针。值得注意的是这个函数仅仅提交了更新请求实际数据是在下一次连接事件到达后通过链路层发出去的所以连续多次调用时要注意上一次的数据是否已经发送完成否则协议栈内部的缓冲区可能会被后续数据覆盖。在调试时可以用 STM32CubeMonitor-RF 的实时变量监视功能直接观察这个事件触发时rx_buf里的内容快速判断是解析逻辑出错还是手机端就没有写进来。4.3 板子主动发数据给手机通知使能判断与aci_gatt_update_char_value的坑基于 4.2 的流程板子向手机发数据还有一个前提手机必须先向这个特征的 CCCD 写入0x0001以启用通知。如果你在板子启动后立刻调用aci_gatt_update_char_value()数据不会报错但手机收不到因为这个特征的通知开关还处于关闭状态。建议在事件循环里维护一个布尔变量记录当前连接是否有客户端订阅了通知只有订阅状态为真时才开始发数据static uint8_t notification_enabled 0; case ACI_GATT_ATTRIBUTE_WRITTEN_EVT: { // 判断写的是不是 CCCD 描述符句柄 if (evt-AttrHandle cccd_handle) { // CRC 校验之外还要看写入的值是 0x0001 还是 0x0000 notification_enabled (evt-Data[0] 0x01) ? 1 : 0; } break; }另一个容易忽略的问题是单包数据长度。即使你在 CubeMX 里把Payload Size设成了 251aci_gatt_update_char_value在单次调用中填写的长度也不能超过当前连接协商出的 MTU 值减去 3 字节的 ATT 头长度。如果手机端没有发起 MTU 协商默认 23 字节 MTU 意味着一次只能放 20 个字节的用户数据超过 20 会被协议栈直接拒绝。因此在循环发送大数据时要自行分包uint16_t len sizeof(data_block); uint16_t offset 0; while (len 0) { uint16_t chunk (len 20) ? 20 : len; aci_gatt_update_char_value(custom_service_handle, notify_char_handle, offset, chunk, data_block[offset]); offset chunk; len - chunk; // 注意这里需要等待连接事件发送完成不能在临界区直接连续调用 }这段代码只是演示分包逻辑实际使用时要加上发送完成回调的等待或者用osDelay()留出至少一个连接间隔的时间窗口否则两次通知会被协议栈串并到同一个连接事件里触发BLE_STATUS_INSUFFICIENT_RESOURCES错误。如果你用的是实时操作系统建议在这段逻辑里加一个信号量在发送完成事件到达时释放。5. 手机 App 连接实测用 nRF Connect 验证服务发现与数据通道5.1 最小验证流程从扫描到订阅通知的四个操作把编译后的程序烧进 STM32WB55RG 开发板打开手机上的 nRF Connect App按顺序执行以下四步每一步都对应一个协议栈行为也是排查问题的分水岭扫描并连接名为MY-WB55RG的设备。如果扫描不到回到 3.2 检查广播数据和可发现模式设置如果能搜到但连接就断开检查 M0 协议栈版本和 M4 工程的库版本是否完全一致。在Generic Access服务里找到设备名称特征读出来看看是否和广播包名称一致。这一步能区分是 GAP 层配置错误还是广播数据错误。展开自定义服务0x34FB你应该能看到一个可读可写的特征和一个描述符。对特征执行一次 Write 操作写一个字节0x01。点击特征的 Notify 图标订阅通知再回到串口助手向板子发送一条触发指令观察 App 的 Log 窗口有没有收到通知数据。这些步骤全部通过说明整个 GATT 数据库、权限模型、连接事件循环和通知路径都是通的。如果某一步失败日志里通常会在 HCI 事件层有明确的报错例如写入特征返回ATT_ERROR_WRITE_NOT_PERMITTED那就是特征权限声明里漏了写权限如果订阅通知后收不到数据检查 4.3 里的notification_enabled标志有没有被正确置位。你可以用下面这段 Python Bleak 脚本在电脑上做同样的事情适合在 CI 环境里做自动化验证import asyncio from bleak import BleakClient ADDRESS AA:BB:CC:DD:EE:FF # 从扫描结果里替换成实际 MAC CHAR_UUID 00000001-0000-0000-0000-000000000000 async def main(): async with BleakClient(ADDRESS) as client: # 等待 MTU 协商完成这里约等于 GATT 层初始化完成 await client.get_services() # 读特征值触发一次 READ 请求 value await client.read_gatt_char(CHAR_UUID) print(fRead: {value}) # 向特征写入一个字节 await client.write_gatt_char(CHAR_UUID, b\x01) # 订阅通知 def notification_handler(sender, data): print(fNotification from {sender}: {data}) await client.start_notify(CHAR_UUID, notification_handler) await asyncio.sleep(10) await client.stop_notify(CHAR_UUID) asyncio.run(main())BleakClient会在初始化连接过程中自动执行服务发现从client.get_services()返回的对象里可以逐条检查服务句柄、特征句柄以及 CCCD 是否存在。write_gatt_char默认使用 Write Request 带响应模式对应板子事件里的ACI_GATT_ATTRIBUTE_WRITTEN_EVT如果你在板子端只实现了 Write Without Response 处理这里会表现出一方能写、另一方收不到的现象排查时注意看板子的回调有没有进入断电保护分支。5.2 MTU 协商对数据吞吐的影响连接建立后手机和板子之间会通过Exchange MTU Request协商一个更合理的 MTU 值。在 BLE 4.2 之后协商的 MTU 范围是 23 到 247 字节取决于双方支持能力。在 STM32WB55RG 侧MTU 大小由aci_gatt_update_char_value的内部参数决定但它实际可用的最大值受到三方面约束CubeMX 里配置的Payload Size、协议栈编译时的缓冲池大小、以及手机端是否发起协商。nRF Connect 默认会在连接后自动协商一个大 MTU所以你会在 Log 里看到MTU updated to 247。而上面 Python 脚本里BleakClient默认协商到最大所以在 20 字节分包的情况下可以正常发送超过 20 字节的数据但 STM32WB55RG 内部如果仍然以单包 20 字节的缓冲单元存储那连续通知仍然会被拆包。实测中如果你需要高吞吐率建议把 CubeMX 里的Payload Size设为 251并确保手机端在连接后主动请求协商否则你只能在应用层做分包和重组。// 在连接建立后可以主动要求更新连接参数例如请求 30ms 间隔 hci_le_connection_update( connection_handle, // 从连接完成事件中取得 24, // 最小连接间隔24 * 1.25ms 30ms 40, // 最大连接间隔40 * 1.25ms 50ms 0, // 从机延迟 600 // 超时时间600 * 10ms 6s );hci_le_connection_update的调用时机一般在HCI_LE_CONNECTION_COMPLETE_EVT事件回调分支里且最好不要在同一个事件栈里直接调用稍微延后几个毫秒再发起避免两个 HCI 命令在 M0 核侧排队造成未知状态的冲突。从机延迟参数填 0 表示从机在每个连接事件都监听主机的数据包这最适合双向实时通信如果做蓝牙传感器且只需要周期性上报把从机延迟设为 4可以让从机每隔 5 个连接事件才醒来一次显著降低平均电流。5.3 串口日志与 RTT 输出的搭配排错在真机调试时我一般同时开三路输出一路 UART 打印应用层状态一路 STM32CubeMonitor-RF 看 RF 协议栈状态一路 Wireshark 抓 USB Dongle 的空中包。板子端的串口日志里最关键的是要在BLE_Status()入口打印事件码这样能直接看到协议栈往应用层丢了多少事件以及应用层有没有因为某个分支一直 return 而漏处理。比如手机连接后你只看到HCI_LE_CONNECTION_COMPLETE_EVT但随后手机断开时没有HCI_DISCONNECTION_COMPLETE_EVT那就是 M0 核崩溃了或者 IPC 通道卡住了这通常不是代码逻辑问题而是协议栈版本和芯片不匹配。CFG 工程里默认开启了 RTT 打印的部分宏你可以用 STM32CubeMonitor-RF 连接板子的 SWD 口建立 RTT 通道观察BLE_Trace系统日志它会把 HCI 层所有命令响应的执行结果和时间戳打出来比串口 UART 的信息更完整不会因为应用层代码卡死在while(1)里而丢失。6. 从能连通到能交付三个值得加进工程里的进阶检查点工程跑通最小闭环之后如果你要在真实产品里用这套代码建议再补三项验证第一在aci_gap_init里把安全模式设为LE_SEC_MODE_1 | LE_SEC_LEVEL_2然后实现aci_gap_bond配对回调用真实手机和常见 BLE 调试工具做一轮配对测试确认设备和手机都能正确响应配对请求第二在BLE_Status的HCI_LE_CONNECTION_UPDATE_COMPLETE_EVT分支里把协商后的连接间隔、从机延迟、超时时间打印出来和生产环境里配置的预期值做比对防止手机系统尤其是 Android 后台限制强制套用自己的连接参数第三加上aci_hal_set_tx_power_level和 RSSI 采样逻辑在设备放在桌面上和放进金属外壳里两种场景下分别读取 RSSI 和连接失败的次数这样你手里的验收报告里会多一个“有效覆盖范围”的数据支撑而不是只写“能连上”。最后补充一点STM32WB55RG 的 BLE 程序调试和普通单片机程序调试区别很大很多问题在应用层代码里看是逻辑错误实际上是协议栈事件顺序或时序问题保持事件回调入口的日志完整比反复烧录断点排查效率高得多。本文还有配套的精品资源点击获取
返回列表