ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

ESP32经典蓝牙串口透传实战:BluetoothSerial原理、调优与避坑

ESP32经典蓝牙串口透传实战:BluetoothSerial原理、调优与避坑

如果你手上有一块 ESP32,想把它当成一个“无线串口”来用——手机点一下就连上,电脑打开串口助手就能收发字节,或者两块板子之间不用接一根线就能互传数据——那你十有八九会翻到BluetoothSerial这个库。它是 Arduino-ESP32 核心包里自带的一个经典蓝牙(Classic Bluetooth)串口封装,把底层那套 RFCOMM/SPP 的东西裹成了一个长得跟Serial几乎一样的对象,begin()、available()、read()、write()、print()全套都有。我见过太多人第一次做蓝牙透传时,上来就去应用市场搜“蓝牙串口 App”,结果发现自己把 BLE 和经典蓝牙搞混了,折腾一晚上连不上,最后才发现是协议栈压根不是一回事。这篇东西我打算把BluetoothSerial从定位、原理、代码、调优到排查一次性讲透,重点不是把官方例程抄一遍,而是把那些文档里不写、但你在真机上一定会撞到的问题说清楚。适合刚上手 ESP32 的初学者,也适合已经能跑通例程、但一到大数据量或者多设备场景就翻车的开发者。

1. 先把定位想清楚:经典蓝牙串口解决的是什么问题

1.1 一个足够具体的场景,帮你判断要不要用它

设想这么一个需求:现场有一台老设备,只有一个 RS232 串口往外吐数据,你想把这些数据无线收集到十几米外的笔记本上做记录。设备本身没有联网能力,也不打算改造它的固件。这时候最省事的做法就是拿一块 ESP32,一路 UART 接老设备,另一路用经典蓝牙虚拟成一个串口,笔记本配对之后直接在串口助手里看数据。整个过程不需要写上位机程序,不需要配 IP,不需要路由器,开机即用。

这就是BluetoothSerial最舒服的适用面:点对点、短距离、需要“像串口一样”收发字节流、对延迟不敏感但对连接稳定性有一定要求。它的本质是蓝牙 SPP(Serial Port Profile)协议在 ESP32 上的实现,对端看到的不是一个自定义协议设备,而是一个标准的串口服务。所以只要对端支持 SPP——绝大多数安卓手机、Windows 笔记本、Linux 主机、以及一堆工业蓝牙模块——就能连,完全不需要在对面装什么驱动或者 SDK。

反过来说,如果你的对端是 iPhone 或者 iPad,那这条路基本走不通。iOS 对外部配件的串口通信走的是另一套认证体系,普通 SPP 服务在 iOS 上根本不会出现在可用设备列表里。很多人卡在这一步,以为是代码写错了,其实是平台限制。这一点必须先说在前面,能省掉你半天时间。

1.2 芯片选型是第一道坎:有些型号根本没有经典蓝牙

这是我最想强调的一条。ESP32 这个家族名字下面,芯片型号之间的差异比想象中大得多。经典蓝牙只在部分型号上存在,后面出的几款精简型、低成本型号往往只保留了低功耗蓝牙,射频前端和协议栈里压根没有 BR/EDR 那一套。你拿着一块只有 BLE 的板子去编译带BluetoothSerial的代码,要么编译期就报错,要么跑起来begin()直接返回失败,串口里一片安静,非常容易被误判成“模块坏了”。

判断方法很简单,两道保险:

第一道在代码里。BluetoothSerial.h文件开头本身就有编译期检查,如果当前芯片的配置里没有开启经典蓝牙相关的能力,编译会直接停下来并给出提示。所以如果你看到编译报错指向这个头文件,别急着怀疑环境,先确认板子型号。

第二道在选板子的时候。买之前看清楚参数页写的是“蓝牙经典 + BLE 双模”还是只有“BLE”。同系列里带双模的型号做透传最省心,只有 BLE 的型号就得换思路,改用 BLE 的串口服务,那是另一套代码,API 完全不一样,不能照搬。

