STM32 HAL 庫實現乒乓快取加空閒中斷的串列埠 DMA 收發機制,輕鬆跑上 2M 波特率

撲魚發表於2022-02-19

前言

直接儲存器訪問(Direct Memory Access,DMA),允許一些裝置獨立地訪問資料,而不需要經過 CPU 介入處理。因此在訪問大量資料時,使用 DMA 可以節約可觀的 CPU 處理時間。在 STM32 中一般的 DMA 傳輸方向:記憶體->記憶體、外設->記憶體、記憶體->外設。這裡的外設可以是 UART、SPI 等資料收發裝置。

通用非同步收發傳輸器(Universal Asynchronous Receiver/Transmitter,UART),在嵌入式開發中一般稱為串列埠,通常用於中、低速通訊場景,波特率低有 6400 bps,高能達到 4~5 Mbps。波特率低於 115200 bps 而且資料量不大場景中一般用不到 DMA 收發資料,因為 STM32 晶片的主頻有幾十到上百兆赫茲,低速串列埠這點中斷響應就灑灑水而已。但當收發資料量很大,或波特率提高到 Mbps 數量級時就很有使用 DMA 的必要了,這時再使用阻塞方式或中斷方式收發資料,都會佔用過多的 CPU 時間,影響其他任務的執行。

對於 STM32 中使用 DMA 收發資料,網路上有很多例程和部落格,作為學習 DMA 的使用都沒問題。但它們中的大部分都是基礎的使用,在高速、大資料量的場景中很容易出現資料異常。對於一個高速、可靠的串列埠收發程式而言,DMA 是必須的,而雙緩衝區空閒中斷以及 FIFO 資料緩衝區也是非常重要的成分。這也是本文將要解決的問題。

STM32CubeMX 配置

本文使用的開發平臺:

  • STM32F407(RoboMaster C 型板)
  • STM32CubeMX 6.3.0
  • STM32Cube FW_F4 V1.26.2
  • CLion
  • GNU C/C++ Compiler

首先使能高速外部時鐘

然後設定時鐘樹。1 處是外部晶振的頻率,按自己所用晶振的實際頻率填寫;2 處一般填寫自己所用晶片的最大頻率,我這裡用的 F407 就是 168 MHz。填入後回車,其他地方的數值都會自動計算出來,非常方便。

接下來配置串列埠:

  1. 選擇一個串列埠;

  2. 設定模式為 Asynchronous(非同步);

  3. 設定波特率、幀長度、奇偶校驗以及停止位長度;

  4. 點選 Add 新增接收和傳送的 DMA 配置,注意在 RX 中將 DMA 模式改為 Circular,這樣 DMA 接收只用開啟一次,緩衝區滿後 DMA 會自動重置到緩衝區起始位置,不再需要每次接收完成後重新開啟 DMA;

  5. 開啟串列埠總中斷;

  6. 選擇正確的 GPIO 引腳。在 CubeMX 預設選擇的引腳大多數都正確的情況下,這很容易被忽略,出 BUG 再查的時候很難想到是這裡的問題,一定要核對好。

其他如除錯介面、作業系統以及工程管理等設定不在贅述,一頓常規操作後可 GENERATE CODE。

串列埠 DMA 接收

串列埠收到資料之後,DMA 會逐位元組搬運到 RX_Buf 中。搬運到一定的數量時,就會產生中斷(空閒中斷、半滿中斷、全滿中斷),程式會進入回撥函式以處理資料。處理資料這一步在本文中是將資料寫入 FIFO 中供應用讀取,將在後文介紹。先來看資料接收的流程圖。

全滿中斷和半滿中斷都很好理解,就是串列埠 DMA 的緩衝區填充了一半和填滿時產生的中斷。而空閒中斷是串列埠在上一幀資料接收完成之後在一個位元組的時間內沒有接收到資料時產生的中斷,即匯流排進入了空閒狀態。這對於接收不定長資料十分方便。

