:用協(xié)程和消息驅(qū)動構(gòu)建嵌入式應用)
做了幾年物聯(lián)網(wǎng)嵌入式開發(fā)前陣子在調(diào)LuatOS的項目時發(fā)現(xiàn)好多新手甚至一些老手都被這個叫sys的庫整懵了??疵忠詾槭莻€系統(tǒng)配置類庫結(jié)果翻文檔發(fā)現(xiàn)里面全是taskInit、wait、timerStart、subscribe這些接口一時半會根本不知道它們是什么關(guān)系更不知道怎么組合起來用。后來我把這個庫當成一個“跑在MCU上的微型RTOS”來理解一切都通了。sys其實就是LuatOS的核心運行框架負責任務調(diào)度、延時等待、定時器管理、事件訂閱和發(fā)布整個Lua腳本生態(tài)都建立在它之上。換句話說只要你想在LuatOS里寫稍微復雜一點的業(yè)務邏輯就繞不開sys這套API。這篇文章我把sys庫的核心API和它背后的運行機制好好拆一遍結(jié)合我實際調(diào)過的4G模組項目講講哪些接口到底怎么用、為什么這么用、踩了哪些坑。內(nèi)容會比較實操適合正在用LuatOS做項目或者剛接觸這塊想快速上手的同學。1. 先搞明白sys在處理什么鬼問題1.1 為什么嵌入式Lua需要一套“運行框架”傳統(tǒng)寫MCU固件要么裸機跑死循環(huán)要么上RTOS比如FreeRTOS、RT-Thread用任務調(diào)度。LuatOS不太一樣它把Lua腳本解釋器跑在模組上讓開發(fā)者用Lua寫業(yè)務邏輯。但問題來了——Lua本身是沒有“多任務并行”概念的。你在一個Lua腳本里寫死循環(huán)或等延時整個虛擬機就卡住了網(wǎng)絡回調(diào)、串口數(shù)據(jù)、按鍵事件全部沒法處理。所以必須有一個框架讓這些耗時操作能“讓出”執(zhí)行權(quán)等條件滿足了再接著往下跑。sys干的就是這件事。它的核心機制是用Lua協(xié)程coroutine模擬多任務每個業(yè)務邏輯可以理解為一個“小任務”。用消息循環(huán)來驅(qū)動事件把串口接收、網(wǎng)絡連接、定時器到期這些外設(shè)事件統(tǒng)一變成消息分發(fā)給對應的協(xié)程。提供定時器、延時、信號量等工具接口讓任務之間能協(xié)作和同步。一句話總結(jié)sys就是LuatOS的“操作系統(tǒng)內(nèi)核”只是它跑在Lua層之上。1.2 從裸輪到“框架”的思維轉(zhuǎn)變我遇到過不少從裸機開發(fā)轉(zhuǎn)過來的朋友上手LuatOS時總習慣在主流程里寫while true去輪詢狀態(tài)結(jié)果發(fā)現(xiàn)一卡就卡死懷疑是SDK有問題。其實不是是你的思路沒轉(zhuǎn)過來。用sys這套框架寫代碼核心思維是三個字不要等。不要用死循環(huán)等數(shù)據(jù)不要用delay等時間。一切靠事件驅(qū)動等串口數(shù)據(jù)就sys.waitUntil掛起等固定時間就sys.timerStart交給定時器等別的任務干完就sys.publish發(fā)個消息通知。提示剛上手時心里默念“一切皆消息無事掛等待”寫出來的代碼基本就能跑順。如果你發(fā)現(xiàn)自己在寫while true大概率是思路沒轉(zhuǎn)過來。2. sys庫核心API逐個拆解2.1 任務創(chuàng)建與延時sys.taskInit 和 sys.wait先記住最關(guān)鍵的一對接口sys.taskInit和sys.wait??疵志湍懿碌絪ys.taskInit用于創(chuàng)建任務也就是協(xié)程傳入一個函數(shù)和參數(shù)它就會把這個函數(shù)丟進調(diào)度器去跑sys.taskInit(function() while true do log.info(main, task running) sys.wait(1000) end end)這段代碼的意思是創(chuàng)建一個任務里面循環(huán)打印日志每次打印完等待1000毫秒。重點是那個sys.wait(1000)它在等的時候不會阻塞其他任務調(diào)度器會去跑別的協(xié)程等時間到了再切回來繼續(xù)往下執(zhí)行。注意一點sys.wait只能在sys.taskInit創(chuàng)建的任務里用因為它本質(zhì)上是讓出當前協(xié)程如果你在主代碼層面直接調(diào)用調(diào)度器根本不知道讓給誰。另外有個細節(jié)sys.taskInit返回的是一個協(xié)程句柄如果你后面想主動干掉這個任務可以用它來配合控制但多數(shù)情況下我們讓它跑完自己結(jié)束就行。2.2 定時器sys.timerStart / sys.timerLoopStart / sys.timerStop定時器是sys里使用頻率最高的一批函數(shù)。它和你自己寫延時不一樣因為定時器不占用任務上下文到點了由調(diào)度器自動觸發(fā)回調(diào)非常適合做“到點做事”這種場景??磶讉€最常用的-- 一次性定時器5000毫秒后執(zhí)行一次 sys.timerStart(function() log.info(timer, 5秒到了) end, 5000) -- 循環(huán)定時器每1000毫秒執(zhí)行一次最多循環(huán)10次 sys.timerLoopStart(function() log.info(timer, 每秒一次) end, 1000, 10) -- 停止指定定時器 local timerId sys.timerStart(function() log.info(timer, 這條可能不會執(zhí)行) end, 10000) sys.timerStop(timerId)注意sys.timerLoopStart的第三個參數(shù)是循環(huán)次數(shù)傳nil表示無限循環(huán)。這個參數(shù)經(jīng)常被忽略結(jié)果出現(xiàn)“明明只想循環(huán)10次結(jié)果一直跑不停止”的問題。sys.timerStart返回的timerId很關(guān)鍵如果你想在定時器觸發(fā)之前取消它必須保存這個ID。很多同學忘了保存導致定時器沒法停止只能在回調(diào)函數(shù)里加邏輯判斷挺繞的。2.3 消息訂閱與發(fā)布sys.subscribe 和 sys.publish如果你寫過前端這組接口就很像發(fā)布訂閱模式。它解決的是模塊間解耦的問題A模塊關(guān)心某件事就訂閱一個消息B模塊發(fā)生了那件事就發(fā)布這個消息內(nèi)容參數(shù)一并帶過去。-- 訂閱消息 DATA_READY sys.subscribe(DATA_READY, function(data) log.info(sub, 收到數(shù)據(jù), data) end) -- 在其他模塊發(fā)布消息 sys.publish(DATA_READY, hello)這組的精髓在于發(fā)布者不需要知道誰訂閱了訂閱者也不需要知道誰發(fā)布。這樣模塊之間就沒有強耦合代碼改起來很舒服。sys.waitUntil則是消息機制在任務里的延伸用法它會讓當前任務掛起等待某個消息收到消息后返回還能設(shè)置超時時間。sys.taskInit(function() -- 等待串口數(shù)據(jù)超時5秒 local data sys.waitUntil(UART_DATA, 5000) if data then log.info(task, 拿到數(shù)據(jù):, data) else log.info(task, 超時了沒等到) end end)這個功能特別適合“等結(jié)果”場景比如發(fā)了個HTTP請求等服務器響應或者向外部傳感器要數(shù)據(jù)等它回包。配合超時參數(shù)能有效防止任務無限掛起。2.4 框架啟動與重啟sys.run 和 sys.restart可能你會疑惑sys.taskInit也建了任務定時器也啟動了但整個調(diào)度器誰來啟動答案就是sys.run()。一般在LuatOS的main腳本里會這么寫-- 創(chuàng)建業(yè)務任務 sys.taskInit(function() while true do sys.wait(5000) log.info(main, 業(yè)務跑著) end end) -- 啟動系統(tǒng)調(diào)度 sys.run()sys.run()一旦調(diào)用就會進入框架主循環(huán)不要再在里面加任何阻塞代碼也不要期待它返回。所有業(yè)務邏輯都要通過任務、定時器、消息回調(diào)來運行。sys.restart(msg)用于軟件重啟腳本。傳一個字符串參數(shù)作為原因重啟時可以用...接住方便排查問題sys.restart(遇到異常嘗試恢復)3. 從零寫一個真實案例串口數(shù)據(jù)采集上報3.1 需求場景設(shè)定前面接口都過了一遍可能還有點零散。我拿一個真實做過的場景把它們串起來設(shè)備通過串口接一個傳感器每2秒從串口讀一次數(shù)據(jù)讀到的數(shù)據(jù)通過網(wǎng)絡上報到云平臺。同時本地要有一個心跳每30秒打印一次狀態(tài)防止任務跑飛。傳感器如果連續(xù)3次沒讀到數(shù)據(jù)要自動重啟串口外設(shè)。這個場景足夠有代表性里面會用到任務、定時器、消息、等待、超時處理這些核心機制。3.2 完整實現(xiàn)代碼-- 串口讀取任務 local uartMsg UART_NEW_DATA local uartId 2 local uartObj sys.taskInit(function() -- 打開串口這個API細節(jié)不同模組有差異但思路一致 uartObj uart.create(uartId, 115200, 8, 1, 0) uart.on(uartId, receive, function() -- 串口收到數(shù)據(jù)讀取全部緩存 local data uart.read(uartId, 8192) if data and #data 0 then -- 把數(shù)據(jù)發(fā)布給其他任務 sys.publish(uartMsg, data) end end) while true do -- 每2秒主動查詢一次傳感器狀態(tài) uart.write(uartId, GET_STATUS\r\n) sys.wait(2000) end end) -- 數(shù)據(jù)處理任務 sys.taskInit(function() local lostCount 0 while true do local data sys.waitUntil(uartMsg, 5000) if data then lostCount 0 log.info(data, 收到傳感器數(shù)據(jù):, data) -- 實際項目里這里做解析和上報比如 -- network.httpRequest(...) 或 mqtt.publish(...) else lostCount lostCount 1 log.warn(data, 本次沒讀到數(shù)據(jù)連續(xù)失敗:, lostCount) if lostCount 3 then log.error(data, 連續(xù)3次失敗重啟串口外設(shè)) uart.close(uartId) os.sleep(100) uartObj uart.create(uartId, 115200, 8, 1, 0) lostCount 0 end end end end) -- 心跳定時器 sys.timerLoopStart(function() log.info(heartbeat, 系統(tǒng)運行正常) end, 30000) sys.run()3.3 這段代碼里的設(shè)計思路先看串口讀取任務。它每2秒主動給傳感器發(fā)一次GET_STATUS指令收到數(shù)據(jù)后在回調(diào)里用sys.publish發(fā)消息數(shù)據(jù)讀取任務用sys.waitUntil等這個消息。這樣串口回調(diào)這個“不占任務上下文”的事件就和數(shù)據(jù)處理這個“需要長期運行”的邏輯松耦合了。再看超時處理。傳感器偶爾會沒回數(shù)據(jù)如果直接用阻塞延時等會卡住整個任務。這里用sys.waitUntil(uartMsg, 5000)5秒等不到就把data置為nil走超時分支。連續(xù)3次都超時后執(zhí)行串口重啟。這個設(shè)計極大地增強了異?;謴湍芰崪y在傳感器偶發(fā)無響應的時候非常管用。最后是啟動調(diào)度器。所有任務創(chuàng)建完最后調(diào)用sys.run()進入主循環(huán)。有人會把sys.run()放在腳本開頭然后后面創(chuàng)建任務這在有些版本里也行但按慣例最后調(diào)用最清晰邏輯也順。3.4 為什么選“消息等待”而不是直接全局變量很多從單片轉(zhuǎn)過來的人第一反應是串口回調(diào)收到數(shù)據(jù)直接丟進一個全局變量數(shù)據(jù)處理任務輪詢這個變量不就行了從功能上說確實能跑但問題是代碼會越來越亂如果后面還要同時處理按鍵、網(wǎng)絡、定時上報等多個事件每個都靠全局變量還要自己處理“有沒有新數(shù)據(jù)”這種標志位復雜度直線上升。用sys.publishsys.waitUntil每個數(shù)據(jù)源各發(fā)各的消息每個任務的邏輯里各自等待對應消息互不干擾新加模塊也不影響舊邏輯。如果你以后要維護一個幾千行的LuatOS工程會深刻體會到“解耦”兩個字有多值錢。4. 常見問題與排查技巧實錄4.1 問題排查速查表先放一個速查表都是我在實際調(diào)試中遇到的高頻問題現(xiàn)象大概率原因解決辦法sys.wait不生效或報錯在非任務上下文里調(diào)用確認在sys.taskInit創(chuàng)建的協(xié)程內(nèi)調(diào)用定時器只觸發(fā)一次就不再跑用了sys.timerStart但以為是循環(huán)需要循環(huán)用sys.timerLoopStarttimerLoopStart不停止第三個循環(huán)次數(shù)沒傳傳具體次數(shù)或手動timerStopwaitUntil永遠等不到消息名拼寫不一致訂閱和發(fā)布的消息ID必須完全一致設(shè)備串口收到數(shù)據(jù)但任務沒反應串口回調(diào)里做了耗時操作回調(diào)里只publish耗時處理放任務里系統(tǒng)隨機卡死協(xié)程里出現(xiàn)未捕獲異常用xpcall或sys自帶的錯誤鉤子打印調(diào)用棧內(nèi)存緩慢增長定時器任務里創(chuàng)建了閉包沒釋放檢查回調(diào)里是否反復創(chuàng)建局部函數(shù)量大引用4.2 協(xié)程卡死怎么排查sys框架里最煩人的問題是“某個任務不動了”但系統(tǒng)其他功能還正常。此時先不要急著懷疑SDK按這個思路排查先看任務里有沒有sleep、while true這種阻塞邏輯。有一回我排查一個奇怪的“設(shè)備半死”問題最后發(fā)現(xiàn)是一個任務里寫了os.sleep(1000)以為沒多大事結(jié)果這個任務正好持有某個鎖其他任務都在等它整個邏輯就卡住了。換成sys.wait之后立竿見影。再看有沒有“條件永遠等待”的情況。比如sys.waitUntil(WAIT_MSG)沒給超時參數(shù)而發(fā)布這個消息的模塊又因為外設(shè)異常掛了那么這個任務會永遠掛起。這類問題最好的預防手段就是所有waitUntil盡量都帶超時。最后看日志。LuatOS運行出錯時一般會有Lua traceback把它完整保存下來。如果沒開完整日志可以加一個全局錯誤鉤子sys.taskInit(function() while true do sys.wait(10000) local m collectgarbage(count) log.info(mem, 內(nèi)存占用KB:, m) end end)這樣能實時監(jiān)控內(nèi)存排查泄漏時很有用。4.3 定時器精度與大任務沖突sys的定時器在通常的LuatOS場景下毫秒級偏差是可以接受的但如果你需要高精度的定時比如PWM波形的節(jié)拍不要依賴定時器回調(diào)要在中斷或?qū)S糜布〞r器里做。還有一個常見坑定時器回調(diào)函數(shù)里如果做了耗時操作比如大量日志、字符串拼接、網(wǎng)絡請求會推遲后續(xù)定時器觸發(fā)。這是因為LuatOS跑在單線程Lua虛擬機上回調(diào)執(zhí)行期間調(diào)度器沒法切出去。解決方法是回調(diào)里不要處理重邏輯只發(fā)消息由專門任務去執(zhí)行耗時工作。這也是我前面案例里“串口回調(diào)只publish”的同一個原則。4.4 內(nèi)存泄漏和循環(huán)引用Lua有自動垃圾回收但嵌入式設(shè)備內(nèi)存小GC壓力大。在sys.timerLoopStart回調(diào)里如果不斷創(chuàng)建匿名函數(shù)且該函數(shù)捕獲了外部變量就會導致內(nèi)存回收不及時甚至永久泄漏。我建議循環(huán)定時器回調(diào)盡量引用全局函數(shù)而不是每次創(chuàng)建新閉包。大塊數(shù)據(jù)用完后置nil別等GC自己動手。需要頻繁創(chuàng)建銷毀的任務注意任務結(jié)束時要讓所有引用都斷掉。4.5 利用消息機制降耦合的實踐心得最后分享一個我后來一直沿用的習慣無論項目大小在寫LuatOS業(yè)務前先花幾分鐘定義好消息字典。比如-- msg_define.lua local M {} M.UART_DATA UART_DATA M.KEY_PRESSED KEY_PRESSED M.NET_WORK NET_WORK M.OTA_DONE OTA_DONE return M所有模塊統(tǒng)一引用這個字典而不是到處寫字符串字面量。這樣不僅能防止消息名拼錯還能讓你一眼看出整個系統(tǒng)有哪些事件在流轉(zhuǎn)。調(diào)試時把消息名打到日志里整個系統(tǒng)的信息流就看得清清楚楚。注意發(fā)布消息時如果帶中文或表格數(shù)據(jù)接收方直接打印可能顯示為table最好用json.encode或log.info里的格式化功能轉(zhuǎn)成可讀字符串不然定位問題非常痛苦。5. sys庫之外還需要知道的事sys這套框架不是孤立的它和LuatOS其他模塊配合才能發(fā)揮最大價值。比如網(wǎng)絡狀態(tài)變化會通過sys.publish廣播NET_STATE_CHANGED之類的事件你可以在自己的業(yè)務任務里訂閱這些事件及時更新設(shè)備狀態(tài)。底層SDK的底層外設(shè)驅(qū)動也經(jīng)常會回調(diào)到Lua層觸發(fā)sys調(diào)度所以理解sys其實就理解了整個LuatOS的事件驅(qū)動根基。如果你要往高級方向進階可以去讀一讀LuatOS的sys庫源碼在SDK的luat/modules/sys.lua附近里面核心實現(xiàn)其實并不復雜就是用協(xié)程加事件表驅(qū)動調(diào)度。讀懂了之后再遇到靈異現(xiàn)象你會比看文檔的人更快定位問題。我個人在實際操作中最深的一個體會是這框架的所有設(shè)計核心目標就一個——“別讓整個系統(tǒng)因為某個慢操作而停擺”。想通這一點你用sys的思路就會很自然把慢操作都包在任務里把跨模塊的協(xié)作都交給消息把所有等待都帶上超時。做到這三點你的LuatOS項目基本不會出大問題。