技術

[xv6 #2] Chapter 0 – Operating system interfaces – Code: Processes and memory

テキストの8〜10ページ

概要

xv6のプロセスは、命令とデータとスタックを置くためのユーザー空間のメモリ領域と、カーネルで管理されるプロセスの状態から成る。
xv6はタイムシェアリングを提供していて、それは実行を待ってるプロセス群の間で使えるCPUを自動的に切り替える。プロセスが実行されてないときはそのレジスタの内容を保存して、次にプロセスが再開されるときに読み込む。
どのプロセスも一意に識別可能な正の整数がついてて、それはプロセスIDもしくはpidと呼ばれる。

あるプロセスは、forkというシステムコールを使って新しいプロセスを生成することが出来る。
forkは新しいプロセス(子プロセスと呼ばれる)を生成し、そのプロセスは元のプロセス(親プロセス)と全く同じメモリの内容を持つ。
forkは親プロセスでも子プロセスでも返り値を返す。
親プロセスでは、子プロセスのpidを返す。
子プロセスでは、ゼロを返す。
次のプログラムの断片について考えてみよう。

int pid;
pid = fork();
if(pid > 0){
  printf("parent: child=%d\n", pid);
  pid = wait();
  printf("child %d is done\n", pid);
} else if(pid == 0){
  printf("child: exiting\n");
  exit();
} else {
  printf("fork error\n");
}

exitというシステムコールは、それを呼んだプロセスの実行を止めて、メモリや開いてるファイルなどのリソースを解放する。
waitというシステムコールは、現在のプロセスの子プロセスが終了したときにそのpidを返す。
まだ子プロセスが終了してなかったら終了するまで待つ。
上記の例はまず次のような結果を印字する。

parent: child=1234
child: exiting

親か子、どちらのprintfが先に呼ばれたかによるので順番は上記の限りではない。
で、その二つの印字のあと、子プロセスは終了し、親プロセスのwaitシステムコールが返ってくる。
その結果、親プロセスはさらに下記を印字する。

parent: child 1234 is done

親プロセスと子プロセスは内容は同じなれど違うメモリ領域、違うレジスタで実行されるので、一方の変更がもう一方に影響しないことに注意。

execシステムコールは、呼び出し元のプロセスのメモリを新しいメモリイメージ(ファイルシステムに置かれたファイルから読み込まれたもの)で置き換える。
そのファイルは、どの部分が命令で、どの部分がデータか、さらにどの命令から開始するか等を指定する、特定のフォーマットに従ってなければならない。
xv6はそのフォーマットとしてELFと呼ばれるものを使う。(その詳細についてはChapter 1で)
execが成功したら、呼び出し元のプログラムへは戻らない。
その代わり、読み込まれた命令をELFヘッダに定義されたエントリポイントから実行する。
execは二つの引数(実行可能なファイルの名前とその引数の配列)を受け取る。
例えば、以下のような感じである。

char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");

このプログラムは、自分自身を、引数 echo hello と共に実行される /bin/echo というプログラムで置き換える。
多くのプログラムは最初の引数を無視する。
習慣的に最初の引数はプログラムの名前である。

xv6のシェルは、以上のような呼び出しを使って、ユーザーの代わりにプログラムを実行する。
シェルの主な構造はシンプルである。(sh.cのmain関数参照。以下にとりあえず載せときます。)

int
main(void)
{
  static char buf[100];
  int fd;
  
  // Assumes three file descriptors open.
  while((fd = open("console", O_RDWR)) >= 0){
    if(fd >= 3){
      close(fd);
      break;
    }
  }
  
  // Read and run input commands.
  while(getcmd(buf, sizeof(buf)) >= 0){
    if(buf[0] == 'c' && buf[1] == 'd' && buf[2] == ' '){
      // Clumsy but will have to do for now.
      // Chdir has no effect on the parent if run in the child.
      buf[strlen(buf)-1] = 0;  // chop \n
      if(chdir(buf+3) < 0)
        printf(2, "cannot cd %s\n", buf+3);
      continue;
    }
    if(fork1() == 0)
      runcmd(parsecmd(buf));
    wait();
  }
  exit();
}

メインのループでgetcmdを使ってコマンドラインから入力を読み取っている。
そしてforkを呼んで、別のシェルプログラムを生成する。
子プロセスがコマンドを実行してる間、親のシェル(親プロセス)はwaitを呼んで待つ。
例えば、もしユーザがが”echo hello”と入力したら、runcmdは”echo hello”を引数として呼ばれる。
runcmdは以下のとおり。(sh.cのruncmd関数)

void
runcmd(struct cmd *cmd)
{
  int p[2];
  struct backcmd *bcmd;
  struct execcmd *ecmd;
  struct listcmd *lcmd;
  struct pipecmd *pcmd;
  struct redircmd *rcmd;

  if(cmd == 0)
    exit();
  
  switch(cmd->type){
  default:
    panic("runcmd");

  case EXEC:
    ecmd = (struct execcmd*)cmd;
    if(ecmd->argv[0] == 0)
      exit();
    exec(ecmd->argv[0], ecmd->argv);
    printf(2, "exec %s failed\n", ecmd->argv[0]);
    break;

  case REDIR:
    rcmd = (struct redircmd*)cmd;
    close(rcmd->fd);
    if(open(rcmd->file, rcmd->mode) < 0){
      printf(2, "open %s failed\n", rcmd->file);
      exit();
    }
    runcmd(rcmd->cmd);
    break;

  case LIST:
    lcmd = (struct listcmd*)cmd;
    if(fork1() == 0)
      runcmd(lcmd->left);
    wait();
    runcmd(lcmd->right);
    break;

  case PIPE:
    pcmd = (struct pipecmd*)cmd;
    if(pipe(p) < 0)
      panic("pipe");
    if(fork1() == 0){
      close(1);
      dup(p[1]);
      close(p[0]);
      close(p[1]);
      runcmd(pcmd->left);
    }
    if(fork1() == 0){
      close(0);
      dup(p[0]);
      close(p[0]);
      close(p[1]);
      runcmd(pcmd->right);
    }
    close(p[0]);
    close(p[1]);
    wait();
    wait();
    break;
    
  case BACK:
    bcmd = (struct backcmd*)cmd;
    if(fork1() == 0)
      runcmd(bcmd->cmd);
    break;
  }
  exit();
}

runcmdは実際のコマンドを実行する。
“echo hello”の場合、runcmd関数の中のcase EXEC:の中のexecが呼ばれる。
もしexecが成功したら、その子プロセスはruncmdを途中で投げ出してechoの(実行ファイルから読み込んだ)命令を実行する。
そしてどこかの段階でechoはexitを呼ぶだろう。
そうすることで親プロセスはmain関数のwaitから復帰する事が出来る。
forkとexecがなぜ一度の呼び出しで行えないか不思議に思うかもしれない。
プロセスの生成とプログラムのロードが分かれてる事が賢いデザインだと後で気づくだろう。

xv6は暗黙のうちに最大量のユーザ空間メモリを割り当てる。
forkは親プロセスのメモリの複製のために必要となる子プロセスのメモリを割り当てる。
そしてexecは実行ファイルを保持するのに十分なメモリを割り当てる。
(mallocなどで)実行中にメモリがさらに必要になったプロセスは、データメモリをnバイトまで増やすためにsbrk(n)を呼ぶことができる。
sbrkは新しいメモリの場所を返す。

xv6はユーザの概念は提供しないので、ファイルやプロセスにユーザを結びつけて他のユーザから保護するという事は出来ない。
Unix的に言えば、全てのxv6のプロセスはroot権限で実行される。

感想

意外だなと思ったのがexecの動作です。
呼び出し元のメモリ読み込んだプログラムの実行イメージで”置き換える”ワケですね。
なのでその前にforkしてると。
execでそのまま別のプロセスを開始出来ればforkする必要もないと思うんですが、テキストにもforkとexecが分かれてるのには理由がある。それは後ほど乞うご期待と書いてあるので追々分かってくるのでしょう。

ソースがちょろっと出てきました。
シェルのソースの一部です。
前回、シェルはユーザ空間で実行される通常のプログラムである、と書かれてましたので、まだカーネル部分を読み始めるというところまでは至ってないということになります。

あと、xv6にはユーザの概念がないと書かれています。
ユーザの概念があると、管理やセキュリティのために色々な概念を導入せざるを得ず、ソースが複雑でより理解しづらいものになるのでしょう。
そのへん気にしなくていいということは、とりあえず読むという身にとってはありがたいことです。

sh.cのruncmd関数を見ると、switchの最初にdefaultを置いてあります。
defaultって最初でもいいのかー!ってのが今回一番の驚きです。

ほとんど訳してしまいました。
たぶん今回に限らずですが、翻訳は超適当ですのでご注意ください。

コメントを残す

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



※画像をクリックして別の画像を表示

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください