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

DMA 传输与中断配置:
从寄存器到“什么时候用哪种模式”

DMA 的配置项在参考手册里列得清清楚楚,照着填一般不会错。 真正让我反复返工的,是另一类问题:什么时候能安全地碰它—— 什么时候可以改长度、什么时候可以改地址、什么时候才算“这一帧真的发完了”。 这篇笔记把我在多块板子上配 DMA 的过程整理成一份可复用的判断顺序。

DMA 中断与标志位 串口收发 循环与单次模式 已发布

一、DMA 到底解决了什么问题(以及解决不了什么)

我最初把 DMA 当成一个“省 CPU”的选项:能不用就不用,觉得中断里搬几个字节也没什么。 后来在两类场合被教育了。第一类是高速率连续接收—— 每来一个字节就进一次中断,中断里还要判帧、还要存缓冲, 波特率一提上去,CPU 就有相当比例的时间花在进出中断上,主循环被切得七零八落。 第二类是多通道 ADC 扫描:一块板子上四个通道轮流转换, 如果每次转换完成都中断一次,再在中断里把结果塞进数组, 中断频率就是采样率的四倍,而这件事本来完全不需要 CPU 参与。

把这两类需求交给 DMA 之后,CPU 只在一批数据搬完时被打断一次, 中间过程完全不占指令周期。更关键的是时序上的收益: 外设的数据寄存器只有一个,如果 CPU 没来得及读走,新数据就会把它覆盖, 这就是串口里的溢出错误(ORE)——它和“CPU 忙不忙”直接相关。 DMA 把“取走数据”这件事变成硬件行为,取走的时刻是可预测的,溢出窗口就小得多。

但 DMA 解决不了的事情同样要提前想清楚,否则会把问题从“配置”挪到“更难看出来的地方”:

  • 它不知道协议边界。DMA 只认识“搬够多少个数据项”,不知道哪个字节是一帧的开头、哪个是结尾。判帧仍然要靠 CPU 侧的逻辑。
  • 它不保证语义顺序。它保证的是按序搬运,但不保证“上一帧已经被处理完”。
  • 它不会替你处理资源冲突。某个外设的请求能不能落到某个通道上,是芯片设计时定死的,软件层面绕不过去。
  • 它不会阻止缓冲被覆盖。循环模式下数据会一直往后写,写满一圈就回到开头继续写,旧数据什么时候被踩掉取决于你的处理速度。

所以在我的经验里,DMA 的配置项本身是“填空题”,难点全在后面那句问话上: 什么时候可以安全地碰它。改长度、改地址、清标志、重新使能, 这四个动作都必须在传输真正停止之后才做—— 后面第五、六、七节讲的三个坑,本质都是这一句话的推论。

二、配置一份 DMA 传输要定的八个参数

不管用厂商库还是直接写寄存器,一份 DMA 传输要回答的问题都是同样八个。 我习惯先把这八个写在纸上,再动手写初始化代码—— 因为其中大部分一旦定下来就不该在运行中改,而定错了往往要到联调阶段才暴露。

参数含义怎么选选错的后果
外设与方向 谁发起请求,数据从外设到内存,还是从内存到外设 接收=外设→内存;发送=内存→外设;ADC 扫描≈外设→内存 方向写反,表现为“通道一直在搬但缓冲里全是旧数据”
数据宽度 源和目的各自的搬运单位,两者可以不同 串口收发按字节;ADC 结果按半字;外设数据寄存器宽度由外设决定 宽度配大,会把相邻寄存器一起搬走或把两个字节合成一个;配小则丢高位
地址是否递增 搬完一项之后,源地址和目的地址各自加不加 外设侧一般不递增(永远读同一个数据寄存器);内存侧必须递增 内存侧忘了递增,一整批数据全落在同一个地址上,只剩最后一个值
传输长度 搬多少项,注意单位是“数据项”而不是字节 四通道十组扫描=四十项;按字节算就会差成两倍 长度算错,要么少搬一半,要么溢出到缓冲之外
循环还是单次 搬完之后自动重装计数继续,还是停下来 持续接收、ADC 连续扫描用循环;一次性发送用单次 发送用循环会反复把同一段数据发出去;接收用单次则只收一帧就停
优先级 多个通道同时请求时谁先占总线 按“丢了会影响功能”的程度排,接收类高于发送类 低优先级通道被长期压制,表现为“偶发丢一段数据”
触发源 硬件请求触发,还是软件写一下启动位 外设搬运一律用硬件请求;纯内存搬运只能软件触发 该用硬件请求的用了软件触发,通道只搬一次就再不响应
完成后的动作 置哪些标志、产生哪些中断、要不要回调 只开真正要用的那一两个,其余关掉 把三个中断全开,等于把中断频率乘三,还容易在错误中断里死循环

