2026年11月13日 星期五

In-Car ECU Field Guide: Module Types, Vehicle Networks, and Finding ECUs on a Test Bench with PCAN and UDS | 車內 ECU 圖鑑:模組種類、車內網路,以及用 PCAN+UDS 在測試台上找出有哪些 ECU

一台車上有幾十顆電子控制模組(ECU),各管一塊:引擎、煞車、車門、儀表、影音、通訊……它們再用 CAN、LIN、車用乙太網路串在一起。這篇分兩部分:前半是我整理的車內 ECU 圖鑑(38 種模組的位置、功能、別名、維修重點,加上車內網路和故障碼字首);後半是到一座車廠測試台上實作,用 PCAN 和 Python 找出台上實際有哪些 ECU、讀出它們的名稱和版本,最後試著假扮 ESP 讓儀表顯示車速(沒成功,但學到很多)。

說明:測試台是工作上的開發設備,文中不寫車廠、車型、料號與 VIN,也不放實機照片,只寫通用的做法和觀察。

一、車上有哪些 ECU:互動圖鑑

起點是一份介紹 ECU 種類的影片簡報,裡面列了 BCM、PDM、ACM、EVIC、SM、RSM、IC、DDM、HSM、MSM、APM、SKIM、TCM、CAB、PCM 這 15 個縮寫(都是某美系車廠的叫法)。光看縮寫很難記,所以我把它擴充成一個單頁網頁圖鑑:自己畫一台 SUV 線稿,把 38 種模組標在大致的位置上,點編號就在右邊看說明。


顏色依領域分,借用車用線束的顏色慣例當記憶點:安全氣囊的線束和接頭本來就是黃色,電動車高壓線是橘色。上方的按鈕可以只看某一個領域:

領域代表模組一句話
動力ECM/PCM、TCM引擎噴油點火、變速箱換檔
底盤ABS/ESC、EPS、EPB、TPMS、SAS煞車、轉向、手煞車、胎壓、轉向角
車身BCM、IC、HVAC、DDM/PDM、MSM、SKIM、ELV、EVIC、RSM、SM燈光門鎖、儀表、空調、車門、座椅、防盜、轉向柱鎖、頂置控制台、雨量、天窗
被動安全ACM/ACU、OCS氣囊、判斷前乘客座有沒有人
資訊娛樂IVI、AMP、TCU影音主機、擴大機、4G/5G 車載通訊
駕駛輔助前視攝影機、前雷達、盲點雷達、停車輔助、ADAS 域控制器車道維持、主動巡航、AEB
電動高壓VCU、BMS、MCU/逆變器、OBC、DC-DC整車控制、電池管理、馬達、充電、取代發電機
網路CGW 中央閘道、OBD-II 診斷座網段之間轉送、診斷入口

名詞卡片可以搜尋縮寫、英文或中文。同一種模組在不同車廠名字常常不一樣,例如車身控制模組 BCM 在 Benz 叫 SAM、在 BMW 叫 FEM/BDC、在 Volvo 叫 CEM,卡片的「別名」欄都列出來了;另外像 TCU 同時是「變速箱控制」和「車載通訊」的縮寫,也特別註明:

二、模組之間怎麼溝通

模組依領域掛在不同網段上,中央閘道(Gateway)負責在網段之間轉送需要的訊號,也是 OBD-II 診斷的入口。車窗、座椅馬達、雨量感知器這類便宜的小東西,多半掛在 BCM 或車門模組底下的 LIN 子匯流排:

  • CAN(ISO 11898):CAN-H/CAN-L 雙絞線,兩端各一顆 120 Ω 終端電阻;在 OBD 座量 pin 6 和 14 之間約 60 Ω 就表示兩顆都在。動力常見 500 kbit/s,車身 125~500 kbit/s。
  • CAN FD:資料段可加速到 2~5 Mbit/s、每框 64 位元組,底盤和刷寫韌體用得多。
  • LIN:單線 19.2 kbit/s、一主多從,最便宜。
  • FlexRay:10 Mbit/s、時間觸發、雙通道備援,高階車的底盤線控;後半段實作會遇到它。
  • 車用乙太網路(100/1000BASE-T1):一對雙絞線就能跑,攝影機、ADAS、影音主機和新一代骨幹網路用。
  • UDS(ISO 14229):統一診斷服務,修車廠的診斷電腦就是用它跟模組對話;後面掃描用到的是 0x22「讀資料」和 0x19「讀故障碼」。

三、故障碼開頭字母

四、實作:在測試台上找出有哪些 ECU

手邊有一座車廠的測試台:一個鐵架上裝著一部分真車模組(閘道、兩顆車身控制模組、轉向柱鎖、駕駛側車門模組、數位儀表、影音主機、車載通訊盒),背板把每條網段拉成一個 DB9 孔,標著「車身」「儀表」「資訊娛樂」「通訊」「診斷」……還有 FlexRay 和 LIN。電腦端用 PCAN-USB 加 python-can,全部用 SSH 遠端操作。

第 1 步:只聽不送,看匯流排上有誰

PCAN 先插在車身 CAN 的孔,聽 15 秒,統計每個 CAN ID 出現幾次、週期多長:

import can, time, collections
bus = can.Bus(interface="pcan", channel="PCAN_USBBUS1", bitrate=500000)
seen = collections.Counter(); t0 = time.time()
while time.time() - t0 < 15:
    m = bus.recv(0.5)
    if m is not None and not m.is_error_frame:
        seen[(m.arbitration_id, m.is_extended_id)] += 1
bus.shutdown()
for (i, ext), n in sorted(seen.items()):
    print(f"{i:08X}" if ext else f"{i:03X}", n, f"{15000 / n:.0f} ms")

結果是 85 個 ID、每秒約 270 框。光看 ID 不知道誰送的,但裡面有一組很有用的訊框:網路管理(NM)訊框。每顆醒著的模組會定期廣播「我在線上」,這個平台的 NM 訊框 ID 是固定前綴加上一個位元組的節點號,所以把前綴相同的挑出來,就知道這條匯流排上有 4 個節點。後來 UDS 掃描證實:節點號正好等於該模組診斷位址的低位元組。

第一個坑:隔了一陣子再跑,2 秒內一框都沒有,送出去的請求也沒人回 ACK,PCAN 報「錯誤計數達到警告值」。原因是測試台睡了:點火(KL15)關掉、車門也沒動之後,網路管理會讓整條 CAN 進入休眠,完全安靜。按一下啟動鈕就醒了。之後的掃描程式都先聽 2 秒,確認有訊框才開始。

第 2 步:UDS 掃描,問每個位址「你是誰」

每顆模組都有一組診斷用的 CAN ID:一個收請求、一個回答案。掃描就是對 11-bit 的 0x700~0x7EF、以及 29-bit 的一段位址,逐一送 22 F1 97(ReadDataByIdentifier,讀「系統名稱」),有回 62 F1 97 … 的就是存在的模組;再對它們讀料號(F187)、軟體版本(F189)、硬體料號與版本(F191、F1A3)、VIN(F190)。只用讀取服務,不切診斷模式、不寫入、不清碼。

名稱通常超過 7 個位元組,回應會用 ISO-TP 分成多框:收到首框(PCI 0x1_)要立刻回一個 Flow Control(30 00 00),模組才會繼續送連續框(0x2_)。另外模組可能先回 7F 22 78(處理中,請等),這時要把等待時間拉長,不能當成失敗:

def read_did(req, ext, did, timeout=0.2):
    send(req, ext, [0x03, 0x22, did >> 8, did & 0xFF])
    t_end, buf, total, rid = time.time() + timeout, b"", 0, None
    while time.time() < t_end:
        m = bus.recv(0.02)
        if not is_response(m, req, ext):
            continue
        d = bytes(m.data); pci = d[0] >> 4
        if pci == 0:                                  # 單框
            p = d[1:1 + (d[0] & 0x0F)]
            if p[:1] == b"\x7f" and p[2:3] == b"\x78":  # 處理中,多等一下
                t_end = time.time() + 2; continue
            return m.arbitration_id, p
        if pci == 1:                                  # 首框:回 Flow Control
            total = ((d[0] & 0x0F) << 8) | d[1]; buf, rid = d[2:], m.arbitration_id
            send(req, ext, [0x30, 0x00, 0x00]); t_end = time.time() + 1
        elif pci == 2 and m.arbitration_id == rid:    # 連續框
            buf += d[1:]
            if len(buf) >= total:
                return rid, buf[:total]
    return None, None

