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

串口接收的三种做法:
轮询、中断与 DMA + 空闲中断

同样是“把字节从数据寄存器里拿走”这一件事,我在这几年的板子上用过三种做法。 这篇笔记把三者的代价、适用场合和配置顺序摊开对比, 重点讲我最常用的“循环 DMA 接收 + 空闲中断判帧”这条完整链条, 并完整复盘一次频繁拔插接插件之后串口只收到一个字节的真实事故。

串口接收 空闲中断 DMA 异常恢复 已发布

一、三种做法解决的是同一个问题

串口接收这件事,剥掉协议以后只剩一句话: 数据到了,怎么第一时间把它从数据寄存器里拿走。 拿晚了,下一个字节就会把上一个覆盖掉,硬件置起溢出标志, 这一整帧就废了。三种做法的差别,全在“谁去拿、什么时候去拿”上。

  • 轮询:主循环隔一段时间问一次“有没有新数据”,有就拿走。
  • 逐字节接收中断:每收到一个字节硬件中断一次,中断服务程序把字节搬进缓冲。
  • DMA + 空闲中断:一串字节由 DMA 自动搬进缓冲,CPU 只在“总线安静下来”时被打断一次,然后一次性处理整帧。

这篇笔记要回答的是“接收通道怎么配”: 三种做法各自的开销、缓冲需求、抗突发能力和配置顺序。 至于“一帧到底什么时候算收完了”——长度字段、字节间超时、空闲中断、帧头搜索这四种判据怎么选, 我在《串口通信的帧同步:四种“怎么知道一帧收完了”的做法》里单独整理过。 两篇是上下游关系:那篇回答“怎么判一帧结束”,这篇回答“字节由谁搬、怎么搬”。 如果你只关心判据选型,看那篇就够;如果你正在决定某一路串口要不要上 DMA,看这篇。

第三种做法会用到 DMA,而 DMA 自身的配置项与坑(八个必填参数、三种中断的分工、 标志为什么必须“先关再清”、改长度之前为什么必须先停通道), 我单独写在《DMA 传输与中断配置:从寄存器到“什么时候用哪种模式”》里。 本文提到 DMA 的地方只讲“接收这条链路怎么串起来”,寄存器层面的细节不在这里重复。

三栏并排:轮询(CPU 主动查询标志)、逐字节中断(每字节一次中断)、DMA 加空闲中断(硬件搬运 + 一次帧结束中断)
图 1 · 三种串口接收方式的数据通路对比图

二、做法一:轮询——什么时候它其实是对的

轮询经常被当成“新手写法”一笔带过,但我后来发现它在几种场合其实是最优解, 强行上中断反而会把代码搞复杂。

第一类是一次性、可阻塞的场合。 比如上电初始化时给外接模块发一条配置命令、然后等它应答; 有一块板子的上电流程里就有“初始化串口之后,先看看这一路有没有参数帧送进来”这一步—— 收到就进参数模式,收不到就按默认参数继续。 这段时间里系统本来就无事可做,用轮询读几个字节最简单, 出了问题也最容易看出来。

第二类是只发不收的调试口。这类通道上没有接收需求, 配置接收中断纯属多余。

第三类是刚接手一路陌生协议、需要先把链路跑通的时候。 我现在的习惯是先用轮询把“收到字节 → 存缓冲 → 判帧 → 解析”这条链跑通, 确认协议理解没错之后,再把“取字节”这一层换成中断或 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 + 空闲中断——我最常用的方案

这是我现在默认的选择,链条一共六步,缺一步都会出问题:

  1. DMA 循环接收常开:缓冲配好之后就让通道一直在跑,收满一圈自动回到开头继续收,CPU 完全不参与。
  2. 空闲中断触发判帧:总线安静下来时硬件给一次中断,这一次中断就代表“一帧大概收完了”。
  3. 用剩余计数反算本次长度:长度 = 缓冲总长 − 剩余项数。
  4. 关掉 DMA:让长度不会再变,也防止处理期间缓冲被继续写。
  5. 处理这一段数据:校验、解析、或者只是投递一条消息出去。
  6. 重新装载并再次使能:清标志、重置长度、开通道,等下一帧。
