iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

一天一個 PR,讓我告訴你嵌入式開發的殘酷:如何為 AI Agent 建立可信的工程閉環系列 第 3

Day 3 - 為了知道 Command 跑完沒,我們先把LOG搞髒了:UART LOG 汙染

  • 分享至 

  • xImage
  •  

系列說明 >> 本系列大部分 PR 來自私人或公司專案,部分程式碼、環境設定與實作細節不便公開。

我只能保證文中提到的 PR 與問題皆為真實案例,但會經過必要的匿名化與內容調整。

本系列主要分享問題如何被發現、背後的思考方式,以及解法如何形成,不會深入討論完整實作與部署細節,敬請見諒。

Today’s Change

  • Change Ref: PR #4-feat(serialwrap): 重構透明多終端 UART broker
  • Issue: serialwrap 透過 BEGIN/RC/END Marker 判斷 Command 結果,但遇到 Reboot、Background Output、Interactive 操作,或 Human 與 Agent 同時使用 UART 時,原本的判斷方式開始失準。
  • Root Cause: Command Lifecycle、Session State 與 UART Ownership 被拆散在 tmux、minicom、Target Shell、Log Marker 與下游 Parser 裡,沒有單一 component 對整段 UART 操作負責。
  • Solution: 移除 Marker Injection,改由 serialwrapd 直接持有實體 UART,統一管理 Command、Console 寫入與 Recovery;Target 只接收原始 Command 與 Raw Keystrokes。
  • Evidence: 完成 Unit Tests 與安裝版實機驗證;COM0/COM1/COM2 Self-test 最終皆為 OK,並驗證一般 Command、Background Output、Interactive、Recovery 與多個 Minicom Console。

才剛把垃圾掃乾淨——TestPilot 就開始管 Marker 了

技術債,技術債,一時快樂一時債,一直快樂一堆債。

之前 serialwrap 用得有多爽,現在接進測試就有多痛。

上一篇為了讓 Full Run 真的跑完,TestPilot 補了 Command Extraction、Readback Fallback、RC 判斷,最後還得把 Report 裡的 BEGIN、RC、END、Shell Prompt 與 Command Echo 全部清掉。

原本是 serialwrap 用來辨認 Command 的格式,最後卻變成 TestPilot 也必須理解的格式。

UART Log 看不懂,還可以先怪 Marker 太多。

但當 TestPilot 必須知道 Marker 長什麼樣子,才能把它們清掉時,問題就不只是 Log 難看了。

上一篇已經把結論講得很清楚:

不再補 Regex,也不再多寫一層 Sanitizer。

葵花寶典記載,欲練神功,必先自宮!

長痛不如短痛,脖子一伸,手起刀落。

Marker,砍了吧!

FullRun

Marker 一開始其實很好用

不過在正式殺死 Marker 以前,還是先替它說句公道話。

這東西當初不是亂塞的,而且在 Day 1 的使用情境裡,確實很好用。

在沒有 serialwrap 的情況下,Agent 可以透過 tmux 對 minicom 下 Command,但 UART Log 是一段持續增加的文字。

裡面可能同時包含前一條 Command 的 Output、Shell Prompt、Command Echo、Kernel Log、Background Message,以及工程師剛才手動輸入的內容。

serialwrap 必須知道:哪一段才屬於剛剛送出的 Command?

最直接的方法,就是在 Command 前後加上唯一 Marker,再把 Return Code 一起印出來:

echo __BEGIN_abcd1234__
real_command
rc=$?
echo __RC_abcd1234__=$rc
echo __END_abcd1234__

接著從 minicom Log 裡找到 BEGIN 與 END,把中間那一段切出來。

Agent
  ↓
tmux send-keys
  ↓
minicom
  ↓
BEGIN + Command + RC + END
  ↓
DUT UART
  ↓
minicom log
  ↓
Marker Extraction
  ↓
Command Result

對一般 Shell Command 而言,這套方法很有效。

每次產生不同的 ID,Marker 幾乎不會撞名;BEGIN 與 END 之間就是這次的 Output,RC 則提供成功或失敗的判斷。

重點是,Parser 很好寫。

serialwrap 不需要辨認每一種 Prompt,也不用從一大段 Log 裡猜 Command 到底在哪裡結束。

Day 1 做到這裡時,我確實覺得問題差不多解完了。

直到接進 TestPilot,開始遇到那些不照著:

