かーねるさんとか

発言は個人の見解であり、所属する組織の公式見解ではありません。

netmap でわかる Linux カーネルハック入門

以前のエントリー*1で、netmap API を使ったアプリケーションを作成する方法やデータ構造についてとりあげました。今回は少しレイヤーを下げて、カーネルのどのような機能を使って、netmap API が作られているのかについてまとめました。

Linux カーネルハックを始めてみたいけれど、何から手をつければよいかわからないという方にとって、netmap で使われているカーネルハックの方法について知ることは、とても良い導入の一つだと思います。

Linux カーネルハック

netmap は、キャラクタデバイスカーネルモジュールとして実装されています。今回はキャラクタデバイスカーネルモジュールで何ができるのか、ということと、netmap がそれらをどのように使っているかについて説明します。

カーネルハックで、Linux カーネルに新しい機能を追加する場合に、カーネルソースコードを直接変更することもできますが、カーネルのコードを直接変更した場合は、カーネル自体をコンパイルした後、コンピューターの再起動をしてカーネルをロードし直す必要があります。特にカーネルコンパイルは、ソースコードの量がとても多いので、ラップトップ等のパソコンで行うと30分から1時間前後の時間がかかってしまいます。

一方、カーネルモジュールであれば、ロードのためにコンピューターの再起動が不要で、コンパイルの時間も短いため、比較的手軽に開発が行えます。

netmap を実現するのに必要な実装

まず、netmap の完成形の状態を確認して、netmap の機能を実現するために、どのような実装が必要であるかを見ていきます。

以前のエントリーで、netmap のアプリケーションの使い方*2や、アプリケーション側から見えるデータ構造*3についてまとめましたので、そちらも参考にしてください。

以下に、netmap がパケットを転送する際の処理の流れを示します。

https://raw.githubusercontent.com/yasukata/asset/master/img/netmap_structure_201711/netmap_structure.png

最初の構成として、カーネル空間に用意されたパケットバッファが、アプリケーションのメモリ空間にマップされ、共有メモリが作成されています。まず、パケットバッファを用意するために、①あるアプリケーションが、カーネルにメモリ確保をリクエストできる機能が必要です。また、②そのメモリをカーネルとアプリケーションの間での共有メモリとして利用できる機能が実装されている必要があることがわかります。

アプリケーションがパケットを送信するには、共有メモリ上のパケットバッファにデータを直接書き込んだ後(図中では青い矢印)、アプリケーションは、データ送信のリクエストをカーネルに送ります(赤い矢印)。③この段階で必要になるのが、アプリケーションから、カーネルへデータ送信リクエストを行う手段です。

netmap カーネルモジュールは、アプリケーションからリクエストを受け取った後、NIC のドライバを通じて、データの転送を行います(緑色の矢印)。

ここまで見てきたことから、この一連の流れを実現するためには、少なくとも、アプリケーションとカーネルの連携手段として、以下の3つの機能が必要であることがわかります。

  1. カーネル内部でのメモリ確保をリクエストする手段
  2. アプリケーションとカーネル間の共有メモリの作成
  3. データ送信リクエストの手段

キャラクタデバイスカーネルモジュールは、上記の3つを実装できる機能を備えています。

1つ目と、3番目、カーネル空間でのメモリ確保のリクエスト、データ送信のリクエストの2つについては、キャラクタデバイスから取得されたファイルデスクリプタに対して ioctl( ) システムコールを発行することで行います。2つ目、共有メモリの作成については、キャラクタデバイスmmap( ) システムコールの組み合わせで実現できます。

キャラクタデバイスのサンプルプログラム

実際にプログラムを見ていただくのが早いと思ったので、GitHub にサンプルコード*4を用意しました。

サンプルプログラムは以下のようにしてコンパイルしてください。

$ git clone https://github.com/yasukata/kernel_module_cdev_template.git
$ cd kernel_module_cdev_template
$ make

kmod.ko という名前のファイルができていれば成功です。これがカーネルモジュールのファイルです。このカーネルモジュールを以下のコマンドでカーネルにインストールしてみてください。

$ insmod kmod.ko

これで、このカーネルモジュールに実装された機能がカーネルに追加されました。

カーネルの機能を確認するには、アプリケーションから、その機能にアクセスして試して見る必要があります。app という名前のディレクトリ以下に、動作を確認するためのアプリケーションが入っています。以下のようにしてビルドしてみてください。

$ cd app
$ make

kmod-test という名前のアプリケーションのバイナリができていれば成功です。

