STM32 DMA 学习笔记:从搬运工原理到串口/ADC 实战,一篇讲透
本文是一篇系统的 DMA(Direct Memory Access,直接存储器访问)学习笔记:前半部分讲透 DMA 是什么、为什么需要它、STM32 DMA 控制器的架构(通道/仲裁/三要素/模式/中断),后半部分用 CubeMX + HAL 完成四个实战(内存搬运 → 串口 DMA 发送 → 串口 DMA + 空闲中断不定长接收 → ADC 多通道 DMA),最后附 HAL 速查表、FAQ 和面试考点。
本文是本系列 ADC 篇、定时器篇的姊妹篇,三篇联合阅读效果更佳。
适合:单片机/嵌入式入门同学,以及需要快速复习 DMA 应付笔试面试的同学。
阅读路线
-
赶时间直接上手 → 看 第四章 四个实战;
-
面试/考试抱佛脚 → 看 第六、七章 速查表和 FAQ;
-
想真正理解 → 从头读,重点吃透第二、三章。
一、DMA 是什么:CPU 雇来的"专职搬运工"
先想一个场景:串口以 1 Mbps 波特率疯狂接收数据,每个字节 10µs 就到一颗。如果让 CPU 逐字节去中断里读 DR 再写进内存,CPU 基本别干别的了;主程序一卡顿,数据立刻溢出丢失。
DMA(Direct Memory Access,直接存储器访问)就是来解决这类问题的:它是一块独立于 CPU 的硬件,专门负责"把数据从 A 搬到 B"——整个过程不经过 CPU 的寄存器、不消耗 CPU 指令周期。

DMA 能搬三种方向的货:
| 方向 | 典型场景 |
|---|---|
| 外设 → 内存 | 串口接收、ADC 采样、SPI 收数 |
| 内存 → 外设 | 串口发送、SPI 发显示数据、DAC 输出波形 |
| 内存 ↔ 内存 | 大块数组复制(可以当"硬件 memcpy"用) |
💡 一句话理解:CPU 是大脑,DMA 是传送带。 大脑负责"决定搬什么、搬到哪、搬多少",传送带负责执行,搬完了再通知大脑验收。
用 DMA 的三大好处:
-
解放 CPU:搬运期间 CPU 跑自己的主程序;
-
高速不漏:硬件逐字节搬,1Mbps 数据流、1Msps 采样都稳收;
-
省电省时:搬完一次统一中断一次即可,不用每字节打断 CPU。
二、STM32 DMA 架构详解
2.1 控制器与通道
STM32F103 有 DMA1(7 个通道),大容量型号另有 DMA2(5 个通道)。每个通道(Channel)可以理解成一条独立的传送带,可以独立配置源、目的、数量、宽度、优先级。

看图记住四个角色:
-
外设请求线:外设(串口、ADC、SPI…)想搬数据时,通过专用请求线"举手"申请;
-
仲裁器:多个通道同时举手时,按优先级决定谁先搬(见 2.3);
-
通道引擎:真正干活的单元,每个通道有"三件宝"——CPAR(外设地址)、CMAR(内存地址)、CNDTR(剩余传输数量,每传一个自动减 1);
-
中断输出:每个通道独立产生 HT(半满)/ TC(全满)/ TE(错误)中断,提醒 CPU 来验收。
2.2 通道请求映射:硬件连线定死的
哪个外设的请求接到 DMA1 的哪个通道,是芯片内部物理连线固定的,软件不能随意改。常用映射(F103):