每個位址等 150~200 ms,全部掃完約 1~2 分鐘。我把掃描程式丟到測試電腦上用排程工作自己跑、結果寫成文字檔再取回,因為那台筆電的 Wi-Fi 很不穩(螢幕一合上,天線收訊就掉到快斷線),SSH 撐不了整段掃描。

第 3 步:接車身 CAN 和接診斷孔,掃到的不一樣

模組接車身 CAN接診斷孔原因
轉向柱鎖、駕駛側車門模組、第二顆車身控制模組有回有回本來就掛在車身 CAN 上,直接問得到
中央閘道不回(只看得到它的 NM 訊框)有回閘道只在診斷那一側回應診斷請求
數位儀表、影音主機、車載通訊盒看不到有回在別的網段,要靠閘道從診斷孔轉送過去
第一顆車身控制模組看不到有回掛在另一條匯流排
方向盤按鍵、頂置控制台、上下觸控螢幕—不回LIN 從屬節點或掛在主機後面,不直接回診斷

所以接車身 CAN 只找到 3 顆,接診斷孔找到 8 顆。結論很實用:要盤點模組、讀版本、讀故障碼,就接診斷孔;要監聽或模擬車身訊號(例如開關車門、點火狀態),就接那條網段本身,因為閘道不會把這類週期訊息從診斷孔轉進去。最後測試台上接了兩支 PCAN,一支固定接診斷孔、一支固定接車身 CAN,用 PCAN_USBBUS1/2 分開,診斷和測試腳本互不干擾。

這個平台的 11-bit 診斷 ID 有固定規律:回應 ID=請求 ID+固定差值,例如請求 0x74A 就從 0x7B4 回來。有趣的是,有兩顆模組在位址 0x700、0x701 也會「順便」回一次,掃描結果要用回應 ID 去重,才不會多算。掃描結果還有一個用途:通訊盒讀到的軟體版本,剛好和刷寫資料夾裡的軟體包版本一致,以後刷寫前後跑一次掃描,就能確認版本有沒有換成功。

第 4 步:驗證 E2E 保護(AUTOSAR CRC8)

和安全相關的 CAN 訊息(點火狀態、車速、煞車……)每一框都帶 E2E 保護:第 0 個位元組是 CRC,第 1 個位元組的低 4 位是 0~15 循環的計數器。接收端只要 CRC 對不上就整框丟掉,所以想自己送這類訊息,一定要先把 CRC 算對。

常見的做法是 AUTOSAR 的 CRC8(多項式 0x2F、初值和最後 XOR 都是 0xFF),對第 1~7 位元組做 CRC,最後再多餵一個「Data ID」位元組,每種訊息的 Data ID 不同。怎麼確定這個平台也是這樣算?拿監聽到的點火狀態訊框來反推:0~255 每個 Data ID 都試一次,看哪一個算出來的 CRC 跟實際的第 0 個位元組一樣:

def crc8_autosar(data):
    crc = 0xFF
    for b in data:
        crc ^= b
        for _ in range(8):
            crc = ((crc << 1) ^ 0x2F) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
    return crc ^ 0xFF

def e2e_crc(frame, data_id):            # frame[0] 是 CRC,算 frame[1:] 加 Data ID
    return crc8_autosar(bytes(frame[1:]) + bytes([data_id]))

for f in captured_frames:               # 兩框計數器不同的真實訊框
    print(f.hex(), [hex(i) for i in range(256) if e2e_crc(f, i) == f[0]])

兩框(計數器 11 和 13)都只有同一個 Data ID 對得上,而且跟開源社群公開的同平台實作一致,表示演算法確定沒錯。這招很好用:只要有一框真實訊息,就能確認 CRC 算法和那個訊息的 Data ID。

第 5 步:假扮 ESP 讓儀表顯示車速(失敗)

測試台上沒有引擎和 ESP,所以儀表一直顯示 0 km/h,下面亮一排煞車、ABS、胎壓、氣囊的故障燈。我把一支 PCAN 改插「儀表」那條網段,先聽:99 個 ID,裡面沒有任何 10~20 ms 的動力或車速訊息,表示沒有人在送車速,我送的話不會跟人搶。接著用開源資料裡同集團另一個平台的車速訊息格式(ID、位元位置、0.01 km/h 單位)加上算好的 CRC 和計數器,每 20 ms 送一框:

  • 車速 0→120 km/h 來回兩趟:儀表沒動。
  • 車速固定 60 km/h,把「車速有效旗標」從 0 試到 7,每種 5 秒:儀表還是沒動。

每次都要人盯著儀表很沒效率,所以換一個能自動判斷的方法:一邊送車速、一邊用 UDS 0x19 讀儀表自己的故障碼,比對送之前、送的時候、停止後。儀表有 83 筆故障碼,其中 30 筆是作用中的「收不到某某訊號」;如果我送的訊息被收下,對應那筆的「目前失敗」狀態位元應該會在幾秒內清掉,CRC 錯的話反而可能多出一筆「訊號無效」:

base = read_dtcs()                       # {DTC: 狀態位元組}
start_sending_speed()                    # 背景執行緒每 20 ms 送一框
time.sleep(8)
during = read_dtcs()
for k in sorted(set(base) | set(during)):
    if base.get(k) != during.get(k):
        print("變化", k, base.get(k), "→", during.get(k))

結果三次都是 83 筆、作用中 30 筆,一筆都沒變:儀表完全不理這個訊息。推測原因:這個平台的 ESP 和動力系統在實車上走 FlexRay,由閘道整理後再轉成儀表 CAN 上的訊息,轉過來的 ID、格式和 Data ID 都不一定跟別的平台一樣。沒有車廠的通訊矩陣(K-Matrix,DBC/ARXML),只能亂槍打鳥,所以先停在這裡。另外拍照才發現,這台是電動車版儀表,左邊那顆其實是功率表(READY/POWER),本來就沒有轉速可以顯示。

學到的幾件事

  • 先聽再送:NM 訊框就能數出網段上有幾顆模組,節點號還能對到診斷位址。
  • 匯流排完全安靜、送出去沒人 ACK,先懷疑測試台在休眠,不是線接錯。
  • 盤點模組接診斷孔,模擬車身訊號接該網段;兩支 PCAN 分工最省事。
  • E2E CRC 可以用一框真實訊息反推驗證;但不同訊息、不同平台的 Data ID 不能套用。
  • 想讓儀表顯示數值,沒有 K-Matrix 很難;邊送邊比對故障碼,比盯著儀表有效率。

延伸閱讀:用 PCAN 在 ATS 操作台讀 OBD-II 行車電腦模擬器、把車速餵給 Geotab IOX 車機(更早的 PeakCAN 與 ECU Simulator 通訊 系列)(OBD-II 是標準化的診斷,跟這篇的 UDS 掃描是同一套 ISO-TP 底層)、ATS 操作台的 CAN 介面。

Reading an OBD-II ECU Simulator with PCAN in the ATS Console and Feeding Live Vehicle Speed to a Geotab IOX Device | ATS 操作台讀行車電腦模擬器:OBD-II 掃描視窗、PCAN 實測,把即時車速餵給 Geotab IOX 車機

測車機(行車記錄器這類接在車上的裝置)時,常常需要「車子正在跑」的資料:車速、轉速、里程。實車不方便,所以手邊有一台行車電腦模擬器,它是一顆假的引擎 ECU,接上 OBD 診斷就會回車速、轉速,數值用旋鈕轉。這次在自己寫的 ATS 網頁操作台裡加了一個獨立的 OBD-II 掃描視窗,用 PCAN 讀模擬器的參數,再把讀到的車速當成 Geotab GO 的車輛資料,送給走 Geotab IOX 協定的車機。這篇記錄做法、實測數字和踩到的坑。

行車電腦模擬器 IMSB5010