这张表里我最容易搞错的是数据宽度和长度单位,因为它们经常不一致: 串口那边外设寄存器是字节宽度、内存缓冲如果按半字组织, 那么“宽度可不同”这件事就从一句文档说明变成了实际配置; 而长度按项计数时,一个“四十项”的 ADC 扫描在字节口径下是八十。 我现在固定用一个办法避免出错:先算清楚“一共多少项”,再算清楚“一项多少字节”, 最后把两个数字分别写在注释里,对不上就说明哪里理解错了。

还有一个参数表里没列的隐式前提:缓冲的存放位置。 如果核上开了数据缓存,DMA 直接写内存可能不经过缓存, CPU 读到的就是旧内容;这类问题需要把缓冲放到非缓存区, 或者在“搬完”和“读之前”各做一次缓存维护。 我手上那套带缓存能力的主频较高的工程里,干脆没有开启指令与数据缓存 (工程注释写明依据芯片手册,在那个主频下开启加速会产生异常), 所以这一层问题没有暴露出来——但这属于“没遇到”,不是“不存在”, 换平台时我会把这一条重新查一遍。

中心一个 DMA 通道方框,周围五个要素连线:源地址、目的地址、传输长度、触发源、传输模式(循环/单次)
图 1 · DMA 传输要素图

三、三种中断:传输完成、半完成、传输错误

一个 DMA 通道通常会给三个可用的中断源,它们的含义完全不同, 适合做的事情也完全不同。我见过(也写过)“三个全开、处理函数里写同一段代码”的写法, 结果就是每批数据被打断三次,其中一次还是错误中断。

中断源触发时机适合做什么不适合做什么
传输完成 设定的长度全部搬完那一刻 发送结束收尾;定长数据块就绪 判断“线上已经发完”(这只说明搬运结束,见第五节)
半完成 搬到总长度一半时 双缓冲:处理前半的同时后半继续收 把它当“完成”用,会把半帧当整帧提交给上层
传输错误 总线访问出错、配置非法等异常 停下来、标记本批数据作废、重置通道 什么都不做,或者只在里面打个日志就返回

半完成中断的双缓冲怎么用

半完成中断的价值在于它给出了一个确定的边界: 中断到来时,前半段已经定稿、后半段正在被写。 于是处理顺序就很自然:在半完成中断里处理前半段, 在完成中断里处理后半段,下一轮反过来。 这样 CPU 的处理时间和数据到达时间就重叠起来了, 等效缓冲深度也算翻倍。

用双缓冲有三个前提,少一个就会出问题。 第一,两段缓冲要分开,处理前半时绝不能让 DMA 还在往前半写; 第二,处理时间必须小于半批数据的到达时间, 否则下一轮的半完成中断会压上来,处理逻辑被重入; 第三,“半”这个边界要跟协议边界对得上, 如果一帧的长度不固定,半完成中断切出来的多半是半帧, 那它就只能当“提醒”,不能当“帧结束”用。 我在这类场合的做法是:半完成中断里只做“标记 + 唤醒”, 真正的切帧还是交给空闲判帧那一套逻辑。

传输错误为什么必须处理

传输错误中断的危险在于它可能连续触发。 如果错误条件没有被解除、错误标志也没有被清掉, 中断会一直被重新拉起,程序看起来像卡死在中断里,主循环完全不跑。 这类“中断风暴”在现场表现为“设备没反应,但也不重启”,很难从外部判断。

所以我的处理原则是三条: 先关通道再清标志(顺序见第七节)、 把本批数据标记为无效而不是当作完整数据交给上层、 重新装载一次再放行。 第二点尤其重要:错误的传输往往长度不对, 如果上层不知道,就会把半截数据当成一整帧去解析, 最后表现出来的是“校验偶尔不过”这种看不出根因的现象。

四、循环模式管接收,单次模式管发送

“接收用循环、发送用单次”是我在几乎所有串口 DMA 场合都沿用的分工。 理由不难想:接收是被动且不知何时结束的—— 数据什么时候来、来多少,都不由我决定, 所以硬件应该一直待命,收满了自己回到开头继续收; 发送是主动且长度已知的——我要发多少是自己决定的, 发完就该停下,停下来之后才好去做收尾动作。

