:從能跑到能交付)
1. 從零散模塊到可交付系統(tǒng)V1封版到底在封什么做過嵌入式項目的人大概都有這種體會功能一個個調(diào)通了CAN能收發(fā)、Flash能讀寫、PI控制環(huán)跑起來也不震蕩但當你試圖把這堆東西打包成一個能交給別人、能復現(xiàn)、能迭代的版本時問題就全冒出來了。V1項目封裝與總結(jié)這件事本質(zhì)上不是寫文檔而是把一堆在我電腦上能跑的代碼變成換個人、換塊板子、隔三個月還能跑的工程資產(chǎn)。我這次封版的V1項目核心是一塊STM32主控板跑FreeRTOS做任務調(diào)度通過CAN總線跟外部節(jié)點通信用PI算法做閉環(huán)調(diào)節(jié)參數(shù)和日志落在片上Flash里。這套組合在工業(yè)控制、電機驅(qū)動、電源管理里非常典型也是很多基于STM32的畢業(yè)設計或產(chǎn)品原型的標準骨架。關鍵詞里出現(xiàn)的STM32、FreeRTOS、CAN、PI、Flash基本就是這類項目的五大件缺一個都構(gòu)不成完整閉環(huán)。這篇文章面向的是已經(jīng)把功能跑通、準備做版本收口的人。如果你還在糾結(jié)STM32芯片第一腳怎么確認、芯片包怎么裝那屬于更前置的階段但如果你手里已經(jīng)有一個能演示但不敢交付的項目那接下來的內(nèi)容應該能幫你少走不少彎路。我會把封版過程中真正卡人的地方拆開講——不是教科書式的項目總結(jié)模板而是我實際踩過的坑、做過的取舍以及那些當時覺得無所謂、后來被反復打臉的細節(jié)。先說一個反直覺的結(jié)論V1封版最耗時間的從來不是寫代碼而是確認代碼到底在什么條件下能跑。功能驗證只證明了某一條路徑通封版要證明的是所有預期路徑都不崩。這兩件事的工作量差著一個數(shù)量級。2. 封版前必須鎖死的五個技術(shù)基線2.1 為什么基線比功能更重要很多人封版時第一反應是功能都測過了直接打包。但功能測試是點狀的基線是面狀的。所謂基線就是那些一旦變動就會讓整個系統(tǒng)行為漂移的底層設定。V1階段如果不把它們寫死并記錄V2一上手就會陷入上次明明好的這次怎么不行的循環(huán)。我這次鎖定的基線有五項芯片型號與封裝、時鐘樹配置、FreeRTOS內(nèi)核版本與堆方案、CAN波特率與采樣點、Flash分區(qū)布局。這五項的共同特點是——它們不直接體現(xiàn)業(yè)務功能但任何一項變了上層代碼都可能需要跟著改。2.2 時鐘樹最容易被忽略的隱形依賴STM32的時鐘樹是典型的配一次就不想再碰的東西但它恰恰是封版時必須記錄的第一項。我遇到過的情況是調(diào)試階段為了圖方便把系統(tǒng)時鐘從72MHz臨時降到48MHz跑功能全正常封版時忘了改回去結(jié)果CAN波特率計算基于錯誤的時鐘通信時好時壞排查了兩天才定位到。正確的做法是在封版文檔里明確寫出外部晶振頻率、PLL倍頻分頻參數(shù)、各總線AHB/APB1/APB2的實際頻率。這些數(shù)字不是給自己看的是給未來那個想改個外設時鐘的人看的。下面是我項目里的實際配置記錄方式項目配置值說明外部晶振8MHz板載無源晶振PLL源HSE不使用HSI避免溫漂系統(tǒng)時鐘72MHzPLLM8, PLLN9, PLLP2APB136MHzCAN掛載在此總線APB272MHzSPI、USART掛載提示時鐘配置一旦封版任何外設的波特率、定時器周期計算都必須基于這張表不能憑記憶。2.3 FreeRTOS堆方案別等到堆棧溢出才后悔FreeRTOS的堆管理有heap_1到heap_5五種方案很多人隨手選heap_4就完事。封版時我建議明確記錄選的是哪種、堆總大小多少、各任務棧深度多少。原因很簡單FreeRTOS堆棧溢出檢測這個功能只有在配置了對應宏之后才會生效而它生效的前提是你知道每個任務實際用了多少棧。我這次用的是heap_4帶碎片合并堆總大小設為16KB五個任務的棧深度分別是CAN接收任務512字、PI計算任務256字、Flash寫入任務512字、日志任務384字、空閑任務128字。這些數(shù)字不是拍腦袋來的是用uxTaskGetStackHighWaterMark()實測后留了約30%余量。這里有個經(jīng)驗PI計算任務看著簡單但如果里面用了浮點運算且沒開FPU棧消耗會比預期大不少。我一開始給PI任務只留了128字跑起來偶爾HardFault查了半天才發(fā)現(xiàn)是棧溢出。后來加到256字才穩(wěn)。2.4 CAN波特率與采樣點通信穩(wěn)定的真正命門CAN總線能通不代表通信可靠。封版時我把CAN的配置細化到了位定時參數(shù)級別波特率500kbps、采樣點設在87.5%、同步跳轉(zhuǎn)寬度為1個時間份額。為什么是87.5%而不是常見的75%因為我的總線長度約20米、節(jié)點數(shù)4個在500kbps下這個采樣點能獲得更好的容錯余量。CAN協(xié)議報文解析里最容易被忽略的是錯誤幀處理。V1階段我建議至少實現(xiàn)錯誤計數(shù)器讀取、總線關閉Bus-Off后的自動恢復、以及發(fā)送失敗的重傳上限。這些不實現(xiàn)現(xiàn)場一旦干擾大一點節(jié)點就假死了。2.5 Flash分區(qū)給未來留出余地片上Flash不是無限大的封版時必須把分區(qū)定下來。我的方案是前64KB給Bootloader和參數(shù)區(qū)中間192KB給應用程序最后16KB給日志區(qū)。參數(shù)區(qū)用雙備份CRC校驗日志區(qū)用環(huán)形寫入。這樣即使應用程序升級失敗參數(shù)和日志也不會丟。Flash原理上要注意的是STM32的Flash寫入前必須先擦除且擦除粒度是頁通常1KB或2KB。日志區(qū)如果頻繁寫入一定要做磨損均衡否則某幾頁很快就會被寫壞。我用的策略是日志按頁輪轉(zhuǎn)寫滿一頁換下一頁全部寫滿后從頭覆蓋。3. 任務劃分與調(diào)度FreeRTOS不是萬能藥3.1 任務粒度粗了卡頓細了開銷大FreeRTOS項目最容易犯的錯是把任務切得太碎。我見過有人給每個外設都開一個任務結(jié)果任務切換開銷比實際業(yè)務還大。V1封版時我重新梳理了任務劃分最終收斂到五個任務劃分依據(jù)是功能內(nèi)聚實時性要求。CAN接收任務優(yōu)先級最高因為它要保證報文不丟PI計算任務次之因為它有固定的控制周期Flash寫入和日志任務優(yōu)先級較低可以容忍延遲空閑任務兜底。這個優(yōu)先級順序不是隨便定的是基于誰錯過了截止時間后果最嚴重來排的。3.2 任務間通信隊列還是信號量任務間通信方式的選擇直接影響系統(tǒng)穩(wěn)定性。我的原則是傳數(shù)據(jù)用隊列傳狀態(tài)用信號量或任務通知。CAN接收任務收到報文后通過隊列把數(shù)據(jù)傳給PI計算任務Flash寫入任務完成擦寫后通過任務通知告訴日志任務可以繼續(xù)。這里有個坑隊列的深度要留夠。我一開始CAN接收隊列只設了4個深度結(jié)果總線突發(fā)流量時隊列滿報文被丟棄。后來加到16個深度才夠用。隊列深度不夠的表現(xiàn)很隱蔽——不報錯就是偶爾丟數(shù)據(jù)。3.3 堆棧溢出檢測封版前必須打開FreeRTOS提供了兩種堆棧溢出檢測方式一種是檢測任務切換時棧指針是否越界另一種是填充魔數(shù)后檢查是否被覆蓋。我兩種都開了雖然會略微增加開銷但封版階段穩(wěn)定性優(yōu)先。具體配置是在FreeRTOSConfig.h里把configCHECK_FOR_STACK_OVERFLOW設為2然后實現(xiàn)vApplicationStackOverflowHook()回調(diào)在里面記錄出錯的任務名并復位。這樣一旦有任務棧溢出系統(tǒng)不會莫名其妙跑飛而是留下線索后重啟。注意堆棧溢出檢測本身不能防止溢出只能發(fā)現(xiàn)溢出。真正的解決辦法還是給足棧空間。3.4 空閑任務與鉤子函數(shù)別浪費這個資源空閑任務優(yōu)先級最低但它的鉤子函數(shù)vApplicationIdleHook()是個好東西。我在里面做了兩件事一是喂看門狗二是統(tǒng)計CPU空閑率。喂狗放在空閑任務里意味著只要系統(tǒng)還在正常調(diào)度狗就不會被餓死如果某個任務卡死導致空閑任務跑不到狗就會復位系統(tǒng)。這比單獨開一個喂狗任務更省資源。CPU空閑率的統(tǒng)計方法是在空閑鉤子里對一個計數(shù)器累加每隔一秒在日志任務里讀取并清零就能算出空閑占比。這個數(shù)據(jù)對判斷系統(tǒng)負載很有用封版時記錄下來V2加功能時就有參照。4. PI控制環(huán)的封裝從能跑到能調(diào)4.1 PI參數(shù)不是調(diào)出來就完事PI控制是這類項目的核心算法但很多人調(diào)出一組能用的參數(shù)就收工了。封版時我做了三件事參數(shù)結(jié)構(gòu)化、限幅處理、抗積分飽和。這三件事不做換一個工況參數(shù)就可能失效。參數(shù)結(jié)構(gòu)化是指把Kp、Ki、積分上限、輸出上限這些打包成一個結(jié)構(gòu)體而不是散落在代碼各處。這樣換參數(shù)時只改一個地方也方便存到Flash里。我的結(jié)構(gòu)體大概長這樣typedef struct { float Kp; float Ki; float integral; float integral_max; float output_max; float output_min; } PI_Controller;4.2 抗積分飽和不處理會出大問題積分飽和是PI控制里最經(jīng)典的坑。當輸出已經(jīng)達到上限但誤差還在累積時積分項會越積越大等誤差反向時積分項需要很長時間才能退回來表現(xiàn)為系統(tǒng)響應遲鈍甚至超調(diào)。解決辦法是積分限幅或者積分分離。我用的是積分限幅當輸出飽和時停止積分累加。代碼上就是在計算積分項之前判斷一下輸出是否已經(jīng)到限幅值。這個邏輯很簡單但不做的話在大階躍輸入下系統(tǒng)表現(xiàn)會很難看。4.3 控制周期與任務周期的關系PI計算任務的周期必須和控制周期一致而且這個周期要穩(wěn)定。FreeRTOS的vTaskDelay()是相對延時實際周期會有抖動。如果對周期精度要求高應該用vTaskDelayUntil()做絕對延時。我這次用的是后者控制周期1ms實測抖動在幾十微秒以內(nèi)對大多數(shù)應用夠了。如果控制周期要求更嚴就得考慮用硬件定時器觸發(fā)把PI計算放到中斷里。但中斷里做浮點運算要小心得確認FPU上下文保存是否正確。V1階段我為了簡單還是放在任務里用絕對延時保證周期。5. CAN通信的可靠性封裝5.1 報文解析要防御性編程CAN協(xié)議報文解析看起來簡單但現(xiàn)場數(shù)據(jù)千奇百怪。封版時我給解析函數(shù)加了完整的邊界檢查DLC長度校驗、數(shù)據(jù)范圍校驗、超時判斷。任何一項不通過就丟棄并計數(shù)而不是硬著頭皮解析。我見過因為沒做長度校驗一個異常報文導致數(shù)組越界的案例。CAN總線上什么都有可能發(fā)生防御性編程不是過度設計是基本要求。5.2 發(fā)送失敗的處理策略CAN發(fā)送不是100%成功的總線忙、仲裁失敗、錯誤狀態(tài)都會導致發(fā)送失敗。封版時我明確了策略發(fā)送失敗重試3次每次間隔由硬件自動重傳3次仍失敗則記錄錯誤并丟棄該報文。不無限重試避免任務卡死。這里要區(qū)分發(fā)送請求失敗和發(fā)送完成失敗。前者是郵箱滿后者是總線錯誤。兩者的處理方式不同代碼里要分開判斷。5.3 總線關閉的恢復CAN控制器進入Bus-Off狀態(tài)后必須等待128次11位隱性位才能恢復。如果不處理節(jié)點就永久離線了。我的做法是在CAN錯誤中斷里檢測Bus-Off標志然后啟動恢復流程先停止CAN延時后再重新初始化。這個過程要記錄日志方便現(xiàn)場排查。6. Flash存儲的封裝與磨損管理6.1 參數(shù)存儲雙備份加CRC參數(shù)存Flash最怕的是寫一半掉電導致參數(shù)區(qū)損壞。我的方案是雙備份A區(qū)和B區(qū)輪流寫每次寫入前先算CRC讀取時校驗CRC哪個區(qū)CRC對就用哪個。這樣即使一個區(qū)寫壞了另一個區(qū)還能用。寫入流程是先擦除備用區(qū)寫入新參數(shù)和CRC校驗通過后再更新當前有效區(qū)標志。這個標志本身也要存Flash且更新要原子化。6.2 日志存儲環(huán)形寫入與磨損均衡日志區(qū)用環(huán)形寫入寫滿一頁換下一頁。STM32的Flash擦寫壽命約1萬次如果日志寫入頻繁必須做磨損均衡。我的策略是日志不按時間順序?qū)懚前错撦嗈D(zhuǎn)每頁寫滿后擦除最舊的一頁再寫。這樣每頁的擦寫次數(shù)大致均勻。日志格式我用了定長記錄每條記錄包含時間戳、事件類型、數(shù)據(jù)。定長記錄的好處是讀取時可以直接定位不用遍歷。6.3 Flash操作的互斥Flash擦寫期間CPU會暫停STM32的Flash操作會阻塞總線如果此時有中斷需要執(zhí)行可能導致時序問題。封版時我把Flash操作放在低優(yōu)先級任務里且在擦寫前掛起其他任務對Flash的訪問。FreeRTOS里可以用互斥量保護Flash訪問但要注意互斥量不能在中斷里用。7. 封版文檔與版本管理7.1 封版文檔該寫什么封版文檔不是用戶手冊是給下一個接手的人包括三個月后的自己看的。我寫的內(nèi)容包括硬件配置清單、軟件版本號、編譯環(huán)境、燒錄方式、已知問題、測試記錄。其中已知問題最重要把那些暫時沒解決但不影響V1交付的問題列清楚避免V2重復踩坑。7.2 版本號與代碼管理版本號我用了三段式主版本.次版本.修訂號。V1.0.0表示第一個正式封版。代碼用Git管理封版時打TagTag信息里寫清楚這個版本對應的硬件版本和測試狀態(tài)。編譯環(huán)境也要記錄Keil版本、芯片包版本、FreeRTOS版本。這些信息不記錄換臺電腦可能就編譯不過。7.3 測試記錄的留存封版前的測試記錄要留存包括測試項、測試條件、測試結(jié)果、測試人。這不是形式主義是當V2出現(xiàn)回歸問題時能快速定位是不是V1就有的問題。我這次留了CAN通信、PI控制、Flash讀寫、FreeRTOS穩(wěn)定性四類測試記錄每類都有具體的測試數(shù)據(jù)和現(xiàn)象描述。8. 那些封版時才發(fā)現(xiàn)的坑8.1 編譯優(yōu)化等級變了行為也變了調(diào)試階段我用的優(yōu)化等級是-O0封版時為了減小體積改成-Os結(jié)果PI控制出現(xiàn)輕微震蕩。查了半天發(fā)現(xiàn)是浮點運算在-Os下被重排導致計算順序變化。解決辦法是把PI計算函數(shù)用__attribute__((optimize(O0)))單獨標記或者干脆接受體積大一點用-O0。這個坑很隱蔽因為代碼邏輯沒變只是編譯器行為變了。8.2 看門狗喂狗位置不對看門狗一開始我放在主循環(huán)里喂后來改成空閑任務鉤子。但空閑任務優(yōu)先級最低如果高優(yōu)先級任務一直占著CPU空閑任務跑不到狗就會復位。這其實是好事——說明系統(tǒng)真的卡了。但要注意如果某個任務正常執(zhí)行時間較長比如Flash擦除要確保它不會餓死空閑任務。我的做法是在長任務里主動讓出CPU。8.3 CAN終端電阻忘了接這個坑很基礎但很常見。調(diào)試時用短線通信正常封版后接長線通信不穩(wěn)定查了半天發(fā)現(xiàn)是終端電阻沒接。CAN總線兩端各需要一個120歐姆終端電阻不接的話信號反射會導致通信錯誤率上升。這個不是軟件問題但封版時要確認硬件也到位。8.4 Flash寫入時的中斷影響Flash擦寫期間如果發(fā)生中斷且中斷服務程序也在訪問Flash會導致沖突。STM32的Flash操作會暫停CPU取指如果中斷向量表在Flash里中斷響應會延遲。我的做法是Flash操作期間關中斷但關中斷時間不能太長否則影響CAN接收。折中方案是把Flash操作分片每次擦一小塊中間開中斷。9. 從V1到V2封版不是終點封版的意義在于給V2一個干凈的起點。V1封版后我列了一份V2的改進清單CAN協(xié)議棧增加診斷功能、PI參數(shù)支持在線整定、Flash日志增加導出接口、FreeRTOS增加低功耗模式。這些在V1階段就發(fā)現(xiàn)了需求但為了封版進度沒有做進去。V2啟動時直接從V1的Tag拉分支基線不用重新確認文檔不用重新寫測試用例可以復用。這就是封版的價值——它把一次性工作變成了可累積的資產(chǎn)。我個人在實際操作中的體會是封版最難的從來不是技術(shù)而是克制??吹酱a里有個小瑕疵就想改看到參數(shù)還能再優(yōu)化就想調(diào)但封版要求你停下來把當前狀態(tài)固化。那些想改的東西寫進V2清單就好。V1能交付比V1完美更重要。