做过无线串口调试的人多半有过这样一段经历:兴致勃勃打开 BLE 的例程,结果被 GATT、服务 UUID、特征值、Descriptor 这一串概念堵在门口,想让两块板子对着发个“hello world”,代码量已经堆到一百多行,还得两边约定好服务 UUID,稍改一个字节就得重新烧一遍。等我回过头去用 ESP32 上的BluetoothSerial库时,那种感觉就像绕了一大圈弯路之后,突然发现旁边有扇门是开着的——它把经典蓝牙的 SPP 串口协议封装成了一个能读能写的 Stream 对象,代码写起来和使用一个普通串口几乎没区别。这篇内容就围绕这个库展开,把它到底是什么、哪些芯片能用、API 里哪些方法真正值得花时间、两个可以直接抄的实例,以及我实测下来最容易翻车的几个地方,全部讲清楚。适合刚上手 ESP32 无线通信的读者,也适合已经会被 BLE 折腾烦了、想找一个更省事的透传方案的人。
1. 先把定位说清楚:SPP 与 BLE 是两条完全不同的路
1.1 BluetoothSerial 在协议栈里的位置
用一句话概括:BluetoothSerial 是 Arduino-ESP32 核心库对底层经典蓝牙 SPP 服务的 C++ 封装,它把一条蓝牙链路抽象成了 Stream 的子类。Stream 这个基类,熟悉 Arduino 的人都不陌生,Serial、SoftwareSerial、WiFiClient 都是它的子类,这意味着你用它的时候,心态就应该是“我在操作一个串口”——有available()、read()、write()、println()、peek()、flush(),只是这个“串口”的物理介质不是两根线,而是 2.4GHz 的射频。
往下看一层,调用链大致是:你的代码 → BluetoothSerial → Bluedroid 协议栈的 SPP 模块 → L2CAP → 基带射频。Bluedroid 是 ESP-IDF 里集成的经典蓝牙与 BLE 双模协议栈,SPP(Serial Port Profile)是蓝牙 SIG 定义的一个标准剖面,它的存在意义就是把一条射频链路模拟成 RS-232 串口。所以凡是支持 SPP 的设备,双方之间根本不需要约定什么自定义协议,接上就是一个管子,管子两头随便灌数据。这是 SPP 和 BLE 最本质的差别:BLE 天生是“面向属性”的,你得先定义数据长什么样、放在哪个特征值里;而 SPP 天生是“面向流”的,数据就是数据。
提示:别把 SPP 当成“蓝牙串口就是发 AT 指令”的东西。AT 指令是模块固件层面的东西,很多蓝牙模块用 AT 指令来配置;而 ESP32 上的 BluetoothSerial 是直接调协议栈 API,没有 AT 指令这一层,也没有“透传模式”和“命令模式”的切换。
1.2 什么时候该选它,什么时候别碰
我的经验判断标准很粗暴:如果两个设备之间要持续、双向、大数据量地搬字节流,而且对端是安卓手机或者另一块支持 SPP 的板子,直接上 BluetoothSerial;如果对端是 iPhone、是低功耗传感器、是需要组网几十个节点,那就是 BLE 的地盘,硬用 SPP 只会白费功夫。
具体到场景上,SPP 这套方案特别适合这几类活:
| 场景 | 为什么适合 SPP | 注意点 |
|---|---|---|
| 老设备无线化改造 | 老设备只吐 UART 字节流,加个 ESP32 桥接即可 | 需要处理 UART 与蓝牙的速率差 |
| 手机调试嵌入式 | 安卓上有大量支持 SPP 的串口工具,即连即用 | iPhone 基本无解,见第 5 节 |
| 短距离大块数据传输 | SPP 有重传机制,吞吐比 BLE 大得多 | 别指望超过 100 KB/s 稳定跑 |
| 主从双向控制 | 一条链路双向全双工 | 单链路,多客户端要自己做调度 |
| 需要稳定长连接 | 经典蓝牙的连接维持机制成熟 | 空闲不做心跳也可能被系统断掉 |
反过来,有些需求就不该用 SPP。比如你要做的是周期性上报一个温湿度值、电池供电、一节纽扣电池撑一年,那 SPP 的功耗会让你的电池一个月见底;再比如你要做一个能被 iOS App 直接扫描到的设备,SPP 服务在 iOS 上根本不被普通 App 识别,这条路是走不通的。这些边界如果一开始没想清楚,后面推倒重来的代价会很大。
1.3 芯片型号这一关必须先过
这是我见过最多的“为什么我编译报错”问题来源。ESP32 家族并不是所有型号都带经典蓝牙。原版 ESP32(包括各种模组形态)是双模的,经典蓝牙和 BLE 都有;但后续的一些型号为了成本和定位,只保留了 BLE。你在选型阶段就要把这一条作为必须满足的条件,不能只看 Flash 和 GPIO 够不够。
判断方法很简单,两处确认:一是看数据手册的无线特性章节里有没有“Bluetooth Classic”或“BR/EDR”字样;二是直接在 Arduino IDE 里编译一段#include <BluetoothSerial.h>的代码,如果提示找不到这个头文件,或者链接阶段报 SPP 相关符号缺失,那就是这颗芯片不支持。我在一个项目里就吃过这个亏,硬件已经打样回来才发现手上的模组只有 BLE,最后只能用 BLE 的串口透传服务重新写一遍,前后多花了一周。
2. 从零跑通第一个透传:环境、分区与最小代码
2.1 编译环境里三个最容易漏的配置
环境这块,我默认你已经在 Arduino IDE 里装好了 ESP32 的开发板支持包。装包的过程网上资料很多,这里只讲装完之后必须改的三处,这三处不改,后面大概率要浪费时间。
第一处是开发板型号。下拉菜单里选和你手上模组最接近的那一项就行,串口能下载、能打印日志就够了。这一项选错通常表现为烧录失败或者程序跑飞,报错信息还挺吓人,实际上换个选项就好。
第二处是分区表(Partition Scheme)。这一项最容易被忽略,也最容易造成“编译成功但烧录后不断重启”。原因是加上经典蓝牙协议栈之后,固件体积会明显上涨,默认那种“1.2MB APP / 1.5MB SPIFFS”的分区方案很可能装不下,编译输出里 APP 大小一栏会变成红色。解决办法是把分区表切换成给 APP 更大空间的那种,比如标注了 3MB APP 的选项。这个坑我印象很深,当时烧录完串口一直吐 Guru Meditation 的复位信息,查了半天代码没发现问题,最后才发现是固件的大小超出了分区。
第三处是 CPU 频率。带蓝牙的代码对时序有点敏感,频率太低的选项(比如 80MHz)在数据量大的时候容易出现丢包,建议保持默认的 240MHz。这个改动不需要额外成本,但收益是实际的。
提示:加了蓝牙之后首次编译会明显变慢,因为要把协议栈一起编译进去,三四分钟很正常。别以为 IDE 卡死了就强行关掉,那反而会把缓存弄乱,下次编译更慢。
2.2 二十行代码的收发骨架
下面这段是我平时用来验证环境是否通的最小代码,去掉了所有花哨的东西,只做一件事:手机发什么,它就回什么,同时把内容打印到 USB 串口监视器。
#include <BluetoothSerial.h> BluetoothSerial SerialBT; void setup() { Serial.begin(115200); // USB 串口,用于调试输出 SerialBT.begin("ESP32_Bridge"); // 蓝牙设备名,默认作从机 Serial.println("BT ready, waiting for connection..."); } void loop() { // 有客户端连接且收到了数据 if (SerialBT.available()) { String s = SerialBT.readString(); Serial.print("recv: "); Serial.println(s); SerialBT.print("echo -> "); SerialBT.println(s); // 原样回显 } delay(10); }SerialBT.begin("ESP32_Bridge")这个调用做了三件事:初始化 Bluedroid 的 SPP 模块、把设备名设成ESP32_Bridge、启动 SPP 服务端开始监听。第二个参数是可选的布尔值isMaster,不写就是false,也就是“我是从机,我等别人来连”。设备名这块有个细节——尽量用纯英文和数字,长度别超过十几个字符,中文名在某些安卓机上会显示成乱码甚至扫描不到,这个现象我在不止一台机器上复现过。
代码里的readString()是个方便但危险的函数,它依赖超时判断一帧结束,数据一多就会把原本分开的两条消息粘成一条。这个最小例子里还能用,真做项目时换成逐字节读或者用带缓冲的回调,具体在第 4 节讲。
2.3 手机端连接的真正顺序
代码烧进去之后,手机端的操作顺序是有讲究的,顺序错了会一直连不上,还以为是代码有问题。
正确的顺序是:先打开手机系统设置的蓝牙,搜索ESP32_Bridge并完成配对;配对成功后,再打开串口类工具,在工具的设备列表里选中它发起连接。注意这两步是分开的,系统蓝牙里显示“已配对”只代表链路层认了,不代表 SPP 通道打开了;很多工具需要自己再去建立 SPP 连接。我见过有人一直在系统蓝牙界面反复点“连接”然后抱怨没反应,实际上那一步只是配对,真正的串口连接要在 App 里做。
还有一个非常常见的坑:你改了设备名,手机却还是显示旧名字。这是因为手机把之前的配对信息缓存下来了。改名字之后必须去系统的蓝牙设置里“忘记这个设备”或者“取消配对”,再重新扫描。不做这一步,你会觉得新名字根本没生效。这个缓存机制在安卓上尤其顽固,有些机型甚至需要重启蓝牙开关才刷新。
3. 把 API 面捋一遍:哪些方法天天用,哪些一年用一次
3.1 连接管理这几个:begin、connected、hasClient、end
begin()是整个库的入口,重载有两种形态:带设备名和带设备名加主从标志。它返回bool,返回false基本只有一个原因——协议栈初始化失败,常见于内存不足或者之前没正确调用end()就重复初始化。重复调用begin()是个真实存在的坑,有些人在loop()里发现连不上就想再begin()一次,结果直接把协议栈搞崩。正确的做法是断开后要么在事件回调里处理重连,要么先end()再begin()。
connected()和hasClient()这两个容易搞混。connected()对于从机来说比较宽泛,更多是在主机模式下判断自己有没有连上对端;hasClient()则是从机视角,判断“有没有一个客户端已经连到我的 SPP 服务上”。我在从机代码里判断“能不能发数据”时,用的通常是hasClient()。这两个函数的区别在写双向通信时会体现得非常明显,用错了就会出现“明明没连上,代码却认为连着,一直往黑洞里写数据”的情况。
end()用来关闭蓝牙释放内存。凡是需要在运行时切换工作模式的场景,一定要先end();如果你还想在运行时改设备名,也必须先end()再begin("新名字"),否则新名字可能不生效。
3.2 数据面的读写:flush 和 setTimeout 的语义要注意
读这块,available()返回缓冲区里还有多少字节可读,read()读一个字节并返回 int(没数据返回 -1),peek()看一眼不出队,readString()/readStringUntil()是便捷函数。底层缓冲区的容量有限,如果对端发得比读得快,多出来的部分就会丢——这不是库的锅,是所有流式接口的通病,解决方式是用回调在数据到达协议栈的瞬间就取走,而不是等loop()轮询。
写这块,write()有单字节和缓冲区两个重载,print()/println()是格式化输出的语法糖。flush()在这里的行为需要说清楚:它并不保证数据已经发到对端,只保证数据已经交给了底层协议栈。别用flush()来同步对端状态,我见过有人在发完指令后flush()然后立刻读响应,结果读不到就认为是设备不响应,实际上是flush()返回并不代表对端处理完了。
写操作还有个必须养成的习惯:看返回值。write()返回实际写入的字节数,在链路拥塞时可能小于你传入的长度,甚至返回 0。稳妥的写法是循环写直到全部写完,或者在拥塞状态里挂起等待。下面这段是我常用的写法:
size_t writeAll(const uint8_t *buf, size_t len) { size_t sent = 0; uint32_t t0 = millis(); while (sent < len) { size_t n = SerialBT.write(buf + sent, len - sent); sent += n; if (n == 0) { // 拥塞,等一下再试 if (millis() - t0 > 2000) break; // 防止死等 delay(5); } } return sent; }setTimeout()影响的是readString()之类依赖超时的函数,默认 1000ms。如果你确实要用这类便捷函数,把这个值调大能减少半包,但真正可靠的做法永远是自己做帧同步,靠超时去猜帧边界是最不可靠的一招。
3.3 回调注册与拥塞事件
这个库提供了一套回调机制,除了register_callback()这个直接对接底层事件的方式,还提供了几个语义更清楚的封装:onData()在数据到达时触发,onConnect()在连接建立时触发,onDisconnect之类的操作则要借助通用回调里的关闭事件。如果你的数据量比“偶尔发一条命令”大,就应该用onData()而不是在loop()里轮询,因为它拿到的是协议栈交付的原始数据块,不会丢。
register_callback()注册的是esp_spp_cb_t,能收到的事件包括初始化完成、服务端连接建立、连接断开、数据到达、拥塞状态变化、写完成等。里面有两个事件在实战里最有价值:一个是服务端连接建立,可以用来打印对端地址、做连接计数;另一个是拥塞状态事件,它告诉你链路缓冲区是不是满了。发送大块数据时,如果不知道拥塞状态,一味往write()里塞,结果就是返回 0 或者丢数据。
提示:回调函数里不要做耗时操作,不要
delay(),更不要在里面再调用begin()/end()。回调运行在协议栈的任务上下文里,堵住它会连带把整个蓝牙栈卡死,表现就是“连上了但再也收不到数据”。
4. 两个能直接抄的实例:从透传桥到带帧协议的可靠收发
4.1 实例一:给老设备的 UART 加一条蓝牙链路
这是最实用的一个场景:手上有个设备只会通过 UART 吐数据,你想让它能被手机无线访问。思路是让 ESP32 同时开着两个“串口”——一个物理的Serial1,一个虚拟的SerialBT,在两个方向上各自转发。
硬件连接上,把目标设备的 TX 接到 ESP32 的一个可用 RX 脚上,把目标设备的 RX 接到 ESP32 的 TX 脚上,共地。代码大概是这样:
#include <BluetoothSerial.h> BluetoothSerial SerialBT; #define UART_RX 16 #define UART_TX 17 void setup() { Serial.begin(115200); Serial1.begin(115200, SERIAL_8N1, UART_RX, UART_TX); SerialBT.begin("ESP32_UART_Bridge"); } void loop() { // UART -> 蓝牙 while (Serial1.available()) { uint8_t b = Serial1.read(); SerialBT.write(b); } // 蓝牙 -> UART while (SerialBT.available()) { uint8_t b = SerialBT.read(); Serial1.write(b); } }看起来十行就完事了,但真正跑起来有几个点不处理会很难受。第一,Serial1.begin()的波特率必须和目标设备完全一致,这一项错了就是满屏乱码;ESP32 的 UART 硬件分频能力不错,常见波特率都能对得上,但如果是非标准的奇葩波特率,误差可能会累积出问题。第二,双向转发的时候注意别让两个 while 循环互相饿死,我上面写的是先处理完 UART 再处理蓝牙,数据量大时会有延迟,更均衡的做法是每轮各处理固定字节数。第三,蓝牙侧的发送速度明显快于某些低速 UART,如果目标设备的 UART 只有 9600,而手机一股脑发过来几 KB,SerialBT的缓冲区会瞬间堆满。这时候要么在应用层做流控(比如让对端等 ACK),要么在中间加一个环形缓冲区削峰。
顺带说一句,这个桥接结构的价值在于:上层的蓝牙链路对老设备完全透明,老设备那边的固件一行都不用改,它仍然以为自己只是在跟一个串口聊天。
4.2 实例二:加个简单的帧格式,把粘包半包问题彻底解决
流式接口最烦的就是没有消息边界。手机连着发AA和BB,中间隔了 10ms,可能被拼成一帧AABB,也可能被切成AABB,取决于链路时序。这在“回显”这种场景里无所谓,一旦涉及命令解析就必然出问题。解决办法就是自己定个简单的帧格式,最小可用版本我一般用这样一套:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2 字节 | 固定0xAA 0x55,用于对齐 |
| 长度 | 1 字节 | 后续 payload 字节数,0 到 200 |
| 载荷 | 变长 | 实际数据 |
| 校验 | 1 字节 | 从长度字段到载荷末尾逐字节异或 |
接收侧用一个状态机推进,代码骨架如下:
enum RxState { WAIT_H1, WAIT_H2, WAIT_LEN, WAIT_PAYLOAD, WAIT_SUM }; RxState st = WAIT_H1; uint8_t len = 0, idx = 0, sum = 0; uint8_t payload[200]; void feed(uint8_t b) { switch (st) { case WAIT_H1: if (b == 0xAA) st = WAIT_H2; break; case WAIT_H2: st = (b == 0x55) ? WAIT_LEN : (b == 0xAA ? WAIT_H2 : WAIT_H1); break; case WAIT_LEN: len = b; sum = b; idx = 0; st = len ? WAIT_PAYLOAD : WAIT_SUM; break; case WAIT_PAYLOAD: payload[idx++] = b; sum ^= b; if (idx >= len) st = WAIT_SUM; break; case WAIT_SUM: if (b == sum) handleFrame(payload, len); st = WAIT_H1; break; } } void loop() { while (SerialBT.available()) feed(SerialBT.read()); }这个状态机看着朴素,但有几个设计取舍值得说清楚。帧头用两个字节而不是一个,是因为单字节帧头在二进制数据里碰撞概率太高,一旦数据里恰好出现那个值就会误判,然后用后面整段数据去凑校验,越走越偏。长度字段限死在 200 以内,是为了让缓冲区大小可控,同时也防住了“收到一个超大长度导致越界写”这类问题——如果对端发了len=250,我的缓冲区只有 200,那必须在前一步就丢弃并复位状态机。校验用异或是最简单的一种,能挡住大部分偶发错误,如果对可靠性要求高,换成 CRC16 更稳妥,代价是多算一点。
这个模式我在好几个项目里复用,改的只是帧头和字段布局。关键在于:定好格式之后,发送侧必须先按格式组包再写,不能一边写一边拼,那样很容易发出半帧,对端状态机就一直卡在那里等。
4.3 反过来用:让 ESP32 主动去连别的设备
前面讲的都是“别人连我”,begin()的isMaster传true就变成主动模式,用connect()去连已知设备,参数可以是设备名,也可以是地址。这个模式适合的场景是:ESP32 作为数据汇聚端,去连一个固定的、支持 SPP 的外设。
这里有个实际限制要提前知道:主动连接的前提是你已经知道对端的地址或者名字,如果对端地址是会变的(有些手机的 SPP 地址在每次配对后会变),那就得先扫描。库里有discoverAsync()配合回调做异步扫描,扫描结果里能拿到设备名和地址,适合有交互界面的场景。不过我更倾向的做法是:先让用户手动配对一次,把地址存到 NVS 里,之后每次上电直接用旧地址连,省掉一次扫描的时间,连接也更快。这一招在启动速度敏感的场合很有用。
5. 实测踩坑清单:连不上、乱码、掉线、内存不够怎么查
5.1 手机搜不到或者连不上,按这个顺序查
遇到“搜不到”,我从这几层依次排查,基本能定位到问题在哪:
- 确认芯片支持经典蓝牙。前面强调过,这是硬性门槛,不支持的话代码根本跑不起来。
- 确认名字没改过或者已取消配对。旧配对缓存会让手机显示旧名字甚至拒绝连接。
- 确认设备名是纯英文数字且不长。中文名和超长名在部分机型上有问题。
- 确认没有被其他客户端占着。SPP 服务端通常只维持一个客户端连接,另一台手机连上了,这台就连不进来,表现就是“配对成功但连接失败”。
- 确认配对 PIN 或者安全配对设置。有些手机会要求输入 PIN,库里可以用
setPin()设定固定值,也可以用另一套安全简单配对的接口把它简化掉。
“配对成功但连不上”这个现象特别值得单独说。它说明链路层通了,但 SPP 通道没建立起来。原因往往是手机上那个 App 只做了配对没做连接,或者上一个连接还挂在那儿没释放。我的处理办法是:在固件的连接事件回调里打印日志,看服务端到底有没有收到连接建立事件。收到了就说明是 App 侧的问题,没收到就是根本没进来,往蓝牙配置方向查。
5.2 中文乱码和“少收一个字节”的真相
乱码问题有两类,成因完全不同。第一类是编码不一致,对端按 GBK 发,你按 UTF-8 解析,必然乱;解决办法是两边统一成 UTF-8,或者干脆只在协议里传二进制,显示层再处理。第二类是把read()的返回值当成字符直接print,read()返回的是int,没数据时是 -1,直接转成char会得到一个乱码字符,看起来就像收到了脏数据。这是新手特别容易踩的坑,正确处理是先用available()判断再读。
还有一类现象是“发 10 个字节只收到 9 个”。这通常不是丢包,而是帧边界判断的时机不对——数据是分几批到达的,你在第一批刚到时就去读,自然是少的。SPP 的链路是有重传的,正常情况下不会丢,问题几乎都出在应用层的拆包逻辑上。第 4 节那个状态机就是专门对付这个的。
5.3 和 BLE 共存时的内存与编译问题
ESP32 是双模芯片,理论上经典蓝牙和 BLE 可以同时开着,但代价是内存。Bluedroid 协议栈两个模式都开,RAM 占用会很可观,如果再叠加 WiFi,很容易在运行中出现分配失败,表现为随机重启或者连接不上。我实测下来的经验是:除非业务真的需要,否则别在两个模式之间来回切,也别同时开。
如果确实需要,几个可以缓解的点:一是把 WiFi 的使用降到最低,用完就关;二是把任务栈调小一点,别动辄开个 8KB 栈的任务;三是尽量用回调而不是轮询,少占用上下文切换的开销。还有一种更彻底的办法,是让固件在启动时根据某个标志位决定开哪个模式,然后重启切换——听起来笨,但稳定性最好。
编译层面的坑是库冲突。如果你同时引了 BLE 相关的库和经典蓝牙的库,可能会出现符号重复或者头文件互相覆盖的情况,报错信息一般比较隐晦,比如某个类型重定义。遇到这种,先看编译输出的第一行错误,别看刷屏的最后几行,那往往只是连锁反应。
5.4 短连接被系统静默断开
手机系统对蓝牙连接是有一套省电策略的。如果你的 ESP32 只是安静地等待,链路长时间没有任何数据来往,手机侧有可能主动断开,或者进入一种“看起来连着但发不出数据”的假死状态。表现在代码上就是hasClient()突然变false,或者write()一直返回 0。
我的应对办法很简单,也不优雅但极其有效:加一个低频心跳。每隔十几秒,ESP32 主动往链路里写一个字节的哨兵值,接收侧识别到这个值就丢掉,不当作业务数据。这样做有两个好处,一是保持了链路活跃,二是能及时发现链路已经断了(写入失败就能判断)。心跳频率别太高,太频繁反而增加功耗,而且意义不大。
提示:心跳字节一定要在协议里明确排除掉,别让它混进业务数据流里。我一般用一个业务里永远不会出现的固定值,或者在帧格式之外单独走一个极简的心跳包。
另外就是end()之前别忘了清理状态,尤其是那种“允许用户手动切换工作模式”的设备,切模式时不end()就重新begin(),考验的不是代码逻辑而是运气。
最后分享一个我踩了很多次才养成的习惯:每次改动蓝牙相关的代码,先把 USB 串口的波特率调到 115200 以上并保持监视器常开,所有连接建立、断开、收到多少字节,全部打日志。蓝牙这东西没有物理线缆可看,出了问题唯一的线索就是日志,那些“莫名其妙就好了”的情况,事后回头看日志,几乎都能找到一行被忽略的输出。我现在甚至会在回调里把事件编号和当前时间戳一起打出来,看起来啰嗦,但真出问题时,这几行输出能省掉大半天的瞎猜。