本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

串口通信的帧同步:
四种“怎么知道一帧收完了”的做法

串口协议里最难的部分不是解析,而是“怎么判断一帧结束了”。 长度字段、字节间超时、空闲中断、帧头搜索——这四种做法我在自己的四个工程里都实际用过, 它们的代价各不相同,失败时的表现也完全不一样。这篇笔记把它们摆在一起对比, 并附上 RS485 方向脚切换、CRC 校验范围和错误码返回这些绕不开的细节。

RS485 帧同步 空闲中断 CRC16 已发布

一、为什么“帧同步”比“帧解析”难

我刚开始写串口协议的时候,以为难点在解析:把字节拼成结构体、处理大小端、把字符串转成数字。 写的项目多了才发现,解析其实是整条链路上最舒服的一段——它的输入是明确的, 一帧数据摆在面前,怎么拆都有标准答案。

真正难的是它前面那一步:从一条没有边界的字节流里,切出一帧来。 串口只保证“字节按顺序到达”,不保证“帧与帧之间有标记”。所以每一份自定义串口协议里, 都必须回答同一个问题:收到第几个字节的时候,我可以确定这一帧收完了?

这个问题答错的代价,比 CRC 校验失败大得多。答错只有三种可能:

  • 切早了:只收到半帧就去解析,表现为“CRC 全部失败的间歇性丢帧”;
  • 切晚了:把两帧粘成一帧,表现为“偶尔整段报文解不动”,而且一丢就是两条命令;
  • 切错位:帧头识别错一个字节,后面全部错位,直到总线上出现一段足够长的空闲才恢复。

三种表现都长得像“通信不稳定”,排查方向却完全不同。这篇笔记里的四种做法, 分别来自我做过的一块 LED 广告屏控制卡(自定义 RS485 帧 + JSON 报文)、 一台液体加注控制器(用 4G 模组、靠超时收 AT 应答)、 一台工业压力变送器(Modbus-RTU 从站 + RS485 升级通道)。 我把它们按“判据”分类,而不是按“哪个项目”分类,这样更容易看出各自适合什么场景。

二、方案一:长度字段主动闭合

最省事的做法是在帧头后面直接写清楚“这一帧有多长”。接收方读长度字段、数够字节数, 这一帧就闭合了,完全不需要等超时,也不需要额外的定时器。

我在 LED 广告屏控制卡上用的就是这个结构(帧格式见下表,这是我自己定的协议):

偏移长度内容说明
Byte[0]1 B帧头字节(兼作站号)取值由设备地址决定;不等于本机地址就丢弃整帧
Byte[1]1 B固定标识字节第二道帧头确认;不匹配就清零重新找帧头
Byte[2..3]2 BLEN(小端)payload 长度;帧总长 = LEN + 6
Byte[4..LEN+3]LEN BJSON 文本(ASCII)命令体,用 cJSON 解析
Byte[LEN+4]1 BCRC16 低字节小端,先发低字节
Byte[LEN+5]1 BCRC16 高字节校验范围覆盖帧头 + 长度 + 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 都在这里校验 */
}

为什么不用理论值,而要往上取一个更保守的档位?我的理由有三条,都跟“工程余量”有关:

  1. 节拍是整数毫秒。理论值算出来是 3.65 ms,而 1 ms 的节拍只能数出整数个毫秒, 向上取整本身就已经比理论值宽了一档;
  2. 要留抖动余量。上位机如果是 PC 加 USB 转串口,字节之间的间隔受驱动和调度影响, 几十到几百微秒的抖动很常见,贴着理论值取太贴边;
  3. 要留处理余量。中断里收到字节到置标志之间也有时间,极端情况下会挤占计时窗口。

代价也要一起算:我取的这个档位在 9600 bps 下大约是 4.8 个字符时间,比规范的 3.5 稍宽,够用; 但同一个毫秒数换到 115200 bps,就相当于几十个字符时间了——如果上位机连续发多条命令、 帧间静默只有一两毫秒,这个阈值就会把两帧粘在一起。 这一点我在调上位机时踩过:固定毫秒阈值必须和波特率、上位机的发送节奏一起核对, 不能只写一个毫秒数就认为它通用。

