:避坑指南)
簡介這是一份面向嵌入式開發(fā)者的STM32F407綜合實戰(zhàn)工程整合FreeRTOS實時操作系統(tǒng)與LWIP協(xié)議棧并完成文件系統(tǒng)移植適合已具備一定STM32基礎(chǔ)、希望打通網(wǎng)絡(luò)通信與多任務(wù)調(diào)度的進階學(xué)習(xí)者。工程以RMII接口搭配KSZ8031 PHY通過修改PHY寄存器地址即可適配其他型號PHY同時內(nèi)置嵌入式WebServer用于整體功能驗證另附IO、CAN、USART等外設(shè)測試程序可作為工業(yè)控制、物聯(lián)網(wǎng)終端等場景的參考模板。壓縮包共629個文件約17.25MB以155個h頭文件與131個c源文件為核心代碼輔以Keil工程配置、編譯中間文件及少量說明文檔結(jié)構(gòu)完整便于二次開發(fā)。目前已有1005人學(xué)習(xí)下載。讀者可從中獲得FreeRTOS與LWIP協(xié)同移植的完整思路、PHY驅(qū)動適配方法、FATFS文件系統(tǒng)接入流程以及WebServer測試框架并借助CAN、USART等例程快速驗證硬件是學(xué)習(xí)STM32F4網(wǎng)絡(luò)與RTOS綜合應(yīng)用的實用參考。1. 一塊 F407 上塞進七個中間件這套組合到底在解決什么問題如果你手頭有一塊 STM32F407 的開發(fā)板上面掛著 KSZ8031 以太網(wǎng) PHY、SD 卡槽、DS18B20 溫度傳感器、CAN 收發(fā)器還要跑 FreeRTOS 和 LwIP那這篇文章就是寫給你的。這套組合的典型場景是工業(yè)數(shù)據(jù)采集網(wǎng)關(guān)通過 18B20 采集現(xiàn)場溫度通過 CAN 總線讀取設(shè)備狀態(tài)通過 USART 對接上位機或調(diào)試終端通過 SD 卡做本地數(shù)據(jù)存儲通過以太網(wǎng)把數(shù)據(jù)推到遠端服務(wù)器所有任務(wù)由 FreeRTOS 調(diào)度。聽起來每個模塊單獨用都不難但把它們?nèi)M同一顆芯片、同一套 RTOS 里問題就來了——PHY 初始化不成功、LwIP 跑著跑著就斷、SD 卡和以太網(wǎng)搶總線、CAN 中斷優(yōu)先級和 RTOS 調(diào)度打架。我前后做過三版類似的板子第一版調(diào)了將近兩周才讓以太網(wǎng)穩(wěn)定收發(fā)第二版才把 SDIO 和 LwIP 的沖突理清楚。下面按“先跑通單個模塊再整合最后調(diào)穩(wěn)定性”的路徑把每一步的命令、參數(shù)和踩坑點講清楚。2. 從裸機到 FreeRTOS任務(wù)劃分與優(yōu)先級怎么定2.1 為什么不能把所有邏輯塞進一個任務(wù)很多人第一次用 FreeRTOS 會習(xí)慣性地創(chuàng)建一個任務(wù)然后在里面輪詢所有外設(shè)。這種做法在功能驗證階段能跑但一旦加上 LwIP 和 SD 卡寫入就會出現(xiàn)“以太網(wǎng)收包不及時導(dǎo)致丟包”或者“SD 卡寫入阻塞導(dǎo)致 CAN 報文溢出”的問題。正確的做法是按數(shù)據(jù)流和實時性要求拆任務(wù)。我一般會拆成這幾個任務(wù)以太網(wǎng)接收任務(wù)優(yōu)先級最高因為 LwIP 的ethernetif_input需要及時處理、CAN 接收任務(wù)次高CAN 報文有硬件緩沖區(qū)但溢出后不可恢復(fù)、溫度采集任務(wù)低頻1 秒一次即可、SD 卡寫入任務(wù)低優(yōu)先級但需要較大的棧、USART 命令解析任務(wù)中等優(yōu)先級響應(yīng)上位機指令。優(yōu)先級數(shù)值越大優(yōu)先級越高在FreeRTOSConfig.h里配置configMAX_PRIORITIES至少為 8。// 任務(wù)優(yōu)先級定義數(shù)值越大優(yōu)先級越高 #define TASK_PRIO_ETH_RX 6 // 以太網(wǎng)接收必須最高 #define TASK_PRIO_CAN_RX 5 // CAN 接收次高 #define TASK_PRIO_USART_CMD 4 // 串口命令解析 #define TASK_PRIO_TEMP 3 // 溫度采集 #define TASK_PRIO_SD_WRITE 2 // SD 卡寫入最低 // 任務(wù)棧大小單位是 word4 字節(jié) // LwIP 相關(guān)任務(wù)棧要 512 以上SD 卡寫入因為用了 FATFS 也要 512 #define TASK_STACK_ETH_RX 512 #define TASK_STACK_CAN_RX 256 #define TASK_STACK_USART 256 #define TASK_STACK_TEMP 256 #define TASK_STACK_SD_WRITE 512這里的關(guān)鍵參數(shù)是棧大小。LwIP 的tcpip_thread和ethernetif_input如果棧不夠會直接進 HardFault而且這種 HardFault 往往在運行幾分鐘后才出現(xiàn)調(diào)試起來很痛苦。我的經(jīng)驗是凡是調(diào)用 LwIP API 的任務(wù)棧至少 512 word凡是調(diào)用f_write的任務(wù)棧至少 512 word因為 FATFS 內(nèi)部有較深的調(diào)用鏈。2.2 中斷優(yōu)先級與 FreeRTOS 的配合STM32F407 的中斷優(yōu)先級分組必須設(shè)置為NVIC_PRIORITYGROUP_4即 4 位搶占優(yōu)先級、0 位子優(yōu)先級。FreeRTOS 的configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY一般設(shè)為 5意味著搶占優(yōu)先級高于 5 的中斷不能調(diào)用FromISR結(jié)尾的 API。以太網(wǎng)和 CAN 的中斷優(yōu)先級要設(shè)得比這個值低數(shù)值大否則xQueueSendFromISR會觸發(fā)斷言。// 在 main 里最先調(diào)用設(shè)置中斷優(yōu)先級分組 HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4); // 以太網(wǎng)中斷優(yōu)先級搶占優(yōu)先級設(shè)為 6允許調(diào)用 FromISR API HAL_NVIC_SetPriority(ETH_IRQn, 6, 0); HAL_NVIC_EnableIRQ(ETH_IRQn); // CAN 接收中斷優(yōu)先級設(shè)為 7 HAL_NVIC_SetPriority(CAN1_RX0_IRQn, 7, 0); HAL_NVIC_EnableIRQ(CAN1_RX0_IRQn);注意如果以太網(wǎng)中斷優(yōu)先級設(shè)成 4 或更高xQueueSendFromISR會直接觸發(fā)configASSERT死循環(huán)現(xiàn)象是程序卡在vPortValidateInterruptPriority里出不來。3. RMII 接口驅(qū)動 KSZ8031從 PHY 復(fù)位到 ping 通3.1 硬件連接檢查與 GPIO 配置RMII 模式下STM32F407 的 PA1 是 REF_CLKPA2 是 MDIOPA7 是 RMII_CRS_DVPC1 是 MDCPC4 是 RMII_RXD0PC5 是 RMII_RXD1PG11 是 RMII_TX_ENPG13 是 RMII_TXD0PG14 是 RMII_TXD1。KSZ8031 需要 50MHz 參考時鐘通常由 STM32 的 PA8 通過 MCO1 輸出或者由外部晶振提供。我用的板子是 PA8 輸出 50MHz 給 PHY。// 使能 GPIO 時鐘 __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_GPIOC_CLK_ENABLE(); __HAL_RCC_GPIOG_CLK_ENABLE(); // 配置 PA8 為 MCO1 輸出 50MHz GPIO_InitTypeDef gpio {0}; gpio.Pin GPIO_PIN_8; gpio.Mode GPIO_MODE_AF_PP; gpio.Pull GPIO_NOPULL; gpio.Speed GPIO_SPEED_FREQ_VERY_HIGH; gpio.Alternate GPIO_AF0_MCO; HAL_GPIO_Init(GPIOA, gpio); // 配置 MCO1 時鐘源為 HSE分頻系數(shù)根據(jù)晶振頻率計算 // 假設(shè) HSE 為 8MHz要輸出 50MHz 需要 PLL 配置這里用 PLLI2S 或直接旁路 // 更常見的做法是 PHY 用獨立 50MHz 晶振PA8 不輸出 HAL_RCC_MCOConfig(RCC_MCO1, RCC_MCO1SOURCE_HSE, RCC_MCODIV_1);如果 PHY 有獨立的 50MHz 晶振PA8 就不需要配置為 MCO。這一點在畫板子時就要確認否則調(diào)試時發(fā)現(xiàn) PHY 沒有時鐘會浪費很多時間。3.2 PHY 復(fù)位與 LwIP 初始化順序KSZ8031 的復(fù)位引腳通常接在某個 GPIO 上復(fù)位時序要求至少保持 10ms 低電平然后拉高等待 100ms 以上再訪問 PHY 寄存器。LwIP 的ethernetif_init里會調(diào)用LAN8720_Init類似的 PHY 初始化函數(shù)但 KSZ8031 的寄存器地址和 LAN8720 不同需要自己寫。// PHY 復(fù)位假設(shè)復(fù)位引腳接在 PD3 void KSZ8031_Reset(void) { HAL_GPIO_WritePin(GPIOD, GPIO_PIN_3, GPIO_PIN_RESET); HAL_Delay(20); // 至少 10ms留余量 HAL_GPIO_WritePin(GPIOD, GPIO_PIN_3, GPIO_PIN_SET); HAL_Delay(150); // 等待 PHY 內(nèi)部穩(wěn)定 } // 讀取 PHY IDKSZ8031 的 ID 應(yīng)該是 0x0022 開頭 uint32_t KSZ8031_GetID(void) { uint32_t id 0; id ETH_ReadPHYRegister(0, PHY_REG_ID1); id (id 16) | ETH_ReadPHYRegister(0, PHY_REG_ID2); return id; }LwIP 的初始化順序是lwip_init()→netif_add()→netif_set_default()→netif_set_up()。在netif_add時會調(diào)用ethernetif_init里面完成 PHY 復(fù)位和 MAC 配置。如果 PHY 沒復(fù)位成功netif_set_up之后ping不通但netif狀態(tài)顯示 up這種“假 up”現(xiàn)象很常見。3.3 驗證以太網(wǎng)通路的三個命令在 FreeRTOS 里跑 LwIP建議先用ping驗證鏈路層和網(wǎng)絡(luò)層。在 PC 端執(zhí)行# 假設(shè)板子 IP 設(shè)為 192.168.1.100 ping 192.168.1.100 -t # 如果 ping 不通先看 ARP 表有沒有板子的 MAC arp -a | findstr 192.168.1.100 # 用 Wireshark 抓包過濾條件 arp || icmp如果 ARP 請求發(fā)出去了但板子沒回應(yīng)說明以太網(wǎng)接收中斷沒進或者ethernetif_input沒被調(diào)用。檢查ETH_IRQHandler里是否調(diào)用了HAL_ETH_IRQHandler以及HAL_ETH_RxCpltCallback里是否給了信號量讓接收任務(wù)處理。4. SDIO 掛 FATFS 與 LwIP 共存總線沖突和 DMA 陷阱4.1 SDIO 初始化與 FATFS 掛載STM32F407 的 SDIO 外設(shè)支持 4 位寬總線但和以太網(wǎng)共用 DMA 控制器時會有沖突。SDIO 的時鐘分頻在SDIO_INIT_CLK_DIV里設(shè)置初始化階段不能超過 400kHz初始化完成后可以提高到 24MHz 或 48MHz。// SDIO 初始化注意 DMA 和中斷優(yōu)先級 void SDIO_Init(void) { hsd.Instance SDIO; hsd.Init.ClockEdge SDIO_CLOCK_EDGE_RISING; hsd.Init.ClockBypass SDIO_CLOCK_BYPASS_DISABLE; hsd.Init.ClockPowerSave SDIO_CLOCK_POWER_SAVE_DISABLE; hsd.Init.BusWide SDIO_BUS_WIDE_4B; // 4 位寬 hsd.Init.HardwareFlowControl SDIO_HARDWARE_FLOW_CONTROL_ENABLE; hsd.Init.ClockDiv 2; // 48MHz / (22) 12MHz穩(wěn)定后可改小 HAL_SD_Init(hsd); } // FATFS 掛載 FATFS fs; FRESULT fres; fres f_mount(fs, 0:, 1); // 1 表示立即掛載 if (fres ! FR_OK) { printf(f_mount failed: %d\r\n, fres); }f_mount的第三個參數(shù)為 1 時會立即調(diào)用disk_initialize如果 SD 卡沒插好或者 SDIO 初始化失敗會返回FR_NOT_READY或FR_DISK_ERR。4.2 SDIO 和以太網(wǎng)共用 DMA 的沖突處理STM32F407 的 DMA1 和 DMA2 各有多個流。以太網(wǎng) MAC 使用 DMA 自己的專用通道不占用 DMA1/DMA2 的流但 SDIO 使用 DMA2 的某個流。如果 LwIP 的memcpy和 SDIO 的 DMA 同時訪問總線矩陣會出現(xiàn)偶發(fā)的數(shù)據(jù)錯誤。我的做法是SD 卡寫入任務(wù)在調(diào)用f_write前獲取一個互斥量確保同一時間只有一個任務(wù)在操作 SDIO。// SD 卡操作互斥量 SemaphoreHandle_t xSDMutex; // 寫入任務(wù)里 if (xSemaphoreTake(xSDMutex, pdMS_TO_TICKS(1000)) pdTRUE) { fres f_open(fil, 0:/data.csv, FA_OPEN_ALWAYS | FA_WRITE); if (fres FR_OK) { f_lseek(fil, f_size(fil)); // 追加到末尾 f_printf(fil, %lu,%d.%d\r\n, tick, temp_int, temp_dec); f_close(fil); } xSemaphoreGive(xSDMutex); } else { // 獲取互斥量超時記錄錯誤 }注意f_printf在 FATFS 里默認可能沒使能需要在ffconf.h里把_FS_READONLY設(shè)為 0_USE_STRFUNC設(shè)為 1 或 2。4.3 掉電保護和文件系統(tǒng)一致性工業(yè)現(xiàn)場經(jīng)常直接斷電FATFS 在寫入過程中斷電會導(dǎo)致文件系統(tǒng)損壞。我一般會定期調(diào)用f_sync并且在寫入完成后關(guān)閉文件。如果對可靠性要求高可以加一個超級電容或者鐵電存儲器做掉電檢測檢測到掉電后立即f_sync并關(guān)閉文件。// 掉電檢測中斷優(yōu)先級設(shè)為最高 void PVD_IRQHandler(void) { if (__HAL_PWR_GET_FLAG(PWR_FLAG_PVDO)) { // 立即同步文件系統(tǒng) f_sync(fil); f_close(fil); // 停止所有寫入任務(wù) vTaskSuspend(xSDWriteTaskHandle); } }5. 18B20、CAN、USART 的整合時序、過濾器和中斷5.1 18B20 的微秒級時序與 RTOS 調(diào)度沖突DS18B20 是單總線器件復(fù)位脈沖要求拉低至少 480us讀寫時隙在 60us 左右。在 FreeRTOS 里如果用vTaskDelay做延時最小精度是 1 個 tick通常 1ms完全不夠。必須用DWT或者TIM做微秒級延時。// 用 DWT 做微秒延時 void DWT_Delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t cycles us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) cycles); } // 18B20 復(fù)位 uint8_t DS18B20_Reset(void) { uint8_t presence; GPIO_InitTypeDef gpio {0}; gpio.Pin GPIO_PIN_0; gpio.Mode GPIO_MODE_OUTPUT_OD; gpio.Pull GPIO_NOPULL; gpio.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, gpio); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); DWT_Delay_us(500); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET); DWT_Delay_us(70); presence HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0); DWT_Delay_us(430); return presence; }在讀取溫度時要關(guān)中斷或者用互斥量保護整個時序否則一個中斷進來就會打亂時隙。我的做法是把 18B20 的讀取放在一個低優(yōu)先級任務(wù)里讀取前taskENTER_CRITICAL()讀完后taskEXIT_CRITICAL()臨界區(qū)大約 1ms對系統(tǒng)實時性影響可接受。5.2 CAN 過濾器配置與接收隊列STM32F407 的 CAN1 有 28 個過濾器組可以配置為掩碼模式或列表模式。工業(yè)場景下通常只接收特定 ID 的報文用掩碼模式更靈活。// CAN 過濾器配置接收 ID 為 0x100~0x1FF 的標準幀 CAN_FilterTypeDef filter {0}; filter.FilterBank 0; filter.FilterMode CAN_FILTERMODE_IDMASK; filter.FilterScale CAN_FILTERSCALE_32BIT; filter.FilterIdHigh 0x100 5; // 標準幀 ID 左移 5 位 filter.FilterIdLow 0; filter.FilterMaskIdHigh 0x700 5; // 掩碼0x700 表示高 3 位必須匹配 filter.FilterMaskIdLow 0; filter.FilterFIFOAssignment CAN_RX_FIFO0; filter.FilterActivation ENABLE; HAL_CAN_ConfigFilter(hcan1, filter); // 啟動 CAN HAL_CAN_Start(hcan1); HAL_CAN_ActivateNotification(hcan1, CAN_IT_RX_FIFO0_MSG_PENDING);在HAL_CAN_RxFifo0MsgPendingCallback里用xQueueSendFromISR把報文發(fā)給接收任務(wù)不要在中斷里做解析。5.3 USART 命令解析與 DMA 接收USART 用 DMA 接收不定長數(shù)據(jù)時常用 IDLE 中斷加 DMA 的方式。配置 DMA 為循環(huán)模式在 IDLE 中斷里計算接收長度。// USART1 DMA 接收配置 uint8_t uart_rx_buf[256]; void USART1_Init(void) { // ... 省略 GPIO 和 USART 配置 HAL_UART_Receive_DMA(huart1, uart_rx_buf, sizeof(uart_rx_buf)); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); } // IDLE 中斷處理 void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint32_t len sizeof(uart_rx_buf) - __HAL_DMA_GET_COUNTER(huart1.hdmarx); // 把數(shù)據(jù)發(fā)給命令解析任務(wù) BaseType_t xHigherPriorityTaskWoken pdFALSE; xQueueSendFromISR(xUartQueue, len, xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } HAL_UART_IRQHandler(huart1); }6. 避坑與排查這套組合最容易翻車的五個地方6.1 以太網(wǎng) ping 通但 TCP 連接不上現(xiàn)象PC 能 ping 通板子但用 TCP 調(diào)試工具連接板子的 8080 端口一直失敗。原因通常是 LwIP 的tcpip_thread棧溢出或者MEMP_NUM_TCP_PCB設(shè)得太小。解決把lwipopts.h里的MEMP_NUM_TCP_PCB從默認的 5 改成 10TCPIP_THREAD_STACKSIZE從 1024 改成 2048。6.2 SD 卡寫入一段時間后返回 FR_DISK_ERR現(xiàn)象系統(tǒng)運行幾分鐘后f_write開始返回FR_DISK_ERR重啟后恢復(fù)。原因是 SDIO 的 DMA 和以太網(wǎng) DMA 在總線矩陣上沖突導(dǎo)致 SDIO 數(shù)據(jù)傳輸錯誤。解決降低 SDIO 時鐘到 12MHz或者在sdio.c里把 SDIO 的 DMA 優(yōu)先級設(shè)為最高并在f_write前后加SCB_CleanDCache_by_Addr如果開了 D-Cache。6.3 CAN 接收一段時間后不再進中斷現(xiàn)象CAN 正常接收幾分鐘后HAL_CAN_RxFifo0MsgPendingCallback不再觸發(fā)。原因是 CAN 的接收 FIFO 溢出后沒有正確清除標志位。解決在回調(diào)里先讀CAN_RI0R寄存器再調(diào)用HAL_CAN_GetRxMessage確保 FIFO 釋放。另外檢查CAN_IT_RX_FIFO0_OVERRUN是否使能溢出時要有處理。6.4 18B20 讀數(shù)偶爾為 85 或 -127現(xiàn)象溫度值偶爾跳變成 85 或 -127。85 是 DS18B20 的上電默認值說明復(fù)位或轉(zhuǎn)換命令沒發(fā)成功-127 是通信錯誤。原因是讀取時序被中斷打斷。解決把 18B20 讀取放在臨界區(qū)里或者用硬件定時器觸發(fā)讀取避免任務(wù)切換。6.5 系統(tǒng)運行幾小時后 HardFault現(xiàn)象系統(tǒng)運行幾小時后進入 HardFault看門狗復(fù)位。原因通常是某個任務(wù)的棧溢出最常見的是 LwIP 的ethernetif_input任務(wù)或者 SD 卡寫入任務(wù)。解決在FreeRTOSConfig.h里使能configCHECK_FOR_STACK_OVERFLOW為 2并在vApplicationStackOverflowHook里打印任務(wù)名。把 LwIP 相關(guān)任務(wù)棧加到 1024 wordSD 卡寫入任務(wù)棧加到 768 word。7. 用 FreeRTOS 的運行時統(tǒng)計和 LwIP 的統(tǒng)計計數(shù)器做長期穩(wěn)定性驗證這套系統(tǒng)跑通不難難的是連續(xù)跑 72 小時不出問題。我一般會在系統(tǒng)里加兩個統(tǒng)計FreeRTOS 的vTaskGetRunTimeStats和 LwIP 的stats_display。前者看每個任務(wù)的 CPU 占用后者看丟包和內(nèi)存錯誤。// 使能 FreeRTOS 運行時統(tǒng)計 // 在 FreeRTOSConfig.h 里 #define configGENERATE_RUN_TIME_STATS 1 #define configUSE_STATS_FORMATTING_FUNCTIONS 1 #define portCONFIGURE_TIMER_FOR_RUN_TIME_STATS() // 用 TIM6 做統(tǒng)計時鐘 #define portGET_RUN_TIME_COUNTER_VALUE() TIM6-CNT // 在某個低優(yōu)先級任務(wù)里定期打印 void vStatsTask(void *pvParameters) { char buf[512]; while (1) { vTaskGetRunTimeStats(buf); printf( Task Stats \r\n%s\r\n, buf); stats_display(); // LwIP 統(tǒng)計 vTaskDelay(pdMS_TO_TICKS(60000)); // 每分鐘一次 } }vTaskGetRunTimeStats輸出的格式是“任務(wù)名 運行時間 百分比”如果某個任務(wù)占用超過 80%說明它有阻塞或死循環(huán)。LwIP 的stats_display會打印mem、memp、pbuf等統(tǒng)計如果memerr或pbuferr持續(xù)增長說明內(nèi)存池不夠或者有泄漏。我自己的習(xí)慣是在實驗室里用可調(diào)電源給板子供電串口接一個日志工具連續(xù)跑 72 小時每 10 分鐘記錄一次任務(wù)棧剩余和 LwIP 錯誤計數(shù)。如果 72 小時后xPortGetFreeHeapSize沒有明顯下降stats_display里的錯誤計數(shù)為 0這套系統(tǒng)才算能拿去現(xiàn)場用?,F(xiàn)場部署時再加一個硬件看門狗喂狗邏輯放在優(yōu)先級最低的任務(wù)里確保所有高優(yōu)先級任務(wù)都正常運行時才喂狗。希望幫到你。本文還有配套的精品資源點擊獲取