CPU と機械語(3)

入出力とシステムコール

まともなオペレーティングシステム(OS)があるコンピュータでは、入出力を 行なうには、オペレーティングシステムの機能を呼び出す。 この呼び出しをシステムコールという。

xspim はあくまでmipsシミュレータなので、本格的なオペレーティングシス テムはもっていないが、入出力ができないと不便なので、簡単なOS的な機能を 持っている。それを呼び出すと入出力ができる。

MIPS には、システムコールを行なうための機械語命令 syscall がある。 システムコールには種類ごとに「システム・コール・コード」 という番号がついており、システムコールを行なうには、 システム・コール・コードをレジスタ R2 にロードし、 引数をレジスタ R4 から R7 に(引数が浮動小数点数の場合はレジスタ$f12に) ロードしてから、syscall 命令を実行する。 システムコールが値を返す場合は、その値はR2に納められる。 ただし、これは、整数レジスタに値を納めるべき場合であって、 浮動小数点数が返る場合は、レジスタ $f0 に入る。 (倍精度の場合は、もちろん、$f0と$f1を組にしたものが使われる。)

R2 とか、R4 とかレジスタの番号を覚えるのは面倒なので、

のように、レジスタに別名がつけてあり、アセンブリ言語ではこの別名を使っ てよい。

xspim のシステム・コール一覧:

サービス名意味番号引数戻り値
print_intintを印刷1$a0=integer
print_floatfloatを印刷2$f12=float
print_doubledoubleを印刷3$f12=double
print_string文字列を印刷4$a0=string(の先頭番地)
read_intint を入力5integer($v0に)
read_floatfloat を入力6float($f0に)
read_doubledoubleを入力7double($f0に)
read_string文字列を入力8$a0=buffer, $a1=length(buffer領域に書込み)
sbrkメモリ要求9$a0=要求バイト数address($v0に)
exitプログラム終了10

read_stringで、buffer は入力した文字列をしまうための領域の先頭番地。 n=length とおいた時、n - 1文字をbufferから始まる領域に読み込んで、その 直後に'\0' を入れる。(文字列のしっぽは'\0'であったから。) ただし、読み 込んだ行の長さが n - 1 文字より短い時は、改行文字までを読み取って、そ の直後に'\0'を入れる。

sbrk については、当面使わないので、説明はあと回しにする。

システムコールを行うプログラムの例

"the answer = 5" とプリントするだけのプログラムを考える。 C 言語で言えば、printf("the answer = %d\n", result); のような もの。ファイル名は answer5.s とする。 (answer5.sのダウンロード)

        .data              #ここから書くものは静的データセグメントに置かれる
str:                       #文字列のラベル
        .asciiz "the answer = "

        .text              #ここから書くものはテキストセグメントに置かれる
        .globl main
main:
        li $v0, 4          #print_str のシステム・コール・コードを$v0 にロード
                           #(load immediate疑似命令)
        la $a0, str        #プリントする文字列のアドレス(load address 命令)
        syscall            #システムコールにより文字列をプリント

        li $v0, 1          #print_intのシステム・コール・コード
        li $a0, 5          #プリントする整数
        syscall            #システムコールにより整数をプリント

静的データセグメントの意味はあとで説明する。とりあえずデータを置くため の領域の1つと考えてほしい。 テキストセグメント はプログラムが置かれるメモリ領域である。 これについてもくわしくはあとで。

少しまともなプログラムの例

入力の和を求めるプログラム

4個の int を読み込んで、それらの和を求める。

C 言語で言えば、次のような処理をする:

#include <stdio.h>
main()
{
  int i, n, sum;

  sum=0;
  for (i=0; i<4; i++) {
    scanf("%d", &n);
    sum = sum + n;
  }
  printf("%d", sum);
}

ただし、scanf や printf は呼び出さないし、&n のようなポインタも使わない。 変数 i のためにレジスタ $t0 (R8 の別名), n のために $t1 (R9), sum のために $t2 (R10)を使うことにしよう。和の計算でオーバーフローしても気にしないことにする。

以下、アセンブリ言語プログラム:

        .text
        .globl main
main:
        li $t2, 0                       # sum=0
        li $t0, 0                       # i=0