Command
→ Output
→ Prompt
→ Done

這條路走的非典型操作。


Marker 能切字串,但管不了 UART 的各種花式死法

BEGIN 與 END 最先遇到的問題,是它們只能表示字串邊界。

例如:

echo __BEGIN__
false
echo __END__

中間的 false 已經失敗,但 END 還是會出現,所以後來又補了 RC Marker。

再來是:

reboot

如果 reboot 真的成功,原本的 Shell 會直接消失,RC 與 END 根本沒有機會印出來。最成功的一次 Reboot,最後反而會被判成 Timeout。

還有 Background Command:

(sleep 1; echo TOKEN)&

Prompt 很快就回來,但 TOKEN 一秒後才出現。看到 Prompt 就結束,晚到的 Output 可能會被算進下一條 Command。

更麻煩的是 Interactive:

啟動工具
→ 等待輸入
→ 輸入文字
→ 再等待
→ Ctrl-C

Bootloader Menu、Password Prompt、menuconfig,甚至單純開一個 cat,都不是送出一行文字後等待 END 的操作。

BEGIN、RC、END 並不是完全沒用。

它只是把「會正常回到 Shell 的 Line Command」當成了所有 UART 操作的共同形狀。

單獨執行幾條 Command 時,這個假設很少出事。Full Run 把 Reboot、Background、Interactive 與不同 Target State 全部放進來後,例外就開始比正常流程還忙。

FullRun

Marker 拿掉,改成等 Prompt 不就好了?

既然 Marker 會污染 UART,最直接的替代方案,就是送出原始 Command,等 Shell Prompt 回來,再把中間的 Output 當成 Result。

Command
→ Output
→ Prompt
→ Result

一般的 pwdcat 或 Query Command 確實可以這樣處理。

問題是,前面那些 Case 一個都沒有消失。

reboot 不會回到原本的 Prompt;Background Output 會在 Prompt 後繼續出現;Interactive 操作則根本沒有單一 Prompt 可以代表完成。

所以「等 Prompt」可以成為一種 Command Mode,但不能取代整套 Marker Model。

至少必須分清楚:

  • 一般 Command:等待 Prompt 後完成。
  • Background Command:Prompt 可以先回來,但後面還有 Output。
  • Interactive:維持一段 Session,持續收送 Raw Keystrokes。
  • Recovery:Target 不回應時,不能只回一個 Timeout。

到這裡,問題已經不是怎麼把 stdout 切得更準。

這些都是 Command 在 Host 端的生命週期,必須有地方記住目前正在做什麼。


那就讓 minicom 繼續持有 UART,外面再加一把 Lock?

舊架構裡,實體 UART 由 minicom 開啟。

Agent 透過 tmux 對 minicom 下 Command,再從 minicom Log 取回結果。

如果只是擔心兩個 Agent 同時輸入,看起來可以在 Agent 的操作路徑外面加一把 Lock:

Agent A ─┐
         ├─> Lock ─> tmux ─> minicom ─> UART
Agent B ─┘

Agent 之間確實可以排隊。

但工程師仍然可以直接在 minicom 裡輸入。

有時候我看到 Agent 剛下完一條 Command,覺得方向不太對,便順手補上一條:

logread

同一時間,Agent 又送出:

dmesg -n 1

最後 DUT 可能收到:

logredmesg -n 1ad

這條 Command 不是我下的,也不是 Agent 下的。

是我們一起完成的。

是的,我就是那個來添亂的工程師,我就手賤咩……(搓手)

原本實體 UART 是由 minicom 開啟,Agent 再透過 tmux 間接操作 minicom。工程師坐在 minicom 前面時,輸入根本不會經過 Agent 使用的 Lock。

Human ──────────────> minicom ─> UART
Agent ─> Lock ─> tmux ─┘

問題不在 Lock 本身。

問題在於這把 Lock 只管得到 Agent。

當然,也可以繼續替每一個入口補 Lock。Agent 一把、CLI 一把、測試流程一把,未來再多一個 Tool,就再多補一把。

但只要這些 Lock 不在實體 UART 的共同入口,它們就只能保證自己的 Caller 很守規矩,無法保證 UART 最後收到的資料沒有和別人混在一起。

到這裡,幾個比較便宜的方向差不多都走過了:

拿掉 Marker,改等 Prompt
→ 只能處理一般 Command