這台是 imsapp 的 OBD2 ECU Simulator(型號 IMSB5010),有 LCD、6 顆旋鈕、MIL 按鈕和 USB 埠。手冊整理成下表:

項目內容
協定ISO15765-4(CAN)11-bit/29-bit × 250K/500K,另有 ISO9141-2、ISO14230 KWP(K-Line),用左下 4 位 DIP 開關切換,LCD 第一行會顯示目前協定
OBD 母座6 CAN-H、14 CAN-L、4/5 地、16 電源(附 13 V 變壓器)
6 顆旋鈕MAF(PID 10)、水溫(05)、轉速(0C)、車速(0D)、進氣溫度(0F)、節氣門(11)
MIL 按鈕按下去 MIL 亮、產生已存/待確認/永久故障碼和凍結資料
USBCP2102,115200 8N1,可以用指令改 PID 值、VIN 等
支援服務Mode 01、02、03、04、06、07、09、0A

PCAN 只能接 CAN 那四種,K-Line 收不到。手冊有一處前後矛盾:第 4 頁說旋鈕 1 是 PID 10(MAF),第 11 頁的 PID 表卻把「Knob 1」標在 PID 54(蒸發系統蒸氣壓)。實機轉旋鈕 1,變的是 MAF,PID 54 不動,所以第 11 頁是錯的。

ATS 的 OBD-II 掃描視窗怎麼做

ATS 的 CAN 視窗工具列多了一顆「OBD」,按下去開獨立視窗。流程是標準的 OBD-II(SAE J1979、ISO 15765-4):

  1. 先問 Mode 01 的 PID 00、20、40…「支援表」,每張表 4 bytes、32 個位元對應 32 個 PID,列出這台 ECU 支援哪些。
  2. 依序問支援的 PID,一問一答:送出請求後等到回應或逾時,才問下一個。
  3. 依 J1979 公式換算,例如轉速=(A×256+B)÷4、水溫=A−40、節氣門=A×100÷255。
  4. 按鈕讀 VIN(Mode 09)、故障碼(Mode 03 已存、07 待確認、0A 永久)、清除故障碼(Mode 04)。VIN 超過一框,要照 ISO-TP 回 Flow Control 才收得到後面幾框。
請求(功能定址 7DF):02 01 0D 55 55 55 55 55   ← 問 PID 0D 車速
回應(引擎 ECU 7E8):03 41 0D 3C ...            ← 0x3C = 60 km/h

實測(msi 筆電、PCAN-USB FD 用 Classic 500k、11-bit 功能定址):模擬器支援 93 個 PID(01~5F 幾乎都有),回應延遲約 31 ms(最大 62 ms),每秒約 20 問,0 逾時。輪詢一秒幾十框,預設不顯示在 CAN 視窗也不記 log,免得洗版;要留紀錄可以勾選打開。

踩到的坑

1. 讀得到值,兩秒後連線就自己斷了

ATS 原本的保護是「錯誤框持續 2 秒就自動中斷連線」,避免沒有 ACK 時控制器無限重送洗版。但 log 一看:這些 PCAN「錯誤框」的 ID 都是 0(沒有錯誤種類),而且接收錯誤計數一路往下掉(90 → 76 → 56)。PCAN 每成功收一框、計數減 1,就報一次狀態,34 次減少剛好對上那 35 個「錯誤框」。也就是說通訊正常、正在恢復,卻被當成錯誤。

修法:錯誤計數比上一次真的下降時,只當狀態顯示(每 5 秒最多一行),不算錯誤。計數上升或卡在同一個值,照舊重開通道、自動中斷。卡在同一個值這點很重要:真的錯誤很多時,計數會飽和在固定值,只用「沒有變大」來判斷會漏掉。我用假的錯誤框重播了四種情況驗證。一開始接收錯誤計數就有 90~127 的原因還沒查清,可能和終端電阻或共地有關。

2. 93 個 PID 輪流問,轉旋鈕要等 4~5 秒才看得到

一秒 20 問、93 個 PID 輪一圈要 4~5 秒,轉旋鈕時儀表要好幾秒才變。改成儀表上的 8 個 PID(6 顆旋鈕加負載、電壓)每一輪都問,其他 PID 每輪只插一個輪流,儀表大約每秒更新 2 次。

3. 轉旋鈕時 CAN 視窗沒有封包

OBD 是一問一答,模擬器不會因為旋鈕轉了就主動送東西,要等 ATS 問到那個 PID 才回。加上輪詢預設不顯示,CAN 視窗看起來就是空的。要看請求和回應一來一回,就勾「輪詢的框也顯示在 CAN 視窗」。

用 USB 指令改數值

旋鈕要用手轉,自動化測試不方便。模擬器的 USB 序列埠可以直接設 PID 值,指令以 | 結尾,成功回 OK>。在 ATS 裡用 UART 連那個 COM 埠,送 $PID0D:60|,OBD 視窗的車速就變成 60 km/h。我也把這些指令做成 ATS 的指令集(IMSB5010),可以點選或寫進腳本。模擬器也會從同一個埠印出它收到和回應的 OBD 封包,方便對照。

$PID0D:60|          車速 60 km/h
$PID0C:2000|        轉速 2000 rpm
$PID31:125|         清碼後行駛距離 125 km
$VINLFV3B28R8A3025310|   改 VIN(17 字)
<R>:<S>:[000007DF] 02 01 0D        ← 模擬器印出:收到請求
<T>:<S>:[000007E8] 03 41 0D 3C     ← 模擬器印出:回 60 km/h

還沒確認的是:用指令設了車速之後,再轉車速旋鈕會不會把它蓋掉。

把車速餵給車機(Geotab IOX)

車機走的是 Geotab 的 IO Expander 協定:GO 裝置是主控,車機(IOX)是從端,29-bit ID、500k。IOX 用 0x25 Type 2 跟 GO 要「GO 資料」,GO 回 0x27 Type 2,52 bytes 裡有時間、經緯度、車速(km/h)、轉速、里程(0.1 km)、狀態旗標(點火、匯流排有活動、車速/里程來自引擎…)、引擎時數等。ATS 本來就能模擬 GO,只是車速、轉速、里程是寫死的固定值。

這次在 IOX 視窗加了「車輛資料」:

  • 來源:可以選固定值,或任一條正在 OBD 掃描的 CAN 連線。也可以選偵測到、還沒連線的第二支 PCAN,ATS 會自動用同樣鮑率連上並開始掃描。
  • 選 OBD 時,車速(0D)、轉速(0C)、里程、引擎時數(1F 運轉秒數換算)都用 5 秒內的即時值,並設「車速/里程來自引擎」旗標。模擬器沒有里程 PID(A6),先用 PID 31(清碼後行駛距離)代替,可以用 $PID31:…| 設。
  • 同一支 PCAN 也行:OBD 用 11-bit、IOX 用 29-bit,模擬器和車機可以並在同一條 500k 匯流排上,ATS 同一條連線兩個功能一起跑。
  • 畫面上顯示車機來要過幾次、上次送了什麼,另外有「主動送一次」和「定時主動送」。

為什麼要「主動送」?翻了前一天的實機 log,車機只在每次 Linux 開機完成、報上 External Device ID 之後,跟 GO 要 1 次 GO 資料,之後就不再要了(一天 7 次,全部都是開機當下)。它每 10 秒送一次 protobuf,但對照 Geotab 官方的 iox_messaging.proto,最外層欄位編號對不上,不是標準的訂閱訊息,所以也不是在訂閱車速。照標準流程,車機只會拿到開機那一刻的車速;要即時車速只能由 GO 主動送,但這不在規格裡,車機收不收要實測。

藍牙篇:用 ELM327 讀 OBD

PCAN 要接線,有時候只想插上去就讀。手邊這顆是常見的 ELM327 藍牙 OBD 轉接頭,直接插在模擬器的 OBD 座上,從 OBD 座的 16 腳拿電。它用 ELM327 的 AT 指令集:電腦送文字 010D,它回一行一行的十六進位,最後以 > 提示字元結束;底層的 CAN、K-Line 協定和多框的 Flow Control 都由它自己處理。