同样的思路也用在液体加注控制器的 4G 模组通信上:那台设备收的是模组的 AT 应答, 我把“一段时间内没有新字节”当作一帧结束——这个时间在代码里是一个宏,不写死在判断里, 改波特率的时候只需要改一处;再配上一个小深度的消息队列和有限次重试。 这类模组的应答长度不固定、也没有长度字段,超时判帧是最省事的选择。

字节间超时最容易错的两个细节

  • 清零的位置:空闲计数器必须在“收到字节”的那条路径上清零,而且要在中断里清, 不能等任务去清。我个人的习惯是在串口接收中断里把计数清零、把“有新字节”标志置上, 节拍中断只负责加一。
  • 只触发一次:帧结束是一个“边沿”,不是一个“电平”。 如果解析任务是周期轮询的,它在计数器越过阈值之后会反复看到“帧结束了”, 所以解析完成后必须把计数器和缓冲下标一起清零,否则同一帧会被解析好几遍。
一条字节流,每个字节下方标出与前一字节的间隔值,用一条水平阈值线(3.5 字符时间)区分帧内间隔与帧间间隔
图 1 · 字节间隔与帧边界判定时序图

四、方案三:串口空闲中断与软件模拟

很多 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 兜底;代码可读性下降

我的选择顺序

  1. 先问“最坏情况下我会丢掉什么”。丢掉一条查询命令可以重发,丢掉一条写参数命令可能就写坏了, 这决定了你能接受多高的误判率。
  2. 单主机、问答式、报文短(几十到几百字节):长度字段 + 全帧 CRC,最省事, 我在广告屏控制卡上就是这么做的。
  3. 报文长度不定、对端是 PC 或模组、帧间天然有静默:字节间超时。 记得把阈值和波特率一起算,别把某一个毫秒数当成万能值。
  4. MCU 有 IDLE 中断、而且本来就要收整包:硬件空闲中断 + 定时器兜底。 注意给接收计数加上界检查,我在这一条上留过隐患。
  5. 总线上有第三方设备、或者要兼容旧协议:帧头搜索 + CRC 全帧校验。 多花一点 CPU,换“自己能从混乱里恢复”的能力。

最后一点也是我最想强调的:这四种方案不是互斥的。 广告屏控制卡上我实际上用了三种——两字节帧头 + 长度字段 + 毫秒级空闲超时, 外面再套一层覆盖全帧的 CRC。四种判据互为兜底听起来很啰嗦, 但它的好处是排查时每一条都能单独验证:把超时阈值临时改大、看长度字段、看 CRC 结果, 三步就能定位到底是哪一层出的问题。

四条并排的字节流,内容相同,分别在“长度字段闭合”“字节间超时”“空闲中断”“帧头搜索”四种判据下,用竖线标出切帧位置
图 2 · 四种帧同步方案的切帧位置对比图

七、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 校验失败”“长度不符”,一眼就能定位到哪一层。

九、几条我反复用到的经验

  1. 先定判据,再写解析。先回答“我怎么知道一帧收完了”,解析代码自然就顺了。
  2. 帧头至少两个字节。单字节帧头在多机总线上太容易被 payload 冒充。
  3. CRC 从帧头第一个字节算起,覆盖长度字段。省这几个字节的校验范围,换来的是无法解释的误响应。
  4. 超时阈值要跟波特率一起算,不要抄一个数字。在 9600 下约合 4.8 个字符时间的 那个毫秒阈值,换到 115200 下就相当于几十个字符时间。
  5. 接收缓冲永远要有上界检查。长度字段会骗人,上位机也会发错; 夹住下标不等于安全,丢帧重来才是。
  6. 方向脚和帧边界是一件事。RS485 上的“偶发丢帧”,先查方向切换时序,再查线。
  7. 把帧格式写成表,放在代码注释和文档里各一份。 我在广告屏控制卡上吃过这个亏:代码里只留了一句“通讯数据格式请查看协作文档”, 而那份文档并不在工程里,后来想改协议只能靠抓包反推帧格式。

参考资料与说明

  • 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 解析均指该库的公开用法。
  • 本文涉及的具体帧格式、超时阈值、错误码与代码片段,都是个人学习项目里的实现, 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码;关键参数已做通用化处理。
  • 出于对个人项目实现细节的保护,本文只公开方法与思路;涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
  • 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。