注意:同一个工程在两块外观几乎一样的板子上表现不同,九成是芯片型号差异导致的,不要先怀疑代码。

1.3 经典蓝牙和 BLE 该怎么选,一张表说清楚

新手最容易犯的错是把两者当成“新旧版本”的关系,觉得 BLE 是升级版,那就用新的。实际上它们面向的场景完全不同,选择依据应该是业务需求而不是新旧。

对比维度经典蓝牙(SPP)低功耗蓝牙(BLE)
典型吞吐较高,适合连续字节流较低,适合小包低频
连接建立速度较慢,配对过程明显快,广播即发现
功耗高,持续连接耗电明显低,适合电池设备
对端兼容性安卓、Windows、Linux 良好全平台良好,含 iOS
编程模型类似串口,字节流基于服务与特征值,需定义协议
适合场景数据透传、老设备改造、调试通道传感器上报、可穿戴、手机 App 交互

我自己的判断习惯是:只要对端是安卓或 PC,且需要连续、双向、不定长的数据流,就优先选经典蓝牙;一旦对端可能包含苹果设备,或者设备靠电池供电要跑几个月,那就老老实实做 BLE。中间那种“既要 iOS 又要大流量”的需求,往往得两个都做,或者干脆换 Wi-Fi。

2. BluetoothSerial 的底层构成与关键 API 地图

2.1 从 RFCOMM 到 SPP,分层理解它到底在干什么

把BluetoothSerial想象成一条“虚拟的串口线”,这条线在蓝牙协议栈里其实是由好几层拼起来的。最底下是基带和射频,负责把比特发出去;往上是 L2CAP,做逻辑通道复用;再往上是 RFCOMM,这一层才是关键——它模拟了串口的控制信号和数据通道,把蓝牙链路包装成一条条“信道”;最上面是 SPP,它规定了“用 RFCOMM 的一条信道来传串口数据”这个用法,并定义了服务发现时该用什么 UUID。

BluetoothSerial这个库做的事情,就是把 SPP 服务注册、RFCOMM 信道建立、数据收发缓冲区管理这些全部封装起来,然后对外暴露一组和HardwareSerial高度相似的接口。所以你写SerialBT.write(buf, len)的时候,实际发生的是:数据先进库内部的发送缓冲,再经由协议栈切分、加上 RFCOMM 帧头,通过已建立的 ACL 链路发出去,对端协议栈收到后剥掉帧头,还原成字节流塞进它的串口接收缓冲。

理解这一层有个直接好处:你会知道“一次 write 不等于对端一次 read”。RFCOMM 有自己的帧大小限制,底层还会根据链路质量和缓冲情况动态切分,所以你在发送端写 1000 字节,接收端完全可能分三次available()才凑齐。后面第 5 节讲分帧就是从这个事实出发的。

2.2 库在哪、要不要额外装、编译要注意什么

先破除一个高频误解:BluetoothSerial不需要你手动去装第三方库。它是 Arduino-ESP32 核心包的一部分,只要你的开发板管理器里装了对应核心,这个头文件就在。很多人搜“BluetoothSerial 库下载”,装了一堆来路不明的第三方包,结果版本冲突,编译一堆重定义错误。正确做法是直接在代码里#include "BluetoothSerial.h",能编译过就说明核心包齐全。

第二个常见坑是容量。经典蓝牙协议栈本身要占不少 Flash,如果你的工程同时开了 Wi-Fi、文件系统、OTA,很容易在链接阶段报“程序超出可用空间”。这时候不是代码的问题,是分区表的问题。去开发板菜单里把分区方案改成给应用留更大空间的选项,通常立刻就过了。我一般在新工程一开始就把这个选项设好,免得写到一半才发现。