それでは、アプリケーションを実行してみましょう。

$ ./kmod-test
Start poll, wait 2 sec
poll done

上のような出力がされましたでしょうか。

次に、以下のようなコマンドを試してみてください。dmesg コマンドでは、カーネル側から出力されたメッセージを確認することができます。

$ dmesg | tail -n 20
...
[  875.668875] open() allocated private data
[  875.668881] page 0 at ffff961033aab000
[  875.668883] page 1 at ffff96102ab95000
[  875.668884] page 2 at ffff96102c40d000
[  875.668885] 3 pages are allocated
[  875.668891] page fault offset 8192, page 2
[  875.668892] mmap is done
[  875.668895] off 9000, len 12
[  875.668895] Hello world!
[  877.672468] release() released private data

上のような出力になりましたでしょうか。

サンプルプログラムの説明

先に、サンプルプログラムの動かし方について説明しました。次に、実際にサンプルプログラムが何をしているのかについて、netmap と照らし合わせて説明します。

netmap を使ったアプリケーションを書く場合には、以下の4つのポイントでアプリケーションとカーネルのコミュニケーションが行われます。

  1. キャラクタデバイス /dev/netmap に対して open( ) システムコールを発行し、ファイルデスクリプタを取得する。
  2. 取得したファイルデスクリプタに、ioctl( ) システムコールを発行し、ネットワークインターフェース登録のリクエストを送る。このリクエストに対して、カーネルはパケットバッファ用のメモリを確保します。
  3. 取得したファイルデスクリプタに、mmap( ) システムコールを発行し、アプリケーション空間に、カーネル空間との共有メモリを作成する。
  4. 取得したファイルデスクリプタに、ioctl( ) システムコールを実行し、送受信のリクエストをカーネルへ送る。

まず、最初にカーネルモジュールを insmod コマンドを使ってカーネルにインストールしましたが、そのときに /dev/kmod というキャラクタデバイスの特殊ファイルが作られます。これは、ステップ1で open( ) システムコールを発行する /dev/netmap と対応するものです。

カーネルモジュールの実装では、以下のように、miscdevice 構造体を宣言して、misc_register( ) 関数を実行すると、/dev/*** というキャラクタデバイスを作成できます。miscdevice 構造体と、misc_register( ) 関数は、Linux カーネル自体が用意、実装している関数です。カーネルハックを行っていく場合、多くの部分は、Linux カーネルに実装されている関数を使って機能を追加していきます。

サンプルプログラムでは、miscdevice の登録完了後に、dmesg に "Linux character device driver is loaded" というメッセージを出力するようにしてあります。

struct miscdevice kmod_cdevsw = {
	MISC_DYNAMIC_MINOR,
	"kmod",
	&kmod_fops,
};
...
	misc_register(&kmod_cdevsw);

アプリケーションは、この特殊ファイル /dev/kmod を使って、カーネルの機能へアクセスしていきます。

open システムコールに対応する処理

上記の miscdevice 構造体は、サンプルプログラムの実装では、下記の kmod_fops という名前の file_operations 構造体へのポインタを保持しています。ここには、/dev/kmod に対して、open( ) システムコールが呼ばれた場合の処理 ( kmod_open ) と、open( ) から得られたファイルデスクリプタに対して、 mmap( ), ioctl( ), poll( ), close( ) システムコールのが、それぞれ呼び出された場合の処理が登録されています。

static struct file_operations kmod_fops = {
	.owner = THIS_MODULE,
	.open = kmod_open,
	.mmap = kmod_mmap,
	.unlocked_ioctl = kmod_ioctl,
	.poll = kmod_poll,
	.release = kmod_release,
};

アプリケーション側 ( kmod-test.c ) に、以下のような箇所があります。/dev/kmod に対して、open( ) システムコールを実行しているところです。

	fd = open("/dev/kmod", O_RDWR);

この open( ) システムコールは、カーネルモジュールのコード ( kmod.c ) 内の、以下の関数の実装へたどり着きます。これは、kmod_open( ) が kmod_fops ( file_operations 構造体 ) の中で、open が呼ばれた時に対応する処理として登録されたためです。

サンプルプログラムでは、kmod_open( ) が実行されるときに、"open() allocated private data" というメッセージが dmesg に出力されるようにしています。

static int kmod_open(struct inode *inode, struct file *filp)
{
	...
	printk(KERN_INFO "open() allocated private data\n");
	...
}

これまでの説明で、netmap で /dev/netmap の特殊ファイルに open( ) が呼ばれた際にカーネルモジュールのどの処理へ入っていくのかのイメージをつかんでいただけたらと思います。

ioctl システムコールに対応する処理

次の処理として、ioctl( ) システムコールで、カーネル内部にアプリケーションのためのメモリ確保を行うリクエストを送ります。netmap では、この機能を使って、カーネル空間にパケットバッファ用のメモリを確保します。

サンプルプログラムでは、kmod_user.h というファイルの中にアプリケーションとカーネルで共有される構造体と、命令の種類が宣言されています。今回は、IOCREGMEM という名前をつけた命令に対して、カーネルがメモリを確保するように実装します。

IOREGMEM を引数にした場合に、shared_struct 構造体のメンバ変数の len に、カーネル内部に確保するメモリの大きさを指定します。

アプリケーションでは、以下のような実装になります。

#define IOCREGMEM _IO('i', 1)
#define IOCPRINTK _IO('i', 2)

struct shared_struct {
	unsigned long len;
	unsigned long off;
};
	struct shared_struct s;
	...
	s.len = 10000;
	if (ioctl(fd, IOCREGMEM, &s) != 0) {

次に、カーネルモジュールの実装で、IOCREGMEM を引数に ioctl( ) が呼ばれた場合に対応する処理を見ていきます。

/dev/kmod から取得されたファイルデスクリプタに対して ioctl( ) を実行すると、kmod_fops ( file_operations 構造体 ) に登録してある通り、kmod_ioctl( ) 関数が実行されます。第二引数にコマンドが渡され、ここにアプリケーションが指定した、IOCREGMEM の値が入ってきます。

この関数内で、コマンドを switch 文に渡して、それぞれの場合に必要な実装をしていきます。

この処理が完了すると、メモリ確保が完了し、"pages are allocated" というメッセージが dmesg に出力されます。

static long kmod_ioctl(struct file *filp, unsigned int cmd, unsigned long data)
{
	...
	switch (cmd) {
	case IOCREGMEM:
		{
			...
			/* メモリ確保処理 */
			printk(KERN_INFO "%u pages are allocated\n", num_pages);
		}
		break;
	...
}
mmap システムコールに対応する処理