替 Agent 操作加 Lock
→ Human Input 仍然可以繞過

Marker 可以處理字串切割,卻無法表示所有 UART 操作的生命週期。

Lock 可以限制某一條操作路徑,卻無法管理整條 UART。

真正缺少的不是另一種 Parser,也不是另一把 Lock。

是 UART 必須有一個共同的 Owner。

我只是想偷個懶,怎麼就這麼難啊?

FullRun

有個大膽的想法:把 UART 全部收回來

原本我的設想,是讓 serialwrap 當一個中繼輔助的角色,越感覺不到它的存在,我就越舒服。

我不想改掉自己使用 minicom 的習慣,也不想為了 Agent 再維護另一套 UART 操作方式。

但所有 TX 如果都必須經過同一個入口,那乾脆一不做二不休:

實體 UART 全部收回來。
你們都不准再自己拿。

我的 UART 我來管,我命由我不由天!

新的方向變成:

Human Minicom ─┐
Agent CLI      ├──> serialwrapd ───> Physical UART ───> DUT
其他 Client   ┘

這次由 serialwrapd 直接持有實體 UART。

Human 使用的 minicom 不再直接連到 TTY,而是連到 Broker 提供的 Console;Agent 與其他 Client 也從同一個入口提交操作。

UART RX 可以 Fan-out。

工程師、Agent 與 Log Collector 都能看到相同的資料。

但 TX 必須全部經過同一個寫入點,再由 Broker 決定現在輪到誰。

RX:一份資料,可以分給很多人看
TX:所有輸入,必須排成同一條隊伍

Command ID、目前正在執行哪一種操作、是否 Timeout,以及失敗後進行了哪些 Recovery,也都留在 Host 端管理。

Target 只接收真正要執行的 Command,或 Interactive 操作需要的 Raw Keystrokes。

不再收到 BEGIN、RC 與 END。

也不需要替 serialwrap 維護一套與 DUT 本身無關的控制格式。

看起來很完美。

我可以繼續使用 minicom,Agent 也能透過同一個入口操作,TestPilot 則不用再理解 Marker。

我果然是天才……

當時的我確實是這樣想的。

FullRun

架構圖畫完,還是得插上真的 UART

完成重構後,除了 Unit Tests,也使用安裝版 daemon 驗證 COM0、COM1 與 COM2。

三個 Session 的 Self-test 最後都回到:

OK

一般 Command 分別執行了 echopwd;Background Command 驗證 Prompt 回來後,延遲 Output 仍然能被取得;Interactive 則使用 cat 測試文字輸入、Enter 與 Ctrl-C

COM0 與 COM2 也實際透過 Minicom Console,確認工程師原本的操作方式仍然能用。

其中 COM1 曾回報:

TARGET_UNRESPONSIVE

接著經過:

session recover

才恢復正常。

這次驗證的重點,不只是拿掉 Marker 後仍然能下 Command。

一般 Command、Background、Interactive、Human Console 與 Recovery,確實都走過同一條 UART Path。

水啦,這條路看起來行得通。

至少 Broker 不是只存在於架構圖裡。


一條 Command 有 Result,Case 就有證據了嗎?

到這裡,serialwrap 已經可以對一條 UART 操作負責。

TestPilot 不必再理解 Marker Grammar,也不用先清掉 serialwrap 的控制字串,才能讓人看懂 UART Output。

但 Test Case 需要的資訊,仍然不只是一條 Command 的 stdout 與 RC。

Case A 觸發的 Kernel Log,可能在 Command 完成後才出現;DUT 與 STA 也可能在同一段測試期間,各自產生需要保留的 UART 訊息。

只保存整理過的 Result,還是不知道整個 Case 的現場從哪裡開始,又在哪裡結束。


今日工事: serialwrap_lib.py 正式退場,下台一鞠躬。

舊時代 Marker 的眼淚,就讓它留在舊時代。

歷史,已經翻頁了。

下一篇,TestPilot 不再只接 stdout。

我們開始讓每一個 Test Case 對應到真正的 UART Evidence。

Have a nice day.


上一篇
Day 2 - Test PASS,但只跑了三分之一:揭穿自動化測試的假成功
下一篇
Day 4 - 測試都自動化了,還在人工剪 Log:UART 資料為什麼要拆成三層
系列文
一天一個 PR,讓我告訴你嵌入式開發的殘酷:如何為 AI Agent 建立可信的工程閉環4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言