| 通道 | 常见外设请求 |
|---|---|
| 通道1 | ADC1(独占!ADC 走 DMA 就用它) |
| 通道2 | SPI1_RX、USART3_TX |
| 通道3 | SPI1_TX、USART3_RX |
| 通道4 | USART1_TX、SPI2_RX |
| 通道5 | USART1_RX、SPI2_TX |
| 通道6 | USART2_RX、I2C1_TX |
| 通道7 | USART2_TX、I2C1_RX |
⚠️ 不用背表!CubeMX 里从某个外设的 DMA Settings 界面点 Add,该外设所有可用的 DMA 通道会自动列出来,照着选即可。
注意:F103 的 ADC2/ADC3 没有独立 DMA 请求(想走 DMA 得借双 ADC 模式经 ADC1 的通道);TIM2/3/4 的更新/捕获事件也都有固定的 DMA 通道(可以用来做定时器触发 DMA 的高级玩法)。
2.3 仲裁与优先级:谁前先搬?

两级裁决:
-
软件优先级(每个通道可配 4 档):很高 Very High > 高 High > 中 Medium > 低 Low;
-
硬件通道号:同一软件优先级内,通道号小的优先。
举例:CH4(很高) vs CH2(高)同时请求 → CH2 先;CH4(很高) vs CH5(很高)→ CH4 先(4 < 5)。裁决是"逐次"进行的,低优先级不会被永久饿死。
📌 实战建议:把"绝不能丢数据、实时性最强"的通道(比如高速 ADC、高速串口 RX)设成很高/高优先级;把"慢也无所谓"的(内存拷贝、日志发送)设成中低优先级。
三、一次 DMA 传输的完整流程
3.1 配置 DMA = 回答三个问题

-
源地址:从哪搬?(外设寄存器如
USART1->DR,或内存数组起始地址) -
目的地址:搬到哪?(方向:外设→内存、内存→外设、内存→内存)
-
数据个数(NDTR):搬多少个?每传 1 个 CNDTR 自动减 1,减到 0 传输完成。
3.2 数据宽度与地址自增(两大易错点)

-
数据宽度(PSIZE/MSIZE):可选 8 / 16 / 32 位,两端可以不同,硬件自动打包/拆包;
-
地址自增:内存侧一般开 MINC(搬完一个自动指向下一个);外设侧一般关 PINC(始终指向同一个 DR);
-
实战守则:尽量让"外设宽度 = 内存宽度"(串口 uint8_t、ADC uint16_t),否则极易数据错位、全是乱码。
3.3 两种工作模式:Normal vs Circular

| Normal 普通模式 | Circular 循环模式 | |
|---|---|---|
| 行为 | CNDTR 减到 0 → 传输停止、通道关闭 | 减到 0 → 自动重装,循环往复 |
| 重启 | 需软件重新配置启动 | 不用管,永不停止 |
| 典型用途 | 一次性任务(发一包数据、搬一块数组) | 连续采集/接收(ADC 多通道、串口监听) |
3.4 DMA 中断:HT / TC / TE
每个通道能产生三种中断(在 NVIC 里给对应 DMA 通道勾中断使能):
-
HT(Half Transfer)半满中断:搬完一半;
-
TC(Transfer Complete)全满中断:搬完全部;
-
TE(Transfer Error)错误中断:总线错误(极少用,调试配置时有用)。

HT+TC 组合在循环模式下有个绝活——乒乓/双半缓冲:前半灌满时中断处理前半(DMA 继续灌后半),全满时处理后半(DMA 回卷灌前半),采集永不停歇,处理永远不慌。
四、上手实战(STM32CubeMX + HAL)
四个实战从易到难,通用配置流程一张图:

