IoPLMaterials

Edit this page Report an issue

MIPS アセンブリ言語入門

ここから先はだんだんハードウェアに近い話が見えてくるので,ここでターゲット言語の MIPS アセンブリ言語について少し勉強しよう.本節では,MIPS アセンブリ言語でプログラムを実際に書いて,その動作を確認する.MIPS アーキテクチャについてはいわゆるパタヘネ本の和訳を読まれたい.(買って読んで損はない本である.)

本節の内容は MIPS に詳しい人から見ると不十分だったり不正確だったりする部分が多いかもしれない.改善のための提案には Issue を立ててもらうとありがたい.

ところで,自分が普段使っているマシンで MIPS のコードが直接動く人は多分いない.したがって,本節の内容を実行するには,MIPS のコードを動かすためのシミュレータが欲しい.そのようなシミュレータとして一番お手軽なのはSPIMである.これをインストールして使ってもよいのだが,ここではymyzk氏による拡張spim-for-kuisを勧める.インストール方法はspim-for-kuisのページを参照されたい.以下の例も spim-for-kuis での実行例である.

例1: レジスタに即値をロードして標準出力に出力する

まずはシンプルなプログラムから見てみよう.以下のプログラムをex01.sとして保存してspim -file ex01.sを実行してみてほしい.spim-for-kuis が入っていれば,spim -show_stats -show_instructions -file ex01.s を実行すると,実行された命令と命令数に関する統計情報が出力される.(Armアセンブリ言語のためのハイライティングしか用意されていなかったので一部構文エラーの警告らしきハイライトが出ているが気にしないこと.)

    .text
    .globl  main
main:                       
    addiu   $sp,$sp,-20
    li		$v0,1
    li      $a0,20
    syscall
    addiu   $sp,$sp,20
    jr		$ra

MIPS アセンブリのプログラムでは,各行に以下のいずれかが書かれている.

一行ずつ内容を説明しよう.

注意: MIPS では分岐命令の直後に遅延スロットが生ずる(すなわち,分岐先の命令の前に分岐命令の直後に置かれた命令が実行されてしまう.)そのため,ここで挙げたコードは,遅延スロットに何もしない nop 命令を挿入する等して遅延スロットを潰さなければ実機では正しく実行されないはずである.しかし,SPIMシミュレータにおいてはこの遅延スロットが存在しないかのようにシミュレーションをしてくれるので,可読性のために,遅延スロットのことを考慮しないコードで説明を行っている.

例2: メモリアクセス

例1のプログラムではレジスタを操作して計算を行う方法を示した.上述したようにレジスタは数が限られているので,より多くの記憶領域が必要になる場合にはメモリに保存すべき情報を読み書きすることになる.以下のプログラムは,メモリアクセスを行う計算を実装した例である.

	.text
	.globl	main
main:
	addiu	$sp,$sp,-20
	li		$t0,5
	sw		$t0, 4($sp)
	lw		$t1, 4($sp)
	li		$v0,1
	move	$a0,$t1
	syscall
	addiu	$sp,$sp,20
	jr		$ra

例1のプログラムと異なる部分について説明を加える.

このプログラムでは,メモリアクセスの仕方を示すために,値 5 をメモリを経由してレジスタ $t0 から $a0 にコピーするなど,意図的に無駄な命令を挟んでいる.メモリアクセスはレジスタへのアクセスに比べて遅いので,必要がなければこのようにプログラムを書くことは無いだろう.

例3: 関数呼び出しを伴うプログラム

MiniML4- には関数呼び出しが入っているので,関数呼び出しを MIPS アセンブリ言語でどのように書いたらよいかを理解することは重要である.ここでは関数がどのように実装されているかを学ぼう.

スタックフレーム

関数呼び出しをアセンブリ言語で実装するにあたっての問題点の一つは,関数の実行に必要な情報をメモリ上にどのように保存しておくかである.関数を呼び出し,計算を実行し,その後元の場所に戻るためには,少なくとも以下の情報を保持する領域をメモリ上に確保しておく必要がある.

少しややこしいが,要は関数を正しく実行するためには,ローカルな記憶領域を呼び出しごとに確保する必要があるということである.

このローカルな記憶領域をメモリ上のどこに取るべきかは必ずしも自明ではない.例えばプログラム中で f という関数が定義されており,全部で 64 バイト のローカルな記憶領域が必要であったとしよう.「関数 f のための 64 バイトの記憶領域」を一箇所メモリ中に確保するだけでは十分ではない.なぜならば,f が再帰呼び出しを行っているかもしれないからである.f のための単一の記憶領域しか用意されていないとすると,再帰呼び出しのたびに領域が上書きされてしまい,呼び出しごとにローカルな記憶領域を確保するという目的を果たせない.かと言って,1回目の再帰呼び出し,2回目の再帰呼び出し...ごとに異なる記憶領域をあらかじめ確保しておくのでは,深い再帰が頻繁には行われない場合にほとんどの領域が無駄になってしまう.

この問題を解決するために通常用いられているのが,関数呼び出しのための記憶領域をスタックを用いて管理する方法である.この方法では,関数の先頭で必要なサイズのローカルな記憶領域をスタックの先頭に push し,関数から返る直前に記憶領域を pop する.これで関数呼び出しごとにローカルな記憶領域を確保することができ,かつ(関数呼び出しが終わったら記憶領域は解放されるので)メモリが無駄になることもない.

