Unix

xv6OSを真面目に読みこんでカーネルを完全に理解する -画面描画 編-

はじめてのOSコードリーディング ~UNIX V6で学ぶカーネルのしくみにインスパイアされてxv6 OSを読んでます。

UNIX V6自体はx86CPUでは動作しないため、基本的には、UNIXv6をX86アーキテクチャで動くようにしたxv6 OSのリポジトリをForkしたkash1064/xv6-public: xv6 OSのソースコードを読んでいくことにしました。

前回main関数で実行されるpicinit関数とioapicinit関数の動きを確認しました。

xv6OSを真面目に読みこんでカーネルを完全に理解する -IOAPIC 編-はじめてのOSコードリーディング ~UNIX V6で学ぶカーネルのしくみにインスパイアされてxv6 OSを読んでます。 UNIX V6...

今回はconsoleinit関数の挙動を追っていきます。

もくじ

consoleinit関数

consoleinit関数はconsole.cで以下のように定義されています。

void consoleinit(void)
{
  initlock(&cons.lock, "console");

  devsw[CONSOLE].write = consolewrite;
  devsw[CONSOLE].read = consoleread;
  cons.locking = 1;

  ioapicenable(IRQ_KBD, 0);
}

まず1行目のinitlock(&cons.lock, "console");ですが、これはメモリ割り当て・排他制御 編で確認したメモリロックのためにspinlock構造体を初期化する関数でした。

今回使用している&cons.lockは、以下のように定義されています。

static struct {
  struct spinlock lock;
  int locking;
} cons;

なお、consoleinit関数の中ではメモリロックは行われません。

consoleread関数やconsolewrite関数などが実行される際に、consが使われてメモリロックが行われます。

続いて、以下の行を見ていきます。

devsw[CONSOLE].write = consolewrite;
devsw[CONSOLE].read = consoleread;

ここで参照しているdevswdevsw構造体の配列であり、この配列はfile.cで定義されています。

struct devsw devsw[NDEV];

また、devsw構造体の定義はfile.hで行われています。

// table mapping major device number to
// device functions
struct devsw {
  int (*read)(struct inode*, char*, int);
  int (*write)(struct inode*, char*, int);
};

extern struct devsw devsw[];
#define CONSOLE 1

ちなみにNDEVparam.hで10と定義されていることがわかります。

#define NDEV         10  // maximum major device number

devsw構造体について

ここで詳しく知りたいのは、devsw構造体が何者かという点です。

UNIXのマニュアルなどを見ると、devsw構造体はデバイスドライバがcharacter device interfacesを持つ場合に使用されるもののように見えます。

参考:devsw(9) – NetBSD Manual Pages

また、以下のページのように、「システムが一文字ずつデータを転送する機器に対応」した入出力インターフェースがcharacter device interfacesに該当すると考えられます。

参考:デバイスファイル – Wikipedia

ここでdevsw構造体の定義を見ると、readwriteの2種類の関数ポインタが設定されています。

ここには、consoleinit関数のように任意の関数割り当てが行われます。

引数にはいずれもinode構造体が含まれます。

inode構造体は、devsw構造体と同様にfile.hで定義されています。

// in-memory copy of an inode
struct inode {
  uint dev;           // Device number
  uint inum;          // Inode number
  int ref;            // Reference count
  struct sleeplock lock; // protects everything below here
  int valid;          // inode has been read from disk?

  short type;         // copy of disk inode
  short major;
  short minor;
  short nlink;
  uint size;
  uint addrs[NDIRECT+1];
};

inodeとは

そもそもinodeとは何かについて触れておきます。

inodeとはざっくり言うとファイル、ディレクトリなどのファイルシステムのオブジェクトに関する情報が格納されている構造体です。

inodeの持つ情報としては以下のようなものが挙げられます。

  • ファイルサイズ(バイト数)
  • ファイルを格納しているデバイスのデバイスID
  • ファイルの所有者、グループのID
  • ファイルシステム内でファイルを識別するinode番号
  • タイムスタンプ

参考:inode – Wikipedia

xv6OSのinode構造体を見ても上記に近い情報が格納されています。

