NE2000ドライバを書いたよ(QEMU編)
NE2000互換のNICドライバを書いてみました。
NE2000(ne2k)はNovellという会社が作った製品で、1990年代にはNE2000互換NICが主流となるほど人気だったらしいです。
手元にあるH8 3069Fという日立製作所のマイコンにこのNICが付いているので、折角だから書いてみようと思って書きました。
かなり古いNICなのであんまりドライバ実装のブログを見かけなかったので(本当に誰得だが)書いてみました。
QEMU編
まずQEMUでドライバを開発して、その後実機で動作確認します。ドライバはLinuxのカーネルモジュールとして実装します。Linuxにはnet_device_opsというNICが持つべき機能の関数ポインタをメンバに持つ構造体があり、これに関数を詰めてnet_deviceとして登録すればLinuxがバシバシその関数を叩いて通信するようになっているので、net_device_opsの特に重要そうな部分だけ実装していきます。Linuxの話も大変興味深いですが、今回はne2kのドライバ開発ということで説明は最小限にとどめます。
まずは仕様書を読んで概要を...
ne2kの仕様はとてもシンプルです。
ここではNICにおいて基本となるリングバッファの仕様、DMAについて説明し、その後パケットの送受信の流れ、レジスタなどについて説明します。
ne2kの仕様書:https://web.archive.org/web/20010612150713/http://www.national.com/ds/DP/DP8390D.pdf
リングバッファ
ne2kに限らず、基本的にNICはリングバッファというバッファを用いてCPUとデータのやり取りをします。
リングバッファとは、最初と最後がつながっているバッファのことです。
イメージとしては、バッファを要素とする配列があり、その配列の最後の次の要素が、最初の要素になる感じです。
ne2kの場合は一つのバッファが256バイトと決まっています。
NICは、パケットを受信したら、256バイトずつに分けてリングバッファに連続して書き込んでいき、最後のバッファまで書ききった場合は次からのデータは最初のバッファに書きこみます(つまり、一周したらどんどん上書きされていく)。
↓仕様書から抜粋

