
1. 数传方案选型为什么 BLE 成了这类项目的默认选择做嵌入式设备的数据传输说来说去绕不开三种无线方案Wi-Fi、4G/5G、BLE。前几年大家一窝蜂去搞 Wi-Fi 模组本地网络环境好、带宽大、速度痛快但功耗和成本也上来了。做便携式设备的人很快发现一块 18650 电池给 Wi-Fi 模组供电还没等数据传完电池先报警了。4G 更不用多说资费、入网、天线、SIM 卡这些事情对很多中小项目来说都是负担。相比之下 BLE 这个方案协议栈成熟、模组价格低、功耗极低、手机原生支持简直是“串口到手机”这条路最自然的接法。我第一次做类似的项目是在一个便携式数据采集器上。设备端是一个 STM32F103 系列 MCU通过 UART 接收各个传感器的数据要把它实时同步到手机 App 上。当时选型的时候对比过 ESP8266、CC2541、nRF52832 这些方案最终选了 BLE 模组原因很简单设备大多数时间处于低频数据输出状态每秒只有几十字节BLE 的 2.4GHz 频段、20 字节到 244 字节的 payload 完全够用。更关键的是BLE 协议栈里专门有一套适用于低功耗设备的 GATT 数据交互机制配合 notify 通知特性服务器端主动推数据给客户端不需要客户端一次次轮询这正好匹配了“串口收什么手机就看什么”的需求。所以这篇博客里我会把从串口数据采集、BLE 模组转发、到手机 App 接收解析的完整链路拆开讲一遍。适合正在做 IoT 外设、运动健康设备、工业传感器数据采集或者纯粹想把一个串口设备通过 BLE 变成可用手机操作的项目的人。你不需要是蓝牙协议专家也不用啃完 3000 页的蓝牙核心规范我会沿着实际代码和调试过程来写让你能直接上手把这条路跑通。1.1 三种无线数传方案的实际对比先说结论选 BLE 不是因为 BLE 能通吃所有场景而是在“串口设备与手机之间传小数据”这个具体场景下它的综合成本最低。Wi-Fi 方案的优点是带宽大、协议标准、跟路由器基础设施天然对接适合固件升级、视频流、批量文件同步这类数据量大的任务。缺点在硬件成本和功耗上一个 Wi-Fi 模组加天线匹配电路、电源管理实际 BOM 成本和调试成本都要比 BLE 模组高不少。在电池供电的产品中Wi-Fi 模组平均电流动辄几十到上百毫安这对工作电流只有几十到几百微安量级的 BLE 模组来说差距是数量级的。4G/5G 的能力最强能实现真正的互联网远程连接设备在任何有信号的地方都可以回传数据。但代价也最重模块贵、SIM 卡需要管理、流量费需要长期投入、天线设计和入网认证还牵扯到很多合规问题。如果是做公网远程监控、车联网、物流追踪这类必须跨地域的项目4G 值得上如果只是手机在旁边看一下设备状态用 4G 就是杀鸡用牛刀还得每个月养着流量卡纯属自找麻烦。BLE 在短距无线通信里还有一个隐性优势手机原生支持。iOS 从 iPhone 4s 开始就内置了 CoreBluetooth 框架Android 从 4.3 开始提供 BLE API。这意味着你不需要用户在手机上装额外的硬件接收器也不需要 App 去访问什么设备管理器打开蓝牙、扫一扫、连上就完事。对用户来说这种“手机直接连设备”的交互成本几乎为零。1.2 BLE 带来的开发门槛和回报BLE 的短板也是真实存在的。第一速率低实际有效吞吐量取决于连接间隔和 MTU 大小常见情况下能做到几十 KB/s 就算不错了不适合大批量传输。第二距离有限BLE 的典型有效距离在户外空旷环境下只有几十米室内穿墙后衰减严重。第三虽然协议栈帮你做了很多工作但理解和调优连接参数、MTU、notify 行为还是需要花时间去吃透。不过回到我们这个“串口到手机”的场景BLE 的这些短板恰好都不算问题。串口设备本身数据量通常不大传感器数据、状态帧、控制指令一帧撑死几十字节。距离方面人要操作设备设备基本就在手边。开发难度方面BLE 协议虽然复杂但模组厂商已经把底层的链路层、L2CAP、ATT/GATT 都封好了你面对的是 UART 接口和 AT 指令或简单的 SDK 接口。实际上你真正要设计的核心协议只有一层应用层如何把串口数据变成 GATT 上面的值。2. 串口侧数据通路的起点和最容易翻车的地方很多人做这类项目会把注意力全放在 BLE 上结果问题全出在串口。串口看起来简单无非 TX、RX、GND 三根线但在实际项目中波特率不匹配、电平不匹配、硬件流控忘关、数据帧粘连这些问题都会让你在 BLE 端排查到怀疑人生。因为一旦串口这里的数据就是错的、丢的、乱的蓝牙链路再稳定也白搭。2.1 串口参数配置不止是 9600 和 115200串口通信的参数其实是一组组合波特率、数据位、停止位、校验位、流控。绝大多数人用 8N18 个数据位、无校验、1 个停止位这没问题。问题出在波特率的选择上很多传感器模块默认出厂是 9600也有一批是 115200还有小众模块用 57600、38400甚至有翻遍整个数据手册都找不到标注的。我的建议是在项目启动阶段就把波特率统一约定成一个固定值并且把它写进双方通信协议里最好通过配置通道进行握手确认。波特率和 BLE 数传之间的关系很容易被忽略。串口侧如果以 115200 波特率持续向 BLE 模组灌数据而 BLE 侧的实际传输吞吐量只有 30 KB/s 左右数据就会在模组的缓冲区里堆积。BLE 模组的 UART 缓冲区一般只有几百字节到几 KB堆满了就丢帧。所以串口侧必须做发送节奏控制要么降低波特率要么增加流控/应答机制要么在应用层做帧间隔控制。一个可行的做法是串口侧不把数据一股脑全发出去而是先按“一帧”为单位打包帧与帧之间留出 10 ms 到 50 ms 的空隙给 BLE 协议栈留足时间把数据推送到手机端。这样虽然降低了串口的瞬时吞吐量但保证了整条链路的可靠性。数据链路的吞吐量瓶颈在 BLE 侧串口这边再快也只是把数据往缓冲区里堆而已。2.2 设备侧数据协议设计不要上来就裸传串口数据如果只是单字节、单通道的传感器值裸传问题不大。但实际项目中一个设备上往往有很多种数据温度、湿度、设备状态、累计量、告警事件甚至还有配置项和升级包。这些数据混在一起走同一个串口如果没有协议封装接收端根本没法区分哪段是什么。我建议在设备端就设计一套最简帧格式字段长度说明帧头2 字节固定值 0xAA 0x55用于同步长度1 字节从类型到校验前所有字节数类型1 字节0x01 传感器数据0x02 状态帧0x03 配置帧数据N 字节实际负载校验1 字节所有字节异或或 CRC8这个格式看起来朴素但足够解决“数据从哪开始、到哪结束、是什么类型、对不对”这几个核心问题。在实际调试时串口助手和手机 App 都靠这个格式来正确切割数据流。2.3 串口到 BLE 模块电平匹配与接线避坑串口电平不匹配是新手第二容易踩的坑。STM32 的 UART 引脚一般是 3.3V TTL 电平树莓派的 UART 也是 3.3V但老的 Arduino 或者 5V 单片机可能是 5V TTL 电平。如果直接把 5V 设备接到 3.3V 的 BLE 模组串口上轻则数据乱码重则烧坏模组。连接之前一定要确认双方的电平范围。如果不匹配用双向电平转换模块几块钱一个不贵。接线顺序也有讲究。调试初期很多人会忘记共地串口通信明明接好了 TX/RX但数据就是乱码。原因很简单UART 是异步串行通信双方的参考地必须一致。如果没有共地信号电平的参考点是浮动的收到的数据自然全是噪声。模块间接线务必保证 GND 相连这一点请刻在脑子里。硬件流控RTS/CTS默认关闭如果打开了但另一侧没有对应引脚连接会导致发送方等流控信号等到天荒地老数据卡死。调试时如果发现发了几帧就停了先检查是不是把流控给开了。3. BLE 通信链路广播、连接与 GATT 服务设计串口侧的数据准备好了接下来就是 BLE 模组的活。这里要理解 BLE 的通信模型外设Peripheral在广播自己的存在中心设备Central比如手机扫描到广播后发起连接。连接建立之后双方通过 GATTGeneric Attribute Profile进行数据交互。GATT 的结构是 Service服务- Characteristic特征- Value值三层。你要把串口数据变成手机能读到的值就得在这三层上做文章。3.1 广播数据怎么配才能让 App 快速找到设备广播数据是设备的第一张名片手机 App 扫描设备时主要靠广播包里携带的信息来识别目标设备。广播包可以包含设备名称、Service UUID、厂商自定义数据等。如果广播数据里没有任何可过滤的信息App 只能把所有扫描到的设备都列出来体验非常差。实际经验是在广播包里把自定义 Service UUID 带上App 端扫描时按这个 UUID 过滤。这样即使两台设备名字一样也能通过 UUID 区分。广播名不要设置太长BLE 广播包本身大小有限制传统广播通道包最大 31 字节设备名加 Service UUID 加厂商数据很容易超。如果超了很多协议栈会自动把部分数据截掉或放不进广播包导致手机端扫描信息不全。这里还有个细节广播类型选择。可连接非定向广播Connectable Undirected Advertising是默认的选择适合大多数外设。如果设备已经跟手机建立了连接还想让其他手机也能扫描到它就得改广播策略但这属于高级玩法要根据具体模组 SDK 来配置这里不做展开。3.2 连接参数连接间隔、从机延迟与超时连接建立之后BLE 链路的效率由连接参数决定。核心参数有三个连接间隔Connection Interval、从机延迟Slave Latency、超时时间Supervision Timeout。连接间隔是主机和从机之间两次通信的时间间隔范围从 7.5ms 到 4s。间隔越短数据交互越及时但功耗越高。间隔越长功耗越低但数据延迟越大。从机延迟允许从机跳过若干次连接事件而不必每次都被主机唤醒进一步省电。超时时间是链路认为连接已经丢失的时间阈值。对“串口数据实时推送手机”这个场景我推荐的参数组合是连接间隔 30ms 到 50ms从机延迟 0 到 4超时时间 5 秒以上。如果数据频率很高比如每 20ms 就要推一帧连接间隔就不要超过 20ms否则数据会在模组里排队加大延迟和丢包风险。如果设备对功耗敏感可以把间隔拉大到 100ms同时把应用层的数据合并成更大的包再发。需要特别注意的是Android 手机上的系统蓝牙栈可能会根据自己的策略修改连接参数你通过 GATT 请求设置的连接参数不一定能生效。实测中iOS 对连接参数的协商相对严格Android 各家手机差异很大。遇到参数不一致时不要死磕连接参数而是在应用层加缓冲和重发机制。3.3 GATT 服务设计一个服务搞定还是多服务拆分GATT 服务设计直接决定了 App 端解析逻辑的复杂度。最省事的方式是建一个自定义 Service里面放两个 Characteristic一个用于写入手机给设备发数据一个用于通知设备给手机发数据。核心代码如下Write Characteristic属性为 Write / Write Without Response负责接收 App 下发的控制指令、配置参数。Notify Characteristic属性为 Notify负责主动推送设备发出的串口数据。必须同时支持 CCCDClient Characteristic Configuration Descriptor因为手机端需要先写入 0x01 来订阅通知才能收到 Notify 数据这个步骤很多人会忘。不要第二个服务也不必把每种数据类型拆成单独的 Characteristic。原因很简单BLE 的读写操作一次能承载的数据有限拆分太细反而提高了 App 端逻辑的复杂度。我更倾向于在 Notify Characteristic 上用应用层协议区分数据类型也就是说GATT 层面的设计保持最小化数据语义交给上层的帧格式去解释。这样做还有一个好处如果设备以后新增数据类型不需要改 GATT 结构只需要在协议里加类型值。4. 手机 App 端从扫描到收数的完整逻辑手机 App 是整条链路的终点也是用户直接面对的部分。App 端的核心任务有三个扫描到目标设备、建立 GATT 连接、订阅 Notify 并持续接收数据。Android 和 iOS 的实现思路不同但逻辑相通。4.1 Android 端扫描与连接Android 端的 BLE 开发在过去几年变化很大API 从传统的 startLeScan 迁移到了 BluetoothLeScanner运行时权限也增加了定位权限要求。很多老教程还在用已经废弃的 API照着做编译报错是小事关键是在 Android 12 以上机型上根本扫不到设备。现代 Android 扫码的基本步骤如下在 AndroidManifest.xml 中声明 BLUETOOTH_SCAN、BLUETOOTH_CONNECT 权限Android 12 以上需要动态申请。获取 BluetoothLeScanner 实例。构建 ScanFilter按 Service UUID 过滤目标设备。启动扫描回调中拿到 ScanResult通过设备名称或地址连接。连接时要用BluetoothGattCallback来处理各种回调。在onConnectionStateChange中判断连接成功然后立即调用discoverServices()去发现服务。服务发现完成之后再通过getService()拿到你的自定义 Service然后用setCharacteristicNotification()去订阅 Notify。4.2 notify 通道数据从设备到手机的关键订阅 Notify 的过程有个经典坑只调用setCharacteristicNotification()是不够的。你还需要往对应 Characteristic 的 CCCD0x2902描述符写入 0x0100这才能真正开启通知。很多新手在这步漏掉结果设备发了半天数据App 端一个回调都没有。我建议在主流程中加入onDescriptorWrite回调确认写入成功后再提示用户“已连接”。数据到达后onCharacteristicChanged回调会返回整个特征值。如果设备侧数据量较大而且 MTU 协商后仍然不够模组和 App 之间往往会在 GATT 层自动进行分包但手机端 callback 拿到的值可能是分片后的单包。这时候App 端需要自己负责把收到的这些“半包”按协议拼回完整帧。Android 设备对 BLE 的兼容性较差的说法并不夸张。不同厂商的蓝牙协议栈对onCharacteristicChanged回调的时机、对包大小的处理、对连接参数的处理都有差异。实测中发现某些手机在通知频繁时会丢回调时长不固定。应对办法是在 App 端做协帧缓存和超时重传机制。具体做法后面讲协议设计时再展开。4.3 iOS 端与 Android 的差异处理iOS 端通过 CoreBluetooth 框架实现 BLE接口设计比 Android 干净不少但它的行为策略和 Android 有几点不同。第一iOS 端扫描时也必须通过 Service UUID 过滤否则会报错不允许通过设备名来过滤。第二iOS 对后台 BLE 的支持有严格限制App 进入后台后仍然可以接收 notify 数据前提是你预先声明了 UIBackgroundModes 中的bluetooth-central和bluetooth-peripheral并且用户没有断开连接。第三iOS 不允许未知设备的 Service 和 Characteristic 被任意读取和写入必须通过 discoverServices 和 discoverCharacteristics 一步步把 UUID 找出来。如果项目同时支持 Android 和 iOS我建议把 BLE 连接流程的核心逻辑统一封装成抽象接口内部按平台实现。这样平台差异被隔离在底层上层业务代码只需要关心“收到了什么数据”。不然到了后期平台逻辑混在一起排查问题会非常痛苦。5. 协议设计经验一帧数据的完整旅程串口数据到了 BLE 模组模组通过 GATT 通知发送App 端收到通知拿到特征值。看起来是一条简单链路但实际传输过程中数据不会像流水一样温顺地按照你定义的帧边界到达。你会发现有时候一次通知只带了半个帧有时候一次通知带了 3 个完整帧再加半个帧更糟的时候一个帧被拆成 5 个包分好几次通知才到齐。这些问题的根源是“串口的字节流”和“BLE 的分包通知”之间存在粒度不匹配必须通过应用层协议来解决。5.1 自定义帧格式包头、长度、负载、校验我在前面第 2 节已经给过一种帧格式这里再把它上升到协议层面说细一点。一个可靠的数据帧至少要满足这几个条件接收方可以从随机字节流中准确找到帧的起始位置。接收方可以确定这一帧到哪里结束。接收方可以判断这一帧的数据是否在传输过程中损坏。帧头选固定值 0xAA 0x55注意如果负载数据里也可能出现 0xAA 0x55那就要做转义处理或者靠长度字段来跳过头部。更稳妥的方案是使用带状态机的解析逻辑从扫描帧头开始然后读长度再按长度字段补齐整个帧最后做校验比对。这种状态机解析方式我在串口助手、BLE App 里都用同一套逻辑一次封装到处复用。校验方式我推荐 CRC8 或者累加和。CRC8 有现成的查表实现代码量很小检测能力足够覆盖一帧几十字节的应用层数据。不要用简单的异或虽然写起来快但两个字节同时翻转时异或校验检测不出来。如果项目对数据完整性要求高比如固件升级、关键配置下发直接上 CRC16 甚至 CRC32。5.2 MTU 限制下的分包与重组BLE 默认的 MTUMaximum Transmission Unit是 23 字节其中 ATT 层头部占 3 字节实际数据载荷只有 20 字节。也就是说在默认配置下一个 Notify 最多带 20 字节如果一帧串口数据有 100 字节它就要被拆成 5 包发送。Android 和 iOS 都支持通过requestMtu()协商更大的 MTUAndroid 一般能到 512 字节iOS 也有 185 或更大的值但协商不是万能的有些旧设备或模组固件不支持。协商 MTU 后建议把“单帧数据长度”控制在 MTU 载荷范围内这样一帧数据可以塞进一次通知里减少 App 端拼包的工作量。如果一帧确实超过 MTU 载荷那就在协议层处理分包和重组在帧头里加一个“包序号”字段1 字节。加一个“是否最后一包”的标志位。App 端按包序号拼接校验缺失或乱序。分包重组逻辑写起来不复杂但在低功耗设备上尽量不要用因为它会增加额外的处理开销和电量消耗。设计协议时优先考虑“一次一帧”。5.3 粘包与半包串口和 BLE 都会遇到的难题粘包是指多帧数据一次性被读到半包是指一帧数据不完整。串口侧的程序员对这两件事不陌生在 BLE 链路里也完全一样。串口侧出现粘包的原因是串口接收中断太快应用层还没来得及读取 FIFO数据就已经连续到达。解决办法是在串口接收回调里只做数据缓存不清空时马上解析主循环或专用解析任务里按帧格式去查头和长度。BLE 侧出现粘包连续几帧一次性到的主要原因是 notify 的数据到达 App 时底层可能做了批量递交。出现半包的原因是 MTU 太小导致一帧数据被拆成多个 notify。无论哪种情况App 端的处理逻辑是一样的用一个累积缓冲区把收到的数据按字节流追加进去然后反复尝试从缓冲区开头解析完整帧解析出来的帧交给上层业务逻辑剩余字节留在缓冲区里等下批数据。这其实就是一套标准的 sticky/half packet 处理算法写一次之后在串口调试工具、Android App、iOS App 三个场景可以共用同一套代码逻辑非常划算。6. 常见问题与排查技巧实录这条路走到这里你已经知道了整体架构和每个环节的关键点。但实际项目的坑总是比教科书多我把这几年调试各种串口 BLE 设备时遇到的典型问题和排查思路整理了一下。希望能帮你减少一点踩坑时间。6.1 连接不稳定、频繁断开连接不稳定先看距离和干扰。BLE 在 2.4GHz 频段和 Wi-Fi、USB 3.0、微波炉共享频谱。设备附近如果有大功率 Wi-Fi 路由器、USB 3.0 硬盘盒干扰会非常明显。排查方法是把手机放到设备旁边 10 厘米内看断连频率是否下降。如果明显下降就基本确认是干扰问题。排除干扰后看连接参数。如果连接间隔设置得太短比如 7.5ms同时设备处理能力不足数据堆积会导致协议栈超时连接就会断开。调试时可以把超时时间设到 20 秒以上这样即使链路短期拥塞也不会轻易被判死。另外注意设备端是否有频繁地进中断、关中断、忙等待导致协议栈事件得不到处理这类问题需要在设备的主循环里保证 BLE 协议栈及时获得 CPU。还有一类情况是手机端的蓝牙栈主动断连。Android 上某些系统省电策略会在设备长时间无有效数据交换时断开 BLE 连接。解决办法应用层做一个“我的心跳数据包”每隔几秒设备主动发一个帧保持链路活跃。实践证明30 秒一次的心跳包足够。6.2 数据丢帧、乱序丢帧先分清楚是串口侧丢还是 BLE 侧丢。你可以在设备端写一个“自环测试”固件把 BLE 收到的数据通过串口打印回来同时统计发出和收回的字节数。如果串口侧收不全问题在设备端的串口数据源或者单片机处理不及时跟 BLE 无关。如果串口侧收得全但 App 端丢数据那问题出在 BLE 链路上。BLE 侧丢帧的常见原因是 notify 发送过快导致缓冲区溢出。BLE 模组的 GATT 通知并不保证送达它只是在每个连接事件里尽量发数据。如果连接间隔长、设备每一个连接事件发的包多而模组缓冲区不够大新数据会覆盖旧数据。解决办法加应用层 ACK 机制设备端在收到 App 的确认后再发下一批数据但这种机制的实时性会下降需要根据业务取舍。乱序问题相对少见BLE 底层通常保证连接事件内的数据包顺序但多个连接事件之间的包在极端情况下可能乱序。在应用协议里给帧加序号接收端对乱序帧做缓存和重排即可。我通常的做法是序号在初始化时随机生成这样能避免从 0 开始的固定规律带来的安全问题。6.3 功耗异常很多设备接入 BLE 后发现待机电流大这就是连接参数没配好。如果设备一直以 7.5ms 间隔维持连接即使没数据要发也要每个连接事件响应主机的空包查询功耗自然高。正确的做法是根据需求决定连接间隔。如果数据不频繁从机延迟设到 4 以上连接间隔拉到 100ms 甚至更大设备大部分时间可以睡眠。还要检查射频部分的功耗。一些 BLE 模组在空闲时会自动进入低功耗模式前提是 MCU 不再通过 UART 发数据。如果 MCU 一直有数据发比如某个传感器以 1 Hz 的频率不断上报模组就一直处于活跃射频状态电流下不去。在项目设计时可以让 MCU 在无事件时主动关掉不用的外设和模组的射频通过 GPIO 或指令控制。6.4 调试工具组合最后分享一套我一直在用的调试工具链能帮你把串口侧、BLE 侧、App 侧分开排查串口侧SSCOM、Serial Port Assistant 这类工具看设备到底发出来什么数据。注意有些工具的画面显示字节数不可靠尽量用支持 hex 显示和保存 log 的工具。BLE 侧nRF Connect 是神器。手机装一个扫描、连接、看 Service/Characteristic、手动写值、订阅 Notify 一目了然。遇到问题先拿它确认数据是不是真的从模组发出来了再回去查 App 代码。协议分析nRF Sniffer for Bluetooth LE配合 Wireshark 抓空中的数据包。当蓝牙链路的行为比较诡异、又不想改代码加日志时直接抓包分析连接参数、数据包重传、空包交互能够快速找到问题源头。这些工具不一定每个都用但手里至少要有“串口助手 nRF Connect”。几乎所有环境问题都先在这两个工具上复现再决定往哪边查。比起一上来就啃代码效率高太多了。就我个人经验来说这类“串口到手机”的 BLE 项目真正决定开发体验好坏的不是某个技术难度特别高而是链路中的每一跳都要严谨对待。串口侧不匹配、BLE 侧参数没调对、App 端订阅通知漏了写描述符任何一环出问题整个系统就完全不可用。调试时尽量做到分层隔离先用串口助手确认 MCU 发出的数据正确再用 nRF Connect 确认 BLE 模组的数据正确最后才轮到写 App 逻辑。如果这三层的每一层都是干净的那么整条链路肯定能通。后续做产品化还可以在这个基础协议上加上 OTA 升级、多设备并行连接、云端中转等功能但底层的数据通路设计仍然是以这套思路为核心打底的。