对比项循环模式(接收)单次模式(发送)
结束条件不会自己结束,只能软件停止搬够长度就自动停
计数寄存器搬满一轮自动重装停在零,需要重新写
长度怎么得到用总长减去剩余计数反算发送长度是自己给的,不需要反算
典型收尾动作停止 → 取长度 → 处理 → 重新装载等发送完成 → 切方向脚 → 清标志
最容易出的问题缓冲被绕圈覆盖、长度反算失效长度寄存器改晚了、标志等错了

这套分工还有个副作用值得提前知道: 循环接收的通道永远不会自己停。 这意味着任何“我要改一下缓冲”的念头, 都必须先经过“把通道停下来”这一步。 如果哪次代码里出现了“直接改计数寄存器”的写法, 在实验室可能看不出问题,在现场数据恰好正在到达时就会丢一段。

上下两条,上方接收:循环填充环形缓冲,指针不断回绕,下方发送:一次搬运指定长度后自动停止
图 2 · 循环接收与单次发送的对比时序图

五、发送路径的两个坑:什么时候改长度,等哪一个标志

坑一:长度寄存器只能在通道停止之后改

发送的第二帧数据和第一帧不一样长,是很常见的事。 这时候要改的是“还剩多少项”那个计数寄存器。 如果通道还处于使能状态,这次写入的后果是未定义的: 有的实现直接忽略,有的会在当前项搬完之后才生效, 表现出来就是“第二帧发出去的长度偶尔不对”。

我现在固定按四步做,顺序不换: 先关通道,确认使能位真的读回零; 再写计数寄存器; 然后清一遍全部相关标志(通道级和控制器级的都清); 最后重新使能。 这四步写成一个小函数,发送和接收共用,谁也不会漏掉一步。

坑二:等错了标志,最后一字节就被削掉

这个坑我在 RS485 半双工上踩得最狠。 “发送完成”其实有两个不同层次的标志: 一个是发送数据寄存器空,表示“可以往里放下一个字节了”; 另一个是发送完成,表示“最后一个字节连停止位都已经送出引脚了”。 两者的差距正好是一个字节的发送时间。

在单向的调试串口上,这个差距没人关心; 但在 RS485 上,它决定方向脚什么时候能切回接收。 如果只等“数据寄存器空”就切方向,最后一字节还在移位寄存器里, 就会被收发器掐掉;对方看到的是“长度短一个字节、校验失败”, 而且这种现象随波特率升高而变得更频繁, 很容易被误判成“线太长”或者“干扰大”。

/* 错误示范:只等 DMA 搬完、只等数据寄存器空,就去切方向脚 */
dma_send(TX_DMA, buf, len);

while (!dma_transfer_done(TX_DMA)) { }   /* 只说明"数据搬进外设了" */
while (!uart_tx_reg_empty()) { }         /* 只说明"可以放下一个了" */

rs485_switch_to_rx();                    /* 切早了:最后一字节被削掉 */

正确的等待对象是那个“发送完成”标志,而且不能死等—— 一旦外设状态异常,死等会把整个任务挂在这里。 我在工程里给这类等待统一配了超时计数兜底: 等到了就正常走,超时就按“发送失败”处理并重置发送通道。 DMA 的传输完成标志 ≠ 外设发完了, 这一条是我认为最值得写在代码注释里的一句话。

六、接收路径的两个坑:怎么知道收了多少,缓冲填满怎么办

坑一:长度是“反算”出来的,而且只在没绕圈时成立

循环接收的通道不会告诉你“这次收了多少”, 它只知道“还剩多少项没搬”。所以长度只能反算: 本次长度 = 缓冲总长 − 剩余计数。 这个减法有两个前提,我一开始都没注意到。

第一个前提是读剩余计数时通道必须已经停下。 如果通道还在跑,读完总长、再读剩余计数的这一小段时间里数据还在进来, 两个数就不属于同一时刻,算出来的长度会偏大或偏小—— 偏大更危险,因为它会把上一次的旧数据也算进这一帧。

第二个前提是这一轮没有绕圈。 循环模式搬满一整圈之后计数会自动重装, 如果刚好收满整圈,剩余计数等于总长,减法得到零, 看起来就像“一个字节都没收到”; 如果收了一圈多,减法得到的是“绕圈之后的余数”, 比真实长度小得多。 所以“缓冲被填满之后怎么办”和“长度怎么反算”其实是同一个问题的两面。

/* 按个人理解重写的最小片段:函数名是伪代码,指代库里对应的那一组操作 */
static void rx_reload(void)
{
    dma_stop(RX_DMA);                      /* 1. 先真正停下来 */
    dma_set_count(RX_DMA, RX_BUF_SIZE);    /* 2. 停止之后才允许改长度 */
    dma_clear_all_flags(RX_DMA);           /* 3. 先关再清,见第七节 */
    dma_start(RX_DMA);                     /* 4. 重新使能,等下一帧 */
}

