はじめに
192.168.77.1 に ping を送信しています 32 バイトのデータ:
要求がタイムアウトしました。
192.168.77.1 からの応答: バイト数 =32 時間 =2ms TTL=64
192.168.77.1 からの応答: バイト数 =32 時間 =1ms TTL=64
だあぁーーーー! きたーーーーーー!!
SoftBank Air ターミナル6(SBA6D)上で、自作した外部LinuxカーネルモジュールによるUSB CDC NCM gadgetから、Windows 11へ最初のIPv4パケットが通った瞬間だった。
私はプログラマではない。Cもまともに書かないし、Linuxカーネルのソースコードを日常的に読む人でもない。
それでも今回、
- vendor独自Linux 5.4.238
- 非公開ABIの推定
- vendor kernel moduleの逆アセンブル
- ConfigFS USB gadget
- CDC NCM
- kernel crash / pstore解析
- Windows標準UsbNcm.sysとの相互接続
まで降りていくことになった。
現場作業の大部分を担当したのは、Linux実機へSSHで入れるようにした常駐AI「くろこちゃん」である。
私がやったことを乱暴に要約すると、
私:
「USB NICをもっと速くしたい。やっといて」
AI:
「承知しました」
から始まった。
この記事では、非プログラマがAIと実機を組み合わせて、仕様の分からないvendor kernel上へUSB NCM function driverを実装し、Windowsとの通信まで持っていった過程を技術寄りに記録する。
対象機器:SoftBank Air ターミナル6
対象はSoftBank Air ターミナル6(SBA6D)。
確認できている環境は以下。
Architecture: aarch64
Kernel: Linux 5.4.238
Userspace: OpenWrt 21.02.7系
UDC: 11201000.usb
内部には、
- Wi-Fi 7
- 5G modem
- Ethernet
- USB Device Controller
- GPIO
- Linux / OpenWrt系userspace
などが存在する。
普通に使えば5Gホームルータだが、かなり汎用的なLinux機でもある。
以前からEasyMesh、6GHz、MLOなどを調査しており、その過程でUSB gadget側も触れることが分かった。
vendor firmwareにはもともとCDC NCM functionが存在する。
ConfigFS上では概ね、
/config/usb_gadget/g1
以下に、
acm.gs2
ffs.adb
acm.gs0
acm.gs1
ncm.gs8
などが構成されている。
Windows 11へ接続すると、標準のMicrosoft UsbNcm.sys がbindする。
vendor NCMでは実測で、
Windows -> T6A : 約1.02~1.05 Gbit/s
T6A -> Windows : 約1.34 Gbit/s
程度まで出た。
しかしここで欲が出た。
5Gを扱える機械なのだから、せめて2.5Gbps級まで持っていきたい。
そこで、
vendor NCMを調べ、自作NCM function driverを作り、こちらでチューニングできるようにしよう
という話になった。
問題:stock kernelだが、普通のstock kernelではない
SBA6Dのkernel versionは5.4.238。
しかし、Linux 5.4.238の公開ソースと同じ構造体配置を前提に外部moduleを書けば動く、という単純な話ではなかった。
vendor側で変更されたprivate ABIが存在する。
今回かなり効いたのが struct net_device だった。
たとえば最終的に実機から推定・確認した主要offsetには、
netdev_ops = 0x1f8
ethtool_ops = 0x200
min_mtu = 0x22c
max_mtu = 0x230
dev_addr = 0x318
_tx = 0x3c0
num_tx_queues = 0x3c8
real_num_tx_queues = 0x3cc
embedded dev = 0x510
netdev_priv = 0x8c0
などがある。
さらにTX queue側は、
struct netdev_queue stride = 0x140
state offset = 0x90
と推定した。
ここでいうprivate ABIは、ものすごく乱暴に言えば、
vendor kernel内部で、構造体の各メンバが「何バイト目に置かれているか」という内輪の約束
である。
公開kernel用headerをそのまま信じて違うoffsetを触れば、当然別のメモリを読む。
そしてkernelは落ちる。
ABIをどう調べたか
正しいvendor kernel source一式が揃っているわけではない。
そこで複数の情報源を突き合わせた。
主に使ったのは、
- active kernel Image
- vendor kernel module
- symbol / relocation
- disassembly
- 実機挙動
- kernel crash log
- pstore
である。
単に逆アセンブルして終わりではない。
仮説を作る。
外部moduleへ反映する。
実機でloadする。
Windowsを接続する。
落ちる。
pstoreを見る。
再びdisassemblyへ戻る。
このループで少しずつABIを固めた。
要するにリバースエンジニアリングなのだが、実際にやっている最中の感覚は、
また落ちた
↓
どこ?
↓
ここ
↓
offset違う?
↓
直す
↓
もう一回
である。
pstoreが「仕様書」になった
組み込み機でkernel panicやOopsを起こすと困るのが、SSHも一緒に死ぬことだ。
画面の前にconsoleがあるわけでもない。
そこで非常に役立ったのがpstoreだった。
pstoreに残ったpanic/Oops情報から、
- fault virtual address
- PC
- call trace
- fault instruction
を追える。
今回の開発では、この「死ぬ直前の記録」がprivate ABIを推定する重要な材料になった。
たとえばある版では、
atomic64_fetch_andnot
netif_tx_wake_queue
t6a_gether_connect
ncm_set_alt
composite_setup
configfs_composite_setup
mtu3_irq
というcall traceと、
fault VA = 0x90
が得られた。
これがTX queueまわりのlayout mismatchを疑う大きな手掛かりになった。
結果的に、
_tx = 0x3c0
netdev_queue stride = 0x140
netdev_queue.state = 0x90
などの推定へつながった。
つまり、
kernel crashが単なる失敗ではなく、未知ABIを測る観測結果になった
わけである。
v1:いきなりsymbol collision
最初のcustom NCMでは、vendor usb_net.ko と同じ gether_* symbolをexportしてしまった。
vendor側は約40 symbolsをexport。
こちらの初期版は16 symbolsをexportしており、その16個がすべて衝突した。
loaderにclean rejectされる。
そこでcustom側は、
t6a_gether_*
へrenameし、不要なexportも除去した。
これでsymbol collision自体は回避できた。
しかし、次の壁が待っていた。
v3:usb_function_register() = -EEXIST
診断moduleを作って追ったところ、
usb_function_register() = -EEXIST
だった。
原因は単純。
vendor usb_net がすでに、
ncm
というUSB function driver名を登録していた。
custom側も同じ名前を使えば衝突する。
そこでcustom ConfigFS function driver名を、
t6a_ncm
へ変更した。
重要なのは、USB descriptor上ではCDC NCMのままであること。
ConfigFS内部で識別するfunction driver名だけを変えた。
これで、
/config/usb_gadget/.../functions/t6a_ncm.test0
を作成できるようになった。
v4:Windowsに触らせたらkernel crash
v4では、
- module load
- 30秒安定
- ConfigFS instance生成
- function link
- UDC bind
まで通った。
しかしWindowsを接続するとkernel crash。
ここで先ほどのTX queue ABI mismatchが表面化した。
custom NCM側が想定していた net_device / netdev_queue layoutと、SBA6D vendor kernelの実layoutが一致していなかった。
TX queueへ直接アクセスしていた箇所を洗い出し、opaque accessor化した。
最終ELF上で、未管理の直接layout accessをゼロにするところまで確認した。
v5:Windowsで3.8Gbps表示、しかしまた落ちる
v5はかなり進んだ。
Windows 11側で、
UsbNcm Host Device
Status = Up
LinkSpeed = 3.8 Gbps
まで到達した。
喜んだ。
しかしIP設定後、T6Aがrebootした。
pstoreには、
Unable to handle kernel NULL pointer dereference
VA 0x88
pc:
eth_start_xmit+0x294/0x2f8
Call trace:
eth_start_xmit
ncm_tx_timeout
__hrtimer_run_queues
...
が残った。
最初は、
ndo_start_xmit(NULL, netdev)ってNULL渡してるじゃん
と思いたくなる。
ところがLinux NCM datapathを追うと、NULL skbはNTB flush用の正規経路だった。
つまり、
ndo_start_xmit(NULL, netdev)
自体をrejectする修正は誤りになる。
実際のfault instructionを調べると、
ldr x3, [x0, #0x88]
だった。
さらにx0を追うと、skbではなく途中で別pointerへ上書きされていた。
結局これも、残存していたTX関連ABI offsetの誤りだった。
「NULLだからNULLチェックを入れる」という雑な修正をしなかったのは重要だったと思う。
旧v6:修正したら以前のバグが復活した
次に作ったv6では、NULL-SKB経路とTX ABIを修正した。
ところが、
insmod RC=0
なのに、
lsmodに残らない
/sys/moduleにもない
ConfigFS functionも作れない
という妙な状態になった。
logを見ると、
initcall ncmmod_init returned -17
だった。
-17 は -EEXIST。
調べると、
DECLARE_USB_FUNCTION_INIT(ncm, ...)
を再導入していた。
つまり以前直した、
vendor ncm
vs
custom ncm
の名前衝突を、自分たちで復活させていた。
これを再び、
t6a_ncm
へ戻した。
低レイヤ開発の敵は未知ABIだけではない。
昔直した自分でもある。
canonical v6:最初の1パケット
最終的な成功版は、
t6a_usb_ncm_canonical_v6.ko
SHA256:
8d17f76c797d7704f5367441a8000470be73c815495f9016d20fb6b876330799
実機では、
insmod: PASS
ConfigFS instance: PASS
function link: PASS
UDC bind: PASS
Windows enumeration: PASS
まで到達。
T6A側:
usb0 = 192.168.77.1/24
Windows側:
192.168.77.2/24
そして冒頭のping。
timeout
2ms
1ms
最初のIPv4 packetが通った。
続けて、
Windows -> T6A
30 transmitted
30 received
0% loss
T6A -> Windowsも、
30 transmitted
30 received
0% loss
だった。
ここで「動かす」という第一段階は達成した。
性能:なぜ350Mbpsで綺麗に止まるのか
次はiperf3。
Windows -> T6A:
sender 344 Mbit/s
receiver 340 Mbit/s
T6A -> Windows:
sender 353 Mbit/s
receiver 352 Mbit/s
Retr = 0
さらに -P 4:
SUM receiver ≈ 353 Mbit/s
ほぼ変わらない。
つまり、
P1 ≈ P4 ≈ 350Mbps
である。
値もかなり平坦。
packet lossやRetransmissionで崩れているわけでもない。
これはsingle TCP flowが律速している形には見えない。
どこか共通datapathに350Mbps前後のceilingがある可能性が高い。
候補として挙げたのは、
- single-core CPU
- softirq
- NCM NTB aggregation
- USB request queue depth
- NTB size
- TX flush timer
- memcpy
- software checksum
- GSO/GRO
- scatter-gather
- IRQ affinity
- MTU3 UDC scheduling
- endpoint request size
など。
一方、vendor NCMは同じhardware、同じWindows UsbNcm.sys で1Gbpsを超える。
つまり、
hardwareが350Mbpsまでしか出ない
という線は薄い。
答えを知っているblack boxが隣に存在する。
これはリバースエンジニアリング対象として非常にありがたい。
最初のチューニング:NTBを16KiBから64KiBへ
最初に狙ったのがNCMのaggregationだった。
baselineではNTBが16KiB構成。
そこで候補版では、
NTB input max : 16KiB -> 64KiB
NTB output max: 16KiB -> 64KiB
TX datagrams:
32 -> 64
flush timeout:
300us -> 80us
へ変更した。
狙いは、USB request 1回あたりにより多くのEthernet frameを詰め、request回数やinterrupt、CPU overheadを減らすこと。
一方でQMUやchecksum/TSO/SGなどのhardware offloadは、正しいfeature advertisement条件を確認できていないため触らなかった。
速そうだからfeature bitを立てる、はkernel network driverでは危険すぎる。
64KiB版:Windowsでは3.8Gbps、しかしpacketは0
64KiB候補をload。
insmod RC=0
ConfigFS function生成成功。
UDC bind成功。
Windows側:
UsbNcm Host Device #4
Status = Up
LinkSpeed = 3.8 Gbps
T6A側:
UDC state = configured
usb0 = UP, LOWER_UP
192.168.77.1/24
見た目は完璧。
しかしpingは通らない。
Windows側ARPにも 192.168.77.1 が載らない。
T6A側では、
RX packets = 0
TX packets = 0
そしてneighborは、
192.168.77.2 FAILED
だった。
つまりIP層以前にEthernet frameが流れていない。
dmesgを見た。
答えは一行だった。
mtu3 11201000.usb:
req length > supported MAX:65532 requested:65536
大量に出ていた。
64KiBは「4 bytesだけ」大きすぎた
ここはかなり笑った。
設定したrequest length:
65536
MTU3 UDCが受け付ける最大値:
65532
差:
4 bytes
64KiB化の方向そのものが間違っていたわけではない。
SBA6DのMTU3 UDCには、少なくともこのpathでは、
MAX = 65532
という境界が存在していた。
Windows enumerationも成功する。
USB linkもUpになる。
UDC stateもconfiguredになる。
しかし実データ用requestをqueueしようとした時点で拒否される。
そのため、
usb0 UP
LOWER_UP
Windows NIC Up
なのに、
RX=0
TX=0
ARP失敗
という状態になった。
次候補では、
NTB_DEFAULT_IN_SIZE = 65532
NTB_OUT_SIZE = 65532
TX_MAX_NUM_DPE = 64
TX_TIMEOUT_NSECS = 80000
を試す予定である。
これを書いている時点では、まだ65532-byte版の性能結果は出ていない。
AIに何をやらせたのか
このプロジェクトでは、AIに質問してコード片をもらった、という使い方ではない。
AIから実際のLinux環境へSSHできるようにしている。
現場では、
build
objdump
readelf
nm
grep
diff
insmod
rmmod
logread
dmesg
pstore
iperf3
ip
ethtool
などを組み合わせて調査している。
人間側は、
- 目的
- 試験条件
- 変更してよい範囲
- 危険操作の禁止
- 結果の評価
- 次に何を疑うか
を決める。
AI側は、
- 大量のコード比較
- binary解析
- ABI候補抽出
- module生成
- static validation
- 実機試験
- log収集
- 次仮説の生成
を担当する。
そして最終判定者は実機である。
Human
↓ hypothesis / objective
AI
↓ implementation / analysis
Hardware
↓ result
AI
↓ interpretation
Human
↓ next decision
というループになる。
ここが、単純な「AIコーディング」と少し違うところだと思う。
「ネットに答えがない」場合でもAIは使える
LLMについて、
インターネットにある情報を要約して答えているだけ
という説明をよく見る。
もちろん学習済み知識や検索は重要である。
しかし今回面白かったのは、最終的な答えがWeb上に存在しない部分だった。
たとえば、
SBA6D vendor kernelにおける
struct net_deviceのこのoffsetは何か
という答えが、そのままWeb検索で見つかるわけではない。
そこで、
- vendor binaryを見る
- active kernelを見る
- crashする
- pstoreを見る
- instructionを見る
- 仮説を作る
- 実機で試す
という観測ループを回す。
AIは「答えを検索する装置」ではなく、
仮説を大量に作り、実験し、結果から次へ進む作業者
として使える。
非プログラマの私にとって、この差はかなり大きかった。
まとめ
SoftBank Air ターミナル6のstock Linux 5.4.238上で、外部USB CDC NCM function driverを作成した。
現在までに、
external module load PASS
ConfigFS function PASS
UDC bind PASS
Windows UsbNcm enumeration PASS
bidirectional IPv4 PASS
30 packet ping / loss 0% PASS
iperf3 約340~353Mbps
まで到達している。
また、performance tuning中に、
MTU3 request max = 65532 bytes
という実機側の新しい制約も確認した。
現在の目標は、
350Mbps ceilingの原因特定
↓
1.5Gbps
↓
2.5Gbps
↓
3.5Gbps
である。
Linuxカーネルドライバを書こうと思って始めたわけではない。
USB NICを速くしたかっただけだ。
それをAIに、
やっといて
と頼んだ。
気がついたら、vendor kernelのprivate ABIを逆アセンブルとkernel crashから復元していた。
LLMは人間を堕落させると思う。
以前ならCを勉強し、kernel sourceを読み、USB gadget frameworkを理解して、それからようやく入口に立ったのかもしれない。
今回は順番が逆だった。
まず実機を触る。
落ちる。
必要になったところだけ調べる。
また触る。
そして、知らないまま始めた人間が、知らなかった場所まで来てしまった。
現在、自作USB NICは350Mbps。
純正は1Gbpsを超える。
そしてMTU3は、65536 bytesを受け付けてくれなかった。
4 bytes削って、もう一度である。