老システムエンジニアが「最近の若い者はアセンブリ言語も書けない」とボヤくので、「そりゃー必要がないからさ」と返しておきました。確かに仕事で使わない上に、参考書もほとんどなく、周りに仲間もいないのでは、現役プログラマーの方々が敬遠したくなるのも無理はありません。
アセンブリ言語はたいへん敷居が高く、取り付きにくい言語です。このメモは入門というより、読者が入門前に軽いウォーミングアップをしていただくために書きました。三回に分けて執筆し、今回の主役はキャリーフラグとゼロフラグです。
1 私のプログラム第1号
パソコンの黎明期にTK-80という機械がありました。(一発で歳がバレる)サンプルとして1から100まで加えるプログラムが付いていて、実行させるとノータイムで答えの13ba(十進数の5050)を表示し、感激したものです。あれを再現してみましょう。
C言語では次のようになりますね。
/*** my first program (my_first.c) ***/
#include <stdio.h>
int main(int argc, char **argv)
{ unsigned short answer;
unsigned short addend;
unsigned short thelimit;
answer = 0;
addend = 1;
thelimit = 100;
do
{ answer += addend;
addend++;
} while ( addend <= thelimit );
printf("%x\n", answer);
return 0;
}
当時はアセンブラもコンパイラもなかったので、機械語の命令を1バイト(16進数2桁)ずつ手作業でメモリに並べていってから、実行開始番地を指定し、「ここから実行せよ」と命令しなければなりませんでした。
さて、これからの作業は、この計算部分だけをアセンブリ言語で書いてみようというわけです。C言語のmain関数は次のようになりますね。
/*** add by assembly language (with_asm.c) ***/
#include <stdio.h>
extern unsigned short asmadd();
int main(int argc, char **argv)
{ unsigned short answer;
answer = asmadd();
printf("%x\n", answer);
return 0;
}
2 汎用レジスタ
レジスタはメモリと違ってCPUの中にあります。汎用レジスタはax、bx、cx、dxの4個、いずれも2バイトのサイズです。4個の性能はほとんど同じですが、main関数に値を返すときはaxを使うという約束になっています。
2バイトといえば、私が使っているgccコンパイラはunsigned shortが2バイトですから、両者は同じサイズですね。
2バイトのサイズは0x0000から0xFFFFまで、ちょうど0x10000個の数値を扱うことができます。十進数では65536、技術屋さんは「ムコのゴンザブロウ」と覚えるのだとか。
3 アセンブリ言語による関数(第1案)
それでは汎用レジスタを使って、関数を書いてみます。
/*** addition by assembly language (asmadd_1.s) ***/
.globl asmadd /* global .. called by 'main' */
.type asmadd, @function
asmadd:
movw $0, %ax /* initialize answer */
movw $100, %bx /* set limit (actually constant) */
movw $1, %cx /* initialize addend */
asmadd_loop: /* start of 'do' loop */
addw %cx, %ax /* ax += cx */
addw $1, %cx /* cx++ */
cmpw %cx, %bx /* try (bx - cx) */
jnc asmadd_loop /* loop if no borrow */
ret
さっきC言語で書いたmy_first.cと、意外に似ていますね。movは代入命令で、カンマの右側を左側と同じにします。moveのつもりでしょうが、むしろコピー命令と言いたい感じです。addはもちろん加算命令です。
cmpは比較命令で、カンマの右側から左側を一応引いてみよと命じています。減算命令subと違って右側の値は変わりません。
次の命令は「(比較命令の結果として)桁借りが生じなければジャンプせよ」です。加数が1から100までは桁借りはありませんが、101になると100マイナス101で桁借りを生ずるのでループを抜けます。(注1)
さて、コンパイルの対象ファイルが2つになったので、コンソールから
gcc with_asm.c asmadd_1.s
と指示します。実行可能ファイル(デフォールトでa.out)を実行すれば、答えの13baが表示されるでしょう。
ついでに、thelimitの数値を100でなく400にして、再コンパイルしてみて下さい。答えは5桁になるので桁あふれを生ずるのに、マシンは何食わぬ顔でまちがった答えを表示します。(注2)
4 桁あふれと桁借り
汎用レジスタ、たとえばaxについて、加算と減算をやってみます。いずれも16進数です。
0006 + FFFC = 0002
0002 - FFFC = 0006
加算命令の正解は10002ですが、axは4桁しかないので桁あふれ(キャリーオーバー)を生じ、結果はただの2になっています。
同様に減算命令では被減数が桁借り(ボロウ)をやって答えの6を出しています。
さっきの関数ではこの桁借りの有無を調べ、「ボロウがなければループを続けよ」と命令したのですね。
ところで、さっきの式を見ると、桁あふれや桁借りを無視すれば、FFFCはマイナス4と同じであることがわかります。axをsigned shortとして使うときはこの性質を利用します。具体的にはsigned shortでは最上位ビット(most significant bit)がゼロならば非負、1ならば負数とみなします。つまり
0001から7FFFまでは正数
8000からFFFFまでは負数
です。
なお7FFFに1を加えると突然マイナスになってしまいますが、この現象は桁あふれとは全く違うもので、キャリーオーバーとは言いませんからご注意を。
5 キャリーフラグ
CPUの中にはaxなどのレジスタのほかに、「キャリーフラグ」と呼ばれる1ビットのレジスタがあります。1ビットですから値は0か1のどちらかです。
計算命令の結果としてキャリーオーバーやボロウを生じると、キャリーフラグは1になり、生じなければ0になります。さっきの関数のjncをハードウェア寄りに表現するならば、「キャリーフラグが立っていなければジャンプせよ」です。
キャリーフラグの値は外からの命令で変えることができます。キャリーフラグを0にする命令はclc、1にする命令はstcです。
6 アセンブリ言語による関数(第2案)
加数の初期値を100にして逆順に足し込むと、関数はかなり短くなります。「何とミミッチイことを」と言われそうですが、こういうのはアマチュアにとって最高の知的ゲームで、みんなが楽しんで工夫していました。雑誌によく「こう書けば2バイト節約できる」などのノウハウが載っていたものです。
/*** addition by assembly language (asmadd_2.s) ***/
.globl asmadd /* global .. called by 'main' */
.type asmadd, @function
asmadd:
mov $0x0000, %ax /* initialize answer */
mov $0x0064, %cx /* initialize addend */
asmadd_loop: /* start of 'do' loop */
add %cx, %ax /* ax += cx */
dec %cx /* cx-- */
jnz asmadd_loop /* loop if not zero */
ret
閑話休題、dec命令は初登場ですね。プログラミングでは「1を加えよ」「1を引け」は非常に多く出てくるので、inc、decという命令が用意されています。ちょうどC言語でi++やi--があるのと同じです。
第1案で説明するのを忘れましたが、命令の末尾に付いていたwは、「この命令の対象はワード(2バイト)」という意味です。しかし右側を見れば命令の対象が2バイトなのは一目瞭然ですから、今回はばっさり省きました。なお私は自分自身の混乱を防ぐため、アセンブリ言語の数値はすべて16進数で書くことにしています。
それでは第1案を第2案に差し替えてコンパイルして下さい。
gcc with_asm.c asmadd_2.s
実行結果はもちろん同じですよね。
7 ゼロフラグ
CPUの中にはキャリーフラグのほかに、「ゼロフラグ」と呼ばれる1ビットのレジスタがあります。これも1ビットですから値は0か1のどちらかです。
計算命令の結果がゼロになると、ゼロフラグは1になり、それ以外は0になります。ここでもjnzをハードウェア寄りに表現するならば、「ゼロフラグが立っていなければジャンプせよ」です。
ゼロフラグはキャリーフラグと違って、外から値を変えることはできません。
8 フラグに関する注意事項
キャリーフラグもゼロフラグも、計算命令の結果として変化するものであり、代入命令では変化しません。次の2つを見比べてください。
sub %ax, %ax
mov $0x0000, %ax
どちらの命令もaxの値をゼロにしますが、前者は計算命令です。ボロウはないのでキャリーフラグは0、計算結果はゼロなのでゼロフラグは1になります。後者は代入命令ですからフラグは前のままです。
もうひとつ、incとdecではゼロフラグは変化しますが、キャリーフラグは変化しません。理由を訊かれても「CPUの造り方がそうなっているから」とお答えするしかないのですが、とにかくincとdecは正式の計算命令addやsubとは微妙に違うことを意識しておく必要があります。
9 レジスタのサイズ
世の中で動いているx86系のパソコンは、みんなレジスタのサイズが8バイトです。(少しは例外があるかも)このサイズのレジスタはraxとかrbxという名前で使われます。
フルサイズの右半分、つまり下位4バイトはeaxとかebxという名前です。左半分だけを使うことはできません。
ハーフサイズの更に右半分が、おなじみのaxとかbxで、サイズは2バイトです。
このような世の中ですから、趨勢としてはクォーターサイズのaxやbxよりもハーフサイズが愛用される傾向にあります。われわれも漸次ハーフサイズに移行することにしましょう。
10 コンパイラとアセンブラの役割分担
コンパイラはみんな中間生成物としてアセンブリ言語を作り、それを機械語に翻訳しています。私のgccはasというアセンブラが裏で協力しています。コマンドラインでたとえば
gcc -S my_first.c
(Sは大文字)と書けば、gccがasに渡すアセンブリ言語のファイルを得ることができます。
この前半の仕事、人間の書いたC言語をアセンブリ言語に変える段階では、作業はまことに自由自在、変数をどのレジスタに割り当てるかも勝手に決められるし、気の利いたコンパイラは第1案の原作を第2案のように直してメモリを節約するかもしれません。それどころか@negi-drumsさんによれば、原作を「100 * (1+100) / 2」に変えてしまうコンパイラもあるそうです。(https://qiita.com/negi-drums/items/054cb231b88bc7040e03)
後半の仕事は全然違います。アセンブラには気配りや親切心はありません。人間がカンマの前後のレジスタ名を逆に書いても、初期化しないレジスタをそのまま使っても、アセンブラは黙々と逐語訳するだけです。
私はケチでモノを捨てない性格なので、マイクロソフトのmasm(バージョン5だったか)を今でも持っていて、ときどきMS-DOS上で使って遊んでいます。このアセンブラは文法こそ違うものの(注3)、asと全く同じ機械語を生成します。
少し極言かもですが、アセンブラはTK-80で機械語を1バイトずつ手作業でメモリに並べていった、あの灰色の作業を代行するだけのツールに過ぎないのです。
追記
これは最初に書くべきでしたが、私のコンパイラはgcc6.2.1で、LinuxのFedora25に付いています。マシンはレノボ、CPUはIntel Core i5-6300Uで、普段はウィンドウズ7が走っています。
(注1)
「見ればわかる」ので説明を省きましたが、レジスタの前には%を、ナマの数値の前には$を付けることになっています。
ちょっと違和感を覚えるのは、操作の対象がカンマの右側にあることです。C言語などでは「a = b;」など、操作の対象は左側ですから、ちょうど逆ですね。これは文法なので慣れるしかありません。
(注2)
C言語のレベルでは桁あふれを検知することはできません。アセンブリ言語ならば簡単で、答えに加数を加える命令の後にjc、つまり「桁あふれを生じたらジャンプせよ」と書けばループを抜けることができます。
(注3)
文法が違うだけで、命令そのものは同じです。因みに文法名は前者がAT&T syntax、後者がIntel syntaxと呼ばれています。