本文是 ESP32 BLE 项目的统一入口,已整合早期重构记录和 NimBLE 扫描假死排查文章。
系统概览 flowchart LR
A["手机 App<br/>Central"] <-->|"BLE UART"| B["ESP32-S3<br/>双角色桥接"]
B <-->|"BLE GATT"| C["热敏打印机<br/>Peripheral"]
B <-->|"UART2"| D["TJC 串口屏"]
ESP32-S3 同时扮演两个 BLE 角色:
角色
方向
协议
用途
Peripheral(外设)
手机 → ESP32
Nordic UART Service
手机收发指令/数据
Central(中心设备)
ESP32 → 打印机
私有 GATT Service
发送 ESC/POS 打印指令
加上一路 UART2 驱动 TJC 串口屏,构成了完整的交互链路。
技术栈分层 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ┌─────────────────────────────┐ │ 应用层 │ │ hello_world_main.c │ ← 主循环:采样、校准、显示 │ tjc_uart2.c (串口屏) │ ← 触摸事件解析 + 页面路由 │ key.c (物理按键) │ ← 焦点导航 + 按键分发 ├─────────────────────────────┤ │ 蓝牙服务层 │ │ printer_scan.c │ ← BLE 双角色 + 打印 + 设备列表 ├─────────────────────────────┤ │ NimBLE Host │ ← 开源 BLE 协议栈 (Apache Mynewt) │ ble_gap / ble_gatt / ... │ GAP 广播/扫描/连接 │ ble_hs │ GATT 服务/特征/读写 ├─────────────────────────────┤ │ NimBLE Controller │ ← ESP32-S3 片上 BLE 基带 ├─────────────────────────────┤ │ FreeRTOS │ ← 多任务调度 │ ble_task / nimble_host / │ │ receive_and_decode / │ │ main loop │ ├─────────────────────────────┤ │ ESP-IDF v5.5 │ ← 驱动层:UART/GPIO/I2C/NVS └─────────────────────────────┘
NimBLE 协议栈 选择 NimBLE(而非 Bluedroid)的原因:
轻量 :ROM 占用约 60KB,Bluedroid 约 500KB+
开源 :Apache 2.0,来自 Apache Mynewt 项目
官方支持 :ESP-IDF v4.0+ 内置,配置 CONFIG_BT_NIMBLE_ENABLED=y
关键组件 1 2 3 4 5 6 #include "nimble/nimble_port.h" #include "nimble/nimble_port_freertos.h" #include "host/ble_hs.h" #include "host/ble_uuid.h" #include "services/gap/ble_svc_gap.h" #include "services/gatt/ble_svc_gatt.h"
事件驱动模型 NimBLE 采用回调注册 模式:
1 2 3 4 5 6 ble_hs_cfg.sync_cb = ble_on_sync; ble_hs_cfg.reset_cb = ble_on_reset; ble_gap_disc(addr_type, BLE_HS_FOREVER, ¶ms, printer_gap_event, NULL ); ble_gap_connect(addr_type, &peer, BLE_HS_FOREVER, &conn_params, printer_gap_event, NULL );
回调在 NimBLE Host Task 上下文中执行,通过 nimble_port_freertos_init() 启动:
1 2 3 4 5 6 nimble_port_freertos_init(nimble_host_task); static void nimble_host_task (void *param) { nimble_port_run(); nimble_port_freertos_deinit(); }
双角色 BLE 架构 角色 1:手机 UART 外设 1 2 ESP32 广播 ──→ 手机扫描发现 ──→ GATT 连接 ──→ 订阅 TX 特征通知 ──→ 写入 RX 特征
服务 UUID :Nordic UART Service (NUS) 标准
1 2 3 Service: 6E400001-B5A3-F393-E0A9-E50E24DCCA9E ├── RX Characteristic: 6E400002-... (手机写 → ESP32 读) WRITE └── TX Characteristic: 6E400003-... (ESP32 通知 → 手机) NOTIFY
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 static const struct ble_gatt_svc_def gatt_svr_svcs [] = { { .type = BLE_GATT_SVC_TYPE_PRIMARY, .uuid = &gatt_svr_svc_uuid.u, .characteristics = (struct ble_gatt_chr_def[]) { { .uuid = &gatt_svr_chr_rx_uuid.u, .access_cb = gatt_svr_chr_access, .flags = BLE_GATT_CHR_F_WRITE, }, { .uuid = &gatt_svr_chr_tx_uuid.u, .access_cb = gatt_svr_chr_access, .flags = BLE_GATT_CHR_F_NOTIFY, }, { 0 } }, }, { 0 } };
广播启动 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 static void ble_uart_advertise (void ) { struct ble_gap_adv_params adv_params ; struct ble_hs_adv_fields fields = {0 }; fields.name = (uint8_t *)"ESP32-BLE-Bridge" ; fields.name_len = strlen ("ESP32-BLE-Bridge" ); fields.name_is_complete = 1 ; fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP; ble_gap_adv_set_fields(&fields); adv_params.conn_mode = BLE_GAP_CONN_MODE_UND; adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN; ble_gap_adv_start(addr_type, NULL , BLE_HS_FOREVER, &adv_params, ble_uart_gap_event, NULL ); }
角色 2:打印机客户端 1 ESP32 扫描 ──→ 发现打印机 ──→ GATT 连接 ──→ 服务发现 ──→ 特征发现 ──→ 写入特征
打印机 UUID (私有协议):
1 2 Service: e7810a71-73ae-499d-8c15-faa9aef0c3f2 └── Write Characteristic: bef8d6c9-9c21-4c9e-b632-bd58c1009f9f WRITE_NO_RSP
扫描参数 :
1 2 3 4 disc_params.filter_duplicates = 0 ; disc_params.passive = 0 ; disc_params.itvl = 0x30 ; disc_params.window = 0x25 ;
服务发现链 :连接成功 → printer_discover_service() → 按 UUID 搜服务 → printer_svc_disc_cb() → 搜所有特征 → printer_chr_disc_cb() → 找到写入句柄,置 printer_ready = true。
BLE 数据发送:MTU 自动分片 打印机写入特征采用 BLE_GATT_CHR_F_WRITE_NO_RSP(无响应写入),需要自行处理 MTU 分片:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 static void printer_send_data (const uint8_t *data, uint16_t len) { if (!printer_ready || printer_attr_handle == 0 ) return ; uint16_t mtu = ble_att_mtu(printer_conn_handle); if (mtu < 23 ) mtu = 23 ; uint16_t chunk_size = mtu - 3 ; uint16_t offset = 0 ; while (offset < len) { uint16_t chunk = (len - offset) > chunk_size ? chunk_size : (len - offset); struct os_mbuf *om = ble_hs_mbuf_from_flat(data + offset, chunk); if (!om) return ; ble_gattc_write_no_rsp(printer_conn_handle, printer_attr_handle, om); offset += chunk; vTaskDelay(pdMS_TO_TICKS(10 )); } }
ESC/POS 打印指令 打印机使用标准 ESC/POS 指令集:
1 2 3 4 5 6 7 #define ESC_INIT "\x1B\x40" #define FS_SET_CHINESE "\x1C\x26" #define FS_SELECT_CODEPAGE "\x1C\x43\x00" #define ESC_ALIGN_LEFT "\x1B\x61\x00" #define ESC_ALIGN_CENTER "\x1B\x61\x01" #define ESC_ALIGN_RIGHT "\x1B\x61\x02" #define FS_EXIT_CHINESE "\x1C\x2E"
打印一条记录的流程 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 bool printer_print_query_record (const void *vdata) { memcpy (&buf[len], ESC_INIT, 2 ); len += 2 ; memcpy (&buf[len], FS_SET_CHINESE, 2 ); len += 2 ; memcpy (&buf[len], ESC_ALIGN_CENTER, 3 ); len += 3 ; len += sprintf (&buf[len], "采样数据记录\n" ); memcpy (&buf[len], ESC_ALIGN_LEFT, 3 ); len += 3 ; len += sprintf (&buf[len], "温度: %s C\n" , data->Ta); len += sprintf (&buf[len], "压力: %s KPa\n" , data->Pa); memcpy (&buf[len], FS_EXIT_CHINESE, 2 ); len += 2 ; buf[len++] = 0x1B ; buf[len++] = 0x64 ; buf[len++] = 0x03 ; printer_send_data(buf, len); }
TJC 串口屏通信 物理层 1 ESP32 UART2 ── 115200/8N1 ── TJC 串口屏
协议帧格式 1 2 [0x55][0x00][数据...][0xFF][0xFF][0xFF] 帧头 帧头 载荷 帧尾
收发实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 void send_to_screen (const char *data) { uint8_t *buf = malloc (strlen (data) + 3 ); memcpy (buf, data, strlen (data)); buf[strlen (data)] = 0xFF ; buf[strlen (data) + 1 ] = 0xFF ; buf[strlen (data) + 2 ] = 0xFF ; uart_write_bytes(UART_NUM_2, buf, strlen (data) + 3 ); free (buf); }
指令类型
指令
示例
含义
按钮按下
bluetooth_open=1
屏幕按钮值改变
变量赋值
MAC.txt="DEVICE_01"
设置文本框内容
模拟点击
click buck,1
模拟按下按钮
变量查询
connecting.val=0
直接写按钮值(不触发事件)
关键区别 :
方式
触发屏幕事件
触发串口回传
click bt,1
✅ 触发
✅ 回传
bt.val=0
✅ 变视觉
❌ 不回传
连接失败时用 click 回传,因为需要触发按钮 press/release 动画;其他场景用 .val= 直接设值避免 echo。
FreeRTOS 任务架构 1 2 3 4 5 6 优先级 任务名 功能 ─── ────── ──── 高 NimBLE Host Task BLE 协议栈事件循环 (nimble_port_run) │ ble_task 50ms 轮询:连接/断开/心跳/断线检测 │ receive_and_decode UART2 串口屏帧接收+解析 低 main loop 采样、校准、PID、屏幕刷新
ble_task 主循环 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 void ble_task (void *arg) { while (1 ) { if (ble_connect_flag) { ... } if (ble_disconnect_flag) { ... } if (connected && printer_ready) { ... } if (connected && !ble_is_connected()) { ... } vTaskDelay(pdMS_TO_TICKS(50 )); } }
标志位管理 核心原则:每个标志位只有一个写入方 。
1 2 3 4 串口屏按钮 ──→ sys_status.bluetooth_open (开关) 串口屏按钮 ──→ ble_connect_flag (连接触发) 串口屏按钮 ──→ ble_disconnect_flag (断开触发) ble_task ────→ sys_status.bluetooth_connecting (连接状态)
标志位
写入方
读取方
sys_status.bluetooth_open
tjc_uart2.c (UI 事件)
ble_task, key.c, hello_world_main.c
sys_status.bluetooth_connecting
ble_task (实际状态)
tjc_uart2.c, key.c
ble_connect_flag
tjc_uart2.c
ble_task
ble_disconnect_flag
tjc_uart2.c
ble_task
禁止 UI 直接写 sys_status.bluetooth_connecting ——这个标志反映真实 BLE 连接状态,只能由 ble_task 根据 GAP 事件结果更新。
连接失败的 UI 回传 蓝牙连接是异步操作,失败后需要恢复串口屏上 connecting 按钮的视觉状态:
1 2 3 4 5 6 7 static void ble_reset_screen_button (void ) { char cmd[32 ]; snprintf (cmd, sizeof (cmd), "click connecting,1" ); send_to_screen(cmd); snprintf (cmd, sizeof (cmd), "click connecting,0" ); send_to_screen(cmd); }
在 ble_task 的三处调用:连接超时、主动断开、被动断线。
click 翻转按钮 val=1 → val=0,屏幕回传 connecting=0。由于接收端只对 value==1 做处理,echo 天然安全,不需要额外的抑制逻辑。
设备列表管理 1 2 3 4 5 6 7 8 9 10 扫描发现设备 → ble_update_device_list() → RSSI < -85 ? 丢弃 → memcmp MAC 去重 ? 更新 RSSI → 添加新条目 → ui_scanned_count++ 用户按左/右键 → ble_prev_mac() / ble_next_mac() → ble_get_scanned_device(index) → ble_page_set_device_name() → 屏幕显示
扫描窗口参数优化后,filter_duplicates = 0 确保新设备快速出现在列表中。
关键设计决策
决策
原因
NimBLE 而非 Bluedroid
轻量 60KB vs 500KB,双角色够用
ble_task 50ms 轮询而非纯事件驱动
需要超时检测 + 心跳保活
ble_connect_flag 异步触发
解耦 UI 线程和 BLE 操作
click 回传而非 .val=
需要按钮 press/release 动画
filter_duplicates = 0
应用层去重,加快首次列表填充
WRITE_NO_RSP 而非 WRITE
打印数据量大,无响应减少往返
文件清单
文件
行数
职责
printer_scan.h
75
BLE 模块对外接口(12 个声明)
printer_scan.c
~1020
BLE 双角色 + 打印 + 设备列表(27 个函数)
tjc_uart2.c
~1300
串口屏通信 + 页面路由
key.c
~1150
物理按键 + 焦点导航
hello_world_main.c
~2500
主循环 + 采样校准
data.h
220
全局结构体定义
扫描“假死”的根因与修复 项目中最难定位的问题是扫描页偶发不再出现设备。底层 NimBLE 并没有崩溃,真正卡住的是应用状态:连接失败或页面切换后,ble_scan_enabled、bluetooth_connecting 等标志位没有经过同一条退出路径复位,后续扫描请求被上层提前拒绝。
修复没有继续增加补丁标志,而是做了三件事:
删除含义重叠的 bluetooth_connecting 和页面级连接标志;
把扫描、连接、已连接、断开收敛为显式状态迁移;
所有成功、超时、失败和用户取消路径都调用同一个清理函数。
简化后的状态只有:
1 2 3 4 IDLE → SCANNING → CONNECTING → CONNECTED ↑ │ │ │ └─────────┴───────────┴───────────┘ 失败 / 超时 / 取消
双角色 BLE 的状态原则 ESP32 同时作为 Peripheral 面向手机、作为 Central 连接打印机,两个角色不能共享一个模糊的“蓝牙已连接”变量。最终遵循以下原则:
手机连接和打印机连接分别建模;
回调只投递事件,不在回调中执行耗时 UI 或打印操作;
FreeRTOS 任务之间通过队列传递事件,状态只由所属模块写入;
页面只读取状态并发出命令,不直接修改 BLE 内部标志;
连接失败必须回传到串口屏,不能只在日志里结束。
为什么应用层去重 扫描参数保留 filter_duplicates = 0,由应用层根据地址去重。这样设备第一次广播时就能进入列表,不必等待 NimBLE 的过滤节奏;同时应用层可以更新 RSSI 和显示名称。代价是需要限制列表长度并定期淘汰过期设备。
结论 这次重构最大的收获不是解决了某一个扫描 bug,而是确认了嵌入式并发系统里“谁有权写状态”。当回调、页面、按键和主循环都能修改同一个标志时,增加再多日志也只能缓解问题;把状态所有权和迁移路径收紧,系统才真正可预测。