據(jù)不丟的實(shí)現(xiàn)方案)
簡(jiǎn)介雙串口MODBUSDMAFIFO完整工程面向需要實(shí)現(xiàn)MODBUS多從機(jī)通信與串口高效收發(fā)的STM32開發(fā)者。工程基于FREEMODBUS庫在雙串口場(chǎng)景下引入DMA與FIFO緩沖機(jī)制降低CPU負(fù)載、保證數(shù)據(jù)有序性可應(yīng)用于工業(yè)自動(dòng)化設(shè)備、物聯(lián)網(wǎng)節(jié)點(diǎn)等對(duì)實(shí)時(shí)性要求較高的場(chǎng)景。資源包共收錄1220個(gè)文件壓縮后僅9.2MB以C/H源碼和工程配置文件為主除約900個(gè)源碼文件外還包含STM32CubeMX的.ioc/.mxproject配置、IAR/Keil工程文件、.a/.lib靜態(tài)庫、.hex/.axf構(gòu)建產(chǎn)物以及md/txt說明文檔目錄層次清晰便于對(duì)照學(xué)習(xí)和移植。目前已有1013人學(xué)習(xí)瀏覽。通過該工程讀者可以直接獲取雙串口DMAFIFO收發(fā)框架、FREEMODBUS從機(jī)配置方法、多從機(jī)交互邏輯和完整可編譯工程適合作為Modbus協(xié)議棧二次開發(fā)或串口性能優(yōu)化的參考樣例。1. 雙串口MODBUSDMAFIFO到底解決什么問題雙串口MODBUSDMAFIFO這套資源解決的不是“怎么把Modbus代碼復(fù)制兩份”的問題而是“兩條RS-485總線同時(shí)壓到同一臺(tái)從站上數(shù)據(jù)怎么不丟”的問題?,F(xiàn)場(chǎng)很常見1號(hào)總線的主站下發(fā)參數(shù)2號(hào)總線的主站輪詢運(yùn)行數(shù)據(jù)兩邊從站地址不同、波特率還不一樣。如果靠串口中斷一字節(jié)一字節(jié)收主程序一進(jìn)臨界區(qū)或者Flash一擦寫立刻丟字節(jié)。我拆完這套資源后的感受是兩路串口各配一個(gè)DMA接收通道用空閑中斷IDLE判定Modbus幀結(jié)束再把DMA緩沖區(qū)里的字節(jié)倒進(jìn)統(tǒng)一的FIFO隊(duì)列交給FreeModbus按幀解析中斷次數(shù)能降一個(gè)數(shù)量級(jí)。適合手里有STM32從站工程、想把中斷收數(shù)換成DMA收數(shù)的人直接拆改。2. 模塊怎么搭雙串口、DMA、FIFO三者的分工與順序2.1 為什么全中斷接收在雙串口下容易翻車Modbus RTU 的幀邊界很脆弱協(xié)議規(guī)定兩幀之間至少 3.5 個(gè)字符時(shí)間的靜默幀內(nèi)字符必須連續(xù)。一幀最長(zhǎng) 256 字節(jié)按 115200bps 8N1 計(jì)算一個(gè)字節(jié)位時(shí)間約 86.8μs3.5 個(gè)字符約 304μs。也就是說整個(gè)串口在等字節(jié)的過程中CPU 被迫跟著字節(jié)節(jié)奏走完全沒有彈性。最常見的單字節(jié)中斷收數(shù)代碼長(zhǎng)這樣void UARTx_IRQHandler(void) { uint8_t ch; if (__HAL_UART_GET_FLAG(huartx, UART_FLAG_RXNE) ! RESET) { ch (uint8_t)(huartx.Instance-RDR 0xFF); ring_fifo_push(rx_fifo_x, ch); /* 每來一個(gè)字節(jié)就壓一次隊(duì)列 */ __HAL_UART_CLEAR_FLAG(huartx, UART_FLAG_RXNE); } }這段代碼看著沒問題實(shí)際在雙串口時(shí)會(huì)暴露出兩類開銷一次中斷現(xiàn)場(chǎng)保護(hù)壓棧出棧幾十個(gè)周期兩次中斷之間還有不可裁剪的調(diào)度窗口。更麻煩的是兩個(gè)串口共享同一中斷優(yōu)先級(jí)時(shí)一旦 1 號(hào)總線連續(xù)發(fā)幀2 號(hào)總線的字節(jié)會(huì)在嵌套向量表里排隊(duì)等 1 號(hào)把一幀收完2 號(hào)硬件接收移位寄存器已經(jīng)被新字節(jié)覆蓋——ISR 沒來得及讀數(shù)據(jù)就沒了。工業(yè)現(xiàn)場(chǎng)還有更隱蔽的情況操作系統(tǒng)臨界區(qū)、Flash 擦寫、調(diào)試打印都會(huì)關(guān)中斷。實(shí)測(cè)關(guān)中斷 2ms 后9600 波特率下會(huì)丟 2 到 3 個(gè)字節(jié)。DMA 的價(jià)值在于“中斷延遲期間字節(jié)被外設(shè)自動(dòng)搬到內(nèi)存”FIFO 的價(jià)值在于“搬過來的字節(jié)還要按順序喂給 Modbus 解析不能一次全推給協(xié)議?!?。這也是這套資源把 DMA 和 FIFO 綁在一起的原因。2.2 推薦接收鏈路DMA 搬字節(jié)IDLE 定幀尾FIFO 排隊(duì)整套鏈路拆開看是五段外設(shè)字節(jié)進(jìn)入 RX DMADMA 連續(xù)寫入收發(fā)緩沖區(qū)串口產(chǎn)生 IDLE 空閑中斷時(shí)暫停 DMA軟件把緩沖區(qū)中已接收的字節(jié)拷貝進(jìn) FIFO應(yīng)用層再從 FIFO 拉數(shù)據(jù)交給 FreeModbus 分包解析。收數(shù)方式CPU 占用抗中斷延遲幀邊界檢測(cè)單字節(jié)中斷最高差RXNE 沒及時(shí)讀就丟額外軟件定時(shí)器定時(shí)器輪詢 RDR中一般字節(jié)被覆蓋風(fēng)險(xiǎn)超時(shí)近似DMA 空閑中斷 FIFO低強(qiáng)DMA 自動(dòng)保存IDLE 中斷天然定位關(guān)于 IDLE 中斷要說明一點(diǎn)串口在一個(gè)字節(jié)時(shí)間內(nèi)沒有收到新的起始位就會(huì)拉高 IDLE 標(biāo)志。Modbus 幀內(nèi)字節(jié)不允許出現(xiàn)這種間隙所以 IDLE 經(jīng)常被當(dāng)作“幀結(jié)束”的標(biāo)志。對(duì) 115200 而言一個(gè)字節(jié)空隙約 87μs小于協(xié)議要求的 3.5 字符時(shí)間只要主站字節(jié)間不間斷IDLE 不會(huì)被誤觸發(fā)。唯一例外是波特率很低或主站軟件發(fā)送不連貫這類情況后面避坑章節(jié)單獨(dú)處理。2.3 雙串口并發(fā)為什么拷貝入 FIFO 不用加鎖兩個(gè)串口各自使用獨(dú)立的 DMA 通道、獨(dú)立的接收緩沖區(qū)和獨(dú)立的 FIFO內(nèi)存地址完全不重疊。即便兩條總線同時(shí)來幀寫的是不同區(qū)域裸機(jī)環(huán)境下不需要互斥鎖。這套資源的文件結(jié)構(gòu)也是這樣設(shè)計(jì)的兩個(gè) FIFO 獨(dú)立應(yīng)用層有一個(gè)統(tǒng)一的輪詢?nèi)肟凇?define UART1_RX_FIFO_SIZE 512 #define UART2_RX_FIFO_SIZE 512 static uint8_t uart1_rx_dma_buf[UART1_RX_FIFO_SIZE]; static uint8_t uart2_rx_dma_buf[UART2_RX_FIFO_SIZE]; static uint8_t uart1_fifo_mem[UART1_RX_FIFO_SIZE]; static uint8_t uart2_fifo_mem[UART2_RX_FIFO_SIZE]; static ring_fifo_t fifo_uart1 { .buf uart1_fifo_mem, .size UART1_RX_FIFO_SIZE }; static ring_fifo_t fifo_uart2 { .buf uart2_fifo_mem, .size UART2_RX_FIFO_SIZE };這里有三個(gè) 512 字節(jié)的數(shù)組分別是 DMA 原始緩沖區(qū)和 FIFO 存儲(chǔ)區(qū)。為什么兩個(gè)大小一致因?yàn)?DMA 一輪最多搬運(yùn)的數(shù)據(jù)量不會(huì)超過 FIFO 容量如果 FIFO 比 DMA 緩沖區(qū)小IDLE 中斷里拷貝時(shí)會(huì)丟數(shù)據(jù)。如果只跑一個(gè)從站256 字節(jié)也夠雙從站且主站頻繁讀多寄存器時(shí)512 更穩(wěn)。3. FreeModbus移植與雙串口初始化能抄的代碼和參數(shù)3.1 移植 FreeModbus 時(shí)改哪幾個(gè) port 函數(shù)FreeModbus 的串口移植集中在 portserial.c 里核心就是下面幾個(gè)函數(shù)函數(shù)作用這套資源的改法xMBPortSerialInit初始化串口與 DMA啟動(dòng) HAL_UART_Receive_DMA 接收xMBPortSerialGetByte從 FIFO 取一個(gè)字節(jié)ring_fifo_pop(rx_fifo[port])xMBPortSerialPutByte發(fā)送單字節(jié)拉高 DE寫 DR 或走 DCA 發(fā)送xMBPortSerialEnableIrq使能收發(fā)中斷使能 IDLE 中斷發(fā)送完成后關(guān) DEvMBPortSerialClose關(guān)閉端口停 DMA關(guān)中斷移植的坑通常在 xMBPortSerialPutByte。Modbus 是半雙工協(xié)議發(fā)送前要把 RS-485 方向引腳拉高發(fā)完再拉低。如果只發(fā)一個(gè)字節(jié)就立刻拉低最后一個(gè)字節(jié)的停止位還在線上對(duì)端收到的幀就短了一截。3.2 雙串口的 DMA 初始化與參數(shù)選擇初始化代碼可以直接抄重點(diǎn)看 DMA 通道選擇和傳輸模式void BSP_UART_DMA_Init(void) { /* USART1 接收 DMA默認(rèn) NORMAL 模式由 IDLE 觸發(fā)停止 */ hdma_uart1_rx.Instance DMA1_Channel5; /* 按實(shí)際型號(hào)改 */ hdma_uart1_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_uart1_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_uart1_rx.Init.MemInc DMA_MINC_ENABLE; hdma_uart1_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_uart1_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_uart1_rx.Init.Mode DMA_NORMAL; __HAL_LINKDMA(huart1, hdmarx, hdma_uart1_rx); /* USART2 接收 DMA通道換掉其他參數(shù)一致 */ hdma_uart2_rx.Instance DMA1_Channel6; hdma_uart2_rx.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_uart2_rx.Init.PeriphInc DMA_PINC_DISABLE; hdma_uart2_rx.Init.MemInc DMA_MINC_ENABLE; hdma_uart2_rx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_uart2_rx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_uart2_rx.Init.Mode DMA_NORMAL; __HAL_LINKDMA(huart2, hdmarx, hdma_uart2_rx); HAL_UART_Receive_DMA(huart1, uart1_rx_dma_buf, UART1_RX_FIFO_SIZE); HAL_UART_Receive_DMA(huart2, uart2_rx_dma_buf, UART2_RX_FIFO_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); __HAL_UART_ENABLE_IT(huart2, UART_IT_IDLE); }兩個(gè)關(guān)鍵參數(shù)要說明。第一方向必須是 DMA_PERIPH_TO_MEMORY外設(shè)地址不自增內(nèi)存地址自增第二模式用 DMA_NORMAL 而不是 DMA_CIRCULAR。這套資源的思路是“收滿一幀就?!钡?IDLE 中斷把數(shù)據(jù)處理完再重新調(diào)用 HAL_UART_Receive_DMA 啟動(dòng)下一輪。如果用 CIRCULAR 模式緩沖區(qū)會(huì)被自己的寫入指針反復(fù)覆蓋IDLE 中斷里取數(shù)據(jù)時(shí)可能已經(jīng)丟了一截。另外注意 HAL 庫的 HAL_UART_Receive_DMA 會(huì)把 huart 的 RxState 置為 BUSY如果 IDLE 中斷里先停 DMA、拷貝數(shù)據(jù)、再重新啟動(dòng)必須確保上一次調(diào)用已經(jīng)返回 HAL_OK否則會(huì)卡在 HAL_BUSY 上。3.3 雙從站地址路由與輪詢邏輯FreeModbus 原生只抽象一個(gè)串口雙串口時(shí)我習(xí)慣做一層獨(dú)立的幀分發(fā)不強(qiáng)行套兩個(gè)協(xié)議棧實(shí)例。思路是先各自從 FIFO 提取完整幀校驗(yàn) CRC 后比對(duì)從站地址命中哪一路就走哪一路的應(yīng)答邏輯void MODBUS_Poll(void) { uint16_t len1 0, len2 0; len1 modbus_frame_extract(fifo_uart1, rx_frame[0]); len2 modbus_frame_extract(fifo_uart2, rx_frame[1]); if (len1 0 rx_frame[0].addr slave_addr[0]) { modbus_dispatch(rx_frame[0]); } if (len2 0 rx_frame[1].addr slave_addr[1]) { modbus_dispatch(rx_frame[1]); } }modbus_frame_extract 的邏輯是從 FIFO 里逐字節(jié)彈出邊彈邊算 CRC16直到幀長(zhǎng)度超過 4 字節(jié)且 CRC 校驗(yàn)通過才算拿到一幀完整數(shù)據(jù)。這樣做的優(yōu)勢(shì)在于兩條串口互不阻塞1 號(hào)總線上一幀 100 字節(jié)的讀操作不會(huì)延誤 2 號(hào)總線的寫操作。地址路由有個(gè)容易忽略的點(diǎn)如果兩條總線上主站恰好用相同從站地址就要在 modbus_dispatch 里帶總線編號(hào)參數(shù)應(yīng)答時(shí)找對(duì)應(yīng)的發(fā)送串口和 DE 引腳而不是用全局變量。3.4 RS-485 方向切換和發(fā)送完成判斷發(fā)送方向的切換我一般放在 FreeModbus 的 xMBPortSerialEnableIrq 回調(diào)里void vMBPortSerialEnable(BOOL xRxEnable, BOOL xTxEnable) { if (xTxEnable) { RS485_DE_WRITE(1); /* 拉高 DE進(jìn)入發(fā)送模式 */ __HAL_UART_ENABLE_IT(huart_send, UART_IT_TC); } else { /* 不能在這里立刻拉低 DE要等發(fā)送完成中斷 */ } }TC 中斷意味著 TDR 寄存器已空但移位寄存器里的最后一個(gè)字節(jié)可能還在線上。如果 TC 一到就關(guān) DE總線上的幀尾會(huì)被截掉。常見做法是在 TC 中斷里做幾十到一百微秒的延時(shí)再關(guān)void UART_TC_Callback(void) { volatile uint32_t delay; for (delay 0; delay 120; delay); /* 粗略延時(shí)按主頻微調(diào) */ RS485_DE_WRITE(0); }這個(gè)延時(shí)不追求精確只要大于一個(gè)字符的位時(shí)間即可。比如 9600 波特率下一個(gè)字符約 1.04ms115200 下約 87μs延時(shí) 120 個(gè)循環(huán)在常見主頻下約幾十微秒到一百微秒勉強(qiáng)覆蓋高速場(chǎng)景。嚴(yán)謹(jǐn)做法是按系統(tǒng)時(shí)鐘算周期但資源包里的實(shí)現(xiàn)通常就是這樣粗調(diào)現(xiàn)場(chǎng)夠用。4. FIFO緩沖與幀邊界判斷把 DMA 搬運(yùn)的字節(jié)按幀喂給協(xié)議棧4.1 環(huán)形 FIFO 實(shí)現(xiàn)與“容量必須是 2 的冪”環(huán)形 FIFO 的代碼很短但容量有個(gè)硬性約束必須寫成 2 的冪。原因很簡(jiǎn)單取余操作可以換成位與省掉除法指令typedef struct { uint8_t *buf; volatile uint16_t head; /* 寫指針 */ volatile uint16_t tail; /* 讀指針 */ uint16_t size; /* 必須是 2 的冪 */ volatile uint16_t len; /* 當(dāng)前數(shù)據(jù)長(zhǎng)度 */ } ring_fifo_t; static inline uint16_t ring_fifo_count(const ring_fifo_t *f) { return f-len; } static inline uint8_t ring_fifo_push(ring_fifo_t *f, uint8_t b) { if (f-len f-size) { return 0; /* 滿丟棄新字節(jié) */ } f-buf[f-head] b; f-head (f-head 1) (f-size - 1); f-len; return 1; } int ring_fifo_pop(ring_fifo_t *f, uint8_t *b) { if (f-len 0) { return 0; } *b f-buf[f-tail]; f-tail (f-tail 1) (f-size - 1); f-len--; return 1; }代碼里有兩個(gè)細(xì)節(jié)。第一head 和 tail 都用了 volatile因?yàn)閷懼羔樤?IDLE 中斷里被修改讀指針在應(yīng)用層任務(wù)里被修改第二len 字段用來判斷空和滿比直接用 head tail 判斷更可靠避免空和滿兩種狀態(tài)混淆。我把 512 字節(jié)的 FIFO 用在雙串口上原因是 Modbus 最長(zhǎng)幀 256 字節(jié)DMA 一輪最多搬 256 字節(jié)FIFO 至少要是 DMA 緩沖區(qū)的兩倍才能容忍應(yīng)用層解析稍慢時(shí)的積壓。4.2 IDLE 中斷里的 DMA 停收與數(shù)據(jù)搬運(yùn)IDLE 中斷是這套資源的主心骨。進(jìn)來之后先停 DMA再取計(jì)數(shù)器最后把數(shù)據(jù)搬進(jìn) FIFOvoid UART_Idle_IRQHandler(UART_HandleTypeDef *huart, ring_fifo_t *fifo, uint8_t *dma_buf, uint16_t dma_size) { uint16_t dma_index; if (__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE) RESET) { return; } __HAL_UART_CLEAR_FLAG(huart, UART_FLAG_IDLE); /* 關(guān)鍵先停 DMA再讀計(jì)數(shù)器否則數(shù)據(jù)還在寫入 */ __HAL_DMA_DISABLE(huart-hdmarx); /* 當(dāng)前 DMA 已搬了多少字節(jié) */ dma_index dma_size - (uint16_t)__HAL_DMA_GET_COUNTER(huart-hdmarx); for (uint16_t i 0; i dma_index; i) { ring_fifo_push(fifo, dma_buf[i]); } /* 清空緩沖區(qū)重新啟動(dòng)下一輪接收 */ memset(dma_buf, 0, dma_size); HAL_UART_Receive_DMA(huart, dma_buf, dma_size); }一定不要把 __HAL_DMA_DISABLE 放到讀取 CNDTR 之后。DMA 在 IDLE 中斷入口處可能還在響應(yīng)最后一次 RXNE 請(qǐng)求如果先讀計(jì)數(shù)器再停 DMA讀出的值可能偏大或偏小導(dǎo)致搬運(yùn)錯(cuò)位。先停再讀雖然多花幾個(gè)周期但數(shù)據(jù)是穩(wěn)定的。還有一個(gè)細(xì)節(jié)HAL_UART_Receive_DMA 在 DMA 停掉之后調(diào)用需要重新設(shè)置傳輸長(zhǎng)度。如果上一步的 DMA 已經(jīng)搬完 512 字節(jié)且沒有觸發(fā) IDLE這里的 HAL_UART_Receive_DMA 會(huì)因?yàn)?huart-RxState 仍為 BUSY 而返回錯(cuò)誤。所以建議在調(diào)用前把 huart-RxState 強(qiáng)制復(fù)位或者直接復(fù)用底層 API 重新配置。4.3 幀邊界3.5 字符靜默靠 IDLE 還是靠定時(shí)器IDLE 中斷在大多數(shù)情況下夠用但遇到“主機(jī)字節(jié)間有微小停頓”時(shí)會(huì)誤判。比如 PC 串口工具做主站發(fā)送緩沖區(qū)刷新不及時(shí)幀內(nèi)兩個(gè)字節(jié)之間出現(xiàn)超過一個(gè)字符時(shí)間的空閑IDLE 就會(huì)提前觸發(fā)。低速 9600 波特率時(shí)3.5 個(gè)字符約 3.6ms遠(yuǎn)大于一個(gè)字符時(shí)間IDLE 反而不容易誤判高速 115200 時(shí)一個(gè)字符約 87μs如果主站軟件在字節(jié)間停頓超過 87μsIDLE 就會(huì)把一幀拆成兩幀。穩(wěn)妥做法是加一個(gè)定時(shí)器輔助判定。每個(gè)字節(jié)進(jìn)入 FIFO 時(shí)記錄時(shí)間應(yīng)用層定期檢查如果當(dāng)前時(shí)間與最后字節(jié)時(shí)間差超過 3.5 字符時(shí)間就認(rèn)為幀結(jié)束把 FIFO 里剩余數(shù)據(jù)當(dāng)作一幀處理#define MB_35C_TICK(baud) ((uint32_t)(35000000UL / (uint32_t)(baud))) static uint32_t last_rx_tick[2]; void Modbus_Frame_Check_Timer(void) { uint32_t now HAL_GetTick(); if ((now - last_rx_tick[0]) MB_35C_TICK(9600) ring_fifo_count(fifo_uart1) 0) { modbus_frame_dispatch(fifo_uart1); } if ((now - last_rx_tick[1]) MB_35C_TICK(9600) ring_fifo_count(fifo_uart2) 0) { modbus_frame_dispatch(fifo_uart2); } }這套資源里保留了兩種邊界判定默認(rèn)走 IDLE注釋里留有定時(shí)器分支。我自己的習(xí)慣是波特率大于 57600 時(shí)用 IDLE低于 19200 時(shí)用定時(shí)器。因?yàn)楦咚贂r(shí)字節(jié)間隔短IDLE 觸發(fā)更靈敏低速時(shí) 3.5 字符時(shí)間足夠長(zhǎng)定時(shí)器判定更符合協(xié)議定義。4.4 FIFO 容量選多大、溢出怎么辦不同波特率下推薦容量參考波特率推薦 FIFO 大小理由9600128幀間隔長(zhǎng)應(yīng)用層來得及處理38400256一幀最長(zhǎng) 256留一幀余量115200512雙串口同時(shí)到達(dá)時(shí)防積壓9216001024DMA 連續(xù)搬運(yùn)量更大溢出時(shí)我傾向保留舊數(shù)據(jù)、丟棄新字節(jié)。原因是 Modbus 從站本身不負(fù)責(zé)重傳主站超時(shí)后會(huì)重發(fā)完整幀丟掉新字節(jié)最多導(dǎo)致當(dāng)前幀解析失敗保留舊數(shù)據(jù)能保證之前已經(jīng)對(duì)齊的幀不受影響。每個(gè) FIFO 里維護(hù)一個(gè)溢出計(jì)數(shù)方便調(diào)試時(shí)觀察是否經(jīng)常超出容量。5. 雙串口避坑記錄五個(gè)我親眼見過的坑5.1 IDLE 在長(zhǎng)幀中間觸發(fā)把一幀拆成兩幀現(xiàn)象主站一次讀 20 個(gè)保持寄存器從站卻解析出兩幀一幀 CRC 錯(cuò)誤另一幀長(zhǎng)度不對(duì)。原因主站是 PC 串口調(diào)試工具發(fā)送緩沖刷新不及時(shí)幀內(nèi)字節(jié)之間出現(xiàn)了超過一個(gè)字符時(shí)間的空閑。UART 的 IDLE 標(biāo)志在“線空閑滿一個(gè)字符時(shí)間”時(shí)就會(huì)置位協(xié)議??吹?IDLE 就認(rèn)為幀結(jié)束。解決不要只依賴 IDLE。把第四章的定時(shí)器輔助判定打開或者把主站側(cè)換成帶連續(xù)發(fā)送模式的設(shè)備。排查時(shí)先用示波器抓總線波形看字節(jié)之間是否有超過一個(gè)字符寬度的空閑段。5.2 DMA 計(jì)數(shù)器還沒讀就被下一次接收改寫現(xiàn)象IDLE 中斷里讀 CNDTR 得到 10實(shí)際 DMA 緩沖區(qū)里只有 6 個(gè)字節(jié)是有效的幀頭被吞掉。原因IDLE 中斷響應(yīng)有延遲進(jìn)入 ISR 時(shí) DMA 尚未完全停住UART 的 RXNE 又觸發(fā)了一次 DMA 請(qǐng)求CNDTR 繼續(xù)遞減。解決ISR 第一行必須先 __HAL_DMA_DISABLE再讀 __HAL_DMA_GET_COUNTER。同時(shí)把該 DMA 通道的半傳輸中斷關(guān)掉避免 HT 中斷和 IDLE 中斷同時(shí)操作 CNDTR造成競(jìng)態(tài)。5.3 復(fù)位后第一個(gè)字節(jié)被當(dāng)成幀頭整條總線判錯(cuò)現(xiàn)象設(shè)備上電后主站第一次讀數(shù)據(jù)返回的幀總是錯(cuò)一個(gè)字節(jié)之后恢復(fù)正常。原因DMA 緩沖區(qū)里殘留上電默認(rèn)值 0xFF復(fù)位后 DMA 在應(yīng)用層初始化之前就開始搬運(yùn)了緩沖區(qū)里混進(jìn)了無效字節(jié)。協(xié)議棧把這些 0xFF 當(dāng)成幀頭解析自然出錯(cuò)。解決初始化 DMA 之前先 memset 接收緩沖區(qū)為 0x00再把 FIFO 清空最后啟動(dòng) HAL_UART_Receive_DMA。另外確認(rèn)復(fù)位后 UART 的 RXNE 不會(huì)在 DE 拉低之前觸發(fā)數(shù)據(jù)讀取。5.4 兩個(gè)串口的 DE 控制互相干擾兩條 RS-485 總線上打架現(xiàn)象1 號(hào)總線讀數(shù)據(jù)時(shí)2 號(hào)總線上的電平抖動(dòng)從站響應(yīng)幀出現(xiàn)移位和損壞。原因兩個(gè)串口共用了同一個(gè)方向控制引腳或者驅(qū)動(dòng)板上的收發(fā)器方向控制信號(hào)是反邏輯的。代碼里把 DE 拉高時(shí)兩條總線的 DE 同時(shí)被拉高兩個(gè)收發(fā)器同時(shí)驅(qū)動(dòng)總線。解決確認(rèn)兩路 RS-485 使用獨(dú)立的 DE 引腳如果硬件已經(jīng)共用只能把兩路收發(fā)器的方向控制接到不同的 GPIO并在代碼里分別控制。調(diào)試時(shí)用萬用表量?jī)蓚€(gè) DE 引腳的電平能立刻發(fā)現(xiàn)問題。5.5 發(fā)送 DE 關(guān)早了響應(yīng)幀最后一個(gè)字節(jié)被截?cái)喱F(xiàn)象從站自檢認(rèn)為已發(fā)送完畢主站收到的響應(yīng)幀長(zhǎng)度少一字節(jié)CRC 校驗(yàn)不過。原因TC 中斷觸發(fā)條件是 TDR 已空但不代表移位寄存器已空。此時(shí)最后一位可能還在線上立刻拉低 DE 會(huì)把停止位截掉。解決TC 中斷里做短延時(shí)再拉低 DE或者等 TXE 和 TC 兩個(gè)標(biāo)志都成立再加半個(gè)字符時(shí)間的延時(shí)。按波特率 115200 計(jì)算延遲 50μs 就足夠覆蓋最后一位輸出。6. 進(jìn)階把 FIFO 統(tǒng)計(jì)狀態(tài)放進(jìn) Modbus 寄存器當(dāng)驗(yàn)收指標(biāo)6.1 用保持寄存器開放內(nèi)部狀態(tài)雙串口從站調(diào)試最頭疼的是“丟幀了但不知道怎么丟的”。我習(xí)慣在項(xiàng)目里加四個(gè)保持寄存器把 FIFO 溢出次數(shù)、CRC 錯(cuò)誤次數(shù)、DMA 恢復(fù)次數(shù)、IDLE 觸發(fā)次數(shù)映射到寄存器地址uint16_t stat_regs[4]; #define REG_FIFO_OVERRUN_CNT 0 /* FIFO 溢出次數(shù) */ #define REG_RX_CRC_ERR_CNT 1 /* CRC 校驗(yàn)失敗次數(shù) */ #define REG_DMA_RECOVER_CNT 2 /* DMA 重新啟動(dòng)次數(shù) */ #define REG_IDLE_CNT 3 /* IDLE 中斷次數(shù) */ uint16_t read_stat_reg(uint8_t reg_idx) { return stat_regs[reg_idx]; }把這組寄存器掛到 FreeModbus 的保持寄存器回調(diào)里主站通過標(biāo)準(zhǔn) 03H 功能碼就能讀到。驗(yàn)收時(shí)先讓主站持續(xù)輪詢一小時(shí)如果 FIFO 溢出計(jì)數(shù)不斷增長(zhǎng)說明應(yīng)用層消費(fèi)速度跟不上如果 IDLE 觸發(fā)次數(shù)遠(yuǎn)大于實(shí)際幀數(shù)量說明字節(jié)間空隙過大應(yīng)該改定時(shí)器邊界判定。6.2 雙串口互測(cè)的自檢技巧沒有兩條獨(dú)立總線時(shí)可以用一根杜邦線把 UART1 的 TX 和 UART2 的 RX 短接反過來也一樣。程序里定時(shí)從 UART1 發(fā)一條標(biāo)準(zhǔn)讀保持寄存器請(qǐng)求再由 UART2 的 FIFO 接收并回包驗(yàn)證兩路鏈路是否正常void Modbus_SelfTest_Tick(void) { uint8_t req[8] {0x01, 0x03, 0x00, 0x00, 0x00, 0x02, 0xC4, 0x0B}; UART1_SendBytes(req, 8); /* 發(fā)到 UART1 總線 */ HAL_Delay(20); if (ring_fifo_count(fifo_uart2) 0) { stat_regs[REG_RX_CRC_ERR_CNT]; } }這里 CRC 值是固定的所以測(cè)試幀不需要額外計(jì)算。如果 fifo_uart2 沒有收到任何數(shù)據(jù)說明 DMA 初始化或者 DE 方向控制有問題直接定位到對(duì)應(yīng)代碼。從那以后我每次做雙串口從站調(diào)試都強(qiáng)制把四個(gè)統(tǒng)計(jì)寄存器加進(jìn)去跑一晚上輪詢?cè)倏从?jì)數(shù)幀邊界和緩沖容量的問題基本都是這么暴露的。這套資源里的 DMA 停收順序和 FIFO 容量參數(shù)也是我當(dāng)時(shí)踩過這些坑之后改出來的希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取