loop:
        bge $t0, 4, loop_end            # branch on greater than equal.
                                        # i >= 4 ならループを抜ける
                                        # 実は、これは疑似命令で、2行の
                                        # 機械語命令になる。
        li $v0, 5                       # read_int のシステム・コール・コード5
        syscall                         # システムコール
        move $t1, $v0                   # 読んだ値を n へ。(値がコピーされる)
        addu $t2, $t2, $t1              # add unsigned
                                        # sum = sum + n
        addiu $t0, $t0, 1               # add immediate unsigned
                                        # $t0 と即値 1 の和を $t0 に入れる
                                        # i++ あるいは、同じことだが、i=i+1
        b loop                          # loop へ branch (分岐)
loop_end:
        li $v0, 1                       # print_int は1番
        move $a0, $t2                   # 引数レジスタに sum を入れる
        syscall                         # システムコールで印刷

本当は一番最後に jr $ra を実行したほうがよいのだが、これの意味はあとで 説明するので、今は省略しておく。

一般に、addu, addiu などの「u」は 「unsigned(符号なし)」を意味する。従っ て、これらは本来、符号なし整数用の加算命令である。しかし、符号ありの整 数の加算にも使える。ただし、その場合、オーバーフローは無視される。 C 言語では加減算等でのオーバーフローは無視される(エラーにはならず、 だまってでたらめな結果を返す。) C と同じにしたければ、アセンブラでも addu, addiu等を使えばよい。

上のプログラムを sum.s というファイルに入れて、xspim で実行してみると よい。

実はすでに http://www.kyoto-su.ac.jp/~kbys/kiso/cpu/sum.sとして置いてある。(しかし、一度自分で入力してみたほうが勉強にはなるかも知れない。)

3次元ベクトルの和を求めるプログラム

次の C 言語のプログラムに相当するプログラムをアセンブリ言語で書いて みる:

int va[3]={0,1,2};    /* 配列の宣言と初期化 */
int vb[3]={3,4,5};    /* 配列の宣言と初期化 */
int vc[3];

main()                /* 2つの3次元ベクトルva と vbの和を求めてvcに入れる */
{
  int i;

  for (i=0; i<3; i++) {
    vc[i]=va[i]+vb[i];
  }
}

これをアセンブリ言語で書くと次のようになる:

        .data
va:
        .word 0, 1, 2           # 0, 1, 2 をワードとして格納
vb:
        .word 3, 4, 5           # 上と同様
vc:
        .space 12               # 3ワード分のスペースを確保
                                # 1ワードは、MIPS R2000 では4バイト
                                # 従って、3ワードは、12バイト
                                # .space n は n バイトのスペースを確保
                                # 場所をとっておくだけで、データは入れない
        .text
        .globl main
main:
        li $t0, 0               # i = 0;        $t0 を i として使う
loop:
        bge $t0, 3, loop_end    # i >= 3 ならくり返しをやめる
        sll $t1, $t0, 2         # $t1 <- i * 4        (int は4バイトだから。)
                                # sll は shift left logical で、
                                # sll        $t1, $t0, 2 は、$t0 に入っている数を
                                # 左に 2 ビットシフトしたものを $t1 に入れる。
                                # 例えば、1 を2ビットシフトすると、100 (10進の4)
                                # になる。1ビット左シフトするごとに2倍される。
                                # 4倍する理由は、1ワードが4バイトだから。
                                # 要するに、$t1 に 4*i が入っているようにしたい。
        lw $v0, va($t1)         # $v0 <- va[i]; 
                                # (va のアドレス(これは定数) + $t1)番地、
                                # すなわち、va のアドレス + 4 * i 番地(i=$t0)
                                # から 1 ワードだけロードする
                                # 一般に「即値(レジスタ)」は
                                # 即値 + レジスタで求まる番地を示す
                                # lw は load word
        lw $v1, vb($t1)         # $v1 <- vb[i]
                                # ここまで来ると、$v0 に va[i] の値が、
                                # $v1 に vb[i]の値が入っているはず。
        addu $v0, $v0, $v1      # $v0 <- $v0 + $v1
        sw $v0, vc($t1)         # $v0 -> vc[i]
                                # sw は store word
                                # (vc のアドレス(これは定数) + $t1)番地、
                                # すなわち、vc のアドレス + 4 * i 番地(i=$t0)
                                # に 1ワードだけストアする。
        addiu $t0, $t0, 1       # i++
                                # なお、$t1を使わず、$t0 に4を加えていく
                                # 方法もある。
        b loop
