嵌入式DMA配置实战:从原理到STM32 UART收发优化
最近在优化一个嵌入式系统的数据采集模块时,遇到了一个棘手的问题:系统在连续高速传输数据时,偶尔会出现数据错位或丢失。经过排查,发现是DMA(直接存储器访问)配置不当导致的。DMA作为现代处理器中解放CPU、提升数据传输效率的核心外设,其配置细节直接关系到系统的稳定性和性能。本文将围绕DMA,特别是26DMA-11(一种常见的DMA控制器型号/配置场景),从原理到实战,完整拆解其配置流程、常见陷阱及优化方案。无论你是刚接触嵌入式开发的新手,还是正在调试具体DMA问题的工程师,都能从中找到清晰的步骤和可复用的代码。
1. 背景与核心概念:为什么需要DMA?
在深入26DMA-11之前,我们必须理解DMA是什么,以及它解决了什么问题。
1.1 DMA是什么?
DMA(Direct Memory Access,直接存储器访问)是一种允许特定硬件子系统(外设)直接读写系统内存,而无需中央处理器(CPU)介入的技术。你可以把它想象成一个“数据搬运工”。
在没有DMA的系统中,如果外设(如ADC、UART、SPI)需要传输数据到内存,流程是这样的:
- 外设产生数据,触发中断。
- CPU响应中断,暂停当前任务。
- CPU执行中断服务程序(ISR),从外设寄存器中读取一个数据单元(如一个字节)。
- CPU将这个数据单元写入内存的目标地址。
- CPU恢复被中断的任务。
这个过程对于少量、低速数据尚可接受。但对于高速、大批量的数据传输(如摄像头采集图像、音频流处理、网络包收发),频繁的中断和CPU参与会消耗大量计算资源,导致系统响应变慢,甚至无法处理高速数据流。
DMA的出现,正是为了将CPU从这种简单重复的“搬运工”角色中解放出来。DMA控制器接管了数据在内存与外设之间(或内存与内存之间)的传输工作。传输过程中,CPU只需在传输开始前配置好DMA控制器(告诉它源地址、目标地址、传输数量等),传输完成后处理一次中断即可,期间可以继续执行其他任务,极大地提高了系统整体效率。
1.2 26DMA-11的含义解析
“26 DMA 26DMA-11”这个标题可能让人困惑。在嵌入式开发中,这通常不是指一个具体的产品型号,而更可能是一种特定场景或配置的简称。结合常见实践,我们可以从两个角度理解:
- 指代一种DMA控制器配置模式或通道:在某些微控制器(如STM32系列)中,DMA控制器有多个流(Stream)和通道(Channel)。编号可能指代特定的DMA和流/通道组合。例如,“DMA2 Stream 6”可能被简写为“26DMA”,而“Channel 11”可能指该流映射到的外设请求源。这是一种在工程师间交流时,用于快速定位具体DMA资源的说法。
- 指代一个具体的应用场景:例如,使用DMA2的某个流(Stream 6)来处理来自某个外设(其通道号为11,可能是定时器、ADC等)的数据传输。标题“26DMA-11”可能就是指代这个具体的“DMA2-Stream6-Channel11”配置组合。
为了普适性,本文将以STM32系列微控制器的DMA为例进行讲解,因为其DMA架构清晰,应用广泛。文中涉及的原理、配置步骤和代码思想,同样适用于其他包含DMA控制器的芯片(如GD32、NXP的Kinetis系列等),只需根据具体的数据手册调整寄存器名称即可。
2. 环境准备与版本说明
在开始实战之前,我们需要搭建好开发环境。
- 硬件平台:本文以STM32F407 Discovery板为例,其核心是Cortex-M4内核的STM32F407VGT6。其他STM32F1/F2/F3/F4/H7系列芯片原理类似。
- 开发环境:
- IDE: STM32CubeIDE 1.10.0 或更高版本(集成STM32CubeMX配置工具和GCC编译链)。
- 固件库: HAL库(硬件抽象层)。本文使用HAL库进行开发,因其封装性好,便于快速上手。对于追求极致性能和代码大小的项目,可以考虑直接操作寄存器或使用LL库。
- 调试工具: ST-LINK/V2调试器(或板载的ST-LINK),用于程序下载和调试。
- 示例项目目标: 实现通过DMA,将数组中的数据自动发送到串口(USART),同时通过DMA将串口接收到的数据自动存入另一个数组,全程无需CPU参与数据传输。
版本兼容性说明: HAL库的API在不同版本的STM32CubeMX/CubeIDE中可能略有差异。本文代码基于STM32CubeFW_F4 V1.27.0的HAL库编写。如果你使用其他版本或系列(如F1、H7),函数名和参数基本一致,但可能需根据CubeMX生成的代码进行微调。
3. DMA核心原理与配置项拆解
理解DMA的配置,关键在于掌握其“传输事务”的构成。一次完整的DMA传输通常由若干“传输事务”组成,每个事务又包含若干“数据项”。
3.1 DMA传输要素
- 源地址 (Source Address): 数据从哪里来?可以是外设的数据寄存器地址(如
&USART1->DR),也可以是内存地址(如一个数组buffer)。 - 目标地址 (Destination Address): 数据到哪里去?同样可以是外设寄存器或内存地址。
- 传输方向 (Direction):
- 外设到内存 (Peripheral-to-Memory): 如ADC采集数据到数组。
- 内存到外设 (Memory-to-Peripheral): 如从数组发送数据到UART。
- 内存到内存 (Memory-to-Memory): 在两个内存区域间拷贝数据(某些DMA控制器支持)。
- 传输数据宽度 (Data Width): 单次传输操作的数据位宽。常见有字节(8位)、半字(16位)、字(32位)。源和目标的宽度可以独立设置,但通常需要匹配,或由DMA控制器处理打包/解包。
- 传输数量 (Data Number/Count): 总共要传输多少个“数据项”。注意,数据项的单位是上面设置的“数据宽度”。
- 传输模式 (Mode):
- 单次模式 (Normal): 配置的传输数量完成后,DMA通道自动停止,需要软件重新使能才能进行下一次传输。
- 循环模式 (Circular): 传输数量完成后,DMA自动重置传输计数器并重新开始,形成连续不断的传输。非常适合用于持续的数据流,如音频播放、实时数据采集。
3.2 STM32 DMA架构简介(以DMA2为例)
STM32F4系列有两个DMA控制器:DMA1和DMA2。每个控制器有多个流(Stream,如DMA2有Stream0~Stream7),每个流可以映射到多个通道(Channel),通道号对应具体的外设请求。
- 流 (Stream): DMA传输的执行单元。每个流独立工作,可以配置优先级。
- 通道 (Channel): 每个流需要选择一个通道,这个选择决定了是哪个外设的请求触发这个流的传输。例如,USART1的TX请求可能映射到DMA2的Stream7 Channel4。
- 仲裁器 (Arbiter): 当多个流同时请求时,根据优先级(软件可配置)决定哪个流先使用DMA总线。
为什么是“26DMA-11”?假设它指“DMA2 Stream6 Channel11”。我们需要查阅STM32F4的参考手册(RM0090),找到DMA2请求映射表。我们会发现,Channel 11可能对应着某个外设,比如某个定时器(TIM)的更新事件或捕获比较事件。这强调了查阅官方数据手册的重要性,不同芯片、不同外设的DMA映射关系是确定的,不能臆想。
3.3 关键配置寄存器/参数(HAL库视角)
在HAL库中,这些要素通过一个结构体DMA_HandleTypeDef来配置。主要成员包括:
Instance: 指向具体的DMA流,如DMA2_Stream6。Init: 一个DMA_InitTypeDef结构体,包含核心配置:Channel: 通道选择,如DMA_CHANNEL_11。Direction: 传输方向。PeriphInc/MemInc: 外设/内存地址是否在每次传输后递增。对于外设寄存器(如USART->DR),地址固定,应设为DMA_PINC_DISABLE。对于内存数组,地址应递增 (DMA_MINC_ENABLE)。PeriphDataAlignment,MemDataAlignment: 外设/内存的数据对齐(宽度)。Mode: 循环或普通模式。Priority: 流优先级。
Parent: 指向使用此DMA句柄的外设句柄(如UART句柄),用于回调函数关联。
4. 完整实战案例:UART串口DMA收发
我们通过一个经典案例——UART的DMA收发,来串联所有知识点。目标:CPU启动一次DMA发送和循环DMA接收后,就可以去处理其他任务,数据搬运由DMA自动完成。
4.1 使用STM32CubeMX创建工程与配置
- 新建工程:打开STM32CubeIDE,选择对应的芯片型号(STM32F407VGTx)。
- 配置时钟:在
RCC配置中,将HSE(外部高速时钟)设置为Crystal/Ceramic Resonator。在Clock Configuration标签页,配置系统时钟为168MHz(根据板载晶振调整)。 - 配置USART1:
- 在
Connectivity下找到USART1。 - 将
Mode设置为Asynchronous(异步模式)。 - 配置
Baud Rate为115200。 - 保持其他参数默认。
- 在
- 关键步骤:配置DMA
- 在
USART1的配置界面,切换到DMA Settings标签页。 - 点击
Add添加发送DMA请求。DMA Request: 选择USART1_TX。Stream: 根据数据手册,USART1_TX可能映射到DMA2 Stream7 Channel4。我们选择DMA2 Stream7。Direction:Memory To Peripheral。Priority:Medium。
- 再次点击
Add添加接收DMA请求。DMA Request: 选择USART1_RX。Stream: 选择DMA2 Stream2(假设映射到Channel4)。Direction:Peripheral To Memory。Priority:Medium。- 特别注意:将接收DMA的
Mode从默认的Normal改为Circular(循环模式)。这样串口一旦收到数据,就会源源不断地存入我们指定的缓冲区,永不停止,直到我们主动关闭。
- 在
- 生成代码:配置项目名称、路径、Toolchain为
STM32CubeIDE,然后点击Generate Code。
4.2 分析生成的代码与用户代码编写
CubeMX生成了初始化代码,我们需要在此基础上添加应用逻辑。
首先,查看main.c中的初始化部分:
/* 在 main() 函数中,CubeMX 生成了以下调用 */ MX_DMA_Init(); // 初始化DMA控制器 MX_USART1_UART_Init(); // 初始化USART1,其中包含了DMA的关联配置在usart.c中,可以看到DMA句柄的初始化:
// 发送DMA句柄 hdma_usart1_tx.Instance = DMA2_Stream7; hdma_usart1_tx.Init.Channel = DMA_CHANNEL_4; hdma_usart1_tx.Init.Direction = DMA_MEMORY_TO_PERIPHERAL; // ... 其他配置 if (HAL_DMA_Init(&hdma_usart1_tx) != HAL_OK) { Error_Handler(); } // 将DMA句柄与USART句柄关联 __HAL_LINKDMA(huart, hdmatx, hdma_usart1_tx); // 接收DMA句柄 (模式为Circular) hdma_usart1_rx.Instance = DMA2_Stream2; hdma_usart1_rx.Init.Channel = DMA_CHANNEL_4; hdma_usart1_rx.Init.Direction = DMA_PERIPHERAL_TO_MEMORY; hdma_usart1_rx.Init.Mode = DMA_CIRCULAR; // 循环模式! // ... 其他配置 if (HAL_DMA_Init(&hdma_usart1_rx) != HAL_OK) { Error_Handler(); } __HAL_LINKDMA(huart, hdmarx, hdma_usart1_rx);4.3 编写用户应用代码
我们在main.c中添加用户代码。
步骤1:定义发送和接收缓冲区
/* 在 USER CODE BEGIN PV 区域定义全局变量 */ #define RX_BUFFER_SIZE 256 #define TX_BUFFER_SIZE 128 uint8_t uart_rx_buffer[RX_BUFFER_SIZE]; // DMA循环接收缓冲区 uint8_t uart_tx_buffer[TX_BUFFER_SIZE] = "Hello, CSDN! This message is sent via DMA!\r\n"; /* USER CODE END PV */步骤2:在main()函数初始化后启动DMA接收
/* 在 USER CODE BEGIN 2 区域 */ // 启动UART的DMA循环接收 // 参数:UART句柄,接收缓冲区地址,接收数据长度(单位:字节) if (HAL_UART_Receive_DMA(&huart1, uart_rx_buffer, RX_BUFFER_SIZE) != HAL_OK) { // 启动失败处理 Error_Handler(); } // 此时,DMA已经开始在后台循环监听串口数据,并自动存入uart_rx_buffer // 当缓冲区写满后,DMA会自动从缓冲区头部开始覆盖(循环模式特性) /* USER CODE END 2 */步骤3:编写函数,通过DMA发送数据
/* 在 USER CODE BEGIN 4 区域 */ void UART_SendMessage_DMA(uint8_t* pData, uint16_t Size) { // 检查DMA发送是否繁忙。如果上一次传输未完成,需要等待或做其他处理。 // 在实际项目中,这里可能需要一个发送状态机或队列来管理。 while(HAL_UART_GetState(&huart1) == HAL_UART_STATE_BUSY_TX) { // 可以在此处进行超时处理或任务切换 HAL_Delay(1); } // 启动DMA发送 if (HAL_UART_Transmit_DMA(&huart1, pData, Size) != HAL_OK) { // 发送失败处理 Error_Handler(); } // 函数立即返回,CPU无需等待数据发送完毕 } /* USER CODE END 4 */步骤4:在主循环中测试
/* 在 USER CODE BEGIN WHILE 区域 */ uint32_t last_tick = 0; while (1) { // 每隔1000ms发送一次数据 if (HAL_GetTick() - last_tick > 1000) { last_tick = HAL_GetTick(); UART_SendMessage_DMA(uart_tx_buffer, strlen((char*)uart_tx_buffer)); } // 处理接收到的数据:检查DMA接收到了多少新数据 // 技巧:DMA本身有一个计数器(CNDTR),表示还剩多少数据未传输。 // 对于循环接收,我们可以通过计算“已写入”的位置来判断新数据。 // 更常见的做法是使用串口空闲中断(IDLE)配合DMA,这里先演示一个简单轮询方法(不高效,仅示意)。 static uint16_t last_pos = 0; // 获取DMA接收流当前剩余未传输的数据量 uint16_t current_remain = __HAL_DMA_GET_COUNTER(huart1.hdmarx); // 计算当前写位置 uint16_t current_pos = RX_BUFFER_SIZE - current_remain; if (current_pos != last_pos) { // 有新的数据 if (current_pos > last_pos) { // 线性增加,未发生回绕 process_received_data(&uart_rx_buffer[last_pos], current_pos - last_pos); } else { // 发生回绕(DMA写指针从缓冲区末尾回到了开头) process_received_data(&uart_rx_buffer[last_pos], RX_BUFFER_SIZE - last_pos); process_received_data(&uart_rx_buffer[0], current_pos); } last_pos = current_pos; } // CPU可以在这里执行其他任务 HAL_Delay(10); // 简单延时,实际项目中可能是OS的任务调度 } /* USER CODE END WHILE */步骤5:实现数据处理函数(示例)
void process_received_data(uint8_t* data, uint16_t size) { if(size == 0) return; // 示例:将接收到的数据回显(通过DMA发送回去) // 注意:这里直接调用发送函数,在实际复杂应用中需要考虑发送缓冲区管理 UART_SendMessage_DMA(data, size); // 或者,你可以在这里解析协议、存储数据等 // for(uint16_t i=0; i<size; i++) // { // if(data[i] == '\r' || data[i] == '\n') // { // // 发现一行结束符 // } // } }4.4 编译、下载与验证
- 连接开发板的串口1(USART1)到电脑的USB转串口工具,在电脑上打开串口助手(如Putty、SecureCRT),设置波特率115200。
- 编译工程,通过ST-LINK下载到开发板。
- 复位开发板。你应该在串口助手中看到每隔一秒打印一次
Hello, CSDN!...。 - 在串口助手中发送任意字符串(如
test),你应该能立刻看到回显的test。这个过程是:- 你发送的字符通过串口线进入STM32的USART1。
- USART1触发DMA请求(因为接收DMA已使能且为循环模式)。
- DMA2_Stream2自动将USART1数据寄存器(DR)中的字节搬运到你定义的
uart_rx_buffer中。 - 主循环中的轮询逻辑检测到新数据,调用
process_received_data。 process_received_data函数调用UART_SendMessage_DMA,启动DMA2_Stream7将数据从uart_rx_buffer的相应位置搬回USART1的数据寄存器发送出去。- 整个数据收发过程,CPU只参与了“启动DMA发送”和“轮询判断有无新数据”这两件极简单的工作,核心的数据搬运全部由DMA完成。
5. 常见问题与排查思路
DMA配置灵活,也容易出错。下面是一些典型问题及解决方法。
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| DMA根本不能启动传输 | 1. DMA或外设时钟未使能。 2. DMA流/通道映射错误。 3. 源/目标地址错误(如未使能地址递增)。 4. 传输数量为0。 | 1. 检查__HAL_RCC_DMA2_CLK_ENABLE()等时钟使能函数是否被调用。2.仔细核对数据手册的DMA请求映射表,确认外设请求、DMA、流、通道四者对应关系。这是“26DMA-11”类问题的核心。 3. 检查 PeriphInc和MemInc配置。外设寄存器地址通常不递增,内存地址通常递增。4. 检查调用 HAL_UART_Transmit_DMA等函数时传入的Size参数是否大于0。 |
| DMA传输数据错位(如字节顺序乱) | 1. 数据宽度配置不匹配。 2. 字节序问题(在大端芯片上)。 3. 缓冲区对齐问题。 | 1. 确保PeriphDataAlignment和MemDataAlignment与外设和内存数据的实际宽度一致。例如,USART是8位,应设为DMA_PDATAALIGN_BYTE。2. STM32是小端模式,通常无需特别处理。如果涉及与外部大端设备通信,需转换。 3. 确保缓冲区地址符合对齐要求(如32位传输时地址需4字节对齐)。可以使用 __ALIGNED关键字定义数组。 |
| DMA循环接收覆盖未处理的数据 | 1. 数据处理速度跟不上接收速度。 2. 缓冲区大小不足。 | 1. 这是DMA循环模式的典型问题。最佳实践是使用“双缓冲区”或“环形缓冲区”机制。结合串口空闲中断(IDLE),在检测到一帧数据接收完成后,迅速处理上一块缓冲区,同时让DMA使用另一块缓冲区继续接收。 2. 增大接收缓冲区 RX_BUFFER_SIZE。 |
| DMA发送一半卡住,不发送完整数据 | 1. 发送过程中缓冲区被意外修改或释放。 2. 发送完成中断未正确触发或处理。 3. 流控未配置(如硬件流控RTS/CTS)。 | 1.确保DMA发送期间,源内存缓冲区内容保持稳定,且不被其他代码修改。对于局部变量数组,函数返回后其内存可能被重用,必须使用全局或静态缓冲区。 2. 检查是否使能了发送完成中断 HAL_UART_TxCpltCallback,并在回调函数中进行了正确的状态清理。3. 如果使用了硬件流控,确保对方设备能正确响应RTS/CTS信号。 |
| 同时使用多个DMA流时,某个流优先级低导致数据丢失 | DMA流优先级配置不当。 | 在CubeMX或代码中,为高实时性要求的数据流(如ADC高速采集)配置Very High优先级,为低实时性要求的流(如内存拷贝)配置Low优先级。 |
| 调试时无法查看DMA相关变量 | 优化等级过高。 | 在调试版本中,将编译器优化等级暂时调整为-O0或-Og,以确保变量不被优化掉,方便观察DMA计数器、缓冲区内容等。 |
6. 最佳实践与工程建议
掌握了基础操作后,以下建议能帮助你在实际项目中更稳健、高效地使用DMA。
6.1 缓冲区管理策略
- 绝不使用局部变量数组作为DMA缓冲区:DMA传输是异步的,函数返回后局部数组内存失效,会导致传输数据错误或内存访问冲突。始终使用全局变量、静态变量或动态分配(在DMA传输期间确保内存有效)的内存。
- 使用对齐的内存:对于要求数据宽度对齐的外设(如32位宽的DAC),使用
__attribute__((aligned(4)))或__ALIGNED(4)来定义缓冲区,确保地址是4字节对齐的。 - 双缓冲区/环形缓冲区:对于高速连续数据流(如音频、摄像头),这是避免数据覆盖的黄金标准。原理是准备两个缓冲区A和B。DMA正在向A写数据时,CPU处理B中的数据;当A写满,通过DMA半传输/传输完成中断触发切换,DMA开始写B,CPU处理A。HAL库的ADC双模式、I2S等外设直接支持此机制。
6.2 中断的合理使用
DMA传输完成、半传输完成、传输错误都会产生中断。合理使用中断能极大提升效率。
- 发送完成中断:在
HAL_UART_TxCpltCallback中,可以释放发送缓冲区、通知任务发送完成、启动下一次发送。避免在主循环中轮询发送状态。 - 接收完成/半传输完成中断:对于循环接收,结合“双缓冲区”思想。在
HAL_UART_RxHalfCpltCallback(半满中断)和HAL_UART_RxCpltCallback(全满中断)中切换CPU处理的缓冲区指针。这是处理高速流数据的推荐方式。 - 串口空闲中断(IDLE):这是UART DMA接收的“神器”。使能IDLE中断后,当串口总线上一段时间没有新数据,就会触发此中断。在IDLE中断服务程序中,你可以根据DMA的当前计数器计算出这一帧数据的确切长度,然后一次性处理,完美替代轮询方式。配置方法如下:
// 在UART初始化后,使能IDLE中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); // 在stm32f4xx_it.c的中断服务函数USART1_IRQHandler中,添加IDLE中断处理 void USART1_IRQHandler(void) { /* ... 其他中断处理 ... */ if(__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 清除IDLE标志位 // 调用用户自定义的IDLE处理函数 UART_IDLE_Callback(&huart1); } /* ... */ } // 在用户文件中实现回调函数,计算数据长度并处理 void UART_IDLE_Callback(UART_HandleTypeDef *huart) { if(huart->Instance == USART1) { uint16_t remain_data = __HAL_DMA_GET_COUNTER(huart->hdmarx); // 获取剩余未传输量 uint16_t received_len = RX_BUFFER_SIZE - remain_data; // 计算已接收长度 // 处理 uart_rx_buffer 中从0到 received_len-1 的数据 process_one_packet(uart_rx_buffer, received_len); // 重置DMA接收(对于循环模式,只需重置计数器?不,需要重新启动以从缓冲区开头开始) // 更安全的做法是:停止DMA,重置缓冲区索引,再重启DMA接收。 // 这里简化处理,假设process_one_packet很快,并且我们允许覆盖。 // 实际应采用双缓冲区。 } }
6.3 错误处理与稳定性
- 使能DMA传输错误中断:在CubeMX中勾选
Transfer Error Interrupt,并在错误中断回调函数HAL_UART_ErrorCallback中记录日志、重置DMA和外设。这有助于捕获硬件异常或配置错误。 - 超时机制:对于DMA发送,如果依赖完成中断,应设置一个软件超时计时器。如果长时间未进入发送完成回调,应视为错误,进行超时处理(如重置串口和DMA)。
- 资源冲突检查:同一个DMA流不能同时用于两个外设。在复杂系统中,初始化阶段应检查DMA资源分配,避免冲突。
6.4 性能优化考量
- 内存到内存传输:如果芯片支持(如STM32F4),利用DMA进行大块内存拷贝(如
memcpy)比CPU快得多,且不占用CPU时间。使用HAL_DMA_Start并配置方向为DMA_MEMORY_TO_MEMORY。 - 数据宽度与突发传输:在总线带宽允许的情况下,使用更大的数据宽度(如32位)可以减少传输次数,提升效率。某些高级DMA支持突发传输(Burst),能进一步优化总线利用率。
- 使用DMA链接列表(仅限高级DMA):对于非常复杂、不连续的传输任务,一些DMA控制器支持链接列表模式,可以预先定义好一系列传输描述符,DMA自动按列表执行,实现极其灵活的数据搬运。
回到我们最初的标题“26DMA-11”,它更像是一个具体问题的坐标。在真实的项目开发中,当你需要配置一个特定的DMA流时,第一件事永远是:打开芯片的参考手册,找到DMA请求映射表,确认外设、DMA控制器、流、通道这四者唯一且正确的对应关系。这是所有DMA配置工作的基石,一旦错了,后续的一切调试都可能徒劳无功。
通过本文的梳理,你应该已经掌握了DMA从概念、配置、编程到调试的完整链条。DMA是提升嵌入式系统性能的利器,但也需要细致和耐心。建议你亲手在开发板上实现一遍UART的DMA收发,再尝试将其应用到ADC采集、SPI通信等场景,逐步积累经验。当你能熟练驾驭DMA时,意味着你对嵌入式系统的理解又深入了一个层次。