/* 按个人理解重写的最小片段:函数名是伪代码,不含任何产品参数 */
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 一次都不用进来, 等它发完了才被打断一次。 中断频率与“帧率”成正比,而不是与“字节率”成正比—— 这就是它在高波特率下依然从容的原因。

一条字节流,下方画空闲标志线与中断触发点,标注“用剩余计数反算本次帧长”的过程
图 2 · 空闲中断判帧时序图

五、三种做法的对照

把三者放在一起对比时,“最大波特率”这一列我故意写成定性描述: 因为它不是一个由 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。
  2. 回头核对接收缓冲:“长度恒为 1”是最关键的线索—— 它说明第一个字节进了缓冲,后面的字节再也没进来。
  3. 顺着这条线索往上找,注意力落到空闲标志上: 拔插瞬间总线上的电平跳变,被当成了一次空闲事件。

根因

根因是异常时序让空闲标志在错误的时刻被置起。 插拔接插件时线上的电平跳变被硬件识别成了“总线空闲”, 软件按“一帧结束”的流程做了收尾: 取长度、关接收通道、处理这一段数据。 但这一次的收尾发生在一个不完整的时刻, 通道关掉之后没有重新被有效地装载起来, 接收链路就停在那里。 后面真正到达的字节里,只有第一个进入了数据寄存器(于是长度是 1), 其余的因为没有接收通道在搬,全部丢失。

解决办法

  1. 给“异常帧”一条恢复路径:长度为零、或者长度明显不合法的帧, 不再简单地“忽略并等待下一帧”,而是强制执行一次接收链路的重新装载—— 把缓冲填满、重启接收中断,用一次强制重启把可能卡住的链路拉回来。
  2. 按协议规定的最大帧长限定 DMA 长度:把接收圈从“随便给一个大缓冲” 收紧到公开标准里那一档最大报文长度(Modbus RTU 的 ADU 上限是 256 字节)。 上界一收紧,异常能覆盖的空间变小,重新同步的代价也跟着变小。
  3. 加一条“长时间没有有效帧”的兜底判据:在这个判据上也做一次链路重初始化, 防止将来出现别的形态的卡死。

实测结果

改完之后做了反复拔插测试:在上级持续以大约每秒十次的频率广播测量电文的情况下, 异常一旦出现,大约 6 秒之内就能自动恢复接收, 不再需要人工断电重启。6 秒这个数字不是设计出来的, 而是“广播周期 × 恢复判据所需的帧数”自然得到的—— 上级一直在发,所以只要接收链路恢复了,下一帧就能重新对上。

八、接收缓冲区的设计:环形还是线性

三种接收做法最后都要落到“字节存哪里”。 我在这几年里用过三种结构,各自的适用场合差别很大。

结构怎么用溢出时怎么办适合的场合
线性单帧缓冲 一帧一个缓冲,处理完清空下标 丢新,整帧作废,等下一次静默重新同步 帧之间有明显静默、处理速度快于到达速度
环形缓冲 写指针与读指针各自推进,绕圈复用 覆盖最旧,或丢新(取决于业务更关心连续性还是最新值) 连续字节流、处理速度有波动
双缓冲(乒乓) DMA 写其中一半,CPU 处理另一半 两块都满说明处理跟不上,只能丢 定长数据块,且希望处理与接收并行

丢新还是丢旧,我按一条标准判断: 如果业务关心的是“数据的连续性”(比如一条报文必须完整才能解析),丢新—— 既然处理不过来,保留新的也只会让积压更长, 而且丢掉新数据能保证“手上处理的那一段永远是连续的”。 如果业务关心的是“最新的状态”(比如实时值上报),丢旧—— 历史值没有意义,最新的那个才有用。

不管选哪种结构,有一条是硬性的:长度上界必须设,而且要在每一个写入点检查。 上界从哪来?优先来自协议标准(有明确最大报文长度就按它配), 其次来自内存预算。我在一块只有几 KB 内存的板子上算过这笔账: 两个串口的收发缓冲各占一档标准最大报文长度,几个缓冲加起来, 留给其他功能的内存就所剩无几了——上界不是“想设多大设多大”, 而是被内存倒推出来的。反过来说,如果内存充裕, 把缓冲配得比协议上界大很多并不是“更安全”, 它只是让“缓冲被填满”这个异常信号来得更晚、更隐蔽。