inodeは、システム内に一意のIDで管理されます。(xv6OSでは恐らくinumが該当する)

割り当て可能なinode番号には通常上限があり、もしinode番号が枯渇した場合は、ストレージデバイスのディスク容量に空きがあっても新規にファイルの作成ができなくなります。

一般的なLinuxシステムの場合は、df -iコマンドで各デバイスごとの使用可能なinodeの上限を確認できます。

$ df -i
Filesystem                         Inodes  IUsed   IFree IUse% Mounted on
udev                              1007124    449 1006675    1% /dev
tmpfs                             1019154    919 1018235    1% /run
/dev/mapper/ubuntu--vg-ubuntu--lv 1310720 380878  929842   30% /
tmpfs                             1019154      1 1019153    1% /dev/shm
tmpfs                             1019154      5 1019149    1% /run/lock
tmpfs                             1019154     18 1019136    1% /sys/fs/cgroup
/dev/loop0                             29     29       0  100% /snap/bare/5
/dev/loop2                          10847  10847       0  100% /snap/core18/2284
/dev/loop1                          10836  10836       0  100% /snap/core18/2253
/dev/loop3                          11776  11776       0  100% /snap/core20/1270
/dev/loop5                          18500  18500       0  100% /snap/gnome-3-34-1804/72
/dev/loop4                          11777  11777       0  100% /snap/core20/1328
/dev/loop6                          18500  18500       0  100% /snap/gnome-3-34-1804/77
/dev/loop7                          65095  65095       0  100% /snap/gtk-common-themes/1519
/dev/loop8                            796    796       0  100% /snap/lxd/21835
/dev/loop9                          64986  64986       0  100% /snap/gtk-common-themes/1515
/dev/loop10                           796    796       0  100% /snap/lxd/21545
/dev/loop11                           479    479       0  100% /snap/snapd/14295
/dev/loop12                           482    482       0  100% /snap/snapd/14549
/dev/sda2                           65536    320   65216    1% /boot
tmpfs                             1019154     45 1019109    1% /run/user/121
tmpfs                             1019154     83 1019071    1% /run/user/1000

参考:iノード(inode)とは

consolewrite関数を読む

この時点ではまだinodeを使ってファイルを作成することはないので、ひとまずxv6OSのコードに戻ります。

devsw[CONSOLE].write = consolewrite;
devsw[CONSOLE].read = consoleread;

devsw配列のCONSOLE = 1要素のwritereadには、それぞれconsole.cで定義された関数が割り当てされます。

まずはconsolewrite関数を読んでみます。

int consolewrite(struct inode *ip, char *buf, int n)
{
  int i;

  iunlock(ip);
  acquire(&cons.lock);
  for(i = 0; i < n; i++) consputc(buf[i] & 0xff);
  release(&cons.lock);
  ilock(ip);

  return n;
}

consolewrite関数はターゲットとなるinode構造体変数のポインタとconsputc関数に引き渡す文字およびその長さが引数として与えられます。

まずiunlock関数ですが、これはfs.cで定義されています。

// Unlock the given inode.
void iunlock(struct inode *ip)
{
  if(ip == 0 || !holdingsleep(&ip->lock) || ip->ref < 1) panic("iunlock");
  releasesleep(&ip->lock);
}

この関数についてはファイルシステムを扱う際に詳しく見ていきますが、受け渡しされたinodeの持つ‘sleeplock‘構造体を操作してロックを解放しています。

続いてはacquireでロックを取得した後、consputc関数に受け渡しされた文字列を一文字ずつ流し込んでいます。

この時、与えられた文字列は0xFFとのANDになるので、印字可能な状態が担保されます。

void consputc(int c)
{
  if(panicked){
    cli();
    for(;;) ;
  }

  if(c == BACKSPACE){
    uartputc('\b'); uartputc(' '); uartputc('\b');
  } else{
      uartputc(c);
  }
  cgaputc(c);
}

ここで、与えられた値を引数としてuartputc関数が呼び出されます。

uartputc関数はuart.cで定義された関数で、シリアルポート(UART)への空きこみを行います。