4.1 实战一:内存到内存搬运(理解 DMA 最快的例子)
目标:把 src[] 数组内容用 DMA 复制到 dst[],相当于"硬件版 memcpy"。这是唯一不需要外设参与的 DMA 用法,最适合验证 DMA 通路。
CubeMX 配置:DMA1 → Add → 选 MEMTOMEM(内存到内存),任意通道;Normal 模式;两边数据宽度都选 Byte(或一致即可);内存地址 Increment 勾上。
用户代码:
uint8_t src[] = "DMA memory-to-memory demo!";
uint8_t dst[64] = {0};
HAL_DMA_Start(&hdma_memtomem_dma1_channel1,
(uint32_t)src, (uint32_t)dst, sizeof(src));
/* 阻塞等待传输完成 */
HAL_DMA_PollForTransfer(&hdma_memtomem_dma1_channel1,
HAL_DMA_FULL_TRANSFER, 100);
if (memcmp(src, dst, sizeof(src)) == 0)
printf("DMA memcpy 成功!dst = %s\r\n", dst);
else
printf("数据不一致,检查配置\r\n");
要点解析:
-
mem2mem 模式下"外设地址"填的是源内存地址;
-
这是唯一能直观看到"DMA 完成"的最小例子,跑通它说明 DMA 硬件、时钟、NVIC 一切正常。
4.2 实战二:串口 DMA 发送(CPU 下令即走)
目标:用 DMA 发送一大段数据(如日志、传感器数据帧),发送期间 CPU 完全不参与。
CubeMX 配置:USART1 开异步 115200 → DMA Settings → Add → USART1_TX(自动选中 DMA1 通道4,方向 M2P,Normal,优先级按需勾选)。
用户代码:
uint8_t tx_buf[] = "Hello, this is a DMA UART TX demo!\r\n";
HAL_UART_Transmit_DMA(&huart1, tx_buf, sizeof(tx_buf) - 1);
/* 发送完成回调:N 字节全部搬完自动进这里 */
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
// 可以在这里:发下一帧、置发送完成标志、关串口省电…
}
}

要点解析:
-
调一句
HAL_UART_Transmit_DMA就完事,HAL 自动配置 DMA 源/目的/数量并启动外设请求; -
⚠️ 细坑:DMA 的 TC 只代表"字节全塞进 DR 了",最后一个字节的最后一位还在串口的移位寄存器里"跑"!断电、切方向、改复用前要等 UART 的 TC 标志(
__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC))才算真正发完,否则会丢尾巴。
4.3 实战三:串口 DMA + 空闲中断,不定长接收(明星方案)
这是串口接收的终极方案,每个嵌入式工程师必须会:
-
问题:串口 DMA 接收需要预先告诉它"收多少个字节",但现实中数据包长短不一;
-
解法:DMA(循环或普通) + 串口空闲中断 IDLE。IDLE 的触发条件是"RX 线连续约 1 字节时间没有新数据"——正好当成"这一帧收完了"的信号。

CubeMX 配置:USART1 → DMA Settings → Add → USART1_RX(Normal 或 Circular 均可,推荐 Normal + 手动重启,更好理解);NVIC 把 USART1 全局中断勾上(注意是串口中断,不是 DMA 中断——我们用串口的 IDLE 标志)。
用户代码(经典手写版,全系列通用):
#define BUF_SIZE 100
uint8_t rx_buf[BUF_SIZE];
volatile uint16_t rx_len = 0; // 实际收到长度
/* 初始化:使能 IDLE 中断 + 启动 DMA 接收 */
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE);
/* 串口中断服务函数(USER CODE 区追加 IDLE 检测) */
void USART1_IRQHandler(void)
{
HAL_UART_IRQHandler(&huart1);
/* USER CODE BEGIN USART1_IRQn 1 */
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) // 总线空闲了 → 一包收完
{
__HAL_UART_CLEAR_IDLEFLAG(&huart1); // 必须清标志!
HAL_UART_DMAStop(&huart1); // 停DMA
rx_len = BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 实际长度
/* 处理 rx_buf[0 .. rx_len):回显示例 */
HAL_UART_Transmit_DMA(&huart1, rx_buf, rx_len);
HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE); // 重启,等下一包
}
/* USER CODE END USART1_IRQn 1 */
}
要点解析:
-
算长度公式是灵魂:已收长度 = 缓冲区总长 − DMA 还剩没搬的个数(
__HAL_DMA_GET_COUNTER); -
中断里三件事(停 DMA → 算长度处理 → 清标志重启)要快进快出,别在里面干耗时操作;
-
新版 HAL 库(CubeF1 1.8+,F4/H7 等)还提供更省事的一键 API:
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, BUF_SIZE)+HAL_UARTEx_RxEventCallback(huart, Size),回调里Size就是收到的长度,原理一模一样。
4.4 实战四:ADC + DMA 多通道采集(呼应本系列 ADC 篇)

