
記得最早做 ADC 采集項目的時候踩過一個很經典的坑用輪詢方式讀多通道 ADCCPU 全程死等轉換完成啥別的事都干不了后來換成單緩沖 DMA采集倒是不用 CPU 管了但處理數據的時候要么停掉 DMA 等處理完要么硬著頭皮邊寫邊讀經常讀到一半新一半舊的 “撕裂數據”。相信很多人都卡在這一步知道 DMA 能解放 CPU但不知道怎么讓 “采集” 和 “處理” 真正同時跑起來。本文就從硬件原理到工程代碼完整拆解一套工業級常用方案ADC 非連續轉換模式 DMA 乒乓雙緩沖 FreeRTOS 雙線程解耦實現硬件采集與 CPU 計算完全并行全程幾乎零 CPU 占用。讀完這篇文章你可以掌握這四個核心能力徹底搞懂 ADC 非連續轉換模式的工作邏輯與適用場景從零實現 DMA 乒乓雙緩沖從根源避免讀寫沖突用 FreeRTOS 任務通知做輕量同步替代笨重的信號量一套可直接復用的、符合工業規范的并行采集代碼框架一、核心原理拆解為什么這套架構能實現 “并行采集”很多人用了很久 DMA也只是停留在 “配置好就能自動傳數據” 的層面并沒有真正理解并行的本質。我們從三個核心模塊逐一拆開講。1.1 ADC 掃描 非連續轉換模式要講非連續模式得先理清 ADC 的三個基礎模式很多新手在這里就繞暈了。掃描模式Scan Conversion ModeADC 按照預設的 Rank 順序依次轉換多個通道而不是只轉一個通道。比如開 6 個通道掃描模式開啟后ADC 會自動從通道 0 轉到通道 5不用手動挨個切換。連續轉換模式Continuous Conversion Mode一組轉換完成后立刻自動開始下一輪不需要外部觸發。適合不間斷連續采樣的場景。非連續轉換模式Discontinuous Conversion Mode在掃描序列的基礎上把所有通道分成若干小組每次觸發只轉換其中一組轉換完成就停止等下一次觸發再轉下一組。舉個最直觀的例子6 個 ADC 通道設置非連續轉換數為 2那么整個序列就被分成了 3 組第 1 次觸發轉換通道 0、通道 1第 2 次觸發轉換通道 2、通道 3第 3 次觸發轉換通道 4、通道 5三次觸發之后完成一輪完整的 6 通道采集。很多人會問好好的連續模式不用為什么要拆成一組一組的 這就是非連續模式的價值在實時控制系統里我們不一定需要一次把所有通道都采完。比如電機控制電流、電壓通道需要高頻采樣溫度、電壓母線通道可以低頻采樣或者每采一組就立刻做一次閉環計算不用等所有通道都轉完降低單次采樣的延遲。配合軟件觸發我們可以精準控制每一組的采樣時機。1.2 DMA 乒乓雙緩沖的本質先講單緩沖的痛點只有一塊緩沖區的時候DMA 往里寫數據的同時CPU 如果往外讀就很容易讀到 “半新半舊” 的數據 —— 前半字節是上一幀的后半字節是新一幀的這就是數據撕裂。要么你就等 DMA 停了再讀那采集就斷了要么你就硬讀數據正確性沒保障。乒乓緩沖Ping-Pong Buffer的核心思想就是用兩塊大小完全相同的緩沖區交替承擔 “寫入目標” 和 “讀取對象” 的角色第一階段DMA 往緩沖 A 寫數據此時 CPU 不碰 A只讀取緩沖 B 里上一幀的數據A 寫滿觸發中斷立刻切換 DMA 目標到緩沖 B開始寫 B第二階段DMA 往 B 寫數據同時 CPU 去讀緩沖 A 里剛采完的數據B 寫滿后再切回 A循環往復兩塊緩沖區就像兩個乒乓球拍你打過來我打過去讀寫永遠落在不同的內存上從根源上避免了競爭。不需要加鎖、不需要關中斷硬件采集和 CPU 處理完全并行采集全程不會因為處理而暫停處理也不會因為采集而阻塞。嚴格來說這不是 “零拷貝”數據還是從 ADC 外設搬到了內存但它做到了采集與處理的零等待并行這是嵌入式采集系統里最核心的性能優化思路之一。1.3 RTOS 下的線程解耦思想很多人做 RTOS 采集喜歡把 “啟動 ADC、等中斷、處理數據” 全塞在一個線程里寫是能寫但擴展性很差改采集邏輯要動處理代碼改處理代碼又容易影響采集時序。標準的工程做法是拆成兩個職責完全獨立的線程調度線程高優先級只干三件事 —— 等 DMA 中斷通知、切換緩沖、重啟采集、給處理線程發就緒信號。它的核心目標是保證采集時序不被打斷響應越快越好。處理線程普通優先級只干一件事 —— 收到就緒通知后讀取對應緩沖區的數據做電壓換算、濾波、FFT、存儲、上傳等業務邏輯。為什么一定要拆成兩個線程有三個核心好處職責單一采集鏈路和業務邏輯完全解耦改算法不用動采集驅動改硬件不用動處理代碼優先級可控調度線程設為高優先級保證 DMA 一寫滿就能立刻切換緩沖不會因為處理線程在跑就耽誤采集擴展性強后面要加濾波算法、加 LCD 顯示、加 MQTT 上傳只需要修改或新增處理線程采集鏈路完全不用動不會引入新的風險二、CubeMX 手把手全配置下面以 STM32F407 為例一步步配出完整的工程環境。所有參數都會講清楚為什么這么設避免只會抄配置不懂原理。2.1 基礎系統配置RCC 時鐘外部高速晶振 HSE配置系統主頻為 168MHzAPB1 分頻為 42MHzAPB2 分頻為 84MHzADC 時鐘配置為 84MHz 分頻保證 ADC 工作在合規頻率內。SYS 時基Debug 選 Serial Wire時基源選擇定時器 TIM6。?? 踩坑提醒FreeRTOS 內核會占用 SysTick 作為系統時基如果 HAL 庫的時基也用 SysTick兩者會沖突導致延時函數不準、系統跑飛。所以開了 FreeRTOS 之后HAL 時基一定要改成定時器。串口配置開啟 USART1波特率 1152008 位數據位1 位停止位無校驗用于日志輸出。2.2 ADC 外設詳細配置開啟 ADC1勾選 IN0~IN5 共 6 個通道Rank 順序按通道號依次排列采樣時間統一設置為 15 個周期。核心參數按以下方式配置Scan Conversion ModeEnable。開啟掃描模式才能自動轉換多個通道。Continuous Conversion ModeDisable。關閉連續轉換配合非連續模式每次觸發只采一組。Discontinuous Conversion ModeEnable。開啟非連續轉換。Number of Discontinuous Conversions2。每組 2 個通道6 個通道分 3 次觸發完成。Data AlignmentRight alignment。右對齊方便直接讀取原始值。DMA Continuous RequestsDisable。關閉 DMA 連續請求非連續模式下每次觸發只傳一組數據不需要連續傳輸。2.3 DMA 通道配置在 ADC1 的 DMA 設置里添加 DMA 通道參數如下DirectionPeripheral To Memory外設到內存方向。ModeNormal單次傳輸模式。這里不用 Circular 循環模式因為我們要手動切換兩個緩沖區的地址乒乓切換需要軟件控制目標內存循環模式只針對單塊緩沖區自動循環。Peripheral Increment AddressDisable。外設地址固定就是 ADC 的數據寄存器地址不用自增。Memory Increment AddressEnable。內存地址自增依次寫入緩沖區的每個元素。Data Width外設和內存都選 Half Word半字16 位對應 ADC 的 12 位轉換結果。PriorityHigh。DMA 優先級設高保證采集數據不被其他 DMA 通道打斷。2.4 NVIC 中斷配置開啟對應 DMA 通道的全局中斷ADC1 對應 DMA2 Stream0。搶占優先級設為 5子優先級設為 0。?? 踩坑提醒FreeRTOS 有一個系統最大調用優先級閾值configMAX_SYSCALL_INTERRUPT_PRIORITY默認值為 5。只有搶占優先級數值≥5也就是優先級等于或低于這個閾值的中斷才能安全調用xxxFromISR系列函數。如果把 DMA 中斷優先級設成 4數值更小優先級更高在中斷里發任務通知會直接觸發 HardFault而且這個問題非常隱蔽新手很容易踩。不需要開啟 ADC 全局中斷。我們靠 DMA 傳輸完成中斷來做事件同步ADC 本身不需要產生中斷。2.5 FreeRTOS 配置啟用 FreeRTOS使用原生 API 接口創建兩個任務adc_sched_task優先級 3較高棧大小 256 Word負責采集調度與緩沖切換adc_process_task優先級 2普通棧大小 512 Word負責數據處理與日志輸出棧大小的估算依據調度線程邏輯簡單只有狀態切換和函數調用256 字足夠處理線程涉及浮點運算、串口打印棧開銷更大給 512 字留足余量。調度線程優先級更高保證 DMA 中斷觸發后能第一時間切換緩沖不耽誤下一次采集。三、工程級代碼完整實現所有代碼都寫在 CubeMX 生成的 USER CODE 區域內重新生成工程不會被覆蓋。全程使用靜態數組分配緩沖符合工業級內存規范最后會補充 malloc 版本的差異與取舍。3.1 全局變量與緩沖定義放在USER CODE BEGIN PV區域/* USER CODE BEGIN PV */ #include FreeRTOS.h #include task.h /* 乒乓雙緩沖每組2個通道每個緩沖2個16位數據 */ static volatile uint16_t s_adc_buf1[2]; static volatile uint16_t s_adc_buf2[2]; /* 當前正在寫入的緩沖標記0buf11buf2 */ static volatile uint8_t s_cur_write_buf 0; /* 任務句柄 */ TaskHandle_t g_adc_sched_task_hdl NULL; TaskHandle_t g_adc_process_task_hdl NULL; /* USER CODE END PV */這里有兩個細節緩沖區加volatile修飾因為它們會在 DMA 硬件和線程中同時訪問防止編譯器優化掉讀寫操作。緩沖大小嚴格匹配非連續轉換數每組 2 個通道每個通道 16 位所以每個緩沖定義 2 個uint16_t元素。3.2 DMA 傳輸完成中斷回調重寫 ADC 轉換完成回調函數放在USER CODE BEGIN 0區域/* USER CODE BEGIN 0 */ void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { BaseType_t xHigherPriorityTaskWoken pdFALSE; /* 僅向調度線程發送通知不做任何業務邏輯 */ xTaskNotifyFromISR(g_adc_sched_task_hdl, 0, eNoAction, xHigherPriorityTaskWoken); /* 如果有高優先級任務被喚醒執行任務切換 */ portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } } /* USER CODE END 0 */ 設計原則中斷要 “快進快出”。這里只有兩行核心邏輯既不讀數據、也不做計算、更不打印。中斷只負責發事件通知所有業務邏輯全部放到任務上下文執行。很多新手喜歡在中斷里算電壓、打印日志硬生生把幾微秒就能走完的中斷拖到幾十微秒甚至毫秒級嚴重擠占其他中斷的響應時間這是嵌入式實時系統的大忌。3.3 線程 A采集調度線程負責緩沖切換與采集啟停是整個采集鏈路的調度核心void adc_sched_task(void *pvParameters) { /* 初始化先啟動第一次采集目標為buf1 */ s_cur_write_buf 0; HAL_ADC_Start_DMA(hadc1, (uint32_t *)s_adc_buf1, 2); /* 注意長度是數據個數不是字節數 */ for (;;) { /* 阻塞等待DMA傳輸完成通知pdTRUE表示等待后清空通知值 */ ulTaskNotifyTake(pdTRUE, portMAX_DELAY); /* 先停止當前DMA再切換緩沖避免硬件傳輸中改地址 */ HAL_ADC_Stop_DMA(hadc1); uint8_t ready_buf_idx; if (s_cur_write_buf 0) { /* 當前寫的是buf1就緒的就是buf1下一次切到buf2 */ ready_buf_idx 0; HAL_ADC_Start_DMA(hadc1, (uint32_t *)s_adc_buf2, 2); s_cur_write_buf 1; } else { /* 當前寫的是buf2就緒的就是buf2下一次切到buf1 */ ready_buf_idx 1; HAL_ADC_Start_DMA(hadc1, (uint32_t *)s_adc_buf1, 2); s_cur_write_buf 0; } /* 向處理線程發送通知攜帶就緒的緩沖編號 */ xTaskNotify(g_adc_process_task_hdl, ready_buf_idx, eSetValueWithOverwrite); } }?? 踩坑提醒HAL_ADC_Start_DMA的第三個參數是數據項的個數不是字節數。16 位寬度下傳 2 就代表傳輸 2 個半字共 4 字節。很多人順手寫成sizeof(s_adc_buf1)結果傳了 4相當于一次采 4 個通道直接越界訪問后面的內存輕則數據錯亂重則直接 HardFault。這個坑我當年剛學的時候卡了整整一下午。3.4 線程 B數據處理線程收到就緒通知后讀取對應緩沖區的數據做電壓轉換與業務處理void adc_process_task(void *pvParameters) { for (;;) { uint32_t ready_buf_idx; /* 阻塞等待通知取出緩沖編號 */ xTaskNotifyWait(0, 0xFFFFFFFF, ready_buf_idx, portMAX_DELAY); uint16_t *p_ready_buf; if (ready_buf_idx 0) { p_ready_buf (uint16_t *)s_adc_buf1; } else { p_ready_buf (uint16_t *)s_adc_buf2; } /* 原始值轉實際電壓12位ADC參考電壓3.3V */ float ch0_volt p_ready_buf[0] * 3.3f / 4096.0f; float ch1_volt p_ready_buf[1] * 3.3f / 4096.0f; /* 業務處理打印日志實際項目可替換為濾波、FFT、存儲等 */ printf(通道0: %.3fV | 通道1: %.3fV\r\n, ch0_volt, ch1_volt); } }處理線程的執行時間完全不影響采集只要在一幀采集周期內處理完就行。比如 1kHz 采樣率每幀 1ms只要你的算法耗時小于 1ms就完全不會拖慢采集速度這就是乒乓架構的優勢。3.5 malloc 版本與靜態內存的取舍很多教學示例里喜歡用pvPortMalloc動態分配緩沖區代碼大概是這樣/* 教學示例用工業項目不推薦 */ uint16_t *p_buf1 pvPortMalloc(2 * sizeof(uint16_t)); uint16_t *p_buf2 pvPortMalloc(2 * sizeof(uint16_t));教學里用 malloc 只是為了演示動態內存用法但在真實工業項目里固定生命周期的緩沖區一律優先用靜態數組核心原因有四個內存碎片風險反復申請釋放內存會讓堆空間越來越碎片化后期可能申請不到連續的大塊內存嵌入式設備沒有內存整理機制碎片只會越積越多。分配失敗風險malloc可能返回 NULL嵌入式環境下幾乎沒有優雅的兜底方案大概率直接死機。靜態數組編譯時就分配好永遠不會分配失敗。執行時間不確定malloc 的執行時間不是固定的取決于堆空間的空閑狀態違反實時系統 “時間可預測” 的核心要求。棧堆溢出風險單片機的堆空間本身就很小分配不當很容易和棧空間溢出沖突引發玄學 bug。 工業級規范大小固定、生命周期貫穿整個程序運行的內存全部使用靜態分配。只有那些臨時使用、用完就釋放的小塊內存才酌情使用動態分配。四、完整運行時序與流程拆解很多人配置完代碼能跑但說不清每一步到底是誰在干什么。我們把整個乒乓循環拆成五步徹底搞懂并行是怎么實現的。4.1 系統啟動執行順序系統時鐘、外設、DMA、NVIC 依次初始化FreeRTOS 內核初始化創建兩個任務調度器啟動高優先級的調度線程先運行調度線程啟動第一次 ADCDMA然后進入阻塞等待通知處理線程啟動也進入阻塞等待通知DMA 后臺開始往 buf1 寫數據CPU 空閑可執行其他低優先級任務4.2 乒乓循環全流程第一步采集第一幀DMA 后臺向 buf1 寫入 2 個通道的數據兩個線程均處于阻塞狀態CPU 可以執行其他任務或者進入空閑。第二步第一幀采集完成buf1 被寫滿觸發 DMA 傳輸完成中斷中斷里給調度線程發通知隨后退出中斷。第三步切換緩沖調度線程因為優先級高被立刻喚醒它先停止 DMA將寫入目標切換為 buf2立刻重啟 ADC 開始第二幀采集隨后向處理線程發送通知告訴它 buf1 的數據就緒了。做完這些調度線程再次進入阻塞。第四步采集與處理并行執行DMA 開始往 buf2 寫第二幀數據同時處理線程被喚醒讀取 buf1 里的第一幀數據做電壓計算、濾波、打印等操作。這就是整個架構最核心的并行時刻DMA 在后臺搬數據CPU 在前臺算數據兩個硬件完全同時工作誰也不等誰。第五步循環往復buf2 寫滿后再次觸發中斷重復上述流程切回 buf1 寫入處理線程去讀 buf2 的數據。4.3 并行時間線分析我們用文字畫一條時間軸直觀對比單緩沖和乒乓緩沖的差異單緩沖方案采集 T 時間 處理 T 時間 總耗時 2T處理的時候不能采集采集的時候不能處理乒乓緩沖方案采集第二幀的同時處理第一幀總耗時只取決于更長的那個。只要處理時間≤采集時間總耗時就等于采集時間處理相當于 “白送” 的。這也是為什么這套方案能大幅提升采集效率 —— 它不是讓 CPU 跑更快而是把原本被浪費的等待時間利用了起來。五、架構深度分析與橫向對比5.1 這套架構的 4 個核心優勢硬件采集零 CPU 占用ADC 轉換 DMA 搬運全程由硬件自主完成CPU 只在切換緩沖的時候花費幾微秒其余時間完全解放可以跑通信、顯示、算法等其他任務。數據零撕裂讀寫永遠操作不同的緩沖區不會出現 “讀一半被改寫” 的情況數據完整性有絕對保障不需要任何鎖機制。中斷輕量高效中斷僅做事件通知耗時控制在微秒級不會長時間關中斷也不會影響其他外設的中斷響應。模塊高度解耦采集驅動層和業務處理層完全分離換 ADC 通道只改硬件配置加算法只改處理線程互不干擾項目越大優勢越明顯。5.2 RTOS 版本 vs 裸機版本 詳細對比很多人會問裸機也能做乒乓緩沖為什么一定要上 RTOS我們從多個維度做個橫向對比表格對比維度裸機乒乓方案RTOS 多線程方案同步機制全局標志位 主循環輪詢任務通知事件驅動執行模型主循環順序執行處理時阻塞后續邏輯多線程搶占調度采集與處理完全并行實時性較差主循環其他任務會延遲處理響應優秀優先級調度采集響應時間確定擴展性差新增功能容易讓主循環越來越臃腫好新增業務只需加線程耦合度低內存開銷小無需 RTOS 內核額外 RAM稍大需要任務控制塊、棧空間等開銷調試難度簡單邏輯線性稍復雜需要理解搶占與同步適用場景簡單單功能采集、極小資源 MCU復雜多任務項目、中高性能 MCU選型建議如果是極簡單的采集功能MCU 資源又非常緊張裸機乒乓足夠用如果項目里還有通信、顯示、控制等多個功能模塊對實時性有要求優先上 RTOS 方案長期來看開發和維護效率高很多。5.3 為什么用任務通知而不是二值信號量 / 消息隊列很多人做同步第一反應就是用二值信號量或者消息隊列。但在這種一對一的線程同步場景里任務通知是更優的選擇。從底層實現來看二值信號量需要一個獨立的隊列控制塊結構體有自己的鏈表、計數器等字段要占用幾十字節的額外內存。任務通知沒有額外的內核對象它本質就是任務 TCB 結構體里的一個變量操作它就是直接修改任務控制塊不需要走隊列鏈表的復雜邏輯。量化對比下來內存開銷任務通知零額外內存比二值信號量節省約 60% 的 RAM 占用。執行速度任務通知的發送和接收速度比二值信號量快 40%~60%因為少了很多隊列操作的 overhead。功能靈活性任務通知不僅能做同步還能攜帶數據比如本例中的緩沖編號可以替代二值信號量、計數信號量甚至輕量的消息傳遞。當然它也有適用邊界一對一同步用任務通知最優如果是多個任務同步一個事件還是用信號量更合適如果需要傳遞大量數據、有多消費者就用消息隊列。技術沒有絕對的好壞選匹配場景的就行。六、適用場景與踩坑指南6.1 推薦使用的場景中高速連續數據采集比如振動傳感器、音頻采集、電能質量監測采樣率在幾 kHz 到幾十 kHz 的場景多通道實時控制系統比如電機驅動、電源控制需要分批次采樣并快速閉環計算批量算法處理場景需要做數字濾波、FFT、校準算法處理耗時較長的場景多任務復雜項目系統內還有通信、顯示、存儲等多個任務需要解耦的場景6.2 不推薦使用的場景超低頻采樣幾秒甚至幾分鐘才采一次輪詢方式完全夠用上乒乓緩沖和 RTOS 屬于過度設計內存極度緊缺RAM 只有幾 KB 的極小資源 MCU放不下 RTOS 和雙緩沖的開銷極致低延遲單點處理只需要單通道數據采集完立刻就要響應雙緩沖反而會引入一幀的延遲6.3 開發必看踩坑點DMA 傳輸長度單位陷阱HAL 庫的 DMA 啟動函數長度參數是數據項的個數不是字節數。一定要根據數據寬度來算不要直接傳 sizeof否則必然越界。FreeRTOS 中斷優先級配置錯誤所有調用xxxFromISR函數的中斷搶占優先級必須大于等于configMAX_SYSCALL_INTERRUPT_PRIORITY的數值。優先級設高了數值更小會直接觸發 HardFault而且很難排查。緩沖大小與分組數不匹配非連續轉換數是多少每個緩沖的元素個數就要是多少。比如非連續數是 3緩沖只開了 2 個元素DMA 寫的時候會直接沖掉后面的內存引發各種玄學 bug。切換緩沖前不停止 DMA必須先調用HAL_ADC_Stop_DMA再修改目標地址再重新啟動。如果傳輸過程中直接改地址DMA 可能會出現傳輸錯誤甚至訪問非法地址。全局共享變量不加 volatile中斷和線程共享的標志位、緩沖區一定要加volatile修飾否則編譯器開啟優化后會把變量緩存到寄存器里中斷改了值線程看不到程序就死等在那里。結尾總結這套 ADCDMA 乒乓緩沖 FreeRTOS 的并行架構核心本質其實就是一句話讓專業的硬件干專業的事CPU 只做決策和計算不要把時間浪費在無意義的等待和搬運上。學習的時候建議循序漸進先把裸機單緩沖 DMA 跑通理解 DMA 的基本工作方式再改成乒乓緩沖體會讀寫并行的優勢最后再上 FreeRTOS把采集和處理拆成線程理解多線程解耦的價值。一步一步走基礎才扎實遇到問題也能快速定位。