第三个是核心包版本差异。不同大版本的 Arduino-ESP32 在蓝牙这块的 API 有过调整,比如begin()的重载参数、发现设备的回调形式,在版本之间会有出入。我的建议是:照着你实际装的核心版本文档写代码,别照抄网上博客,尤其是两三年前的文章。遇到“函数不存在”的报错,先去核心包目录里翻一眼头文件,比搜索引擎快得多。

2.3 核心 API 速查:哪些是常用的,哪些是救命的

下面这张表我按使用频率排了序,前几个是你天天要用的,后面几个是出问题时才会想起来但非常关键的。

API作用使用要点
begin(name, isMaster)初始化并启动从机必须给一个可被搜索到的名字
available()返回可读字节数返回 0 不代表断开,别拿它判断连接
read()/readString()读取数据大包场景建议用read(buf, len)
write()/print()/println()发送数据println会附加换行,做二进制协议时要小心
hasClient()是否有对端连接从机模式下判断连接状态最可靠的方式
disconnect()主动断开当前连接断开后可以继续被搜索,不用重新 begin
end()彻底释放蓝牙资源想在运行中切换角色时必须先调它
register_callback()注册协议栈事件回调连接、断开、发送完成都能拿到通知
setPin()设置固定配对码必须在begin()之前调用,否则无效
getAddress()读取本机蓝牙地址做白名单、写日志时很有用
connect()主动连接对端仅主机模式可用

这里单独说两个容易用错的。

available()是新手最爱拿来做连接判断的。它只反映“接收缓冲里现在有没有数据”,连接着但没有数据时它稳稳地返回 0。用它判断连接,会导致你的程序以为断线了然后反复重连。正确的判断依据是hasClient(),从机模式下这个才是连接状态的权威来源。

setPin()和enableSSP()这类配置项有个共同特点:必须在begin()之前调用。因为begin()一执行,协议栈就初始化完成了,之后再改这些参数要么不生效,要么得先end()再重来。这个顺序问题在文档里往往一句带过,但实际调试时非常折磨人。

3. 从零跑通第一个透传实例

3.1 环境与板级配置的三步走

第一步,装好 Arduino IDE 或者你习惯的编辑器插件,在开发板管理器里添加 Arduino-ESP32 的核心包索引,然后装一个稳定的版本。我不建议一上来就追最新,选一个社区讨论多、示例齐全的版本更省事。

第二步,选板子。注意这里要选对具体型号,不能随便挑一个“ESP32 Dev Module”就完事——如果你手上的板子属于只有 BLE 的那一档,选错型号会导致编译出来的固件行为和预期不符。选完之后把上传速度、Flash 大小、分区方案按实际硬件配好。

第三步,验证串口。先烧一个最简单的Serial.println程序,确认串口监视器能看到输出、波特率对得上。这一步不能省,因为后面蓝牙出问题时,你需要一个可信的调试通道来判断到底是蓝牙挂了还是程序根本没跑起来。我见过不少人折腾半天蓝牙,最后发现是串口线接触不良,压根没看到任何日志。

3.2 最小可用透传代码,逐行讲清楚

下面这段是从机模式的双向透传,功能是把硬件串口收到的数据转发到蓝牙,把蓝牙收到的数据转发到硬件串口。它短,但每一行都有讲究。

#include "BluetoothSerial.h" BluetoothSerial SerialBT; void setup() { Serial.begin(115200); delay(100); // 第一个参数是对外广播的设备名,尽量带上项目标识,方便现场区分 if (!SerialBT.begin("ESP32-Bridge-01")) { Serial.println("bluetooth init failed"); while (true) { delay(1000); } } Serial.println("bluetooth ready, waiting for connection"); } void loop() { // 硬件串口 -> 蓝牙 if (Serial.available()) { size_t n = Serial.available(); uint8_t buf[128]; if (n > sizeof(buf)) n = sizeof(buf); size_t got = Serial.readBytes(buf, n); SerialBT.write(buf, got); } // 蓝牙 -> 硬件串口 if (SerialBT.available()) { size_t n = SerialBT.available(); uint8_t buf[128]; if (n > sizeof(buf)) n = sizeof(buf); size_t got = SerialBT.readBytes(buf, n); Serial.write(buf, got); } delay(10); }

