资讯详情

ESP32 BluetoothSerial 经典蓝牙SPP串口透传与避坑

📅 2026/11/2 9:11:28 | 华诺云谱 👁 阅读
ESP32 BluetoothSerial 经典蓝牙SPP串口透传与避坑
做过无线串口调试的人多半有过这样一段经历兴致勃勃打开 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 双模协议栈SPPSerial 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、endbegin()是整个库的入口重载有两种形态带设备名和带设备名加主从标志。它返回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没数据返回 -1peek()看一眼不出队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而手机一股脑发过来几 KBSerialBT的缓冲区会瞬间堆满。这时候要么在应用层做流控比如让对端等 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 以内是为了让缓冲区大小可控同时也防住了“收到一个超大长度导致越界写”这类问题——如果对端发了len250我的缓冲区只有 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()的返回值当成字符直接printread()返回的是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 以上并保持监视器常开所有连接建立、断开、收到多少字节全部打日志。蓝牙这东西没有物理线缆可看出了问题唯一的线索就是日志那些“莫名其妙就好了”的情况事后回头看日志几乎都能找到一行被忽略的输出。我现在甚至会在回调里把事件编号和当前时间戳一起打出来看起来啰嗦但真出问题时这几行输出能省掉大半天的瞎猜。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