デバイススイッチの実装

第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がお勧め。デバイスドライバを書く人向けの文章なので、上記の実装がどうなっているのかよくわかる。

*1:その後、登場したネットワークデバイスは、cdevでもbdevでもない第3のデバイスで、/devには対応付けられず、rawソケットインタフェースを使って操作される。

*2:ioctl(2)が導入されたのはV7からで、stty(2)とgtty(2)は内部的にioctlを呼び出すようになった。