現在網路上大部分教程都使用了全滿中斷加空閒中斷的方式來接收資料,不過這存在了一定的風險:DMA 可以獨立於 CPU 傳輸資料,這意味著 CPU 和 DMA 有可能同時訪問緩衝區,導致 CPU 處理其中的資料到中途時 DMA 繼續傳輸資料把之前的緩衝區覆蓋掉,造成了資料丟失。所以更合理的做法是藉助半滿中斷實現乒乓快取。

一個緩衝區實現的乒乓快取

乒乓快取是指一個快取寫入資料時,裝置從另一個快取讀取資料進行處理;資料寫入完成後,兩邊交換快取,再分別寫入和讀取資料。這樣給裝置留足了處理資料的時間,避免緩衝區中舊資料還沒讀取完又被新資料覆蓋掉的情況。但是出現了一個小問題,就是 STM32 大部分型號的串列埠 DMA 只有一個緩衝區,要怎麼實現乒乓快取呢?

沒錯,半滿中斷。現在,一個緩衝區能拆成兩個來用了。

看這圖我們再來理解一下上面提到的三個中斷:接受緩衝區的前半段填滿後觸發半滿中斷,後半段填滿後觸發全滿中斷;而這兩個中斷都沒有觸發,但是資料包已經結束且後續沒有資料時,觸發空閒中斷。舉個例子:向這個緩衝區大小為 20 的程式傳送一個大小為 25 的資料包,它會產生三次中斷,如下圖所示。

程式實現

原理介紹完成,感謝 ST 提供了 HAL 庫,接下來再使用 C 語言實現它們就很簡單了。

首先開啟串列埠 DMA 接收。

#define RX_BUF_SIZE 20
uint8_t USART1_Rx_buf[RX_BUF_SIZE];
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, USART1_Rx_buf, RX_BUF_SIZE);

然後編寫回撥函式,在回撥函式裡把 USART1_Rx_buf 中的資料搬運到 FIFO 中。

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    static uint8_t Rx_buf_pos;	//本次回撥接收的資料在緩衝區的起點
    static uint8_t Rx_length;	//本次回撥接收資料的長度
    Rx_length = Size - Rx_buf_pos;
    fifo_s_puts(&uart_rx_fifo, &USART1_Rx_buf[Rx_buf_pos], Rx_length);	//資料填入 FIFO
    Rx_buf_pos += Rx_length;
    if (Rx_buf_pos >= RX_BUF_SIZE) Rx_buf_pos = 0;	//緩衝區用完後,返回 0 處重新開始
}

這個回撥函式本身是弱函式,需要自己把它重寫一遍。它有兩個傳入的引數,第一個引數無須多言,第二個引數 Size 則是指整個緩衝區中已經被使用的大小。它有一個很神奇的地方,上文提到的三個中斷都會進入這裡,所以要寫的程式碼只有這麼幾行了。

但是這帶來一個問題,如何區分這三個中斷呢?答案就是不用區分,只需要每次計算接收資料的起始地址和資料長度就能完成接收。所以我定義了兩個靜態變數:本次接收資料的長度 = 緩衝區被使用的總大小 - 本次回撥接收的資料在緩衝區中的起始位置;而起始位置從 0 開始,每次回撥加上本次接收資料的長度就好。

串列埠 DMA 傳送

串列埠 DMA 的傳送比接收簡單了許多,只需要把資料從傳送資料的 FIFO 複製到傳送緩衝區中,然後呼叫 HAL 庫傳送函式就完成了:

const uint8_t TX_FIFO_SIZE = 100;
static uint8_t buf[TX_FIFO_SIZE];				//傳送緩衝區
uint8_t len = fifo_s_used(&uart_tx_fifo);		//待傳送資料長度
fifo_s_gets(&uart_tx_fifo, (char *)buf, len);	//從 FIFO 取資料
HAL_UART_Transmit_DMA(&huart1, buf, len);		//傳送

