デバイススイッチの実装
第11回Lions本勉強会にインスパイアされ、UNIX V6、V7そしてV1へさかのぼりつつ、カーネルとデバイスドライバの界面であるデバイススイッチの実装について考えてみた。
UNIXではデバイスもファイルとして見せた方が汎用的で便利ではということで、スペシャルファイルが導入された。その際、デバイスはその性格からブロックデバイス(bdev)とキャラクタデバイス(cdev)に分類された。bdevはディスクや磁気テープのように固定長(512バイトとか)のブロック単位で操作するデバイスである。通常はファイルシステムにマウントして利用されるデバイスで、性能向上のためバッファキャッシュを介してデータが読み書きされる。一方、cdevはTTYのように文字単位で操作するデバイスというのが元々の意味だが、構造を持たないもの、/dev/nullなどの擬似デバイスなどその他のデバイスが含まれるようになった*1。デバイスドライバの実装としては、cdevはbdevのようにバッファ管理など上位層と連携する必要はないので、ドライバ内で動作が完結することが多い。
実装としてcdevとbdevにわけたことは理解できるけど、ユーザに見せる必要はなかったのではないか。事実Plan 9ではその区別を取り去ってしまった。今では実質的な違いはバッファキャッシュを使うかどうかにしかない。例えば、ディスクをrawデバイスとして扱いたい場合は、/dev/r*というキャラクタデバイスにアクセスする。同じディスクがインタフェースによって別名を持っているのだ。また、V7の時代はcdevでしかioctl(2)できなかった*2。というのも、そもそもこれはstty、gttyするためのインタフェースだったからだ。なんだかデバイスの分類はUNIXの盲腸の1つという気がする。まぁ、ネットワークデバイスが入りこのモデルは完全に破綻しているわけだが。
閑話休題。UNIXカーネルは言ってみれば、ファイルシステムという皮を被ったI/O多重化装置である。そのデバイス毎に処理を多重化している部分がデバイススイッチ(bdevsw、cdevsw)であり、それはデバイスの種類(つまりメジャー番号の数)だけ保持される。ファイルシステムとデバイスドライバの界面とも言える。これらのスイッチはオブジェクト指向的に設計されており、bdevswはopen、close、strategy、cdevswはopen、close、read、write、sgtty(V7からioctlに改名)という関数ポインタを持つ。ここにはデバイスドライバ毎に対応する関数が登録される。この構造は、現在の*BSDでも拡張はされているが、基本的に踏襲されている。
4617 struct bdevsw { 4618 int (*d_open)(); 4619 int (*d_close)(); 4620 int (*d_strategy)(); 4621 int *d_tab; 4622 } bdevsw[]; 4635 struct cdevsw { 4636 int (*d_open)(); 4637 int (*d_close)(); 4638 int (*d_read)(); 4639 int (*d_write)(); 4640 int (*d_sgtty)(); 4641 } cdevsw[];
ここで視点を変えて、V6でのシステムコール呼び出しからデバイススイッチへの流れを振り返る。open(2)はnamei関数でパス名からiノード番号を探索し、オープンファイルテーブルをセットし、対応するインデックス(ファイル記述子)を返す。read(2)、write(2)では、オープンファイルテーブルからiノードを調べ、readi、writei関数を呼ぶ。readiはファイルがcdevならcdevsw.d_readを呼び、bdevならbreadやbreada経由でbdevsw.d_readを呼ぶ。これらはRK-11であればrkreadやrkstrategyという関数になる(下図参照)。
/dev/rrk0(キャラクタデバイス)をreadした場合:
sysread -> readi -> cdevsw.d_read (rkread)/dev/rk0(ブロックデバイス)をreadした場合:
sysread -> readi -> bread -> bdevsw.d_read (rkstrategy)
いつからこのような実装になったのかは想像するしかないのだが(少なくともV4、V5は同じ実装)、アセンブリ時代(UNIX V1)は実装が違っていた。V1では、なんとiノードの先頭40個がスペシャルファイル用に予約されていた。ファイルシステムとデバイスドライバの境界はあいまいで、readi関数の中にデバイスドライバのエントリ関数が直書きされていて、iノード番号で表引きしてジャンプしていた。この段階でcdevとbdevの区別もない。その後、共通の処理がくくり出されて、cdevswとbdevswにまとめられたのだろう。通常ファイルのiノード番号は41番から始まり、dskr関数で処理される。いかにもad hocな実装である。V6ではデバイスを追加するためにカーネルコンフィグは必要でも、ソースの変更は不要だ。また、余談になるが、breadやbwrite関数は通常ファイル用では使われず、スペシャルファイル用だった。
readi: clr u.nread / accumulates number of bytes transmitted tst u.count / is number of bytes to be read greater than 0 bgt 1f / yes, branch rts r0 / no, nothing to read; return to caller 1: mov r1,-(sp) / save i-number on stack cmp r1,$40. / want to read a special file (i-nodes 1,...,40 are for special files) ble 1f / yes, branch jmp dskr / no, jmp to dskr; read file with i-node number (r1) / starting at byte ((u.fofp)), read in u.count bytes 1: asl r1 / multiply inode number by 2 jmp *1f-2(r1) 1: rtty / tty; r1=2 rppt / ppt; r1=4 rmem / mem; r1=6 rrf0 / rf0 rrk0 / rk0 rtap / tap0 rtap / tap1 rtap / tap2 rtap / tap3 rtap / tap4 rtap / tap5 rtap / tap6 rtap / tap7 rcvt / tty0 rcvt / tty1 rcvt / tty2 rcvt / tty3 rcvt / tty4 rcvt / tty5 rcvt / tty6 rcvt / tty7 rcrd/ crd
simhで動かせば、確かにルートディレクトリのiノード番号が41であることが確認できる。lsの先頭カラムがiノード番号である。
# ls -la / total 8 41 sdrwr- 7 root 70 Jan 1 00:00:00 . 41 sdrwr- 7 root 70 Jan 1 00:00:00 .. 43 sdrwr- 2 root 620 Jan 1 00:00:00 bin 42 sdrwr- 2 root 250 Jan 1 00:00:00 dev 104 sdrwr- 2 root 110 Jan 1 00:00:00 etc 114 sdrwr- 2 root 60 Jan 1 00:00:00 tmp 41 sdrwr- 10 root 110 Jan 1 00:00:00 usr
念のため、/devも見ておこう。/dev/ttyのiノード番号が1になっている。以下はreadiのテーブルと一致している。
# ls -la /dev total 2 42 sdrwr- 2 root 250 Jan 1 00:00:00 . 41 sdrwr- 7 root 70 Jan 1 00:00:00 .. 22 s-rwrw 1 sys 0 Jan 1 00:00:00 lpr 3 s-rwrw 1 sys 0 Jan 1 00:00:00 mem 2 s-rwrw 1 sys 0 Jan 1 00:00:00 ppt 4 s-rwrw 1 sys 0 Jan 1 00:00:00 rf0 5 s-rwrw 1 sys 0 Jan 1 00:00:00 rk0 6 s-rwrw 1 sys 0 Jan 1 00:00:00 tap0 7 s-rwrw 1 sys 0 Jan 1 00:00:00 tap1 8 s-rwrw 1 sys 0 Jan 1 00:00:00 tap2 9 s-rwrw 1 sys 0 Jan 1 00:00:00 tap3 10 s-rwrw 1 sys 0 Jan 1 00:00:00 tap4 11 s-rwrw 1 sys 0 Jan 1 00:00:00 tap5 12 s-rwrw 1 sys 0 Jan 1 00:00:00 tap6 13 s-rwrw 1 sys 0 Jan 1 00:00:00 tap7 1 s-rw-w 1 root 0 Jan 1 00:00:00 tty 14 s-rw-w 1 root 0 Jan 1 00:00:00 tty0 15 s-rw-w 1 root 0 Jan 1 00:00:00 tty1 16 s-rw-w 1 root 0 Jan 1 00:00:00 tty2 17 s-rw-w 1 root 0 Jan 1 00:00:00 tty3 18 s-rw-w 1 root 0 Jan 1 00:00:00 tty4 19 s-rw-w 1 root 0 Jan 1 00:00:00 tty5 20 s-rw-w 1 root 0 Jan 1 00:00:00 tty6 21 s-rw-w 1 root 0 Jan 1 00:00:00 tty7 1 s-rw-w 1 root 0 Jan 1 00:00:00 tty8
どんなファイルでもopen-close-read-writeで操作できるというオブジェクト指向的な考えは、実装自体がそうなっている必然性はない。V1からV6、V7への流れは再実装やリファクタリングによって実装が熟れていく過程を示していると思うが、デバイススイッチ部に関しては、C言語の利用が寄与した面が大きいのではないだろうか?
(追記:2011-09-22)V6やV7あたりのデバイス処理をもっと知りたければ、dmrのThe UNIX I/O Systemがお勧め。デバイスドライバを書く人向けの文章なので、上記の実装がどうなっているのかよくわかる。