几个细节值得展开。

begin()的返回值我做了判断。虽然大多数情况下它就是成功,但芯片不支持、协议栈初始化失败、内存不足时它会返回 false。加上这个判断,出问题时你能一眼看到是启动阶段就挂了,而不是傻等连接。

转发时我用了readBytes(buf, n)而不是逐字节read()。逐字节读在小数据量下没问题,但一旦对端连续发几百字节,循环里每次read()都要过一遍函数调用和缓冲检查,效率明显下降,还容易在loop()里被其他逻辑打断造成丢数据。批量读进缓冲区再一次性写出去,稳得多。

delay(10)这个尾巴看起来无所谓,实际上是省电和降低总线压力的手段。去掉它,loop()会以极高的频率空转,虽然功能正常,但芯片发热和功耗都会上去,在没有数据的时候尤其浪费。10 毫秒的延迟对串口透传的实时性影响可以忽略。

缓冲数组我固定成了 128 字节。这个值不是随便定的:太小会导致大数据包被切得很碎,转发开销变大;太大则占用栈空间,而且loop()单次处理时间变长。128 到 256 之间是比较舒服的区间,具体可以按你的数据包大小调整。

3.3 手机端怎么连,以及连上之后怎么验证

安卓手机上,先用系统设置的蓝牙页面搜索并配对,配对码通常是1234或0000,如果代码里改过setPin()就用你设的那个。配对成功后,打开一个支持经典蓝牙 SPP 的串口工具,在设备列表里找到你刚才设的那个名字,点击连接。注意有些工具区分“经典”和“低功耗”两个入口,选错了会一直搜不到设备。

连接成功的标志,在你这边是串口监视器里那行提示之后的静默,在手机端是工具里出现“已连接”。验证方法很简单:在手机端发一串字符,比如hello,串口监视器里应该原样出现;反过来在串口监视器里输入内容并发送,手机端也应该收到。

Windows 笔记本做对端时有个经典坑:配对之后设备管理器里会冒出两个串口,一个是“传入”,一个是“传出”。发数据要用传出的那个,用传入的那个你会发现只能收不能发。这个命名很反直觉,我第一次遇到时也懵了一阵。

注意:如果手机能搜到设备但连接后立刻断开,先检查是不是另一个设备已经连着。从机模式下同一时刻一般只维持一条 SPP 连接,第二条会被拒绝,表现就是“连上又掉”。

4. 主从双向:让 ESP32 主动去连别人

4.1 主机模式的启动流程和角色差异

从机模式是“我在这儿,你来连我”,主机模式是“我去找别人”。两者的初始化代码只差一个参数,但运行逻辑差别很大。

// 第二个参数为 true 表示主机模式 SerialBT.begin("ESP32-Master-01", true);

主机模式下,那个名字参数基本没什么用——因为你不广播,别人搜不到你。真正要做的是发起设备发现,从搜索结果里挑目标,然后建立连接。

主机模式最典型的用途是替代一根线:比如你的设备要和一台蓝牙打印机、一台蓝牙电子秤、或者另一块做从机的板子通信。这种场景下,连接的主动权在你手里,你可以做重连策略、可以做多设备轮询。

有个前提要说清楚:主机模式下一次通常只维持一条 SPP 连接。想同时对接多个从设备,得走更底层的多连接管理,BluetoothSerial这层封装并不直接支持。如果你的需求是“一个主机收多个从机数据”,要么改成“多个从机轮流被主机连接”,要么放弃这个库,用协议栈原生接口自己管。

4.2 扫描、过滤与连接,别把所有设备都连一遍

设备发现是要花时间的,而且周围可能有几十个蓝牙设备。所以扫描必须带过滤,否则你会发现程序在不停地连错设备。

