前几天调试一块板子,串口打印日志还算正常,但一旦把波特率降到 9600,又在主循环里塞进一个传感器轮询任务,整个系统就开始一顿一顿的。用示波器看波形,串口的发送之间经常出现明显空隙,CPU 一直在“站在数据寄存器前面发呆”。后来我把这段搬运工作交给 DMA,效果立竿见影。这篇就展开聊聊 DMA 案例实践 2:存储器到串口数据传输,也就是内存到外设方向的 DMA 发送。
这篇内容适合谁?如果你是刚接触 DMA、只会用阻塞方式发串口数据的开发者,这里会有底层原理、完整配置步骤和可以直接抄的发送接口;如果你已经用过 DMA 但遇到过“最后一个字节丢了”“数据错乱”“重复启动不工作”这类问题,后面专门有一章讲我踩过的坑。这个方向是 DMA 最典型的应用之一,搞明白之后,ADC 定时采集、内存到内存拷贝这些场景也会一通百通。
1. 串口阻塞发送的痛点:为什么我决定启用 DMA
1.1 从一次数据丢帧说起
先算一笔账。假设波特率 115200,串口发送一个字节实际要占 10 个 bit(8 位数据 + 起始位 + 停止位),那么一个字节需要 10 / 115200 ≈ 86.8us。如果一帧应用层数据是 120 字节,用轮询方式发送,这一帧就需要约 10.4ms。听起来不算长,但如果 MCU 还要同时处理按键扫描、LED 刷新、传感器采集,这 10ms 就是白白耗在等待“发送数据寄存器空”上面的。
把波特率降到 9600 之后更夸张,一个字节需要约 1.04ms,120 字节就是 125ms。这个量级对于大多数实时性要求不高的系统已经非常难受了,更别说我上次调试的设备里,主循环里还有一个显示刷新任务,结果就是串口日志和显示流程互相影响,偶尔连传感器数据都来不及处理。
阻塞发送的代码通常是这样的:
for (uint16_t i = 0; i < len; i++) { while (!(UART1->SR & UART_SR_TXE)); // 等待发送数据寄存器空 UART1->DR = buf[i]; // 写一个字节 }这段代码的逻辑很简单,但在等待期间 CPU 没有别的事可做。如果串口速度再慢一点,这个等待就会被无限放大。问题不在代码写错,而在于“CPU 亲自搬砖”这个模式本身就浪费了计算资源。
1.2 DMA 的定位:把“搬数据”从 CPU 手里抽出来
DMA 的全称是 Direct Memory Access,翻译过来就是“直接存储器访问”。叫这个名字,是因为它可以在不经过 CPU 干预的情况下,直接在存储器和外设寄存器之间搬运数据。
在串口发送这个场景里,DMA 能把一整块内存缓冲区里的数据,按指定长度一个个搬到串口的数据寄存器里。每搬一个字节,DMA 会等待串口“准备好”再搬下一个。CPU 要做的只是在开局设置好源地址、目标地址、搬运长度,然后就可以干别的事去了。搬完后 DMA 还能拉一个中断通知你。
用一个生活化的类比来解释:CPU 是大厨,串口是传菜口。阻塞模式相当于大厨做完一道菜,亲自端到传菜口,再跑回厨房做下一道;DMA 模式相当于大厨把一批菜放到传送带上,传送带按节奏把菜送出去,大厨可以同时准备下一批菜。
本篇聚焦的方向是“存储器到串口”,在 DMA 术语里属于 Memory-to-Peripheral(存储器到外设)。对应的外设到存储器方向(比如串口接收、ADC 采集)可以等后面的案例实践再展开,原理是同一套,只是方向换一下。
2. DMA 传输的链路拆解:四个关键参数决定一次搬运
2.1 方向、地址、宽度、数量
任何一次 DMA 传输,都要先把下面这些参数定清楚:
| 参数 | 本次案例取值 | 说明 |
|---|---|---|
| 传输方向 | 存储器到外设 | 从内存缓冲区读数据,写入串口数据寄存器 |
| 外设地址 | 串口数据寄存器地址 | 每次搬运都写到同一个寄存器,外设地址不递增 |
| 存储器地址 | 发送缓冲区首地址 | 每搬运一个字节,内存地址自动加 1 |
| 数据宽度 | 8 位 | 串口发送按字节处理 |
| 传输数量 | 待发送字节数 | DMA 内部计数器,每次搬运后减 1 |
| 工作模式 | 普通模式 | 计数器减到 0 后停止,完成中断置位 |
这些参数里,最容易搞错的是地址递增方向。存储器到串口发送时,内存地址要递增,外设地址必须固定。反过来,如果设置成内存地址固定、外设地址递增,DMA 就会反复读取同一个字节,然后往一串地址越来越大的寄存器里写,轻则数据完全错误,重则跑飞到未知外设地址,触发总线错误。
数据宽度也要特别注意。串口数据寄存器本质上是 8 位有效,你在配置 DMA 时如果选了 16 位或 32 位宽度,每次搬运会写入半字或整个字,很多 MCU 的串口只会取低 8 位,结果就是每隔几个字节丢一次数据。这个坑我见过不止一次,排查时看寄存器配置,十有八九是宽度没对齐。
2.2 一次 DMA 传输的完整生命周期
配置完成后,一次存储器到串口的 DMA 传输会经历以下阶段:
- CPU 写完 DMA 控制寄存器、地址寄存器、传输数量寄存器,然后把 DMA 通道使能。
- DMA 进入空闲等待状态,此时没有数据流动。
- 串口发送数据寄存器变空(TXE 事件产生),硬件自动向 DMA 发出请求。
- DMA 响应请求,从源地址读取一个字节,写入外设数据寄存器。
- 传输计数器减 1,源地址按设定递增。
- 如果计数器还没有到 0,DMA 继续等待下一个 TXE 请求。
- 计数器减到 0 后,DMA 置传输完成标志,产生中断(如果开启了中断)。
- 在普通模式下,DMA 通道自动禁用,等待 CPU 下次配置。
注意第 3 步很关键:DMA 是“请求-应答”机制,不是把数据一口气从内存倒到串口。每个字节的搬运节奏完全由外设的请求信号决定。串口慢,DMA 就慢;串口快,DMA 就快。这正好是模拟阻塞发送的理想替代方案,只不过等待者从 CPU 变成了 DMA 硬件。
2.3 为什么串口的 TXE 会成为天然的搬运节拍
TXE 是串口发送数据寄存器为空的标志。只要这个标志置位,就意味着可以向数据寄存器写入下一个要发送的字节。注意,这个标志和“整个字节已经从 TX 引脚发完”是两回事,后面第 4 章会专门说。
在数据库里,DMA 会监控外设的请求线。串口一旦置位 TXE,请求线就有效,DMA 就执行一次搬运。整个过程是硬件握手,不需要软件参与,所以 CPU 完全不知道底层已经搬了多少个字节。只有在传输完成后,如果开了中断,CPU 才会收到通知。
这让我想起一个关键设计思路:DMA 与低速外设配合时,天然就是“异步发送”,你不需要在代码里手写一个状态机去等待发送完成。把发送函数调完后,接着跑主循环,逻辑上非常干净。
3. 代码实现:存储器到串口的 DMA 发送全流程
3.1 准备发送缓冲区与长度计算
代码的第一步是准备一块要发送的数据缓冲区。这里特别强调一点:缓冲区生命周期必须覆盖整个 DMA 发送过程,不能是函数内部的局部数组。原因很简单,DMA 是异步的,你调用发送函数后,DMA 还在后台搬数据,如果函数已经返回,局部数组所在栈空间被后续调用覆盖,DMA 搬出去的全是乱码。
我惯用的写法是定义全局或文件级静态数组:
static const uint8_t test_buf[] = "DMA 存储器到串口传输测试\r\n"; #define TEST_BUF_LEN (sizeof(test_buf) - 1) // 去掉结尾'\0',串口不需要如果缓冲区来自运行时的业务数据,可以维护一个全局数组,在确保 DMA 发送完成后再修改内容。
3.2 DMA 通道初始化与串口请求映射
初始化 DMA 之前,要先查芯片手册确认两件事:第一,你用的串口发送请求被映射到哪个 DMA 通道;第二,串口数据寄存器的地址是多少。不同 MCU 差异很大,但从寄存器配置的角度,逻辑基本一致。
下面我用寄存器风格写出核心配置,实际使用的位段定义以你自己的芯片参考手册为准:
void dma_uart_init(void) { // 1. 使能 DMA 控制器时钟(查手册找到对应外设时钟门) enable_dma_clock(); // 2. 关闭 DMA 通道,防止配置过程中被触发 DMA_CHANNEL.CTRL &= ~DMA_CTRL_EN; // 3. 通道方向:存储器 -> 外设 DMA_CHANNEL.CTRL |= DMA_CTRL_DIR_MEM_TO_PERIPH; // 4. 外设地址固定,内存地址递增 DMA_CHANNEL.CTRL |= DMA_CTRL_PINC_DISABLE; DMA_CHANNEL.CTRL |= DMA_CTRL_MINC_ENABLE; // 5. 数据宽度:8位 DMA_CHANNEL.CTRL |= DMA_CTRL_PSIZE_8BIT; DMA_CHANNEL.CTRL |= DMA_CTRL_MSIZE_8BIT; // 6. 工作模式:普通模式,不循环 DMA_CHANNEL.CTRL &= ~DMA_CTRL_CIRC; // 7. 使能传输完成中断(可选) DMA_CHANNEL.CTRL |= DMA_CTRL_TCIE; // 8. 外设地址:串口数据寄存器 DMA_CHANNEL.CPAR = UART_DR_ADDR; }初始化只做一次。真正每次发送时,只需要重新设置内存地址和传输数量,然后使能通道。
3.3 启动一次发送的函数封装
普通模式下,每次发送都要走一套固定流程。我习惯封装成下面这样:
void dma_uart_send(const uint8_t *buf, uint16_t len) { // 0. 等待上次传输完全结束 while (DMA_CHANNEL.CTRL & DMA_CTRL_EN) { // 如果通道还在使能状态,说明上次没有完成,一直等 } // 1. 关闭 DMA 通道 DMA_CHANNEL.CTRL &= ~DMA_CTRL_EN; // 2. 重装传输长度 DMA_CHANNEL.CNDTR = len; // 3. 重新设置源地址(内存缓冲区) DMA_CHANNEL.CMAR = (uint32_t)buf; // 4. 清除上次可能的完成标志 DMA_CHANNEL.IFCR |= DMA_IFCR_CTCIF; // 5. 使能 DMA 通道 DMA_CHANNEL.CTRL |= DMA_CTRL_EN; // 6. 使能串口的 DMA 发送请求 UART1->CR3 |= UART_CR3_DMAT; }这个函数有两点值得解释。
为什么第 0 步要等待通道关闭?因为普通模式完成传输后,DMA 会自动把 EN 位清 0,但如果你在传输进行中再次调用该函数,第 2 步重装 CNDTR 会直接破坏正在进行的搬运,导致当前帧错乱。等待 EN 位归零是一种简单粗暴的互斥保护,虽然等的时候 CPU 会被占用一小段时间,但安全优先。
为什么第 6 步要放在最后?串口的 DMA 发送请求一旦使能,只要 TXE 置位,DMA 就会立刻开始搬运。如果 DMA 通道没有全部配置好就把串口 DMAT 位打开,可能出现用旧地址、旧长度发送的情况。把使能串口 DMA 请求放在最后,能保证配置一致性。
3.4 用中断知道发送什么时候结束
普通模式发送完成后,DMA 会置位传输完成标志。如果初始化时开了 TCIE 中断,就会进入中断服务函数:
void DMA_CH_IRQHandler(void) { // 检查传输完成标志 if (DMA_CHANNEL.IFCR & DMA_IFCR_CTCIF) { // 先清标志 DMA_CHANNEL.IFCR |= DMA_IFCR_CTCIF; // 关闭串口 DMA 发送请求,避免残留请求 UART1->CR3 &= ~UART_CR3_DMAT; // 通知业务层,发送完成 uart_tx_done_flag = 1; } }很多人问,传输完成中断里到底要不要关闭串口的 DMAT 位?我的建议是:普通模式下,DMA 传输计数到 0 后通道自动禁用,串口的 DMAT 位其实不会再触发搬运,但留着它不构成危害。不过有些 MCU 上,如果后续切换到串口阻塞发送,DMAT 位没有关闭,会导致 DMA 和 CPU 同时往数据寄存器写,这个就非常危险。所以养成习惯,在完成中断里一并关掉 DMAT。
4. 实测中必须绕开的坑:缓冲区、标志位与重复启动
4.1 局部缓冲区与异步发送的生命周期冲突
前面提过局部数组的问题,这里展开讲一个真实调试场景。某次我图省事,在函数里写了这样的代码:
void send_telemetry(void) { uint8_t buf[48]; fill_telemetry(buf); dma_uart_send(buf, sizeof(buf)); }第一版测试时看起来没问题,但只要在这个函数后面再加上别的局部数组操作,串口收到的数据就开始出现一段内容是上一个函数的残留。排查到最后发现,dma_uart_send 返回时,DMA 其实才搬了一小半,buf 还在栈上;紧接着另一个函数调用覆盖了同一块栈内存,DMA 后面搬出来的自然就是新内容。
正确做法有三个方向:把 buf 改成全局数组;在业务层确保 DMA 完成前不释放内存;或者用双缓冲来交替。对于高频数据采集,双缓冲是我最推荐的方案,会在后面细说。
4.2 发送未完成就被再次写入新旧数据混合
就算缓冲区是全局的,还有一个常见问题:上一帧 DMA 还没搬完,主循环已经把新数据写进了同一个缓冲区。结果就是串口发出的一帧数据前一半是上一帧,后一半是这一帧,看起来像乱码,但仔细看又觉得有规律。
解决这个问题的核心是“发送前确认上一帧结束”。一个简单的做法是在写入缓冲区之前检查 DMA 忙标志:
while (DMA_CHANNEL.CTRL & DMA_CTRL_EN) { // 等待 DMA 通道停止 } memcpy(tx_buf, new_data, len); dma_uart_send(tx_buf, len);这样虽然会阻塞主循环,但能保证数据一致性。对于实时性要求更高的场景,用双缓冲更合适:进程 A 往 buffer[0] 填数据,DMA 正在发送 buffer[1];发送完成中断里交换两个 buffer 的角色,两个进程不会在同一时刻操作同一个缓冲区。
4.3 DMA 完成中断不是串口发送完成
这是最容易忽略的细节。DMA 的传输完成信号,代表的是“DMA 已经把最后一个字节写入串口数据寄存器”,而不是“最后一个字节已经从 TX 引脚发送出去”。
串口发送一个字节需要时间,哪怕写入数据寄存器后,移位寄存器还要把 8 位数据和起始停止位移出去。如果 DMA 完成中断里立刻把串口禁用、进入低功耗、或者切换串口引脚复用功能,最后这一两个字节就会直接从物理上被截断。
具体表现是:用示波器看,发送帧的尾部少了几位,或者最后一个字节完全消失。波特率越低越明显,因为每个字节占用的时间线性拉长。
遇到这种情况,我通常的解决办法是:在 DMA 完成中断里不急着做“破坏性”操作,只清理 DMA 状态;要真正关闭串口或休眠,必须等串口发送完成标志(TC)置位。实操代码可以这样:
if (uart_tc_flag) { // 等上一个字节完全发送完 }或者更省事一点:开启串口的发送完成中断,在 TC 中断里再做关串口、置业务标志之类的操作。这样把“DMA 搬运完成”和“物理发送完成”两个时刻彻底分开,就不再丢尾字节了。
4.4 重复启动 DMA 时的配置失效
普通模式下,DMA 传输完成后,通道会自动禁用,但很多固件库不会自动清除控制寄存器里的使能位。如果你不先关闭通道,直接重新写 CNDTR、CMAR,然后发现 DMA 不工作,先怀疑是不是这个原因。
我每次重复启动用的是一个固定套路:等待通道空闲,关闭通道,重装长度,重设源地址,清完成标志,使能通道,最后使能串口 DMAT。四步顺序不能乱。尤其是“清完成标志”这一条,很多初学者容易漏掉。如果上一次传输完成标志还在,而你又刚好开启了传输完成中断,那么使能通道后中断会立刻进入一次,逻辑上就像多触发了一次完成事件。
还有一个更隐蔽的情况:DMA 通道正在进行传输,你在中断外修改了 CNDTR,结果当前帧提前结束,字节丢失。所以重复启动前一定要确认通道 EN 位已经清除,不能用“感觉应该发完了”来判断。
4.5 带 D-Cache 内核的缓存一致性问题
如果你的 MCU 内核带数据缓存(常见于 Cortex-M7 或更高端的型号),DMA 和 CPU 之间还有一个隐含的“缓存一致性问题”。
CPU 写一块内存缓冲区后,数据可能还躺在 D-Cache 里,没有真正写回 SRAM。DMA 读取时,如果 DMA 不走缓存路径,就会读到旧数据,发送出去的全是乱码。反过来,DMA 往内存里写数据后,CPU 去读,也可能读到缓存里的旧值。
解决思路有两种:一是发送前调用 clean 操作,把缓存中这一区域的数据写回内存;二是把 DMA 缓冲区放到非缓存(non-cacheable)内存区。很多 MCU 的链接脚本里都预留了 DMA 专用 RAM 段,这个段默认不带 cache,就是为了省去手动清理的麻烦。
我在某次带有 D-Cache 的平台上调试时,发出去的 64 字节里前面 32 字节正确、后面 32 字节全部是旧数据,就是因为前面 32 字节正好还在 cache line 里没写回。最后加了 clean 缓存操作,问题立刻消失。如果你的平台没有 cache,可以从这一节直接略过,但你知道了这个坑,以后换平台时不容易被坑。
5. 验证与性能:让数据说话
5.1 用逻辑分析仪确认 DMA 发送真的在工作
代码写完,怎么确认 DMA 真的把数据搬出去了?最直接的方法是拉一根逻辑分析仪到 TX 引脚,观察波形。我一般会先在缓冲区里填入有规律的字节,比如交替的 0x55 和 0xAA。
原因很简单,0x55 的二进制是 01010101,0xAA 是 10101010,在波形上能非常清楚地看到每个 bit 的翻转。如果你发送的结果出现连续的 0 或 1,通过对比波形,就能快速判断数据是错位、丢字节还是纯粹没发出来。
另一个验证方法是额外占用一个 GPIO。在 DMA 完成中断里翻转这个引脚,用示波器观察,从启动 DMA 到 GPIO 翻转的时间间隔应该约等于整帧数据发送的时间。比如 10 字节 @ 115200,理论上约 868us,实测如果有 900us,说明 DMA 传输和中断链路都是正常的。这一步能帮你把问题范围缩小到“DMA 没有搬运”还是“搬运完成后应用层没处理”。
5.2 CPU 占用实测对比
我用一个最简单的 GPIO 翻转实验对比阻塞发送和 DMA 发送的效果:主循环里翻转一个 LED,同时周期性调用串口发送函数。
阻塞发送时,LED 翻转周期明显变长,因为 CPU 在串口发送期间被占住了。改成 DMA 发送后,dma_uart_send 函数只消耗几十微秒的配置时间,随后 CPU 马上回到主循环继续翻转 LED,LED 的闪烁周期几乎不变化。
理论数据也可以算出来:阻塞方式发送 120 字节 @ 115200,CPU 需要等待约 10.4ms;DMA 方式下,CPU 只花配置寄存器的时间,通常在几微秒到几十微秒之间,按 10us 算,节省了 99.9% 的等待时间。对于低速串口,这个收益只会更明显。
当然,DMA 也不是完全没有代价。DMA 搬运数据时会占用系统总线的带宽,在高负载系统中,如果同时有多个 DMA 通道、CPU 频繁访问外部存储器,总线仲裁带来的少量延迟是存在的。但相比 CPU 全程空转,这点延迟通常可以忽略。
5.3 什么时候不该用 DMA
DMA 并不是无脑越用越好。如果数据帧只有 2~3 字节,而且发送频率非常高(比如 1ms 一次),你每次配置 DMA 的代码和中断开销,可能比直接阻塞发送多不少,而且还会占用一个 DMA 通道。
我的经验是:单次发送长度在几十字节以上,或者 CPU 繁忙程度较高时,DMA 收益明确;如果只是偶尔发一个字节的应答,继续用阻塞发送反而简单可靠。选择标准其实就一条:算一下阻塞等待的总时间,和 DMA 配置加中断处理的总开销,谁更划算就用谁。
实际项目中我也见过因为 DMA 中断过于频繁,导致主循环响应变慢的例子。问题不在 DMA 本身,而在中断调用频率太高。这种时候不一定非要回归阻塞,也可以把多次短发送合并成一帧批量发送,效果一样。
6. 从案例实践到自己的发送框架
做完整轮验证之后,我现在已经把串口发送模块统一改成异步接口,核心思路是:业务层只负责往发送队列里塞数据,发送函数只做 DMA 配置,真正的发送由中断驱动完成。这样主循环里不会再出现任何和串口发送等待相关的代码。
在这个案例里,我最深的一点体会是:DMA 发送和普通函数调用有本质区别,它是异步的、并行的、由硬件节拍驱动的。你必须在写代码之前想清楚缓冲区生命周期、完成标志语义、重复启动顺序,否则 DMA 带来的自由感立刻会被乱码和丢字节打碎。
建议你从一个小实验开始:准备一个 64 字节的全局数组,用 DMA 循环发送 0x55/0xAA 交替数据,先不接任何业务逻辑,用逻辑分析仪确认波形正确,再一步步加入中断、双缓冲、队列。比起直接改项目里的串口发送层,这样能更快建立对 DMA 的信心。