公司动态
DMA地址+偏移量机制详解:从ADC多通道采样到串口环形缓冲区
做嵌入式这两年凡是接触过串口、ADC、SPI这类外设的人八成都被一个问题绕进去过为什么DMA启动的时候不是从缓冲区的头开始传而是要从某个“地址加上偏移量”的地方开始尤其是当你打开参考手册或者某个驱动库的API注释看到那句“Start transfer from address offset”时心里不免嘀咕这个offset到底怎么算是字节偏移还是元素偏移是每个通道都要加还是只需要加一次今天这篇就把这个事彻底讲明白。我把自己在STM32、GD32以及部分国产MCU上做DMA传输的实战经验整理出来围绕“地址偏移量”这个核心机制把ADC多通道循环采样、串口空闲中断接收、环形缓冲区这些高频场景全部拆开揉碎。无论你是刚接触DMA的新手还是被数据错位、缓冲区越界折磨过的老手这篇文章都能给你提供一套可以直接抄作业的配置方案和排查思路。1. 先搞清楚DMA传输里的“地址”和“偏移量”到底指什么1.1 DMA的本质把搬运工从CPU换成硬件DMA全称Direct Memory Access直译就是“直接存储器访问”。它存在的意义只有一个数据搬运这件事不要让CPU逐字节地做而是交给DMA控制器这个专职的“快递员”来干。CPU只需要在开始时告诉快递员三个关键信息——从哪拿货源地址、把货放哪目的地址、拿多少货传输长度然后就可以干别的事去了。等快递员干完活再发个中断通知一下。这个模型在很多场合下非常好用但它有个隐含的限制DMA控制器这个快递员认的是“绝对地址”。也就是说它只能听从“从0x20001000这个地址开始搬”这样的指令而不懂“这个数组的第3个元素往后”这种相对描述。所以在实际项目里当我们要从某个缓冲区中间位置开始传输时就必须在软件层把“基地址偏移量”算好然后把最终的结果写进DMA的地址寄存器。这就是“Start transfer from address offset”这句话的真实含义。它说的不是DMA硬件自己有什么偏移机制而是提醒开发者你在启动DMA之前得自己把目标起始地址算准。这个“算准”看着简单实际是很多数据错位、越界问题的源头。举个例子假设你有一个uint16_t的采样缓冲区adc_buf[64]想从第32个元素开始传输那么传给DMA的存储器地址就不该是(uint32_t)adc_buf而是(uint32_t)adc_buf[32]等价于(uint32_t)adc_buf 32 * sizeof(uint16_t)。这里的“32 * 2 64字节”就是偏移量你漏掉任何一个sizeof数据就会跑偏。1.2 地址偏移的两种形态软件算好 vs 硬件自增搞清楚了偏移量的本质再看DMA控制器里的地址寄存器就会发现还有另一种“偏移”——硬件自动递增。大多数DMA控制器都有一个方向性的地址增长模式Memory Increment / Peripheral Increment。开启之后每传完一个数据单元源地址或目的地址会自动加1对于字节或加2对于半字或加4对于字。这种增长本质上也是一种偏移只不过它是硬件自动完成的不需要你每次传输前手动算。这两者的区别很关键软件算好的偏移只在启动传输前算一次传输过程中起始地址固定不变。适合“整个传输从某个中间位置开始”的场景。硬件自增的偏移每次传输后自动变化配合缓冲区遍历。适合“从某个位置开始持续搬运一整段数据”的场景。实际工程里这两种经常叠加使用。比如ADC多通道循环采样中DMA每次转换完成都会把结果写入缓冲区并且地址自动递增等写满最后一个地址后又回到缓冲区首地址循环模式。这时候如果你想在处理中断里只读取缓冲区中间某几个通道的数据就需要用“软件偏移”去定位起始位置而让硬件继续自增以保证后续数据的连续写入。提示在配置DMA之前先想清楚你要的是“一次固定起始地址的传输”还是“边传边递增地址的流式传输”。这个决定会直接影响你选择普通模式还是循环模式也影响地址递增方向是加还是减的设置。2. 场景实战ADC多通道扫描与循环采样里的偏移计算2.1 缓冲区数据布局与偏移量推导ADC多通道扫描DMA循环采样是“地址偏移量”这个知识点最典型的应用场景。我遇到过太多人在这里栽跟头要么读出来的通道数据张冠李戴要么用DMA读出来的值和手动轮询完全对不上。先说清楚数据布局。假设你用ADC1扫描通道0、1、2、3四个通道开启DMA请求每次转换完成自动搬运。那么DMA缓冲区里的数据排列是固定的第一次转换结果写到buf[0]通道0第二次写到buf[1]通道1第三次buf[2]通道2第四次buf[3]通道3第五次又回到buf[0]通道0以此类推。如果开启了循环模式这个写入过程会一直持续下去缓冲区不停地被刷新。如果你的需求是“每个通道采样N次取平均值后再用”那么有两个设计方案方案A缓冲区大小 通道数 × NDMA一次传输长度 通道数 × N传输结束后产生一次完整中断再统一处理。这种方式适合低速周期采样缺点是缓冲区大实时性一般。方案B缓冲区大小 通道数DMA开启循环模式用半传输中断或定时器触发去读取当前数据。这种方式适合持续采样数据一直是最新的。不管哪种方案定位某个通道的数据都需要算偏移。对于通道ch它的第k次采样结果在缓冲区中的下标是index ch k * channel_count对应的地址偏移就是offset_bytes index * sizeof(adc_buf[0])比如ADC扫描4个通道你想读取通道2的最新采样值在循环模式下就是buf[2]在深度N的缓冲模式下第3轮采样的通道2数据就是buf[2 2 * 4] buf[10]。这个偏移量如果你写错一位数据就串了。2.2 半传输中断不用等整批数据就能处理前半段再深入一步DMA还有一个很实用的功能叫“半传输中断”Half Transfer Interrupt。它会在DMA传输到缓冲区一半时触发一次中断全部传完时再触发一次完整传输中断。这个特性在ADC连续采样场景里特别有用因为它能把数据处理分成两次做降低延迟。但半传输中断也引入了新的偏移问题。假设缓冲区长度为64个半字128字节DMA传完32个半字时触发半传输中断。此时前半段数据在buf[0..31]后半段还正在写入buf[32..63]。你在中断回调里处理前半段数据时读写的位置就是一个典型的基础地址偏移// 半传输中断时有效数据从buf[0]开始长度32 process_data(adc_buf, 32); // 完整传输中断时有效数据从buf[32]开始长度32 process_data(adc_buf[32], 32);这里的adc_buf[32]就是“基地址 偏移量”的又一次体现。如果你偷懒写成adc_buf就会把已经处理过的数据再处理一遍同时漏掉后半段的新数据最终结果就乱了。注意半传输中断触发的位置取决于DMA字节数寄存器CNDTR的初始值减半。如果你的缓冲区长度是奇数个数据单元或者数据类型不是1字节对齐半传输中断的触发位置可能不会落在你预期的元素边界上。设计缓冲区时最好保证总字节数是2的幂或偶数倍避免尴尬的错位。2.3 常见问题手动轮询和DMA读到的值为什么对不上热词里有一条“adc的dma模式与手动轮询值不一样”这个我太有感触了。很多人配置好ADCDMA后发现用DMA读到的转换值和直接手动读ADC数据寄存器拿到的数值差得离谱就开始怀疑DMA是不是有问题。其实问题通常出在以下几个方面第一通道扫描顺序和缓冲区映射顺序不一致。ADC扫描的顺序不一定就是物理通道的顺序有些MCU可以配置扫描序列。如果你的扫描序列是通道2、通道0、通道1、通道3那么DMA缓冲区里第一个数据是通道2的第二个是通道0的。你如果按“通道0在buf[0]”的逻辑去读当然对不上。第二数据宽度配置不一致。ADC数据寄存器常见12位存储在右对齐模式时有效数据是低12位但寄存器本身是16位或32位宽。DMA搬运时如果数据宽度设成了8位就会把同一个寄存器拆成两段搬数据自然不对。正确做法是把DMA的数据宽度设置为半字16位或字32位和ADC的数据寄存器宽度保持一致。第三启动DMA的时机不对。有些MCU要求先使能DMA通道再触发ADC转换否则第一批数据可能不完整。反过来如果你在ADC转换过程中反复开关DMA也可能丢掉部分转换结果。第四没有关闭ADC的DMA方式和DMA的循环模式联动。当用循环模式时DMA会持续搬运数据并覆盖缓冲区。如果你在读取数据的同时DMA还在写入就可能出现“读到一半被覆盖”的情况数值当然不稳定。解决办法是开启环形缓冲区后搭配双缓冲区Double Buffer机制或者在数据处理期间暂停DMA拷贝完再恢复。3. 场景实战串口DMA 空闲中断与环形缓冲区偏移3.1 不定长接收的组合方案串口接收一直是DMA应用的高频场景尤其是“DMA 空闲中断IDLE Interrupt”这套组合几乎成了MCU串口接收不定长数据的标准解法。它的原理不复杂DMA一直在后台搬运串口收到的数据到内存缓冲区CPU不参与当串口总线上出现空闲状态也就是一帧数据发完了总线静默超过一个字节时间硬件会触发空闲中断此时CPU才介入处理缓冲区里刚收到的一整包数据。这个方案的好处是显而易见的高频数据由DMA搬运CPU只在一包数据接收完毕的瞬间被唤醒一次处理完继续休眠极大降低CPU占用率。但正因为DMA是“一直在后台跑”的缓冲区中的数据不会每次从0开始。如果你接收了第一包数据比如20字节后没有及时处理紧接着来了第二包比如15字节DMA会从上次停止的位置继续往后写。这就是环形缓冲区Ring Buffer概念的由来也是“地址偏移量”的又一个典型应用。3.2 写偏移的实时计算与读偏移管理在串口DMA发送环形缓冲区中一般有两个偏移量需要维护读偏移read_indexCPU从缓冲区哪个位置开始取数据。写偏移write_indexDMA下一次写入的位置。对于DMA接收来说写偏移其实不需要软件维护因为DMA硬件自己知道它写到了哪里。我们通过DMA当前剩余计数也就是CNDTR寄存器的值可以反推出“DMA已经写到了哪里”write_index buffer_size - (uint16_t)DMA_GetCurrDataCounter(DMAx_Channelx);这个公式是串口DMA接收的核心。缓冲区大小是固定的DMA剩余计数代表还有多少个数据没搬完所以“已搬完的数量 总大小 - 剩余计数”而这就是DMA当前写到的位置也就是本次接收数据的结束偏移。拿到写偏移后就可以和软件维护的读偏移一起组成环形缓冲区的读写指针。每次空闲中断触发时我们就在中断里做以下事情读取当前DMACNDTR计算本次接收的数据长度。从读偏移位置开始把数据拷贝到协议解析缓冲区。更新读偏移 (读偏移 本次接收长度) % 缓冲区大小。这里最关键的一个细节是空闲中断触发后DMA并没有停止它还会继续接收后续数据。所以你必须先把数据从环形缓冲区取走或者让DMA继续往缓冲区后面写。如果你在中断里处理时间太长新来的数据可能覆盖掉还没处理的数据。3.3 一个可参考的STM32F4串口DMA配置代码关键部分直接上一份我常用的STM32F407串口DMA接收初始化代码这套配置在F1/F4系列上都能直接用GD32的兄弟们把库函数换一换也差不多。#define UART_RX_BUF_SIZE 512 uint8_t uart_rx_buf[UART_RX_BUF_SIZE]; volatile uint16_t uart_read_index 0; void UART_DMA_RX_Init(void) { // 1. 初始化串口开启空闲中断 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); // 2. 配置DMA接收通道 DMA_InitTypeDef DMA_InitStructure; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2, ENABLE); DMA_DeInit(DMA2_Stream5); DMA_InitStructure.DMA_Channel DMA_Channel_4; // 串口1接收对应通道4 DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_Memory0BaseAddr (uint32_t)uart_rx_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralToMemory; // 外设到内存 DMA_InitStructure.DMA_BufferSize UART_RX_BUF_SIZE; // 缓冲区大小 DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; // 外设地址不增 DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; // 内存地址自增 DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; // 循环模式满了自动回绕 DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_FIFOMode DMA_FIFOMode_Disable; DMA_InitStructure.DMA_FIFOThreshold DMA_FIFOThreshold_HalfFull; DMA_InitStructure.DMA_MemoryBurst DMA_MemoryBurst_Single; DMA_InitStructure.DMA_PeripheralBurst DMA_PeripheralBurst_Single; DMA_Init(DMA2_Stream5, DMA_InitStructure); // 3. 启动DMA DMA_Cmd(DMA2_Stream5, ENABLE); }对应的空闲中断处理函数void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 先读SR再读DR清空闲中断标志 USART_ReceiveData(USART1); USART_ClearITPendingBit(USART1, USART_IT_IDLE); // 计算DMA当前写偏移 uint16_t write_index UART_RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA2_Stream5); // 处理从uart_read_index到write_index之间的新数据 // 注意要处理回绕如果write_index uart_read_index说明数据分成了两段 uint16_t len (write_index uart_read_index) ? (write_index - uart_read_index) : (UART_RX_BUF_SIZE - uart_read_index write_index); if (len 0) { // 拷贝或解析数据 UART_ParseData(uart_rx_buf, uart_read_index, len); // 更新读偏移 uart_read_index write_index; } } }这份代码里最需要注意的地方就是“回绕”的处理。因为DMA循环模式写满缓冲区后会回到开头继续写所以write_index可能比read_index小这时候数据会分成两段一段在缓冲区尾部一段在缓冲区头部。很多新手在这一步翻车就是因为只处理了write_index read_index的常规情况。提示如果项目里收发频率很高建议在每次空闲中断里把write_index缓存下来并用一个结构体统一管理读写索引避免中断嵌套导致索引被修改。4. 配置的坑从寄存器到HAL库的落地细节4.1 地址对齐与缓冲边界问题很多人在配置DMA时忽略了一个隐性约束——内存地址对齐。不同平台、不同DMA控制器对内存地址的对齐要求不一样。STM32F1/F4系列的DMA比较宽松基本上字节、半字、字对齐都能兼容。但到了STM32F7/H7系列特别是支持D-Cache的高端芯片上DMA缓冲区如果没做Cache Line对齐通常是32字节就可能出现“DMA写入的数据CPU读不到”“CPU写的数据DMA搬的是旧值”的诡异问题。解决办法是在定义缓冲区时加上对齐属性。IAR、Keil、GCC各有各的写法我常用GCC的#define DMA_CACHE_LINE_SIZE 32 uint8_t uart_rx_buf[UART_RX_BUF_SIZE] __attribute__((aligned(DMA_CACHE_LINE_SIZE)));Keil环境则可以写成ALIGN_32BYTES uint8_t uart_rx_buf[UART_RX_BUF_SIZE];这个对齐问题对于“地址偏移量”来说还有一个隐藏含义如果缓冲区首地址对齐到32字节而你要从偏移7字节的位置启动DMA传输那么实际的传输地址首地址7就不再满足32字节对齐了。这在硬件层面没问题但在D-Cache做Invalidate操作时Cache Line的粒度是32字节你只Invalidate从偏移7开始的区域实际上会把整个32字节Line都失效那么Line内其他部分的数据可能因为没被DMA更新而读到旧值。所以建议设计缓冲区时把偏移量也考虑成对齐粒度的一部分或者干脆在DMA处理前后对整块缓冲区做Cache维护。4.2 D-Cache一致性处理F7/H7这个话题值得单独拿出来说。在带D-Cache的MCU上STM32F7、H7、部分国产高端芯片DMA和CPU之间的数据一致性问题是排第一位的坑。CPU访问DMA缓冲区时会优先读Cache里的数据。如果DMA已经从外设搬了新数据到内存但Cache还保留着旧数据CPU读到的就是“过期”的数据。反过来如果CPU往缓冲区写了数据准备让DMA搬走但数据还滞留在Cache里没有写回内存DMA搬走的就是内存里的旧值。解决方案是操作DMA缓冲区前后做Cache维护DMA发送前执行SCB_CleanDCache_by_Addr把CPU写的数据刷回内存。DMA接收后执行SCB_InvalidateDCache_by_Addr使Cache失效让CPU下次读取时从内存重新加载。这里又出现了一个“地址偏移”的细节这两个维护函数都需要传入起始地址和长度而起始地址就是“基地址偏移量”。如果你只在初始化时维护了基地址而实际DMA传输是从偏移位置开始的长度也没对齐到Cache Line那维护就是不完整的。4.3 国产芯片GD32、HC32等的差异点这些年国产MCU用得越来越多GD32、HC32、AT32这些芯片我都用过。它们多数兼容STM32的DMA框架但在细节上还是要留意。GD32系列和老款STM32F1很接近但DMA通道和外设的映射关系可能不同。比如GD32F303和STM32F103虽然管脚兼容但DMA通道的请求映射有差异如果你想直接照搬STM32的库函数很可能发现串口3的DMA接收根本不起作用。正确做法是查对应芯片的用户手册里的“DMA request mapping”表确保通道选择正确。HC32系列小华半导体的MCU的DMA和STM32差异就大一些它支持多种传输模式包括块传输、重复块传输等。配置寄存器的手感更接近瑞萨系列。在使用HC32L136这类芯片时我建议直接参考官方例程别按STM32的思维硬套因为它的地址偏移机制和中断标志位定义差别相当大。注意跨平台移植DMA代码时不能只换库函数。一定要检查三样东西DMA通道与外设的映射关系、中断标志位的定义、以及地址自增单位字节/半字/字。这三样任何一个不对传输结果都会莫名其妙地出错。5. 常见问题排查与避坑清单我把这些年调试DMA时遇到的典型问题整理成一张速查表基本上遇到“数据不对、传输不启动、死机重启”这几种现象照着查就行:现象可能原因排查方法DMA传输不启动CNDTR一直没变化DMA通道和外设请求映射错误查看芯片手册“DMA请求映射表”核对通道配置数据全部错位或字符颠倒数据宽度配置不一致外设8位/内存16位等确保外设和内存的数据宽度一致或正确处理半字/字序ADC多通道只有第一个通道数据对扫描顺序和缓冲区映射顺序不一致打印缓冲区前N个数据手动核对通道对应关系串口DMA接收偶尔丢包、粘包空闲中断处理时间过长DMA继续写入覆盖未处理数据缩短中断处理时间或改用双缓冲区机制DMA和CPU读到的数据不一致D-Cache未做Clean/Invalidate维护在DMA操作前后调用Cache维护函数确保缓冲区对齐程序进入HardFaultDMA写入地址越界检查偏移量计算是否有误尤其是“元素长度”没乘sizeofDMA循环模式缓冲区被覆盖处理速度跟不上DMA写入速度扩大缓冲区提高中断优先级或使用半传输中断分流处理修改缓冲区大小后数据乱了CNDTR初始值未同步修改排查所有读取CNDTR的地方确保都用同一个宏定义除了表格里的问题我再补充几个独门经验第一个DMA传输数据长度的单位是“数据单元”而不是“字节”。CNDTR里填的是“要传多少个数据单元”数据单元的大小由数据宽度决定。如果你配置的是16位宽度传输100个数据单元就是200字节。算偏移量时偏移也要按数据单元算不是按字节算。我记得有一次一个项目里ADC缓冲区明明是200个半字我按字节写了CNDTR400结果DMA直接越界写到了无关内存区查了好久才发现是这里的问题。第二个环形缓冲区的“回绕”绝对值最容易被忽略。上面串口例子里已经演示了回绕时的长度计算实际项目里如果缓冲区大小是2的幂可以用位与运算来快速实现取模index (index 1) (BUFFER_SIZE - 1)但前提是缓冲区大小严格为2的幂。这个技巧在中断里能省不少CPU时间。第三个DMA调试时不要急着仿真正常流程先给一个固定数据源比如手动往外设数据寄存器写值做小样本测试确认地址和len正确后再接真实外设。// 判断DMA缓冲区是否有回绕的小工具函数 uint16_t RingBuf_GetDataLen(uint16_t read_index, uint16_t write_index, uint16_t buf_size) { if (write_index read_index) return write_index - read_index; else return buf_size - read_index write_index; }排查DMA通讯问题时建议先不要用调试器的“暂停”功能。因为暂停的一瞬间DMA还会继续跑你看到的寄存器值和内存内容可能已经过时。正确做法是做一个简单的“自测模式”每收到一包数据就点亮一个LED确认传输逻辑通了再去看数据内容。6. 我对“地址偏移”这个设计的理解与使用建议关于DMA使用中的地址偏移我个人的体会有几点。首先在工程结构上建议把“偏移量计算”封装成独立的、可复用的函数不要每次在使用的地方临时拼地址。这样一旦数据类型变化从uint16_t换成uint32_t从数组换成结构体只用改一个函数避免在多个位置遗漏。// 用一个宏统一计算缓冲区中某个索引的偏移地址 #define BUF_OFFSET(buf, index, type) ((uint32_t)(buf) (uint32_t)(index) * sizeof(type)) // 使用示例 uint32_t addr BUF_OFFSET(adc_buf, 5, uint16_t);其次设计DMA缓冲区时一定要预留足够的余量尤其是串口、CAN这类可能收到不定长数据的场景。把缓冲区设成最坏情况下最大包长的2倍以上至少能留出处理时间窗口避免在高负载时数据被覆盖。再次遇到DMA传输问题的时候还原基本法最有效第一看外设的数据寄存器是不是真的产生了数据第二看DMA地址寄存器对不对第三看传输长度对不对第四看通道请求是否使能。按这个顺序排查大多数问题都能在10分钟内定位。最后想说DMA的“地址偏移量”这个机制虽然基础但它实际上是整个DMA灵活性的基石。真正把基地址、偏移量、数据宽度、地址自增方向、循环模式这五个参数搭配好你就能设计出很优雅的数据流方案——比如多通道AD采集、连续摄像头的断续图像传输、串口双缓冲收发全都是这套东西的组合应用。能把这些基础机制用顺了以后再遇到那些看起来很复杂的外设驱动也就没那么可怕了。