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