loop_end:

このプログラムは http://www.kyoto-su.ac.jp/~kbys/kiso/cpu/vector.s として置いてある。

メモリの使い方について

もっと複雑なプログラムを書くには、MIPS 向けのプログラムを書く上での 規約をいくつか覚えないといけない。

MIPS 向けと言っても、基本的な考え方は、他のプロセッサと共通。ただ、具 体的なアドレスなどは、プロセッサによって違っている。例えば、下の図の 0x400000番地などは、MIPS での決まりである。しかし、テキスト・セグメン トとか静的データとか、動的データ、スタックと言った概念は、どのCPUにも 共通するものと言ってよい。(用語は少し違っている可能性がある。)

主記憶領域の使用法

主記憶は3つの領域に分けて使用される。 すなわち、テキスト・セグメントデータ・セグメントスタックセグメントの3つである。

                ┌───────┐
                │    予備      │
   0x400000番地 ├───────┤
                │              │ 
                │              │ テキスト・セグメント  ←── 機械語命令を置く
                │              │
 0x10000000番地 ├───────┤
                │              │←─┐
                │  静的データ  │    │     ←──────── 実行前からサイズがわかっているものを置く
                │              │    │
                ├- - - - - - - ┤ データ・セグメント
                │              │    │
                │  動的データ  │    │     ←──────── 実行時にサイズの決まるものを置く
                │              │←─┘
                ├───┬───┤
                │      ↓      │
                │              │
                │              │
                │      ↑      │
                ├───┴───┤
                │              │
                │              │スタック・セグメント ←─── 自動変数や、関数の引数などを置く
                │              │
 0x7fffffff番地 └───────┘

テキストセグメント

テキストセグメントは、プログラムの機械語が格納される領域である。 アドレス空間の一番若い番地のあたりにある。 開始アドレスは 0x400000 であるが、他の CPU ではこの番地とは限らない。 通常、この領域はOSによって書き込み不可にされている。

データ・セグメント

データ・セグメントは、 テキスト・セグメントよりも大きな番地に位置する領域で、データが置かれる。 これはさらに静的データセグメント(静的領域)動的データセグメント(動的領域)の2つに分かれている。 動的データセグメントはしばしばヒープとも呼ばれる。

  1. 静的データセグメントの開始アドレスは、10000000(16進)であるが、 他のCPU ではこの番地に限らない。 この領域に置かれるのは、 コンパイル時にサイズが確定していて、 プログラムの開始から終了までずっとアクセス可能なデータである。
    たとえば、C 言語の大域変数のためのメモリ領域は、 静的データセグメントに置かれる。 大域変数のために必要なメモリ領域の大きさは コンパイル時に確定しており、また、大域変数は プログラムの実行中はいつでもアクセス可能だからである。 例えば、int 型の大域変数なら、サイズは 4 (32ビット CPUの場合)とわかってしまう。 また、大域配列も静的データセグメントに置かれる。 C の文字列定数もサイズがわかっているので、 普通静的データセグメントに置かれる。
  2. 動的データ(dynamic data) セグメント(ヒープ)は静的データ・セグメン トのすぐ後ろに配置される。 データを置くために必要なメモリの量がコンパイル時にはわからず、 プログラムの実行が始まってから その量がわかるような場合、 ヒープの中にデータを置く。
    例えば、データをファイルから読み込むような場合、 実際にファイルを読んでみないと データの個数がわからないことが多い。 従って、 データを格納するための領域のサイズをコンパイル時には決められない。 そのような場合、実行が始まってからヒープに領域を確保して、 そこにデータを置くようにする。
malloc 関数

C プログラムにおいては、ライブラリ関数 malloc を呼ぶと、 ヒープの中からメモリの割当てを受けることができる。 malloc の名は memory allocation に由来する。

malloc(n) は、ヒープの中を見て、データを置いてい ない場所で n バイト分空いている所を見つけて、そこの先頭番地を 返してくれる。