ここでは、COM1(I/Oポート 0x3f8)に受け取った値を書き込んでいます。

void uartputc(int c)
{
  int i;

  if(!uart) return;
  for(i = 0; i < 128 && !(inb(COM1+5) & 0x20); i++) microdelay(10);
  outb(COM1+0, c);
}

COM1+0はデータレジスタになっており、ここに値が書き込まれると送信バッファに書き込みが行われます。

その前の行のinb(COM1+5)はラインステータスレジスタの値の読み取りを行っています。

ラインステータスレジスタの6番目のbitはTHREと呼ばれるレジスタで、このbitが立っているときは送信バッファが空で、新たなデータを送信可能であることを意味します。

参考:Serial Ports – OSDev Wiki

つまり!(inb(COM1+5) & 0x20)の行は、ラインステータスレジスタのTHREをチェックして、送信バッファが使用可能でない場合はmicrodelay関数により処理を遅延させる処理を行っているわけです。

ちなみに、BACKSPACEが入力された場合の書き込みがuartputc('\b'); uartputc(' '); uartputc('\b');になっているのは、カーソルを一つ戻してスペースで上書きした上で、もう一度書き込み前の位置にカーソルを戻しているイメージみたいです。

ビデオメモリの書き込み

シリアルポートへの書き込みが完了したら、最後にcgaputc関数が呼び出されます。

cgaputc関数では、入力値をビデオメモリに書き込んで出力します。

static void cgaputc(int c)
{
  int pos;

  // Cursor position: col + 80*row.
  outb(CRTPORT, 14);
  pos = inb(CRTPORT+1) << 8;
  outb(CRTPORT, 15);
  pos |= inb(CRTPORT+1);

  if(c == '\n') pos += 80 - pos%80;
  else if(c == BACKSPACE){
    if(pos > 0) --pos;
  } else{
    crt[pos++] = (c&0xff) | 0x0700;  // black on white
  }
  if(pos < 0 || pos > 25*80) panic("pos under/overflow");

  if((pos/80) >= 24){  // Scroll up.
    memmove(crt, crt+80, sizeof(crt[0])*23*80);
    pos -= 80;
    memset(crt+pos, 0, sizeof(crt[0])*(24*80 - pos));
  }

  outb(CRTPORT, 14);
  outb(CRTPORT+1, pos>>8);
  outb(CRTPORT, 15);
  outb(CRTPORT+1, pos);
  crt[pos] = ' ' | 0x0700;
}

ここで使用している書き込み先のcrtはアドレス0xb8000の領域です。

この領域はフレームバッファと呼ばれる領域です。

//PAGEBREAK: 50
#define BACKSPACE 0x100
#define CRTPORT 0x3d4
static ushort *crt = (ushort*)P2V(0xb8000);  // CGA memory

参考:フレームバッファ(frame buffer)とは – IT用語辞典 e-Words

参考:3.-The Screen

まずCRTPORTですが、これはCRT Controllerのレジスタである0x3D4を指しています。

これは制御用のレジスタで、0x3D4に対応するデータレジスタ領域は0x3D5となります。

この2つの領域を利用してコンソール上のカーソル位置をコントロールできます。

0x3D4に14をセットすることで、16bitで表現されるカーソルの上位8bitを制御することを指定します。

そして、15をセットした場合は、カーソルの下位8bitを制御することを指定します。

つまり、以下の行では変数posに現在のカーソルの16bitを格納しているわけです。

outb(CRTPORT, 14);
pos = inb(CRTPORT+1) << 8;
outb(CRTPORT, 15);
pos |= inb(CRTPORT+1);

ここで取得したカーソルの上位bitと下位bitの関係は以下のようになります。

上位8bitがカーソルの行の位置を指し、下位8bitが何文字目かを指しています。

https://yukituna.com/wp-content/uploads/2022/02/image-37.png

以下の行は、改行文字が渡された場合の挙動です。

if(c == '\n') pos += 80 - pos%80;

カーソル位置が次の行の一番左端の位置になるようにposを加算しています。

また、BACKSPACEが与えられた場合はカーソル位置を一つ戻します。

