「ゼロからのTCP/IPプロトコルスタック自作入門」をGo言語で実装する。自習用リポジトリ。
「ゼロからのTCP/IPプロトコルスタック自作入門」を読み進めながら、 TCP/IP プロトコルスタック microps を Go 言語で実装する。
- ネットワークの理解を深める。
- Go 言語の学習。
- Ubuntu 24.03.0 LTS (WSL2)
- go 1.24.0
- 書籍では、予めいくつかの定数やユーティリティ関数が用意されているが、それらは必要になったら実装していく。
- なるべく、Go言語らしいコードを心掛ける。 Unsafe は極力使わない。
- コードや説明文には日本語を使用する。
- パッケージをなるべく適切に分けたいが、そうすると、テストコードを実行するための main パッケージから直接関数を呼び出せるようにするために関数を外部公開する必要がある。 Step によって呼び出す関数が変わってくるため、外部公開する関数も適宜調整が必要となってしまうが、その点は妥協する。
- Makefile
- 最低限のものを用意。
- ./test ディレクトリ内のコードをビルド対象とする。
- ビルドする際にlintを掛けるようにしておく。
- platform_linux.go
- プラットフォーム依存のコードは言語側で吸収してくれそうだが、一応作成しておく。後で不要になるかもしれない。
- Goのビルドタグ機能を使うため末尾に _linux を付ける。
- メモリ確保コードは Go では不要、ロック機構は sync.Mutex を使用するので不要、疑似乱数も rand パッケージを使用するので不要。
- 結果として、platform_xxx 系の関数だけを用意するが、乱数シードの初期化も Go では不要なので、関数のスケルトンだけ用意することになった。
- util.go
- internal/util パッケージとする。
- ロギング関数群
- FILE* は Go だと io.Writer。
- flockfile(), funlockfile() は sync.Mutex で代用。
- __FILE__ や __LINE__ といったマクロ定数は、 runtime.Caller で代用。
- HexDump の中では Unsafe を使う必要がある。Unsafe を使うのはここだけの予定。
- HexDump の引数は any 型だが、ダンプできるのは固定長型のみ。
- string 型は渡せない。ascii 配列に変換する。
- スライス型は渡せない。固定長の配列型にする。
- int 型は int8 などサイズが明確な型にする。
- dump_on.go, dump_off.go
- 書籍では CFLAGS="-DHEXDUMP" を付けたときだけダンプ出力するような仕掛けになっているので、これを Go のビルドスイッチで再現する。
- net.go
- 書籍通りに実装。
- test.go
- テストコードは main パッケージとする。
- Go では配列を定数にできないので testData は変数にする。
- シグナルの割り込み処理は signal.NotifyContext を利用する。
- defer stop() を呼び出すため、処理を setup() から main() に移した。
- setup() の中で割り込み処理を書くが、onSignal() のような関数は設けずに無名関数を Go ルーチンで呼び出す。
- net.go
- マクロ定数は const 定数に置き換え。
- 単方向リストはスライスに置き換えられるので、不要な変数や関数は実装しないようにする。
- 構造体メンバの next や index は不要になる。
- メモリ確保関数 ~alloc などが要らなくなる。
- 書籍では新しいデバイスをリストの先頭に挿入しているが、単純に末尾に追加していくことにする。
- オブジェクトを操作する関数群は構造体のレシーバとして実装する。
- test.go
- dummyInit()
- C では、自前でメモリ確保してそれを端方向リストに連結するため、ずっと同じオブジェクトのポインタを保持し続けることができるが、 Go ではスライスに追加される際に値コピーされて別のオブジェクトとなってしまう。 そのため、netDeviceRegister() の戻り値で「いま追加したオブジェクトのポインタ」を返すようにコードを変更している。 microps パッケージ内のデバイスリストを直接公開する方法もあるが、それはすべきではないと考えたので、一旦この状態で先に進む。
- dummyInit()
- net.go
- ポリモーフィズムを利用してデバイスドライバ固有の Open(), Close(), Output() を実装する方法をどうするか。
- 書籍のように、Open(), Close(), Output() それぞれを関数オブジェクトとして用意するのは Go らしくないのでは。
- Go ではインタフェースを使用したいが、NetDevice 構造体ごとインタフェースにするのは書籍とコードがかけ離れすぎてしまう。
- 結局、NetDevice 構造体の中の Ops メンバをインタフェースにすることにした。
- その結果、Open(), Close() の実装は任意ではなく必須となったため、不要な場合は単純に true を返すだけのコードとする。
- Output() も必須となるため、実装されていない場合のエラーチェックは不要となった。これは、実行時ではなくコンパイル時に必須チェックできていることになる。
- ポリモーフィズムを利用してデバイスドライバ固有の Open(), Close(), Output() を実装する方法をどうするか。
- loopback.go
- driver パッケージにしたが、microps パッケージに一本化すべきかもしれない。その点は後で変えるかも。
- util.co
- ログに出力する関数名にレシーバの型名を出力するように変更。メソッドを使うことで、 Output といった関数が複数存在することになり区別が付かなくなってしまったため。
- より Go らしいコードにするためにリファクタリング。
- microps パッケージで保持するデバイスリストはインタフェース型にし、デバイスドライバの Open(), Close(), Output() を実装した構造体を用意する方式とする。
- デバイスドライバで共通で保持する情報が NetDevice 構造体に集約されているため、これを構造体の埋め込みで保持することとし、 Info() という独自のメソッドでその構造体へアクセスできるようにする。これは書籍には無い実装である。
- NetDeviceRegister() と LoopbackInit() は dev を返す必要がなくなったので戻り値を bool のみに変更。
- NetDevices を専用の型にする必要がないため修正。
- net.go
- 書籍では NetProtocolRegister() にコールバック関数 handler を渡してプロトコルリストに登録しているが、 Go ではインタフェースにしたいので、必要な関数を実装した構造体を渡すようにする。
- インタフェースの関数名は InputHandler にしておく。
- 書籍の ip_input() は ip.go の InputHandler() として実装されることになる。
- また、書籍ではプロトコル種別を引数で渡して関数内で登録するが、構造体を引数で渡すので呼び出し元で登録しておくのが自然と考えて変更する。
- net.go, loopcack.go
- これまでに実装した関数の typ 変数をすべてプロトコル種別に修正(デバイス種別と勘違いしていた)。
- loopback.go
- 直前のリファクタリングで LoopbackInit() の戻り値を bool にしたが、こちらは NetDevice にすべきだったので修正。
- net.go
- デバイス名称の番号が 1 からスタートしていたので、 0 スタートに修正。
- ip.go
- IPアドレスと文字列の相互変換の関数は、Go の慣習に従って以下のように実装する。
- ip_addr_pton() : ParseIPAddr() という通常の関数とする。
- ip_addr_ntop() : IPAddr.String() というメソッドとする。
- 書籍の ip_print() で debugdump() を使わずに hexdump() を直接呼び出しているのは何故かわからない。DebugDump() を呼び出すことにする。
- IPアドレスと文字列の相互変換の関数は、Go の慣習に従って以下のように実装する。
- util.go
- バイトオーダー変換用の関数を書籍と同じように用意する。
- システムのバイトオーダーを調べる方法、binary.NativeEndian と binary.LittleEndian といった定義を使えないかと考えたが無理だったので、書籍と同じ方法にした。
- 直接比較は型不一致で行えない。文字列化しても "NativeEndian" となってしまうので意味がなかった。
- どちら向きの変換であっても、結局はネイティブエンディアンがリトルエンディアンであったらバイトスワップするだけ。 それぞれ用意する必要はあるのか?コードの可読性のため?一応、書籍と同じように用意しておく。
- 全体
- flockfile(), funlockfile() の代わりに sync.Mutex.Lock() を使用していたが、呼び出しがネストするとデッドロックしてしまった。 これらのロック機構の目的は、一連の print 出力の間に別の出力が混ざらないようにすることと考えられる。 Go では、1行1行 print を行うのではなく、StringBuilder で出力文字列のブロックを構築してから出力することで代用できる。 そのため、ロック機構を削除した。
- バイトコードと別の型(構造体や []uint16 など)を変換する場合のバイトオーダー指定は binary.NativeEndian 固定で良さそう。 余計な変換はせず、バイトコードをそのままの順序で解釈することになるため。
- ip.go
- IPInterface 構造体に NetInterface 構造体を埋め込む。これまで通り Info() で埋め込まれた構造体を参照可能とするので、書籍の NET_IFACE マクロのようなものは不要。
- 定数 IPAddrStrLen は使用しないので削除。string 型を使用するため。
- 全体
- コメントの誤記を修正
- ip.go
- ip_output_device() は、 IPIface型のメソッドとするのが自然と考え、IPIface.Output() として実装する。
- 全体
- 可読性向上のために各関数に空行を挿入。
- ip.go
- 書籍に沿って進めると、IPプロトコルを管理するための構造体を追加する必要がある。 しかし、Go 言語の実相の都合上、IPProtocol 構造体を、IPプロトコル自身を管理するための構造体として作成済みなので困った。 書籍で言っているのは上位プロトコル(ICMP, TCP, ...)のことなので、名称を別に設ける必要がある。 IPUpperProtocol とする。
- icmp.go
- 上で定義する IPUpperProtocol はインタフェース型とし、実体となる構造体は icmp.go で定義する。 net.go と ip.go の関係と同じ。
- icmp.go
- icmp_type_ntoa() は ICMPType.String() メソッドとして実装。
- 関数の戻り値で成否を返す時に bool 型と error 型が混在していたので、bool 型に統一する
- Go の慣習的には error 型に統一すべきだが、書籍に合わせてコーディングしていくと bool 型がやりやすいので。
- util.go
- ポインタを使わないようにすることで、[]uint8 と任意の型(ICMPHdr など)の相互変換を多用するため、ユーティリティ関数に切り出す。
- ToBytes() と FromBytes() を作成
- ポインタを使わないようにすることで、[]uint8 と任意の型(ICMPHdr など)の相互変換を多用するため、ユーティリティ関数に切り出す。
- Makefile
- TAPデバイスの作成処理を追加。
- ついでにコードを少し整理。
- ether.go
- このレイヤーは driver パッケージにすべきとも思ったが、書籍に合わせて microps パッケージに作成しておく。
- Ethernetアドレスと文字列の相互変換の関数は、Go の慣習に従って以下のように実装する。
- ether_addr_pton() : ParseEtherAddr() という通常の関数とする。
- ether_addr_ntop() : EtherAddr.String() というメソッドとする。
- 書籍の ether_print() で debugdump() を使わずに hexdump() を直接呼び出しているのは何故かわからない。DebugDump() を呼び出すことにする。
- test.go, tap.go
- 書籍ではテストコード test.c と tap.c の両方に main 関数を用意して、一度の make で複数の実行バイナリをビルドしている。 Go で同じことはできないため、ビルドタグ TAP を指定したら TAP テスト用の実行バイナリをビルドするように設定する。
TAGS="HEXDUMP,TAP" makeのようにビルドすることで TAP のテスト用の実行バイナリを作成できる。実行バイナリの名称は常に test であることに注意。- テストコードの出力が若干書籍と異なる(サンプルコードでは ~ via tap0 のように出力されない)ので、その点は修正を入れた。
- Step11 から割り込み処理が必要になるので、先に実装しておく。
- intr_linux.go
- C言語の SIGRTMIN は Go からは参照できないようなので、syscall.SIGUSR1 で代用する。
- 利用できるIRQ 番号は SIGUSR1 と SIGUSR2 の2つに限定されてしまうが、今回は1つしか使用しないはずなのでこれで進める。
- IRQ 番号に対応する構造体のメンバや関数の引数はシグナルで代用する。
- シグナル受信処理を行うためにスレッドは作らず Go ルーチンを利用する。そのため、 pthread_xxx()系の関数に対応する処理はほぼ不要になる。
- Go ルーチンの起動直後の同期や終了指示にはチャネルを利用する。そのため、SIGHUP は使用しない。
- C言語の SIGRTMIN は Go からは参照できないようなので、syscall.SIGUSR1 で代用する。
- ここでランタイムを最新版の Go 1.24 にアップした。
- 全体
- テストコードからどこを参照することになるか分からないため、microps パッケージの関数や構造体メンバ等は公開(先頭大文字)に統一しておく。
- loogback.go
- このファイルだけ driver パッケージに分割する意味を感じなくなったため、microps パッケージに移す。