ne2kはNIC内部にビルトインのSRAMを持っていて、これをパケットの受信用リングバッファとして使います。
このメモリは受信用リングバッファだけでなく、送信するパケットをNICとCPUでやりとりするメモリとしても使われるため、受信用のリングバッファ領域と送信用の領域に分けて使用します。
その二つをどう分けるかというと、PSTART、PSTOPというレジスタに、それぞれリングバッファの開始ページ、終了ページを書き込むことで受信用リングバッファの領域を設定するようです。
開始アドレス、ではなく開始ページ、といっているのは、PSTARTやPSTOPに設定する値は正確にはSRAMのメモリアドレスではなく、アドレスの下位8ビットを切り捨てたバッファ単位のページアドレスだからです。バッファは256バイトずつの区切りなので、メモリアドレスの指定は下位8ビット分が不要になるのです。また、基本的にne2kのレジスタはすべて8ビット幅であるため、全体で表すことのできるメモリアドレス空間は8+8で16ビット、つまり64kBになります。
このようにNIC側に専用のバッファを用意しているのは古いNICのようで、普通のNICはメインメモリにリングバッファを構築するようです。
リモートDMA
NICのSRAMはCPUのバスにつながっていないので、NICのリングバッファにあるデータをCPUが読むために、ne2kに備わっているリモートDMA機構を使用します。これは、リングバッファの指定したアドレスの範囲を読みだしてI/Oポートを介してCPUに送ったり(リモート読み込み)、逆にCPUがI/Oに書き込んだ値をSRAMに書き込んだり(リモート書き込み)する機構です。
ドライバによるリモートDMAの操作は、レジスタを通して行います。RSAR0、RSAR1というレジスタにバッファのアドレスを書きます。アドレスの指定に二つレジスタがあるということは、PSTARTのようにページアドレスを指定するのではなく、ちゃんとバイト単位で指定できるということです。RBCR0、RBCR1というレジスタは、RSARで指定したアドレスを開始アドレスとして、何バイト読み込み/書き込みを行うかを設定するレジスタです。
RSAR、RBCRの設定が終わったら、Commandレジスタの特定のビットを立てることで、DMAにリモート読み込み、リモート書き込みのコマンドを出します。
パケット送受信
ne2kのパケット送受信はとてもシンプルです。送受信に関わるレジスタがいくつかあります。
パケット受け取り
- Current Page Register(CURR): 次に受信したパケットをNICが書き込むリングバッファのページアドレスを示す。
- Boundary Pointer Register(BNDRY): ホスト(CPU)が現時点で読み終えている場所のページアドレスを示す。
この二つのレジスタは次の値で初期化します。
CURR = PSTART + 1
BNDRY = PSTART
この状態でパケットを受信した時の流れを説明します。
- NICは、受信したパケットをCURRが指すページレジスタからリングバッファに順に書き込んでいく
- 書き込みが終了したら、CURRが指すページアドレスを受信したパケットのサイズだけ後ろにずらす
- NICが受信割込みをする
- ドライバの割り込みハンドラは、受信パケットサイズを取得(後述)し、BNDRY+1からパケットサイズ分、リモートDMAによってパケットをリングバッファから読みだす
- ドライバの割り込みハンドラは、BNDRYをCURR - 1に設定する
- ドライバの割り込みハンドラは、割り込み発生を示すビットを落として、処理を終了
これで一つのパケットを受け取れます。1.~3.まではNIC側が行い、4.~6.をドライバが行います。
まず1.では、パケットを受信したNICがリングバッファにパケットを256バイトずつ書き込んでいきます。最初のバッファの始めの4バイトは、パケットの受け取りステータス、パケットの長さ、そして次のパケットへのポインタを格納するためのフィールド用に空けておきます。
2.では、先ほど受信したパケットを書き込んだ次のバッファのページアドレスにCURRを設定することで、次回のパケットはここから書き込みが始まるようになっています。
これらの処理を終え、3.でNICが割り込みを起こし、処理がドライバの割り込みハンドラに移ります。
割り込みハンドラは、まずBNDRY + 1(すなわち以前のCURR)の最初の4バイトだけをリモートDMAで読み込み、以下の構造体としてキャストし、パケット長を得ます。
struct {
uint8_t status;
uint8_t next_page;
uint16_t length;
} __attribute__((packed)) rx_hdr最初の4バイトを除いた値が実際のパケット長になるので、その分のデータをまたリモートDMAで読み出します。
読み出しが終了し、割り込みハンドラの終盤では、BNDRYの値をCURR - 1に設定します。この時点でCURRは次に来るパケットの先頭ページアドレスを指しているので、CURR - 1はいま受け取ったパケットの丁度最後の部分を指していることになります。最後にドライバは割り込みが発生したことを示すレジスタのビットを落として、処理を終了します。
これを繰り返して、受信する度に割り込みを起こし、BNDRYがCURRをリングバッファ上で追い続ける形でパケットを受信します[^1]。
以上の手順をプログラムすると以下のようになります。割り込みハンドラのプログラムです。
irqreturn_t ne2k_receive(int irq, void *dev_id)
{
printk("hello fron handler");
struct net_device *dev = dev_id;
uint8_t isr;
ne2k_write(NE2K_COMMAND_OFFSET, 0x22); // DMAをabort, STA = 1
isr = ne2k_read(NE2K_ISR_OFFSET);
if (isr == 0) {
return IRQ_NONE;
}
if (isr & 0x1) {
uint8_t curr, bndry;
ne2k_write(NE2K_COMMAND_OFFSET, 0x62); // DMAをabort, PS0 = 1, STA = 1
curr = ne2k_read(NE2K_CURR_OFFSET);
ne2k_write(NE2K_COMMAND_OFFSET, 0x22); // DMAをabort, STA = 1
bndry = ne2k_read(NE2K_BNDRY_OFFSET);
// bndry と currが異なる場合、まだ読んでないパケットがある(TODO: リングバッファの場合を考慮)
uint8_t read_page = bndry + 1;
if (read_page >= 0x80) read_page = 0x46;
ne2k_write(NE2K_RSAR0_OFFSET, 0x0);
ne2k_write(NE2K_RSAR1_OFFSET, read_page);
ne2k_write(NE2K_RBCR0_OFFSET, 0x4); // 4バイト要求
ne2k_write(NE2K_RBCR1_OFFSET, 0x0);
ne2k_write(NE2K_COMMAND_OFFSET, 0x0A); // リモートread開始
struct {
uint8_t status;
uint8_t next_page;
uint16_t length;
} __attribute__((packed)) rx_hdr;
insb(NE2K_BASE_ADDRESS + 0x10, &rx_hdr, 4);
uint16_t packet_len = rx_hdr.length - 4; // ヘッダ分を除く
struct sk_buff *skb = netdev_alloc_skb(dev, packet_len);
if (skb) {
ne2k_write(NE2K_RSAR0_OFFSET, 0x04);
ne2k_write(NE2K_RSAR1_OFFSET, read_page);
ne2k_write(NE2K_RBCR0_OFFSET, packet_len & 0xff);
ne2k_write(NE2K_RBCR1_OFFSET, packet_len >> 8);
ne2k_write(NE2K_COMMAND_OFFSET, 0x0A); // remote read
insb(NE2K_BASE_ADDRESS + 0x10, skb_put(skb, packet_len), packet_len);
skb->protocol = eth_type_trans(skb, dev);
netif_rx(skb);
for (int i = 0; i < packet_len; i++) {
printk("packet(%d): %02x\n", i, skb->data[i]);
}
} else {
dev->stats.rx_dropped++;
}
uint8_t next_bndry = rx_hdr.next_page - 1;
if (next_bndry < 0x46) next_bndry = 0x7F;
ne2k_write(NE2K_BNDRY_OFFSET, next_bndry);
}
ne2k_write(NE2K_ISR_OFFSET, isr);
return IRQ_HANDLED;
}
リモートDMAのsend packet コマンド
今回は、リングバッファからI/Oポートを通してCPUからデータを読めるようにするリモート読み込みという機能でパケットを読み込んでいます。実はリモートDMAにはリモート読み込み、リモート書き込み以外にも、Send Packet Commandという、受信したパケットを読み込んでI/Oポートに書き込むという機能があり、DMAが自動でパケットサイズを設定してI/Oポートにバーストで書き込んでくれます。今回割り込みハンドラ側で行ったBNDRYの更新もしてくれます。リモート読み込みのパケット受信に特化したバージョン的なものです。 じゃあこれ使えばいいじゃん! しかしこの機能は最初のパケット部分ではないヘッダ部分も含めて送ってきたり、NE2000互換NICではこの機能がちゃんと実装されていなかったりということであんまり信用されていないらしく使いませんでした。こういう仕様書外のコンテキスト、やめてね。パケット送信
パケット送信は以下の流れで行います。
- ドライバは、DMAのリモート書き込みを用いて、I/Oポートを介してパケットをNICのバッファに転送する
- TPSRというレジスタにNICのバッファの先頭ページアドレスを設定
- TBCR0、TBCR1に送信するパケットサイズを設定
- CommandレジスタのTXPビットをセットし、送信処理を明示的に初期化、その後NICが2.及び3.で設定したレジスタの値をもとにパケットを送信
パケット受信時はリモート読み込みという機能を使ってリングバッファからドライバ側にパケットを渡しましたが、今回は逆のリモート書き込みを行います。2.と3.では、書き込まれたパケットのページアドレスとサイズを指定します。その後、ドライバがCommandレジスタのTXPビットを立てることで、NICはパケットの先頭にイーサネットフレームに必要なプリアンブルという8オクテットのデータを付けるなどをして、データを流します。
イーサネットパケットは、データの衝突を確実に検出するため、全体のサイズが最低でも64バイトなければならないという決まりがあり、データ部分だけでいうと最低46バイト分データがなければなりません。この設定を行うのはドライバ側と明記されているので、ドライバはパケットをDMAでNICに転送する前に46バイト未満のデータの場合はパディングを加える処理を行わなければなりません。とかいいながら今回はこの処理を割愛しているのですが...
送信処理のプログラムは以下の通り。
int ne2k_transmit(uint8_t *buf, int size)
{
if (buf == NULL || size == 0) {
return 1;
}
if (size < 46) {
// TODO: When transmitting data smaller than 46 bytes, the packet must be padded to a
// minimum size of 64 bytes. The programmer is responsible
// for adding and stripping pad bytes
}
uint8_t tstart = 0x40;
ne2k_write(NE2K_COMMAND_OFFSET, 0x22);
// remote write
ne2k_write(NE2K_RSAR0_OFFSET, 0x0);
ne2k_write(NE2K_RSAR1_OFFSET, tstart);
ne2k_write(NE2K_RBCR0_OFFSET, size & 0xff);
ne2k_write(NE2K_RBCR1_OFFSET, size >> 8);
ne2k_write(NE2K_COMMAND_OFFSET, 0x12); // remote write
outsb(NE2K_BASE_ADDRESS + 0x10, buf, size);
// パケット送信
ne2k_write(NE2K_TPSR_OFFSET, tstart);
ne2k_write(NE2K_TBCR0_OFFSET, size & 0xff);
ne2k_write(NE2K_TBCR1_OFFSET, size >> 8);
ne2k_write(NE2K_COMMAND_OFFSET, 0x26); // 送信処理の初期化
return 0;
}net_deviceとして登録
LinuxにNICとして認識してもらうには結構手順が要ります。どっちかというとne2kのドライバ開発よりこっちの方が苦戦しました。
net_deviceとして登録するためには、以下の作業が必要です
- デバイス固有のプライベート構造体の定義
- デバイスの割り当てと初期化
- alloc_netdev
- eth_hw_addr_set(MACアドレスの設定)
- register_netdev(net_device登録)
- 操作関数の実装
- 解放処理
最初に言った通り詳しくは説明しません。最終的なコードは以下のようになりました
#include <linux/netdevice.h>
#include <linux/etherdevice.h>
#include "ne2k.h"
struct ne2k_priv {
struct net_device *dev;
unsigned long io_base;
int irq;
spinlock_t lock;
uint8_t mac[6];
uint8_t next_packet;
};
static int ne2k_netdev_open(struct net_device *dev)
{
int ret;
struct ne2k_priv *priv = netdev_priv(dev);
// NICの初期化
ne2k_init();
// パケット受け取りハンドラの登録
ret = request_irq(priv->irq, ne2k_receive, IRQF_SHARED, dev->name, dev);
if (ret) {
pr_err("%s: failed to register interrupt handler IRQ %d\n", dev->name, priv->irq);
return ret;
}
netif_start_queue(dev);
return 0;
}
static int ne2k_netdev_stop(struct net_device *dev)
{
struct ne2k_priv *priv = netdev_priv(dev);
netif_stop_queue(dev);
free_irq(priv->irq, dev);
return 0;
}
static netdev_tx_t ne2k_netdev_start_xmit(struct sk_buff *skb, struct net_device *dev)
{
struct sk_buff *buf = skb;
struct ne2k_priv *priv = netdev_priv(dev);
unsigned long flags;
spin_lock_irqsave(&priv->lock, flags);
while (buf) {
int len = skb_headlen(buf);
if (len > 0 && buf->data) {
ne2k_transmit((uint8_t *)buf->data, len);
}
buf = skb_shinfo(buf)->frag_list;
}
spin_unlock_irqrestore(&priv->lock, flags);
return NETDEV_TX_OK;
}
static struct net_device_stats *ne2k_netdev_stats(struct net_device *dev)
{
return &dev->stats;
}
struct net_device_ops ne2k_netdev_ops = {
.ndo_open = ne2k_netdev_open,
.ndo_stop = ne2k_netdev_stop,
.ndo_start_xmit = ne2k_netdev_start_xmit,
.ndo_get_stats = ne2k_netdev_stats,
};
static void ne2k_setup(struct net_device *dev)
{
ether_setup(dev);
dev->netdev_ops = &ne2k_netdev_ops;
dev->flags |= IFF_BROADCAST | IFF_MULTICAST;
}
struct net_device *my_netdev;
static int __init ne2k_module_init(void)
{
struct ne2k_priv *priv;
uint8_t mac_address[6] = {0};
int ret;
my_netdev = alloc_netdev(sizeof(struct ne2k_priv), "ne%d", NET_NAME_UNKNOWN, ne2k_setup);
if (!my_netdev) {
printk("ne2k_mod: alloc_netdev failed\n");
return -ENOMEM;
}
priv = netdev_priv(my_netdev);
priv->dev = my_netdev;
priv->io_base = 0x300;
priv->irq = 9;
spin_lock_init(&priv->lock);
ne2k_get_mac_address(&mac_address);
memcpy(priv->mac, mac_address, 6);
ne2k_set_par(mac_address);
eth_hw_addr_set(my_netdev, mac_address);
ret = register_netdev(my_netdev);
if (ret) {
pr_err("ne2k_mod: register_netdev failed with %d\n", ret);
free_netdev(my_netdev);
return ret;
}
pr_info("Registered %s successfully\n", my_netdev->name);
return 0;
}
static void __exit ne2k_exit_module(void) {
if (my_netdev) {
unregister_netdev(my_netdev); // 登録抹消(ip link から消える)
free_netdev(my_netdev); // メモリの完全解放
}
pr_info("ne2k_mod: Unregistered device\n");
}
module_init(ne2k_module_init);
module_exit(ne2k_exit_module);
MODULE_DESCRIPTION("NE2000");
MODULE_LICENSE("GPL2");テスト
送受信のテストとして、pingを打ってみました。
$ ping google.com
PING google.com (142.251.24.101) 56(84) bytes of data.
64 bytes from 142.251.24.101: icmp_seq=1 ttl=255 time=5506 ms
64 bytes from 142.251.24.101: icmp_seq=2 ttl=255 time=8999 ms
64 bytes from 142.251.24.101: icmp_seq=3 ttl=255 time=8487 ms
64 bytes from 142.251.24.101: icmp_seq=4 ttl=255 time=12317 ms
64 bytes from 142.251.24.101: icmp_seq=5 ttl=255 time=11689 ms
64 bytes from 142.251.24.101: icmp_seq=6 ttl=255 time=11665 ms
64 bytes from 142.251.24.101: icmp_seq=7 ttl=255 time=9996 ms
^C
--- google.com ping statistics ---
10 packets transmitted, 7 received, 30% packet loss, time 25571ms
rtt min/avg/max/mdev = 5505.745/9808.476/12317.172/2211.281 ms, pipe 6なんか9秒くらいかかってますがなんでなんでしょうね。NAPIじゃないからかな?
パケットロスした時とか、衝突した時はNICがよしなにやってくれそうですが、そこら辺の処理をドライバ側でなんにもしてないのが原因の一つかもしれないですね。
この辺の細かい話は実機で動かすとなったら詰めないといけないポイントです。
感想
書く前はNICのドライバってかなり大変そうだな~と思っていました。実際リングバッファやDMAの話を理解するのには苦労しましたが、Geminiに聞きながら少しづつ理解することができました。
イーサネットパケットのプリアンブルの話とか、パケット送信と送信の間のフレーム間ギャップの話とか、マスタリングTCP/IPでしか習ったことなかったことが実際に出てきて面白かったです。
Linuxのnet_deviceの部分はかなりGeminiに書いてもらってから読みましたが、すぐ忘れそう。
いつか実機への移植もやります。
参考(net_device関連):
https://lv4.hateblo.jp/entry/linux_network_driver
https://yama-vanvan.hatenablog.com/entry/2023/05/06/151151
[^1]: 毎回割り込みを発生させると遅いので、LinuxではNew APIというものが用意されていて、少ない割り込みの回数で受信を行えるようにしています。