
做嵌入式這兩年凡是接觸過串口、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采集、連續攝像頭的斷續圖像傳輸、串口雙緩沖收發全都是這套東西的組合應用。能把這些基礎機制用順了以后再遇到那些看起來很復雜的外設驅動也就沒那么可怕了。