次に、共有メモリの作り方について見ていきます。netmap では、先ほどの ioctl( ) によって確保したパケットバッファ用のメモリをアプリケーションにマップする用途で利用します。

サンプルプログラム ( kmod-test.c ) の中に以下のような箇所を見つけてください。引数の fd は、/dev/kmod への open( ) システムコールの戻り値であることに気をつけてください。引数に 10000 を指定することで、10000 バイト分の共有メモリを作成することをカーネルにリクエストします。

	mem = mmap(0, 10000, PROT_WRITE | PROT_READ, MAP_SHARED, fd, 0);

mmap( ) システムコールが呼ばれると、カーネルモジュール内の以下の関数 kmod_mmap( ) へたどり着きます。これも、kmod_mmap( ) が kmod_fops ( file_operations 構造体 ) の中で、mmap が呼ばれた時に対応する処理として登録されているためです。

static struct vm_operations_struct kmod_mmap_ops = {
	.fault = kmod_mem_fault,
};

static int kmod_mmap(struct file *filp, struct vm_area_struct *vma)
{
	...
	vma->vm_ops = &kmod_mmap_ops;
	...
}

kmod_mmap( ) の実装で重要なのが、vma->vm_ops = &kmod_mmap_ops の部分です。kmod_mmap_ops 変数は vm_operations_struct 構造体で、ページフォルト時に呼び出される関数が登録できます。上記の実装では、kmod_mem_fault( ) 関数が呼び出されることになります。

アプリケーションとの共有メモリを実装する方法は複数ありますが、netmap では、以下のようにページフォルトごとに、アプリケーションのメモリ空間にページをマップしていきます。

static int kmod_mem_fault(struct vm_area_struct *vma, struct vm_fault *vmf)
{
	...
	pa = virt_to_phys(priv->page_ptr[off / PAGE_SIZE]);
	...
	pfn = pa >> PAGE_SHIFT;
	...
	page = pfn_to_page(pfn);
	...
	vmf->page = page; // ここにフォルトが発生したメモリ領域にマップするページのアドレスを代入します。

	printk(KERN_INFO "mmap is done\n");

	return 0;
}

ioctl を使った複数の異なるカーネル機能の使い方