もし、空いていなければ、動的データセグメントを広 げて、n バイト分の場所を確保しようとする。 といっても、 オペレーティングシステムがプログラムに使用を認めているメモリ領 域は起動時に決まっていて、 その外の領域を勝手に使うことはできないので、 動的データセグメントを広げるには、 オペレーティングシステムに対して、 メモリをもっと使わせてくれるように要求を出さなければならない。 要求をオペレーティングシステムが認めた場合、オペレーティングシ ステムは、使ってよい領域を広げてくれるので、malloc は新たに使 えるようになった領域を利用して、n バイト分の空きを見つけて、 その先頭番地を返してくれる。

データセグメントを広げる要求をオペレーティングシステムが認 めてくれなかった場合は、malloc の実行は失敗する。 その場合、プログラマは、あきらめてプログラムを停止させるなり、 別のことを実行するなりと いった処理をする。

動的データセグメントが広げられる時は、 メモリ空間の後ろに向かって伸びて行く。

スタック・セグメント

アドレス空間の最上部(アドレスの値が最大になっている側のはじ。 32ビットのMIPS では末尾のアドレスは0x7fffffff)に配置される。 自動変数の値や関数の引数の値を入れるために用いる。 一時的にレジスタの内容を退避したりするためにも用いる。 もっとくわしい使い方はあとで。

スタック・セグメントも使うに従って伸びて行く。ヒープ とは反対に、アドレスの小さくなる方向に伸びる。

スタックが今どこまで伸びているか記憶しておかないといけないので、 スタック・セグメントの先頭番地を記憶しておくためのレジスタを用意する。 これをスタックポインタという。 MIPS では、R29 をスタックポインタとして使用するという規約がある。

R29 がスタックポインタだというのを覚えるのは面倒なので、アセンブリ プログラムで$29 と書く代わりに $sp (sp ← stack pointer)と書いても 良いことになっている。 汎用レジスタに対してこのような別名が色々用意されている。

メモリ割り当ての例

C 言語プログラムにおけるメモリ割り当ての様子をみてみよう:

        double x = 0.0;        // グローバル変数。静的データセグメントに置かれる。
        int a[100];            // グローバル配列。静的データセグメントに置かれる。
        char *message = "Hello, world!\n";  // 文字列定数。静的データセグメントに置かれる。
        
        int main()
        {
          int n;        // 自動変数。レジスタかスタックに置かれる。
          char *buff;   // 同様。
          int *b;       // 同様。

          …
          buff = (char *)malloc(1000);
               /* 1000バイト分のメモリ領域を新たに確保。動的データ領域(ヒープ)に確保される。 
                  malloc はその領域の先頭へのポインタ(アドレス)を返す。
                  返ってくるポインタは void * という型なので、(char *) というキャスト(型変換)により
                  文字へのポインタの型に変換してbuffに入れる。*/
          …
          free(buff);
               /* buff が指していた領域を使う必要がなくなったら、free(buff) を呼べば、
                  その領域は、空き領域と認識されるようになる。そのような認識は、
                  メモリを管理するルーチンがやっている(その詳細はこの授業ではやらない)。
                  空き領域として認められた部分は、あとで再利用される。
                  すなわち、あとで malloc(n) が呼び出された時に、n が上で解放した
                  1000バイトよりも小さければ、この 1000バイトの中から nバイトが使用
                  される可能性がある。
                  もちろん、他に空いている場所があれば、他の場所が使われるかも知れない。*/
          b = (int *) malloc(n * sizeof(int))
                 /* n 個分の int を置く領域を確保したい時 */
                 /* このように、malloc の引数は変数や計算式になっていることもある。
                    従って、何バイト確保すべきかは、プログラム実行時までわからない。
                    つまり、コンパイル時にはわからないから、こういう領域はコンパイル時に
                    確保するわけにはいかない。
                    こういう事があるので、動的データセグメントが必要。*/
                 /* sizeof(型)と書くと、その型のデータをしまうのに必要なバイト数が求まる。
                    たとえば、int が4バイトのマシンなら、sizeof(int) は、4 になる。
                    int が8バイトのマシンなら、sizeof(int) は、8 になる。
                    この仕掛けのおかげで、int のサイズが何バイトであっても、それを気にしないで、
                    sizeof(int)とさえ書いておけば、どのマシンでも動くプログラムになる。
                    もし、sizeof(int) のかわりに自分で 4 と書いてしまったら、
                    int が8バイトのマシンでは動かないプログラムになってしまう。*/
          …
        }

