<1>前置概念補充
在深入拆解三種模式前,先通過提供的?“函數對比表”?建立整體認知:這張表是串口收發的「武器庫索引」,清晰標注了查詢、中斷、DMA 三種模式下,收發 / 回調函數的對應關系。后續會結合實際代碼,講透每個函數怎么用、何時觸發,先記住這張表的核心關聯👇
功能 | 查詢模式 | 中斷模式 | DMA 模式 |
---|---|---|---|
發送 | HAL_UART_Transmit | HAL_UART_Transmit_IT HAL_UART_TxCpltCallback | HAL_UART_Transmit_DMA HAL_UART_TxHalfCpltCallback HAL_UART_TxCpltCallback |
接收 | HAL_UART_Receive | HAL_UART_Receive_IT HAL_UART_RxCpltCallback | HAL_UART_Receive_DMA HAL_UART_RxHalfCpltCallback HAL_UART_RxCpltCallback |
錯誤處理 | - | HAL_UART_ErrorCallback | HAL_UART_ErrorCallback |
簡單說:
- 查詢模式:用「阻塞函數」收發,CPU 全程等待
- 中斷模式:用「啟動函數 + 完成回調」,收發完自動通知 CPU
- DMA 模式:用「DMA 啟動函數 + 半完成 / 完成回調」,數據自動傳輸,CPU 完全解放
查詢模式的簡單,是靠「犧牲 CPU 效率」實現的。對比另外兩種模式的函數邏輯,差異一目了然:
模式 | 收發邏輯 | CPU 參與度 | 典型函數 |
---|---|---|---|
查詢 | 函數阻塞,CPU 全程等待收發完成 | 100% 占用 | HAL_UART_Transmit /Receive |
中斷 | 啟動后立即返回,收發完成回調通知 | 僅中斷觸發時參與 | HAL_UART_Transmit_IT ?+ 回調 |
DMA | 數據自動傳輸,CPU 無需參與 | 0 參與(純硬件搬運) | HAL_UART_Transmit_DMA ?+ 回調 |
一、STM32 串口通信_查詢方式
(1)開篇引言
這篇教程專為?0 基礎嵌入式初學者?打造,用最通俗的語言拆解 STM32 串口通信核心函數?HAL_UART_Transmit
?的用法,從硬件接線、工具使用到代碼邏輯,一步步帶大家實現 “STM32 發數據,電腦串口助手收數據”。后續還會擴展中斷、DMA 等高級用法。
(2)硬件連接:串口接線邏輯(核心!接錯沒數據)
1. 接線原理
串口通信遵循?“發送端連接收端,接收端連發送端”?的規則,簡單說:
- STM32 開發板的?TX 引腳(如 DshanMCU-F103 底板的?
PA9
),要連接 USB 串口模塊的?RX 引腳 - STM32 開發板的?RX 引腳(如 DshanMCU-F103 底板的?
PA10
),要連接 USB 串口模塊的?TX 引腳 - 兩者的?GND 引腳?必須相連(保證電平參考一致)
同時,ST-Link 要保持連接,負責給開發板供電、燒錄程序和調試
2. 實物接線參考(搭配你的硬件圖)
下圖清晰展示了 DshanMCU-F103 底板與 USB 串口模塊的接線方式:
(3)驅動安裝:讓電腦識別串口
如果你用的是?CH340 串口模塊,需安裝 CH340 驅動(對應你提供的?8_CH340_CH341驅動程序
?文件夾),步驟如下:
- 解壓?
8_CH340_CH341驅動程序
,找到并運行?CH341SER.EXE
,按提示完成安裝。 - 插入 USB 串口模塊,打開?設備管理器,查看 “端口(COM 和 LPT)” 列表。若出現類似?
USB-SERIAL CH340 (COMxx)
(如 COM38),說明驅動安裝成功。
(4)串口助手:收發數據的 “窗口”
我們用?sscom5.13.1
收發數據,操作步驟:
- 解壓運行?
sscom5.13.1.exe
,在 “通訊端口” 選擇識別到的串口(如 COM38 )。 - 設置?波特率為 115200(必須與代碼配置一致!),數據位 8、停止位 1、無校驗。
- 點擊 “打開串口”,即可開始收發數據。
(5)代碼解析:HAL_UART_Transmit 怎么用?
以下是核心代碼邏輯:
#include "main.h"
#include "usart.h" // 串口相關頭文件
#include "gpio.h" // GPIO 相關頭文件/* 全局變量定義(根據需求使用,此處保留核心邏輯) */
extern UART_HandleTypeDef huart1;
char c; // 存儲串口接收的字符/*** @brief 主函數:程序入口* @retval int 返回值(一般無實際意義)*/
int main(void)
{// 1. 初始化 HAL 庫、系統時鐘、串口、GPIO 等(CubeMX 自動生成,無需深究)HAL_Init(); SystemClock_Config(); MX_USART1_UART_Init();MX_GPIO_Init(); // 2. 主循環:不斷收發數據while (1){// ① 發送提示信息:“Please enter a char: \r\n”HAL_UART_Transmit(&huart1, "Please enter a char: \r\n", 20, 1000); // ② 接收數據:循環嘗試接收 1 個字節,直到成功(超時時間 100ms )while(HAL_OK != HAL_UART_Receive(&huart1, &c, 1, 100)); // ③ 處理數據:收到的字符 +1(如輸入 'a' 變成 'b' )c = c + 1; // ④ 發送處理后的數據:把 +1 后的字符發回電腦HAL_UART_Transmit(&huart1, &c, 1, 1000); // ⑤ 發送換行符:讓串口助手顯示更整潔HAL_UART_Transmit(&huart1, "\r\n", 2, 1000); }
}
關鍵函數:HAL_UART_Transmit
?解析
HAL_UART_Transmit(&huart1, &c, 1, 1000);
&huart1
:串口句柄,指定用 USART1 發數據(由 CubeMX 配置生成,直接用即可 )。&c
:要發送的數據地址。發送單個字符用?&c
,發送字符串則用字符串數組名(如?str
?)。1
:發送數據的長度。發送 1 個字符填?1
,發送字符串填?strlen(str)
(自動計算長度 )。1000
:超時時間(毫秒)。如果 1 秒內沒發出去,函數返回錯誤。
(6)效果驗證:收發數據測試
1. 正常情況:輸入?1
?輸出?2
- 操作:串口助手收到提示?
Please enter a char:?
后,輸入?1
?并發送。 - 預期:開發板回發?
2
(因為代碼里?c = c + 1
?)。
2. 異常情況:輸入?123
?無正確響應
- 問題:代碼里是?單字節接收(
HAL_UART_Receive(&huart1, &c, 1, 100)
?),一次只能收 1 個字符。若輸入?123
,實際會分 3 次接收(1
、2
、3
?),但代碼邏輯未處理多字節連續輸入,導致 “輸入?123
?看似沒反應”。 - 解決思路(后續優化方向 ):
- 用?數組 + 長度判斷?接收多字節數據。
- 結合?中斷 / DMA?實現 “數據自動緩存,無需 CPU 一直等待”。
(7)常見問題排查
1. 串口助手收不到數據?
- 檢查?接線:TX/RX 是否交叉連接,GND 是否共地。
- 檢查?波特率:代碼和串口助手的波特率是否均為?
115200
。 - 檢查?驅動:設備管理器是否識別到串口,驅動是否安裝成功。
2. 發送字符串怎么改?
若要發送字符串(如?Hello
?),可修改代碼:
char str[] = "Hello"; // 定義字符串數組
// 發送字符串:長度用 strlen(str) 自動計算
HAL_UART_Transmit(&huart1, str, strlen(str), 1000);
(8)后續優化預告(進階方向)
當前代碼用的是?查詢方式(發數據要等待、收數據要循環詢問 ),缺點是 “CPU 一直忙等,無法做其他事”。后續會擴展:
- 中斷方式:數據到來自動觸發,CPU 可并行處理其他任務(適合實時性高的場景 )。
- DMA 方式:數據直接在內存和外設間傳輸,完全無需 CPU 參與(適合大數據量場景 )。
?二、不實用的官方中斷模式
STM32 串口中斷深度解析:從硬件原理到代碼實戰(以 HAL 庫為例)
<2>中斷模式完整函數鏈
中斷模式的收發,是「啟動函數 → 中斷觸發 → 回調函數」的完整鏈條,用表格串聯更清晰:
階段 | 發送流程(中斷) | 接收流程(中斷) |
---|---|---|
啟動 | HAL_UART_Transmit_IT ?啟動發送 | HAL_UART_Receive_IT ?啟動接收 |
中斷觸發 | 發完 1 字節 → TXE 中斷;發完所有字節 → TC 中斷 | 收到 1 字節 → RXNE 中斷;收完所有字節 → 接收完成中斷 |
回調通知 | 發完所有字節 →?HAL_UART_TxCpltCallback | 收完所有字節 →?HAL_UART_RxCpltCallback |
錯誤處理 | 統一走?HAL_UART_ErrorCallback | 統一走?HAL_UART_ErrorCallback |
(1)開篇:為什么需要串口中斷?
在之前的查詢方式串口通信中,CPU 需要不斷 “詢問” 串口是否有數據,就像一個人不停地問 “你有數據嗎?有數據嗎?”,這會讓 CPU 無法去做其他更有意義的事情。而串口中斷就像給串口裝了一個 “門鈴”,當有數據到來或者數據發送完成時,串口主動 “按門鈴” 通知 CPU,這樣 CPU 就可以在等待串口的空閑時間去處理其他任務,大大提高了系統效率。
(2)串口中斷的硬件基礎
(一)STM32 串口中斷相關寄存器
- 狀態寄存器(USART_SR)
- TXE(Transmit Data Register Empty)位:當發送數據寄存器為空時,該位被置 1。這意味著可以往發送數據寄存器中寫入新的數據了。在中斷模式下,我們可以使能 TXE 中斷,當 TXE 位置 1 時觸發中斷,去發送下一個數據。
- TC(Transmission Complete)位:當一幀數據發送完成時,該位被置 1。可以利用 TC 中斷來判斷一次發送是否完全結束。
- RXNE(Read Data Register Not Empty)位:當接收數據寄存器中有數據時,該位被置 1,可使能 RXNE 中斷來觸發接收操作。
- 控制寄存器(USART_CR1)
- TXEIE 位:用于使能 TXE 中斷。當該位被置 1,且 TXE 位置 1 時,會觸發串口發送中斷。
- TCIE 位:用于使能 TC 中斷。當該位被置 1,且 TC 位置 1 時,會觸發串口發送完成中斷。
- RXNEIE 位:用于使能 RXNE 中斷。當該位被置 1,且 RXNE 位置 1 時,會觸發串口接收中斷。
(二)串口中斷的硬件觸發流程
USART1 等外設可以通過 DMA 請求與系統進行數據交互,同時也可以通過中斷的方式。當使能了串口的某個中斷(如 TXE 中斷)后:
- 當發送數據寄存器為空(TXE=1)且 TXEIE=1 時,硬件會觸發中斷請求,這個請求會通過總線矩陣等到達 CPU 的中斷控制器。
- CPU 響應中斷后,會跳轉到對應的中斷處理函數(如 USART1_IRQHandler)去執行相應的操作。
- 對于接收來說,當接收數據寄存器非空(RXNE=1)且 RXNEIE=1 時,同樣會觸發中斷請求,進入接收中斷處理流程。
(3)HAL 庫串口中斷函數解析
(一)中斷處理函數入口:USART1_IRQHandler
void USART1_IRQHandler(void)
{HAL_UART_IRQHandler(&huart1);
}
這是串口 1 的中斷處理函數入口,當串口 1 觸發中斷時,CPU 會首先跳轉到這里。然后調用HAL_UART_IRQHandler(&huart1)
函數,這個函數是 HAL 庫中處理串口中斷的核心函數,它會去檢查中斷源(是 TXE 中斷、TC 中斷還是 RXNE 中斷等),并調用相應的處理邏輯。
(二)HAL_UART_IRQHandler 函數關鍵邏輯
/* UART in mode Transmitter */
if ((((isrflags & USART_SR_TXE) != RESET) && ((cr1its & USART_CR1_TXEIE) != RESET)))
{UART_Transmit_IT(huart);return;
}
/* UART in mode Transmitter end */
if ((((isrflags & USART_SR_TC) != RESET) && ((cr1its & USART_CR1_TCIE) != RESET)))
{UART_EndTransmit_IT(huart);return;
}
- TXE 中斷處理:當檢測到狀態寄存器中的 TXE 位為 1(發送數據寄存器為空),并且控制寄存器中的 TXEIE 位為 1(使能了 TXE 中斷)時,會調用
UART_Transmit_IT(huart)
函數,這個函數會去處理發送過程中的數據填充等操作,繼續發送下一個數據。 - TC 中斷處理:當檢測到 TC 位為 1(發送完成),并且 TCIE 位為 1(使能了 TC 中斷)時,會調用
UART_EndTransmit_IT(huart)
函數,用于處理發送完成后的一些收尾工作,比如標記發送完成狀態等。
(三)HAL_UART_Transmit_IT 函數
HAL_StatusTypeDef HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size)
{/* Check that a Tx process is not already ongoing */if (huart->gState == HAL_UART_STATE_READY){if ((pData == NULL) || (Size == 0U)){return HAL_ERROR;}huart->pTxBuffPtr = pData;huart->TxXferSize = Size;huart->TxXferCount = Size;huart->ErrorCode = HAL_UART_ERROR_NONE;huart->gState = HAL_UART_STATE_BUSY_TX;/* Enable the UART Transmit data register empty Interrupt */__HAL_UART_ENABLE_IT(huart, UART_IT_TXE);return HAL_OK;}else{return HAL_BUSY;}
}
- 函數作用:這個函數用于以中斷模式啟動串口發送。首先檢查串口當前狀態是否為
HAL_UART_STATE_READY
,如果是,就對串口句柄中的發送緩沖區指針、發送數據大小、發送計數器等進行初始化,然后將串口的狀態設置為HAL_UART_STATE_BUSY_TX
表示正在發送,最后使能 TXE 中斷(通過__HAL_UART_ENABLE_IT(huart, UART_IT_TXE)
),這樣當發送數據寄存器為空時就會觸發中斷,進入發送中斷處理流程。 - 參數解析:
huart
是串口句柄,pData
是要發送的數據緩沖區指針,Size
是要發送的數據長度。
(四)UART_Transmit_IT 函數
static HAL_StatusTypeDef UART_Transmit_IT(UART_HandleTypeDef *huart)
{const uint16_t *tmp;/* Check that a Tx process is ongoing */if (huart->gState == HAL_UART_STATE_BUSY_TX){if ((huart->Init.WordLength == UART_WORDLENGTH_9B) && (huart->Init.Parity == UART_PARITY_NONE)){tmp = (const uint16_t *)huart->pTxBuffPtr;huart->Instance->DR = (uint16_t)(*tmp & (uint16_t)0x01FF);huart->pTxBuffPtr += 2U;}else{huart->Instance->DR = (uint8_t)(*huart->pTxBuffPtr++ & (uint8_t)0x00FF);}if (--huart->TxXferCount == 0U){/* Disable the UART Transmit Data Register Empty Interrupt */__HAL_UART_DISABLE_IT(huart, UART_IT_TXE);/* Enable the UART Transmit Complete Interrupt */__HAL_UART_ENABLE_IT(huart, UART_IT_TC);}return HAL_OK;}else{return HAL_BUSY;}
}
- 數據發送處理:當進入這個函數時,首先檢查串口是否處于
HAL_UART_STATE_BUSY_TX
狀態(即正在發送過程中)。然后根據串口配置的字長和校驗位情況,從發送緩沖區中取出數據寫入到串口的數據寄存器(huart->Instance->DR
)中。如果是 9 位數據且無校驗,就按 16 位處理;否則按 8 位處理。 - 中斷切換:每發送一個數據,發送計數器
TxXferCount
減 1。當TxXferCount
減到 0 時,說明當前要發送的數據已經全部放到數據寄存器了,這時候需要關閉 TXE 中斷(因為不需要再觸發 “發送數據寄存器為空” 的中斷了),并使能 TC 中斷(等待發送完成中斷),這樣當一幀數據發送完成后就會觸發 TC 中斷。
(4)中斷模式完整函數鏈
中斷模式的收發,是「啟動函數 → 中斷觸發 → 回調函數」的完整鏈條,用表格串聯更清晰:
階段 | 發送流程(中斷) | 接收流程(中斷) |
---|---|---|
啟動 | HAL_UART_Transmit_IT ?啟動發送 | HAL_UART_Receive_IT ?啟動接收 |
中斷觸發 | 發完 1 字節 → TXE 中斷;發完所有字節 → TC 中斷 | 收到 1 字節 → RXNE 中斷;收完所有字節 → 接收完成中斷 |
回調通知 | 發完所有字節 →?HAL_UART_TxCpltCallback | 收完所有字節 →?HAL_UART_RxCpltCallback |
錯誤處理 | 統一走?HAL_UART_ErrorCallback | 統一走?HAL_UART_ErrorCallback |
(5)中斷接收與發送完整代碼流程
(一)全局變量定義
extern UART_HandleTypeDef huart1;
int key_cut=0;
void key_timeout_func(void *args);
struct soft_timer key_timer ={~0,NULL,key_timeout_func};static uint8_t g_data_buf[100];
static circle_buf g_key_bufs;static volatile int g_tx_cplt = 0;
這里定義了串口句柄(通過extern
引用)、一些用于按鍵處理的變量(雖然在串口中斷中可能暫時沒用到,但屬于整個工程的變量)以及用于標記發送完成的 volatile 變量g_tx_cplt
(因為在中斷回調函數和主函數中都會訪問,需要用 volatile 保證其可見性)。
(二)發送完成回調函數
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{g_tx_cplt =1;
}
當串口發送完成中斷觸發并處理完成后,會調用這個回調函數,將g_tx_cplt
置 1,用于在主函數中判斷發送是否完成。
(三)等待發送完成函數
void Wait_Tx_Complete(void)
{while(g_tx_cplt ==0);g_tx_cplt =0;
}
這個函數會在主函數中被調用,用于等待發送完成。它會一直循環檢查g_tx_cplt
是否為 1,當為 1 時說明發送完成,然后將其置 0,為下一次發送做準備。
(四)主函數中的中斷發送流程
while (1)
{/*enable txe interrupt*/HAL_UART_Transmit_IT(&huart1,str2,strlen(str2));/*wait for tc*/Wait_Tx_Complete();while(HAL_OK !=HAL_UART_Receive(&huart1,&c,1,100));c=c+1;HAL_UART_Transmit(&huart1,&c,1,1000);HAL_UART_Transmit(&huart1,"\r\n",2,1000);
}
- 發送流程:在主循環中,首先調用
HAL_UART_Transmit_IT(&huart1,str2,strlen(str2))
以中斷模式發送字符串str2
,然后調用Wait_Tx_Complete()
等待發送完成(通過檢查g_tx_cplt
)。 - 接收流程:發送完成后,調用
HAL_UART_Receive(&huart1,&c,1,100)
以查詢方式接收一個字節的數據(這里也可以改為中斷接收方式,后續優化方向),接收完成后對數據進行簡單處理(c=c+1
),然后再用查詢方式發送回傳數據和換行符。
(6)中斷模式優缺點與適用場景
(一)優點
- 效率高:不需要像查詢方式那樣一直占用 CPU 去等待串口狀態,CPU 可以在等待串口中斷的時間去處理其他任務,比如進行按鍵掃描、傳感器數據處理等,提高了系統的整體效率。
- 實時性好:當串口有數據到來或者發送完成時能及時觸發中斷進行處理,對于一些對實時性要求較高的應用場景(如工業控制中的快速指令響應)非常合適。
(二)缺點
- 代碼復雜度高:相比查詢方式,中斷模式需要處理中斷函數、回調函數、中斷使能與禁用、中斷標志判斷等,代碼邏輯相對復雜,對于初學者來說理解和調試難度較大。
- 資源占用:雖然 CPU 利用率提高了,但中斷本身也會帶來一定的開銷,比如中斷上下文切換等,如果中斷過于頻繁,也可能會影響系統性能。
(三)適用場景
適用于對實時性要求較高、CPU 需要同時處理多個任務的場景,比如多傳感器數據實時上傳、工業設備的遠程控制指令接收與響應等。而對于一些簡單的、對實時性要求不高的小項目,查詢方式可能因為代碼簡單更容易實現。
(7)常見問題與調試方法
(一)中斷不觸發問題
- 可能原因:
- 1.中斷使能不正確:比如在
HAL_UART_Transmit_IT
中沒有正確使能 TXE 中斷,或者在HAL_UART_IRQHandler
中相關中斷源的使能和標志判斷有問題。 - 2.串口配置錯誤:在 STM32CubeMX 中配置串口時,沒有正確使能對應的中斷(如在 NVIC 設置中沒有使能 USART1 的中斷)。
- 3.全局中斷未使能:即使串口的中斷使能了,但如果 CPU 的全局中斷沒有使能(比如沒有調用
__enable_irq()
函數,不過在 HAL 庫中一般會自動處理,但也可能因為某些配置被關閉),也無法響應中斷。
- 調試方法:
- 檢查 CubeMX 配置:查看串口的中斷是否在 NVIC 中正確使能,優先級是否設置合理。
- 檢查代碼中的中斷使能函數:在
HAL_UART_Transmit_IT
中查看__HAL_UART_ENABLE_IT
是否正確調用,使能的中斷類型是否正確。 - 在中斷處理函數入口加斷點:在
USART1_IRQHandler
函數中加斷點,看是否能進入中斷處理函數,如果進不去,說明中斷觸發有問題;如果能進去,再逐步檢查HAL_UART_IRQHandler
中的邏輯。
(二)數據發送不完整或錯誤
- 可能原因:
- 發送緩沖區處理問題:在
UART_Transmit_IT
中,數據從緩沖區取出和指針移動的邏輯有錯誤,導致數據沒有正確發送或者發送了錯誤的數據。 - 中斷切換邏輯問題:TXE 中斷和 TC 中斷的切換沒有正確處理,導致數據發送到一半就停止或者發送完成后沒有正確標記狀態。
- 數據長度設置錯誤:在調用
HAL_UART_Transmit_IT
時,strlen(str2)
計算的長度不正確,導致發送的數據長度錯誤。
- 發送緩沖區處理問題:在
- 調試方法:
- 在
UART_Transmit_IT
函數中加斷點,檢查每次從緩沖區取出的數據是否正確,指針移動是否正確。 - 檢查中斷切換時的計數器
TxXferCount
,看其遞減是否正確,以及中斷使能和禁用是否在正確的時機。 - 打印發送的數據長度和實際發送的數據(可以通過串口助手配合,或者在代碼中使用調試串口打印中間變量),檢查數據是否正確。
- 在
(三)回調函數不執行
- 可能原因:
- 沒有正確實現回調函數:在 HAL 庫中,回調函數需要按照規定的名稱和參數實現,比如
HAL_UART_TxCpltCallback
,如果函數名寫錯或者參數不匹配,就不會被正確調用。 - 中斷沒有正確觸發到發送完成階段:可能在數據發送過程中出現了錯誤,導致沒有觸發 TC 中斷,所以回調函數不會執行。
- 沒有正確實現回調函數:在 HAL 庫中,回調函數需要按照規定的名稱和參數實現,比如
- 調試方法:
- 檢查回調函數名稱和參數是否正確。
三、中斷改造方法
(1)核心需求:“串口收發數據不丟” 要解決啥問題?
想象你用串口給設備發消息,比如連續快速發 10 個字符。如果設備處理慢,或者中斷響應不及時,數據就會 “擠在一起” 丟包。環形緩沖區?就像一個 “臨時倉庫”,先把收到的數據存起來,主程序慢慢取;中斷?負責 “一收到數據就通知倉庫存數據”,兩者配合就能解決丟包問題。
(2)代碼角色分工:誰在干啥?
// usart.c:專門處理串口中斷、環形緩沖區,收數據存緩沖區、取數據邏輯
#include "main.h" // 包含HAL庫等
#include "usart.h" // 串口相關聲明
#include "circle_buffer.h" // 環形緩沖區頭文件// 靜態全局變量:僅usart.c內部用,避免全局污染
static volatile int g_tx_cplt = 0; // 發送完成標志(0=未完成,1=完成)
static volatile int g_rx_cplt = 0; // 接收完成標志(同理)
static uint8_t g_c; // 臨時存“剛收到的1個字節”
static uint8_t g_recvbuf[100]; // 環形緩沖區的“物理存儲數組”
static circle_buf g_uart1_rx_bufs; // 環形緩沖區的“控制結構體”(存讀寫位置等)// 串口發送完成回調函數:HAL庫規定名,發送中斷完成后自動調用
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{g_tx_cplt = 1; // 標記“發送完成”,告訴主程序可以繼續了
}// 【關鍵】串口接收完成回調函數:收到1個字節后,HAL庫自動調用
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{// 1. 把剛收到的1個字節(存在g_c里)存進環形緩沖區cirble_buf_write(&g_uart1_rx_bufs, g_c); // 2. 重新使能接收中斷!否則只能收1個字節,收完就不監聽了HAL_UART_Receive_IT(&huart1, &g_c, 1);
}// 等待發送完成:給主程序用,阻塞直到發送完成
void Wait_Tx_Complete(void)
{while (g_tx_cplt == 0) { /* 循環等,直到g_tx_cplt被回調函數設為1 */ }g_tx_cplt = 0; // 清零,下次發送再用
}// 啟動串口接收中斷:主程序初始化時調用,開啟“收數據觸發中斷”
void startuart1recv(void)
{// 1. 初始化環形緩沖區:告訴它用g_recvbuf數組存數據,大小100circld_buf_init(&g_uart1_rx_bufs, 100, g_recvbuf); // 2. 使能接收中斷:收到1個字節就觸發HAL_UART_RxCpltCallbackHAL_UART_Receive_IT(&huart1, &g_c, 1);
}// 從環形緩沖區取1個字節:給主程序用,取“存好的數據”
int UART1getchar(uint8_t *pVal)
{// 調用環形緩沖區的讀取函數,把數據放到pVal里return circle_buf_read(&g_uart1_rx_bufs, pVal);
}
先看代碼里的關鍵模塊,像 “部門分工” 一樣理解:
代碼部分 | 作用類比 | 核心任務 |
---|---|---|
circle_buf (環形緩沖區) | 快遞臨時倉庫 | 存收到的數據,主程序按需取 |
HAL_UART_RxCpltCallback (接收中斷回調) | 倉庫管理員 | 一收到串口數據,就存進倉庫 |
startuart1recv | 啟動 “倉庫監聽” | 打開串口中斷,讓它能觸發回調 |
UART1getchar | 取快遞的人 | 從倉庫里拿數據給主程序用 |
(3)流程拆解:從 “啟動程序” 到 “收發數據” 全步驟
1. 初始化:給 “倉庫” 和 “串口” 搭好架子
環形緩沖區初始化(對應?
circld_buf_init(&g_uart1_rx_bufs,100,g_recvbuf);
):- 就像給倉庫劃分區域:
g_recvbuf
?是實際存數據的數組(倉庫貨架),g_uart1_rx_bufs
?是管理這個數組的 “倉庫規則”(比如:數據存在哪、存了多少、從哪取)。 - 你可以理解為:
circld_buf_init
?幫你 “建了一個 100 字節的臨時倉庫,準備存串口數據”。
- 就像給倉庫劃分區域:
串口中斷啟動(對應?
startuart1recv();
):- 調用?
HAL_UART_Receive_IT(&huart1,&g_c,1);
,意思是:“串口 1 啊,你開啟‘接收中斷’吧!只要收到 1 個字節,就觸發回調函數!” - 這一步是 “打開倉庫管理員的監聽開關”,讓串口一收到數據,就通知程序處理。
- 調用?
2. 數據接收:“倉庫管理員” 怎么存數據?
當串口收到數據時(比如電腦發了一個字符?'A'
),會觸發?HAL_UART_RxCpltCallback
?中斷回調,流程像這樣:
- 觸發條件:串口硬件收到 1 個字節(比如?
'A'
),自動觸發這個函數。 - 存數據到環形緩沖區(對應?
cirble_buf_write(&g_uart1_rx_bufs,g_c);
):g_c
?里存著剛收到的字節(比如?'A'
?的 ASCII 碼)。- 調用?
cirble_buf_write
,就像 “管理員把剛收到的快遞(數據)放進倉庫貨架(數組?g_recvbuf
)”。
- 重新開啟中斷(
HAL_UART_Receive_IT(&huart1,&g_c,1);
):- 因為中斷觸發一次后會關閉,必須重新調用它,才能繼續監聽下一個字節。
- 相當于 “管理員存完這個快遞,趕緊打開監聽,等下一個快遞”。
3. 主程序取數據:從 “倉庫” 拿數據處理
// main.c:主程序,負責初始化、循環收發數據
#include "main.h" // 包含HAL庫等基礎頭文件
#include "i2c.h"
#include "usart.h" // 假設usart相關聲明在這里(實際工程需單獨.h)
#include "gpio.h"
#include "circle_buffer.h" // 環形緩沖區頭文件// 軟件定時器結構體(按鍵消抖用,和串口主邏輯關聯弱,初學可先關注串口)
struct soft_timer
{uint32_t timeout; // 超時時間戳(毫秒)void *args; // 回調參數void (*func)(void *); // 回調函數
};// 全局變量聲明(實際工程建議用static+訪問函數,這里簡化)
extern UART_HandleTypeDef huart1; // 串口1句柄,usart.c里會用到
int key_cut = 0;
void key_timeout_func(void *args);
struct soft_timer key_timer = {~0, NULL, key_timeout_func};// 環形緩沖區相關(按鍵邏輯,初學可先跳過,關注串口部分)
static uint8_t g_data_buf[100];
static circle_buf g_key_bufs;// 串口收發相關函數聲明(實際應放usart.h,這里簡化)
void startuart1recv(void); // 啟動串口接收中斷
int UART1getchar(uint8_t *pVal); // 從環形緩沖區取數據
void Wait_Tx_Complete(void); // 等待發送完成
void Wait_Rx_Complete(void); // 等待接收完成(實際未用到,演示用)// 主函數:程序入口
int main(void)
{char *str = "Please enter a char: \r\n"; // 發送的提示字符串char *str2 = "www.100ask.net\r\n"; // 歡迎信息char c; // 存儲接收到的字符// 1. 初始化HAL庫、系統時鐘HAL_Init(); SystemClock_Config(); // 2. 初始化環形緩沖區(按鍵邏輯,初學先記住串口也有環形緩沖區)circld_buf_init(&g_key_bufs, 100, g_data_buf); // 3. 初始化外設:GPIO、I2C、串口MX_GPIO_Init(); MX_I2C1_Init(); MX_USART1_UART_Init(); // 4. 初始化OLED(如果有的話,演示用,和串口核心邏輯無關)OLED_Init();OLED_Clear();OLED_PrintString(0, 0, "cnt : ");int len = OLED_PrintString(0, 2, "key val : ");// 5. 先發送一條歡迎信息HAL_UART_Transmit(&huart1, str2, strlen(str2), 1000); // 6. 啟動串口接收中斷:讓串口一收到數據就觸發中斷存緩沖區startuart1recv(); // 主循環:一直運行while (1){// 7. 非阻塞發送提示信息:告訴串口“后臺發,發完通知我”HAL_UART_Transmit_IT(&huart1, str, strlen(str)); // 8. 等待發送完成(阻塞等待,確保發完再干別的)Wait_Tx_Complete(); // 9. 從環形緩沖區取數據:循環取,直到取到數據(0表示成功)while (0 != UART1getchar(&c)) { /* 沒數據就循環等 */ }// 10. 處理數據:收到的字符+1,再發回去c = c + 1; HAL_UART_Transmit(&huart1, &c, 1, 1000); HAL_UART_Transmit(&huart1, "\r\n", 2, 1000); // 以下是按鍵消抖邏輯(和串口主流程關聯弱,初學可暫時注釋掉)// key_timeout_func相關邏輯...(演示用,不影響串口理解)}
}// 按鍵消抖回調(和串口主邏輯無關,初學可跳過)
void key_timeout_func(void *args)
{uint8_t key_val;key_cut++;key_timer.timeout = ~0;if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_14) == GPIO_PIN_RESET)key_val = 0x1;elsekey_val = 0x81;cirble_buf_write(&g_key_bufs, key_val);
}// 定時器修改函數(和串口主邏輯無關,初學可跳過)
void mod_timer(struct soft_timer *pTimer, uint32_t timeout)
{pTimer->timeout = HAL_GetTick() + timeout;
}// 定時器檢查函數(和串口主邏輯無關,初學可跳過)
void cherk_timer(void)
{if (key_timer.timeout <= HAL_GetTick()){key_timer.func(key_timer.args);}
}// GPIO中斷回調(和串口主邏輯無關,初學可跳過)
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{if (GPIO_PIN_14 == GPIO_Pin){mod_timer(&key_timer, 10);}
}
主程序里有個循環?while(0 != UART1getchar(&c));
,它的作用是?“一直從倉庫里取數據”,流程:
- 調用?
UART1getchar(&c)
(對應?return circle_buf_read(&g_uart1_rx_bufs,pVal);
):- 這是 “取快遞的人” 去倉庫找數據。
circle_buf_read
?會檢查環形緩沖區里有沒有數據。 - 如果有數據,就把數據放到?
c
?里(比如剛才存的?'A'
);如果沒數據,就等下次再取。
- 這是 “取快遞的人” 去倉庫找數據。
- 主程序處理數據(比如?
c=c+1; HAL_UART_Transmit(&huart1,&c,1,1000);
):- 拿到?
c
(比如?'A'
)后,主程序可以修改它(比如?c+1
?變成?'B'
),再通過?HAL_UART_Transmit
?發回去。
- 拿到?
4. 發送數據:“非阻塞” 發送是咋回事?
代碼里用了?HAL_UART_Transmit_IT(&huart1,str,strlen(str));
?+?Wait_Tx_Complete();
?發送數據:
HAL_UART_Transmit_IT
:“告訴串口:你后臺慢慢發數據,別阻塞主程序!發完了通知我。”Wait_Tx_Complete()
:“主程序在這等著,直到串口發完數據(g_tx_cplt
?變成 1),再繼續干別的。”
(類比:你點了個外賣配送,不用一直盯著,配送完 App 通知你 —— 但你可以選擇 “等配送完再干別的”)
(4)環形緩沖區核心邏輯(白話版)
很多同學不懂 “環形緩沖區” 咋循環存數據,用?“快遞貨架” 比喻?講清楚:
存數據(
cirble_buf_write
):- 貨架有 100 個格子(
g_recvbuf[100]
),管理員存數據時,按順序往后放。 - 存滿了怎么辦?環形?的關鍵:從最后一個格子跳回第一個格子繼續存(像操場跑圈),這樣不用清空數組,反復利用空間。
- 貨架有 100 個格子(
取數據(
circle_buf_read
):- 取數據的人按 “存數據的順序” 拿,存的時候從格子 0→1→2…,取的時候也 0→1→2…,保證數據順序不亂。
- 如果存的比取的快,倉庫會暫時存著;如果取的比存的快,就等新數據進來。
(5)“不丟數據” 的核心秘密:中斷 + 環形緩沖區配合
- 中斷?保證 “一收到數據就存”:不管主程序在干啥,只要串口有數據,立刻觸發回調存進倉庫,不會因為主程序忙別的就丟數據。
- 環形緩沖區?保證 “數據有地方存”:即使主程序處理慢,數據先存在倉庫里,不會因為 “沒及時取” 就丟失,主程序啥時候有空啥時候取。
(6)新手常見疑問解答
Q1:HAL_UART_RxCpltCallback
?里為啥要重新調用?HAL_UART_Receive_IT
?
A:因為串口中斷觸發一次后,會自動關閉。重新調用才能繼續監聽下一個字節,保證 “收到一個存一個”,不斷觸發中斷。
Q2:環形緩沖區和普通數組有啥區別?
A:普通數組存滿了必須清空才能繼續存;環形緩沖區像 “循環跑道”,存滿了從開頭繼續存,不用清空,效率更高。
Q3:主程序里?while(0 != UART1getchar(&c));
?是死循環嗎?
A:不是死循環!UART1getchar
?里,沒數據時會返回非 0(比如 -1),主程序會一直循環嘗試取;一旦取到數據(返回 0),就跳出循環繼續處理。
(7)總結:完整流程串起來
- 初始化:建環形緩沖區倉庫,打開串口中斷監聽。
- 收數據:串口收到數據 → 觸發中斷回調 → 數據存進環形緩沖區 → 重新開啟中斷,等下一個數據。
- 取數據:主程序循環從環形緩沖區取數據,處理后可以再發回去。
- 發數據:用中斷方式后臺發送,主程序不用阻塞等待,發完再繼續干活。
這樣配合,就能實現?“串口收發數據不丟失”,不管數據來多快、主程序多忙,都能穩穩接住~
四、STM32 DMA 串口收發教程(結合中斷,從 0 講透)
<3>DMA 模式的回調函數(此次核心代碼)
DMA 模式比中斷模式多了「半完成回調」,適合大數據量的 “邊傳邊處理”,用表格對比差異:
回調類型 | 觸發時機 | 典型用途 |
---|---|---|
完成回調 | 數據全部收發完成后觸發 | 最終數據處理(如校驗、存儲完整數據) |
半完成回調 | 數據收發到一半時觸發 | 實時處理(如顯示傳輸進度、臨時緩存) |
錯誤回調 | 收發過程中出現錯誤時觸發 | 錯誤恢復(如重發、報錯提示) |
DMA 發送有?HAL_UART_TxHalfCpltCallback
(發一半觸發)、HAL_UART_TxCpltCallback
(發完觸發);接收同理。
/發送
HAL_UART_Transmit_DMA
HAL_UART_TxHalfCpltCallback
HAL_UART_TxCpltCallback
//接收
HAL_UART_Receive_DMA
HAL_UART_RxHalfCpltCallback
HAL_UART_RxCpltCallback
//錯誤回調
HAL_UART_ErrorCallback
(1)DMA 是啥?解決啥問題?
1. 白話理解 DMA
- DMA 全稱:直接存儲器訪問(Direct Memory Access)
- 作用:讓數據 “自己搬家”,不用 CPU 盯著!
比如串口發 1000 個字符:- 不用 DMA:CPU 得逐個把字符 “抱” 到串口寄存器,期間不能干別的(像被 “拴在串口旁當苦力”)。
- 用 DMA:CPU 說 “我要發這 1000 個字符,地址是 xx”,然后就去干別的。DMA 控制器自己把數據逐個搬到串口,搬完告訴 CPU(通過中斷)。
2. 解決的核心問題
- 解放 CPU:讓 CPU 能同時處理其他任務(比如按鍵掃描、LED 控制),不用卡在 “收發數據” 上。
- 適合大數據:發 1000 個字符、收 1000 個字節時,DMA 效率碾壓 “CPU 逐個搬”。
(2)和之前 “中斷收發” 的區別(對比理解)
方式 | 核心邏輯 | 適合場景 | 缺點 |
---|---|---|---|
普通中斷 | 收 / 發 1 個字節就觸發中斷,CPU 處理 | 數據量小、需要實時響應 | 數據量大時,CPU 被中斷 “累死” |
DMA + 中斷 | DMA 自動搬數據,搬完(或搬一半)觸發中斷告訴 CPU | 大數據收發(比如發 1000 字符) | 接收時需配合 IDLE 中斷才好用(下文講) |
(3)代碼改造思路(把 “中斷收發” 改成 “DMA 收發”)
1. 發送改造:把?HAL_UART_Transmit_IT
?換成?HAL_UART_Transmit_DMA
- 原來的中斷發送:發 1 個字節觸發一次中斷,CPU 得管 “發完一個,下一個咋發”。
- DMA 發送:
- CPU 告訴 DMA:“我要發數組?
str
,長度?strlen(str)
,目標是串口 TDR 寄存器”。 - DMA 自動循環搬數據,全部搬完后觸發?
HAL_UART_TxCpltCallback
?中斷,告訴 CPU “發完了”。
- CPU 告訴 DMA:“我要發數組?
2. 接收改造:HAL_UART_Receive_DMA
?+ (可選 IDLE 中斷)
- DMA 接收:
- CPU 告訴 DMA:“我要收數據,存到數組?
RxBuf
,最多收?len
?個字節”。 - DMA 自動把串口收到的數據搬到?
RxBuf
,收滿?len
?個?觸發?HAL_UART_RxCpltCallback
;收一半?觸發?HAL_UART_RxHalfCpltCallback
。
- CPU 告訴 DMA:“我要收數據,存到數組?
- 為啥需要 IDLE 中斷?
串口收數據時,可能 “斷斷續續”(比如對方一次發 5 個,又發 3 個)。DMA 只會在 “收滿指定長度” 才觸發中斷,沒法知道 “對方已經發完一批”。這時候需要?IDLE 中斷:串口 “空閑” 時(沒數據來)觸發中斷,告訴 CPU“對方暫時不發了,你可以處理收到的數據了”。
四、保姆級代碼流程拆解(結合你發的代碼改造)
1. 關鍵函數對應(看第四張圖?DMA 模式
?函數)
函數名 | 觸發時機 | 作用 |
---|---|---|
HAL_UART_Transmit_DMA | 主程序調用,啟動 “DMA 發送” | 告訴 DMA 開始發數據 |
HAL_UART_TxCpltCallback | DMA 把數據全部發完后觸發 | 通知 CPU “發送完成” |
HAL_UART_Receive_DMA | 主程序調用,啟動 “DMA 接收” | 告訴 DMA 開始收數據 |
HAL_UART_RxCpltCallback | DMA 把數據全部收滿后觸發 | 通知 CPU “收滿指定長度了” |
HAL_UART_RxHalfCpltCallback | DMA 收了一半數據后觸發 | (可選)收一半時提前處理數據 |
HAL_UART_ErrorCallback | 收發出錯時觸發(比如總線錯誤) | 處理錯誤 |
2. 改造后的?main.c
?核心代碼(發送部分)
// main.c 主程序#include "main.h"
#include "usart.h" // 包含串口、DMA 相關聲明// 全局變量
UART_HandleTypeDef huart1; // 串口1句柄(CubeMX 配置生成)
char *str = "Hello DMA! 這是用 DMA 發的數據\r\n"; // 要發的字符串// 主函數
int main(void)
{HAL_Init(); // 初始化 HAL 庫SystemClock_Config(); // 配置系統時鐘MX_USART1_UART_Init(); // 初始化串口(CubeMX 生成,包含 DMA 配置)MX_DMA_Init(); // 初始化 DMA(CubeMX 生成)// 1. 啟動 DMA 發送:把 str 的內容,通過 DMA 發給串口HAL_UART_Transmit_DMA(&huart1, (uint8_t *)str, strlen(str)); // 2. 主循環:發完后,DMA 會觸發 HAL_UART_TxCpltCallback 中斷while (1){// 發完后,這里可以干別的(比如掃按鍵、閃 LED)// 不用卡在“等發送完成”,因為 DMA 自己在后臺發}
}// 【關鍵】DMA 發送完成回調函數:HAL 庫規定名稱,發完自動調用
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{if (huart == &huart1) // 確認是串口1的回調{// 可以在這里做“發送完成后的操作”:// 比如再發一次數據、切換 LED 狀態HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); // 假設 LED 宏定義好了}
}
(5)結合“DMA 模式圖” 詳細講流程??
1. 發送流程(對應第二張圖?RAM → DMA → USART
)?
?
CPU 下達指令:
執行?HAL_UART_Transmit_DMA(&huart1, str, len)
,CPU 告訴 DMA:- 源地址:
str
(RAM 里的字符串數組)。 - 目標地址:串口的?TDR 寄存器(Transmit Data Register,發送數據寄存器)。
- 長度:
len
(要發多少個字節)。
- 源地址:
DMA 自動搬數據:
DMA 控制器開始工作,逐個把?str
?里的字節,從 RAM 搬到?USART 的 TDR。- 這一步?不需要 CPU 參與!CPU 可以去干別的(比如?
while(1)
?里的按鍵掃描)。
- 這一步?不需要 CPU 參與!CPU 可以去干別的(比如?
發送完成觸發中斷:
DMA 把?len
?個字節全部搬完后,觸發?HAL_UART_TxCpltCallback
?中斷。- CPU 暫停當前任務,執行回調函數里的邏輯(比如 “再發一次數據”“翻轉 LED”)。
2. 接收流程(?USART → DMA → RAM
)
假設要收數據到數組?RxBuf[100]
,流程:
CPU 下達指令:
執行?HAL_UART_Receive_DMA(&huart1, (uint8_t *)RxBuf, 100)
,CPU 告訴 DMA:- 源地址:串口的?RDR 寄存器(Receive Data Register,接收數據寄存器)。
- 目標地址:
RxBuf
(RAM 里的數組)。 - 長度:
100
(最多收 100 個字節)。
DMA 自動搬數據:
串口收到數據,存到?RDR 寄存器?→ DMA 自動把 RDR 的數據搬到?RxBuf
。觸發中斷的兩種情況:
- 收滿 100 個字節:觸發?
HAL_UART_RxCpltCallback
,告訴 CPU“收滿了,來處理”。 - 收了 50 個字節(一半):觸發?
HAL_UART_RxHalfCpltCallback
,告訴 CPU“收了一半,可以提前處理”(可選)。
- 收滿 100 個字節:觸發?
為啥需要 IDLE 中斷?
如果對方發的數據?不足 100 個(比如只發 30 個),DMA 不會觸發?RxCpltCallback
(因為沒收滿 100)。這時候需要?IDLE 中斷:- 串口 “空閑” 時(沒數據來),觸發 IDLE 中斷,告訴 CPU“對方暫時不發了,你可以處理?
RxBuf
?里的 30 個數據”。
- 串口 “空閑” 時(沒數據來),觸發 IDLE 中斷,告訴 CPU“對方暫時不發了,你可以處理?
(6)關鍵函數詳解(保姆級逐行講)
1.?HAL_UART_Transmit_DMA
:啟動 DMA 發送
HAL_UART_Transmit_DMA(&huart1, (uint8_t *)str, strlen(str));
參數 1:
&huart1
?→ 操作的串口(串口 1)。參數 2:
(uint8_t *)str
?→ 要發的數據在 RAM 里的地址。參數 3:
strlen(str)
?→ 要發的字節數(比如字符串長度)。底層干了啥(結合第二張圖):
- 配置 DMA 的?源地址?為?
str
,目標地址?為?huart1->Instance->TDR
(串口 TDR 寄存器)。 - 配置 DMA 傳輸長度為?
strlen(str)
。 - 啟動 DMA 傳輸,開始自動搬數據。
- 配置 DMA 的?源地址?為?
2.?HAL_UART_TxCpltCallback
:發送完成回調
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{if (huart == &huart1){// 發送完成!可以在這里做后續操作HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); // 比如翻轉 LED}
}
- 觸發時機:DMA 把所有數據搬完(TDR 發完)后,HAL 庫自動調用。
- 作用:告訴 CPU “發送結束”,可以執行 “發完后的邏輯”(比如再發一批、記錄日志)。
3.?HAL_UART_Receive_DMA
:啟動 DMA 接收
uint8_t RxBuf[100]; // 存接收的數據
HAL_UART_Receive_DMA(&huart1, RxBuf, 100);
參數 1:
&huart1
?→ 操作的串口(串口 1)。參數 2:
RxBuf
?→ 數據接收后,存到 RAM 里的數組。參數 3:
100
?→ 最多收 100 個字節。底層干了啥(結合第三張圖):
- 配置 DMA 的?源地址?為?
huart1->Instance->RDR
(串口 RDR 寄存器)。 - 配置 DMA 的?目標地址?為?
RxBuf
。 - 配置 DMA 傳輸長度為?
100
。 - 啟動 DMA 傳輸,串口收到數據后,DMA 自動把 RDR 的數據搬到?
RxBuf
。
- 配置 DMA 的?源地址?為?
4.?HAL_UART_RxCpltCallback
:接收完成回調(收滿長度觸發)
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{if (huart == &huart1){// 收滿 100 個字節了!可以處理 RxBuf 里的數據// 比如打印、解析HAL_UART_Transmit(&huart1, RxBuf, 100, 1000); // 把收到的發回去}
}
- 觸發時機:DMA 把?
RxBuf
?收滿 100 個字節后,自動調用。 - 注意:如果對方發的數據不足 100,不會觸發這個回調!這時候需要配合?IDLE 中斷(下文補講)。
(7)接收的 “坑”:DMA 接收需配合 IDLE 中斷(初學者必看)
1. 問題:DMA 接收 “收不滿長度,不觸發回調”
比如你讓 DMA 收 100 個字節,但對方只發 30 個。DMA 沒收滿 100,不會觸發?HAL_UART_RxCpltCallback
,主程序就 “不知道啥時候該處理這 30 個數據”。
五、STM32 USART 進階:DMA + IDLE 中斷收發保姆級教程(含代碼修復)
(1)前言:為什么需要 DMA + IDLE 中斷?
在 STM32 串口通信中,普通中斷收發適合小數據量,但面對連續不定長數據時:
- 純中斷:頻繁觸發中斷,CPU 負擔重
- 純 DMA:無法判斷 “數據何時接收完成”(比如對方發 50 字節后突然停止)
而?DMA + IDLE 中斷?完美解決這兩個痛點:
- DMA 自動搬運數據,解放 CPU
- IDLE 中斷精準識別 “數據傳輸暫停”,讓我們知道 “該處理已收數據了”
(2)核心問題拆解(老師代碼的坑)
1. 問題 1:忘記使能接收通道
- 現象:DMA 能發數據,但收不到任何內容
- 原因:串口接收的 DMA 通道未正確使能,數據無法從串口寄存器搬運到內存
2. 問題 2:IDLE 中斷后未重啟 DMA
- 現象:只能接收一次數據,之后無響應
- 原因:IDLE 中斷觸發后,未重新啟動 DMA 接收,導致后續數據無法被捕獲
(3)DMA + IDLE 中斷完整流程(從硬件到代碼)
1. 硬件配置(CubeMX 關鍵步驟)
(1)串口配置
- 模式:Asynchronous(異步模式)
- 波特率:根據需求設置(如 115200)
- 使能 DMA:
- TX:
DMA1 Channel 4
(Memory To Peripheral
) - RX:
DMA1 Channel 5
(Peripheral To Memory
)
- TX:
(2)NVIC 配置
- 使能?
USART1 global interrupt
- 使能?
DMA1 Channel 4/5 interrupt
(可選,部分場景需 DMA 半傳輸 / 傳輸完成中斷)
2. 代碼流程拆解
(1)文件結構
usart.c
:核心邏輯(DMA 收發、中斷回調)
(2)usart.c
?完整代碼(修復版)
/* USER CODE BEGIN 1 */
// 發送完成標志(用于非阻塞發送時等待發送結束)
static volatile int g_tx_cplt = 0;
// 接收完成標志(用于普通中斷接收模式,此處DMA模式較少用)
static volatile int g_rx_cplt = 0;
// 臨時接收緩沖區(普通中斷模式下存儲單個字節)
static uint8_t g_c;
// DMA接收緩沖區(一次接收10個字節)
static uint8_t g_buf[10];
// 環形緩沖區的物理存儲空間(用于存儲所有接收到的數據)
static uint8_t g_recvbuf[100];
// 環形緩沖區控制結構(管理讀寫位置等信息)
static circle_buf g_uart1_rx_bufs;/*** 發送完成回調函數(DMA模式)* 當DMA將所有數據從內存發送到串口后,由HAL庫自動調用*/
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{// 標記發送完成(用于Wait_Tx_Complete函數判斷)g_tx_cplt = 1;// 注意:此處注釋提到"放入環形緩沖區",但發送完成無需操作緩沖區// 發送完成回調主要用于喚醒等待的任務或處理發送后邏輯
}/*** 等待發送完成(阻塞函數)* 調用后會一直等待,直到DMA發送完成(g_tx_cplt被置1)*/
void Wait_Tx_Complete(void)
{// 循環等待發送完成標志while (g_tx_cplt == 0);// 清除標志,為下一次發送做準備g_tx_cplt = 0;
}/*** 接收完成回調函數(普通中斷模式)* 當使用HAL_UART_Receive_IT時,每收到1個字節觸發一次* 注意:在DMA+IDLE模式下,主要使用HAL_UARTEx_RxEventCallback*/
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{// 標記接收完成(普通中斷模式下使用)g_rx_cplt = 1;// 將收到的單個字節存入環形緩沖區for(int i = 0; i < 10; i++){// 此處邏輯有誤!普通中斷每次只收1字節,應直接存g_c// 正確寫法:cirble_buf_write(&g_uart1_rx_bufs, g_c);// 但代碼中錯誤地循環寫入g_buf(DMA緩沖區),可能導致數據異常cirble_buf_write(&g_uart1_rx_bufs, g_buf[i]);}// 重新啟動接收中斷(很重要!否則只能接收一次)// 注意:此處使用了錯誤的函數!在DMA模式下應使用HAL_UARTEx_ReceiveToIdle_DMA// 正確寫法:HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_buf, 10);HAL_UART_Receive_IT(&huart1, &g_c, 1);
}/*** 啟動串口接收(初始化函數)* 配置環形緩沖區并開啟接收中斷*/
void startuart1recv(void)
{// 初始化環形緩沖區(指定緩沖區大小和物理存儲數組)circld_buf_init(&g_uart1_rx_bufs, 100, g_recvbuf);// 啟動接收中斷(注意:此處使用了普通中斷模式!)// 正確寫法:HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_buf, 10);HAL_UART_Receive_IT(&huart1, &g_c, 1);
}/*** 從環形緩沖區讀取一個字節* 返回0表示成功讀取,非0表示緩沖區為空*/
int UART1getchar(uint8_t *pVal)
{return circle_buf_read(&g_uart1_rx_bufs, pVal);
}/*** IDLE事件+DMA接收回調函數(關鍵!)* 當DMA接收完成或串口空閑時觸發*/
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{// 將DMA緩沖區中的數據存入環形緩沖區for(int i = 0; i < Size; i++){cirble_buf_write(&g_uart1_rx_bufs, g_buf[i]);}// 重新啟動DMA接收(關鍵!否則只能接收一次)// 注意:老師忘記在IDLE中斷中調用此函數,導致只能接收一次數據HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_buf, 10);
}
/* USER CODE END 1 */
(4)核心邏輯分步詳解(從 “啟動” 到 “收數據”)
1. 啟動流程(startuart1recv
?做了什么?
- 環形緩沖區:
g_recvbuf
?作為 “臨時倉庫”,存零散收到的數據 - DMA 配置:告訴硬件 “把串口收到的數據,自動搬到?
g_buf
,一次搬 10 字節”
2. 接收流程(DMA + IDLE 如何配合?)
(1)正常接收(數據連續)
?
?(2)觸發 IDLE 事件(數據暫停
?????
- 為什么要重啟 DMA?
DMA 傳輸一旦完成(或觸發 IDLE),會自動停止。必須重新調用?HAL_UARTEx_ReceiveToIdle_DMA
,才能繼續接收后續數據。
3. 發送流程(DMA 發送如何工作?)
(5)常見問題與解決方案(初學者必看)
1. 問題:DMA 接收后,環形緩沖區無數據
- 原因:
- DMA 通道未正確使能(CubeMX 配置問題)
HAL_UARTEx_RxEventCallback
?中未正確重啟 DMA
- 解決:
- 檢查 CubeMX 的 DMA 配置(確保?
USART1_RX
?通道使能) - 確認?
HAL_UARTEx_RxEventCallback
?中調用了?HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_buf, 10);
- 檢查 CubeMX 的 DMA 配置(確保?
2. 問題:只能接收一次數據,之后無響應
- 原因:IDLE 事件觸發后,未重啟 DMA 接收
- 解決:在?
HAL_UARTEx_RxEventCallback
?中添加重啟代碼HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_buf, 10);
3. 問題:IDLE 中斷頻繁觸發
- 原因:串口線干擾或波特率配置錯誤,導致誤判 “空閑”
- 解決:
- 檢查硬件接線(確保 GND 共地,串口線無松動)
- 重新校準波特率(確保收發雙方波特率一致)
(6)總結:DMA + IDLE 中斷的價值
- 效率:DMA 自動搬運數據,CPU 可同時處理其他任務
- 靈活性:IDLE 事件精準識別 “數據暫停”,完美支持不定長數據收發
- 可靠性:環形緩沖區緩沖零散數據,避免丟包
這套方案是 STM32 串口通信的 “進階標配”,掌握后可輕松應對串口調試助手連續發數據、上位機批量傳文件等場景!
六、完善UART程序與stdio(最終結果)
// 引入頭文件:頭文件相當于工具包,包含了各種已經寫好的函數和定義,方便我們直接使用
#include "main.h" // 主頭文件,包含系統初始化、核心函數的定義
#include "dma.h" // DMA相關工具:用于高速數據傳輸(不用CPU參與)
#include "i2c.h" // I2C通信工具:用于和OLED等I2C設備通信
#include "usart.h" // UART串口工具:用于串口收發數據(比如和電腦通信)
#include "gpio.h" // GPIO工具:用于控制引腳高低電平(比如按鍵、LED)
#include "circle_buffer.h"// 環形緩沖區工具:一種特殊的存儲結構,適合臨時存數據
#include <stdio.h> // 標準輸入輸出工具:包含printf、scanf等函數// 定義一個"軟定時器"結構體:用軟件實現定時功能(類似鬧鐘,到時間了就做指定的事)
struct soft_timer
{uint32_t timeout; // 超時時間點(單位:毫秒):記錄"什么時候響鈴"void * args; // 回調參數:給定時任務傳的數據(這里暫時不用)void (*func)(void *); // 回調函數:超時后要執行的任務("響鈴后要做的事")
};// 聲明外部變量:huart1是UART1的"句柄"(可以理解為UART1的身份證,操作它必須用這個句柄)
extern UART_HandleTypeDef huart1;int key_cut = 0; // 按鍵計數:記錄按鍵被有效按下的次數// 聲明按鍵超時處理函數(后面會具體實現)
void key_timeout_func(void *args);// 創建一個按鍵專用的軟定時器:初始狀態為"未激活"(timeout=~0表示無窮大)
struct soft_timer key_timer = {~0, NULL, key_timeout_func};// 聲明幾個函數(先告訴編譯器有這些函數,后面再實現)
void Wait_Tx_Complete(void); // 等待串口發送完成
void Wait_Rx_Complete(void); // 等待串口接收完成
void startuart1recv(void); // 啟動UART1接收功能
int UART1getchar(uint8_t *pVal);// 從UART1讀取一個字符// 環形緩沖區相關變量:用來臨時存儲按鍵數據(防止按鍵觸發太快處理不過來)
static uint8_t g_data_buf[100]; // 實際存儲數據的空間(可以存100個字節)
static circle_buf g_key_bufs; // 環形緩沖區的管理結構(負責讀寫數據)/*** 按鍵超時處理函數:軟定時器到時間后執行(用于按鍵消抖后確認狀態)* 為什么需要消抖?按鍵按下時金屬觸點會抖動(10ms內可能通斷多次),10ms后再讀才準確*/
void key_timeout_func(void *args)
{uint8_t key_val; // 存儲按鍵的狀態值(0x1表示按下,0x81表示松開)key_cut++; // 按鍵計數+1(每有效觸發一次,計數加1)key_timer.timeout = ~0; // 重置定時器:處理完后暫時關閉,下次按鍵再激活// 讀取GPIO_PIN_14引腳的電平(這個引腳接了按鍵)// GPIO_PIN_RESET表示低電平(按鍵按下,因為按鍵通常接下拉電阻)// GPIO_PIN_SET表示高電平(按鍵松開)if(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_14) == GPIO_PIN_RESET)key_val = 0x1; // 按鍵按下,存0x1elsekey_val = 0x81; // 按鍵松開,存0x81(用不同值區分兩種狀態)// 把按鍵狀態存入環形緩沖區(先存起來,后面慢慢處理)circle_buf_write(&g_key_bufs, key_val);
}/*** 設置定時器超時時間(激活定時器)* @param pTimer:要設置的定時器* @param timeout:要等待的時間(單位:毫秒)*/
void mod_timer(struct soft_timer *pTimer, uint32_t timeout)
{// HAL_GetTick():獲取系統從啟動到現在的毫秒數(比如啟動后1秒,返回1000)// 超時時間點 = 當前時間 + 等待時間(比如現在1000ms,等10ms,超時點就是1010ms)pTimer->timeout = HAL_GetTick() + timeout;
}/*** 檢查定時器是否超時(軟定時器的核心邏輯)* 相當于"看鬧鐘有沒有響",需要在主循環里反復調用*/
void check_timer(void)
{// 如果當前時間 >= 定時器的超時時間點,說明"鬧鐘響了"if(key_timer.timeout <= HAL_GetTick()){// 執行定時器綁定的任務(調用回調函數)key_timer.func(key_timer.args);}
}/*** GPIO中斷回調函數:當引腳電平變化時自動調用(比如按鍵按下時)* @param GPIO_Pin:觸發中斷的引腳編號*/
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{// 判斷是不是GPIO_PIN_14引腳觸發的中斷(我們的按鍵接在這個引腳)if(GPIO_Pin == GPIO_PIN_14){// 激活按鍵定時器,10ms后執行超時處理(用于消抖)mod_timer(&key_timer, 10);}
}/*** 聲明系統時鐘配置函數(由STM32CubeMX自動生成,負責設置CPU等的工作頻率)*/
void SystemClock_Config(void);/*** 主函數:程序的入口,所有代碼從這里開始執行* @retval int:返回值(嵌入式程序通常不返回,這里只是標準格式)*/
int main(void)
{int len; // 臨時變量,用來存字符串長度// 定義要發送的字符串:\r\n是換行符(串口通信中換行需要這兩個字符)char *str = "Please enter a char: \r\n";char *str2 = "www.100ask.net\r\n";char c; // 用來存從串口接收的字符// 初始化HAL庫:重置所有外設,初始化Flash和系統滴答定時器(用于延時)HAL_Init();// 配置系統時鐘:設置CPU、外設的工作頻率(比如72MHz)SystemClock_Config();// 初始化按鍵專用的環形緩沖區:指定大小100,用g_data_buf作為存儲區circle_buf_init(&g_key_bufs, 100, g_data_buf);// 初始化各個外設(由STM32CubeMX自動生成)MX_GPIO_Init(); // 初始化GPIO(配置引腳功能)MX_DMA_Init(); // 初始化DMAMX_I2C1_Init(); // 初始化I2C1(用于OLED通信)MX_USART1_UART_Init(); // 初始化UART1(配置波特率等參數)// 初始化OLED屏幕并清屏OLED_Init();OLED_Clear();// 在OLED上顯示固定文本:第一行顯示"cnt : "(用于顯示按鍵計數)// 第三行顯示"key val : "(用于顯示按鍵狀態值)OLED_PrintString(0, 0, "cnt : ");len = OLED_PrintString(0, 2, "key val : ");// 通過UART1發送str2字符串到電腦:// 參數:UART句柄、要發的字符串、長度、超時時間(1000ms發不出去就放棄)HAL_UART_Transmit(&huart1, str2, strlen(str2), 1000);// 啟動UART1的接收功能:讓UART1準備好接收數據,收到后會觸發中斷startuart1recv();// 主循環:程序啟動后會一直在這里循環執行(無限循環)while (1){// 通過printf發送str2到串口(printf已被設置為通過UART1發送)printf("%s", str2);// 內層循環:等待用戶輸入一個有效字符(不是'r'也不是換行符)while(1){// 從串口接收一個字符(scanf已被設置為通過UART1接收)scanf("%c", &c);// 如果接收的字符不是'r'也不是換行符('\n'),就處理并退出內層循環if(c != 'r' && c != '\n'){c = c + 1; // 字符加1(比如輸入'a'變成'b',輸入'1'變成'2')printf("%c\r\n", c); // 把處理后的字符發回串口break; // 退出內層循環,回到外層循環重新等待輸入}}}
}
// 發送/接收完成標志,使用volatile確保編譯器不優化
static volatile int g_tx_cplt=0;
static volatile int g_rx_cplt=0;
// 臨時存儲接收數據的變量和緩沖區
static uint8_t g_c;
static uint8_t g_buf[10];
static uint8_t g_recvbuf[100];
// UART1接收緩沖區(環形緩沖區)
static circle_buf g_uart1_rx_bufs;/*** UART發送完成回調函數* 當UART發送完成時,此函數會被自動調用*/
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{if(huart == &huart1){g_tx_cplt=1; // 設置發送完成標志// 注釋中提到"放入環形緩沖區",但此處未實現}
}/*** 等待UART發送完成* 阻塞函數,會一直等待直到發送完成*/
void Wait_Tx_Complete(void)
{while (g_tx_cplt == 0 ); // 等待發送完成標志g_tx_cplt=0; // 清除標志,準備下一次發送
}/*** UART接收完成回調函數* 當使用HAL_UART_Receive_IT()接收到指定數量的數據后,此函數會被調用*/
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{if(huart == &huart1){g_rx_cplt=1; // 設置接收完成標志// 將接收到的數據(10字節)存入環形緩沖區for(int i=0;i<10;i++){cirble_buf_write(&g_uart1_rx_bufs,g_buf[i]);}// 重新啟動接收,準備接收下一組數據HAL_UARTEx_ReceiveToIdle_DMA(&huart1,g_buf,10);}
}/*** 啟動UART1接收* 初始化環形緩沖區并開啟接收中斷*/
void startuart1recv(void)
{// 初始化環形緩沖區,大小為100字節circld_buf_init(&g_uart1_rx_bufs,100,g_recvbuf);// 啟動中斷接收,每次接收1字節HAL_UART_Receive_IT(&huart1,&g_c,1);
}/*** 從UART1接收緩沖區獲取一個字符* 返回0表示成功獲取,非0表示緩沖區為空*/
int UART1getchar(uint8_t *pVal)
{return circle_buf_read(&g_uart1_rx_bufs,pVal);
}/*** UART接收空閑回調函數* 當檢測到UART接收線路空閑時,此函數會被調用*/
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{if(huart == &huart1){// 將接收到的數據存入環形緩沖區for(int i=0;i<Size;i++){cirble_buf_write(&g_uart1_rx_bufs,g_buf[i]);}// 重新啟動接收,準備接收下一組數據HAL_UARTEx_ReceiveToIdle_DMA(&huart1,g_buf,10);}
}// 回退處理相關變量
static int g_last_char;
static int g_backspace=0;/*** 重定向fputc函數* 實現printf通過UART1發送數據*/
int fputc(int ch,FILE* stream)
{HAL_UART_Transmit(&huart1,(const uint8_t *)&ch,1,10);return ch;
}/*** 重定向fgetc函數* 實現scanf通過UART1接收數據*/
int fgetc(FILE *f)
{int ch;if (g_backspace){g_backspace=0;return g_last_char; // 返回上一個字符(回退功能)}// 阻塞等待,直到從環形緩沖區讀取到數據while(0 != UART1getchar((uint8_t *)&ch));g_last_char = ch; // 保存當前字符,用于回退功能return ch;
}/*** 實現回退功能* 允許程序"撤銷"上一次讀取的字符*/
int __backspace(FILE *stream)
{g_backspace = 1;return 0;
}
?