一、为什么“帧同步”比“帧解析”难
我刚开始写串口协议的时候,以为难点在解析:把字节拼成结构体、处理大小端、把字符串转成数字。 写的项目多了才发现,解析其实是整条链路上最舒服的一段——它的输入是明确的, 一帧数据摆在面前,怎么拆都有标准答案。
真正难的是它前面那一步:从一条没有边界的字节流里,切出一帧来。 串口只保证“字节按顺序到达”,不保证“帧与帧之间有标记”。所以每一份自定义串口协议里, 都必须回答同一个问题:收到第几个字节的时候,我可以确定这一帧收完了?
这个问题答错的代价,比 CRC 校验失败大得多。答错只有三种可能:
- 切早了:只收到半帧就去解析,表现为“CRC 全部失败的间歇性丢帧”;
- 切晚了:把两帧粘成一帧,表现为“偶尔整段报文解不动”,而且一丢就是两条命令;
- 切错位:帧头识别错一个字节,后面全部错位,直到总线上出现一段足够长的空闲才恢复。
三种表现都长得像“通信不稳定”,排查方向却完全不同。这篇笔记里的四种做法, 分别来自我做过的一块 LED 广告屏控制卡(自定义 RS485 帧 + JSON 报文)、 一台液体加注控制器(用 4G 模组、靠超时收 AT 应答)、 一台工业压力变送器(Modbus-RTU 从站 + RS485 升级通道)。 我把它们按“判据”分类,而不是按“哪个项目”分类,这样更容易看出各自适合什么场景。
二、方案一:长度字段主动闭合
最省事的做法是在帧头后面直接写清楚“这一帧有多长”。接收方读长度字段、数够字节数, 这一帧就闭合了,完全不需要等超时,也不需要额外的定时器。
我在 LED 广告屏控制卡上用的就是这个结构(帧格式见下表,这是我自己定的协议):
| 偏移 | 长度 | 内容 | 说明 |
|---|---|---|---|
Byte[0] | 1 B | 帧头字节(兼作站号) | 取值由设备地址决定;不等于本机地址就丢弃整帧 |
Byte[1] | 1 B | 固定标识字节 | 第二道帧头确认;不匹配就清零重新找帧头 |
Byte[2..3] | 2 B | LEN(小端) | payload 长度;帧总长 = LEN + 6 |
Byte[4..LEN+3] | LEN B | JSON 文本(ASCII) | 命令体,用 cJSON 解析 |
Byte[LEN+4] | 1 B | CRC16 低字节 | 小端,先发低字节 |
Byte[LEN+5] | 1 B | CRC16 高字节 | 校验范围覆盖帧头 + 长度 + payload |
接收侧是一个逐字节推进的状态机。这里我只给出它的状态迁移表——什么条件下做什么, 属于协议契约;至于“怎么写成代码”,每个字节的比较和搬运都是很直接的实现, 我不想把这一层也照抄出来:
| 当前状态 | 收到什么 | 动作 | 下一状态 |
|---|---|---|---|
| 找帧头 | 与本站号不符的字节 | 不缓存、不动作,直接忽略 | 找帧头 |
| 找帧头 | 与本站号相符的字节 | 存入缓冲,已收长度记为 1 | 等固定标识 |
| 等固定标识 | 约定的固定标识字节 | 存入缓冲,已收长度记为 2 | 收数据 |
| 等固定标识 | 任何其它字节 | 丢弃已缓存内容,长度清零 | 找帧头 |
| 收数据 | 任意字节,且长度未到缓冲上限 | 存入缓冲,长度加一 | 收数据 |
| 收数据 | 任意字节,但长度已到缓冲上限 | 丢弃整帧,长度清零 | 找帧头 |
| 收数据 | 按长度字段收满,或总线空闲超时 | 交给解析:先校验长度,再整体算 CRC | 找帧头 |
| 任意状态 | 总线空闲超过阈值 | 丢弃当前半帧,长度清零 | 找帧头 |
这张表比一段代码长,但它把“判断”和“搬运”彻底分开了: 只有前两个字节需要比较,之后每个字节都只是搬运, 所以每个字节的处理时间是常数,也不依赖任何定时器。 这个性质在中断里尤其重要——中断服务函数里最不该出现的, 就是“根据数据内容决定这一次要做多少事”。
表里有两处是我后来改过的。第一处是缓冲上限:我原来的写法是“存满之后把下标夹在缓冲末端”, 但夹住下标之后,后面继续到达的字节会一直覆写同一个位置,帧内容其实已经废了, 不如当场丢帧重来——丢帧的代价只是重收一帧, 而夹住下标的代价是把一帧已经错位的数据当成好数据往下送。 第二处是帧头:我一开始只用了一个字节做帧头, 后来发现单字节帧头在多机总线上很容易被 payload 里的同值字节冒充, 于是加了第二个固定字节——它不承担任何信息,只负责证明“这确实是帧头”。 这也解释了为什么表里要把帧头做成两道确认、而不是一道: 多这一个字节,换掉的是“帧头认错”这一整类故障。
长度字段方案的代价
长度字段方案最要命的一点是:它把整帧的正确性押在“帧头对齐”上。 一旦在错误的偏移上开始收,长度字段读到的是 payload 里的某个字节, 接下来数的字节数也全错,整条链路要等一次足够长的总线空闲才能恢复。 而且长度字段本身如果被干扰成一个很大的值,缓冲区就危险了——所以上限判断不是可选项。
我的经验是,用长度字段就必须同时做三件事:帧头确认(两个字节以上)、长度上限检查、 CRC 覆盖全帧。三件事都做了,长度字段方案就非常稳; 少任何一件,它都会在某个现场变成“偶发丢帧”。
三、方案二:字节间超时(3.5 字符时间的由来与工程近似)
Modbus 的串行链路规范里给了一个不依赖长度字段的答案: 帧内字节之间的间隔不能超过 1.5 个字符时间,帧与帧之间的静默至少要 3.5 个字符时间。 换句话说,“总线静了一会儿”本身就意味着“上一帧结束了”。 这个做法的妙处是:协议里再也不需要长度字段,任何长度的报文都能收。
3.5 个字符时间到底是多少?以 9600 bps、8N1 为例算一遍: 一个字符 = 1 位起始 + 8 位数据 + 1 位停止 = 10 bit;每位 1/9600 ≈ 104 µs; 所以一个字符 ≈ 1.04 ms,3.5 个字符 ≈ 3.65 ms。 同样的算法,115200 bps 下一个字符 ≈ 86.8 µs,3.5 个字符 ≈ 0.30 ms。
但我在工程里从来不用 3.65 ms 这个理论值,而是用一个整数节拍去近似。 控制卡上跑的是 1 ms 的 FreeRTOS 节拍,做法是: 节拍中断里只要这一拍没有新字节到达,就把空闲计数器加一; 通信任务看到计数器达到我设定的阈值,才认为一帧结束。 这个阈值我取的是一个比理论值更保守的档位—— 宁可多等一点点,也不要在字节间抖动的时候把一帧切断。
/* 1 ms 节拍中断:这一拍没有新字节到达,空闲计数加一 */
void SysTick_IrqHandler(void) { if (!byte_seen_this_tick) { idle_ticks++; } }
/* 通信任务里周期轮询:计数不到阈值就继续等(阈值按波特率算,不写死) */
void frame_poll(void)
{
if (idle_ticks < IDLE_TICKS_END) { return; }
unpack(); /* 帧头、长度、CRC 都在这里校验 */
}为什么不用理论值,而要往上取一个更保守的档位?我的理由有三条,都跟“工程余量”有关:
- 节拍是整数毫秒。理论值算出来是 3.65 ms,而 1 ms 的节拍只能数出整数个毫秒, 向上取整本身就已经比理论值宽了一档;
- 要留抖动余量。上位机如果是 PC 加 USB 转串口,字节之间的间隔受驱动和调度影响, 几十到几百微秒的抖动很常见,贴着理论值取太贴边;
- 要留处理余量。中断里收到字节到置标志之间也有时间,极端情况下会挤占计时窗口。
代价也要一起算:我取的这个档位在 9600 bps 下大约是 4.8 个字符时间,比规范的 3.5 稍宽,够用; 但同一个毫秒数换到 115200 bps,就相当于几十个字符时间了——如果上位机连续发多条命令、 帧间静默只有一两毫秒,这个阈值就会把两帧粘在一起。 这一点我在调上位机时踩过:固定毫秒阈值必须和波特率、上位机的发送节奏一起核对, 不能只写一个毫秒数就认为它通用。
同样的思路也用在液体加注控制器的 4G 模组通信上:那台设备收的是模组的 AT 应答, 我把“一段时间内没有新字节”当作一帧结束——这个时间在代码里是一个宏,不写死在判断里, 改波特率的时候只需要改一处;再配上一个小深度的消息队列和有限次重试。 这类模组的应答长度不固定、也没有长度字段,超时判帧是最省事的选择。
字节间超时最容易错的两个细节
- 清零的位置:空闲计数器必须在“收到字节”的那条路径上清零,而且要在中断里清, 不能等任务去清。我个人的习惯是在串口接收中断里把计数清零、把“有新字节”标志置上, 节拍中断只负责加一。
- 只触发一次:帧结束是一个“边沿”,不是一个“电平”。 如果解析任务是周期轮询的,它在计数器越过阈值之后会反复看到“帧结束了”, 所以解析完成后必须把计数器和缓冲下标一起清零,否则同一帧会被解析好几遍。
四、方案三:串口空闲中断与软件模拟
很多 MCU 的串口外设自带“空闲中断”(IDLE):总线在一段时间内没有新数据时硬件直接给一个中断。 它等于是把“字节间超时”这件事从软件搬到了硬件,软件不用再自己数节拍。 我在工业压力变送器的升级通道里用的就是它,而且还加了第二层保险: 每收到一个字节就复位一次超时定时器,同时空闲中断单独置标志。
/* 每收一个字节:存缓冲 + 重置超时定时器(第一层判据) */
void uart_rx_isr(void)
{
/* RX_BUF_MAX 按协议最大帧长设定,不用产品里的固定数值 */
if (rx_len < RX_BUF_MAX) { rx_buf[rx_len++] = uart_read_byte(); }
timer_restart(&frame_timer); /* 收到字节就重新计时 */
if (uart_idle_flag()) { frame_idle = 1; } /* 第二层:硬件空闲标志 */
}两层判据的意义不一样:硬件空闲中断响应快、不占 CPU,但它的门限是硬件固定的; 超时定时器慢一点,但它是我自己能控制的。两个都置上了,才认为一帧收完去处理。 这也解释了为什么这条路走的是“整包接收”:那版升级通道用一个能装下整个固件包的静态缓冲, 一次收完再统一写 Flash。好处是逻辑极简单、不需要边收边写; 坏处是它吃掉了相当大一块 RAM,而且接收计数当时没有做上界检查—— 这是一个我知道、但当时没有改的隐患。
需要注意的两个移植坑:
- IDLE 标志怎么清。各家 MCU 不一样,多数要求“先读状态寄存器、再读数据寄存器”。 清不干净的表现是中断反复进,看起来像总线一直在收数据。
- IDLE 的门限比 3.5 字符时间短。我个人的理解是它大约在“一个字符时间”量级就触发, 所以严格要符合 Modbus 的 3.5T 规定时,不能只靠 IDLE,还得自己再计时; 反过来,如果协议是自己定的、只要求“分得开帧”,IDLE 就足够好用。
软件模拟空闲中断
如果 MCU 没有 IDLE 中断,或者你不想依赖它,那就用方案二里的办法自己数节拍—— 本质上就是“软件模拟空闲中断”。液体加注控制器里的毫秒级判帧就是这么做的: 它跑在 FreeRTOS 里、用 1 ms 节拍累计,和硬件 IDLE 的效果一样,代价是判定时刻会有 一个节拍到几个节拍的抖动,而且如果判定任务被更高优先级任务长时间抢占,判定还会更晚。 对一个只收 AT 应答的场景来说,这点延迟无所谓;但如果帧间静默本来就只有一两毫秒, 这个抖动就会变成实打实的丢帧。
五、方案四:帧头搜索
前面三种方案都有一个隐含前提:总线上除了合法帧,没有别的东西。 但现场往往不是这样——上电瞬间的电平毛刺、别人调试时插进来的报文、 波特率不匹配时产生的一串垃圾字节,都会让“等空闲分帧”分出一段半截数据。
工业压力变送器的从站解析用的是另一种思路:不假设对齐,直接在接收缓冲里搜帧头。 它的做法是:在接收缓冲里找第一个帧头字节——它可能是协议约定的广播站号,也可能是本机站号, 找到就把它当作帧的起点,从这之后按命令码分派; 同时用一路毫秒级的定时器事件做超时清缓冲:一段时间没有新数据就把整段丢掉。
广播站号是协议里保留的一个地址值,本机站号是设备自己的编号,两者共用一套解析流程, 所以同一条命令既能“一条控制全部设备”,也能“单独寻址某一台”。 这个设计很好用,代价是地址判断被塞进了帧头搜索里,代码读起来比前三种方案费劲。
帧头搜索的失败模式很明确:payload 里出现与帧头相同的字节时,搜索可能从一个“假帧头”开始。 它唯一的兜底就是 CRC——校验不过就丢弃,然后从下一个字节继续找。 所以这个方案有两个硬要求:
- 只从缓冲区起点找第一个帧头,不要“找到哪个用哪个”,否则每次解析的起点都不一样;
- CRC 必须覆盖帧头。如果校验只算 payload,假帧头就会一路被当成真帧处理下去。
我现在的做法是把帧头搜索和长度字段结合起来:先搜帧头, 读到长度字段后定位整帧,再整体算 CRC;校验不过就把起点往后挪一个字节重新搜。 多花的那点 CPU,换来的好处是总线上再乱,设备也能自己重新对齐, 不需要人工断电重启。
六、四种方案的对比与选择建议
把四种做法放在一张表里,最容易看出它们各自在“赌什么”:
| 方案 | 判据 | 我实际用在哪 | 优点 | 主要代价与失败模式 |
|---|---|---|---|---|
| 长度字段主动闭合 | 帧头后的 LEN 字段 | LED 广告屏控制卡(帧总长 = LEN + 6) | 不用等超时,收完立刻可解析;CPU 开销恒定;不依赖定时器精度 | 长度字段被干扰就整帧错位;必须配上长度上限与全帧 CRC |
| 字节间超时 | 帧间静默 ≥ 3.5 字符时间 | LED 广告屏控制卡(1 ms 节拍累计空闲)、液体加注控制器的模组通信(毫秒级空闲阈值) | 不需要长度字段,任意长度报文都能收;实现简单 | 阈值与波特率、上位机节奏强耦合;帧间太密会粘包,发送方卡顿会截断 |
| 空闲中断 / 软件模拟 | 硬件 IDLE 标志,或软件累计节拍 | 工业压力变送器的 RS485 升级通道(IDLE + 定时器重置双保险) | 响应快、不占 CPU;配合整包缓冲最好写 | IDLE 门限比 3.5T 短;标志清除方式因芯片而异;大缓冲吃 RAM |
| 帧头搜索 | 在缓冲里找地址字节 | 工业压力变送器的 Modbus 从站(广播站号 + 本机站号双寻址) | 总线上有杂音也能自己重新对齐;广播与单播共用一套流程 | 搜索要遍历缓冲;假帧头只能靠 CRC 兜底;代码可读性下降 |
我的选择顺序
- 先问“最坏情况下我会丢掉什么”。丢掉一条查询命令可以重发,丢掉一条写参数命令可能就写坏了, 这决定了你能接受多高的误判率。
- 单主机、问答式、报文短(几十到几百字节):长度字段 + 全帧 CRC,最省事, 我在广告屏控制卡上就是这么做的。
- 报文长度不定、对端是 PC 或模组、帧间天然有静默:字节间超时。 记得把阈值和波特率一起算,别把某一个毫秒数当成万能值。
- MCU 有 IDLE 中断、而且本来就要收整包:硬件空闲中断 + 定时器兜底。 注意给接收计数加上界检查,我在这一条上留过隐患。
- 总线上有第三方设备、或者要兼容旧协议:帧头搜索 + CRC 全帧校验。 多花一点 CPU,换“自己能从混乱里恢复”的能力。
最后一点也是我最想强调的:这四种方案不是互斥的。 广告屏控制卡上我实际上用了三种——两字节帧头 + 长度字段 + 毫秒级空闲超时, 外面再套一层覆盖全帧的 CRC。四种判据互为兜底听起来很啰嗦, 但它的好处是排查时每一条都能单独验证:把超时阈值临时改大、看长度字段、看 CRC 结果, 三步就能定位到底是哪一层出的问题。
七、RS485 半双工:方向脚切换与帧同步的耦合
RS485 是半双工,收发共用一对差分线,所以必须有一个 GPIO 控制收发器的方向。 这段代码只有两行,但它和帧同步是直接耦合的——方向脚切错的后果, 往往就表现为“帧同步失效”。
广告屏控制卡上的做法是“收完立刻禁收”:解析函数一进来就把方向脚切到发送态 (等于关掉接收),整帧处理完、应答发完之后,再切回接收态。 这么写的原因是半双工下自己发出去的数据会回灌到自己的 RX 上, 如果一边处理一边还允许接收,这些回波会被当成新帧的开头,把空闲计时和帧头状态全部搅乱。 代价也很直接:处理期间收不到新帧。 我的规避方式是把协议做成问答式——上位机收到应答才发下一条, 这样“处理期间丢帧”就永远不会发生。但如果哪天换成“上位机定时轮询、不等应答”, 这套逻辑就必须改。
压力变送器那边的写法更朴素:发送函数把所有字节丢完之后,固定延时 10 ms 再切回接收。 10 ms 这个数字是有依据的——9600 bps 下一个字节约 1.04 ms,10 ms 足够让移位寄存器排空, 也足够让对方(如果它抢发)反应过来。它的代价是延时是硬编码的: 参数表里波特率是可配的,但延时没有跟着波特率走。
八、CRC 放在哪:校验范围一定要覆盖帧头
CRC 放在帧尾是通行做法,但“从哪个字节开始算”这件事,我在两个项目里的答案是一样的: 从帧头第一个字节开始算,覆盖帧头、长度字段和全部 payload。
理由很实在:如果 CRC 只覆盖 payload,那么帧头被干扰成另一个地址时, 一台本不该响应的设备可能“正确地”处理了一条不属于它的命令——因为 payload 是完好的, 校验也能通过。覆盖帧头之后,任何位置被改动都会让整帧校验失败, 从站直接丢弃即可,不需要再讨论“这帧到底算不算数”。
我用的校验算法是 Modbus 的 CRC-16:多项式 0xA001(0x8005 的反转),
初值 0xFFFF,输入输出都反转,结果不异或。
实现上用的是 256 项的双表查表法(高字节表 / 低字节表各一份),
每字节只需要一次异或和两次查表:
/* CRC-16/MODBUS:双 256 项查表(auchCRCHi[] / auchCRCLo[]),初值 0xFFFF */
static uint16_t crc16_modbus(const uint8_t *msg, uint16_t len)
{
uint8_t crc_hi = 0xFF, crc_lo = 0xFF;
uint16_t i, index;
for (i = 0; i < len; i++) {
index = (uint16_t)(crc_hi ^ msg[i]);
crc_hi = (uint8_t)(crc_lo ^ auchCRCHi[index]);
crc_lo = auchCRCLo[index];
}
return (uint16_t)((crc_lo << 8) | crc_hi); /* 字节序见下面的说明 */
}这一段我只留算法本身,不贴调用它的那几行:校验范围是协议契约, 讲清楚就够了——从帧头第一个字节开始,依次算过长度字段和全部 payload, 总共多少字节由长度字段推出来,调用处不写死任何一个数字。 把范围写死在调用处,正是前面说的“两端各写一遍、然后对不上”的根源。
注意上面这个函数的返回顺序:它是“低字节在高位”。这在工程内部是自洽的——
发送时先发低字节,接收时按同样方式拼回来比对,怎么都不会错。
但跟第三方上位机对接时,字节序必须双方确认。
压力变送器那边我就见过一个很典型的写法:变量 check_crc_h 存的是
crc % 256(其实是低字节),变量 check_crc_l 存的是
crc / 256(其实是高字节),命名和内容正好相反,
于是发送顺序看起来也“反着”。现象是:设备自己和自己通信完全正常,
一旦接上按标准 Modbus(低字节先发)写的上位机就对不上。
我后来给自己定了两条规矩:一是把字节序写成一行注释钉在函数上方, 不要靠变量名去猜;二是上电自检时拿一条已知报文跑一遍校验函数, 确认它和文档里的算法一致,再去看业务逻辑。
校验失败时,回执文案也要分得清
广告屏控制卡里我自己定了一组分层错误码:CODE_RIGHT=1000(成功)、
CODE_ERR=1001(失败)、CODE_WARING=1002(警告),
应答统一是 {"code":"...","msg":"...","data":...} 这个形状。
分层是对的,但文案我写坏了:CRC 校验失败的文案当时写成了“加密校验失败,请重试”,
长度不符写成了“网络好像开小差了”。
现场排查时,看到“加密”两个字的人第一反应会去查授权逻辑,
而不是去查线缆和波特率——错误文案是排查方向的路标,写错方向比不写更糟。
现在我会直接把文案写成“CRC 校验失败”“长度不符”,一眼就能定位到哪一层。
九、几条我反复用到的经验
- 先定判据,再写解析。先回答“我怎么知道一帧收完了”,解析代码自然就顺了。
- 帧头至少两个字节。单字节帧头在多机总线上太容易被 payload 冒充。
- CRC 从帧头第一个字节算起,覆盖长度字段。省这几个字节的校验范围,换来的是无法解释的误响应。
- 超时阈值要跟波特率一起算,不要抄一个数字。在 9600 下约合 4.8 个字符时间的 那个毫秒阈值,换到 115200 下就相当于几十个字符时间。
- 接收缓冲永远要有上界检查。长度字段会骗人,上位机也会发错; 夹住下标不等于安全,丢帧重来才是。
- 方向脚和帧边界是一件事。RS485 上的“偶发丢帧”,先查方向切换时序,再查线。
- 把帧格式写成表,放在代码注释和文档里各一份。 我在广告屏控制卡上吃过这个亏:代码里只留了一句“通讯数据格式请查看协作文档”, 而那份文档并不在工程里,后来想改协议只能靠抓包反推帧格式。
参考资料与说明
- Modbus 应用协议规范(Modbus Application Protocol Specification)中关于功能码、异常码与 CRC16 的定义,属于公开标准。
- Modbus over Serial Line Specification and Implementation Guide 中关于 1.5 字符时间与 3.5 字符时间静默间隔的规定,属于公开标准。
- HC32F460 系列与 GD32F4xx 系列的用户手册中关于 USART 空闲中断(IDLE)标志的说明,均为厂商公开文档。
- cJSON 项目文档(MIT 许可的轻量 JSON 解析库),本文提到的 JSON 解析均指该库的公开用法。
- 本文涉及的具体帧格式、超时阈值、错误码与代码片段,都是个人学习项目里的实现, 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码;关键参数已做通用化处理。
- 出于对个人项目实现细节的保护,本文只公开方法与思路;涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
- 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。