関数呼び出しは頻繁に行われることもあるので,スタックの操作やローカル領域へのアクセスが凄まじく複雑になるとプログラムの効率が悪化する.できるだけ簡単にスタック操作を行うために,実際には以下のような方法でローカル領域のスタックが実現されている.

歴史的な事情により,スタックはメモリアドレスの大きい方から小さい方に伸びることが多いようである.したがって,関数呼び出し時には $sp の値を減らし,関数から返るときには $sp の値を増やすことになる.

各関数呼び出しのローカルな記憶領域が保持されるこのスタックを コールスタック (call stack) と呼ぶ.また,関数呼び出しで確保されるローカルな記憶領域のことを スタックフレーム (stack frame) または アクティベーションレコード (activation record) と呼ぶ.スタックフレームに何を保存するかは,コンパイラに関数呼び出しを実装する上で考えなければならない重要事項の一つである.また,再帰呼び出しが深くなりすぎると スタックオーバーフロー (stack overflow) というメッセージが出ることがあるが,これは関数呼び出しの連鎖が深くなりすぎて,スタック上にスタックフレームを確保することができなくなったときに「スタックに使える領域が溢れて (overflowして) しまった」とユーザに伝えているのである.

呼び出し規約

関数呼び出しを実装するにあたってもう一つ大事なことは,関数を呼び出す際に引数をどのように関数に渡すか,関数から返るときに返り値をどのように呼び出し側に渡すかといった,関数を呼び出す際の呼び出し側と呼び出され側の挙動に関する約束事を決めておくことである.このような約束事のことを 呼び出し規約 (calling convention) と言う.

自分が実行する可能性のある関数をすべて実装する場合であれば,これは自分の好きなように決めればよい.しかしながら,このようなオレオレ呼び出し規約の問題点は,自分と異なる呼び出し規約を定めている人が書いたプログラムを呼び出すのが大変なことである.この場合,プログラム中で異なる呼び出し規約が混在することになって,プログラムを書くのも大変であり,可読性も悪くなる.コンパイラを書く側にしても面倒になる分大変である.

このようなオレオレ呼び出し規約に伴う問題を解決するために,アーキテクチャごとに標準の呼び出し規約が定められていることが多い.この規約に従ってプログラムを書けば(あるいはこの規約に従うコードを生成するコンパイラを書けば)標準呼び出し規約に従ったコード同士をリンクするのが容易になる.

呼び出し規約では,以下のような事項を定めておくことが多い.

実例

それでは,実際に関数呼び出しを行っているプログラム例を見てみよう.呼び出し規約は,以下の通り定められていることにする.(これは MIPS の標準的な規約とは必ずしも一致しない.)

	.text
	.globl	main
main:
	addiu	$sp,$sp,-20
	li		$a0,5
	sw		$ra,0($sp)
	jal		f
	lw		$ra,0($sp)
	move	$a0,$v0
	li		$v0,1
	syscall
	addiu	$sp,$sp,20
	jr		$ra
f:
	addiu	$sp,$sp,-4
	addiu	$v0,$a0,2
	addiu	$sp,$sp,4
	jr		$ra

少し説明を加える.ラベル main に続く部分は以下の通りである.

また,ラベル f に続く関数定義の部分は以下の通りである.

というわけで,f は受け取った値に 2 を加えて返す関数であるので,このプログラムを SPIM で実行すると 7 が標準出力に出力される.

例4: 再帰関数

最後に再帰関数を伴う例がどう実装されているかを見てみよう.ここまでの話が理解できていれば,そんなに難しくはない.ただし,再帰呼び出しに伴ってコールスタックがどのように伸びるか,再帰呼び出しから返る際にコールスタックがどのように縮むかを把握するのは重要である.

	.text
	.globl	main
main:
	addiu	$sp,$sp,-20
	li		$a0,10
	sw		$ra,0($sp)
	jal		f
	lw		$ra,0($sp)
	move	$a0,$v0
	li		$v0,1
	syscall
	addiu	$sp,$sp,20
	jr		$ra
f:
	addiu	$sp,$sp,-12
	ble		$a0,0,end
	sw		$a0,8($sp)
	addiu	$a0,$a0,-1
	sw		$ra,4($sp)
	jal		f
	lw		$ra,4($sp)
	lw		$a0,8($sp)
	addu	$v0,$v0,$a0
	addiu	$sp,$sp,12
	jr		$ra
end:
	li		$v0,0
	addiu	$sp,$sp,12
	jr		$ra

ラベル main からラベル f の直前までの部分は,関数 f を引数 10 で呼び出し,その返り値を標準出力に出力している.(理解できているかどうかを自分で一応確認しておいてほしい.)関数 f がどのように実装されているかを見てみよう.

次に,$a00 以下であったときのジャンプ先であるラベル end 以下を説明しよう.

さて,関数 f は全体としてどのような計算をしているのだろうか.また,再帰呼び出しによってコールスタックの様子はどうなるだろうか.ここまでが理解できていれば,f の開始時の $a0 の値が 3 などである場合にどのような計算が起こるかを手元で書き下してみればわかるはずである.ぜひ一度やってみてほしい.