CubeMX 配置:ADC1 开扫描+连续模式(多通道)→ DMA Settings → Add ADC1(Circular 循环,HalfWord 宽度)→ NVIC 勾 DMA1_Channel1 中断。
用户代码:
uint16_t adc_buf[200]; // 乒乓缓冲:前100/后100
HAL_ADCEx_Calibration_Start(&hadc1);
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 200);
/* 前一半灌满(buf[0:100))时处理前一半 */
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
// 处理 adc_buf[0..99]
}
/* 全满时处理后一半 */
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
// 处理 adc_buf[100..199]
}
要点解析:
-
ADC 的 DMA 必须用 Circular + 循环模式,让采集永不停歇;
-
数据宽度必须选 HalfWord(ADC 的 DR 是 16 位)——选错会出现"数据全是 0 或错位";
-
乒乓缓冲让 CPU 有"半个缓冲的时间窗"处理数据,这是信号采集/音频流的标配写法。
五、HAL DMA API 速查表
| 功能 | 函数 / 宏 | 说明 |
|---|---|---|
| 内存搬运 | HAL_DMA_Start(&hdma, src, dst, Len) |
mem2mem 专用 |
| 阻塞等待 | HAL_DMA_PollForTransfer(&hdma, level, timeout) |
等半满/全满 |
| 串口 DMA 发送 | HAL_UART_Transmit_DMA(&huart, pData, Size) |
|
| 串口 DMA 接收 | HAL_UART_Receive_DMA(&huart, pData, Size) |
配 IDLE👑 |
| 串口一键不定长收 | HAL_UARTEx_ReceiveToIdle_DMA(...) |
新版 HAL |
| ADC DMA | HAL_ADC_Start_DMA(&hadc, buf, Len) |
|
| SPI DMA | HAL_SPI_Transmit_DMA / Receive_DMA |
|
| 查剩余数量 | __HAL_DMA_GET_COUNTER(&hdma) |
算实际收到长度必备 |
| 清标志 | __HAL_DMA_CLEAR_FLAG(&hdma, FLAG) |
|
| 回调 | HAL_DMA_XferCpltCallback 等 |
各外设有自己的简化回调 |
六、常见问题 FAQ
Q1:DMA 启动了,数据一动不动? A:按顺序查:方向配反没有(M2P/P2M)?内存地址自增勾了没?数据宽度两边匹不匹配?外设的 DMA 请求使能了没(HAL 封装的一般自己来,裸写寄存器时容易漏)?NDTR/数量是不是填成了 0?
Q2:串口 DMA 发送,最后一字节丢了? A:见 4.2 节的坑——DMA 是把字节塞进 DR,不等于串口物理发完。断电/切换前等 UART 的 TC 标志位。
Q3:循环模式下,缓冲里的数据好像被"踩"了? A:处理速度跟不上采集速度,DMA 回卷把你还没处理完的旧数据覆盖了。加大缓冲、改用乒乓缓冲提高时间窗、或者提升处理效率(别在 DMA 中断里做耗时处理)。
Q4:串口 DMA 接收,偶尔收到乱码/错位? A:大概率是数据宽度没配成 Byte(8 位),或者你用了循环模式但每次空闲中断没正确处理回卷——循环 + IDLE 要自己算环形边界,新手建议用 Normal + 手动重启,代码更直白。
Q5:ADC+DMA 读数全是 0 或全是 65535? A:数据宽度!ADC 的 DR 是 16 位,必须按 HalfWord 配置;数组也一定要是 uint16_t。另一个常见原因是 MX_DMA_Init 和 MX_ADC_Init 的顺序,DMA 要先初始化。
Q6:F7/H7 上 DMA 明明搬完了,CPU 读到的却是旧数据? A:这是 Cortex-M7 的 D-Cache 一致性问题(F1/F4 没有这烦恼):DMA 写的是物理内存,CPU 读的是缓存。要么调用 SCB_InvalidateDCache_by_Addr() 让缓存失效,要么用 MPU 把 DMA 缓冲区设成"No Cache"区。
Q7:多个外设想用同一个通道? A:一个通道同一时刻只能服务一个请求。想用就分时复用(用前先禁用、重新配置源/目的/数量再启动),或者干脆分开用不占通道(比如串口接收用 DMA,发送状态机也不错)。
Q8:mem2mem 有"专属通道"吗? A:没有,任意通道都行。CubeMX 里选 MEMTOMEM 即可。
七、知识点速查表(背完敢去面试)
-
DMA = CPU 的专职搬运工,搬运过程 0 CPU 占用;
-
三方向:外设↔内存、内存↔内存;
-
STM32F1:DMA1 7 通道 / DMA2 5 通道(大容量);
-
通道三要素:源地址、目的地址、数据个数 CNDTR(每传 1 个 −1);
-
通道映射是硬件固定连线,CubeMX 自动列出可用通道;F103 的 ADC2/3 无独立 DMA;
-
仲裁两级:软件 4 档优先级 → 同级拼通道号(小号优先);
-
数据宽度 8/16/32 位两端可不同,硬件自动打包;实战尽量两边一致;
-
内存侧开自增 MINC,外设侧关自增 PINC;
-
模式:Normal = 一次就停 / Circular = 环形循环;连续采集必选 Circular;
-
中断三剑客:HT 半满、TC 全满、TE 错误;HT+TC 组合 = 乒乓/双半缓冲;
-
串口三件套:TX DMA 发送 / RX DMA + IDLE 空闲中断不定长收;
-
算收到长度:len = 缓冲总长 − __HAL_DMA_GET_COUNTER;
-
经典坑:DMA 的 TC ≠ 串口物理发完(要等 UART TC);F7/H7 要管 D-Cache。
八、进阶阅读方向
-
定时器触发 DMA 给 DAC 喂任意波形(正弦/任意信号发生器);
-
DMA 双缓冲 + CMSIS-DSP FFT,做实时频谱分析(与本系列定时器/ADC 篇联动);
-
环形缓冲区(Ring Buffer)+ DMA + IDLE,工业级串口数据流架构;
-
F4/H7 的 DMA 控制器增强:FIFO、突发传输(Burst)、双缓冲模式(DBM);
-
SDIO + DMA 驱动 SD 卡高速读写,SPI + DMA 驱动 TFT 全屏刷新。
参考资料
-
ST《STM32F10x 参考手册 RM0008》第 12 章:DMA controller (DMA)
-
【CSDN】STM32 串口通信 DMA 接收 + 空闲中断 IDLE 详解:STM32串口通信DMA接收 + 空闲中断IDLE详解_stm32 uart dma idle-CSDN博客
-
【CSDN】STM32 HAL库串口 DMA 空闲中断的正确使用方式:STM32-HAL库串口DMA空闲中断的正确使用方式+解析SBUS信号_stm32 hal usart2 dma-CSDN博客
-
【CSDN】DMA + 串口空闲中断 + 环形缓冲区:STM32 串口 (DMA + 空闲中断 + 环形缓冲区)_串口dma 空闲中断-CSDN博客
-
本系列姊妹篇:《ADC 学习笔记》《STM32 定时器学习笔记》
📝 这篇笔记希望帮你建立"DMA = 传送带"的整体画面:先懂它为什么存在、再懂它怎么搬、最后把串口和 ADC 两个最常用的实战跑通。文中如有疏漏,欢迎评论区指正,一起进步!
更多推荐




所有评论(0)