一、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 通道通常会给三个可用的中断源,它们的含义完全不同, 适合做的事情也完全不同。我见过(也写过)“三个全开、处理函数里写同一段代码”的写法, 结果就是每批数据被打断三次,其中一次还是错误中断。
| 中断源 | 触发时机 | 适合做什么 | 不适合做什么 |
|---|---|---|---|
| 传输完成 | 设定的长度全部搬完那一刻 | 发送结束收尾;定长数据块就绪 | 判断“线上已经发完”(这只说明搬运结束,见第五节) |
| 半完成 | 搬到总长度一半时 | 双缓冲:处理前半的同时后半继续收 | 把它当“完成”用,会把半帧当整帧提交给上层 |
| 传输错误 | 总线访问出错、配置非法等异常 | 停下来、标记本批数据作废、重置通道 | 什么都不做,或者只在里面打个日志就返回 |
半完成中断的双缓冲怎么用
半完成中断的价值在于它给出了一个确定的边界: 中断到来时,前半段已经定稿、后半段正在被写。 于是处理顺序就很自然:在半完成中断里处理前半段, 在完成中断里处理后半段,下一轮反过来。 这样 CPU 的处理时间和数据到达时间就重叠起来了, 等效缓冲深度也算翻倍。
用双缓冲有三个前提,少一个就会出问题。 第一,两段缓冲要分开,处理前半时绝不能让 DMA 还在往前半写; 第二,处理时间必须小于半批数据的到达时间, 否则下一轮的半完成中断会压上来,处理逻辑被重入; 第三,“半”这个边界要跟协议边界对得上, 如果一帧的长度不固定,半完成中断切出来的多半是半帧, 那它就只能当“提醒”,不能当“帧结束”用。 我在这类场合的做法是:半完成中断里只做“标记 + 唤醒”, 真正的切帧还是交给空闲判帧那一套逻辑。
传输错误为什么必须处理
传输错误中断的危险在于它可能连续触发。 如果错误条件没有被解除、错误标志也没有被清掉, 中断会一直被重新拉起,程序看起来像卡死在中断里,主循环完全不跑。 这类“中断风暴”在现场表现为“设备没反应,但也不重启”,很难从外部判断。
所以我的处理原则是三条: 先关通道再清标志(顺序见第七节)、 把本批数据标记为无效而不是当作完整数据交给上层、 重新装载一次再放行。 第二点尤其重要:错误的传输往往长度不对, 如果上层不知道,就会把半截数据当成一整帧去解析, 最后表现出来的是“校验偶尔不过”这种看不出根因的现象。
四、循环模式管接收,单次模式管发送
“接收用循环、发送用单次”是我在几乎所有串口 DMA 场合都沿用的分工。 理由不难想:接收是被动且不知何时结束的—— 数据什么时候来、来多少,都不由我决定, 所以硬件应该一直待命,收满了自己回到开头继续收; 发送是主动且长度已知的——我要发多少是自己决定的, 发完就该停下,停下来之后才好去做收尾动作。
| 对比项 | 循环模式(接收) | 单次模式(发送) |
|---|---|---|
| 结束条件 | 不会自己结束,只能软件停止 | 搬够长度就自动停 |
| 计数寄存器 | 搬满一轮自动重装 | 停在零,需要重新写 |
| 长度怎么得到 | 用总长减去剩余计数反算 | 发送长度是自己给的,不需要反算 |
| 典型收尾动作 | 停止 → 取长度 → 处理 → 重新装载 | 等发送完成 → 切方向脚 → 清标志 |
| 最容易出的问题 | 缓冲被绕圈覆盖、长度反算失效 | 长度寄存器改晚了、标志等错了 |
这套分工还有个副作用值得提前知道: 循环接收的通道永远不会自己停。 这意味着任何“我要改一下缓冲”的念头, 都必须先经过“把通道停下来”这一步。 如果哪次代码里出现了“直接改计数寄存器”的写法, 在实验室可能看不出问题,在现场数据恰好正在到达时就会丢一段。
五、发送路径的两个坑:什么时候改长度,等哪一个标志
坑一:长度寄存器只能在通道停止之后改
发送的第二帧数据和第一帧不一样长,是很常见的事。 这时候要改的是“还剩多少项”那个计数寄存器。 如果通道还处于使能状态,这次写入的后果是未定义的: 有的实现直接忽略,有的会在当前项搬完之后才生效, 表现出来就是“第二帧发出去的长度偶尔不对”。
我现在固定按四步做,顺序不换: 先关通道,确认使能位真的读回零; 再写计数寄存器; 然后清一遍全部相关标志(通道级和控制器级的都清); 最后重新使能。 这四步写成一个小函数,发送和接收共用,谁也不会漏掉一步。
坑二:等错了标志,最后一字节就被削掉
这个坑我在 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); /* 只在本轮未绕圈时成立 */
}坑二:缓冲填满之后的三条出路
缓冲被填满说明“数据来得比处理快”,这时候有三条出路, 选哪条取决于协议有没有长度上界:
- 把缓冲按协议最大帧长来设,让“填满”在合法数据下不可能发生。这是我现在默认采用的做法——如果协议标准里明确规定了单帧的最大长度,那么缓冲就按这个长度配,超出的数据一定是异常,直接丢弃并重新同步,比“多留一点总没错”要安全得多。
- 丢弃并重新同步:填满时停止通道、丢掉整段、重新装载,等下一帧。代价是丢掉一帧,好处是逻辑最简单,不会把半帧当整帧。
- 覆盖最旧数据:把它当成环形缓冲继续写,只在“曾经绕圈”这件事上置一个标志,让上层知道数据可能不连续。这条路的代价是上层必须处理“数据有洞”的情况。
我的选择顺序是“先限上界,再谈策略”。 因为在有明确长度上界的协议上,缓冲填满本身就是异常信号, 它值得被当成故障上报,而不是被悄悄消化掉。
七、标志位的清除顺序:为什么必须“先关再清”
这一节是我认为最容易被忽略、也最容易造成“偶发”现象的一条。 我最初写收尾代码的顺序是“清标志 → 重新装载 → 使能”, 看起来没问题,但会碰到这样一种情况: 清标志的时候通道还开着,清完的下一刻硬件又把它置了起来, 于是下一轮“等标志”的循环立刻返回。
现象非常有欺骗性:第一帧正常,之后每一帧都“提前完成”。 如果是接收,表现为长度反算出来是零或者一个很小的值; 如果是发送,表现为“发得飞快但对方什么都没收到”。 这类问题在单步调试时常常看不出来,因为单步的时候时序被打断了。
所以正确的顺序是:
- 先关通道,并且确认使能位读回零——写下去和真正生效之间可能有延迟。
- 再清全部相关标志:通道级的和控制器级的都要清, 只清一个的话,控制器级的那个还会把中断重新拉起来。
- 然后改长度/改地址,这是停止状态下唯一安全的窗口。
- 最后重新使能,从干净的状态开始下一轮。
同一个道理适用于外设侧的中断标志。 串口的空闲标志就是一个典型例子:它的清除方式不是“写一清零”, 而是要求先读状态寄存器、再读数据寄存器这样一串读操作, 漏掉后一步标志就清不掉,中断会被反复触发。 这类“清除方式各不相同”的标志,我现在的做法是在注释里直接写清楚 “这个标志靠什么动作清”,而不是指望后来人记得。
八、通道与请求映射的冲突:一次真实的“抢同一个流”
前面几节都是“配置和时序”层面的问题,这一类不是—— 它是硬件资源层面的冲突,软件再优化也绕不过去。
那块控制板上有四路 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, 搬完之后通常还要接着做滤波与阈值判断, 那一部分我在《采样数据的处理:环形缓冲、中值滤波与迟滞控制》里写过, 这里不重复。
十、几条我反复用到的经验
- 把八个参数写在纸上再写代码。写得越早,返工越少。
- 任何“改”都要先“停”。改长度、改地址、清标志、重新使能,四件事都只在停止状态下做。
- 先关再清。清标志时通道还开着,等于给自己埋一个“下一轮立刻完成”的雷。
- DMA 的传输完成不等于外设发完了。RS485 上切方向脚之前,一定要等到外设自己的发送完成标志。
- 接收长度是反算的,而且只在没绕圈时成立。有明确长度上界的协议,缓冲就按上界配,把“填满”当成异常。
- 等待都要有超时兜底。死等一个可能永远不来的标志,是把偶发故障升级成死机的最短路径。
- 资源冲突在选型阶段解决。画一张请求与通道的映射表,比在联调现场试半天便宜得多。
- 把结论写进注释。尤其是“这里为什么不用 DMA”这种决定,不写下来一定会被后来人改回去。
参考资料与说明
- 各厂商 Cortex-M 系列参考手册中关于 DMA 控制器(通道/流、请求映射、计数寄存器、传输完成与半完成标志、循环与单次模式)的章节,均为公开文档,具体位域名与映射关系以所用型号的手册为准。
- 各厂商串口章节中关于发送数据寄存器空(TXE)与发送完成(TC)两个标志的区别,以及空闲标志的清除方式,均属公开的外设使用知识。
- Modbus over Serial Line Specification and Implementation Guide 中关于最大报文长度与帧间静默间隔的规定,属于公开标准。
- 本文涉及的具体通道分配、缓冲长度与取舍过程,来自个人学习项目中的实际实现,示例代码为按个人理解重写的最小片段,函数名为伪代码,不代表任何产品或交付代码;关键参数已做通用化处理。
- 出于对个人项目实现细节的保护,本文只公开方法与思路;涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
- 文中出现的芯片与厂商名称仅用于说明技术方案,与相关厂商无隶属或授权关系。