Windows 配對與 COM 埠

  1. ELM327 插上 OBD 座(模擬器要開著)。
  2. Windows 設定 → 藍牙與裝置 → 新增裝置 → 藍牙,選 OBDII,PIN 碼 1234。
  3. 配對後 Windows 會多出兩個「透過藍牙連結的標準序列」COM 埠:一個外送(電腦主動連 ELM327)、一個內送。要用外送的那個:它的裝置執行個體路徑裡有 ELM327 的藍牙 MAC,內送的那個是一串 0。這台 msi 上外送是 COM47、內送是 COM46。這顆是傳統藍牙 SPP(不是 BLE),所以會有 COM 埠。

ATS 怎麼用 ELM327

在 ATS 用 UART 開外送的 COM 埠(藍牙序列埠不看鮑率,填 38400 就好),工具列一樣有「OBD」,開出來是同一個掃描視窗,只是設定區換成 ELM327 的協定選單(自動、CAN 11/29-bit、250k/500k、ISO 9141、KWP)。開始時 ATS 依序送:

ATZ      重置,回版本(例如 ELM327 v1.5)
ATE0     關回顯
ATL0     不要換行,只用 CR
ATS1     hex 之間加空白
ATH1     顯示表頭:每行變成 "7E8 03 41 0D 3C"
ATAT1    自動調整等待時間
ATSP0    協定自動
0100     第一次會 SEARCHING… 找協定,先用長逾時問一次
ATRV     OBD 座電壓
ATDPN    找到的協定(A6=自動找到 CAN 11-bit 500k)

重點是 ATH1:開表頭之後,CAN 回應每一行就是「回應 ID+原始 CAN 資料」,ATS 直接把它當成 CAN 框,餵給 PCAN 那條路已經寫好的解析(支援表、J1979 換算、VIN 多框、故障碼),兩種介面共用同一套程式。差別是 ELM327 一問一答要經過藍牙,比 PCAN 慢;但不用接線,而且 K-Line 的車它也讀得到。

實機結果:這顆回報是 ELM327 v2.1(市面上很多是仿製版),OBD 座電壓 12.3 V,用 CAN 11-bit 500k 讀到 ECU 7E8、93 個 PID,跟 PCAN 讀到的一致。

坑 1:自動找協定會把它弄當

第一次用 ATSP0(自動),ATZ 有回,但送 0100 讓它搜尋協定之後 12 秒都沒回應,接著藍牙連線整個斷掉,連寫入都卡住(Windows 的藍牙 COM 埠斷線時,寫入會一直等)。把連線關掉重開、改成直接指定 ATSP6(CAN 11-bit 500k)就正常了。所以 ATS 的「自動」現在不交給 ELM327 搜尋,而是自己依序試 CAN 11-bit 500k → 250k → 29-bit 500k → 250k,都不通才用 ATSP0;寫入也加了 2 秒逾時,藍牙斷了會提示重開連線,不會整個卡住。

坑 2:拔掉重插,連不回去(錯誤 121)

ELM327 從 OBD 座拔起來再插回去,在 ATS 按「連線」出現 OSError(22, '信號等待逾時。', None, 121)。原因是傳統藍牙 SPP 的 COM 埠不會自己連回去,而是「程式打開 COM 埠的那一刻」Windows 才去連 ELM327;轉接頭剛插上還在開機、或 Windows 還留著上一次的連線狀態時,這一次就會逾時。隔幾秒再開就好(從遠端再試一次,6.9 秒連上,ATI 回 ELM327 v2.1)。

所以 ATS 開 COM 埠時,遇到錯誤 121 就自動重試(最多 4 次、間隔 2 秒),其他錯誤(例如 COM 埠不存在)照舊直接顯示;4 次都不行才提示到 Windows 藍牙設定移除裝置、重新配對。改完實測:重插後直接按「連線」,大約 5 秒連上,接著初始化、找協定、讀到 93 個 PID 都正常。

坑 3:PCAN 和 ELM327 同時讀,兩邊一直逾時

PCAN 和 ELM327 接在同一條匯流排、兩個 OBD 視窗一起開,兩邊都開始出現「逾時」,ELM327 掉到每秒 0.8 問,水溫、MAF、進氣溫度一直讀不到;PCAN 那邊轉速、負載也逾時。

原因在 OBD 本身:ECU 的回應(7E8)沒有寫是回給誰的。兩個診斷工具都用功能定址 7DF 發問,ECU 一律從 7E8 回答,誰都分不出哪一則是自己的:

  • ELM327 這邊:它開了自動調整等待時間(ATAT1),收到第一個 7E8 就結束這次請求。PCAN 剛好問了轉速(010C),ECU 回的 41 0C … 就被正在問水溫(0105)的 ELM327 拿走——PID 對不上,等於沒有回應。
  • PCAN 這邊:兩邊的請求疊在一起時,模擬器(一顆單晶片)來不及處理,有些就漏回了。

這在真車上也一樣:同一條匯流排上同時掛兩個診斷工具會互搶回應。好在這兩個視窗都在同一個 ATS 裡,可以協調:

  1. 共用一把「匯流排鎖」:同一個 ATS 裡所有 OBD 掃描,一次只有一個請求在等回應,回應自然就是問的那一方的。一開始用一般的鎖,ELM327 放開後馬上又自己搶回來,PCAN 一直排不到(模擬測試 85 比 4.6 問/秒),所以改成先到先拿(排號碼牌)。
  2. 處理「晚到的回應」:PCAN 的請求等 150 ms 沒回就算逾時,但 ECU 有時候晚一點還是回了,這則晚到的 7E8 會被下一個發問的 ELM327 撿到。實測就遇到:先開 PCAN、再開 ELM327,ELM327 掃描第一問(支援表 PID 00)就被搶走,整個掃描停掉。所以支援表那一問改成沒回應先重試 3 次;ELM327 收到的回應如果不是它問的 PID,就再問一次。

為了在模擬環境重現,也把模擬的 ECU 改成回應要 5~15 ms、模擬的 ELM327 收到第一個回應就結束(跟真的一樣)。修之前模擬:ELM327 1283 問裡 1136 次逾時;修之後兩邊都 0 逾時。

情況ELM327PCAN
各自單獨跑約 9 問/秒,延遲約 110 ms約 22 問/秒,延遲約 15 ms
同時跑(修之前)0.4~0.8 問/秒,2579 問裡 1169 次逾時22 問/秒,43276 問裡 809 次逾時
同時跑(修之後)7.3 問/秒,469 問 2 次逾時7 問/秒,685 問 1 次逾時

同時跑時兩邊都只剩每秒 7 問左右,是因為要輪流用同一條匯流排;只開一個時速度不受影響。這把鎖只管得到同一個 ATS 裡的掃描,如果是別的程式(例如手機 App 透過同一顆 ELM327 或另一顆轉接頭)一起問,還是會搶。

ELM327 和 PCAN 怎麼選

ELM327 藍牙PCAN
接線插上 OBD 座就好,從 16 腳拿電要接 CAN-H/CAN-L、注意終端電阻
速度約 9 問/秒(藍牙+轉接頭處理)約 22 問/秒
協定CAN 和 K-Line(ISO 9141、KWP)都可以只有 CAN
看得到什麼只看得到回應匯流排上所有框(包括別人的請求)
注意仿製版自動找協定會卡、重插要幾秒才連得上錯誤計數、FD 設定

目前進度與下一步

  • ✔ OBD 視窗實機可用:93 個 PID、即時儀表、VIN、故障碼。
  • ✔ 旋鈕對應確認,手冊第 11 頁勘誤。
  • ✔ USB 指令改車速,OBD 讀得到。
  • ✔ PCAN 錯誤計數誤判斷線已修。
  • ✔ 車速可以當 GO 資料送給車機,同一支或兩支 PCAN 都行(模擬環境測過)。
  • ☐ 車機實機收不收「主動送」的車速、收到後在哪裡看得到。
  • ☐ USB 設的值會不會被旋鈕蓋掉;永久故障碼清不清得掉。
  • ☐ 一開始接收錯誤計數偏高的原因(終端電阻、共地)。
  • ✔ 藍牙 ELM327 實機在 ATS 上讀 OBD:v2.1、93 個 PID(仿製版的自動協定搜尋會卡死,改成 ATS 自己依序試)。
  • ✔ ELM327 重插自動重連(錯誤 121 重試,約 5 秒);PCAN 和 ELM327 同時讀不互搶(共用匯流排鎖,各約 7 問/秒)。

