
1. 從“輪詢”到“DMA”為什么串口通信需要解放CPU如果你用過STM32的串口大概率是從HAL庫的HAL_UART_Transmit和HAL_UART_Receive這兩個函數開始的。它們簡單直接一個循環把數據一個個字節地塞進數據寄存器或者從寄存器里一個個讀出來。在調試初期或者數據量不大的時候這完全沒問題。但當你需要連續發送一長串數據比如通過串口發送一張圖片的像素信息或者以115200的波特率持續接收來自傳感器的數據流時問題就來了。CPU會像個盡職但低效的郵差被死死地綁在“搬數據”這件重復勞動上。它要不斷地檢查狀態寄存器“上一個字節發完了嗎發完了好我塞下一個?!?或者“有數據來了嗎來了好我讀出來?!?這個過程我們稱之為“輪詢”或“阻塞式”通信。在此期間CPU幾乎無法分身去做其他更有價值的工作比如處理算法、響應按鍵、更新顯示等。系統的實時性和效率大打折扣。DMADirect Memory Access直接存儲器訪問就是為了解決這個問題而生的。你可以把它想象成一個獨立的、專業的“數據搬運工”。你只需要告訴DMA三件事數據從哪里來源地址、要搬到哪里去目標地址、要搬多少數據量。然后你啟動它它就會在后臺悄無聲息地完成所有搬運工作完全不需要CPU介入。對于串口發送DMA負責把內存里的一塊數據自動搬運到串口的數據發送寄存器TDR對于接收則是把接收寄存器RDR里的數據自動搬運到內存中你指定的位置。那么使用DMA模式到底能帶來什么好處最核心的好處是解放CPU。在DMA搬運數據的整個過程中CPU是被釋放的它可以去執行其他任務代碼。只有當DMA完成了一次完整的傳輸比如發完了100個字節它才會通過一個中斷來通知CPU“老板活兒干完了。” 此時CPU再來處理后續事宜比如準備下一批數據或者解析剛剛接收到的完整數據包。這種“異步”的工作方式極大地提高了系統的整體吞吐量和響應能力。其次降低了數據丟失的風險。在高速數據流接收場景下使用輪詢方式很容易因為CPU忙于其他任務而錯過數據寄存器中新的字節導致數據被覆蓋而丟失。DMA的硬件觸發機制響應速度極快幾乎在數據到達寄存器的瞬間就會被搬走可靠性高得多。最后它簡化了編程模型。你不再需要編寫繁瑣的、可能產生阻塞的輪詢代碼而是設置好DMA然后基于“傳輸完成中斷”來構建你的應用邏輯代碼結構更清晰更易于維護。在STM32的生態里ST提供的STM32CubeMX工具和HAL庫讓DMA的配置變得前所未有的簡單。它通過圖形化界面幫你完成了底層寄存器配置的絕大部分工作你只需要關注業務邏輯。接下來我們就手把手地從CubeMX的工程創建開始完成一個完整的串口DMA發送與接收配置。2. CubeMX工程創建與基礎外設配置我們以常見的STM32F103C8T6BluePill核心板為例目標是配置USART1使用DMA進行發送和接收。其他系列型號的操作邏輯基本一致。2.1 新建工程與芯片選擇打開STM32CubeMX點擊“New Project”。在芯片選擇器中輸入“STM32F103C8”在結果列表中選擇“STM32F103C8Tx”。注意型號后綴的“Tx”代表LQFP48封裝這是BluePill板常用的封裝。雙擊芯片圖片或點擊“Start Project”進入配置界面。2.2 系統核心SYS與時鐘RCC配置在左側分類視圖的“System Core”中先配置SYS。Debug: 根據你的調試器選擇。如果你使用ST-LINK進行SWD調試這里選擇“Serial Wire”。這會影響PA13(SWDIO)和PA14(SWCLK)兩個引腳的功能。如果選擇不當可能導致后續無法下載或調試程序。然后配置RCC復位與時鐘控制。High Speed Clock (HSE): 選擇“Crystal/Ceramic Resonator”。BluePill板外部通常有一個8MHz的晶振。這一步是為系統提供準確的外部時鐘源是保證串口波特率精確的基礎。2.3 串口USART1引腳與參數配置在左側分類視圖的“Connectivity”中找到USART1。模式選擇: 點擊USART1在右側的“Mode”中選擇“Asynchronous”異步通信模式。這會使能USART1功能并自動占用PA9TX和PA10RX引腳。你可以在下方的芯片引腳圖上看到這兩個引腳變成了綠色。參數配置: 在下方出現的“Configuration”選項卡中點擊“Parameter Settings”。Baud Rate: 設置為115200這是一個非常通用的波特率。Word Length: 8 Bits一個字節。Parity: None無校驗位。Stop Bits: 11個停止位。Over Sampling: 16 Samples默認抗干擾能力較好。其他參數保持默認即可。至此一個最基礎的串口功能就配置好了。但這只是“輪詢模式”的配置。接下來才是DMA配置的核心。3. DMA通道的添加與關鍵參數詳解DMA配置是本文的核心也是容易出錯的地方。STM32的DMA資源是以“流”StreamF1/F4系列或“通道”ChannelF0/F1/F3等系列來管理的。對于STM32F103我們使用的是DMA的“通道”。3.1 添加DMA發送請求在左側分類視圖的“Connectivity”中再次點擊USART1。在右側“DMA Settings”選項卡中點擊“Add”。在彈出的“DMA Request”選擇框中你會看到USART1_TX和USART1_RX。我們先添加發送請求。選擇USART1_TX然后點擊“OK”。這會為USART1的發送功能分配一個DMA通道。3.2 配置DMA發送通道參數添加成功后列表中會出現一行我們需要對其參數進行詳細配置。每一個參數的選擇都有其背后的硬件原理。Direction:Memory To Peripheral。這定義了數據流向從內存我們定義的數組到外設USART1的數據發送寄存器。這是發送操作的正確方向。Priority:Medium。當多個DMA通道同時請求時優先級高的先被服務。對于單一串口發送Low或Medium均可。如果系統中有更緊急的數據搬運如ADC連續采樣可以將其設為High。Mode:Normal。這是最常用的模式。在Normal模式下DMA在傳輸完指定的數據量比如100個字節后就會自動停止并產生傳輸完成中斷。與之相對的是Circular循環模式在該模式下DMA傳輸完指定數據量后會自動重置計數器并重新開始傳輸形成一個循環緩沖區常用于需要持續不斷收發數據的場景如音頻流。初次使用強烈建議先從Normal模式開始理解。Increment Address: 這里有兩項。Memory必須勾選Enable。因為我們發送的數據通常存儲在內存的一個連續數組中DMA每搬運完一個字節或半字、字內存地址需要自動增加以指向下一個待發送的數據。Peripheral必須保持Disable。因為外設USART1的TDR寄存器的地址是固定的我們始終是把數據搬到同一個寄存器里。Data Width: 這里也有兩項。Memory和Peripheral通常都選擇Byte8位。因為串口通信的基本單位是字節8位。這確保了DMA每次搬運的數據大小與串口數據寄存器的大小匹配。這是一個關鍵點如果這里配置為Half Word16位或Word32位而你的串口是8位數據那么DMA一次會搬運2個或4個字節到TDR寄存器這會導致數據錯亂。除非你使用了串口的9位數據模式或硬件流控制否則保持Byte。3.3 添加并配置DMA接收請求重復“添加”步驟這次選擇USART1_RX。Direction:Peripheral To Memory。數據從外設USART1的RDR寄存器到內存。Priority:Medium或High。接收的優先級通常建議設置得比發送高因為接收不及時會導致數據丟失而發送晚一點通??梢越邮堋ode: 對于接收Normal和Circular的選擇更具策略性。如果你接收的是固定長度的數據包比如每包20字節并且你需要在收完一包后立即處理那么用Normal模式在傳輸完成中斷里處理數據。如果你接收的是連續不斷的數據流如GPS模塊的NMEA語句那么Circular模式是更好的選擇。它會在內存中創建一個環形緩沖區新數據會覆蓋舊數據你只需要定期去緩沖區里讀取有效數據即可避免了數據覆蓋丟失的風險。本文后續以Normal模式為例進行代碼編寫。Increment Address:Memory:Enable。原因同發送我們需要把接收到的數據依次存放到數組的不同位置。Peripheral:Disable。原因同發送源地址RDR寄存器固定。Data Width: 同樣都設置為Byte。注意DMA通道沖突。STM32的DMA控制器通道資源是有限的且一個通道在同一時間只能服務于一個外設請求。CubeMX通常會智能地分配空閑通道。但如果你的項目復雜使用了多個外設的DMA如ADC、SPI、另一個串口等可能會遇到通道沖突。如果CubeMX報錯你需要手動調整確保每個外設請求使用的通道是唯一的。4. 中斷與NVIC配置讓CPU知道DMA干完活了配置好DMA的搬運工我們還需要告訴CPU什么時候該來“驗收”工作成果。這就是中斷的作用。4.1 使能DMA傳輸完成中斷在剛才的DMA Settings列表中每一行即每個DMA請求的右側都有一個“NVIC Settings”選項卡有時需要點擊通道行最右側的齒輪圖標進入詳細設置。點擊它。對于USART1_TX通道找到并勾選Transfer Complete interrupt傳輸完成中斷。這樣當DMA發送完所有指定數據后就會產生一個中斷。對于USART1_RX通道同樣勾選Transfer Complete interrupt。當DMA接收滿指定數量的數據后產生中斷。為什么還需要串口自身的中斷在某些高級場景下你可能還需要串口本身的USART1 global interrupt。例如你想在DMA接收的同時還能處理串口的其他事件如幀錯誤、溢出錯誤等。或者你想使用“空閑中斷”Idle Interrupt來檢測一幀數據的結束當串口總線空閑超過一個字符傳輸時間時觸發這在接收不定長數據時非常有用。但為了簡化初學模型本文暫不開啟串口全局中斷僅依賴DMA傳輸完成中斷。4.2 配置NVIC優先級可選但重要在左側分類視圖的“System Core”中點擊NVIC。 在這里你可以看到所有已使能的中斷列表包括我們剛剛使能的DMA1 Channel4 global interrupt對應USART1_TX和DMA1 Channel5 global interrupt對應USART1_RX。Preemption Priority搶占優先級數值越小優先級越高。當兩個中斷同時發生時優先級高的先執行并且可以打斷正在執行的優先級低的中斷。Sub Priority子優先級當兩個中斷的搶占優先級相同時比較子優先級數值小的先執行但它們不能互相打斷。 對于簡單的應用你可以不修改使用默認優先級。但在復雜的實時系統中合理設置中斷優先級至關重要。例如確保接收DMA中斷的優先級高于發送或者高于某些非緊急的定時器中斷。配置完成后你的DMA和中斷設置應該大致如下圖所示具體通道號可能因型號而異USART1_TX - DMA1 Channel4, Direction: M2P, Priority: Medium, Mode: Normal, MemInc: Enable, ... USART1_RX - DMA1 Channel5, Direction: P2M, Priority: Medium, Mode: Normal, MemInc: Enable, ... NVIC: DMA1 Channel4/5 Interrupt Enabled.5. 生成代碼與HAL庫關鍵函數解析點擊CubeMX右上角的“GENERATE CODE”選擇你的IDE如Keil MDK-ARM、IAR或STM32CubeIDE并指定工程路徑。CubeMX會生成完整的初始化代碼。打開生成的主文件通常是main.c在/* USER CODE BEGIN PV */和/* USER CODE END PV */之間我們定義發送和接收緩沖區。/* Private variables ---------------------------------------------------------*/ /* USER CODE BEGIN PV */ uint8_t uart_tx_buffer[100]; // 發送緩沖區 uint8_t uart_rx_buffer[50]; // 接收緩沖區 /* USER CODE END PV */在main函數中系統初始化HAL_Init()、時鐘配置SystemClock_Config()和外設初始化MX_DMA_Init(),MX_USART1_UART_Init()的代碼都已由CubeMX生成并調用。初始化完成后進入主循環while (1)之前是啟動DMA接收的最佳時機。5.1 啟動DMA接收在/* USER CODE BEGIN 2 */區域添加以下代碼來啟動DMA接收/* USER CODE BEGIN 2 */ // 啟動串口DMA接收 // 參數串口句柄 接收緩沖區地址 接收數據長度 超時時間對于DMA接收此參數在非阻塞模式下通常無效可填最大值 if (HAL_UART_Receive_DMA(huart1, uart_rx_buffer, sizeof(uart_rx_buffer)) ! HAL_OK) { // 啟動失敗可以在這里添加錯誤處理比如點亮一個錯誤指示燈 Error_Handler(); } /* USER CODE END 2 */這行代碼是整個DMA接收邏輯的起點。它告訴DMA控制器請監視USART1的接收寄存器一旦有數據到來就立刻將其搬運到uart_rx_buffer數組的開頭連續搬運sizeof(uart_rx_buffer)這里是50個字節。搬運完成后觸發DMA傳輸完成中斷。5.2 編寫DMA傳輸完成中斷回調函數HAL庫采用回調函數機制來處理中斷。我們不需要直接編寫中斷服務函數ISR而是重寫對應的回調函數。當DMA發送或接收完成時HAL庫會在其中斷服務函數里調用這些回調函數。在main.c文件中找到/* USER CODE BEGIN 4 */區域這是我們放置用戶代碼的地方。添加以下兩個函數/* USER CODE BEGIN 4 */ // DMA發送完成中斷回調函數 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { // 判斷是哪個串口觸發的回調 if (huart-Instance USART1) { // USART1的DMA發送完成 // 可以在這里置位一個標志位通知主循環發送完成或者準備下一次發送 // 例如tx_complete_flag 1; // 注意不要在回調函數內進行長時間操作或調用可能阻塞的HAL_Delay() } } // DMA接收完成中斷回調函數 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // USART1的DMA接收完成收到了50個字節 // 1. 處理接收到的數據uart_rx_buffer 中現在有50個新字節 // process_received_data(uart_rx_buffer, 50); // 2. 非常重要重新啟動DMA接收以準備接收下一批數據。 // 因為我們在CubeMX中配置的是Normal模式傳輸完成后DMA就停止了。 // 如果不重新啟動串口后續收到的數據將無法被DMA搬運可能導致溢出錯誤。 HAL_UART_Receive_DMA(huart1, uart_rx_buffer, sizeof(uart_rx_buffer)); } } /* USER CODE END 4 */關于HAL_UART_RxCpltCallback的特別說明 這是最容易忽略的一步。在Normal模式下DMA傳輸完成后就停止了。如果你只啟動一次接收那么DMA在收滿50個字節后就會永遠停止之后串口再來的數據將無處安放除非你開啟了溢出中斷并處理。因此必須在本次接收完成的回調函數中立即重新調用HAL_UART_Receive_DMA來啟動下一次接收從而形成一個“接收-處理-再準備接收”的循環。這是DMA接收Normal模式下的標準用法。5.3 主循環中觸發DMA發送現在我們可以在主循環中模擬一個發送任務。例如當按下某個按鍵如果連接了的話或者定時發送時啟動DMA發送。/* USER CODE BEGIN WHILE */ while (1) { /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ // 示例每秒通過DMA發送一次數據 HAL_Delay(1000); // 準備要發送的數據 sprintf((char*)uart_tx_buffer, Hello DMA! Count: %lu\r\n, HAL_GetTick()); // 啟動DMA發送 // 參數串口句柄 發送數據地址 發送數據長度 超時時間DMA模式下通常無效 if (HAL_UART_Transmit_DMA(huart1, uart_tx_buffer, strlen((char*)uart_tx_buffer)) ! HAL_OK) { // 發送啟動失敗處理 } // 注意調用此函數后立即返回發送在后臺由DMA進行。 // 發送完成后會觸發HAL_UART_TxCpltCallback回調。 // 主循環可以繼續做其他事情比如閃爍LED而不被發送任務阻塞。 HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } /* USER CODE END 3 */6. 進階話題與實戰避坑指南當你成功跑通基礎的DMA收發后可能會遇到一些更復雜的需求和陷阱。下面分享幾個實戰中總結的經驗。6.1 如何接收不定長數據DMA空閑中斷Idle Interrupt前面我們用的是Normal模式配合固定長度接收。但在實際應用中比如接收一串以回車換行\r\n結尾的指令或者一個變長的數據包數據長度是不固定的。此時“DMA空閑中斷”是STM32 HAL庫推薦的優雅方案。原理使能串口的空閑中斷Idle Interrupt。當DMA在接收數據時如果串口總線空閑沒有新數據的時間超過一幀數據的傳輸時間硬件就會產生一個空閑中斷。在這個中斷里我們可以知道從上次處理完數據到現在DMA又搬了多少數據到緩沖區。配置步驟CubeMX配置在USART1的NVIC Settings中使能USART1 global interrupt。在代碼中我們需要手動使能空閑中斷。代碼修改在啟動DMA接收后使能空閑中斷。// 在啟動DMA接收后使能空閑中斷 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);重寫串口全局中斷回調函數HAL_UART_IRQHandler會調用的UART_ExRxEventCallbackHAL庫較新版本或直接在USART1_IRQHandler的中斷服務函數中添加空閑中斷判斷老版本方法。這里以新版本回調為例// 在main.c的USER CODE BEGIN 4區域 void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { // Size參數在空閑中斷觸發時表示自上次事件以來接收到的數據量 // 但更通用的做法是在空閑中斷標志位判斷中計算 } }更通用的做法是在USART1_IRQHandler調用的HAL_UART_IRQHandler函數執行后我們檢查空閑中斷標志。// 在stm32f1xx_it.c的USART1_IRQHandler函數中添加用戶代碼 void USART1_IRQHandler(void) { /* USER CODE BEGIN USART1_IRQn 0 */ // 檢查是否是空閑中斷 if(__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 清除空閑中斷標志必須 // 計算本次收到了多少數據 // DMA接收的數據量 設定的接收長度 - DMA寄存器中剩余未傳輸的數據量 uint16_t received_size sizeof(uart_rx_buffer) - __HAL_DMA_GET_COUNTER(huart1.hdmarx); if(received_size 0) { // 處理數據uart_rx_buffer[0] 到 uart_rx_buffer[received_size-1] process_data(uart_rx_buffer, received_size); // 重新啟動DMA接收指向緩沖區起始位置 HAL_UART_Receive_DMA(huart1, uart_rx_buffer, sizeof(uart_rx_buffer)); } } /* USER CODE END USART1_IRQn 0 */ HAL_UART_IRQHandler(huart1); /* USER CODE BEGIN USART1_IRQn 1 */ /* USER CODE END USART1_IRQn 1 */ }關鍵點一定要清除空閑中斷標志__HAL_UART_CLEAR_IDLEFLAG否則會連續進入中斷。通過__HAL_DMA_GET_COUNTER可以獲取DMA通道中剩余未傳輸的數據量從而反推出已經接收了多少數據。6.2 DMA發送的“坑”數據覆蓋與緩存一致性當你調用HAL_UART_Transmit_DMA后函數會立即返回但DMA可能還在搬運數據。絕對不能在DMA發送完成前修改發送緩沖區uart_tx_buffer的內容否則DMA可能會發送出被修改后的混亂數據。解決方案雙緩沖區Ping-Pong Buffer準備兩個發送緩沖區A和B。當DMA正在發送緩沖區A的數據時主循環可以準備下一幀數據到緩沖區B。在A發送完成的回調函數中切換并啟動B的發送同時主循環可以填充A。如此循環。使用發送完成標志在HAL_UART_TxCpltCallback中設置一個標志位tx_busy 0。主循環中只有檢測到tx_busy 0時才填充緩沖區并啟動下一次DMA發送同時設置tx_busy 1。6.3 調試技巧如何判斷DMA是否在工作查看寄存器在調試器中查看DMA通道的CNDTR寄存器Counter。這個值表示剩余待傳輸的數據量。如果它在遞減說明DMA正在搬運數據。傳輸完成后它會變為0并可能自動重載循環模式或停止普通模式。使用斷點和標志位在DMA發送/接收完成回調函數入口處設置斷點。如果程序能停在這里說明DMA傳輸完成中斷觸發了基本證明DMA工作正常。檢查硬件連接和波特率這是老生常談但最容易出錯的地方。務必確認TX、RX線接對了且兩端設備STM32和PC串口工具的波特率、數據位、停止位、校驗位完全一致。簡化測試初次測試時先只測試發送或只測試接收。例如先注釋掉接收的啟動代碼只測試DMA發送用串口助手看是否能收到數據。成功后再測試接收可以用串口助手定時發送固定數量的字節看是否能進入接收完成回調。6.4 資源管理與錯誤處理HAL庫的DMA函數會返回HAL_StatusTypeDef。除了HAL_OK還可能返回HAL_BUSYDMA正忙、HAL_ERROR參數錯誤或DMA狀態錯誤等。在生產代碼中應該檢查這些返回值并進行適當的錯誤處理比如重試、記錄錯誤日志或系統復位。此外STM32的DMA資源有限。在復雜項目中需要像管理內存一樣管理DMA通道。CubeMX的圖形化分配能解決大部分沖突但當你手動修改代碼或移植工程時務必確認沒有兩個外設試圖使用同一個DMA通道。