九、串口接收自查清单

这份清单是我每配完一路串口接收都会过一遍的,按“最容易漏”的顺序排:

检查项怎么查不通过意味着什么
空闲标志的清除方式对不对 确认是否按“先读状态寄存器、再读数据寄存器”的顺序清除 标志清不掉,中断会被反复触发,主循环像卡死
长度反算异常时有没有恢复路径 人为制造零长度与超长帧,看是否走恢复分支 异常帧把链路带走之后再也回不来
有没有“只收到一个字节就停住”的可能 插拔接插件、反复上下电,观察长度是否恒为一个小值 缺自恢复路径,现场要靠断电重启
协议对帧内间隔与帧间静默的要求是否满足 用上位机分两次写同一帧、再把两帧背靠背发,看判帧结果 一帧被切两半,或两帧粘成一帧
缓冲上界是否按协议最大帧长设定 核对缓冲长度与标准里的最大报文长度 异常数据有空间把缓冲绕圈覆盖,问题难复现
溢出时是丢帧重同步还是夹住下标 查溢出分支的代码 会产出首尾拼接的错误数据,比丢帧更难查
中断里是否只做搬运与通知 通读中断服务程序,找解析、组包、阻塞发送 中断耗时不可控,高波特率下丢字节
判帧窗口是否与波特率一起核算过 按“字符时间 = 10 ÷ 波特率”重算一遍,确认余量 换个波特率档位就失效,现象是“改波特率后偶发丢帧”
有没有“长时间无有效帧”的兜底 人为断开再接通,观察是否自动恢复 链路卡死后只能靠看门狗复位
速率账算过没有 估算最坏情况下处理一帧要多久,与一帧的到达间隔对比 平均能跑但峰值丢数据,属于最难定位的一类

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

  1. 先问“这一路的数据是随时会来吗”。是,就别用轮询;不是,轮询往往是最省事的答案。
  2. 中断里只搬运和通知。解析、组包、回包一律搬到主循环——这条界线我踩过坑之后才划清楚。
  3. 判帧窗口跟着波特率算。字符时间就是十位除以波特率,标准静默是 3.5 个字符时间;实现上留余量,但要知道自己留了多少。
  4. 零长度帧也要处理。空闲事件不一定代表“收到了一帧”,插拔和上电瞬间都会让它误触发。
  5. 异常恢复路径要和正常路径一起设计。只做正常路径,等于把一次插拔变成一次现场返修。
  6. 缓冲上界按协议定,不要按“多留一点总没错”定。上界太宽,异常只会来得更隐蔽。
  7. 溢出时丢整帧,不要夹住下标继续写。拼接出来的数据是最难查的一种错误。
  8. 验收时多插拔几次、多上下电几次。这一步花的时间,通常能在现场省回来。

参考资料与说明

  • 各厂商 Cortex-M 系列参考手册中关于串口状态标志(接收数据寄存器非空、发送数据寄存器空、发送完成、空闲标志)与 DMA 请求的章节,均为公开文档,具体位域名以所用型号的手册为准。
  • Modbus over Serial Line Specification and Implementation Guide 中关于 1.5 字符时间与 3.5 字符时间静默间隔、以及最大报文长度(ADU 上限 256 字节)的规定,属于公开标准。
  • Modbus 应用协议规范(Modbus Application Protocol Specification)中关于报文结构的定义,属于公开标准。
  • 本文涉及的具体缓冲长度、判帧阈值、通道取舍与事故处理过程,来自个人学习项目中的实际实现,示例代码为按个人理解重写的最小片段,函数名为伪代码,不代表任何产品或交付代码;关键参数已做通用化处理。
  • 出于对个人项目实现细节的保护,本文只公开方法与思路;涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
  • 文中出现的芯片与厂商名称仅用于说明技术方案,与相关厂商无隶属或授权关系。