器人平臺(tái)Android開發(fā):架構(gòu)分層與性能優(yōu)化的關(guān)鍵路徑)
早些年做機(jī)器人平臺(tái)Android開發(fā)那會(huì)兒我踩過(guò)最深的坑就是把機(jī)器人的主控程序當(dāng)成一個(gè)普通手機(jī)App來(lái)寫。同樣是Android開發(fā)同樣是Kotlin和Android Studio可一旦你的代碼需要驅(qū)動(dòng)電機(jī)、讀取IMU數(shù)據(jù)、協(xié)調(diào)激光雷達(dá)和雙目攝像頭普通App那套“生命周期優(yōu)先、UI流暢第一”的架構(gòu)和優(yōu)化思路就會(huì)全線崩塌。這篇文章我從架構(gòu)分層、性能優(yōu)化和實(shí)戰(zhàn)復(fù)盤三個(gè)角度把機(jī)器人平臺(tái)Android開發(fā)真正麻煩的地方掰開揉碎講清楚給正在做機(jī)器人主控、或者準(zhǔn)備入坑的開發(fā)者一些可以直接參考的經(jīng)驗(yàn)。1. 機(jī)器人場(chǎng)景對(duì)Android開發(fā)的特殊約束1.1 機(jī)器人主控和手機(jī)應(yīng)用的根本差異先說(shuō)說(shuō)為什么不能把機(jī)器人主控做成“大號(hào)手機(jī)App”。手機(jī)應(yīng)用的運(yùn)行模型是事件驅(qū)動(dòng)的用戶點(diǎn)擊、滑動(dòng)、系統(tǒng)回調(diào)絕大部分代碼都在響應(yīng)人的操作。而機(jī)器人不一樣它開機(jī)之后就必須連續(xù)運(yùn)行要主動(dòng)去輪詢傳感器、周期性地發(fā)送控制指令、處理異常狀態(tài)它的核心不是“響應(yīng)用戶”而是“持續(xù)完成控制閉環(huán)”。這個(gè)差異會(huì)直接影響你寫代碼的姿勢(shì)。手機(jī)App里Activity進(jìn)入onPause是正常的用戶可以切到后臺(tái)去刷別的應(yīng)用但在機(jī)器人主控里看不到界面并不代表任務(wù)可以停。我見過(guò)不少項(xiàng)目把控制邏輯寫在Activity的成員方法里一旦用戶不小心按了Home鍵整個(gè)控制循環(huán)就跟著停了機(jī)器人當(dāng)場(chǎng)失去響應(yīng)這是很危險(xiǎn)的。所以從第一版開始就要明確一條鐵律控制邏輯必須和UI生命周期徹底解耦。機(jī)器人的業(yè)務(wù)內(nèi)核應(yīng)該是一個(gè)長(zhǎng)壽命的Service或者獨(dú)立的Native進(jìn)程UI只是它的一個(gè)視圖。這個(gè)思路和“前后端分離”有點(diǎn)像UI掛了可以重啟內(nèi)核必須保持運(yùn)行。我后面在架構(gòu)設(shè)計(jì)部分會(huì)再具體說(shuō)這套怎么落地。1.2 硬件生態(tài)你面對(duì)的不只是屏幕和觸控手機(jī)開發(fā)者通常面對(duì)的硬件是觸摸屏、攝像頭、陀螺儀、Wi-Fi和藍(lán)牙。機(jī)器人平臺(tái)就復(fù)雜多了。以我做過(guò)的室內(nèi)巡檢機(jī)器人項(xiàng)目為例主控板是一塊工業(yè)級(jí)Android板卡外設(shè)包括四輪差速底盤的伺服電機(jī)編碼器和驅(qū)動(dòng)器通常是串口或者CAN總線連接還有IMU輸出角速度和加速度頻率在100Hz到400Hz單線激光雷達(dá)10Hz到30Hz以及一個(gè)前置雙目相機(jī)和一個(gè)頂置全景相機(jī)。除此之外還有舵機(jī)控制板、急停繼電器、電量監(jiān)測(cè)模塊這些雜七雜八的外設(shè)。這些外設(shè)的接入方式五花八門。串口可能是TTL電平也可能是RS-232需要轉(zhuǎn)接板CAN總線需要主控板支持CAN控制器相機(jī)有些是UVC協(xié)議有些是MIPI接口。你在Android里不能像手機(jī)一樣直接用Camera API去讀某個(gè)定制傳感器更多時(shí)候要直接用Native代碼打開設(shè)備節(jié)點(diǎn)、讀寫數(shù)據(jù)。這種硬件復(fù)雜度帶來(lái)的啟示是開發(fā)機(jī)器人的Android程序一半時(shí)間在寫Java/Kotlin業(yè)務(wù)另一半時(shí)間在寫C/C的Native代碼跟各種設(shè)備節(jié)點(diǎn)打交道。如果你的團(tuán)隊(duì)里沒(méi)有人能搞定JNI層和Linux設(shè)備驅(qū)動(dòng)建議趁早評(píng)估是不是適合走Android這條主控路線否則后面會(huì)非常痛苦。1.3 實(shí)時(shí)性和確定性普通App可以不關(guān)心機(jī)器人不行普通App對(duì)實(shí)時(shí)性幾乎沒(méi)有硬性要求掉一幀、慢幾十毫秒用戶最多覺(jué)得卡但機(jī)器人控制對(duì)實(shí)時(shí)性和確定性的要求近乎苛刻。拿底盤電機(jī)控制來(lái)說(shuō)控制周期一般要求5ms到20msPID控制器要在這個(gè)周期內(nèi)完成誤差計(jì)算和輸出更新。如果系統(tǒng)GC導(dǎo)致控制環(huán)路200ms沒(méi)有更新電機(jī)會(huì)繼續(xù)輸出舊指令小車可能在急停指令發(fā)出后多跑出幾十厘米這個(gè)距離在室內(nèi)巡檢場(chǎng)景里就可能撞上貨架。這里要區(qū)分一個(gè)概念機(jī)器人關(guān)心的是“延遲抖動(dòng)”不是平均性能。手機(jī)App追求的是平均幀率機(jī)器人要的是每個(gè)控制周期都要按時(shí)到達(dá)。所以在系統(tǒng)選型和架構(gòu)設(shè)計(jì)上必須提前考慮中斷響應(yīng)、線程調(diào)度優(yōu)先級(jí)、CPU綁核、內(nèi)存鎖定這些手機(jī)開發(fā)很少碰的東西。后面第三節(jié)我會(huì)展開具體的優(yōu)化手段。維度手機(jī)應(yīng)用機(jī)器人主控核心模式事件驅(qū)動(dòng)響應(yīng)人的操作周期循環(huán)維持控制閉環(huán)生命周期跟隨Activity狀態(tài)獨(dú)立于UI必須持續(xù)運(yùn)行性能目標(biāo)平均幀率高、操作流暢低延遲、低抖動(dòng)、確定性外設(shè)范圍屏幕/相機(jī)/IMU/網(wǎng)絡(luò)電機(jī)/編碼器/多類傳感器/繼電器崩潰容忍度可重啟影響有限必須安全兜底自動(dòng)恢復(fù)這張表基本概括了我認(rèn)知里的差異后面聊優(yōu)化和實(shí)戰(zhàn)很多思路都源于這張表。2. 分層架構(gòu)從內(nèi)核到應(yīng)用層的鏈路設(shè)計(jì)2.1 先定邊界驅(qū)動(dòng)、服務(wù)、邏輯、UI各自歸誰(shuí)機(jī)器人Android主控的架構(gòu)我建議一開始就按分層模型來(lái)搭底層是Linux內(nèi)核和HAL硬件抽象層中間是Native層再往上是JNI適配層最上面是Java/Kotlin的應(yīng)用層和服務(wù)層。為什么要搞這么多層原因很簡(jiǎn)單單一層搞不定。完全用Java讀設(shè)備節(jié)點(diǎn)不是不行但Java層的字節(jié)流讀寫、線程調(diào)度、GC行為都很難做實(shí)時(shí)性保證尤其當(dāng)數(shù)據(jù)量大時(shí)一次系統(tǒng)調(diào)用再跨一次JNI延遲和抖動(dòng)會(huì)疊加。反過(guò)來(lái)如果所有邏輯都用C/C寫業(yè)務(wù)迭代效率太低UI也做不出來(lái)。所以合理的分配是高頻和實(shí)時(shí)敏感的部分下沉到Native層狀態(tài)機(jī)、業(yè)務(wù)邏輯和UI放在Java/Kotlin層JNI只做薄薄的適配不承載業(yè)務(wù)。我實(shí)際采用的分配方案是IMU和激光雷達(dá)等高頻傳感數(shù)據(jù)由Native層常駐線程采集數(shù)據(jù)放到環(huán)形緩沖區(qū)或共享內(nèi)存里PID電機(jī)控制環(huán)路由Native層專用控制線程按固定周期執(zhí)行業(yè)務(wù)狀態(tài)機(jī)放在Java層Service里維護(hù)根據(jù)Native上報(bào)的事件切換狀態(tài)UI層只渲染狀態(tài)和交互配置不直接訪問(wèn)原生接口。核心思路就一句話數(shù)據(jù)下沉采集、事件上拋狀態(tài)。底層持續(xù)采集數(shù)據(jù)只把有意義的狀態(tài)變化拋給上層上層不要求實(shí)時(shí)數(shù)據(jù)只要狀態(tài)同步。這樣兩個(gè)層次之間的耦合最低上層的GC對(duì)底層沒(méi)有直接影響。2.2 下位機(jī)通信的選型串口、CAN還是網(wǎng)絡(luò)機(jī)器人幾乎都有上下位機(jī)的概念A(yù)ndroid主控是上位機(jī)底盤或者機(jī)械臂是下位機(jī)。兩者怎么通信會(huì)直接影響架構(gòu)的復(fù)雜度。我做過(guò)對(duì)比串口是最簡(jiǎn)單也最通用的方案工業(yè)串口在Linux里大多暴露為tty設(shè)備節(jié)點(diǎn)Android里可以用Native代碼直接打開波特率通常在115200到921600之間滿足大多數(shù)傳感和控制需求缺點(diǎn)是帶寬有限大數(shù)據(jù)量的激光雷達(dá)點(diǎn)云不走串口。CAN總線更適合電機(jī)數(shù)量多的場(chǎng)合抗干擾性強(qiáng)實(shí)時(shí)性也有保證但Android主控板上不一定帶CAN控制器通常得外接USB-CAN轉(zhuǎn)換器驅(qū)動(dòng)又是一層麻煩。網(wǎng)絡(luò)Ethernet/Wi-Fi則用于傳輸數(shù)據(jù)量大的感知結(jié)果比如激光雷達(dá)點(diǎn)云、視頻流、深度圖像或者和ROS節(jié)點(diǎn)通信。我的建議是不要指望一種協(xié)議覆蓋全部需求更常見的做法是按實(shí)時(shí)性需求分流底盤控制走CAN或串口雷達(dá)點(diǎn)云和視頻走網(wǎng)絡(luò)電池和溫濕度這類低頻數(shù)據(jù)可以順著串口一并帶上來(lái)。這樣各條鏈路的負(fù)載清晰出了問(wèn)題也容易定位。前期選型時(shí)可以問(wèn)問(wèn)板卡廠商哪些串口、GPIO是可用的、有沒(méi)有現(xiàn)成的CAN接口支持這些信息往往比Android SDK版本更影響項(xiàng)目進(jìn)度。2.3 與ROS生態(tài)的協(xié)作方式很多機(jī)器人團(tuán)隊(duì)會(huì)引入ROS。Android端跟ROS協(xié)作有兩條主流路線一是在Android上直接跑ROS節(jié)點(diǎn)通過(guò)rosjava或者roslib訂閱話題二是Android作為獨(dú)立的上位機(jī)通過(guò)橋接方式和ROS通信。直接跑節(jié)點(diǎn)聽起來(lái)很美但Android的進(jìn)程模型和ROS節(jié)點(diǎn)模型不是一回事依賴打包、話題QoS控制、節(jié)點(diǎn)生命周期管理都比較別扭在ARM工控板上跑完整roscore還占資源。我后來(lái)更推薦第二種方式帶Linux的主控板上跑ROS節(jié)點(diǎn)Android端通過(guò)本地socket或共享內(nèi)存拿到話題數(shù)據(jù)。簡(jiǎn)單說(shuō)就是把Android當(dāng)成“圖形化儀表盤業(yè)務(wù)大腦”把ROS當(dāng)成后端計(jì)算引擎。兩者之間用一個(gè)輕量的本地協(xié)議橋接比如把ROS話題數(shù)據(jù)序列化成protobuf通過(guò)Unix domain socket投遞給Android進(jìn)程。這樣既保留了ROS生態(tài)的算法能力又讓Android的UI和App層保持純粹。這么做還有一個(gè)附帶好處調(diào)試的時(shí)候可以把橋接層臨時(shí)換成TCP直接用電腦連主機(jī)Logcat和桌面端工具同時(shí)看問(wèn)題定位快很多。2.4 狀態(tài)機(jī)驅(qū)動(dòng)把機(jī)器人行為建模成狀態(tài)流轉(zhuǎn)機(jī)器人業(yè)務(wù)邏輯和普通App一個(gè)很大的不同是它有明確的“安全狀態(tài)”。比如巡檢機(jī)器人至少有這幾個(gè)狀態(tài)待機(jī)、巡檢、避障、手動(dòng)接管、充電、急停。如果把每個(gè)狀態(tài)寫成if-else狀態(tài)多了之后代碼會(huì)變成一團(tuán)亂麻。我強(qiáng)烈建議把狀態(tài)機(jī)做成架構(gòu)的一部分。我一般把狀態(tài)機(jī)放在Java層Service里由Native層的控制線程喂事件比如“左前輪編碼器異?!薄凹す饫走_(dá)斷連”“急停按鈕按下”Java層收到這些事件后決定遷移到哪個(gè)狀態(tài)。狀態(tài)遷移會(huì)附帶動(dòng)作比如進(jìn)入急停狀態(tài)要通知Native層立即剎車進(jìn)入充電狀態(tài)要斷開電機(jī)使能。這里有一個(gè)容易被忽略的細(xì)節(jié)狀態(tài)機(jī)的遷移條件要考慮異常兜底不能只做正常流程。激光雷達(dá)斷連不應(yīng)該直接進(jìn)巡檢而是先進(jìn)入安全停機(jī)狀態(tài)。很多現(xiàn)場(chǎng)事故就是在狀態(tài)機(jī)里沒(méi)寫異常遷移路徑導(dǎo)致的建議在測(cè)試階段專門模擬各種傳感器斷連、通信超時(shí)的情況。3. 性能優(yōu)化把延遲和抖動(dòng)壓下去的實(shí)操方法3.1 線程與調(diào)度控制線程要“特殊待遇”機(jī)器人主控的線程模型必須提前設(shè)計(jì)不能隨手用Thread。一般我會(huì)把線程分成三類UI線程只做界面刷新和用戶操作業(yè)務(wù)線程放狀態(tài)機(jī)、任務(wù)調(diào)度、網(wǎng)絡(luò)等中等優(yōu)先級(jí)工作實(shí)時(shí)控制線程是Native層的周期性控制任務(wù)必須單獨(dú)拎出來(lái)。實(shí)時(shí)控制線程要爭(zhēng)取“特殊待遇”。Android應(yīng)用層可以用的方法是Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)把線程優(yōu)先級(jí)提到較高檔位。更徹底的做法是在Native層用pthread_setschedparam把線程調(diào)度策略設(shè)為SCHED_FIFO或SCHED_RR讓Linux內(nèi)核按實(shí)時(shí)策略調(diào)度。不過(guò)這樣做通常需要root權(quán)限而機(jī)器人工控板一般都能拿到權(quán)限。還有一個(gè)實(shí)操細(xì)節(jié)是綁定CPU核心調(diào)用sched_setaffinity把實(shí)時(shí)控制線程固定到某個(gè)空閑核心上避免和其他線程搶核也避免CPU調(diào)頻策略導(dǎo)致頻率波動(dòng)。工業(yè)板一般有四個(gè)或八個(gè)核心挑一個(gè)專門的核做控制和傳感讀取效果非常明顯。如果內(nèi)核對(duì)實(shí)時(shí)搶占特性有要求條件允許的話可以評(píng)估Preempt-RT補(bǔ)丁把系統(tǒng)本身的調(diào)度延遲降一個(gè)量級(jí)這對(duì)底盤控制的穩(wěn)定性有決定性幫助。3.2 傳感器數(shù)據(jù)通路減少拷貝和跨進(jìn)程跳變傳感器數(shù)據(jù)從硬件到業(yè)務(wù)代碼的路徑往往是性能瓶頸所在。最常見的坑是“串口讀數(shù)據(jù) - JNI往上拷貝 - Java層字節(jié)數(shù)組 - Binder跨進(jìn)程 - 業(yè)務(wù)線程處理”每經(jīng)過(guò)一層就多一次拷貝、多一次線程切換延遲和抖動(dòng)都會(huì)疊加。我后來(lái)把數(shù)據(jù)通路改成了這樣Native層常駐線程直接打開設(shè)備節(jié)點(diǎn)讀取原始數(shù)據(jù)解析后寫入預(yù)分配的環(huán)形緩沖區(qū)上層應(yīng)用通過(guò)共享內(nèi)存Ashmem或API 27的SharedMemory直接訪問(wèn)這些數(shù)據(jù)不需要每次調(diào)用都跨進(jìn)程拷貝Native層只在上層需要時(shí)發(fā)出通知比如“新的一幀IMU就緒”。這里要提醒的是無(wú)鎖或者低鎖的環(huán)形緩沖區(qū)非常關(guān)鍵。如果用普通鎖保護(hù)緩沖區(qū)控制線程和讀取線程一競(jìng)爭(zhēng)還是會(huì)引入抖動(dòng)。工業(yè)上常用的方案是單生產(chǎn)者單消費(fèi)者的SPSC環(huán)形隊(duì)列配合內(nèi)存屏障生產(chǎn)者和消費(fèi)者各管各的指針不需要互斥鎖。Android NDK里沒(méi)有現(xiàn)成實(shí)現(xiàn)我就直接用C11的std::atomic自己封裝了一個(gè)成本不高但效果很明顯。3.3 內(nèi)存與GC卡頓的元兇其實(shí)是你自己Android的Java層GC會(huì)短暫暫停所有Java線程這一點(diǎn)在機(jī)器人控制里很致命。有人可能覺(jué)得“我用的是旗艦工控板GC時(shí)間不長(zhǎng)”但GC造成的抖動(dòng)不是平均耗時(shí)問(wèn)題而是偶發(fā)的幾十毫秒、甚至幾百毫秒停頓這在控制回路里完全不能接受。所以在機(jī)器人項(xiàng)目里內(nèi)存管理的原則比普通App更嚴(yán)格控制鏈路上的代碼盡可能不在Java層分配對(duì)象高頻路徑上禁止使用自動(dòng)裝箱、字符串拼接、日志刷屏。我的習(xí)慣是高頻路徑全部走NativeJava層只接收低頻狀態(tài)事件這樣Java層的GC再發(fā)生也不影響控制線程。如果業(yè)務(wù)邏輯不得不在Java層處理高頻數(shù)據(jù)比如點(diǎn)云做特征提取建議用對(duì)象池管理臨時(shí)對(duì)象并主動(dòng)在低負(fù)載時(shí)段調(diào)用System.gc()把GC的“隨機(jī)事故”變成“可控計(jì)劃”。我實(shí)測(cè)過(guò)這種做法控制環(huán)路的P99延遲從250ms降到了20ms以內(nèi)效果非常顯著。也可以用Debug.getPss()定期采樣Java層實(shí)際堆內(nèi)存壓力存到本地日志復(fù)盤卡頓問(wèn)題時(shí)能派上大用場(chǎng)。3.4 系統(tǒng)級(jí)穩(wěn)定看門狗、自啟、斷連恢復(fù)機(jī)器人現(xiàn)場(chǎng)運(yùn)行的穩(wěn)定性光靠架構(gòu)優(yōu)化遠(yuǎn)遠(yuǎn)不夠還必須有系統(tǒng)級(jí)的兜底。第一是看門狗機(jī)制。我項(xiàng)目里的做法是Native控制線程每個(gè)周期更新一個(gè)時(shí)間戳同時(shí)Java層起一個(gè)守護(hù)線程定期檢查如果超過(guò)1秒沒(méi)有更新就判定控制線程卡死立刻通過(guò)GPIO觸發(fā)硬件急停并上報(bào)異常后自動(dòng)重啟控制進(jìn)程。這個(gè)機(jī)制幫我們擋下過(guò)好幾次現(xiàn)場(chǎng)事故。第二是開機(jī)自啟和前臺(tái)服務(wù)。機(jī)器人工控板通常要上電即用所以Manifest里要聲明BOOT_COMPLETED接收器配合前臺(tái)服務(wù)和START_STICKY確保系統(tǒng)意外殺掉進(jìn)程后能重新拉起。有些板卡還有斷電重啟和RTC喚醒需求最好從產(chǎn)品需求階段就確認(rèn)。第三是斷連恢復(fù)。傳感器通信超時(shí)、USB設(shè)備脫落、串口假死都是現(xiàn)場(chǎng)常見的。我一般會(huì)在Native維護(hù)一張外設(shè)健康表周期探活任何一個(gè)外設(shè)連續(xù)N秒無(wú)響應(yīng)就上報(bào)一次“設(shè)備掉線”事件同時(shí)進(jìn)入安全狀態(tài)而不是繼續(xù)傻跑。這個(gè)思路對(duì)后面的調(diào)試和事故復(fù)盤特別有幫助因?yàn)槟憧词录罩揪湍苤罃噙B什么時(shí)候發(fā)生、前后做了什么操作。4. 實(shí)戰(zhàn)復(fù)盤某室內(nèi)巡檢機(jī)器人主控的調(diào)試經(jīng)歷4.1 項(xiàng)目背景和最初架構(gòu)這個(gè)項(xiàng)目是一個(gè)室內(nèi)倉(cāng)庫(kù)巡檢機(jī)器人需求是沿著預(yù)設(shè)路線巡檢貨架檢測(cè)溫度異常、識(shí)別貨架缺貨、避讓人和叉車。主控是一塊工業(yè)級(jí)Android板卡外設(shè)包括兩個(gè)USB相機(jī)、一個(gè)單線激光雷達(dá)、一個(gè)IMU、四輪差速底盤。第一版架構(gòu)當(dāng)時(shí)趕進(jìn)度走得比較“樸素”控制邏輯整個(gè)放在一個(gè)常駐Activity里Java層直接通過(guò)JNI讀串口和網(wǎng)絡(luò)數(shù)據(jù)UI線程和控制線程混在一起。剛開始跑樣機(jī)的時(shí)候問(wèn)題一堆但最棘手的是三個(gè)控制環(huán)路在GC抖動(dòng)下失控、串口IMU數(shù)據(jù)丟幀、系統(tǒng)休眠后機(jī)器人“睡死”。這三個(gè)問(wèn)題很有代表性下面分開講。4.2 問(wèn)題一控制環(huán)路GC抖動(dòng)靠實(shí)時(shí)調(diào)度策略解決現(xiàn)象是機(jī)器人跑直線時(shí)軌跡會(huì)周期性偏右然后被拉回來(lái)頻率大概每幾秒一次。一開始懷疑是電機(jī)本身的問(wèn)題后來(lái)把電機(jī)的速度曲線和GC日志對(duì)齊發(fā)現(xiàn)每次軌跡偏移都對(duì)應(yīng)一次GC發(fā)生并且PID指令更新時(shí)間在這段時(shí)間里超過(guò)180ms沒(méi)有更新。原因很清晰PID控制線程在Java層控制周期雖然是10ms但GC一旦觸發(fā)就卡住整個(gè)進(jìn)程底盤速度控制直接停擺電機(jī)按舊值運(yùn)行軌跡自然偏。解法分兩步。第一步把整個(gè)PID控制循環(huán)遷移到Native層控制線程設(shè)置成實(shí)時(shí)調(diào)度綁定到指定核心并調(diào)用mlockall鎖定內(nèi)存防止換頁(yè)。第二步Java層業(yè)務(wù)代碼只接收“控制指令完成”這類低頻事件高頻的傳感器融合和控制計(jì)算全部下沉。// 控制線程初始化片段設(shè)置實(shí)時(shí)調(diào)度和CPU綁核 struct sched_param param; param.sched_priority 80; pthread_setschedparam(pthread_self(), SCHED_FIFO, param); cpu_set_t cpuset; CPU_ZERO(cpuset); CPU_SET(2, cpuset); // 綁定到核心2避開系統(tǒng)常用核心 sched_setaffinity(0, sizeof(cpu_set_t), cpuset); mlockall(MCL_CURRENT | MCL_FUTURE);遷移之后控制環(huán)路的實(shí)際周期偏差從±200ms降到了±2ms以內(nèi)。這個(gè)項(xiàng)目之后我基本不再糾結(jié)“能不能在Java層做PID”這個(gè)問(wèn)題回答永遠(yuǎn)是不建議。4.3 問(wèn)題二串口IMU丟幀跨進(jìn)程拷貝引發(fā)的間歇性饑餓第二個(gè)問(wèn)題很隱蔽IMU的角速度數(shù)據(jù)明明是200Hz輸出但解析之后時(shí)間戳間隔忽大忽小有時(shí)候連續(xù)幾個(gè)包間隔2ms有時(shí)候突然30ms才來(lái)一個(gè)包。這種“時(shí)快時(shí)慢”的數(shù)據(jù)送到姿態(tài)融合算法里解算出的角度會(huì)輕微抖動(dòng)對(duì)導(dǎo)航影響很大。排查過(guò)程是這樣的先在Native串口讀取線程里打時(shí)間戳發(fā)現(xiàn)數(shù)據(jù)本身從內(nèi)核到應(yīng)用層的時(shí)間間隔是穩(wěn)定的然后在Native到Java的每次讀取都打點(diǎn)發(fā)現(xiàn)丟幀發(fā)生在JNI向上拋數(shù)據(jù)的時(shí)候再壓測(cè)發(fā)現(xiàn)應(yīng)用層的讀取線程和UI線程搶CPU每次JNI調(diào)用都涉及一次線程切換和內(nèi)存拷貝一旦讀取線程被調(diào)度走數(shù)據(jù)就在內(nèi)核緩沖區(qū)堆積等它回來(lái)再讀間隔自然拉長(zhǎng)。解法是把IMU數(shù)據(jù)改成共享內(nèi)存方式。Native寫入環(huán)形緩沖區(qū)Java層的融合線程通過(guò)共享內(nèi)存映射直接讀取讀取線程不再每次調(diào)用JNI而是等待一個(gè)輕量的Semaphore通知。這樣跨層的數(shù)據(jù)拷貝接近于零IMU數(shù)據(jù)間隔從抖動(dòng)±28ms收斂到了±1ms以內(nèi)。數(shù)據(jù)通路設(shè)計(jì)的價(jià)值在這個(gè)問(wèn)題里體現(xiàn)得淋漓盡致。4.4 問(wèn)題三系統(tǒng)休眠導(dǎo)致機(jī)器人“睡死”第三個(gè)問(wèn)題最詭異機(jī)器人運(yùn)行一段時(shí)間后屏幕自動(dòng)熄滅再過(guò)十幾分鐘控制線程就像“掉線”了一樣底盤停止響應(yīng)遠(yuǎn)程看板顯示心跳超時(shí)。重啟App也不行只能整機(jī)斷電。排查Android系統(tǒng)層的休眠行為發(fā)現(xiàn)根源有兩處。第一控制進(jìn)程沒(méi)有持有PARTIAL_WAKE_LOCK系統(tǒng)判定無(wú)活動(dòng)后屏幕熄滅、CPU進(jìn)入idle部分外設(shè)總線的時(shí)鐘被掛起串口讀取線程拿不到數(shù)據(jù)。第二工控板的CPU調(diào)頻策略在低負(fù)載時(shí)段把頻率壓到最低控制線程的實(shí)時(shí)調(diào)度優(yōu)先級(jí)也被某些系統(tǒng)服務(wù)臨時(shí)壓低導(dǎo)致控制周期嚴(yán)重超時(shí)。解法分兩層。系統(tǒng)層面在設(shè)備樹和內(nèi)核配置里禁用不必要的suspend狀態(tài)把CPU調(diào)頻策略改成performance模式保持核心高頻運(yùn)行應(yīng)用層面在長(zhǎng)壽命Service里持有PARTIAL_WAKE_LOCK并在電源管理策略里對(duì)控制線程所在CPU核心禁用熱插拔。改完之后再跑72小時(shí)連續(xù)壓力測(cè)試沒(méi)有再出現(xiàn)“睡死”。這次排查給我的教訓(xùn)是在機(jī)器人項(xiàng)目里Android的省電機(jī)制是敵人不是朋友該關(guān)的省電特性一定要關(guān)干凈。4.5 幾件工具和“早知道”清單復(fù)盤這個(gè)項(xiàng)目有幾個(gè)工具幫了大忙。Perfetto看系統(tǒng)整體調(diào)度和鎖競(jìng)爭(zhēng)最直觀Simpleperf抓Native層的CPU熱點(diǎn)dmesg看內(nèi)核日志定位串口和電源問(wèn)題systrace則適合定位UI線程和系統(tǒng)服務(wù)的交互。這些工具在普通App項(xiàng)目里可能只是輔助在機(jī)器人主控項(xiàng)目里就是必需品。如果讓我重做一次這些事我會(huì)從第一天就做控制鏈路全部Native化Java層不碰任何高頻控制所有傳感器數(shù)據(jù)統(tǒng)一走共享內(nèi)存絕對(duì)不用JNI高頻拷貝系統(tǒng)休眠和CPU調(diào)頻策略的修改提前找板卡廠商要方案不要等現(xiàn)場(chǎng)出問(wèn)題再調(diào)外設(shè)健康表和事件日志從第一版就建立不然現(xiàn)場(chǎng)排障只能靠猜每個(gè)傳感器和控制指令都帶單調(diào)遞增的序列號(hào)方便對(duì)齊時(shí)間軸。最后再分享一點(diǎn)個(gè)人體會(huì)。機(jī)器人平臺(tái)Android開發(fā)的復(fù)雜度并不在于某個(gè)單一技術(shù)點(diǎn)有多難而在于你得同時(shí)面對(duì)嵌入式系統(tǒng)、實(shí)時(shí)控制、移動(dòng)端開發(fā)和現(xiàn)場(chǎng)運(yùn)維的交叉問(wèn)題。每一次調(diào)試到最后往往發(fā)現(xiàn)不是單點(diǎn)問(wèn)題而是調(diào)度、內(nèi)存、通信、系統(tǒng)策略共同作用的結(jié)果。你不僅要會(huì)寫App還得理解Linux內(nèi)核的調(diào)度和驅(qū)動(dòng)模型理解控制原理的基本常識(shí)理解系統(tǒng)電源管理。但反過(guò)來(lái)一旦你把“Android只是一個(gè)人機(jī)交互殼子實(shí)時(shí)控制必須下沉”這個(gè)觀念真正立住后續(xù)的優(yōu)化和排障都會(huì)有章可循。建議想入行的開發(fā)者先從一個(gè)串口電機(jī)和一片IMU開始把數(shù)據(jù)通路和線程模型跑扎實(shí)再逐步往上疊加業(yè)務(wù)會(huì)比一上來(lái)就對(duì)著完整機(jī)器人系統(tǒng)猛沖順暢得多。