static uint16_t rx_length(void)
{
    uint16_t left = dma_get_count(RX_DMA);

    if (left > RX_BUF_SIZE) {              /* 计数越界:状态已经不对了 */
        return 0;
    }
    return (uint16_t)(RX_BUF_SIZE - left);  /* 只在本轮未绕圈时成立 */
}

坑二:缓冲填满之后的三条出路

缓冲被填满说明“数据来得比处理快”,这时候有三条出路, 选哪条取决于协议有没有长度上界:

  • 把缓冲按协议最大帧长来设,让“填满”在合法数据下不可能发生。这是我现在默认采用的做法——如果协议标准里明确规定了单帧的最大长度,那么缓冲就按这个长度配,超出的数据一定是异常,直接丢弃并重新同步,比“多留一点总没错”要安全得多。
  • 丢弃并重新同步:填满时停止通道、丢掉整段、重新装载,等下一帧。代价是丢掉一帧,好处是逻辑最简单,不会把半帧当整帧。
  • 覆盖最旧数据:把它当成环形缓冲继续写,只在“曾经绕圈”这件事上置一个标志,让上层知道数据可能不连续。这条路的代价是上层必须处理“数据有洞”的情况。

我的选择顺序是“先限上界,再谈策略”。 因为在有明确长度上界的协议上,缓冲填满本身就是异常信号, 它值得被当成故障上报,而不是被悄悄消化掉。

七、标志位的清除顺序:为什么必须“先关再清”

这一节是我认为最容易被忽略、也最容易造成“偶发”现象的一条。 我最初写收尾代码的顺序是“清标志 → 重新装载 → 使能”, 看起来没问题,但会碰到这样一种情况: 清标志的时候通道还开着,清完的下一刻硬件又把它置了起来, 于是下一轮“等标志”的循环立刻返回。

现象非常有欺骗性:第一帧正常,之后每一帧都“提前完成”。 如果是接收,表现为长度反算出来是零或者一个很小的值; 如果是发送,表现为“发得飞快但对方什么都没收到”。 这类问题在单步调试时常常看不出来,因为单步的时候时序被打断了。

所以正确的顺序是:

  1. 先关通道,并且确认使能位读回零——写下去和真正生效之间可能有延迟。
  2. 再清全部相关标志:通道级的和控制器级的都要清, 只清一个的话,控制器级的那个还会把中断重新拉起来。
  3. 然后改长度/改地址,这是停止状态下唯一安全的窗口。
  4. 最后重新使能,从干净的状态开始下一轮。

同一个道理适用于外设侧的中断标志。 串口的空闲标志就是一个典型例子:它的清除方式不是“写一清零”, 而是要求先读状态寄存器、再读数据寄存器这样一串读操作, 漏掉后一步标志就清不掉,中断会被反复触发。 这类“清除方式各不相同”的标志,我现在的做法是在注释里直接写清楚 “这个标志靠什么动作清”,而不是指望后来人记得。

八、通道与请求映射的冲突:一次真实的“抢同一个流”

前面几节都是“配置和时序”层面的问题,这一类不是—— 它是硬件资源层面的冲突,软件再优化也绕不过去。

那块控制板上有四路 RS485 串口,我一开始的想法很直接: 四路都上 DMA,收发各一份请求,一共八份。 当时的芯片那一族 DMA 控制器正好只有八个流, 看起来“刚好够用”。但实际上每个流的可用请求是固定的几选一, 并不是想接哪个就接哪个。 配置到后面才发现:某一路串口的接收请求,和另一路串口的发送请求, 被映射到了同一个流上。 一个流只有一份配置能生效,两个请求压在一起, 表现出来就是“其中一路收不到数据”,而且换波特率、换线、换板子都没用—— 因为问题根本不在线上。

串口占用的 DMA 资源用途结论
串口 A资源 1(收)/ 资源 2(发)接现场执行机构保留 DMA
串口 B与串口 D 的发送请求同源预留通道放弃 DMA,改用逐字节中断
串口 C资源 3(收)/ 资源 4(发)接另一组执行机构保留 DMA
串口 D资源 5(收)/ 资源 6(发)接调试与参数通道保留 DMA,但不与 B 同时启用