#include "BluetoothSerial.h" BluetoothSerial SerialBT; const char* TARGET_NAME = "ESP32-Bridge-01"; bool connected = false; class ScanCallbacks : public BTAdvertisedDeviceCallbacks { void onResult(BTAdvertisedDevice* dev) { String name = dev->getName().c_str(); if (name != TARGET_NAME) return; Serial.printf("target found, rssi=%d\n", dev->getRSSI()); SerialBT.cancelDiscovery(); // 先停扫描,再发起连接 if (SerialBT.connect(dev->getAddress())) { connected = true; Serial.println("connected"); } else { Serial.println("connect failed, retry later"); } } }; ScanCallbacks callbacks; void setup() { Serial.begin(115200); SerialBT.begin("ESP32-Master-01", true); SerialBT.discoverAsync(&callbacks); } void loop() { if (!connected) { delay(3000); connected = false; SerialBT.discoverAsync(&callbacks); } delay(50); }

这里最关键的一行是cancelDiscovery()。扫描和连接不能同时进行,这是蓝牙基带的资源限制,不是软件问题。很多人在回调里直接调connect(),结果一直失败,就是因为扫描还没停。顺序必须是:停扫描 → 等一小会儿 → 连接。如果实测下来连接仍然不稳定,在cancelDiscovery()和connect()之间加一个几十毫秒的短延时会更保险。

过滤条件我用了设备名。设备名有它的局限——有些设备广播里不带名字,或者名字被截断。更可靠的做法是用蓝牙地址做白名单,因为地址是唯一的。生产环境里我一般两套都用:先按名字快速筛,再按地址二次确认。

4.3 用事件回调把连接状态管起来

轮询hasClient()能解决大部分问题,但有些信息只有事件回调拿得到,比如“对端主动断开了”“数据已经真正发到射频了”。这些在排查疑难问题时特别有用。

