重編譯)
干過單片機開發(fā)的朋友八成都有過這種經(jīng)歷程序邏輯寫好了功能也調(diào)通了但每次改個運行參數(shù)都得把變量硬編碼進固件重新編譯、燒錄、斷電、上電。如果只是調(diào)個PID系數(shù)還好就怕現(xiàn)場工況一變客戶張口就是“把那個啟動延時改長一點”你只能默默掏出下載器。這活兒干多了真的會懷疑人生。我這次做的這個項目恰好也卡在了這個節(jié)點上。固件核心邏輯已經(jīng)跑穩(wěn)了但離“能用”還差一層窗戶紙——沒有任何本地交互手段。我把它叫做人機界面說白了就是讓固件自己長出一塊“臉”通過顯示屏加按鍵直接查看狀態(tài)、修改參數(shù)、保存配置徹底告別“改一次參數(shù)編譯一次”的原始狀態(tài)。這一期分享的就是我在第7階段做的一件小事在固件里搭了一套極其精簡的菜單交互系統(tǒng)用最笨、最穩(wěn)、最不依賴花哨框架的方式讓設(shè)備有了“可操作感”。這篇文章不挑具體芯片平臺思路通了換個MCU一樣能用。1. 內(nèi)容整體設(shè)計與思路拆解1.1 先搞清楚一件事為什么要做“本地人機界面”在做這個界面之前我的調(diào)試手段其實非常原始串口打印。程序里到處塞printf運行起來接一根USB轉(zhuǎn)TTL線在電腦上開個串口助手靠肉眼看日志猜問題。改參數(shù)就更痛苦了只能是改源碼里的宏定義然后重新編譯燒錄。這種模式的痛點做過的都知道調(diào)試效率低每次改參數(shù)編譯加燒錄最少幾十秒一天改幾十次時間全浪費了?,F(xiàn)場無法操作設(shè)備一旦交給別人用對方不懂代碼遇到需要調(diào)整參數(shù)的情況只能干瞪眼。狀態(tài)不可見運行中到底發(fā)生了什么內(nèi)部狀態(tài)值是多少除了靠串口日志事后分析沒有任何直觀手段。沒有“產(chǎn)品感”一個設(shè)備連個屏幕都沒有按鍵都沒有說難聽點就是個半成品開發(fā)板。所以當(dāng)我決定給固件“長出人機界面”時核心目標非常明確在本地完成參數(shù)查看和參數(shù)整定不依賴電腦不需要重新編譯固件。1.2 方案選型為什么我放棄“復(fù)雜方案”在動手之前我其實糾結(jié)過幾種方案方案A串口命令行交互。通過串口收發(fā)ASCII命令來修改參數(shù)。好處是實現(xiàn)簡單壞處是依然要依賴電腦對使用者不友好而且沒有屏幕反饋操作不直觀。方案B手機藍牙APP配網(wǎng)/調(diào)參。聽起來很現(xiàn)代但需要寫APP或小程序還要處理協(xié)議、配對、兼容性周期太長對于一個小工具性質(zhì)的設(shè)備來說完全是殺雞用牛刀。方案C本地屏按鍵菜單。就是我最終選的路。雖然要寫菜單邏輯但一旦框架搭好后續(xù)擴展非常方便而且任何用戶拿到設(shè)備只看屏幕就能操作這是最接近“產(chǎn)品”的形態(tài)。我最后選擇方案C邏輯也很簡單這個項目的核心是固件本身不是通信協(xié)議也不是APP開發(fā)。人機界面應(yīng)該是固件能力的延伸而不是一個獨立的大工程。所以我把目標定在“夠用、好改、不折騰”上。1.3 硬件選型考慮不引入具體型號為了把方案落地我對手頭的硬件資源做了評估。默認情況下我傾向于選用顯示設(shè)備一個小尺寸的字符型液晶屏常見的有160216列2行或者200420列4行。我最終選擇的是20列4行因為能顯示的信息量更大一行標題兩行內(nèi)容一行提示剛剛好。輸入設(shè)備獨立按鍵通常復(fù)用GPIO檢測。這里有一個非常重要的經(jīng)驗人機界面至少需要三個按鍵才順手分別是“確認/進入”、“返回/退出”和“切換/加減”。如果資源緊張至少也要保留兩個。這些硬件選型思路是“基于常見實踐的補充”。因為項目的核心不是具體硬件型號而是交互邏輯與固件架構(gòu)之間的配合所以我更看重框架的通用性。1.4 人機界面的“靈魂”菜單樹設(shè)計在動筆寫代碼之前我花了很長時間畫了一張“菜單樹”。這是整個界面體系的靈魂后面所有固件邏輯都是在為這棵樹服務(wù)。我把它抽象成三層結(jié)構(gòu)主界面默認顯示設(shè)備當(dāng)前運行狀態(tài)總覽比如運行時間、當(dāng)前溫度/速度/電壓、告警信息。參數(shù)菜單可修改的整定參數(shù)列表比如最大速度、啟動延時、PID的Kp/Ki/Kd等。系統(tǒng)菜單保存設(shè)置、恢復(fù)默認、關(guān)于信息。整棵菜單樹的組織原則只有一條路徑最短層級最淺。能用兩層解決的絕不設(shè)計成三層。因為按鍵操作本身就比較笨拙不能讓用戶為了改一個參數(shù)翻好幾層菜單那是反人類設(shè)計。2. 核心細節(jié)解析與實操要點2.1 頁面即狀態(tài)用“頁面ID”管理所有界面很多初學(xué)者寫界面會把“顯示邏輯”和“業(yè)務(wù)邏輯”揉在一起結(jié)果就是代碼里全是if (page 1) ... else if (page 2) ...改一個頁面恨不得牽動全身。我的做法完全不同把每一個界面當(dāng)作一個獨立的“狀態(tài)”用一個全局的頁面ID變量來標記當(dāng)前處于哪個頁面。整個界面系統(tǒng)就是一個狀態(tài)機按鍵操作只是觸發(fā)狀態(tài)跳轉(zhuǎn)的事件。這樣做的好處是每個頁面的顯示、刷新、退出都集中在自己的分支里互不干擾代碼結(jié)構(gòu)非常清晰。具體實施時我定義了一個枚舉類型把所有的頁面ID列出來然后在主循環(huán)里按當(dāng)前頁面ID去執(zhí)行對應(yīng)的顯示和按鍵處理函數(shù)。這套模式我實測下來非常穩(wěn)后續(xù)加新頁面只是“加一個枚舉值加一個case分支”的事情完全不用動其他頁面。2.2 屏幕刷新策略不要無腦全刷屏幕刷新是一個極其容易被忽視、卻極其影響體驗的細節(jié)。直接整屏覆蓋刷新字符屏還好如果是點陣屏?xí)吹矫黠@的閃爍和拖影。我的策略是“按需刷新哪里變了刷哪里”。具體操作上我把每個頁面需要顯示的“內(nèi)容塊”拆開比如標題區(qū)、數(shù)據(jù)區(qū)、提示區(qū)。每次進入頁面時全刷一次之后只有當(dāng)對應(yīng)數(shù)據(jù)變化時才更新對應(yīng)區(qū)域的字符。比如溫度值變了只更新那一行的幾個字符標題和提示完全不動。這背后還有一個更底層的考慮把“顯示內(nèi)容”和“顯示動作”解耦。固件里維護一套變量界面只負責(zé)把變量的當(dāng)前值映射到屏幕上的字符串至于變量是怎么來的、什么時候更新的界面完全不關(guān)心。這樣即便業(yè)務(wù)邏輯再復(fù)雜界面層也不會亂。2.3 按鍵處理短按、長按與“組合邏輯”按鍵是人機交互的入口處理不好菜單再漂亮也沒用。我在這個項目里做了三類按鍵事件短按按一下立即觸發(fā)。主要用于菜單切換、數(shù)值微調(diào)。長按按住超過一定時間比如600ms觸發(fā)一次。主要用于快速翻頁或者快捷功能。組合鍵兩個按鍵同時按。用于保存參數(shù)或退出到主界面防止誤觸。底層實現(xiàn)上我采用“非阻塞掃描”的思路定時器每10ms掃一次按鍵GPIO檢測電平變化記錄按下時長再根據(jù)時長判定是一次短按還是長按。整個過程不占用CPU輪詢不影響主邏輯運行。這里有一個非常大的坑我必須重點提一下按鍵去抖千萬不要用delay。新手寫按鍵喜歡按下后delay(10)消抖這在純順序執(zhí)行的程序里看起來沒事但一旦你的固件里有動態(tài)顯示刷新、通信收發(fā)等實時任務(wù)一個delay就會卡住整個系統(tǒng)。我全部用狀態(tài)機思路掃描、去抖、判定事件都在定時中斷里完成主循環(huán)只是消費事件體驗完全不一樣。2.4 參數(shù)編輯的交互設(shè)計整定邊界與數(shù)值步進參數(shù)整定是這個界面最核心的功能。我的交互邏輯是進入?yún)?shù)頁當(dāng)前參數(shù)行反白或者顯示光標表示處于“選中”狀態(tài)。短按“加/減”鍵數(shù)值按照設(shè)定的步進變化。短按“確認”鍵光標跳到下一位參數(shù)。長按“確認”鍵保存全部參數(shù)并退出到主界面。這里面最關(guān)鍵的細節(jié)是數(shù)值邊界。每一個參數(shù)在定義時我都會寫清楚三個屬性最小值、最大值、步進值。就算用戶一直按著加鍵數(shù)值到了上限也會自動停止絕對不會越界。這個設(shè)計救了我很多次因為參數(shù)一旦跑到非法范圍設(shè)備運行可能直接崩掉。另外為了操作效率數(shù)值調(diào)整我做了“加速度”處理短按時按步進走持續(xù)按住時超過1秒后自動加速這樣從1調(diào)到1000不用按幾百次體驗好很多。這些都是細節(jié)但恰恰是這些細節(jié)決定了使用者愿不愿意用你做的這個界面。3. 實操過程與核心環(huán)節(jié)實現(xiàn)3.1 菜單頁面的三種“標準形態(tài)”我把自己定義的頁面歸納為三種標準形態(tài)幾乎所有頁面都能套進去頁面類型典型用途按鍵行為信息展示頁主界面、狀態(tài)頁任意鍵進入下一級菜單參數(shù)編輯頁修改運行參數(shù)加減調(diào)整數(shù)值確認保存并返回確認提示頁恢復(fù)默認、系統(tǒng)設(shè)置確認執(zhí)行取消返回這個分類看起來簡單但它讓我寫代碼時有了統(tǒng)一的“套路”。每個頁面不再是從零開始設(shè)計而是先確定它屬于哪一類然后填充內(nèi)容即可。比如狀態(tài)頁就是顯示兩三個變量的值參數(shù)頁就是高亮一行加加減減。3.2 核心代碼框架偽代碼說明這部分的代碼我做成了可復(fù)用框架核心邏輯不綁定特定MCU拿過去改改引腳就能跑。頁面調(diào)度核心思路// 頁面ID枚舉 typedef enum { PAGE_MAIN, PAGE_PARAM_MENU, PAGE_PARAM_EDIT, PAGE_SYSTEM_MENU, PAGE_SAVE_CONFIRM, PAGE_ABOUT, // 新增頁面在這里擴展 PAGE_MAX } page_id_t; // 當(dāng)前頁面全局變量 static page_id_t current_page PAGE_MAIN; // 主循環(huán)中的界面調(diào)度 void ui_task(void) { // 根據(jù)當(dāng)前頁面執(zhí)行對應(yīng)處理 switch (current_page) { case PAGE_MAIN: page_main_handler(); break; case PAGE_PARAM_MENU: page_param_menu_handler(); break; case PAGE_PARAM_EDIT: page_param_edit_handler(); break; case PAGE_SYSTEM_MENU: page_system_menu_handler(); break; // 新增頁面在這里添加case default: break; } }參數(shù)管理表的設(shè)計思路// 參數(shù)表項 typedef struct { int16_t *value_ptr; // 參數(shù)變量指針 int16_t min_value; // 最小值 int16_t max_value; // 最大值 int16_t step; // 步進值 const char *name; // 參數(shù)名稱 } param_item_t; // 參數(shù)表所有可整定參數(shù)集中維護 static const param_item_t param_table[] { {param_max_speed, 0, 3000, 100, MaxSpeed}, {param_start_delay, 0, 60, 1, StartDelay}, {param_pid_kp, 0, 100, 1, PID_Kp}, // 新參數(shù)在這里追加 }; // 數(shù)值增加/減少統(tǒng)一處理函數(shù) void param_adjust(uint8_t param_idx, int8_t direction) { int16_t new_val *param_table[param_idx].value_ptr direction * param_table[param_idx].step; // 邊界裁剪 if (new_val param_table[param_idx].max_value) { new_val param_table[param_idx].max_value; } if (new_val param_table[param_idx].min_value) { new_val param_table[param_idx].min_value; } *param_table[param_idx].value_ptr new_val; }這里要特別說明一下參數(shù)表的靈魂是把“參數(shù)的業(yè)務(wù)邏輯”和“界面的操作邏輯”徹底分離。界面只負責(zé)根據(jù)表的定義去渲染數(shù)值、處理加減它不需要知道這個參數(shù)是干什么用的。將來要加參數(shù)只需在表里加一行界面自動就支持了這種“扁平化”的參數(shù)管理方式在工程上非常實用。3.3 菜單樹的具體落地實例我這里拿“整定PID速度環(huán)”這個場景舉例演示整棵樹是怎么走的第1層主界面顯示當(dāng)前實際速度、目標速度、運行狀態(tài)。按鍵“確認”進入?yún)?shù)菜單。第2層參數(shù)菜單列出“PID_Kp”“PID_Ki”“PID_Kd”“MaxSpeed”四項光標停在第一項通過“加/減”移動光標。第3層參數(shù)編輯光標選中“PID_Kp”按“加/減”調(diào)整數(shù)值實時刷新按“確認”保存當(dāng)前項并返回參數(shù)菜單按“返回”放棄當(dāng)前項修改。用戶在界面上看到的就是簡單的三層菜單但背后對應(yīng)的固件邏輯是一旦“PID_Kp”被修改控制算法會立即讀取新值并生效不需要重啟。這樣現(xiàn)場就能根據(jù)設(shè)備響應(yīng)實時整定效率提升了不止一個檔次。3.4 如何安全地“保存參數(shù)”界面上的參數(shù)修改如果只是存在RAM里斷電就丟了這肯定不行。所以還要做“參數(shù)持久化”——把參數(shù)寫入非易失存儲。我的做法是將參數(shù)表里的所有值打包成一個結(jié)構(gòu)體統(tǒng)一寫入Flash的一個固定扇區(qū)。每次保存時先擦除扇區(qū)再寫入。讀取時則是上電后從該扇區(qū)加載如果校驗失敗則使用默認參數(shù)。這里有一個特別重要的經(jīng)驗Flash擦寫次數(shù)有限不能每次改一個參數(shù)就立刻寫Flash。我的策略是多個參數(shù)統(tǒng)一在“退出參數(shù)菜單”或“長按確認保存”時一次性寫入。否則用戶調(diào)一個參數(shù)按一次保存Flash很快就寫穿了。4. 常見問題與排查技巧實錄4.1 屏幕顯示亂碼或白屏現(xiàn)象屏幕初始化后顯示一團亂碼或者干脆白屏無顯示??赡茉蜃畛R姷氖浅跏蓟瘯r序不對。字符液晶對初始化命令的延時非常敏感上電到初始化之間至少等40ms否則內(nèi)部狀態(tài)機沒準備好后續(xù)命令全部無效。解決辦法嚴格按照數(shù)據(jù)手冊推薦的初始化序列一字不差地執(zhí)行。另外對比度調(diào)節(jié)引腳不能懸空否則可能顯示極淡或者完全看不見這跟硬件電路有關(guān)排查時要一并檢查。4.2 按鍵失靈或跳變這是我在調(diào)試中踩得最深的坑?,F(xiàn)象按一下按鍵有時候觸發(fā)兩次有時候不觸發(fā)。原因分析排查下來有三類原因。第一類是硬件抖動沒有徹底濾除10ms去抖在某些廉價按鍵上不夠第二類是中斷處理里做了太多事導(dǎo)致按鍵狀態(tài)還沒穩(wěn)定就被錯誤讀取第三類是按鍵掃描的周期和主循環(huán)的周期耦合導(dǎo)致重復(fù)觸發(fā)。解決辦法我把按鍵處理獨立成一個定時器驅(qū)動的狀態(tài)機掃描周期固定為10ms去抖時間設(shè)為30ms事件判定在中斷里只置標志位、不執(zhí)行業(yè)務(wù)邏輯主循環(huán)拿到標志后再分發(fā)。這樣處理后按鍵再也沒有出過問題。4.3 修改參數(shù)后設(shè)備運行異?,F(xiàn)象把某個參數(shù)調(diào)到邊界值附近設(shè)備運行明顯不正常比如速度抖動、輸出異常。原因分析參數(shù)超出合理運行范圍但我的整定邊界設(shè)得太寬了。解決辦法這是一個非常現(xiàn)實的問題。參數(shù)邊界不能只看理論值要看設(shè)備實際能安全運行的范圍。我把每個參數(shù)的安全運行范圍重新梳理了一遍把最大最小值收窄到保守區(qū)間里。另外我在參數(shù)編輯頁增加了一個“數(shù)值預(yù)覽”區(qū)修改參數(shù)時同步顯示推算出的運行效果比如理論輸出百分比這樣操作者心里有數(shù)不會盲目拉滿。4.4 固件升級后界面配置丟失現(xiàn)象升級固件后之前保存的參數(shù)全被重置成了默認值。原因分析新固件里參數(shù)表結(jié)構(gòu)變了跟舊固件存儲的布局不一致導(dǎo)致校驗失敗自動加載默認參數(shù)。解決辦法我引入了“參數(shù)表版本號”的概念。每次修改參數(shù)表結(jié)構(gòu)增加/刪除/調(diào)整順序就遞增版本號。如果版本號變化則主動放棄舊參數(shù)使用默認值。這雖然不能直接遷移舊參數(shù)但至少避免了用錯誤布局去解析舊數(shù)據(jù)導(dǎo)致的潛在風(fēng)險在工程上是值得的。4.5 界面卡死按鍵無響應(yīng)現(xiàn)象程序運行一段時間后界面卡死按鍵無論如何按都沒反應(yīng)。原因分析最后定位到是EEPROM/Flash寫入期間CPU被阻塞太久期間按鍵中斷雖然能觸發(fā)但主循環(huán)處理不過來。更糟糕的情況是寫入失敗進入了死循環(huán)重試。解決辦法我給所有Flash寫操作加上了超時保護并且把“正在保存”做成一個頁面狀態(tài)保存期間只允許“等待”或“取消”不會再響應(yīng)其他按鍵事件。這樣一個潛在的死循環(huán)問題就被規(guī)避掉了。5. 實操總結(jié)與擴展建議5.1 核心收獲如果要我用一句話總結(jié)這一期的核心那就是人機界面不是一個可有可無的“花架子”而是固件工程化的必經(jīng)一步。當(dāng)設(shè)備有了本地交互能力它的調(diào)試效率、可維護性、用戶體驗都會產(chǎn)生質(zhì)變。從代碼量上看這套完整的菜單框架核心代碼不過300行左右但它換來的是“現(xiàn)場整定參數(shù)不再需要電腦和下載器”的自由。我覺得這筆投入非常劃算。5.2 可繼續(xù)深挖的擴展方向這個框架我已經(jīng)跑通后續(xù)還有幾個方向我覺得很有意思曲線顯示如果換成圖形點陣屏可以加入實時趨勢曲線很多現(xiàn)場調(diào)試問題一眼就能看穿。多語言支持把菜單字符串做成表切換語言就切換表這樣設(shè)備出口到不同語言環(huán)境也不用改代碼。日志記錄器界面上加一頁“最近告警記錄”把固件運行時異常存下來現(xiàn)場復(fù)位后還能翻看。5.3 個人經(jīng)驗與踩坑心得最后分享幾句掏心窩的話。第一句先畫菜單樹再寫界面代碼。我已經(jīng)見過太多人一上來就敲代碼結(jié)果菜單邏輯越寫越亂最后連自己都不知道當(dāng)前在哪一層。畫樹五分鐘后面省五小時。第二句所有參數(shù)必須有邊界。哪怕你覺得自己寫的是“不可能越界”的參數(shù)也要加邊界限制。現(xiàn)場的操作者不會像你一樣珍惜這個設(shè)備一個非法參數(shù)搞崩設(shè)備的事我親眼見過不止一次。第三句界面代碼一定要“年輕化”。意思是任何頁面、任何參數(shù)表項都應(yīng)該能讓一個沒有參與前期開發(fā)的人看懂、改得動。你永遠不知道三個月后打開這份代碼的人是誰把結(jié)構(gòu)理清楚就是對自己最大的善意。這一期就到這里。下一期我打算把“參數(shù)持久化”這塊展開詳細聊聊包括Flash磨損均衡、掉電保護、參數(shù)校驗這些底層細節(jié)。各位如果有興趣歡迎留言交流你們在固件人機界面設(shè)計上踩過的坑。