系统概览
1 2 3 4 5 6 7 8 9
| ┌──────────┐ BLE UART ┌──────────────┐ BLE GATT ┌──────────┐ │ 手机 App │ ◄──────────► │ ESP32-S3 │ ◄──────────► │ 热敏打印机 │ │ (Central) │ Peripheral │ (双角色桥接) │ Central │ (Peripheral) │ └──────────┘ │ │ └──────────┘ │ ┌────────┐ │ │ │TJC串口屏│ │ │ │ UART2 │ │ │ └────────┘ │ └──────────────┘
|
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 |
全局结构体定义 |