暴的完整實戰(zhàn)指南)
簡介面向嵌入式開發(fā)者的實時操作系統(tǒng)串口收發(fā)工程資料包基于STM32F302CB平臺演示如何通過直接存儲器訪問DMA優(yōu)化通用異步收發(fā)器UART通信實現(xiàn)高效且不丟數(shù)據(jù)的循環(huán)接收覆蓋任務(wù)調(diào)度、緩沖區(qū)切換、數(shù)據(jù)解析等核心環(huán)節(jié)。工程壓縮包共包含六百五十九個文件體積約十三點八一兆字節(jié)以C源碼、頭文件、匯編文件及官方工程配置為主輔以鏈接腳本、調(diào)試配置、圖形化初始化配置等資源目錄結(jié)構(gòu)清晰便于移植與二次開發(fā)。目前已有三百九十六人學(xué)習(xí)下載。工程內(nèi)提供了完整的HAL驅(qū)動代碼、DMA中斷處理示例以及FreeRTOS任務(wù)集成框架同時附有構(gòu)建腳本、調(diào)試描述文件和豐富的外設(shè)配置實例可幫助讀者深入理解DMA循環(huán)接收的底層機(jī)制掌握中斷服務(wù)函數(shù)與實時任務(wù)協(xié)同設(shè)計的思路尤其適合需要高速連續(xù)串行通信的工業(yè)自動化和物聯(lián)網(wǎng)設(shè)備開發(fā)者。此類設(shè)計在實際項目中能顯著降低CPU占用提升通信穩(wěn)定性是進(jìn)階嵌入式開發(fā)的實用參考。1. FreeRTOS 下用 DMA 接管 UART中斷風(fēng)暴的終結(jié)與三個隱藏代價很多人第一次在 FreeRTOS 里碰串口都是被「丟字節(jié)」和「高波特率必翻車」這兩件事逼到查手冊的。用查詢方式收數(shù)據(jù)CPU 被占死任務(wù)調(diào)度成了擺設(shè)用傳統(tǒng)中斷逐字節(jié)收115200 波特率下每秒一萬多次中斷系統(tǒng)響應(yīng)被割成碎片稍一忙就進(jìn) HardFault。我最早做某跨平臺系統(tǒng)的串口通信模塊時就是被這種中斷風(fēng)暴折磨到換方案才徹底轉(zhuǎn)向 DMA。這套「FreeRTOS DMA UART」的方案核心思路是讓串口硬件直接把數(shù)據(jù)搬到內(nèi)存里收滿指定長度或總線空閑了再通知 CPU從「數(shù)據(jù)搬運(yùn)工」變成「數(shù)據(jù)驗收員」。對整個系統(tǒng)而言CPU 占用率能下降八成以上對你來說這套組合的代碼、移植模板和踩坑清單才是真正值錢的部分。注意DMA 不是萬能的緩沖區(qū)管理、斷幀判定、Cache 一致性這三個問題沒處理好移植回去一樣翻車。2. 為什么 FreeRTOS 的串口必須配 DMA先看懂「中斷風(fēng)暴」數(shù)學(xué)模型2.1 實時性斷裂的本質(zhì)一次中斷三次進(jìn)出在 FreeRTOS 里跑傳統(tǒng)串口中斷每次收一個字節(jié)中斷服務(wù)函數(shù)里要做三件事讀數(shù)據(jù)寄存器、把數(shù)據(jù)寫進(jìn)緩沖區(qū)、判斷是否要喚醒接收任務(wù)。每件事在 ARM Cortex-M 上都是十幾次時鐘周期的操作加上中斷壓棧和出棧的固定開銷一次中斷要消耗 60-100 個周期。115200 波特率意味著每 86.8 微秒來一個字節(jié)算下來一秒鐘約 1.15 萬次中斷。這還沒算 FreeRTOS 在中斷里調(diào)用 portYIELD_FROM_ISR 時進(jìn)行的上下文切換開銷——每次切換要保存和恢復(fù)十幾個寄存器。結(jié)果就是系統(tǒng)里最高優(yōu)先級接收任務(wù)的實時性看上去有保障實際上 CPU 的時間片被切得稀碎任何高于串口中斷優(yōu)先級的任務(wù)稍微跑久一點串口緩沖區(qū)就溢出。更隱蔽的問題在低優(yōu)先級任務(wù)。假設(shè)有一個周期 10ms 的采集任務(wù)被串口中斷不斷搶占它的實際完成時間可能被拉到 50ms 開外。這時候你調(diào) FreeRTOS 的任務(wù)優(yōu)先級根本沒意義因為中斷已經(jīng)把調(diào)度的顆粒度打碎了。2.2 DMA 改變了什么搬運(yùn)交給硬件通知只在邊界DMA 接管串口接收后數(shù)據(jù)流是這樣串口外設(shè)收到一個字節(jié)直接由 DMA 控制器搬到指定的內(nèi)存數(shù)組里完全繞開 CPU。只有當(dāng) DMA 搬完設(shè)定長度的數(shù)據(jù)或者外設(shè)觸發(fā)空閑中斷時CPU 才被叫醒一次。以 512 字節(jié)的 DMA 緩沖區(qū)為例同樣是 115200 波特率CPU 每秒被叫醒的次數(shù)從 1.15 萬次降到大約 20 次左右——也就是緩沖區(qū)滿了才處理一次加上空閑中斷若干次。理論上DMA 收滿一個緩沖區(qū)和 FreeRTOS 任務(wù)處理這段數(shù)據(jù)是并行的CPU 只在 DMA 搬運(yùn)結(jié)束的那一刻參與工作。這里有個關(guān)鍵選型點DMA 的傳輸模式。我一般會用 Normal 模式加手動重裝載而不是直接用 Circular 模式原因在后續(xù)章節(jié)展開但先記住一個結(jié)論——Circular 模式省心但丟幀邊界非常難判斷半傳輸中斷那套機(jī)制在 FreeRTOS 任務(wù)調(diào)度下反而容易產(chǎn)生競態(tài)。2.3 任務(wù)架構(gòu)怎么搭生產(chǎn)者-消費(fèi)者模型的正確姿勢硬件層面搞定 DMA 以后軟件層要把接收鏈路由中斷驅(qū)動改成「DMA 寫緩沖、任務(wù)讀緩沖」的生產(chǎn)者-消費(fèi)者模型。典型結(jié)構(gòu)分三段。第一段是 DMA 中斷回調(diào)函數(shù)里把本次搬運(yùn)的數(shù)據(jù)長度記下來然后通過二值信號量或 direct to task notification 喚醒接收任務(wù)中斷里不能做的解析、組幀、分發(fā)全部放到任務(wù)上下文。第二段是接收任務(wù)本體掛起在信號量上等待喚醒被喚醒后從緩沖區(qū)里按幀解析。第三段是業(yè)務(wù)任務(wù)和接收任務(wù)之間用 FreeRTOS 的 StreamBuffer 或者消息隊列解耦。/* FreeRTOS DMA UART 接收任務(wù)骨架 */ static void uart_rx_task(void *arg) { uint8_t frame_buf[128]; uint16_t frame_len 0; for (;;) { /* 等待 DMA 半滿/全滿或空閑中斷信號量 */ xSemaphoreTake(rx_sem, portMAX_DELAY); /* 從 DMA 緩沖區(qū)拷貝一幀數(shù)據(jù)到本地棧緩沖 */ frame_len dma_uart_pack_frame(uart_dma_buf, dma_buf_wr_idx, frame_buf, 128); if (frame_len 0) { /* 這里做協(xié)議解析比如幀頭校驗、CRC 校驗 */ parse_and_dispatch(frame_buf, frame_len); } } }這段代碼的邏輯核心在 dma_uart_pack_frame它負(fù)責(zé)計算 DMA 寫指針走到哪了把新到的數(shù)據(jù)按幀切出來。注意解析動作放在任務(wù)里不在中斷回調(diào)里做是為了避免在 ISR 里執(zhí)行過長邏輯導(dǎo)致新的 DMA 中斷被阻塞——這是判斷這套方案是否合格的分水嶺。信號量參數(shù)上xSemaphoreTake 的等待時間設(shè)為 portMAX_DELAY讓接收任務(wù)在數(shù)據(jù)沒來的時候徹底睡眠不占 CPU。實際項目中建議把信號量換成「任務(wù)通知Task Notification」因為通知的延遲比信號量低大約 20%-30%而且不消耗獨(dú)立的信號量對象。3. 拆分 DMA 緩沖區(qū)與幀邊界判定半傳輸中斷和空閑中斷的分工3.1 為什么單靠「傳輸完成」中斷會卡死自己如果用 DMA 的 Normal 模式搬運(yùn)完設(shè)定的字節(jié)數(shù)才觸發(fā)一次傳輸完成中斷那 CPU 必須趕在 DMA 寫完緩沖區(qū)之前把數(shù)據(jù)取走否則 DMA 繼續(xù)搬運(yùn)新數(shù)據(jù)就會覆蓋還沒處理的內(nèi)容。問題是 FreeRTOS 任務(wù)的調(diào)度延遲不可控——如果接收任務(wù)優(yōu)先級不夠高或者恰好有更高優(yōu)先級任務(wù)在跑DMA 緩沖區(qū)被覆蓋幾乎是必然的。把緩沖區(qū)做大能緩解但治標(biāo)不治本內(nèi)存開銷先吃不消。于是經(jīng)典做法是引入「半傳輸中斷」DMA 每搬滿半個緩沖區(qū)就觸發(fā)一次中斷CPU 處理前半段數(shù)據(jù)等搬滿整個緩沖區(qū)再觸發(fā)傳輸完成中斷CPU 處理后半段。這樣在一個 DMA 周期內(nèi)CPU 始終在處理「另一半」的數(shù)據(jù)緩沖區(qū)永遠(yuǎn)有安全區(qū)。3.2 環(huán)形緩沖區(qū)的寫指針和讀指針怎么跟蹤實際落地時我們不用裸數(shù)組直接當(dāng)緩沖區(qū)而是包一層環(huán)形緩沖區(qū)。環(huán)形緩沖區(qū)的好處是讀寫解耦DMA 只管寫任務(wù)只管讀互不干擾。麻煩的是要維護(hù)兩個指針寫指針由 DMA 硬件在搬運(yùn)時更新讀指針由任務(wù)在消費(fèi)數(shù)據(jù)時更新。/* DMA FreeRTOS 環(huán)形緩沖區(qū)結(jié)構(gòu)體 */ typedef struct { uint8_t pool[DMA_BUF_SIZE]; /* 實際存儲區(qū) */ volatile uint16_t wr_idx; /* DMA 寫到的位置 */ volatile uint16_t rd_idx; /* 任務(wù)讀到的位置 */ uint16_t dma_half_len; /* 半傳輸閾值 */ uint16_t dma_full_len; /* 整包閾值 */ } dma_ring_buf_t; /* DMA 傳輸完成中斷回調(diào) */ void uart_dma_rx_complete_cb(DMA_HandleTypeDef *hdma) { dma_ring_buf_t *rb (dma_ring_buf_t *)hdma-RxBuf; BaseType_t xHigherPriorityTaskWoken pdFALSE; /* 若是半傳輸中斷寫指針移動到 half_len */ if (hdma-Instance-ISR DMA_ISR_HTIF) { rb-wr_idx rb-dma_half_len; } /* 若是全傳輸中斷寫指針回到尾部或者循環(huán) */ else if (hdma-Instance-ISR DMA_ISR_TCIF) { rb-wr_idx rb-dma_full_len; } /* 通知接收任務(wù)處理 */ vTaskNotifyGiveFromISR(rx_task_handle, xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }這段代碼里最核心的是 wr_idx 的賦值邏輯半傳輸中斷到達(dá)時DMA 寫完了前半段wr_idx 指向半閾值全傳輸中斷到達(dá)時DMA 寫完整個緩沖區(qū)wr_idx 指向滿閾值。任務(wù)側(cè)每次拿到新的 wr_idx就對比自己上次處理到的 rd_idx把兩者之間的數(shù)據(jù)全部取走。有一個容易被忽略的參數(shù)DMA 源地址和外設(shè)地址的方向配置。串口 DMA 接收時外設(shè)地址是 UART 數(shù)據(jù)寄存器內(nèi)存地址是 pool 數(shù)組方向是外設(shè)到內(nèi)存這些必須在初始化時設(shè)置正確否則數(shù)據(jù)根本不會進(jìn)到 pool 里。排查時第一件事就是打印外設(shè)地址和內(nèi)存地址八成問題出在這。3.3 空閑中斷負(fù)責(zé)「斷幀」別讓粘包毀了協(xié)議DMA 半滿和全滿中斷解決的是「大流量持續(xù)灌入」的場景但實際串口數(shù)據(jù)幾乎都是不定長的。一幀數(shù)據(jù)可能 10 個字節(jié)也可能 200 個字節(jié)。如果只靠 DMA 長度閾值CPU 必須在收到一幀后等緩沖區(qū)填滿才知道有數(shù)據(jù)延遲無法接受——尤其像 Modbus 這種短幀協(xié)議一幀 8 個字節(jié)512 字節(jié)的緩沖區(qū)要 64 幀才填滿實時性全沒了。解決辦法是啟用 UART 的空閑中斷??偩€空閑超過一個字節(jié)時間說明一幀發(fā)完了立刻觸發(fā)空閑中斷。中斷里我們不做數(shù)據(jù)拷貝只是更新寫指針并通知任務(wù)。/* UART 空閑中斷處理表示一幀結(jié)束 */ void UART_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLE_FLAG(huart1); /* DMA 還在搬運(yùn)的話先關(guān)閉 DMA 通道獲取實際寫指針 */ size_t dma_remaining __HAL_DMA_GET_COUNTER(hdma_usart1_rx); rb.wr_idx rb.dma_full_len - dma_remaining; /* 用任務(wù)通知喚醒接收任務(wù) */ vTaskNotifyGiveFromISR(rx_task_handle, xHigherPriorityTaskWoken); } portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }空閑中斷和 DMA 配合的細(xì)節(jié)在于DMA 計數(shù)器CNDTR時刻在變表示還剩多少字節(jié)要搬。用「DMA 總長度減去剩余計數(shù)」得到當(dāng)前已寫入的位置這樣即使 DMA 還沒到半滿或全滿閾值我們也能精確知道數(shù)據(jù)停在哪。有個參數(shù)值得專門說——空閑中斷的判定時間由串口的波特率決定16 倍過采樣下大約是一個字節(jié)的傳輸時間。波特率越高空閑判定越快所以 CPU 的響應(yīng)要跟上建議空閑中斷不要分配太低的優(yōu)先級。4. 從零配置 FreeRTOS DMA UART 工程初始化順序與參數(shù)對照4.1 初始化順序GPIO、DMA、UART、RTOS 的先后關(guān)系初次上手最容易踩的就是初始化順序。我見過有人先初始化 UART 再初始化 DMA結(jié)果串口一收到數(shù)據(jù)DMA 還沒就位數(shù)據(jù)直接丟在硬件 FIFO 里。正確順序是先配置 GPIO 的復(fù)用功能確保引腳輸出到串口的 TX/RX 通道再配置 DMA 控制器的數(shù)據(jù)流把它綁定到 UART 的接收請求然后初始化 UART 外設(shè)本身使能 DMA 接收請求和空閑中斷最后才創(chuàng)建 FreeRTOS 的信號量和任務(wù)。順序反了輕則收不到數(shù)據(jù)重則 DMA 配置失敗直接 HardFault。/* FreeRTOS DMA UART 初始化順序示例 */ void uart_dma_init(void) { /* 第一步GPIO 復(fù)用配置 */ __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Alternate GPIO_AF7_USART1; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); /* 第二步DMA 流配置必須設(shè)成循環(huán)模式配合環(huán)形緩沖 */ __HAL_RCC_DMA1_CLK_ENABLE(); hdma_rx.Instance DMA1_Stream3; hdma_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_rx.Init.MemInc DMA_MINC_ENABLE; hdma_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_rx.Init.Mode DMA_CIRCULAR; /* 循環(huán)模式保證連續(xù)接收 */ hdma_rx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_rx); /* 第三步UART 使能 DMA 接收 */ huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; HAL_UART_Init(huart1); /* 第四步關(guān)聯(lián) DMA 并啟動接收 */ __HAL_LINKDMA(huart1, hdmarx, hdma_rx); HAL_UART_Receive_DMA(huart1, rb.pool, DMA_BUF_SIZE); /* 第五步創(chuàng)建信號量和接收任務(wù)FreeRTOS 啟動后 */ rx_sem xSemaphoreCreateBinary(); xTaskCreate(uart_rx_task, uart_rx, 256, NULL, 5, rx_task_handle); }初始化參數(shù)里DMA_CIRCULAR 循環(huán)模式是這里最大的決策點。前面說 Normal 模式手動重裝載適合某些場景但環(huán)形緩沖區(qū)的場景下 Circular 是首選——它讓 DMA 在搬滿整個緩沖區(qū)后自動回卷到起點繼續(xù)搬運(yùn)不用 CPU 干預(yù)配合半滿/全滿中斷正好實現(xiàn)「雙緩沖」效果。另一個參數(shù)是 MemDataAlignment。如果緩沖區(qū)聲明為 uint8_t 數(shù)組就用 BYTE如果改成 uint16_t 數(shù)組加速傳輸就要同步改這一項否則 DMA 搬完數(shù)據(jù)在內(nèi)存里是錯位的解析時全是亂碼。4.2 關(guān)鍵宏定義和緩沖區(qū)大小怎么定緩沖區(qū)大小不是拍腦袋定的它取決于你的協(xié)議幀最大長度、波特率、任務(wù)處理速度三者之間的平衡。經(jīng)驗值是緩沖區(qū)至少是最大協(xié)議幀的兩倍同時保證在最高波特率下任務(wù)取出數(shù)據(jù)的速度大于 DMA 灌入的速度。/* 工程配置示例典型的參數(shù)宏定義 */ #define UART_DMA_BUF_SIZE 512 /* 環(huán)形緩沖區(qū)總大小 */ #define UART_RX_FRAME_MAX 128 /* 協(xié)議單幀最大長度 */ #define UART_BAUDRATE 115200 /* 波特率 */ #define UART_RX_TASK_STACK 512 /* 接收任務(wù)棧大小(字) */ #define UART_RX_TASK_PRIO 5 /* 優(yōu)先級高于業(yè)務(wù)任務(wù) */參數(shù)說明512 字節(jié)的緩沖區(qū)在 115200 波特率下DMA 灌滿需要約 44ms只要接收任務(wù)在 44ms 內(nèi)處理完 256 字節(jié)系統(tǒng)就穩(wěn)。任務(wù)棧 512 個字2048 字節(jié)給協(xié)議解析預(yù)留了足夠空間如果解析里有大數(shù)組或格式化打印建議加一倍。我一般會把任務(wù)優(yōu)先級設(shè)為 5比業(yè)務(wù)任務(wù)高一級。太低的話 DMA 緩沖容易溢出太高的話會搶占關(guān)鍵業(yè)務(wù)邏輯的 CPU。這個值不是死的調(diào)試時先用高優(yōu)先級跑通再逐步降。4.3 數(shù)據(jù)解析任務(wù)里如何與協(xié)議層解耦數(shù)據(jù)進(jìn)到任務(wù)上下文后最怕的是在接收任務(wù)里同步調(diào)協(xié)議解析、寫 Flash、發(fā)網(wǎng)絡(luò)。任何一個慢操作都會阻塞下一次空閑中斷的處理造成數(shù)據(jù)堆積。正確做法是把解析和分發(fā)拆成兩個階段。接收任務(wù)只做快速判斷——檢查幀頭、幀尾、CRC然后把完整幀拷貝到 StreamBuffer 里真正的業(yè)務(wù)處理任務(wù)從 StreamBuffer 讀數(shù)據(jù)做業(yè)務(wù)邏輯。StreamBuffer 的好處是天然帶阻塞讀業(yè)務(wù)任務(wù)沒數(shù)據(jù)時自動掛起不占 CPU。/* 接收任務(wù)拷貝到 StreamBuffer 后快速返回 */ void uart_rx_task(void *arg) { uint8_t tmp[DMA_BUF_SIZE]; uint16_t len 0; for (;;) { xTaskNotifyWait(0, 0, NULL, portMAX_DELAY); while (rb.rd_idx ! rb.wr_idx) { len get_valid_frame(rb, tmp, sizeof(tmp)); if (len 0) { /* 寫入流緩沖業(yè)務(wù)任務(wù)會取走 */ xStreamBufferSend(rx_stream, tmp, len, 0); } } } }StreamBuffer 的發(fā)送超時傳 0表示緩沖區(qū)滿就立刻返回不等待。實際使用中要注意 StreamBuffer 的尺寸必須大于可能寫入的最大幀否則大幀會寫不進(jìn)去。觸發(fā)閾值設(shè) 1意思是任何數(shù)據(jù)到達(dá)都喚醒對端任務(wù)不要設(shè)成半包再喚醒那樣會額外引入一幀的延遲。5. FreeRTOS DMA UART 常見問題與排查五個必踩的坑5.1 數(shù)據(jù)錯位或亂碼DMA 搬運(yùn)方向反了現(xiàn)象串口收到數(shù)據(jù)是亂碼每個字節(jié)都不對偶爾能收到幾個正確的字符但整體不可用。原因最常見的兩個——GPIO 復(fù)用配置錯誤引腳實際連到了別的串口DMA 方向初始化成了 MEMORY_TO_PERIPH數(shù)據(jù)從內(nèi)存發(fā)到外設(shè)而不是從外設(shè)收進(jìn)內(nèi)存。第二種更隱蔽因為系統(tǒng)不報錯DMA 也正常搬只是方向反了寄存器里根本沒數(shù)據(jù)。解決第一步檢查 GPIO 復(fù)用功能編號是否正確第二步打印 DMA 控制器的 CR 寄存器里的 DIR 位確認(rèn)是 00外設(shè)到內(nèi)存。我排查時會先用輪詢方式接收一個字節(jié)做比對能收到說明 UART 本身沒問題收不到就是 DMA 配置的問題直接看 DIR 位。5.2 任務(wù)收不到數(shù)據(jù)信號量與 DMA 中斷優(yōu)先級打架現(xiàn)象DMA 中斷被打斷數(shù)據(jù)正常搬運(yùn)了但接收任務(wù)永遠(yuǎn)不被喚醒偶爾按一下復(fù)位鍵積壓的數(shù)據(jù)全部吐出來。原因DMA 中斷的優(yōu)先級被配置成了比 FreeRTOS 內(nèi)核的 SysTick 或 PendSV 還低。Cortex-M 的中斷優(yōu)先級數(shù)值越小優(yōu)先級越高如果 DMA 中斷優(yōu)先級數(shù)值大于等于 PendSV 的閾值FreeRTOS 上下文切換機(jī)制根本不會觸發(fā) ISR 里的通知操作——具體表現(xiàn)就是「中斷進(jìn)了任務(wù)不跑」。解決把 DMA 中斷優(yōu)先級調(diào)到高于 PendSV。在 CubeMX 里將 DMA 中斷優(yōu)先級設(shè)為 Highest 或 High 級別同時確認(rèn) FreeRTOS 的 configMAX_SYSCALL_INTERRUPT_PRIORITY 允許該優(yōu)先級調(diào)用 fromISR 結(jié)尾的 API。我通常設(shè) DMA 中斷優(yōu)先級為 5SysTick 為 15保證 DMA 響應(yīng)最快。5.3 DMA 緩沖區(qū)溢出任務(wù)處理慢導(dǎo)致數(shù)據(jù)被覆蓋現(xiàn)象串口長時間跑高流量數(shù)據(jù)后一幀數(shù)據(jù)中間插入了幾字節(jié)的舊數(shù)據(jù)或者幀的頭尾錯亂。原因緩沖區(qū)是循環(huán)的DMA 寫到頭自動回卷。如果任務(wù)處理速度跟不上 DMA 寫入速度wr_idx 追上 rd_idx就會把還沒消費(fèi)的數(shù)據(jù)覆蓋掉。FreeRTOS 任務(wù)調(diào)度的不確定性加劇了這個問題——任何一次高優(yōu)先級任務(wù)搶占都會讓接收任務(wù)多等幾十毫秒。解決最直接的辦法是加大緩沖區(qū)給任務(wù)更多處理時間更根本的辦法是用雙緩沖區(qū)交替使用——A 緩沖被 DMA 寫滿后自動切到 B 緩沖CPU 只處理 A反過來同理。雙緩沖把「DMA 寫入」和「CPU 讀取」的沖突概率降到最低。5.4 低功耗模式下 DMA 收不到數(shù)據(jù)外設(shè)時鐘被關(guān)了現(xiàn)象設(shè)備進(jìn)入睡眠模式后串口外部發(fā)數(shù)據(jù)無法喚醒或者喚醒后 DMA 數(shù)據(jù)全丟。原因低功耗模式下DMA 控制器的時鐘被關(guān)閉串口數(shù)據(jù)到了但沒有搬運(yùn)通道數(shù)據(jù)在 UART 的 FIFO 里直接溢出。即使 UART 能用外部中斷喚醒芯片DMA 來不及啟動數(shù)據(jù)已經(jīng)丟了。解決在每個需要串口喚醒的低功耗入口前把 DMA 重新初始化或者配置 DMA 控制器為低功耗模式的喚醒源。我一般用「睡眠模式 保留 DMA 時鐘」的折中方案睡眠能省的功耗有限但數(shù)據(jù)完整性有保障。深度睡眠模式下串口喚醒基本不可用除非外接硬件緩沖。5.5 FreeRTOS 內(nèi)存不足任務(wù)棧分配導(dǎo)致啟動失敗現(xiàn)象系統(tǒng)啟動后任務(wù)不運(yùn)行調(diào)試器看任務(wù)狀態(tài)是 Ready 但永遠(yuǎn)不被調(diào)度或者 vTaskStartScheduler 直接斷言失敗。原因FreeRTOS 的堆大小配置不足。接收任務(wù)的棧、StreamBuffer 的存儲區(qū)、其它業(yè)務(wù)任務(wù)的??偤统^了 configTOTAL_HEAP_SIZE。DMA 緩沖區(qū)沒占 FreeRTOS 堆它是靜態(tài)數(shù)組但 StreamBuffer 會動態(tài)分配。解決把接收任務(wù)棧從 512 降到 256或者把 configTOTAL_HEAP_SIZE 從 4KB 提到 16KB。我用的是 heap_4 方案碎片少一些但堆仍然要留至少 20% 的余量。建議在啟動時調(diào)用 xPortGetFreeHeapSize 打印一次堆剩余量心里有數(shù)。6. 進(jìn)階DMA 與 Cache 一致性問題——Cortex-M7 上的隱形殺手6.1 為什么 CM7 核心會出現(xiàn)「DMA 寫了數(shù)據(jù)CPU 讀到舊值」Cortex-M7 或更高端核心帶 D-Cache 時CPU 讀數(shù)據(jù)會先查 CacheCache 沒有才訪問內(nèi)存。DMA 是直接訪問內(nèi)存的外設(shè)它寫完的數(shù)據(jù)在內(nèi)存里但 CPU 的 Cache 里可能還留著舊值于是任務(wù)讀到的還是老數(shù)據(jù)——這就是 DMA 和 CPU 之間的 Cache 一致性問題。癥狀極其隱蔽數(shù)據(jù)明明是新的調(diào)試器單步看數(shù)組確實是新值但跑起來任務(wù)就是拿不到最新內(nèi)容有時候要等 Cache 被其他訪問擠掉才能讀到一次新數(shù)據(jù)表現(xiàn)出「大部分時間正常偶爾丟一幀」。解決手段有兩種一是用 MPU 把 DMA 緩沖區(qū)所在的 SRAM 區(qū)域配置成非緩存屬性讓 CPU 每次都直接訪問內(nèi)存二是在 DMA 搬運(yùn)完成中斷里調(diào)用 SCB_CleanInvalidateDCache主動刷新 Cache。/* 方案一MPU 配置 DMA 緩沖區(qū)區(qū)域為非緩存 */ void mpu_dma_buffer_config(void) { MPU_Region_InitTypeDef MPU_InitStruct; MPU_InitStruct.Enable MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress (uint32_t)uart_dma_buf; MPU_InitStruct.Size MPU_REGION_SIZE_4KB; MPU_InitStruct.SubRegionDisable 0; MPU_InitStruct.TypeExtField MPU_TEX_LEVEL1; MPU_InitStruct.AccessPermission MPU_REGION_FULL_ACCESS; MPU_InitStruct.DisableExec MPU_INSTRUCTION_ACCESS_DISABLE; MPU_InitStruct.IsShareable MPU_ACCESS_NOT_SHAREABLE; MPU_InitStruct.IsCacheable MPU_ACCESS_NOT_CACHEABLE; /* 關(guān)鍵 */ MPU_InitStruct.IsBufferable MPU_ACCESS_BUFFERABLE; HAL_MPU_ConfigRegion(MPU_InitStruct); __HAL_MPU_ENABLE(); }這段配置里 IsCacheable 設(shè)為 NOT_CACHEABLE是核心中的核心。這樣 CPU 訪問這塊緩沖區(qū)時不經(jīng)過 Cache直接讀內(nèi)存DMA 寫進(jìn)去的數(shù)據(jù)立刻對任務(wù)可見。注意不是所有內(nèi)存區(qū)域都適合關(guān)閉 Cache像協(xié)議棧的收發(fā)緩沖區(qū)也建議這樣做全局變量、任務(wù)棧別碰關(guān)掉 Cache 會讓這些區(qū)域的訪問性能大打折扣。方案二適合不想動 MPU 的場景/* 方案二DMA 中斷里主動刷 Cache */ void uart_dma_rx_complete_cb(DMA_HandleTypeDef *hdma) { /* 先清掉 D-Cache 中的舊數(shù)據(jù)再讓 CPU 讀內(nèi)存 */ SCB_InvalidateDCache_by_Addr((uint32_t *)rb.pool, DMA_BUF_SIZE); /* 通知接收任務(wù)處理 */ vTaskNotifyGiveFromISR(rx_task_handle, NULL); }注意這里用的是 Invalidate失效而不是 Clean。因為 DMA 是從外設(shè)往內(nèi)存寫內(nèi)存里的數(shù)據(jù)是最新的CPU 的 Cache 是舊的所以要讓 Cache 失效迫使 CPU 重新從內(nèi)存讀。反過來如果是 CPU 往內(nèi)存寫、DMA 從內(nèi)存讀的場景比如 DMA 發(fā)送就要用 Clean 把 Cache 里的數(shù)據(jù)先寫回內(nèi)存再啟動 DMA。6.2 實測臟數(shù)據(jù)場景與判定方法我最初在某個圖像處理 Demo 里遇到這個坑癥狀是任務(wù)偶爾解析出完全不符合協(xié)議的數(shù)據(jù)但幀頭校驗明明通過了——后來發(fā)現(xiàn)是因為 Cache 里殘留了上一幀的數(shù)據(jù)幀頭字段恰好沒變校驗碼又是對舊數(shù)據(jù)算的整個鏈路的錯誤就這樣被隱藏了。判定方法很簡單在 DMA 完成中斷里主動 Invalidate 一次 Cache看問題是否消失——消失大概率就是 Cache 一致性問題。也可以用調(diào)試器對比 DMA 計數(shù)器和 buffer 里的實際數(shù)據(jù)差異DMA 明明搬了 256 字節(jié)任務(wù)讀的時候前面 64 字節(jié)還是舊值不用再猜直接上 MPU 方案。從那以后我每次在帶 Cache 的核心上移植 DMA 外設(shè)第一件事就是檢查映射緩沖區(qū)地址的 MPU 配置不把這個前提確定清楚后面任何調(diào)試都是白費(fèi)時間。這套 FreeRTOS DMA UART 的組合在邏輯上已經(jīng)足夠可靠剩下的瓶頸全在這類硬件細(xì)節(jié)上希望這些排查路徑能幫你少走幾趟彎路。本文還有配套的精品資源點擊獲取