FIFO 佇列

先進先出(First In, First Out,FIFO)可能看起來很陌生,但如果叫它佇列應該就很熟悉了。在本文中 FIFO 被用來作為 DMA 收發緩衝區(RX_BufTX_Buf)與應用程式之間的緩衝區。說起來抽象,看看下圖展示的接收狀態資料流向。傳送時的資料流向相反。

串列埠接收資料時,DMA 從串列埠暫存器搬運資料到記憶體中開闢的接收緩衝區 RX_Buf,併產生中斷(半滿中斷、全滿中斷、空閒中斷);在中斷的回撥函式裡把 RX_Buf 中的資料送到 FIFO 中,應用程式中只需要檢測 FIFO 是否為空,非空即可讀取資料。

這看起來似乎有些畫蛇添足,已經有了一個 DMA 接收緩衝區,直接從這個 RX_Buf 裡讀取資料豈不美哉?這裡存在的問題就是,對 RX_Buf 的處理只能在 DMA 產生的中斷的回撥函式裡進行;而中斷的回撥函式雖然阻塞住也不影響串列埠接收資料和 DMA 繼續搬運資料到 RX_Buf中,但是 RX_Buf 的大小始終是有限的,後來的資料會把以前的資料覆蓋掉。所以只要資料一來就需要立即處理完成,不及時就會丟資料。FIFO 雖然也會有滿溢的問題,不過出現概率更小,處理起來相對簡單一些。

使用 FIFO 的另一個理由是它把應用層與驅動層隔離開來。App 中不用管 RX_Buf 在什麼情況下會獲得幾個資料,只管從 FIFO 中讀資料;串列埠 DMA 的中斷回撥函式也有了固定的寫法,只管把資料壓入 FIFO。在資料不定長、資料量大的場景中,FIFO 無疑是非常必要的組分。

但是細心的朋友可能在上面 STM32CubeMX 配置串列埠 DMA 的圖中發現也有一個 “fifo”,這與上文敘述的 FIFO 有什麼區別呢?這也是我有過的困惑,稍作說明。

FIFO 與 DMA 的 FIFO 不是同一個 FIFO

DMA 中也有 FIFO,不過它的作用是在串列埠暫存器與記憶體緩衝區之間再加入一個 FIFO 緩衝區,資料流向如下。

由於串列埠暫存器只能儲存一個位元組,所以開啟直接模式的 DMA 每個位元組都要搬運一次資料到記憶體緩衝區中。而 DMA 的 FIFO 實際效果簡單來說就是攢一批資料一起傳送出去,可以減少軟體開銷和 AHB 匯流排上資料傳輸的次數,適合資料連續不斷且系統中還有其他開銷較大的任務這種場景使用。不過也是由於 DMA 的 FIFO 必須攢一批才能傳送,攢不夠就不發了,所以也有一些侷限性。本文沒有使用 DMA 的 FIFO,而是使用直接模式。

移植 FIFO

說了這麼半天終於到寫程式碼的時候了。我沒有自己實現一個 FIFO 環形緩衝區,而是移植了 RoboMaster AI 機器人的韌體中使用的 FIFO

  1. 在上述 ropo 中複製 fifo.cfifo.h 檔案到自己的工程中。

  2. fifo.h 中刪除 #include "sys.h",並在上邊連結裡找到 sys.h,將以下幾行互斥鎖的實現複製到 fifo.h 中,並額外包含標頭檔案 cmsis_gcc.h

    #include <cmsis_gcc.h>
    #define MUTEX_DECLARE(mutex) unsigned long mutex
    #define MUTEX_INIT(mutex)    do{mutex = 0;}while(0)
    #define MUTEX_LOCK(mutex)    do{__disable_irq();}while(0)
    #define MUTEX_UNLOCK(mutex)  do{__enable_irq();}while(0)
    
  3. 這個 FIFO 庫中的動態建立佇列的實現使用了 malloc,如果使用了作業系統,應該自己改成作業系統的記憶體管理 API。不過本文沒有使用動態的方式建立佇列。

