前言
無線通訊當道的今日,筆者所在單位正在開發 usb type-A 轉其他無線協議的設備,林林總總的知識點總繞不開 L2 端點這個話題。
把一顆 MCU 做成「插上電腦就多一張網卡」的裝置,USB 那一側最常見的選擇是 CDC-NCM。這類裝置如果同時還帶一顆無線網卡模組,做成橋接或路由的形式,開機訊息裡往往會冒出兩、三個不同的 MAC 位址 ------ 而且它們本來就該不一樣。
這篇把這件事拆開講清楚:CDC-NCM 在 USB 上做了什麼、host 為什麼會看到一張網卡、那些 MAC 分別屬於誰、以及只有一顆出廠寫死的位址時該怎麼安全地衍生出其他幾個。
本文範圍:只討論 USB 側與網路堆疊的橋接架構。文中的無線側統一以「無線網卡模組」代稱 ------ 它透過 SDIO 與 MCU 連接、自帶完整的 MAC 層、對上層暴露一個標準的乙太網路介面。具體是哪一種無線技術與這裡要討論的架構無關,因此不展開射頻與協議細節。
所有規格引用來自 USB-IF 的公開文件與 IEEE 802 的位址規則,程式碼示範以 TinyUSB(Apache-2.0)與 lwIP(BSD)的公開範例為基礎。
一、CDC-NCM 是什麼:和 ECM、RNDIS 差在哪
USB 上要模擬一張乙太網卡,歷史上有三條路:
| 方案 | Subclass | 出處 | 現況 |
|---|---|---|---|
| CDC-ECM(Ethernet Control Model) | 0x06 |
USB-IF 標準 | 標準、簡單,但吞吐差 |
| RNDIS | 廠商自訂 | 微軟 | Windows 免驅,但非 USB-IF 標準,Linux 支援零散 |
| CDC-NCM(Network Control Model) | 0x0D |
USB-IF 標準(CDC NCM 1.0) | 現在的主流選擇 |
ECM 的致命傷是一次 USB transfer 只裝一個乙太網 frame 。在 High-Speed 上,一個 64 byte 的 TCP ACK 也要佔掉一次完整的 bulk transfer 與一次中斷處理;封包越小、效率越低,實測常常連理論頻寬的兩成都吃不到。
NCM 針對這點做了一件事:聚合 。多個 datagram 打包進同一個 USB transfer,攤掉 per-transfer 的固定成本。這個打包格式叫 NTB(NCM Transfer Block)。
NTB 的結構
一個 NTB 由三部分組成:
+-----------------+ NTH NCM Transfer Header
| NTH16 | 簽章 "NCMH"、序號、整塊長度、指向第一個 NDP
+-----------------+
| NDP16 | NDP NCM Datagram Pointer table
| (index,length) | 每個 datagram 的偏移與長度,以一組全零項結束
| (index,length) |
| (0, 0) |
+-----------------+
| datagram 1 | 實際的乙太網 frame
| datagram 2 |
| ... |
+-----------------+
幾個實作時會踩到的細節:
- NTH16 的簽章是 ASCII
"NCMH"(小端讀成0x484D434E),header 長度固定 12 bytes。32-bit 版本用小寫"ncmh",header 16 bytes。 - NDP16 的簽章是
"NCM0"或"NCM1"------ 最後一個字元0表示 datagram 不帶 CRC,1表示帶。絕大多數實作用NCM0。 - NDP 的表格必須以一組
(0, 0)結束,這是 parser 判斷結尾的唯一依據。少寫這一組,對面會繼續往下讀垃圾。 wBlockLength是整個 NTB 的長度,不是 payload 長度。收端要以它為準,不能相信 USB transfer 的實際長度 ------ 兩者在有 ZLP 的情況下會不一致。
16-bit 與 32-bit 兩種格式由 host 在 SetNtbFormat 請求裡選。除非單一 NTB 會超過 64 KB,用 16-bit 就夠。
兩支 functional descriptor
NCM 的 interface descriptor 底下掛兩支 class-specific functional descriptor,兩支都要有:
| Subtype | 名稱 | 關鍵欄位 |
|---|---|---|
0x0F |
Ethernet Networking Functional Descriptor(沿用 ECM 的) | iMACAddress、wMaxSegmentSize |
0x1A |
NCM Functional Descriptor | bcdNcmVersion、bmNetworkCapabilities |
wMaxSegmentSize 是 host 端 MTU 的依據,一般填 1514(1500 + 14 bytes 乙太網表頭)。bmNetworkCapabilities 宣告支援哪些選用請求,填錯會讓 host 送出裝置不認的 control request。
二、host 為什麼會「看到一張網卡」:iMACAddress
iMACAddress 是上表那支 Ethernet Networking Functional Descriptor 裡的一個 string descriptor index 。它指向的字串就是這張網卡的 MAC 位址,格式是大寫十六進位、12 個字元、沒有任何分隔符號:
"0203847A9601"
第一個字元是第一個 byte 的高 nibble。不是 02:03:84:...,不是小寫,也不能填 0x 前綴 ------ 格式錯了通常不會報錯,host 會安靜地退回隨機位址,或者乾脆不上線。
host 端的 class driver 讀到這個字串,就替它建立的網路介面套上這個 MAC。這就是「插上去多一張網卡」的全部魔法:
- Linux :
cdc_ncmdriver 接手,出現enx<mac>或usb0 - Windows:較新的 Windows 10/Windows 11 內建 USB NCM class driver;更早的系統要自帶 INF
- macOS:內建支援
到這裡,第一個 MAC 出現了 ------ 它屬於 host。
三、那 device 自己呢:第二個 MAC
這是最容易搞混的地方。
iMACAddress 描述的是 host 端那張介面的位址。但 USB 這條線的另一端 ------ device 上跑的網路堆疊(lwIP 之類)------ 也需要一張 netif,那張 netif 也需要一個 MAC。
用一張圖看:
┌──────────── host (PC) ────────────┐
│ netif "usb0" MAC = A │ ← 來自 iMACAddress
└────────────────┬───────────────────┘
│ USB bulk (NTB)
┌────────────────┴───────────────────┐
│ netif "usb" MAC = B │ ← device 側 USB netif
│ (bridge / route / NAT) │
│ netif "wlan" MAC = C │ ← 無線模組自己的位址
└─────────────────────────────────────┘
│
無線網路
A 和 B 是同一條乙太網鏈路的兩端,就像網路線兩頭插著兩張不同的網卡。它們必須是不同的位址 ------ 是 L2 的基本前提。
C 是完全另一件事。 無線網卡模組自帶 MAC 層,它的位址通常燒在模組自己的 OTP 裡,由模組廠配發的 OUI 範圍取得。MCU 只是透過 SDIO 驅動它,改不了那顆位址(除非驅動層提供覆寫機制)。
所以一台這樣的裝置,帳面上有三個 L2 位址,分屬三個不同的介面。
四、為什麼不能讓它們共用同一個位址
「反正都是同一台裝置,填同一個 MAC 不行嗎?」不行,而且失敗的方式取決於你走 bridge 還是 route。
Bridge 模式 ------ USB 側與無線側橋在同一個 L2 廣播網域:
- bridge 靠來源 MAC 學習「這個位址在哪個 port」。A 和 B 相同時,bridge 的 forwarding table 會在兩個 port 之間反覆翻轉(MAC flapping)
- 結果不是完全不通,而是間歇性不通 ------ 有些封包送對 port,有些送錯,看起來像「不穩定」或「掉封包」。這種問題極難查
- 上游的 AP 或 switch 也在學同一個位址,混亂會擴散出去
Route / NAT 模式 ------ USB 側是獨立網段(例如裝置自己當 gateway):
- 兩個介面在不同廣播網域,理論上位址重複不會立刻爆
- 但 device 自己的 ARP 表會出問題:對 B 的 ARP request,堆疊分不出該從哪張 netif 回應
- 而且一旦有人把它改成 bridge 模式(設定開關而已),前面那些症狀立刻出現
還有一個更現實的理由:IEEE 配發的 OUI 是有限資源。一台裝置若要三個全球唯一位址,就得買三倍的位址空間。實務上沒人這樣做。
五、只有一顆 base MAC,怎麼衍生出其他幾個
產線通常只往裝置裡寫一顆位址 ------ 貼在外殼上、印在條碼上、寫進 MCU flash 尾端保留的一小段參數區的那一顆。其餘的靠韌體推導。
推導要滿足四個條件:
- 與 base 不同 ------ 否則就是上一節那些症狀
- 不會撞到別人的全球唯一位址 ------ 不能隨便加一減一,那個結果可能是某家廠商合法配發的位址
- 重開機後不變 ------ 位址跳動會讓 DHCP lease、ARP cache、AP 的關聯紀錄全部錯亂
- 可以從單一來源算出來 ------ 不需要在產線多寫一筆
第 2 點就是 LAA bit 的用途。
LAA bit:IEEE 802 給你的合法空間
乙太網 MAC 的第一個 byte 有兩個特殊 bit:
第一個 byte: b7 b6 b5 b4 b3 b2 b1 b0
│ └── bit 0 : I/G 0=單播 1=群播
└───── bit 1 : U/L 0=全球唯一(UAA) 1=本地管理(LAA)
把 bit 1 設為 1,這個位址就落在「本地管理」空間 ------ IEEE 保證不會把這個空間配發給任何廠商。也就是說,只要設了 LAA bit,你怎麼填後面五個 byte 都不會撞到別人的合法位址。
(順帶:bit 0 一定要保持 0。設成 1 就變成群播位址,當作介面位址用會被丟掉。)
所以最小可行的做法是:
c
/* 從出廠寫入的 base 位址推導 device 側 USB netif 的位址 */
memcpy(netif->hwaddr, base_mac, 6);
netif->hwaddr[0] |= 0x02; /* 設 LAA bit:離開全球唯一空間 */
netif->hwaddr[0] &= ~0x01; /* 保持單播 */
但這樣還不夠 ------ 如果 base 位址本身已經設了 LAA bit,|= 0x02 就是 no-op,衍生出來的位址與 base 相同。所以還要再擾動一個 byte,讓兩者一定不同。
TinyUSB 的 net_lwip_webserver 範例就用了最簡單的一種:把最後一個 byte 的最低位元 XOR 掉,並在註解裡直接寫明 lwIP 那張虛擬介面的 MAC 必須與 host 的不同。
常見的幾種變體,各有取捨:
| 手法 | 優點 | 要注意 |
|---|---|---|
最後一 byte ^= 0x01 |
最省,一定改變 | 相鄰序號的兩台裝置可能互撞(...00 與 ...01 交換後重疊) |
最後一 byte 取反(~) |
距離 base 遠,同批次不易撞 | 一樣要先檢查 base 本身 |
| 保留一個 byte 當「介面編號」 | 可擴充到 3、4 張介面 | 佔掉位址空間,要事先規劃 |
| 對 base 做 hash 取後 3 byte | 分佈均勻 | 不可逆,客服對不回條碼上的位址 |
選哪一種取決於你要不要能從衍生位址反推回條碼。 產線與客服要對得起來的話,用可逆的簡單運算;只在意不撞的話,hash 更安全。
無論哪一種,都要在啟動時檢查衍生結果:
c
static bool mac_is_usable(const uint8_t *m)
{
if (m[0] & 0x01) return false; /* 群播 */
if (!(m[0] | m[1] | m[2] | m[3] | m[4] | m[5])) return false; /* 全零 */
return true;
}
參數區沒寫過的時候讀回來是全 0xFF,這種值一路帶下去,症狀會是「host 看得到網卡但完全不通」,而且很晚才會被發現。開機時擋掉,比在現場查便宜太多。
六、怎麼驗證
三個位址要分三個地方看,不能只看一邊。
host 側看到的(= iMACAddress)
Linux:
bash
ip -br link show
cat /sys/class/net/usb0/address
lsusb -v -d <vid>:<pid> | grep -A4 "CDC Ethernet"
dmesg | grep cdc_ncm
Windows(PowerShell):
powershell
Get-NetAdapter | Format-Table Name, MacAddress, LinkSpeed, Status
device 側的兩張 netif
從裝置的 console 印出來對照。lwIP 的話直接讀 netif->hwaddr;有 REST 或 CLI 介面就從那裡吐。關鍵是三個位址要同時印在一起 ------ 分開印會讓人搞不清楚哪個是哪個,這是這類裝置最常見的除錯浪費。
一份清楚的開機訊息長這樣:
USB descriptor MAC : 02:03:84:7A:96:01 (參數區原始值,host 會採用)
USB netif MAC : 02:03:84:7A:96:00 (由上者衍生,device 側)
WLAN netif MAC : 3C:71:BF:12:34:56 (無線模組 OTP)
檢查清單
- host 看到的位址 == 參數區裡的原始值?不同 → descriptor 或 string descriptor 格式有問題
- device USB netif 的位址 != host 的?相同 → 衍生邏輯沒生效(很可能是 base 本身已設 LAA bit)
- 無線側的位址是不是模組廠的 OUI?看起來像本地管理位址 → 模組 OTP 沒寫,driver 退回隨機或由 MCU UID 生成
- 拔插、重開機後三個位址都不變?會變 → 有一路走到了隨機生成的分支
小結
- CDC-NCM 相對 ECM 的價值在 NTB 聚合 ,代價是 parser 要處理 NTH/NDP 兩層結構,而且
wBlockLength與結尾的(0,0)這兩個地方最容易寫錯。 iMACAddress是給 host 用的,不是裝置自己的位址。這是最常見的誤解。- 一條 USB 網路連線需要兩個 MAC(兩端各一),再加一張無線介面就是三個。它們分屬不同的 L2 端點,共用會在 bridge 模式下造成間歇性、極難查的故障。
- 只有一顆出廠位址時,用 LAA bit 進入本地管理空間再擾動一個 byte ,是最省而且不會侵犯他人位址空間的做法 ------ 但一定要檢查 base 本身,以及擋掉未寫入的
0xFF。
這套架構本身與無線側用的是哪種技術無關。任何「USB 網卡橋接到一張自帶 MAC 層的網卡」的裝置,都會遇到同一組問題。