ioctl( ) では、引数に指定する命令によって、アプリケーションから複数の異なる機能へアクセスする手段として利用できます。netmap では、この機能を使って、ioctl( ) をインターフェースの登録とメモリ確保だけでなく、データ転送と受信のリクエストをカーネルへ送る用途でも利用します。

先ほどは、IOCREGMEM と名前をつけた命令に対して、カーネル空間にメモリを確保するように実装しました。今度は、共有メモリの機能を確認するために、共有メモリ上にアプリケーション側が用意した文字列をカーネル側で読み取る命令を IOCPRINTK という名前をつけた値を引数にして ioctl( ) の呼び出すことで実行できるようにしてみます。

サンプルプログラムのアプリケーションは以下のようにして、システムコールを呼び出します。これは、共有メモリの先頭から、9000 バイト進んだところに、"Hello world!" と書き込んでいます。

	snprintf(&mem[9000], 1000, "Hello world!");
	memset(&s, 0, sizeof(struct shared_struct));
	s.len = strlen("Hello world!");
	s.off = 9000;
	ioctl(fd, IOCPRINTK, &s);

カーネル側の処理は以下のようになります。switch 文で、IOCPRINTK に対応する処理として実装していきます。この中で、共有メモリの先頭に 9000 バイトのオフセットを追加したアドレスを取得する処理を実装し、実際に printk( ) 関数で dmesg に出力してみます。

static long kmod_ioctl(struct file *filp, unsigned int cmd, unsigned long data)
{
	...
	switch (cmd) {
	case IOCREGMEM:
		...
		break;
	case IOCPRINTK:
		{
			...
			/* 共有メモリ先頭から 9000 バイトのオフセットを計算して、buf に代入する処理 */
			printk(KERN_INFO "%s\n", buf);
		}
		break;

アプリケーションを実行後、dmesg を確認すると、以下のように、カーネル側から、共有メモリ上にアプリケーションが書き込んだ "Hello world!" という文字列を読み取ることができていることがわかります。

[  875.668895] off 9000, len 12
[  875.668895] Hello world!

poll システムコールで待機処理を実装する方法

最後に、netmap は受信処理を poll( ) システムコールで待機できるようにしています。select, poll, epoll, kqueue 等のプロッキングによる待機を行わない場合、アプリケーションで busy ループを作ることになり、CPU リソースを大量に消費してしまいます。

アプリケーション側の実装は以下のようになります。以下のようにすると、poll( ) システムコールは、カーネル空間で2秒間待機したのちに、アプリケーションに処理を戻します。

	pfd.fd = fd;
	pfd.events = POLLIN;

	printf("Start poll, wait 2 sec\n");
	poll(&pfd, 1, 2000); // 2000 => 2秒間待機
	printf("poll done\n");

これに対して、カーネル側では、 kmod_fops ( file_operations 構造体 ) で poll( ) が呼ばれた時に対応する処理として登録された kmod_poll( ) 関数が呼び出されます。この関数に引数として与えられる、file 構造体と、poll_table_struct 構造体に加え、以下のように init_waitqueue_head( ) 関数で初期化された、wait_queue_head_t オブジェクトを引数にして、poll_wait( ) 関数を呼び出すことで待機の機能を利用できます。

poll に対応する関数は戻り値が重要で、0 を返すと、対応するアプリケーションプロセスが実行すべき処理がないと判断して、待機の処理へ移行します。他には、POLLIN, POLLOUT, POLLERR を戻り値として設定が可能であり、POLLIN では読み取り可能データがあることをアプリケーションプロセスに伝えるために利用されます。これら、POLLIN, POLLOUT, POLLERR を戻り値として指定した場合には、待機処理へ移行せず、poll( ) システムコールはアプリケーションプロセスへ、すぐに処理を移行します。

	wait_queue_head_t wq;
	...
	init_waitqueue_head(&priv->wq);
static u_int kmod_poll(struct file * filp, struct poll_table_struct *pwait)
{
	struct kmod_priv *priv = filp->private_data;
	poll_wait(filp, &priv->wq, pwait);
	return 0;
}

netmap では、この poll に対応する関数を netmap_poll( ) という名前で実装しており、受信データがなければ、0 をリターンして待機処理へ移行し、その後、待機中に NIC にパケットが到着した場合、NIC から受け取るハードウェア割り込み割り込みを起点として、再度 netmap_poll( ) 関数を実行し、POLLIN を返して、処理をアプリケーションプロセスへ、パケット受信からすぐに移行できるように実装されています。