一、三种做法解决的是同一个问题
串口接收这件事,剥掉协议以后只剩一句话: 数据到了,怎么第一时间把它从数据寄存器里拿走。 拿晚了,下一个字节就会把上一个覆盖掉,硬件置起溢出标志, 这一整帧就废了。三种做法的差别,全在“谁去拿、什么时候去拿”上。
- 轮询:主循环隔一段时间问一次“有没有新数据”,有就拿走。
- 逐字节接收中断:每收到一个字节硬件中断一次,中断服务程序把字节搬进缓冲。
- DMA + 空闲中断:一串字节由 DMA 自动搬进缓冲,CPU 只在“总线安静下来”时被打断一次,然后一次性处理整帧。
这篇笔记要回答的是“接收通道怎么配”: 三种做法各自的开销、缓冲需求、抗突发能力和配置顺序。 至于“一帧到底什么时候算收完了”——长度字段、字节间超时、空闲中断、帧头搜索这四种判据怎么选, 我在《串口通信的帧同步:四种“怎么知道一帧收完了”的做法》里单独整理过。 两篇是上下游关系:那篇回答“怎么判一帧结束”,这篇回答“字节由谁搬、怎么搬”。 如果你只关心判据选型,看那篇就够;如果你正在决定某一路串口要不要上 DMA,看这篇。
第三种做法会用到 DMA,而 DMA 自身的配置项与坑(八个必填参数、三种中断的分工、 标志为什么必须“先关再清”、改长度之前为什么必须先停通道), 我单独写在《DMA 传输与中断配置:从寄存器到“什么时候用哪种模式”》里。 本文提到 DMA 的地方只讲“接收这条链路怎么串起来”,寄存器层面的细节不在这里重复。
二、做法一:轮询——什么时候它其实是对的
轮询经常被当成“新手写法”一笔带过,但我后来发现它在几种场合其实是最优解, 强行上中断反而会把代码搞复杂。
第一类是一次性、可阻塞的场合。 比如上电初始化时给外接模块发一条配置命令、然后等它应答; 有一块板子的上电流程里就有“初始化串口之后,先看看这一路有没有参数帧送进来”这一步—— 收到就进参数模式,收不到就按默认参数继续。 这段时间里系统本来就无事可做,用轮询读几个字节最简单, 出了问题也最容易看出来。
第二类是只发不收的调试口。这类通道上没有接收需求, 配置接收中断纯属多余。
第三类是刚接手一路陌生协议、需要先把链路跑通的时候。 我现在的习惯是先用轮询把“收到字节 → 存缓冲 → 判帧 → 解析”这条链跑通, 确认协议理解没错之后,再把“取字节”这一层换成中断或 DMA。 换的时候只动最底下那一层,上面的解析逻辑一行不用改—— 这个小习惯让我在排查“是协议理解错了还是接收机制配错了”时省了很多时间。
轮询真正的代价有三个,都很实在: 它会阻塞(等待期间别的活干不了)、 它必须知道什么时候去读(对方发得比你轮询得快,就一定丢)、 它不解决突发(一次来一串字节,主循环一轮只能读走一个)。 所以只要这路串口的数据是“随时可能来、来了可能是一串”,轮询就该让位了。
三、做法二:逐字节接收中断——配置顺序与代价
逐字节中断是“最不挑硬件”的做法:几乎所有串口都有“收到一个字节”这个中断源, 不需要 DMA,也不需要空闲标志。我在一块只有几 KB 内存的小板子上就用的它—— 两个串口各配一份接收缓冲,加上发送缓冲,内存刚好够,比硬塞 DMA 描述结构清爽得多。
配置顺序我现在固定按这五步走,顺序本身比内容重要:
| 步骤 | 做什么 | 为什么是这个顺序 |
|---|---|---|
| 1 | 先把接收缓冲与长度计数清零,并置“缓冲未被占用”状态 | 中断可能在使能后的任意时刻到来,先开中断再清缓冲会把刚收到的字节清掉 |
| 2 | 配置串口参数(波特率、数据位、校验、停止位)与收发引脚 | 参数没定好就开中断,会先收到一串错误数据 |
| 3 | 清一遍串口的状态与错误标志 | 上电残留的标志会让中断在使能瞬间立刻触发一次 |
| 4 | 使能“接收数据寄存器非空”中断 | 只开真正要用的那一个,别的不要顺手全开 |
| 5 | 使能串口的接收功能 | 最后再让数据进来,前面几步都已在稳定状态 |
中断服务程序里我只做三件事: 把数据读出来、存进缓冲并让长度加一、 重置静默计时。 判帧、校验、组包、回包全部放到主循环或任务里做。 这条界线我踩过坑之后才划清楚:早期我图省事在中断里直接判帧并回包, 结果高波特率下偶尔会丢字节,查了很久才发现是中断里耗时太长, 把自己的下一次中断挤掉了。
/* 错误示范:在逐字节中断里做整帧解析、组包和阻塞发送 */
void uart_rx_isr(void)
{
rx_buf[rx_len++] = uart_read_data(); /* 搬运:这一步没问题 */
if (frame_is_complete(rx_buf, rx_len)) { /* 判帧:不该放在这里 */
parse_and_reply(rx_buf, rx_len); /* 解析 + 组包 + 阻塞发送 */
rx_len = 0;
}
}这段代码的问题不是“写错了”,而是把时间不可控的事情放进了时间必须可控的地方。 解析要花多久取决于数据内容,发送要等对方或者等发送完成标志, 两者都可能远长于一个字节的到达间隔。 正确的写法是中断里只置一个“帧就绪”标志或投递一条消息, 让主循环去处理。
代价还有一条常被忽略:缓冲与溢出。 每字节一次中断意味着“中断频率 = 字节率”, 缓冲写入点的上界检查必须做,而且溢出时不能“夹住下标继续写”—— 那样写出来的是一段首尾拼接的错误数据,比丢一帧还难查。 我的做法是:一旦发现“长度已经到上界但还有新字节”, 立刻把这一帧标记为作废,等静默到来后整帧丢掉,从下一帧重新同步。
四、做法三:DMA + 空闲中断——我最常用的方案
这是我现在默认的选择,链条一共六步,缺一步都会出问题:
- DMA 循环接收常开:缓冲配好之后就让通道一直在跑,收满一圈自动回到开头继续收,CPU 完全不参与。
- 空闲中断触发判帧:总线安静下来时硬件给一次中断,这一次中断就代表“一帧大概收完了”。
- 用剩余计数反算本次长度:长度 = 缓冲总长 − 剩余项数。
- 关掉 DMA:让长度不会再变,也防止处理期间缓冲被继续写。
- 处理这一段数据:校验、解析、或者只是投递一条消息出去。
- 重新装载并再次使能:清标志、重置长度、开通道,等下一帧。
/* 按个人理解重写的最小片段:函数名是伪代码,不含任何产品参数 */
void uart_idle_isr(void)
{
if (!uart_idle_flag()) { return; }
uart_clear_idle_flag(); /* 空闲标志:先读状态,再读数据 */
dma_stop(RX_DMA); /* 先停,长度才不会继续变 */
uint16_t left = dma_get_count(RX_DMA);
uint16_t n = (uint16_t)(RX_BUF_SIZE - left);
if ((n > 0) && (n <= RX_BUF_SIZE)) {
post_frame(rx_buf, n); /* 只通知,解析留给主循环 */
} else {
rx_abnormal_count++; /* 长度异常:记一次,走恢复路径 */
}
dma_set_count(RX_DMA, RX_BUF_SIZE); /* 停止状态下重装 */
dma_clear_all_flags(RX_DMA); /* 先关再清 */
dma_start(RX_DMA); /* 等下一帧 */
}这条链条里有四个容易忽略的细节,我逐条说:
- 空闲标志是“总线安静了”,不是“帧结束了”。 如果一帧内部出现了明显的停顿(比如上位机分两次写同一帧), 它会被切成两帧;反过来,如果两帧之间没有留出足够的静默, 它们会粘成一帧。所以帧内不能有长间隔、帧间必须有静默, 这两条是协议层的约定,不是接收层能补救的。
- 长度的反算必须在关掉通道之后做。 虽然空闲意味着“暂时没有新数据”,但异常时序下这个假设不一定成立, 先关再算是最省心的顺序。
- 长度为零也要当成一次事件处理。 空闲中断在“总线上什么也没发生”时也可能被置起(尤其是插拔、上电瞬间), 这时反算长度是零。零长度帧应该走“忽略并重新装载”, 而不是走进解析流程。
- 重新装载要写全:计数、标志、使能三步都不能少, 少一步的表现都是“第一帧正常,后面再也收不到”。
这套方案真正的收益在抗突发上:对方一口气发一整帧, 从第一个字节到最后一个字节之间 CPU 一次都不用进来, 等它发完了才被打断一次。 中断频率与“帧率”成正比,而不是与“字节率”成正比—— 这就是它在高波特率下依然从容的原因。
五、三种做法的对照
把三者放在一起对比时,“最大波特率”这一列我故意写成定性描述: 因为它不是一个由 MCU 主频单独决定的数字, 而是“中断开销 + 主循环最坏耗时 + 缓冲深度”共同决定的, 换个工程就得重新算一遍。
| 做法 | CPU 占用 | 能支持的波特率 | 缓冲需求 | 抗突发能力 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|---|---|
| 轮询 | 空转时最省,有数据时全部占用 | 很低 | 一个字节即可 | 差,一次来一串必丢 | 最低 | 上电配置握手、只发不收的调试口、先把协议跑通 |
| 逐字节中断 | 与字节率成正比 | 中(受中断进出开销限制) | 一帧长度 + 若干状态量 | 中,取决于中断里做多少事 | 低(几乎所有串口都支持) | 内存很小、没有 DMA 或 DMA 资源被占满的平台 |
| DMA + 空闲中断 | 与帧率成正比 | 高(受总线和 DMA 带宽限制) | 一整圈缓冲,按协议最大帧长配 | 强,整帧自动搬完 | 中(要同时管好 DMA 与两个标志) | 数据量较大、帧长不定、CPU 还要忙别的事 |
我实际的选择顺序是: 先看内存够不够放一整圈缓冲, 放得下就上 DMA + 空闲中断; 放不下就看 DMA 资源有没有被别的外设占掉, 没有就把缓冲按协议最大帧长收紧,再试一次; 两样都不行,就退到逐字节中断, 并把中断里的事情压到最少。 轮询只用在那些“一次性、可阻塞”的缝隙里。
六、空闲中断的“软件模拟”:用节拍累计字节间隔
不是所有串口都有空闲标志,有的虽然有但不太可靠 (下一节那起事故就与它有关)。这时候可以用软件把“空闲”这件事算出来: 拿一个固定节拍,每当收到一个字节就把计数器清零; 没有新字节的节拍里计数器加一;加到一个阈值就认为“一帧结束了”。
| 时基来源 | 怎么用 | 取舍 |
|---|---|---|
| 定时器固定节拍 | 节拍中断里累加;接收中断里清零 | 粒度均匀、好算;占用一个定时器,节拍越密越费 CPU |
| 系统节拍(毫秒级) | 在系统节拍里累加,接收中断里清零 | 不额外占定时器;但毫秒粒度会带来量化误差,阈值只能落在整毫秒上 |
| 接收长度是否变化 | 节拍里比较“本次长度”和“上次长度”,连续若干次不变就判帧结束 | 不依赖计数器语义,最直观;但长度回绕和清零时机要小心 |
阈值怎么定?我先按公开标准算:8N1 下一个字符是十位, 字符时间 = 10 ÷ 波特率,标准要求的静默间隔是 3.5 个字符时间。 但实际实现里我会比这个标准再宽一些—— 宁可多等一点,也别把一帧切成两半,因为切两半的后果是“两条都不合法”, 比慢一点点严重得多。代价是帧率上限被压低,所以我用的档位偏保守, 在需要高帧率的场合会重新核算这个窗口。
/* 软件模拟空闲中断:在固定节拍里累计"距上次收到字节过了几拍" */
void tick_isr(void) /* 固定节拍,例如毫秒级 */
{
if (rx_byte_seen) { /* 本拍内有字节到达(由接收中断置位) */
gap_ticks = 0;
rx_byte_seen = 0;
} else if (gap_ticks < GAP_MAX) {
gap_ticks++; /* 封顶,避免长期无数据时溢出 */
}
if (gap_ticks == FRAME_GAP_TICKS) { /* 恰好到点,只触发一次 */
frame_ready = 1; /* 由主循环消费 */
}
}这段代码里有两个细节值得单独说。 第一,判据写的是“恰好等于”而不是“大于等于”: 后者会在阈值之后的每一拍都置一次标志,主循环如果处理慢一点, 就会对同一帧处理很多次。 第二,计数器要在“收到字节”的时刻清零,不是“处理完”的时刻清零—— 这两者在处理慢的时候会差很远,按后者实现会让下一帧的判帧窗口从上一帧处理完才开始算, 等于把窗口拉长成了“处理时间 + 静默时间”。
我在几块板子上都用过这个办法: 有一块小板子用固定的定时节拍累计到第若干拍判帧, 有一块用厂商库的板子干脆用“接收长度连续几个毫秒没变化”来判, 还有一块直接用系统节拍累加到某个次数。 三种写法的共同点是:判帧窗口一定要和波特率一起核算, 不能从别的工程抄一个数字过来。
七、一次真实事故的完整复盘:拔插之后只收到一个字节
这一节是这篇笔记里我最想留下来的部分。 它是我用空闲中断这条路线踩过的最深的一个坑, 也是“验收时多插拔几次”这个习惯的来源。
现象
一块作为从站的小板子,在现场频繁拔插通信接插件之后, 通信就终止在那里、无法自行恢复:从站这一侧收到的数据始终只有 1 个字节, 功能完全不动作。断电重启就好了,然后再插拔几次又会复现。 “重启就好”这四个字是我当时最该警惕的信号—— 它说明软件里没有任何自恢复路径,只能靠复位把状态清干净。
排查过程
- 线束与收发器先被排除:如果是接触不良或器件损坏,现象通常是“一个字节都收不到” 或者“收到一串乱码”,而不是长度恰好是 1。
- 回头核对接收缓冲:“长度恒为 1”是最关键的线索—— 它说明第一个字节进了缓冲,后面的字节再也没进来。
- 顺着这条线索往上找,注意力落到空闲标志上: 拔插瞬间总线上的电平跳变,被当成了一次空闲事件。
根因
根因是异常时序让空闲标志在错误的时刻被置起。 插拔接插件时线上的电平跳变被硬件识别成了“总线空闲”, 软件按“一帧结束”的流程做了收尾: 取长度、关接收通道、处理这一段数据。 但这一次的收尾发生在一个不完整的时刻, 通道关掉之后没有重新被有效地装载起来, 接收链路就停在那里。 后面真正到达的字节里,只有第一个进入了数据寄存器(于是长度是 1), 其余的因为没有接收通道在搬,全部丢失。
解决办法
- 给“异常帧”一条恢复路径:长度为零、或者长度明显不合法的帧, 不再简单地“忽略并等待下一帧”,而是强制执行一次接收链路的重新装载—— 把缓冲填满、重启接收中断,用一次强制重启把可能卡住的链路拉回来。
- 按协议规定的最大帧长限定 DMA 长度:把接收圈从“随便给一个大缓冲” 收紧到公开标准里那一档最大报文长度(Modbus RTU 的 ADU 上限是 256 字节)。 上界一收紧,异常能覆盖的空间变小,重新同步的代价也跟着变小。
- 加一条“长时间没有有效帧”的兜底判据:在这个判据上也做一次链路重初始化, 防止将来出现别的形态的卡死。
实测结果
改完之后做了反复拔插测试:在上级持续以大约每秒十次的频率广播测量电文的情况下, 异常一旦出现,大约 6 秒之内就能自动恢复接收, 不再需要人工断电重启。6 秒这个数字不是设计出来的, 而是“广播周期 × 恢复判据所需的帧数”自然得到的—— 上级一直在发,所以只要接收链路恢复了,下一帧就能重新对上。
八、接收缓冲区的设计:环形还是线性
三种接收做法最后都要落到“字节存哪里”。 我在这几年里用过三种结构,各自的适用场合差别很大。
| 结构 | 怎么用 | 溢出时怎么办 | 适合的场合 |
|---|---|---|---|
| 线性单帧缓冲 | 一帧一个缓冲,处理完清空下标 | 丢新,整帧作废,等下一次静默重新同步 | 帧之间有明显静默、处理速度快于到达速度 |
| 环形缓冲 | 写指针与读指针各自推进,绕圈复用 | 覆盖最旧,或丢新(取决于业务更关心连续性还是最新值) | 连续字节流、处理速度有波动 |
| 双缓冲(乒乓) | DMA 写其中一半,CPU 处理另一半 | 两块都满说明处理跟不上,只能丢 | 定长数据块,且希望处理与接收并行 |
丢新还是丢旧,我按一条标准判断: 如果业务关心的是“数据的连续性”(比如一条报文必须完整才能解析),丢新—— 既然处理不过来,保留新的也只会让积压更长, 而且丢掉新数据能保证“手上处理的那一段永远是连续的”。 如果业务关心的是“最新的状态”(比如实时值上报),丢旧—— 历史值没有意义,最新的那个才有用。
不管选哪种结构,有一条是硬性的:长度上界必须设,而且要在每一个写入点检查。 上界从哪来?优先来自协议标准(有明确最大报文长度就按它配), 其次来自内存预算。我在一块只有几 KB 内存的板子上算过这笔账: 两个串口的收发缓冲各占一档标准最大报文长度,几个缓冲加起来, 留给其他功能的内存就所剩无几了——上界不是“想设多大设多大”, 而是被内存倒推出来的。反过来说,如果内存充裕, 把缓冲配得比协议上界大很多并不是“更安全”, 它只是让“缓冲被填满”这个异常信号来得更晚、更隐蔽。
九、串口接收自查清单
这份清单是我每配完一路串口接收都会过一遍的,按“最容易漏”的顺序排:
| 检查项 | 怎么查 | 不通过意味着什么 |
|---|---|---|
| 空闲标志的清除方式对不对 | 确认是否按“先读状态寄存器、再读数据寄存器”的顺序清除 | 标志清不掉,中断会被反复触发,主循环像卡死 |
| 长度反算异常时有没有恢复路径 | 人为制造零长度与超长帧,看是否走恢复分支 | 异常帧把链路带走之后再也回不来 |
| 有没有“只收到一个字节就停住”的可能 | 插拔接插件、反复上下电,观察长度是否恒为一个小值 | 缺自恢复路径,现场要靠断电重启 |
| 协议对帧内间隔与帧间静默的要求是否满足 | 用上位机分两次写同一帧、再把两帧背靠背发,看判帧结果 | 一帧被切两半,或两帧粘成一帧 |
| 缓冲上界是否按协议最大帧长设定 | 核对缓冲长度与标准里的最大报文长度 | 异常数据有空间把缓冲绕圈覆盖,问题难复现 |
| 溢出时是丢帧重同步还是夹住下标 | 查溢出分支的代码 | 会产出首尾拼接的错误数据,比丢帧更难查 |
| 中断里是否只做搬运与通知 | 通读中断服务程序,找解析、组包、阻塞发送 | 中断耗时不可控,高波特率下丢字节 |
| 判帧窗口是否与波特率一起核算过 | 按“字符时间 = 10 ÷ 波特率”重算一遍,确认余量 | 换个波特率档位就失效,现象是“改波特率后偶发丢帧” |
| 有没有“长时间无有效帧”的兜底 | 人为断开再接通,观察是否自动恢复 | 链路卡死后只能靠看门狗复位 |
| 速率账算过没有 | 估算最坏情况下处理一帧要多久,与一帧的到达间隔对比 | 平均能跑但峰值丢数据,属于最难定位的一类 |
十、几条我反复用到的经验
- 先问“这一路的数据是随时会来吗”。是,就别用轮询;不是,轮询往往是最省事的答案。
- 中断里只搬运和通知。解析、组包、回包一律搬到主循环——这条界线我踩过坑之后才划清楚。
- 判帧窗口跟着波特率算。字符时间就是十位除以波特率,标准静默是 3.5 个字符时间;实现上留余量,但要知道自己留了多少。
- 零长度帧也要处理。空闲事件不一定代表“收到了一帧”,插拔和上电瞬间都会让它误触发。
- 异常恢复路径要和正常路径一起设计。只做正常路径,等于把一次插拔变成一次现场返修。
- 缓冲上界按协议定,不要按“多留一点总没错”定。上界太宽,异常只会来得更隐蔽。
- 溢出时丢整帧,不要夹住下标继续写。拼接出来的数据是最难查的一种错误。
- 验收时多插拔几次、多上下电几次。这一步花的时间,通常能在现场省回来。
参考资料与说明
- 各厂商 Cortex-M 系列参考手册中关于串口状态标志(接收数据寄存器非空、发送数据寄存器空、发送完成、空闲标志)与 DMA 请求的章节,均为公开文档,具体位域名以所用型号的手册为准。
- Modbus over Serial Line Specification and Implementation Guide 中关于 1.5 字符时间与 3.5 字符时间静默间隔、以及最大报文长度(ADU 上限 256 字节)的规定,属于公开标准。
- Modbus 应用协议规范(Modbus Application Protocol Specification)中关于报文结构的定义,属于公开标准。
- 本文涉及的具体缓冲长度、判帧阈值、通道取舍与事故处理过程,来自个人学习项目中的实际实现,示例代码为按个人理解重写的最小片段,函数名为伪代码,不代表任何产品或交付代码;关键参数已做通用化处理。
- 出于对个人项目实现细节的保护,本文只公开方法与思路;涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
- 文中出现的芯片与厂商名称仅用于说明技术方案,与相关厂商无隶属或授权关系。