CDC-NCM 的三個 MAC:一條 USB 網路連線上到底有幾個 L2 端點

前言

無線通訊當道的今日,筆者所在單位正在開發 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 的) iMACAddresswMaxSegmentSize
0x1A NCM Functional Descriptor bcdNcmVersionbmNetworkCapabilities

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。這就是「插上去多一張網卡」的全部魔法:

  • Linuxcdc_ncm driver 接手,出現 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 尾端保留的一小段參數區的那一顆。其餘的靠韌體推導。

推導要滿足四個條件:

  1. 與 base 不同 ------ 否則就是上一節那些症狀
  2. 不會撞到別人的全球唯一位址 ------ 不能隨便加一減一,那個結果可能是某家廠商合法配發的位址
  3. 重開機後不變 ------ 位址跳動會讓 DHCP lease、ARP cache、AP 的關聯紀錄全部錯亂
  4. 可以從單一來源算出來 ------ 不需要在產線多寫一筆

第 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 層的網卡」的裝置,都會遇到同一組問題。

相关推荐
未来和明天1 天前
领嵌4G/5GAI边缘计算盒子多路视频算力高达10.4Tops兼容Modbus、DLT645、OPC UA等多种行业协议
人工智能·5g·边缘计算
鸿芯微控科技1 天前
MFC模拟量信号异常怎么排查?0-5V、4-20mA接线与量程换算
c++·5g·mfc·故障排查·质量流量控制器·4-20ma·模拟量信号
daad7772 天前
802.11 前导码与 STF 深度解析(含检测算法与 5G 对比
人工智能·算法·5g·wifi·802.11·前导码
szarron2 天前
VNA6 便携式矢量网络分析仪:从入门到精通
数据库·嵌入式硬件·测试工具·5g·射频工程
普马萨特4 天前
6G 高精度定位:从通信附加功能走向无线空间感知
5g·6g
普马萨特4 天前
5G与6G对比:从万物互联到万物智联的代际跨越
5g·6g
普马萨特4 天前
5G 高精度定位面临的三个主要问题
5g
斐夷所非4 天前
USB | 接口标识与协议标准
usb
luiyarch6 天前
从0到量产:一款车载5G TBOX完整硬件开发流程——方案选型、原理图、PCB、RF、EMC、DV/PV,到SOP全过程拆解
5g·车载系统