2 ポイント 投稿者 GN⁺ 2024-11-12 | 1件のコメント | WhatsAppで共有
  • マイクロコントローラ上で 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
  • 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_WR0S0_TX_WR1 にそれぞれ書いていた
    • S0_TX_WR0 [0x0424] 0x00
    • S0_TX_WR1 [0x0425] 0x3c
  • 自分のログでは両方とも S0_TX_WR0 [0x0424] に書いていた
    • S0_TX_WR0 [0x0424] 0x00
    • S0_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件のコメント

 
GN⁺ 2024-11-12
Hacker News の意見
  • 自分で小さなツールを作れる能力はスーパーパワーで、いわゆる10xプログラマーの核心にあることが多い。
    残念ながら、こうした技術はたいてい目立たない場所で静かに発揮される

    • こういう状況は自分も経験したことがある。組織には歯車のような開発者も必要だし、多くの組織ではそういうやり方だけが実際に回ることもある。
      でも、好奇心があって二次的な影響や近いうちに必要になる機能を考える人にとっては、そうしない開発者と働くのはかなりつらい。
      最近一緒に働いた人は悪い人ではなかったが、チケットに書かれたことだけを100%実装したプロジェクトを自分が整理しているところだ。既存ソフトウェアの問題を置き換えるはずだった新しいプロジェクトが、同じ理由で既存の問題をすべてそのまま残していた。
      ただ、10x開発者という表現には少しバズワードっぽさもある。監督なしで「仕事を終わらせる」無鉄砲な開発者が大きなコストを残し、すべてがサイロ化して、あとで誰かが触るかその人が去るとカードの塔のように崩れるケースを連想するからだ
    • 逆に見れば、Frankがイーサネットパケットを送っている間、他の人たちは組織全体に必要な本来の業務を肩代わりしなければならない。解決すべき現実の問題のバックログがほぼ無限にあるのに、なぜそこで革新するのかという反論も成り立つ。
      理想的には全員に探索時間が与えられるべきだが、上位管理職が「もっと早く終わらせろ」と言うときにも、それを厳格に守ってくれる管理者が必要だ。他チームの管理者が「そのチームは失った時間を埋めるために追加採用を許されるのか? その人員はうちにも必要だ」と言い出すと、さらに難しくなる。
      結局は組織レベルの制度が必要で、Googleでさえ20%時間をやめてしまった。
      「非倫理的」な解決策は、開発見積もりの中にこうした時間を少し織り込んでおくことだ
    • 10x開発者はたいてい、マネージャーやプロダクト責任者が決めた最優先事項に縛られている人ではないことが多い。指示されるのを拒むからなのか、自分が管理者だからなのか、そもそも管理者がいないからなのかは分からない。
      適用可能な場面で自分用のツールを作るのは有用だが、生産的な開発者は最小限のコードしか書かず、すでにあるものを活用するとも言える。この記事の例で言えば、誰もTCPスタックを一から作り直す必要はなく、すでに優れた実装が存在する。
      それでも、自分で作ることが深い理解を得る最良の方法かもしれず、その深い理解こそが神話的な10x開発者の構成要素でもある。ただし、雇用主がその過程にお金を払ってくれると期待はしない
    • 自分も両極端の反応をどちらも経験したことがある。「わあ、本当に便利ですね。このツールでうちのモデルを確認できました」という反応もあれば、「PMに先に確認しましたか? ウサギ穴に落ちないよう気をつけてください」という反応もあった。
      たいてい1日以内に終わることなら、そのままやってしまう。一度も間違いだったことはないし、無駄になったこともなかった。最悪の場合でも、あとでそのコードを別の場所にコピー&ペーストすることになるだけだ
    • 最近の職場で、Pythonとシェルを組み合わせてWSL上で動く小さなツール群を作り、顧客のIoTシステムをデバッグしているところを上司が見てかなり感心していた。
      すると今度は、非プログラマーでも使えるようにしてほしいと言い始め、突然そのツール群が想定よりずっと大きな仕事になってしまった
  • タイトルはかなり曖昧だが、この記事はマイクロコントローラ向けのTCP/IPとイーサネットフレーミングスタックをゼロから作るシリーズの始まりだ。
    著者はTCP/IPを自前で処理できるW5100チップを使っているが、事前に作成したイーサネットフレームを渡す方式にも対応している。ただし、プリアンブルとCRC計算はチップが処理する。
    記事の大半は、チップ自体と通信し、テストパケットを送る過程に関する内容だ。ハードコードされたパケットのように見えるが、記事では明示されていない。
    個人的には、とんでもないハードウェアでイーサネットをビットバンギングする話を期待していた

    • RP2040はトランシーバーさえあればイーサネットのビットバンギングが可能で、100Mb/sも出せる。
      コツの一つは、ありふれたPHY + MagJackボードを改造して、RP2040にクロック信号を作らせることだ。そうすると信号が同期され、RMIIをオーバーサンプリングする必要がなくなる。
      10Mb/sだけでよければ、もっと雑な方法でも可能だ。RP2040でDVI/HDMI映像出力とイーサネットを組み合わせた「現代版」ガラス端末が出るのをまだ待っている。telnetだけでも十分で、SSHはたぶん無理だろう
    • 以前、誰かがATTiny85でそれをやっていた: https://hackaday.com/2014/08/29/bit-banging-ethernet-on-an-a...
  • 最近、イーサネット中心のFPGAエンジニアリングへとやや変わった形でキャリアを移した。
    面白い旅路で、最終的には自分でHard MAC IPを設計し、カスタムPHY IPを通じてパケットを送るところまで行った。
    この挑戦を「ハードモード」でやってみたい人には強く勧めたい。ネットワーキングはユーザーに対してあまりに抽象化されているので、イーサネットカード・モデム・スイッチがパケットの各部分をどう組み立てたり分解したりするのか、PHY/PCSがリンク上で信号をどう復元するのかを理解するのは本当に価値があった

    • 10年ほど前、FPGA上で直接TCPを処理するプロジェクトに参加したことがある。
      デバッグと検証のために、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 対応の ESP32 ボードを見てみたところ、その多く、あるいはかなりの割合が Ethernet を シリアルデバイス のように使っているようだった
  • Ethernet が扱うのはパケットではなく フレーム
    パケットは IP の概念

    • IP を定義した RFC 791 では packet と datagram の両方に言及している。IP はデータグラムを送り、各 IP データグラムは下位 L2 ネットワークに応じて 1 個以上のパケットに分割される
      ただし RFC 791 は、下位 L2 ネットワークが 128 バイトのパケットを使っていた ARPAnet である可能性が高かった時代の文書
      今日ではこの区別は実質的に無意味であるべき。大きな IP データグラムを複数の L2 パケットで送るために IP フラグメンテーションに頼っているなら、何かが間違っている。IPv6 はネットワーク内フラグメンテーションすらサポートしていない
      実務では時が経つにつれて区別はほぼ消えた。通常は Ethernet フレーム 1 つが IP データグラム 1 つを運び、皆が単にパケットと呼ぶからだ
    • HN でしか起きない話。誰かがネットワーキングをゼロから実装したのに、コメント欄では用語ひとつを理由に、その人が何をしているのか分かっていないかのようにけなす
    • Ethernet において packet は 物理層 の概念で、データリンク層の概念であるフレームをカプセル化する
      https://en.wikipedia.org/wiki/Ethernet_frame
    • packet は TCP の概念で、IP はデータグラムを送る
  • マイクロコントローラで有線 Ethernet を試したいなら、大きめの STM32 Nucleo ボードの一部は 100Mbps Ethernet を内蔵していて、25 ドル前後とかなり安い
    STM32Cube ソフトウェアの評価は分かれるが、動く Ethernet 通信のサンプルは生成してくれる
    https://www.st.com/en/evaluation-tools/nucleo-f439zi.html

    • ESP32 ベースが欲しいなら WT32-ETH01 が Aliexpress で約 7 ドル。個人的には STM 部品と同じくらい、あるいはそれ以上に簡単に始められた
      MQTT、HTTP クライアントとサーバーなども付いてくる
      最後に Cube-MX を使ったときは、全体として非常に不快な体験だった。もう一度 STM32 を使うなら stm32-hal か libopencm3 を好むと思う。ツール自体にも生成コードにも、ひどいバグや境界ケースがいろいろあって、何日もデバッグに費やした。今は改善しているかもしれない
      https://github.com/egnor/wt32-eth01
    • Ethernet 搭載の RISC-V ESP32-P4 ボードが約 20 ドルで面白い
      https://liliputing.com/waveshare-esp32-p4-nano-is-a-tiny-ris...
      もっと安いモジュールもあるのを今見つけた。WT32-ETH01 のようなものは性能がやや劣るかもしれない
    • STM32Cube は動く Ethernet 通信のサンプルを生成してくれることもあるが、ハードウェアによっては壊れている
    • 幸い、GitHub には代替になる CMake プロジェクト がいくつもある
    • STM32Cube には強い「90年代」感がある
  • Linux でネットワークスタックを自分で書いてみたいなら、socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)) を使えば、この記事とそれほど変わらない抽象化レベルで作業できる
    SOCK_RAW パケットはパケットデータを変更せずにデバイスドライバとやり取りされる。受信時にはアドレスがパースされ、標準の sockaddr_ll アドレス構造体として渡される
    送信時には、ユーザーが渡すバッファに物理層ヘッダが含まれていなければならず、そのパケットは宛先アドレスが指定するインターフェースのネットワークドライバキューに変更なしで入る
    https://man7.org/linux/man-pages/man7/packet.7.html

    • 逆方向からやるなら、tun(仮想 IP)や tap(仮想 Ethernet)インターフェースを簡単に開ける
      これは VPN インターフェースのようにネットワークスタックへ仮想インターフェースを追加する。一方で packet socket は逆に、実インターフェースと直接やり取りできるようにする
  • 正確には Ethernet フレーム と言うべきだと思う

  • よくできている。自分はちょうど直近 16 時間の勤務時間を逆方向の作業に費やしていた。Ethernet II(VLAN 含む)、IPv4+6、UDP を車載 IP プロトコルまでパースする実装だ
    用途は独自バスのキャプチャストリームを理解することで、そのストリームは Ethernet フレームの中にネストした Ethernet フレームなどを運び、自分はそれを raw socket で受け取っている
    こういう作業では Wireshark と ChatGPT が本当に貴重