void sppCallback(esp_spp_cb_event_t event, esp_spp_cb_param_t *param) { switch (event) { case ESP_SPP_INIT_EVT: Serial.println("spp stack initialized"); break; case ESP_SPP_SRV_OPEN_EVT: Serial.println("a client connected"); break; case ESP_SPP_OPEN_EVT: Serial.println("connected to remote"); break; case ESP_SPP_CLOSE_EVT: Serial.println("link closed"); break; case ESP_SPP_DATA_IND_EVT: // 数据到达,param->data_ind.len 是本次到达的长度 break; case ESP_SPP_WRITE_EVT: // 发送完成,可以在这里做发送队列推进 break; default: break; } } // 在 setup 里、begin 之后注册 SerialBT.register_callback(sppCallback);

ESP_SPP_CLOSE_EVT这个事件特别值得留意。它不光在对端主动断开时触发,连接超时、链路质量太差被协议栈丢弃时也会触发。把断开的时刻和原因记下来,做现场问题复盘时价值极高。我之前遇到过一个“每天下午固定掉线”的案例,最后就是靠这个事件配合时间戳,发现是附近有大功率设备在固定时段工作造成干扰。

5. 大流量下的分帧、缓冲与稳定性调优

5.1 直接 write 大包会遇到什么

先把结论摆出来:SPP 是字节流,不是消息流。你在发送端调一次write(buf, 500),接收端可能一次收到 500 字节,也可能分两次收到 300 和 200,甚至分五次。这不是 bug,是协议栈根据链路情况和内部缓冲做的正常切分。

很多人的代码是这样的:发送端SerialBT.write("GET:1\n"),接收端读一次readStringUntil('\n'),跑起来完全正常。然后数据量一大、或者内容里恰好出现了和分隔符相同的字节,立刻出问题——要么半条指令被当成完整指令执行,要么两条指令粘在一起。

这个问题在调试阶段往往不明显,因为调试时数据量小、发送间隔长,切分很少发生。等上了现场,数据量一上来,问题集中爆发,而且偶发性极强,非常难查。

5.2 一套能直接抄的分帧协议

解决办法是自己定义帧结构,把“消息边界”这件事从协议栈手里拿回来。我常用的是这种最朴素的形式:

帧格式:[0xAA][0x55][LEN_L][LEN_H][PAYLOAD...][CRC_L][CRC_H]

固定两字节帧头用于对齐,两字节长度表示负载长度(小端),负载最长到 1024,最后两字节是校验。接收端用状态机逐字节推进,凑齐一帧才交给业务层处理。

// 接收状态机,放在 loop 里循环调用 enum RxState { WAIT_H1, WAIT_H2, WAIT_LEN_L, WAIT_LEN_H, WAIT_PAYLOAD, WAIT_CRC_L, WAIT_CRC_H }; RxState state = WAIT_H1; uint8_t payload[1024]; uint16_t payloadLen = 0; uint16_t idx = 0; uint16_t crcCalc = 0; void feedByte(uint8_t b) { switch (state) { case WAIT_H1: if (b == 0xAA) state = WAIT_H2; break; case WAIT_H2: state = (b == 0x55) ? WAIT_LEN_L : WAIT_H1; break; case WAIT_LEN_L: payloadLen = b; state = WAIT_LEN_H; break; case WAIT_LEN_H: payloadLen |= (uint16_t)b << 8; idx = 0; crcCalc = 0; if (payloadLen == 0 || payloadLen > sizeof(payload)) { state = WAIT_H1; } else state = WAIT_PAYLOAD; break; case WAIT_PAYLOAD: payload[idx++] = b; crcCalc = crc16Update(crcCalc, b); if (idx >= payloadLen) state = WAIT_CRC_L; break; case WAIT_CRC_L: if (b == (crcCalc & 0xFF)) state = WAIT_CRC_H; else state = WAIT_H1; break; case WAIT_CRC_H: if (b == ((crcCalc >> 8) & 0xFF)) { handleFrame(payload, payloadLen); // 完整一帧,交给业务 } state = WAIT_H1; break; } }

发送侧对应地拼帧:

void sendFrame(const uint8_t* data, uint16_t len) { uint8_t head[4] = { 0xAA, 0x55, (uint8_t)(len & 0xFF), (uint8_t)(len >> 8) }; uint16_t crc = crc16(data, len); SerialBT.write(head, 4); SerialBT.write(data, len); uint8_t tail[2] = { (uint8_t)(crc & 0xFF), (uint8_t)(crc >> 8) }; SerialBT.write(tail, 2); }

这套东西看起来朴素,但好在几个地方:接收端任何时候被打断都能恢复,因为帧头会重新对齐;长度字段让你不用靠分隔符猜边界,二进制数据也能传;CRC 能挡住链路误码,现场电磁环境差的时候能避免“收到一堆看起来正常实际是错的数据”。代价是多几个字节开销,对几十字节的包来说完全可以接受。

注意:分帧的收端状态机必须在同一个上下文里串行推进。如果在中断或者另一个任务里读蓝牙数据,务必加锁,否则字节顺序错乱,状态机永远对不齐。

5.3 共存、干扰和吞吐的现实预期

ESP32 的射频是 Wi-Fi 和蓝牙共用一套前端,这在设计上是优点,实际用起来要留出余量。当 Wi-Fi 和经典蓝牙同时高强度工作时,两者会分时占用射频,蓝牙的连接间隔被拉长,表现为吞吐下降、偶发卡顿甚至短暂断连。

几个能立竿见影的做法:如果透传是主业,Wi-Fi 只在配置阶段用一下然后关掉;把蓝牙数据按固定节奏发送而不是一口气猛灌;在应用层做发送节奏控制,避免瞬时打满。这些改动不涉及底层,但效果明显。

至于吞吐预期,不要被“经典蓝牙理论速率”那几个数字忽悠。实际能跑到什么程度,取决于主从双方、环境干扰、包大小和你的代码效率。我一般给项目定的验收标准是稳定连续的几十 KB/s 量级,这个目标对绝大多数串口数据采集场景已经绰绰有余。真需要更大带宽,该考虑的是换 Wi-Fi 而不是继续榨蓝牙。

6. 常见问题排查实录与避坑清单

6.1 一张能快速定位问题的排查表

下面这张表是我这些年被问得最多的问题,按现象归类,每个都附了最可能的原因和第一步该查什么。

现象最可能原因第一步排查动作
编译报错指向蓝牙头文件芯片不支持经典蓝牙确认板子型号是双模还是只有低功耗
begin()返回 false协议栈初始化失败 / 内存不足查分区方案,精简其他功能后重试
手机搜不到设备未进入可发现状态 / 名字过长确认begin()已执行,缩短设备名
搜到但连接失败已有其他设备连接 / 配对信息残留断开其他设备,删除系统里的旧配对记录
连上后立刻断开对端类型不匹配(如苹果设备)确认对端支持 SPP
只能收不能发PC 端选错串口换“传出”那个串口试
数据偶尔缺字节没有分帧,边界丢失引入帧头 + 长度字段
长时间运行后掉线射频干扰 / 缺少心跳加定期心跳包,记录断开事件时间
同时开 Wi-Fi 后卡顿射频共存竞争降低发送节奏或关闭 Wi-Fi

6.2 几条不写在文档里的经验

第一条:设备名别超过 20 个字符,也别用中文。蓝牙广播包对名字长度有硬限制,超了会被截断,不同平台截断方式还不一样,结果就是你在这边看到的是完整名字,那边扫出来是半截,按名字过滤的代码直接失效。中文名在部分老设备上会显示成乱码,同样会让过滤逻辑崩掉。用英文、数字、短横线,稳。

第二条:调试期把连接状态打到串口上,别省这几行。我习惯在ESP_SPP_SRV_OPEN_EVT和ESP_SPP_CLOSE_EVT里各打一行带时间戳的日志。看着啰嗦,但现场出问题时,这一行日志能直接告诉你“是压根没连上”还是“连上跑了半小时才断”,排查方向完全不同。

第三条:断开之后不要立刻重连。对端刚断开时,协议栈里那条链路可能还没完全释放,这时候发起新连接大概率失败。加个一两秒的退避,成功率会高很多。反过来,如果你是对端,重连之前也要确认自己的连接已经彻底断开,不能连发两次连接请求。

第四条:量产前一定要做长时间稳定性测试。我吃过这个亏——样机跑十分钟好好的,装到现场连续跑两天就开始出现零星丢包。后来发现是内部接收缓冲在小概率下被填满,而我当时的代码里有个分支没有及时排空缓冲。这类问题只有跑够时间才会暴露,做几个小时的测试是发现不了的。我的做法是至少跑 24 小时,中间定时统计收发字节数和错误计数。

第五条:不要在中断里调这个库的任何接口。蓝牙协议栈本身在独立任务里运行,你从中断里调write()或者read(),轻则数据错乱,重则直接崩溃。中断里只做一件事:把数据搬进自己的环形缓冲区。剩下的交给主循环处理。

第六条:flush()慎用。它的语义是等发送缓冲清空,如果链路质量差或者对端不收数据,这个函数会阻塞住,把整个主循环卡死。我一般不用它,需要确认发送完成就靠ESP_SPP_WRITE_EVT事件,非阻塞,安全得多。

第七条:切换角色前必须先end()。想让同一块板子在运行中从从机变主机,直接调begin(..., true)是不行的,协议栈状态已经固定了。正确顺序是end()释放资源,稍等片刻,再重新begin()。这个“稍等片刻”不能省,一毫秒都可能不够。

我在实际项目里用这套东西做过好几轮迭代,最大的体会是:BluetoothSerial的上手成本很低,真正的门槛全在“没想到”的地方——没想到芯片不支持、没想到 iOS 连不上、没想到大包会碎、没想到 Wi-Fi 会抢射频。把这些先想一遍,剩下的就是写代码的事了。

返回列表