延伸閱讀:車內 ECU 圖鑑:模組種類、車內網路,以及用 PCAN+UDS 在測試台上找出有哪些 ECU(OBD-II 之外,用 UDS 盤點一整座測試台上的模組)。

Pi 5 Mecanum Car Part 2: Record & Replay Routes, an lgpio PWM Bug, and Scan-Driven Obstacle Avoidance | Pi 5 麥克納姆輪自走車(二):錄路線照軌跡走、lgpio PWM 卡住的坑、掃描導向避障

這篇接著 Pi 5 麥克納姆輪自走車(一)。上一篇把網頁遙控、陀螺儀走直線、距離圖、路線圖和自動避障做起來了;這次加了「手動開一段路線,再叫車子自己照著軌跡走一次」,順便把實車調校時遇到的幾個大坑整理起來:lgpio 軟體 PWM 會卡住(車子暴衝、或完全不動)、超聲波打到自己的鏡頭、自動避障的策略重寫。每一段都附上程式說明。

說明:第一節錄路線、照軌跡走的截圖和偏差數字是在開發機上用模擬模式(假房間)跑的;實車在測照軌跡走的時候剛好遇到下面的 PWM 卡住問題。11/13 補:實車已經完整跑過,畫面、路線圖和影片放在第七節;鏡頭重新調整後的校正放在第八節。

一、錄路線、照軌跡走

用法很簡單,都在網頁的「路線圖」頁籤:

  1. 按「● 錄路線」,用搖桿開一段路(可以直走、轉彎、平移),開完按「■ 停止錄」。錄好的路線畫成黃色虛線,存在車上(data/route.json),重開程式也還在。
  2. 把車子放回起點,按「▶ 照軌跡走」。勾「從目前位置開始」時,整條路線會搬到目前的位置和車頭方向,所以車子不用擺得很準。
  3. 走的時候推搖桿、按停車、切自動避障都會馬上中斷;正前方小於 25 cm 會停下來等,超過 15 秒就放棄。

錄:每 3 cm 或轉 5° 記一個點

位置用的是路線圖本來就有的推算位置(輪速 × 時間做麥克納姆反運算,車頭方向用 MPU6050 陀螺儀)。錄的時候只要在推算迴圈裡多記點:

# car/route.py
def rec(self, x, y, th):
    lx, ly, lth = self.pts[-1]
    if math.hypot(x - lx, y - ly) >= self.cfg.get("rec_cm", 3) or abs(wrap(th - lth)) >= self.cfg.get("rec_deg", 5):
        self.pts.append((round(x, 1), round(y, 1), round(th, 1)))

停止錄的時候,最後停下來的位置也要補一個點,不然重走會少最後幾公分。

走:麥克納姆可以斜著走,不用先轉頭

一般兩輪車要「先轉向目標再前進」,麥克納姆輪可以往任何方向平移,所以做法是:把下一個目標點換到車身座標(前 fx、左 ly),直接往那個方向走,同時用比例控制修車頭角度。車頭差太多(錄的時候原地轉的那一段)就先原地轉:

# car/route.py:回 (新的 i, vx, vy, w, 完成?)
def step(self, pts, i, x, y, th):
    look, tol, tol_deg = c.get("look_cm", 10), c.get("tol_cm", 5), c.get("tol_deg", 6)
    dist = lambda p: math.hypot(p[0] - x, p[1] - y)
    # 往前找目標點:近的、而且車頭角度差不多的就跳過(原地轉的那幾個點角度差大,會停下來轉)
    while i < len(pts) - 1 and dist(pts[i]) < look and abs(wrap(pts[i][2] - th)) < 10:
        i += 1
    p = pts[i]
    d, err = dist(p), wrap(p[2] - th)
    if i == len(pts) - 1 and d < tol * 0.6 and abs(err) < tol_deg:   # 終點要求準一點
        return i, 0.0, 0.0, 0.0, True
    if (d < tol and i < len(pts) - 1) or abs(err) > 20:               # 車頭差太多:原地轉
        ...
        return i, 0.0, 0.0, math.copysign(c.get("turn_speed", 0.55), err), False
    a = math.radians(th)
    fx = (p[0] - x) * math.cos(a) + (p[1] - y) * math.sin(a)      # 目標在車身座標:前 fx、左 ly
    ly = -(p[0] - x) * math.sin(a) + (p[1] - y) * math.cos(a)
    w = max(-0.3, min(0.3, c.get("kp_deg", 0.03) * err))
    return i, sp * fx / d, sp * ly / d, w, False

「從目前位置開始」就是把整條路線做一次旋轉+平移,讓錄的起點對到現在的位置和車頭:

x0, y0, th0 = self.pts[0]
a = math.radians(th - th0)
return [(x + (px - x0) * c - (py - y0) * s, y + (px - x0) * s + (py - y0) * c, th + wrap(pth - th0))
        for px, py, pth in self.pts]

伺服器端每 50 ms 呼叫一次 step();只有「往前走」的時候才檢查超聲波(它只看得到正前方),前方太近就停車等:

# car/server.py play_loop()
i, vx, vy, w, done = self.route.step(p["pts"], p["i"], o.x, o.y, o.th)
if vx > 0.15 and self.front is not None and self.front < s["stop_cm"]:
    self.car.motors.stop()
    ...   # 等它讓開,超過 wait_s 秒就放棄
self.car.motors.set({n: v * self.limit for n, v in mecanum(vx, vy, w).items()})

模擬測試:錄一段 3 m 路線(直走、左轉 90°、直走、右平移),兩種開始方式都能走完,離原路線最多 4.5 cm、終點差約 2.5 cm。實車因為位置靠輪速推算,一定會比模擬飄,路線越長差越多。

放回起點:疊上起點畫面+陀螺儀+超聲波

車子被手拿起來搬的時候輪子沒轉,推算位置不會變,所以沒辦法用路線圖的位置來擺車。改成開始錄的時候記下三樣東西:攝影機畫面、陀螺儀原始角度(手搬也會跟著轉)、正前方超聲波距離。按「對齊起點」時,起點畫面半透明疊在即時影像上,下面即時提示車頭還要往哪邊轉幾度、前方距離要往前或往後挪幾公分:

"align": {"dyaw": round(((self.route.start["yaw"] - self.imu.yaw) + 180) % 360 - 180, 1),
          "front0": self.route.start.get("front"),
          "front": None if self.front0 is None else round(self.front0)}

二、暴衝和不動:lgpio 停伺服會卡住別支腳的 PWM

這是今天最大的坑。實車有時突然全速衝出去,有時推搖桿完全不動,一開始以為是電池沒電,換了電池量到 12.16 V 還是不動。後來直接看 L298N 的 EN 腳(調速的 PWM):程式以為在送 50% PWM,實際電位卻一直是低的;方向腳(IN)倒是正常。

用一支空腳重現,終於找到原因:lgpio 0.2.2 用 tx_servo(pin, 0) 停掉伺服脈衝時,會讓「別支腳」的軟體 PWM 卡住。tx_busy() 還說它在跑,但電位停住不動:停在高電位就是暴衝,停在低電位就是不動。超聲波裝在伺服上,自動避障一直左右轉頭,程式每次轉完 0.4 秒就停掉伺服脈衝(不然伺服會抖),所以很容易踩到。

測試(另一個執行緒一直開/關伺服,主執行緒改馬達 PWM)PWM 卡住次數
用 tx_servo(pin, 0) 停伺服79/80
同上,所有 lgpio 呼叫加全域 lock63/64(不是執行緒問題)
改用 tx_pwm(pin, 50, 0)+gpio_write(pin, 0) 停伺服0/90
# car/hw.py Servo
def _off(self):
    # 不能用 tx_servo(pin, 0) 停:lgpio 0.2.2 的 bug,停掉伺服會讓「別支腳」的軟體 PWM 卡住
    lg, h = self.g
    lg.tx_pwm(h, self.cfg["pin"], 50, 0)
    lg.gpio_write(h, self.cfg["pin"], 0)

