イーサネットパケット送信事件
(github.com/francisrstokes)- マイクロコントローラ上で TCP/IP スタックを自前で積み上げる第一歩として、STM32F401 Nucleo と Wiznet W5100 shield を接続し、イーサネットフレーム送信を試みた
- W5100 のハードウェア TCP/IP 機能は使わず、MAC Raw モードのみを使用し、チップにはフレーム送信に必要な低レベル処理だけを任せた
- 最初の障害は Arduino Ethernet shield の SPI 配線が Nucleo と合っていないことから始まり、ICSP ヘッダの配線を確認したうえで、ボードへの改造配線で解決した
- その後は chip select のタイミング問題と思われる MISO の異常応答や、Wireshark に現れるゴミパケットに遭遇し、ロジックアナライザと基準実装との比較が重要なデバッグ手段になった
- 最終原因は
w5100_write16()が 2 バイト目を同じアドレスに再度書き込んでしまうバグで、SPI capture CSV 解析ツールを作ったことで正常なパケット送信に到達できた
自前の TCP/IP スタックを作るための出発点
- 目標は、マイクロコントローラ上で TCP/IP スタックを一から実装する “Networking from scratch” シリーズを始めること
- 今回の段階で表面的な成果は最初のイーサネットパケット送信だが、実際の焦点はハードウェアとドライバの間で起きたバグを追跡する過程にある
- 使用したボードは STM32F401 ベースの Nucleo 開発ボード
- ARM Cortex-M4
- 最大 84MHz 動作
- 96KiB RAM
- 複数のパケットを保持するのに十分なメモリだと判断した
Ethernet と W5100 の役割
- Ethernet は単なるポートやフレーム形式ではなく、物理層ハードウェア、信号方式、バス衝突処理、フレーム配置まで含む技術・標準の体系である
- Ethernet の信号処理は複雑なので、通常は専用 ASIC がフレームレベルのデータを受け取り、ケーブル上の電気信号処理を担当する
- プロジェクトでは Wiznet W5100 チップを載せた Arduino Ethernet shield を使用した
- 使用したボードは低価格の互換品で、正常動作のために改造が必要だった
- W5100 は Ethernet ASIC にハードウェア TCP/IP スタックを内蔵したチップである
- 4 つの “socket” を提供する
- TCP、UDP、IP、“MAC Raw” レベルに設定できる
- TCP/IP スタックを自前で実装する目的から、W5100 の TCP/IP 機能は使わず、1 つのソケットを MAC Raw モードでのみ使った
- ユーザーは Ethernet frame を渡す
- W5100 が実際の送信を行う
- preamble と start-of-frame marker は電気的レベルの要素なのでチップが処理する
- 32-bit CRC も W5100 が計算する
問題 1: SPI 信号が W5100 まで届かない
- W5100 とのデータ交換は SPI で行われる
- MOSI: メインチップであるマイクロコントローラの出力
- MISO: W5100 の出力
- clock: データ基準クロック
- chip select: 従属チップと通信中であることを示す信号
- W5100 のデータシートは SPI 上に 4 バイトのコマンドプロトコルを定義している
- 1 バイトの operation
- 2 バイトの 16-bit big-endian address
- 1 バイトの value
- operation は書き込み
0xf0または読み取り0x0fである- それ以外の値は無効で、無視されるはず
- W5100 はバイトを clock out するたびに既知の値を MISO に返すので、通信異常を確認しやすい
- 読み取りコマンドでは 4 バイト目が指定アドレスから読んだ値になる
- 当初は MOSI でコマンドを送っても MISO にゴミ値が見えていた
- 原因は Arduino Ethernet shield が SPI 信号を Arduino 標準ヘッダではなく ICSP 6 ピンヘッダへ配線する設計だったことにある
- 正式な Arduino ボードでは内部的に ICSP と標準ヘッダの SPI 信号が接続されているため問題にならない
- Nucleo ボードには ICSP ヘッダがないため、送った SPI 信号が W5100 に届いていなかった
- マルチメータで電源レールと SPI 信号の間の抵抗を測定し、接続問題を確認した
- 無限大の抵抗が出れば配線が切れていると分かる
- はんだ付けとエナメル銅線で、Nucleo と W5100 の間の信号を直接接続した
問題 2: chip select のタイミングと MISO の異常応答
- SPI 信号が実際につながったことで W5100 と通信できるようになり、次の段階まで実装できた
- W5100 の raw Ethernet transmission 設定
- MAC address 設定
- TX/RX メモリセグメント設定
- テスト用 Ethernet frame を TX メモリに書き込み、送信をトリガー
- Nucleo と shield を CAT5 ケーブルでノートPCに接続し、Wireshark を実行したがパケットは見えなかった
- 低レベルの問題にはエラーメッセージや stack trace がなく、「電子を揺さぶったが期待したことが起きない」という形なので追跡が難しい
- デバッグには logic analyser を使った
- 複数チャネルでデジタル信号の high/low 遷移をサンプリングする
- SPI bus のような信号束をソフトウェアが解釈し、transaction byte を表示する
- CSV などの構造化形式でエクスポートできる
- 使用機材は Saleae Logic 8
- 約 €10 程度の安価な analyser でも Saleae Logic2 と連携できることはあるが、正常かつ一貫した動作にはトレードオフがある
- 最初の SPI コマンドは正常に見えた
0xf0 0x00 0x00 0x80- アドレス
0x0000の Mode Register に最上位ビットを設定して software reset をトリガー - MISO は
0x00から0x03まで正常応答
- 次の読み取りコマンドでは異常応答が現れた
- MOSI:
0x0f 0x00 0x00 0x00 - MISO:
0x03 0xff 0xff 0xff
- MOSI:
0x03が直前 transaction の最後の値であることから、W5100 の内部状態かタイミング問題が疑われた- SPI clock はデータシート上の最大約 14MHz よりはるかに低く使っていたため、原因とは見なさなかった
- そこで chip select が早すぎて high に戻り、チップが悪い状態に入るのではないかと考え、状態変更前に数マイクロ秒の遅延を入れた
- その後 MISO 応答は正常化した
- 設定値を読み戻しても妥当な値になった
- この問題の正確な原因はまだ完全には分かっていない
- データシート 66 ページの SPI timing diagram にある chip select 関連の制約は満たしていた
- 正確な境界条件を再調査することもできるが、現時点ではプロジェクトの前進を優先した
問題 3: Wireshark に現れたゴミパケット
- 再び Wireshark を実行すると、パケットは現れたが意図したものではなかった
- マイクロコントローラが送信コマンドを出した瞬間、本来入れたものよりはるかに大きい raw Ethernet packet がゴミデータで埋まった形で現れた
- データシートと仕様だけを見て実装する計画はいったんやめ、この段階では動作確認済みの実装と比較することにした
- Arduino は基準実装を素早く作るのに便利だった
- Arduino とライブラリ、わずか 5 行ほどのコードで複雑な動作を確認できる
- raw Ethernet packet 送信ライブラリを探すにはさらに時間がかかった
- ほとんどの Arduino ユーザーはネットワーク機能を最初から実装しようとはしない
- 公式ライブラリは public API から raw packet 送信サポートを削除していた
- 最小段階で packet 送受信を行う GitHub プロジェクト W5100MacRaw を見つけた
- そのソースと自分の実装を比較したが、すぐに決定的と分かる差はなかった
- register read/write の順序が違う
- 片方にしかない read/write がある
- 通常、順序依存性があるならデータシートに明記されているはずだと考えた
- read/write の順序を基準実装に合わせても、Wireshark には依然としてゴミパケットが出ていた
小さなツールで露呈した本当のバグ
- 次の戦略は、Saleae Logic2 の SPI capture CSV をパースして register read/write の一覧として再表示するツールを書くことだった
- ツールは Python で作り、全体の長さは 200 行を少し超える程度
- 大半はデータシートから写した register 名とアドレス
- 作成には約 1 時間かかった
- 入出力を明確にするため argument parsing も入れた
- Arduino の基準実装と自分の実装でそれぞれ SPI capture を取り、CSV にエクスポートしてからツールで処理し、diff を取った
- 問題は
w5100_write16(u16 address, u16 value)という便利関数にあった- W5100 の多くの register は 16-bit 値である
- コマンド形式では一度に 8-bit しか書けないため、high/low byte に分けて書く必要がある
- この関数は 2 バイト目を
address + 1に書かず、同じアドレスにもう一度書いていた
- Arduino の基準ログでは
S0_TX_WR0とS0_TX_WR1にそれぞれ書いていたS0_TX_WR0 [0x0424] 0x00S0_TX_WR1 [0x0425] 0x3c
- 自分のログでは両方とも
S0_TX_WR0 [0x0424]に書いていたS0_TX_WR0 [0x0424] 0x00S0_TX_WR0 [0x0424] 0x3c
- この register は Socket 0 transmit write pointer だった
- W5100 はこの 16-bit register を順に読み取り
- TX メモリにバイトを書き込み
- 新しい write pointer を再び書いたあと
- socket send command を送る流れを想定している
- 2 バイト目を書かないまま send を実行したことで、チップが未定義の異常状態に入り、Wireshark にはランダムな結果のように見えるパケットが現れた
- 関数を修正すると、テストパケットは Wireshark に正常表示された
デバッグツールを作る時間の価値
- 最初のイーサネットパケット送信そのものは巨大な偉業ではないが、このプロジェクトでは明確な成功として受け止められる瞬間だった
- バグをたどる作業は個人プロジェクトでは楽しく、ツール作成とデバッグ空間の探索に時間を使うことにはほとんど常に価値がある
- 一部の専門的な環境では、直接 deliverable を作らない作業を無駄と見る傾向がある
- 開発プロセスが JIRA 的に細分化され、チェックボックスを直接埋めない作業は低く評価されがち
- システムをまだ十分に理解していない段階では、ツール作成はテスト作成と同じくらい、時にはそれ以上に重要になりうる
- デバッグは科学的方法の実装に近い
- データを集める
- 予測を立てる
- 実験で予測を検証する
- 新しいデータで予測を更新する
- このプロジェクトはこの先、さらに高い抽象度の問題へ進んでいく
- RFC の誤解
- multitasking code の作成
- 新しいバグへの対応が続いている
1件のコメント
Hacker News の意見
自分で小さなツールを作れる能力はスーパーパワーで、いわゆる10xプログラマーの核心にあることが多い。
残念ながら、こうした技術はたいてい目立たない場所で静かに発揮される
でも、好奇心があって二次的な影響や近いうちに必要になる機能を考える人にとっては、そうしない開発者と働くのはかなりつらい。
最近一緒に働いた人は悪い人ではなかったが、チケットに書かれたことだけを100%実装したプロジェクトを自分が整理しているところだ。既存ソフトウェアの問題を置き換えるはずだった新しいプロジェクトが、同じ理由で既存の問題をすべてそのまま残していた。
ただ、10x開発者という表現には少しバズワードっぽさもある。監督なしで「仕事を終わらせる」無鉄砲な開発者が大きなコストを残し、すべてがサイロ化して、あとで誰かが触るかその人が去るとカードの塔のように崩れるケースを連想するからだ
理想的には全員に探索時間が与えられるべきだが、上位管理職が「もっと早く終わらせろ」と言うときにも、それを厳格に守ってくれる管理者が必要だ。他チームの管理者が「そのチームは失った時間を埋めるために追加採用を許されるのか? その人員はうちにも必要だ」と言い出すと、さらに難しくなる。
結局は組織レベルの制度が必要で、Googleでさえ20%時間をやめてしまった。
「非倫理的」な解決策は、開発見積もりの中にこうした時間を少し織り込んでおくことだ
適用可能な場面で自分用のツールを作るのは有用だが、生産的な開発者は最小限のコードしか書かず、すでにあるものを活用するとも言える。この記事の例で言えば、誰もTCPスタックを一から作り直す必要はなく、すでに優れた実装が存在する。
それでも、自分で作ることが深い理解を得る最良の方法かもしれず、その深い理解こそが神話的な10x開発者の構成要素でもある。ただし、雇用主がその過程にお金を払ってくれると期待はしない
たいてい1日以内に終わることなら、そのままやってしまう。一度も間違いだったことはないし、無駄になったこともなかった。最悪の場合でも、あとでそのコードを別の場所にコピー&ペーストすることになるだけだ
すると今度は、非プログラマーでも使えるようにしてほしいと言い始め、突然そのツール群が想定よりずっと大きな仕事になってしまった
タイトルはかなり曖昧だが、この記事はマイクロコントローラ向けのTCP/IPとイーサネットフレーミングスタックをゼロから作るシリーズの始まりだ。
著者はTCP/IPを自前で処理できるW5100チップを使っているが、事前に作成したイーサネットフレームを渡す方式にも対応している。ただし、プリアンブルとCRC計算はチップが処理する。
記事の大半は、チップ自体と通信し、テストパケットを送る過程に関する内容だ。ハードコードされたパケットのように見えるが、記事では明示されていない。
個人的には、とんでもないハードウェアでイーサネットをビットバンギングする話を期待していた
コツの一つは、ありふれたPHY + MagJackボードを改造して、RP2040にクロック信号を作らせることだ。そうすると信号が同期され、RMIIをオーバーサンプリングする必要がなくなる。
10Mb/sだけでよければ、もっと雑な方法でも可能だ。RP2040でDVI/HDMI映像出力とイーサネットを組み合わせた「現代版」ガラス端末が出るのをまだ待っている。telnetだけでも十分で、SSHはたぶん無理だろう
最近、イーサネット中心のFPGAエンジニアリングへとやや変わった形でキャリアを移した。
面白い旅路で、最終的には自分でHard MAC IPを設計し、カスタムPHY IPを通じてパケットを送るところまで行った。
この挑戦を「ハードモード」でやってみたい人には強く勧めたい。ネットワーキングはユーザーに対してあまりに抽象化されているので、イーサネットカード・モデム・スイッチがパケットの各部分をどう組み立てたり分解したりするのか、PHY/PCSがリンク上で信号をどう復元するのかを理解するのは本当に価値があった
デバッグと検証のために、Verilatorで作ったシミュレーション版をLinuxのTUN/TAPデバイスに接続できるようにし、物理ハードウェアを占有せずに開発者マシンから直接アクセスできるようにしていた。ハードウェアが1台しかなかったので、特に有用だった
そうでなければ、全体像をつかむためにどんな資料を使ったのかも知りたい。
自分のネットワーキング知識はOSIモデルのごく基本的な理解で止まっているが、ネットワーキングの世界を深く掘り下げてみたい
MOSI/MISOの再解釈を初めて見た。master out/slave inの代わりにmain out/subordinate inと呼ぶやり方だ。
これならピン名は引き続きMOSI/MISOと呼べるので、自分も使えそうだ。COPI/CIPO(controller out/peripheral in)という代替案は頭にあまり入ってこなかった
著者が W5100 Ethernet Shield と STM32F401 を使う理由がよく分からない。同じくらい手軽に STM32F407 ボードを使えば内蔵 Ethernet MAC があり、安価な Ethernet PHY ボードと組み合わせて開発できる
Ethernet のサンプルプロジェクトも多く、STM32F401 と同じくらい開発しやすい
また、「Ethernet 信号処理の複雑さのため通常は専用 ASIC を使う」という説明は、マイクロコントローラの文脈では概ね当てはまらないと思う。多くの場合、Ethernet 機能は STM32F407 や ESP32 のようにマイクロコントローラ内の内蔵ペリフェラルとして入っている
内蔵 Ethernet ペリフェラルのあるチップを使うほうが、たしかにずっと理にかなっている。ただ、W5100 の設定の複雑さを ST ペリフェラル設定の複雑さと引き換えにすることにもなる
ネットワーキングコードはすでに実チップをドライバインターフェース(read/write/ioctl のような形)の背後に抽象化してあるので、移植はかなり簡単なはず
本シリーズでは STM32F407 を検討してみる
Ethernet が扱うのはパケットではなく フレーム
パケットは IP の概念
ただし RFC 791 は、下位 L2 ネットワークが 128 バイトのパケットを使っていた ARPAnet である可能性が高かった時代の文書
今日ではこの区別は実質的に無意味であるべき。大きな IP データグラムを複数の L2 パケットで送るために IP フラグメンテーションに頼っているなら、何かが間違っている。IPv6 はネットワーク内フラグメンテーションすらサポートしていない
実務では時が経つにつれて区別はほぼ消えた。通常は Ethernet フレーム 1 つが IP データグラム 1 つを運び、皆が単にパケットと呼ぶからだ
https://en.wikipedia.org/wiki/Ethernet_frame
マイクロコントローラで有線 Ethernet を試したいなら、大きめの STM32 Nucleo ボードの一部は 100Mbps Ethernet を内蔵していて、25 ドル前後とかなり安い
STM32Cube ソフトウェアの評価は分かれるが、動く Ethernet 通信のサンプルは生成してくれる
https://www.st.com/en/evaluation-tools/nucleo-f439zi.html
MQTT、HTTP クライアントとサーバーなども付いてくる
最後に Cube-MX を使ったときは、全体として非常に不快な体験だった。もう一度 STM32 を使うなら stm32-hal か libopencm3 を好むと思う。ツール自体にも生成コードにも、ひどいバグや境界ケースがいろいろあって、何日もデバッグに費やした。今は改善しているかもしれない
https://github.com/egnor/wt32-eth01
https://liliputing.com/waveshare-esp32-p4-nano-is-a-tiny-ris...
もっと安いモジュールもあるのを今見つけた。WT32-ETH01 のようなものは性能がやや劣るかもしれない
Linux でネットワークスタックを自分で書いてみたいなら、
socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL))を使えば、この記事とそれほど変わらない抽象化レベルで作業できるSOCK_RAWパケットはパケットデータを変更せずにデバイスドライバとやり取りされる。受信時にはアドレスがパースされ、標準のsockaddr_llアドレス構造体として渡される送信時には、ユーザーが渡すバッファに物理層ヘッダが含まれていなければならず、そのパケットは宛先アドレスが指定するインターフェースのネットワークドライバキューに変更なしで入る
https://man7.org/linux/man-pages/man7/packet.7.html
これは VPN インターフェースのようにネットワークスタックへ仮想インターフェースを追加する。一方で packet socket は逆に、実インターフェースと直接やり取りできるようにする
正確には Ethernet フレーム と言うべきだと思う
よくできている。自分はちょうど直近 16 時間の勤務時間を逆方向の作業に費やしていた。Ethernet II(VLAN 含む)、IPv4+6、UDP を車載 IP プロトコルまでパースする実装だ
用途は独自バスのキャプチャストリームを理解することで、そのストリームは Ethernet フレームの中にネストした Ethernet フレームなどを運び、自分はそれを raw socket で受け取っている
こういう作業では Wireshark と ChatGPT が本当に貴重