領域の開始番地などは、コンピュータごとに違うが、 MIPS 以外を使うコンピュータであっても、 プログラムを置く領域、静的なデータを置く領域や動的なデータを置く領域、 スタック領域といった概念は必ずある。 色々な変数がどこに確保されるか、あるデータがどこに置かれるか、 といったことは、高級言語のプログラムを理解する上でも極めて重要で、 これがわからないと本当にソフトウェアがわかるようにはならない!

文字列に対するメモリ割り当てについての注意

先に述べたように、"Hello, world!\n" と言った文字列定数は静的データセグメントに置かれる。 例えば、

int main()
{
  char *x = "the answer = ";
  (以下略)
}

のようなプログラムでは、 x は自動変数なのでレジスタかスタック上に置かれるが、 "the answer = " は文字列定数なので静的データ領域に置かれる。 文字列定数はコンパイル時にサイズがわかってしまうからである。

なお、x には文字列が置かれた場所の先頭アドレスが(ポインタとなって) x に代入されるのであって、文字列の本体がまるごと x に入るわけではないことに注意。 x はポインタ変数だから、x に入るのはあくまでもポインタである。

復習になるが、以前に示した answer5.s というアセンブリプログラムでは、

        .data 
str:
        .asciiz "the answer = "

という部分で静的データセグメントに文字列を置いてから、その先頭アドレ スをあとで

        la      $a0, str

によってレジスタに入れていた。$a0 を x だと思えば、 上の例と同じになる。

スタックの使い方

push, pop 操作

今、レジスタ R16 に何か大切なデータが入っているとする。ところが、R16 をどうしても別の目的で使わなければならなくなったとする。この場合、R16 の値を一時的にメモリのどこかに退避し、R16 を別の目的に使ったあと、また 先程退避したデータを R16 に戻す、ということをしなければならない。この ような時、スタックを使うことが多い。次のようにしてスタックを用いる:

  subu $sp, $sp, 4      # $sp ← $sp - 4, スタックポインタを4バイト分
                        # ずらす。レジスタ R16 に入っている4バイトを
                        # 退避するための場所を確保するため。
                        # (subu = subtract unsigned, 符号なし減算)
  sw $16, 0($sp)        # $sp + 0 番地、つまり、スタックポインタが指している
                        # 場所から始まる1ワードの領域に $16 をストア。
                        # (sw = store word)
  # ここまでの操作をデータの「スタックへの push」という。

  …… ここで $16 を何かの目的に使う …

  # ここからあと、退避していたデータを元へ戻す。これを「スタックからのpop」
  # という。
  lw $16, 0($sp)        # スタックポインタが指している場所から始まる1ワード
                        # を$16 へロード。(lw = load word)
  addu $sp, $sp, 4      # スタックポインタ $sp の値を元に戻す。

図示すれば以下のようになる: ($sp が最初は X 番地を指していたと仮定している)

       ┌──────┐                 │              │
  R16: │ 大事な値   │         $sp →  │              │← X 番地
       └──────┘                 │              │
                                        └───────┘

                        ↓スタックにプッシュ

       ┌──────┐         $sp →  │ 大事な値     │← X-4 番地
  R16: │ 大事な値   │                 │              │
       └──────┘                 │              │
                                        └───────┘

                        ↓R16 を好きなように使う

       ┌──────┐         $sp →  │ 大事な値     │← X-4 番地
  R16: │ 新しい値   │                 │              │
       └──────┘                 │              │
                                        └───────┘

                        ↓終ったら元に戻す

       ┌──────┐                 │              │
  R16: │ 大事な値   │         $sp →  │              │← X 番地
       └──────┘                 │              │
                                        └───────┘

もし、$16 と $17 の 2 ワードをプッシュ・ポップするなら、次のようにすれ ばよい:

  subu $sp, $sp, 8      # 2ワード分の場所を確保するので、2*4 で8バイトずらす。
  sw $16, 0($sp)        # $sp + 0 番地に $16 をストア。
  sw $17, 4($sp)        # $sp + 4 番地に $17 をストア。

  …… ここで $16, $17 を何かの目的に使う …

  lw $17, 4($sp)        # $17 を復帰
  lw $16, 0($sp)        # $16 を復帰
  addu $sp, $sp, 8      # スタックポインタ $sp の値を元に戻す。