另外加了一個 PWM 看門狗:每 0.5 秒直接讀 4 支 EN 腳的實際電位。該 PWM 的腳在 12 ms 內一定看得到高、低兩種電位;只看到一種就是卡住,先重送一次,還是不行就緊急停車、關掉自動模式。該停的腳卻是高電位就馬上拉低。發生的事情都記進 systemd journal。

# car/hw.py Motors
def _pwm_ok(self, en):
    lg, h = self.g
    seen = set()
    for _ in range(40):          # 1 kHz、duty 25~60%:12 ms 內一定看得到高低兩種
        seen.add(lg.gpio_read(h, en))
        if len(seen) == 2:
            return True
        time.sleep(0.0003)
    return False

修好之後實車驗證:讓伺服轉頭 18 次再轉輪子,EN 腳的 PWM 每次都正常;之後跑自動避障,看門狗一次都沒觸發。順便把 PWM 上限從 60% 降到 50%:滿電 3 顆 18650 約 12.6 V,經 L298N 壓降後給 TT 馬達還是偏高。

有人問 EN 能不能直接用跳帽接成一直 enable:不建議。那樣只能在 IN 腳打 PWM,用的還是同一套軟體 PWM;而且程式停掉時就少了「EN 拉低=馬達一定停」這道保險。真的要更穩,Pi 5 的 GPIO12/13/18/19 剛好都是硬體 PWM 腳,之後可以不改線換成硬體 PWM。

三、操作紀錄:出事時查得到原因

暴衝那次完全查不到發生什麼事,所以加了操作紀錄,直接 print 進 systemd journal(journalctl -u car):網頁連上/斷線和 IP、所有指令、搖桿推/放開、速度上限變化、自動模式每一步和當時的前方距離、每次掃描的讀值、照軌跡走結束的原因。自動模式的狀態改成 property,一變就記:

@auto_state.setter
def auto_state(self, v):
    if v != getattr(self, "_auto_state", None) and v:
        log(f"自動:{v}" + (f"(前方 {self.front:.0f} cm)" if self.front is not None else ""))
    self._auto_state = v

後面兩個問題都是靠這份紀錄找到的,例如:

19:15:05 掃描:右80 7、右40 37、前 20、左40 20、左80 7
19:15:05 自動:往右轉(右邊已轉 120°)(前方 20 cm)
19:15:09 掃描:右80 7、右40 51、前 39、左40 20、左80 6
19:15:09 自動:左右兩邊都試過不行,後退後迴轉(前方 38 cm)

四、超聲波打到自己的鏡頭

上面紀錄裡「右 80°」每次都是 5~8 cm,連迴轉 180° 之後也一樣,所以不是環境裡的東西。原來是鏡頭改裝到車子右前、又比車頭多出去 3 cm,超聲波往右轉到 70° 以上就打到鏡頭:

超聲波角度右 75°右 70°右 65°右 60°
量到6 cm(鏡頭)8 cm(鏡頭)126 cm25 cm

這個假讀值讓程式一直以為右前角貼著東西。修法:掃描角度右邊只到 60°([-60, -30, 0, 30, 60, 80]),另外過濾「往右超過 68°、又在 12 cm 以內」的讀值,不畫進地圖:

def own_body(self, deg, d):
    """這個讀值是不是打到車子自己(鏡頭裝在右前,超聲波右轉太多會打到)"""
    u = self.cfg.get("ultrasonic", {})
    return d is not None and deg <= u.get("self_deg", -180) and d <= u.get("self_cm", 0)

五、鏡頭側裝:畫面旋轉、扶正、重新校正

為了讓鏡頭不會晃,改成側著鎖(離中心線 8.5 cm、離地 11 cm),畫面就變成躺著的。設定加 rotate(0/90/180/270)在讀進來時就轉正,YOLO、網頁、測距全部用轉正後的畫面;直的畫面寬高對調,水平視角從 60° 變成約 47°,測距的焦距要用原始寬度算:

def geometry(cam):
    """轉正後的畫面寬、高、焦距(像素)、水平視角"""
    W, H = cam.get("width", 640), cam.get("height", 480)
    f = (W / 2) / math.tan(math.radians(cam.get("hfov_deg", 60) / 2))
    if int(cam.get("rotate", 0)) % 180 == 90:
        W, H = H, W
    return W, H, f, round(2 * math.degrees(math.atan(W / 2 / f)), 1)

鎖好後畫面還歪了約 7.4°,再加一個 roll_deg 用 warpAffine 轉回水平(四角會有黑邊)。接著把箱子擺在車子中心線、離車頭 100 cm,用一張畫面同時算出上下(俯角)和左右(偏角):


  • 扶正後箱子底邊在 y≈413(畫面高 640):照鏡頭高 11 cm、距離 97 cm 應該在水平線下 6.5°,實際在 9.5° → 鏡頭往上仰約 3°。
  • 箱子中心在畫面偏左 8.2°,照位置(鏡頭在中心線右邊 8.5 cm)應該偏左 5.0° → 鏡頭往右轉約 3°。
  • 驗算:箱子估在車子中心前方 112 cm(實際 113 cm)、偏離中心線 0.2 cm。

鏡頭只離地 11 cm,角度差 1° 在 1 m 外估距離就差約 17%,所以支架一定要鎖緊;之前鏡頭會晃的時候,同一個箱子兩張畫面就差了 2.6°。

六、自動避障:從「固定轉 30°」改成「照掃描結果走」

原本的規則是:被擋住就往一邊轉 30°,那一邊累積轉到 120° 還不行才換另一邊,兩邊都試過才迴轉。實車在擠的房間裡看路線圖就發現問題:累積角度的記憶常常蓋過眼前的掃描結果。例如右前方明明有 73 cm 空間,只因為右邊已經轉滿 120° 就強制轉向左邊(左邊全都只有 20 cm);或是正前方 157 cm 是空的,卻因為兩邊都「試過」就直接迴轉。

新策略每次都照這次的掃描結果決定:

  1. 前方路線空就直接走:不只看正前方,每個角度的讀值都換成「前方/側向距離」,落在車身寬度內、又在 40 cm 以內才算擋住。
  2. 正前方有路,只是車角擦到旁邊的東西:麥克納姆輪往另一邊橫移 10 cm 再走,不轉彎。
  3. 否則轉到最空、而且夠車寬通過的那個角度(分數=距離-0.3×角度,少轉一點優先),兩邊差不多時選上次那一邊,避免左右來回擺。
  4. 每個方向都不通:後退再掃一次 → 轉 90° 再掃(最多兩次)→ 最後才迴轉 180°。
def blocked_dir(self, seen, c, clear, shift=0.0):
    """往 c 度走、車子先往左平移 shift cm 時,路線(車寬左右各多 path_margin)上 clear 內有沒有東西"""
    half = self.cfg.get("body", {}).get("width_cm", 24) / 2 + self.cfg["auto"].get("path_margin_cm", 4)
    for x, d in seen.items():
        if d is None:
            continue
        r = math.radians(x - c)
        fwd, lat = d * math.cos(r), d * math.sin(r) - shift * math.cos(math.radians(c))
        if 0 < fwd < clear and abs(lat) <= half:
            return True
    return False
async def escape(self, seen, bumped, a, s):
    clear, far = s["stop_cm"] * 1.6, 300
    dist = lambda x: far if seen[x] is None else seen[x]
    # ① 前方路線空:直接走
    if not bumped and dist(0) >= clear and not self.blocked_dir(seen, 0, clear):
        return True
    # ② 正前方有路,只是車角擦到旁邊的東西:往另一邊橫移再走
    if not bumped and dist(0) >= clear:
        for sh in (sc, -sc):
            if not self.blocked_dir(seen, 0, clear, sh) and side_room >= sc + 15:
                await self.strafe(sh, a)
                return True
    # ③ 轉到最空、夠車寬的方向;跟上次同一邊加 15 分
    cands = [x for x in seen if x != 0 and dist(x) >= clear and not self.blocked_dir(seen, x, clear)]
    if not cands:
        return False          # 外面接著:後退再掃 → 轉 90° → 迴轉
    score = {x: min(dist(x), 200) - 0.3 * abs(x) + (15 if self.esc_dir and x * self.esc_dir > 0 else 0) for x in cands}
    best = max(score, key=score.get)
    await self.turn(1 if best > 0 else -1, abs(best), a)
    return True