最后的处理是“放弃其中一路的 DMA,改用逐字节接收中断”。 具体做法是:把那一路上报文的接收退化成每字节一次中断、 在中断里存进缓冲并重置静默计时; 发送也相应退化成阻塞式逐字节写。 因为那一路的数据量本来就不大,牺牲掉的性能在可接受范围内。 我还在那一处中断处理函数里保留了这条备选实现, 并把结论写进注释——避免后面有人“顺手”给它再加一次 DMA 配置, 把已经能跑的系统又弄坏一次。

顺带说一句中断优先级:如果工程跑在实时操作系统上, DMA 和串口中断的抢占优先级必须排在“内核能管理的中断”那条线之后(数值更大), 否则在中断里调用内核提供的接口会破坏它的临界区, 表现是随机的、几乎无法复现的跑飞。 这条线和“哪个通道优先”是两件不同的事,别混在一起调。

九、配好之后要验的四件事

DMA 配好之后,我固定要做四件事才算“这一路通过”。 前两件是功能,后两件是健壮性——后者往往才是决定现场表现的部分。

验证项怎么验不看会怎样
长度对不对 构造已知长度的数据灌进去,把反算出来的长度和实际字节数逐次对比,包括空帧和满缓冲两种边界 长度算错会一路传到协议层,表现成“偶发校验失败”
首尾字节对不对 发送侧重点看最后一个字节有没有被削掉;接收侧重点看第一个字节有没有错位 截断与错位都会被误判成干扰或线材问题
标志有没有残留 连发两帧、连收两帧,第二帧是否和第一帧一样正常 第一次能用第二次不能用,是最难查的一类现象
异常后能不能自恢复 反复拔插接插件、反复上下电,观察接收链路是否还能自己回到正常 异常一旦发生就再也收不到数据,只能靠重启(下文里那起事故就是这一类)

第四项我单独拎出来强调:它对应的是一类“平时完全正常、 被插拔一次就永久失效”的故障。 这类问题的排查成本极高,因为现场描述往往是“有时候就不好使了”, 而验收时只要多插拔几次就能提前发现。 关于帧边界怎么判、四种判帧方案各自适合什么场合, 我在《串口通信的帧同步:四种“怎么知道一帧收完了”的做法》里单独整理过; 而“轮询、逐字节中断、DMA 加空闲中断这三种接收方式怎么选、怎么配”, 在《串口接收的三种做法:轮询、中断与 DMA + 空闲中断》里展开。 三篇的分工是:这篇讲 DMA 自身,那两篇分别讲帧边界和接收通道选型。

另外,ADC 多通道扫描那类“外设→内存”的 DMA, 搬完之后通常还要接着做滤波与阈值判断, 那一部分我在《采样数据的处理:环形缓冲、中值滤波与迟滞控制》里写过, 这里不重复。

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

  1. 把八个参数写在纸上再写代码。写得越早,返工越少。
  2. 任何“改”都要先“停”。改长度、改地址、清标志、重新使能,四件事都只在停止状态下做。
  3. 先关再清。清标志时通道还开着,等于给自己埋一个“下一轮立刻完成”的雷。
  4. DMA 的传输完成不等于外设发完了。RS485 上切方向脚之前,一定要等到外设自己的发送完成标志。
  5. 接收长度是反算的,而且只在没绕圈时成立。有明确长度上界的协议,缓冲就按上界配,把“填满”当成异常。
  6. 等待都要有超时兜底。死等一个可能永远不来的标志,是把偶发故障升级成死机的最短路径。
  7. 资源冲突在选型阶段解决。画一张请求与通道的映射表,比在联调现场试半天便宜得多。
  8. 把结论写进注释。尤其是“这里为什么不用 DMA”这种决定,不写下来一定会被后来人改回去。

参考资料与说明

  • 各厂商 Cortex-M 系列参考手册中关于 DMA 控制器(通道/流、请求映射、计数寄存器、传输完成与半完成标志、循环与单次模式)的章节,均为公开文档,具体位域名与映射关系以所用型号的手册为准。
  • 各厂商串口章节中关于发送数据寄存器空(TXE)与发送完成(TC)两个标志的区别,以及空闲标志的清除方式,均属公开的外设使用知识。
  • Modbus over Serial Line Specification and Implementation Guide 中关于最大报文长度与帧间静默间隔的规定,属于公开标准。
  • 本文涉及的具体通道分配、缓冲长度与取舍过程,来自个人学习项目中的实际实现,示例代码为按个人理解重写的最小片段,函数名为伪代码,不代表任何产品或交付代码;关键参数已做通用化处理。
  • 出于对个人项目实现细节的保护,本文只公开方法与思路;涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
  • 文中出现的芯片与厂商名称仅用于说明技术方案,与相关厂商无隶属或授权关系。