文字入力が与えられた場合は、16bitの文字データを格納します。

else if(c == BACKSPACE){
  if(pos > 0) --pos;
} else{
  crt[pos++] = (c&0xff) | 0x0700;  // black on white
}

この文字データの上位8bitには、背景と文字の色の情報が保持されます。

また、下位8bitには表示する文字が指定されます。

https://yukituna.com/wp-content/uploads/2022/02/image-35.png

参考画像:3.-The Screen

実際にデバッガで確認してみると、この処理によってコンソールに文字が表示されていることを確認できます。

https://yukituna.com/wp-content/uploads/2022/02/image-34.png

次の処理は非常にシンプルで、最大の行数である24行をオーバーした場合に、先頭行を削除してスクロールした上で、末尾の行を空行にしています。

if((pos/80) >= 24){  // Scroll up.
  memmove(crt, crt+80, sizeof(crt[0])*23*80);
  pos -= 80;
  memset(crt+pos, 0, sizeof(crt[0])*(24*80 - pos));
}

最後の処理は、現在のカーソル位置をCRTPORTCRTPORT+1に保存しています。

outb(CRTPORT, 14);
outb(CRTPORT+1, pos>>8);
outb(CRTPORT, 15);
outb(CRTPORT+1, pos);
crt[pos] = ' ' | 0x0700;

これでconsputc関数によるコンソールへの書き込みが完了します。

consolewrite関数に戻ったらメモリロックの解除とinodeのロックを行って終了です。

release(&cons.lock);
ilock(ip);

consoleread関数を読む

次はconsoleread関数を読んでいきます。

consoleread関数は、inodeと読み取り先のポインタ、読み取るバッファサイズを引数として受け取ります。

int consoleread(struct inode *ip, char *dst, int n)
{
  uint target;
  int c;

  iunlock(ip);
  target = n;
  acquire(&cons.lock);
  while(n > 0){
    while(input.r == input.w){
      if(myproc()->killed){
        release(&cons.lock);
        ilock(ip);
        return -1;
      }
      sleep(&input.r, &cons.lock);
    }
    c = input.buf[input.r++ % INPUT_BUF];
    if(c == C('D')){  // EOF
      if(n < target){
        // Save ^D for next time, to make sure
        // caller gets a 0-byte result.
        input.r--;
      }
      break;
    }
    *dst++ = c;
    --n;
    if(c == '\n')
      break;
  }
  release(&cons.lock);
  ilock(ip);

  return target - n;
}

consolwrite関数同様メモリロックとinodeのロック解除を行った後、指定されたバッファサイズのループ内で以下の処理を行います。

c = input.buf[input.r++ % INPUT_BUF];
if(c == C('D')){  // EOF
  if(n < target){
    // Save ^D for next time, to make sure
    // caller gets a 0-byte result.
    input.r--;
  }
  break;
}

*dst++ = c;
--n;
if(c == '\n') break;

input構造体は以下の構造体です。

#define INPUT_BUF 128
struct {
  char buf[INPUT_BUF];
  uint r;  // Read index
  uint w;  // Write index
  uint e;  // Edit index
} input;

このinput構造体に入ってきた値を1文字ずつ読みだして取得しているようです。

実際にこの処理が呼び出されるのは、OSの起動が完了してからです。

具体的には、シェルに入力した文字を取得する際などに使用されます。

以下の画像は、コンソールに「l」という文字を打ち込んだ際の挙動をデバッガで確認したときのものです。

https://yukituna.com/wp-content/uploads/2022/02/image-38.png

ユーザの入力値がどのようにinput.bufに格納されるかは、実際にシェルが使えるようになってから詳しく追っていこうと思います。

最後にioapicenable(IRQ_KBD, 0);で割込みを有効化して、consoleinit関数は終了します。

まとめ

今回はコンソールの初期化を行いました。

入出力インターフェースの仕組みについて知ることができたので非常に興味深かったです。

次回はシリアルポートを初期化するuartinit関数から見ていきたいと思います。

参考書籍

COMMENT

メールアドレスが公開されることはありません。