開發機上做了一個 70 cm 寬、前面封死的假死巷測試(SIM_ROOM=deadend),車子約 53 秒走出來。實車換上新策略後跑了一段:照掃描轉向 13 次、後退再掃 8 次、卡住後退 4 次、橫移閃開 2 次,最後一步的迴轉一次都沒用到。

七、實車照軌跡走(11/13 補)

PWM 的問題修好之後,在家裡走廊實車錄了一段約 5.3 m、142 個點的路線:從起點往前直走約 3 m,左轉橫過,再往回走,繞一圈回到起點附近。接著把車子放回起點,按「▶ 照軌跡走」,車子自己走完全部 142 個點,花了 24 秒,速度上限 60%。

綠線幾乎疊在黃虛線上,轉彎處看起來只差幾公分,最後停在錄的起點旁邊。要注意的是:兩條線都是車子用輪速+陀螺儀「自己推算」的位置,所以這張圖證明的是「控制有照著計畫走」,不等於地上的真實位置;輪子打滑、地板反光造成的誤差在圖上看不出來,真實偏差要另外用地上的標記或從上面俯拍來量。

走的時候網頁照常更新:鏡頭畫面(YOLO 每秒約 10 次,認得的東西會框出來,第一次跑時有認出冰箱)、正前方超聲波距離、路線圖上車子的位置,左下角顯示「照軌跡走 N/142」。下面是用 gx10 把整段網頁畫面錄下來的影片,實際速度:

網頁錄影是在另一台電腦用 Playwright 開同一個頁面、切到路線圖,等按鈕變成「走第 N 點」就開始連續截圖,走完 3 秒後停,再用 ffmpeg 接成影片(tools/capture_replay.py)。

八、鏡頭再調整後重新校正(11/13)

後來又把鏡頭重新固定了一次,位置變成:離地 10.5 cm、比車頭前緣往後 2 cm、在中心線右邊 8 cm。換了位置,俯角、偏角都要重校。這次用一張 Snoopy 凳子當目標,擺在車子正前方的中心線上,超聲波量到 99 cm:


這次改成用程式自動量:把凳子的藍色布面用 HSV 顏色挑出來,逐欄找上緣、下緣再擬合直線算歪斜角,木頭色的凳腳最下面當落地點,再代回和第五節一樣的公式:

  • roll:7.4° → 6.7°(舊參數扶正後凳面還斜約 0.7°)。
  • tilt:鏡頭到凳腳落地點約 103 cm,照高度 10.5 cm 應在水平線下 5.8°,畫面上在 8.8° → 往上仰 3.0°。
  • yaw:凳子在鏡頭左邊 8 cm,應在左邊 4.5°,畫面上只在左 1.3° → 鏡頭往左轉 3.2°(之前是往右 3°,這次支架轉了約 6°)。
  • 驗算:凳子估在車子中心前方 113.7 cm(實際約 114 cm)、離中心線 0.1 cm。

有一個小坑:改了 roll 之後,扶正的畫面裡凳腳的 y 會跟著移動(這次從 400.5 變 406),tilt 一定要用「新 roll 扶正後」的畫面再算一次,不然距離會差好幾公分。

還沒做的

  • 照軌跡走已經實車跑完(第七節),但路線圖是車子自己推算的位置;還要用地上標記或俯拍量真實偏差。
  • 換了滿電電池、PWM 上限改 50% 之後,速度(cm_per_s)要重量,推算位置才會準。
  • 橫移時超聲波看不到側邊;平移用的 strafe_eff 0.8 也還是猜的。
  • EN 腳改用 Pi 5 硬體 PWM,徹底避開軟體 PWM。

2026年11月10日 星期二

CAN Bus on the ATS Console: PCAN FD, Error Frames, Scripts and UWB Ranging with NXP NCJ29D6 | ATS 操作台的 CAN 介面:PCAN FD、錯誤框處理、腳本與 NXP NCJ29D6 UWB 測距

📺 影片版(約 8 分鐘):想先看實際操作的話可以直接看這支影片,完整步驟、設定與程式碼在下方文字版。
YouTube:https://youtu.be/CtsUeW9qyqI(winson-DIY 頻道)
⚡ Shorts 精華版(43 秒):https://youtube.com/shorts/eLg6DQrnNqU

ATS 網頁操作台除了 UART、GPIB、Ethernet,還有一個 CAN 分頁:插 PCAN(PEAK)、Kvaser 或 CANable 都能用,支援 CAN FD,可以手動送框、依 ID 彙整、寫腳本。這篇整理 CAN 介面的用法和實機踩到的坑,後半段是這次的重點:把 NXP NCJ29D6 UWB 模組的測距(ranging) 整合進 ATS。原廠的測試工具是加密的 exe,這次照它的 CAN 收發方式,在 ATS 裡用腳本就能讓兩片模組開始測距,即時看到距離、AoA,還有一個相對位置圖。

說明:本文截圖是在開發機上用模擬模式拍的(virtual CAN 上有一顆假的 ECU 和一片假的 NCJ29D6,回應格式照原廠 log),數值只是示意;實機結果是 11/06 在筆電上用兩支 PCAN-USB FD 對測的紀錄。

一、連線:掃描轉接器、鮑率、CAN FD

切到 CAN 分頁會自動掃描插著的轉接器(python-can 的 detect),下拉選單列出全部,正在使用的會反灰。選鮑率,要 CAN FD 就再選資料段速率(1M/2M/5M),別名可以取「UWB 模組」「車身 CAN」方便分辨。開了 FD 的連線名稱會帶「500k/2M FD」,跟不開 FD 視為不同裝置,log 檔也分開。

第一個坑在 PCAN 的 FD 模式:python-can 的 pcan 介面開 FD 時不吃 bitrate/data_bitrate,直接傳會得到「A parameter contains an invalid value」。要給完整的位元時序,ATS 用 80 MHz 時脈、取樣點 80% 算出來,跟 PCAN-View 的 SAE J2284-4 預設(500k/2M)一致:

# ats/ifaces.py CanBus
if c["interface"] == "pcan":
    # PCAN 的 FD 模式不吃 bitrate/data_bitrate,要完整位元時序
    kw = {"interface": "pcan", "channel": c["channel"], "timing": can.BitTimingFd.from_sample_point(
        f_clock=80_000_000, nom_bitrate=kw["bitrate"], nom_sample_point=80.0,
        data_bitrate=kw["data_bitrate"], data_sample_point=80.0)}

二、收發:手動送框、快捷、依 ID 彙整