使用 FIFO

在串列埠 DMA 接收和串列埠 DMA 傳送兩節已經介紹過了,這裡再貼一下使用方法。

fifo_s_puts(&uart_rx_fifo, &USART1_Rx_buf[Rx_buf_pos], Rx_length);	//資料填入 FIFO
uint8_t len = fifo_s_used(&uart_tx_fifo);		//待傳送資料長度
fifo_s_gets(&uart_tx_fifo, (char *)buf, len);	//從 FIFO 取資料

壓力測試

這樣的一套收發流程當然沒必要在低速環境(115200 bps)使用,但是它到底能用在波特率多高的場景下,穩定性如何,仍然是疑問。所以我們需要對它測試一下。

我選用了 PL2303、FT232 兩種晶片的串列埠模組進行測試。

PL2303

PL2303 資料手冊支援的串列埠波特率為 75 bps 到 6 Mbps。不過我測試之後最大的波特率約為 970000 bps,再大就沒法收到資料了,遠遠達不到預期值。希望有好心人告訴我這是怎麼一回事。

然後測試一下通訊穩定性。我以最小自動重發間隔 10 ms 向微控制器傳送 17 Bytes 的資料包,微控制器再回傳所有資料。測試執行了 58 分鐘,傳送了 1958.69 KB 資料,接收到了 1958.69 KB,沒有丟包,穩定性過關。而截圖中 Tx 比 Rx 的值大是因為停止的時候傳送了資料包沒有接收,在執行的全過程中兩個資料始終相等。

PL2303 還有個小問題是它在 Win10 上的驅動有問題,需要自己下載安裝老版本的驅動才能使用。

FT232

FT232 資料手冊有如下描述:

  • 資料傳輸速率為300波特(baud)到3兆波特 (RS422/RS485和TTL電平)以及300波特到1兆波特(RS232)

我手上這個使用了 FT232 晶片的串列埠模組實測最大波特率為 2 Mbps,終於達到了預期。

而 2 Mbps 下的穩定性測試效果也很好,執行了 66 分鐘,沒有丟包。

然後使用邏輯分析儀簡單測試了 2 Mbps 下通訊實際延遲,測試方法為傳送一個 17 Bytes 的資料包,微控制器接收到後再用串列埠返回所有資料:

  • 轉發延遲在 400 μs 左右。在我的程式裡這段時間主要由檢測 FIFO 是否為空的頻率決定,目前理論值是 1000 Hz。

  • 單個 17 Bytes 的資料包時長為 84 μs,收發過程全長約 0.5 ms

再對比一下 115200 bps 下的通訊,單個 17 Bytes 資料包長度約 1500 μs,資料包的整個收發過程約 3100 μs

根據網上的部落格,STM32F407 支援到 10.5 Mbps,但是這點我沒在手冊上查到。但是 2 Mbps 肯定不是它的極限。微控制器與電腦相連的話,受限於串列埠模組,2 Mbps 基本是天花板了,但是微控制器與微控制器間的串列埠通訊,仍有潛力可以挖掘。

參考

acuity. (2020, September 3). 一個嚴謹的STM32串列埠DMA傳送&接收(1.5Mbps波特率)機制_只要思想不滑坡,想法總比問題多。-CSDN部落格_dma接收. https://blog.csdn.net/qq_20553613/article/details/108367512

STMicroelectronics. (2021, June). Description of STM32F4 HAL and Low-Layer Drivers. https://www.st.com/content/ccc/resource/technical/document/user_manual/2f/71/ba/b8/75/54/47/cf/DM00105879.pdf/files/DM00105879.pdf/jcr:content/translations/en.DM00105879.pdf

相關文章