アセンブリのコードは、どのようなCPU・どのようなOSを想定するか(アーキテクチャ)に強く依存しているため、アーキテクチャが異なっている場合には全く互換性のないコードになる。
アーキテクチャは、2つの異なる粒度で分類される。1つめは命令セットアーキテクチャ (ISA)であり、そのCPUを動かすための機械語の命令セットやレジスタの構成が含まれる。2つめは、Application Binary Interface (ABI)で、プログラムとOSとの間のやり取りを定めたものであり、関数の呼び出し規約やシステムコールの方法などが含まれる。
本年度のアセンブリ演習では、RISC-VのISAシミュレータを用いて実験を行う。具体的には、ISAはrv64imafdc、ABIはlp64dである。ISA の公式ドキュメントは次のサイトから取得できる。
https://riscv.org/specifications/
なお本ドキュメントは辞書のようなものであり、必要に応じて参照すればよい。(本演習を行うだけであれば、特に参照する必要はない。)
本演習の多くでは、 main関数をC言語で書きそこからアセンブリで書いた関数を呼びだすという形式をとる。C言語のほうには、関数宣言とその関数を呼び出すコードを書く。たとえば、int型の引数を1つとり、引数に1を加えた値を返す関数fをアセンブリで書く場合、C言語のコードは次のようなコードになる。
#include <stdio.h>
int f(int);
int main() {
printf("%d\n", f(100));
return 0;
}
そして以下が、アセンブリで関数fを定義する基本的なコードである。
.globl f
.align 2
f:
# ここに関数の中身を書く
addi a0, a0, 1
# 関数の終了処理
ret
上記のCコードをmain.cに、アセンブリコードをfunc.s に保存した場合、以下のようにしてコンパイル・リンクし、実行する。
$ riscv64-unknown-elf-gcc main.c func.s -o main
$ spike /usr/local/brew/opt/riscv-pk/riscv64-unknown-elf/bin/pk main
bbl loader
101
上記のコードに含まれている行に関して、これから順番に説明する。
addi a0, a0, 1
ret
これらは命令や擬似命令と呼ばれる。addiはRISC-Vの基本命令の1つであり、直接機械語に対応している。一方でretは擬似命令であり、アセンブルの際にjalr x0, 0(x1)に展開される。(jalrはRISC-Vの基本命令である。ここで中身を理解する必要はない) 擬似命令は、アセンブリプログラマがコードを書きやすくするために提供されているものであり、時には一行が複数個の機械語の命令に対応する場合もある。(各命令・擬似命令の機能については後述する)
命令は、命令の種類とその引数からなる。
addiや ret はどの命令を使うかをあらわしている。これをニーモニックと呼ぶ。
addiの後ろに並んでいる a0, a0, 1 が命令の引数で、これをオペランドという。
.globl f
.align 2
このように .からはじまる行はアセンブラ指令と呼ばれ、アセンブラに対して特殊な動作を要求する。構文は命令/擬似命令とほぼ同じであるが、基本的にアセンブラ指令は機械語を生成しない。なお、.globl fは、 fを他のソースファイルから参照出来るようにするためのアセンブラ指令であり、.align 2は、アセンブラデータのメモリ上での配置位置を4 (=2の2乗) の倍数に揃えるためのアセンブラ指令である。(後述の「メモリアクセス」も参照のこと)
f:
:でおわる行はラベルと言い、翻訳後のプログラム上の位置に名前をつけるものである。慣習上、ラベルはインデントをせず、それ以外は全てタブ1つでインデントする。
# ここに関数の中身を書く
# 関数の終了処理
#で始まる行はコメントである。
ここまで読んだ段階で上記のCコードとアセンブリコードを自分で作成し、クロスコンパイルしてspikeで実行するとよい。
ここからは実際に自分でアセンブリを書く方法について述べる。命令の正式な仕様について知りたい場合には、上記で挙げた公式のドキュメントを参照する事。
RISC-Vには多数のレジスタがあるが、本演習ですぐに用いるレジスタを紹介する。他のレジスタについては、それらのレジスタの仕様をきちんと把握した自信がある場合を除き使ってはならない。
a0, a1, a2, a3, a4, a5, a6, a7: 64bitの整数レジスタで、関数の引数を受け取るのに使われる。(使われる順番はこの順番である。) a0は、関数の戻り値を返す際にも使われる。(関数に返り値が存在する場合、関数の終了処理が行われた際にa0に入っているものが返される) これらのレジスタは、関数内で自由に変更して構わない。
加減乗除は次のように行う。
# 123 を a0 に入れる
li a0, 123
# a1 の中身を a0 にコピーする
mv a0, a1
# a1 + 123 を計算し、a0 に入れる
addi a0, a1, 123
# a1 + a2 を計算し、a0 に入れる
add a0, a1, a2
# a1 - 123 を計算し、a0 に入れる
addi a0, a1, -123
# a1 - a2 を計算し、a0 に入れる
sub a0, a1, a2
# a1 * a2 を計算し、a0 に入れる
mul a0, a1, a2
# a1 / a2 を計算し、 a0 に入れる
div a0, a1, a2
# a1 % a2 を計算し、 a0 に入れる
rem a0, a1, a2
入力オペランドと出力オペランドに同じレジスタを使っても問題ない。
# a0 をインクリメントする
addi a0, a0, 1
# a0 を2倍する
add a0, a0, a0
# 無条件に label に分岐する
j label
# a0 == a1 だったら分岐する
beq a0, a1, label
# a0 == 0 だったら分岐する
beqz a0, label
# a0 <= a1 だったら分岐する
ble a0, a1, label
# raの指すアドレスに無条件分岐する (関数からの復帰に使う)
ret
分岐の条件としては、blt (less than), ble (less than or equal), beq (equal), bge (greater than or equal), bgt (greater than), bne (not equal) などが存在する。また、それぞれbltz, blezなどと末尾にzを付加することで、0との比較による分岐命令にすることが出来る。
分岐命令を用いる事で、C言語におけるifやwhileのようなプログラムを書くことが出来る。if〜else 構文を用いた次のようなC言語のプログラムを考えたい。(ただし、a0~a2は変数ではなくレジスタだと思ってほしい)
if(a0 == a1){
a1 += 1;
}else{
a0 += 1;
}
a2 = a1;
これをアセンブリで書くと次のようになる。
beq a0, a1, label1
addi a0, a0, 1
j label2
label1:
addi a1, a1, 1
label2:
mv a2, a1
このプログラムの動作は次のようになる。まず1行目でa0の値とa1の値で大小関係が比較される。beq命令を用いているので、a0 == a1であればlabel1に分岐するが、等しくない場合は分岐しないことになる。
まず、分岐が行われた場合を考える。label1(4行目)に飛んだので、次に行われる命令は5行目のaddi a1, a1, 1であり、a1の値に1が足される。そして次に行われる命令はその次の行なので、7行目の mv a2, a1であり、a1の値がa2にコピーされる。(6行目のlabel2は命令ではないので無視される。) ここで重要なポイントは、5行目が実行された後に、1行目に勝手に戻ってきて次に2行目が実行されるというわけではない、ということである。
では次に分岐が行われない場合を考える。1行目のbeq命令の結果label1には飛ばなかったので、次の行である2行目のaddi a0, a0, 1が行われる。その後、3行目のj label2より無条件分岐でlabel2に飛び、mv a2, a1が行われる。なぜ j label2が必要なのかというと、これを書かない場合には次に行われる命令は次の行なので、(label1は命令でないので飛ばして) 5行目のaddi a1, a1, 1が実行される。つまり、if文の中身の式が実行されてしまうことになり、不適切である。
同じように分岐命令を適切に用いることで、 while構文は次のように書くことができる。
label1:
# 条件が満たされなかったらwhileループを抜ける
bne a0, a1,label2
# ここにwhileの中身を書く
j label1
label2:
# ここに続きを書く
修正) while構文内の「無条件分岐」の箇所("b"から"j"に変更)を修正しました (2022.12.23)
メモリからレジスタにコピー(ロード)するには ld命令、 レジスタからメモリにコピー(ストア)するには sd命令を使う。
# メモリの 0+a1 (=a1) 番地から8バイト分を、a0にコピー(ロード)する
ld a0, 0(a1)
# a0の中身8バイトを、メモリの 8+a1 番地にコピー(ストア)する
sd a0, 8(a1)
dはdoubleword(8バイト)の意味である。それより小さいバイトのデータをロードする時には、lb (byte: 1バイト)、lh (half: 2バイト)、lw (word: 4バイト)などを用いる。この際にレジスタの上位ビットは符合拡張がなされるようロードされるため、数値として同一であるとみなしてよい。符合拡張ではなくゼロ拡張をしたい場合には(上位ビットを全てゼロで埋めたい場合には)、末尾にuを付けたlwuのような命令を用いることができる。データをストアする際には、それぞれsb, sh, swなどの命令を用いる。
メモリアクセスをするにあたっては、いくつか注意事項がある。
アクセス先の領域がOSによって割り当てられていなければ、エラー (Segmentation fault) になる。
ハードウェアの都合上、 8バイトデータのロード・ストアは8の倍数アドレスから、4バイトデータのロード・ストアは4の倍数アドレスから行う必要がある。このような制約をアラインメントと呼ぶ。