下方輸入 ID(hex)和資料,勾 EXT(29 bit)或 FD 就能送;可以每 N ms 重複送,常用的框存成快捷(「ID#資料」格式),點一下就送。收發紀錄 → 是送出、← 是收到,FD 框會標 FD 和長度。下圖是模擬 ECU:0x100 每秒心跳,送 OBD 請求 7E0 會從 7E8 回應。

匯流排上框很多的時候,勾「依 ID 彙整」改成每個 ID 一行,顯示方向、DLC、最新資料、次數和實際週期,一眼看出哪個 ID 有沒有在送、週期對不對:

所有收發都記到 CSV(utf-8 BOM,Excel 直接開):一份總表,另外每個連線一份 logs/can/<別名+名稱>_<時間>.csv。

三、錯誤框:只顯示一次、看懂原因、2 秒自動中斷

實機遇到的狀況:接上 DUT 還沒送任何框,畫面就被錯誤框洗版,每秒約 7700 個。一開始以為是沒有 ACK,後來把 PCAN 的錯誤框解開才知道是「接收時填充錯誤、REC 127」:對方送的是 CAN FD,我們這邊沒開 FD。PCAN 錯誤框的格式是 ID=錯誤種類(1 位元/2 格式/4 填充/8 其他),data[0] 0=送出時、1=接收時,data[2]/[3] 是接收/送出錯誤計數:

ERR_KIND = {1: "位元錯誤", 2: "格式錯誤", 4: "填充錯誤", 8: "其他錯誤"}
# 例:ID 4 [4] 01 00 7f 00 → 接收時填充錯誤、REC 127
#   → 提示「鮑率不同或對方送 CAN FD(沒開 FD 時建議 500k/2M)」
# 送出端的錯誤才提示 ACK(沒接東西、只有自己一個節點)

現在的處理方式:同一波錯誤框只顯示第一個,附一行中文原因和建議;之後每秒(10 秒後每 10 秒)一行「已 N 個」。錯誤持續超過 2 秒就自動中斷這個連線,視窗留著標「已中斷」看原因,改好設定再連。另外,沒接東西時送框會因為沒有 ACK 讓控制器無限重送,PCAN 實機證實 bus.reset() 停不了,所以「剛送過框又一直錯」時會把通道關掉重開。

四、CAN 腳本

腳本一行一個框,格式跟快捷一樣是 ID#資料,前面可以加 FD、EXT;@wait 2 等 2 秒,# 開頭 是註解。CAN 沒有「查詢」,所以另外加了等回應的語法:

語法意思
7E0#02 01 0D 00 00 00 00 00送一個框
@expect 011#41 03 XX XX 00 3s等收到開頭符合的框(ID 寫 * 不限、XX 不比對),沒等到腳本就停
UCI 012#20 02 00 00送一則 UCI 訊息,一律 FD,超過 64 bytes 自動分段(下面說明)
@uci */@uci off把收到的框當 UCI 解讀(每則翻成一行中文);off 關掉並印統計
@to COM11 dio17_on => ok> 3s送到另一個開著的連線(例如 UART 控制板讓 relay 上電),可等回應

@expect 從「上一個送出的框之後」收到的開始找,而且依序消耗:同一個回應不會被兩行 @expect 用到,所以「先等 RSP、再等 NTF」這種順序可以照寫。每個 CAN 裝置各自存一份腳本(macros\can\<介面>_<通道>.yaml)。

五、UWB 測距:NXP NCJ29D6 的 UCI over CAN

NCJ29D6 是 NXP 的車用 UWB 晶片,公司的模組透過 CAN FD 跟 PC 溝通,原廠有一支 Ranging Test Tool(Python 包成 exe、Pyarmor 加密)。從它的 log、PCAN-View 的紀錄,以及另一版沒加密的原始碼,把收發方式整理出來:

項目內容
匯流排CAN FD+BRS,500k/2M(80 MHz,取樣點約 80%)
PC → 模組ID 依板子不同(原廠 Setting.ini:0x12/0x10/0x06),ATS 預設 012
模組 → PC實機是 0x011(原廠收的時候不分 ID,所以腳本的 @expect 用 *)
資料就是原始 UCI 訊息:標頭 4 bytes [MT|PBF|GID][OID][長度高][長度低];MT 1=指令 0x2X、2=回應 0x4X、3=通知 0x6X;回應後面補 00 到 FD 長度
長訊息超過 64 bytes 用 UCI 的 PBF 分段:每段 payload 最多 60、各自帶標頭,中間段 byte0 加 0x10,送的時候段間隔 20 ms

腳本的 UCI ID#訊息 會自動切段;收的方向開 @uci 時自動接回完整訊息再解讀。切段的程式照原廠 transmit_uci_message 的做法:

# ats/uci.py
def fragments(msg, size=64):
    """完整 UCI 訊息 → 一個個 CAN FD 框的資料(每段 payload 最多 60,標頭 byte2/3 是這段長度)"""
    msg = bytes(msg)
    if len(msg) <= size:
        return [msg]
    body, room, out = msg[4:], size - 4, []
    for k in range(0, len(body), room):
        seg = body[k:k + room]
        last = k + room >= len(body)
        out.append(bytes([msg[0] & 0xEF if last else msg[0] | 0x10, msg[1], len(seg) >> 8, len(seg) & 0xFF]) + seg)
    return out

內建四個腳本

這些腳本寫在 instruments/NCJ29D6/commands.yaml,標了 iface: can,所以會成為所有 CAN 連線的預設腳本:

  • 讀版本:CORE_GET_DEVICE_INFO(回應 200 多 bytes、分 4 段,接回來後顯示韌體/MAC 版本)+ DEVICE_GET_REBOOT_REASON。
  • FiRa Responder 開始測距:重設 → 低功耗關 → 天線延遲(0x2A)/天線間距 17 mm → SESSION_INIT(session ID 0xCEFAFECA)→ 共同參數(DS-TWR、channel 9、間隔 1000 ms)→ responder 參數(自己 MAC DDCC、對方 BBAA)→ START。
  • FiRa Initiator 開始測距:一樣的流程,改成 controller/initiator、自己 BBAA、對方 DDCC。
  • 停止測距:原廠結束時只送 CORE_DEVICE_RESET,沒有 SESSION_STOP;ATS 照做,並關掉 UCI 解讀、印出統計。

每一步都用 @expect 確認回應狀態 00(OK),SESSION_INIT 還會確認 handle 是 00 00 01 00(原廠後面的指令是固定照這個值寫的)。腳本開頭先 reset 是原廠沒有的,免得上一次的 session 還在,SESSION_INIT 回 SESSION_DUPLICATE。

解讀 RANGE_DATA_NTF:距離與兩個 AoA

開始測距後,模組每秒送一筆 RANGE_DATA_NTF(68 bytes,分兩個 FD 框)。開了 @uci,ATS 會把它組回來翻成一行:距離(cm)、自己量的 AoA,以及對方量的 AoA:

# ats/uci.py Decoder.range_data():每筆 TWR 量測 31 bytes(短 MAC)
mac, st, d = m[1::-1].hex().upper(), m[2], int.from_bytes(m[4:6], "little")      # MAC、狀態、距離 cm
if st == 0 and d != 0xFFFF:
    a = int.from_bytes(m[9:11], "little", signed=True) / 128                      # 自己量的 AoA(度)
    if len(m) >= 18 and m[17]:                                                    # FOM 不是 0 才有
        b = int.from_bytes(m[15:17], "little", signed=True) / 128                 # 對方量的 AoA

這個「對方量的 AoA」是實機對測才發現的:Initiator 收到的 RANGE_DATA 裡,每筆第 15~16 byte 還帶著 Responder 量到的角度,Responder 那邊這個欄位則全是 0。所以 ATS 對 Initiator 顯示「自己量/對方量」兩個 AoA,統計也分開算。

實機對測結果

11/06 在筆電上插兩支 PCAN-USB FD,各接一片模組,一邊跑 Responder、一邊跑 Initiator:CAN ID 用預設 012 就通,模組從 0x011 回。

項目結果
距離約 106 cm
Responder 自己量的 AoA-1.33°
Initiator 收到的「Responder 量的 AoA」-0.88°(跟上一列是同一個角度,兩邊差 0.45°)
Initiator 自己量的 AoA-17.73°(擺放角度造成,確認過不用處理)

停止腳本送 CORE_DEVICE_RESET 後 @uci off,會印出這次的統計:筆數、距離平均/最小/最大/差,兩個 AoA 各自的平均和範圍:


UWB 相對位置圖

只看數字不直觀,所以 CAN 視窗工具列多了「UWB 圖」,開一個彈出視窗:Responder 固定在下方朝上,Initiator 依「距離+Responder 量的 AoA」定位,箭頭是 Initiator 的正前方(由 Initiator 自己量的 AoA 推回來),還有軌跡、平滑(1/3/5/10 筆平均)、右側的距離與兩個 AoA 統計,下方是 2 分鐘曲線,滑鼠移上去看每一點的數值。伺服器解到 RANGE_DATA_NTF 時另外廣播一個 uwb 事件給這個頁面,不進收發紀錄。

還沒確認的

  • AoA 的正負方向(左正還是右正)實機還沒確認,UWB 圖先留了「左右反轉」開關。
  • PCAN-View 有設 SSP Offset 32,python-can 沒有這個參數;如果資料段不穩,先查這裡。
  • 每片板子 PC → 模組的 CAN ID 可能不同,不是 012 時要把腳本裡的 UCI 012# 改掉。