串口通信协议设计:ESP32-S3实现HDLC帧同步
在工业控制、传感器网络和嵌入式系统中,UART(通用异步收发器)几乎无处不在。它简单、省电、硬件成本低——但你也知道, “裸跑”串口数据的痛苦 。
你有没有遇到过这种情况?
- 接收到的数据总是“粘在一起”,分不清哪一包是完整的?
-
传感器传了个
0x7E,结果你的程序以为“帧结束了!”——其实啥都没完; - 噪声干扰导致几个比特翻转,整个命令错乱,设备进入诡异状态;
- 想传个固件升级包?别提了,校验全靠运气。
这些问题归根结底: 没有可靠的帧同步机制 。
而今天我们要聊的,就是一个能让串口“变聪明”的经典方案—— 基于 HDLC 的帧同步协议 ,并用 ESP32-S3 把它落地。
不是纸上谈兵,而是真正能在噪声环境里扛住、能处理任意二进制数据、还能自动检测错误的实战级设计。🚀
为什么传统串口这么“脆弱”?
先别急着上 HDLC,咱们得明白问题出在哪。
最常见的串口通信方式是什么?比如:
"TEMP:25.6\r\n"
或者更底层一点,直接发原始字节流,靠
\n
分隔。
听起来没问题对吧?但在真实世界里,这就像拿纸条传话给隔壁工位,中间经过十个爱改内容的人……
痛点一:帧边界模糊
假设你用
\n
当作结束符。但如果数据本身包含换行呢?比如日志输出、JSON 字符串、甚至固件中的随机字节?
那接收端就会提前截断,造成“半包”。
这就是所谓的 粘包/拆包问题 —— TCP 都有这个问题,何况是连连接都没有的 UART。
痛点二:特殊字符冲突
再比如,你想传一个二进制结构体:
struct sensor_data {
float temp;
uint8_t status;
uint8_t magic; // 假设值为 0x7E
};
好家伙,
magic == 0x7E
,正好是很多人默认的“帧头”。于是还没发完,接收方就喊:“收到一帧!”然后剩下的数据全乱了。
这不是 bug,这是设计缺陷。
痛点三:没人检查错误
UART 不带 CRC,也不重传。如果线路受干扰,哪怕只错了一个 bit,可能就把
SET_LED_ON(1)
变成
SET_LED_ON(0)
—— 用户看着灯突然灭了,一脸懵。
你说加个 checksum 吧,自己写又容易出错,还占时间。
所以,我们需要一种 标准化、健壮、可移植 的方法来封装串口数据。
答案就是: HDLC 。
HDLC 是什么?它怎么解决这些问题?
HDLC 全称是 High-Level Data Link Control ,ISO 制定的链路层协议,广泛用于 PPP、X.25、ISDN 等通信系统。
虽然原生 HDLC 是“面向比特”的(bit-oriented),需要做
位填充(bit stuffing)
,比如连续五个
1
插一个
0
,防止出现标志序列……但这对 MCU 来说太重了。
所以我们用的是它的轻量变种: 基于字节的 HDLC ,也叫 Byte-Stuffed HDLC 或 PPP-HDLC ,专为串口这类字节流接口优化。
它的核心思想就三点:
- 用唯一标志界定帧边界
- 通过转义机制实现透明传输
- 用 CRC 校验保证完整性
我们一个个来看。
🟩 帧定界:用
0x7E
找到起点和终点
HDLC 使用一个特殊的字节作为帧的开始和结束标志:
🔹
0x7E→ 二进制01111110
这个值选得很讲究:既不太常见(不像
0x00
或
0xFF
那样容易出现在数据中),又足够独特,不容易被误匹配。
每一帧长这样:
[0x7E] [Address?] [Control?] [Payload...] [CRC_H] [CRC_L] [0x7E]
接收端的任务很简单:从字节流里找
0x7E
,一旦找到,就开始积累后续数据,直到下一个
0x7E
出现,表示帧结束。
但问题来了:如果 payload 里恰好也有
0x7E
怎么办?
这时候就要靠第二个关键技术: 字节转义(Byte Stuffing) 。
🔁 透明传输:让数据“隐身”的转义规则
为了避免数据中的
0x7E
和
0x7D
被误解为控制字符,HDLC 引入了一个转义机制:
| 原始字节 | 发送时转为 |
|---|---|
0x7E
|
0x7D 0x5E
|
0x7D
|
0x7D 0x5D
|
注意!这里有个技巧:不是简单替换,而是采用 异或 0x20 的方式。
也就是说:
-
0x7E ^ 0x20 = 0x5E
-
0x7D ^ 0x20 = 0x5D
所以规则可以统一描述为:
如果当前字节是
0x7E或0x7D,则先发送0x7D,再发送该字节 XOR 0x20。
发送端编码时执行这个过程,接收端解码时反向操作即可。
这样一来,无论你要传 JPG、音频片段、加密密钥还是随机噪声,都可以安全穿过串口而不破坏帧结构。
这就是所谓的“透明传输”——上层完全感知不到底层的存在。
✅ 差错检测:CRC-16-CCITT 来兜底
最后一步,防错。
HDLC 在帧尾加上两个字节的 FCS(Frame Check Sequence),通常是
CRC-16-CCITT
,多项式为
x^16 + x^12 + x^5 + 1
,初始值
0xFFFF
。
计算方式如下:
uint16_t crc16_ccitt(const uint8_t *data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; ++i) {
crc ^= data[i] << 8;
for (int j = 0; j < 8; ++j) {
if (crc & 0x8000)
crc = (crc << 1) ^ 0x1021;
else
crc <<= 1;
}
}
return crc;
}
接收端收到完整帧后,把 payload + 地址 + 控制等所有字段重新算一遍 CRC,和接收到的两个字节对比。如果不一致,说明传输过程中出了错,直接丢弃!
比简单的 checksum 强太多了——CRC 能检测出单 bit 错、双 bit 错、奇数个错误、突发错误(≤16bit)等等。
对比一下:普通分隔符 vs HDLC
| 特性 | 分隔符法(如 \n) | 长度前缀法 | HDLC |
|---|---|---|---|
| 是否支持二进制 | ❌ | ✅ | ✅✅✅(完全透明) |
| 帧同步精度 | 低(易误判) | 中 | 高(唯一标志 + CRC) |
| 抗干扰能力 | 极弱 | 一般 | 强 |
| 实现复杂度 | 极简 | 简单 | 中等 |
| 适用场景 | 调试打印 | 固定格式通信 | 工业、远程、高可靠场景 |
看到没?当你需要稳定、长期运行、不能轻易现场调试的系统时,HDLC 几乎是必选项。
为什么选 ESP32-S3?它适合干这事吗?
你可能会问:这么“古老”的协议,现在还有必要用吗?特别是有了 Wi-Fi 和蓝牙之后?
当然有必要!
很多工业设备、传感器、PLC、电表水表、电机控制器……它们仍然使用 RS485/UART 接口。而 ESP32-S3 正好是个绝佳的“桥梁型”MCU。
来看看它的优势👇:
💡 双核 Xtensa LX7 @ 240MHz
- 主频高,处理 CRC 和状态机绰绰有余;
- 一个核心跑 Wi-Fi/MQTT,另一个专注解析串口帧,互不干扰。
📦 多 UART + DMA 支持
- 支持最多 3 个 UART;
- RX/TX 均支持 DMA,避免频繁中断拖慢 CPU;
- FIFO 深达 128 字节,缓冲能力强。
🧠 内存充足:512KB SRAM + 384KB ROM
- 足够容纳多个 HDLC 实例、环形缓冲区、协议栈;
- 不用担心堆溢出或频繁 malloc/free。
⏱ FreeRTOS 原生支持
- 可以轻松创建独立任务处理串口输入;
- 结合队列(queue)机制,实现解帧后异步上报。
🌐 自带 Wi-Fi & BLE
- 解析完 HDLC 帧后,可通过 MQTT 上云;
- 或者作为网关,桥接 Modbus RTU 到 HTTP API。
一句话总结: ESP32-S3 不仅能胜任 HDLC 协议实现,还能把它变成一个智能边缘节点。
代码怎么写?状态机才是王道!
好了,理论讲完了,咱们动手写点真东西。
目标很明确:在一个 FreeRTOS 任务中,持续读取 UART 数据流,并从中提取出合法的 HDLC 帧。
关键挑战在于: 数据是逐字节到达的 ,你不能假设每次都能收到完整帧。
所以我们必须维护一个“解析状态”,记住目前处于什么阶段。
设计一个 HDLC 接收状态机
我们定义几种状态:
typedef struct {
uint8_t buffer[MAX_FRAME_LEN]; // 存放当前帧数据
int index; // 当前写入位置
bool escaping; // 是否正处于转义状态
bool frame_started; // 是否已收到起始 flag
} hdlc_rx_state_t;
然后每来一个字节,调用一次
hdlc_parse_byte()
,根据当前状态决定如何处理。
核心逻辑流程图(文字版)
收到 byte:
│
├─ 是 0x7D? → 设置 escaping = true,return false
│
├─ 是 escaping 状态?
│ └─ byte ^= 0x20,清除 escaping 标志
│
├─ 是 0x7E?
│ ├─ 如果 frame_started 且 index > 0:
│ │ └─ 尝试校验 CRC → 成功则返回完整帧
│ ├─ 重置 buffer,index=0,frame_started=true
│ └─ return false
│
└─ 其他字节:
└─ 若 frame_started,则存入 buffer[index++]
是不是很清晰?这就是典型的事件驱动状态机。
完整 C 模块实现
hdlc.h
#ifndef HDLC_H
#define HDLC_H
#include <stdint.h>
#include <stdbool.h>
#define HDLC_FLAG 0x7E
#define HDLC_ESCAPE 0x7D
#define HDLC_XOR 0x20
#define MAX_FRAME_LEN 1024
typedef struct {
uint8_t buffer[MAX_FRAME_LEN];
int index;
bool escaping;
bool frame_started;
} hdlc_rx_state_t;
void hdlc_init(hdlc_rx_state_t *state);
bool hdlc_parse_byte(hdlc_rx_state_t *state, uint8_t byte, uint8_t **frame, int *len);
uint16_t crc16_ccitt(const uint8_t *data, size_t len);
#endif // HDLC_H
hdlc.c
#include "hdlc.h"
#include <string.h>
void hdlc_init(hdlc_rx_state_t *state) {
memset(state, 0, sizeof(*state));
}
uint16_t crc16_ccitt(const uint8_t *data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; ++i) {
crc ^= data[i] << 8;
for (int j = 0; j < 8; ++j) {
if (crc & 0x8000)
crc = (crc << 1) ^ 0x1021;
else
crc <<= 1;
}
}
return crc;
}
bool hdlc_parse_byte(hdlc_rx_state_t *state, uint8_t byte, uint8_t **frame, int *len) {
// 处理转义字符
if (byte == HDLC_ESCAPE) {
state->escaping = true;
return false;
}
// 如果前一个是转义符,则对当前字节进行 XOR 解码
if (state->escaping) {
byte ^= HDLC_XOR;
state->escaping = false;
}
// 处理标志符 0x7E
if (byte == HDLC_FLAG) {
// 已经开始了一帧,并且已经有数据 -> 可能是一帧结束
if (state->frame_started && state->index > 0) {
// 至少要有 CRC 两个字节
if (state->index >= 3) {
uint16_t received_crc = (state->buffer[state->index - 2] << 8) | state->buffer[state->index - 1];
uint16_t calc_crc = crc16_ccitt(state->buffer, state->index - 2);
if (received_crc == calc_crc) {
*frame = state->buffer;
*len = state->index - 2; // 去掉 CRC
return true; // 成功解析出一帧!
}
}
}
// 无论是否成功,都开启新帧或重置
state->index = 0;
state->frame_started = true;
return false;
}
// 不在帧内?跳过
if (!state->frame_started) {
return false;
}
// 缓冲区未满则保存数据
if (state->index < MAX_FRAME_LEN - 1) {
state->buffer[state->index++] = byte;
} else {
// 超长帧,强制重置
state->frame_started = false;
}
return false;
}
这段代码有几个亮点:
- 零动态分配 :所有内存静态分配,适合嵌入式;
-
防溢出保护
:超过
MAX_FRAME_LEN直接放弃当前帧; -
状态隔离
:每个 UART 通道可用独立的
hdlc_rx_state_t实例; - 非阻塞设计 :每次只处理一个字节,不影响实时性。
主循环怎么配合?FreeRTOS + UART DMA
接下来是
main.c
中的集成部分。
我们要做到:
- 非阻塞读取 UART;
- 高效喂给 HDLC 解析器;
- 解出完整帧后交给其他模块处理。
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/uart.h"
#include "hdlc.h"
#define UART_PORT UART_NUM_1
#define BUF_SIZE (256)
static hdlc_rx_state_t rx_state;
void uart_init(void) {
const uart_config_t uart_cfg = {
.baud_rate = 115200,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
.source_clk = UART_SCLK_DEFAULT,
};
uart_param_config(UART_PORT, &uart_cfg);
uart_driver_install(UART_PORT, BUF_SIZE * 2, 0, 0, NULL, 0);
}
void hdlc_task(void *pvParameters) {
uint8_t *frame;
int len;
uint8_t temp_buf[BUF_SIZE];
hdlc_init(&rx_state);
while (1) {
int bytes_read = uart_read_bytes(UART_PORT, temp_buf, BUF_SIZE, pdMS_TO_TICKS(10));
if (bytes_read <= 0) continue;
for (int i = 0; i < bytes_read; i++) {
if (hdlc_parse_byte(&rx_state, temp_buf[i], &frame, &len)) {
// 🎉 成功收到完整帧!
printf("✅ HDLC Frame Received: len=%d, data=[", len);
for (int j = 0; j < len; j++) {
printf("%02X ", frame[j]);
}
printf("]\n");
// TODO: 提交到队列、转发MQTT、触发回调...
}
}
}
}
void app_main(void) {
uart_init();
xTaskCreate(hdlc_task, "hdlc_parser", 4096, NULL, 10, NULL);
}
几点说明:
-
uart_read_bytes()使用 IDF 提供的环形缓冲 + DMA,效率极高; -
超时设为
10ms,平衡延迟与 CPU 占用; -
成功解析后的帧可以通过
xQueueSend()发给其他任务处理,避免阻塞解析线程; - 日志输出可用于调试,正式发布时建议关闭。
实际应用场景:做个工业网关怎么样?
设想这样一个系统:
[RS485 温湿度传感器]
↓ (Modbus RTU over UART)
[ESP32-S3] ——(Wi-Fi)——> [MQTT Broker] ——> [云端 Dashboard]
传感器每隔 5 秒发一次数据,格式如下:
struct __attribute__((packed)) sensor_packet {
uint16_t device_id;
float temperature;
float humidity;
uint32_t timestamp;
};
这些数据被打包进 HDLC 帧发送:
0x7E [DATA...] [CRC_H] [CRC_L] 0x7E
ESP32-S3 收到后解帧、验证 CRC、提取 payload,然后转成 JSON:
{
"id": 101,
"temp": 25.6,
"humi": 60.2,
"ts": 1712345678
}
再通过 MQTT 发布到主题
sensors/room_1
。
整个过程全自动,即使线路干扰导致某帧 CRC 错误,也只是丢失一次采样,不会引发连锁崩溃。
而且将来换别的传感器?只要遵循 HDLC 封装,无需修改 ESP32-S3 的解析逻辑!
工程实践中要注意哪些坑?
别以为代码跑通就万事大吉。真实项目中还有很多细节要打磨。
⚠️ 1. 波特率必须匹配!
哪怕差 1%,也可能导致接收失败,尤其是在高速波特率下(如 460800 或 921600)。
建议:
- 两端使用相同晶振;
- 或启用自动波特率检测(某些 STM32 支持);
- 开发阶段用逻辑分析仪抓波形确认。
⚠️ 2. 加超时机制,防止状态卡死
想象一下:刚收到
0x7E
,开始一帧,但后面一直收不到结束符。可能是对方断电、数据中断、或是干扰严重。
这时你的
index
会一直增长,浪费内存,甚至越界。
解决方案: 定时检查是否有“进行中”的帧,长时间未完成则重置。
例如,在
hdlc_task
中加入:
TickType_t last_byte_time = 0;
// 在每次 parse_byte 后:
if (state->frame_started) {
if (xTaskGetTickCount() - last_byte_time > pdMS_TO_TICKS(20)) {
// 超过 20ms 没收到新数据,重置
state->frame_started = false;
state->index = 0;
state->escaping = false;
}
} else {
last_byte_time = xTaskGetTickCount();
}
这样就能有效防止“半帧堆积”。
⚠️ 3. 使用 ring buffer + DMA 提升性能
如果你的波特率很高(>57600),强烈建议开启 UART DMA。
IDF 默认安装驱动时就可以配置:
uart_driver_install(UART_PORT, 4096, 4096, 10, &uart_queue, 0);
然后配合事件队列监听
UART_DATA
事件,减少轮询开销。
还可以结合
ringbuf
组件做二级缓存,避免
uart_read_bytes
频繁拷贝。
⚠️ 4. 避免在中断里做复杂运算
虽然你可以把
hdlc_parse_byte
放在 ISR 里,但不推荐!
原因:
- CRC 计算是 CPU 密集型;
- 状态机逻辑虽短,但频繁调用会影响其他中断响应;
- FreeRTOS 不鼓励在 ISR 中调用非 ISR-safe 函数。
最佳实践:ISR 只负责把数据搬进缓冲区,主任务慢慢消化。
⚠️ 5. 日志分级,方便调试
开发阶段打开详细日志:
#define HDLC_DEBUG_PRINTF(fmt, ...) printf("[HDLC] " fmt "\n", ##__VA_ARGS__)
上线后关闭,或改为通过特定命令开启。
也可以通过 GPIO 指示灯闪烁次数反映状态:
- 快闪:正在接收;
- 双闪:CRC 错误;
- 长亮:系统异常。
还能怎么扩展?让它更强大!
基础版 HDLC 已经很强了,但我们还可以继续升级。
🔹 多实例支持
如果有多个 UART 接口(比如 UART1 接传感器,UART2 接显示屏),可以为每个通道创建独立的
hdlc_rx_state_t
实例:
hdlc_rx_state_t states[3]; // 对应 UART0/1/2
各自运行自己的解析任务,互不干扰。
🔹 加 ACK/NACK 实现可靠传输
标准 HDLC 支持信息帧(I-frame)、监控帧(S-frame)、无编号帧(U-frame)。
我们可以扩展为:
- 发送方每帧带 sequence number;
- 接收方成功后回
ACK(seq)
;
- 发送方超时未收到 ACK,则重发。
这就变成了一个简易的 ARQ 协议,适用于不可靠链路(如无线串口)。
🔹 支持压缩与加密
既然已经做了封装,不妨再进一步:
- 使用 LZ4 压缩 payload,节省带宽;
- AES 加密敏感数据,提升安全性;
- 甚至支持 OTA 固件分片传输。
🔹 与 LwIP 整合,统一接口
ESP-IDF 提供了
netbuf
和
netconn
接口,原本用于 TCP/IP。
我们可以模仿其风格,抽象出一个
linkbuf
层,让上层应用无需关心数据是从 Wi-Fi 还是 UART 来的。
struct linkbuf *lb = linkbuf_alloc(len);
memcpy(lb->payload, data, len);
link_output(lb); // 自动选择走 Wi-Fi 还是 UART-HDLC
这才是真正的“协议无关化”设计。
写在最后:有时候,“老技术”才是真香
HDLC 看起来有点“古董”——毕竟它诞生于上世纪七十年代。
但正是这种经过几十年考验的协议,才能在恶劣环境下依然坚挺。
它不像 MQTT 那样时髦,也不像 gRPC 那样高性能,但它足够简单、足够可靠、足够标准化。
而在嵌入式世界里, 简单和可靠,往往比一切花哨更重要 。
ESP32-S3 的出现,让我们可以用很低的成本,把这种经典协议发挥到极致。
不再只是“能通”,而是“稳通”。
下次当你面对一堆乱七八糟的串口数据时,不妨试试 HDLC。
也许你会发现:原来串口也可以这么优雅地工作。✨
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
1万+

被折叠的 条评论
为什么被折叠?



