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