機械語とアセンブリ言語
「機械語」という名前はすでに習ったはずだ。 コンピュータが実行できるのは機械語のプログラムだけであり, C言語やその他の言語で書かれたプログラムは, コンパイラを使って機械語プログラムに翻訳することで実行可能になる。
ところで機械語プログラムってどんなものだろうか? 唐突だが,下記は機械語プログラムの例だ。
b8 04 00 00 00 bb 01 00 00 00 b9 a2 80 04 08 ba
0d 00 00 00 cd 80 b8 01 00 00 00 bb 00 00 00 00
cd 80 68 65 6c 6c 6f 2c 20 77 6f 72 6c 64 0a
2桁の16進数が全部で47個並んでいるが,これは47バイトの情報を表している。 1バイトは8ビットだから,この16進数列は 47 × 8 = 376 ビットの2進数の列 10111000 00000100 00000000 ... を表している。 16進数で書く方が短いからそうしているが,機械の中(メモリや補助記憶装置の中)では, 電気的な方法で表された「0」と「1」の列として保持される。
- 参考:10進・16進・2進 対応表
10進 | 16進 | 2進 |
---|---|---|
0 | 0 | 0000 |
1 | 1 | 0001 |
2 | 2 | 0010 |
3 | 3 | 0011 |
4 | 4 | 0100 |
5 | 5 | 0101 |
6 | 6 | 0110 |
7 | 7 | 0111 |
10進 | 16進 | 2進 |
---|---|---|
8 | 8 | 1000 |
9 | 9 | 1001 |
10 | a | 1010 |
11 | b | 1011 |
12 | c | 1100 |
13 | d | 1101 |
14 | e | 1110 |
15 | f | 1111 |
実行可能ファイルの生成
とりあえず上記の機械語プログラムを実行してみよう。
◆演習1.1-1 下記の手順に従って,上記の機械語プログラムを実行しなさい。
なお,シェルプロンプト([190999x@wsa-s001:~]$
みたいなやつ)を単に $
と表記する。$
の右側が実際に打鍵入力すべきコマンドだ。--
とその右側は注釈だから入力してはいけない。
- 上記の16進数列を打鍵入力し,
hello.hex
というファイル名で保存する。
(空白の数や改行位置は自由に変えてよい。a
〜f
の代わりにA
〜F
を使ってもよい。) 下記のコマンドを実行する。
$ xxd -r -p hello.hex > h.bin -- バイナリファイルh.binに変換 $ ~y-takata/bin2elf h.bin -- ELFファイルa.outに変換 SHA-1 checksum: d07f61ed4ab2deb75765642ad8823aacc1f37809 *h.bin
表示されるチェックサムに注意。上記と異なる場合は
hello.hex
の内容が誤っている。- 生成された
a.out
を実行する。$ ./a.out hello, world
上記の16進数列は端末画面に hello, world
と表示するプログラムだったわけだ。
今度はこのプログラムをちょっと改造してみよう。
◆演習1.1-2 hello.hex
の内容を以下のように変更し,上記と同じことを実行しなさい。
実行結果はどのように変化するか?
- 2行目の先頭(先頭を第0バイトとして第16バイト)の 0d を 13 に変更。
- 3行目の3個目の16進数以降の 68 65 6c 6c ... を以下のように変更。
48 6f 77 20 61 72 65 20 79 6f 75 20 64 6f 69 6e 67 3f 0a
(空白の数や改行位置は自由。)
チェックサムは f691b30f3c03383a73466e6f100e7d143b2ee198
になるはずだ。
実はhello.hex
の中身は,「第34バイト以降の文字列(文字コードの列)を端末画面に出力する」プログラムだ。第16バイトの値は出力文字列の長さ(バイト数)を表している。
- 参考:ASCII文字コード表
◆演習1.1-3 以下の16進数列を適当な名前のファイル(例えばa.hex
)に保存し,以下を実行しなさい。
be 02 00 00 00 b9 0a 00 00 00 89 f3 d1 eb 81 fb
02 00 00 00 7c 0d 31 d2 89 f0 f7 f3 85 d2 74 0d
4b eb eb 89 f0 e8 12 00 00 00 49 74 03 46 eb da
b8 01 00 00 00 bb 00 00 00 00 cd 80 60 bb 0a 00
00 00 31 f6 46 b9 0a 90 04 08 c6 01 0a 46 49 31
d2 f7 f3 80 c2 30 88 11 85 c0 75 f1 b8 04 00 00
00 bb 01 00 00 00 89 f2 cd 80 61 c3
hello.hex
と同様にして,実行可能ファイルを生成して実行しなさい。
(チェックサムは580f146f4ee4595a44ab8f635b849ae88b906e94
)- 先頭を第0バイトとして第1〜2バイトの 02 00 を 10 27 に変更し,同じことを実行しなさい。
実行結果がどう変わるか確認しなさい。
(チェックサムは067be97f48a73e7e833712a268c2e603f017b071
) - さらに,第6バイトの 0a を 0f に変更し,同じことを実行しなさい。
実行結果がどう変わるか確認しなさい。
(チェックサムはa5e8c4aa50b5353cd2d72e887ec70ae1b40d9475
) - 上記の実行結果から,このプログラムは何を計算して出力するプログラムか,第1〜2バイト及び第6バイトの値が何を意味しているか,推測しなさい。(ヒント: 10進数 10000は16進数 2710 に等しい。)
- 上記の16進数列を保存したファイルの名前を,このプログラムの計算内容に合った適切なものに変更しなさい。
機械語プログラムの中身
「『2進数8桁(1バイト)の情報の並び』が機械にとってのプログラムである」ということが感じられただろうか。ここでhello.hex
の中身を少し見ておこう
(後で述べるように,実際には「16進数列を記述してプログラムを作る」ことはしないので,細かいことは覚えなくてよい)。
b8 04 00 00 00 bb 01 00 00 00 b9 a2 80 04 08 ba
0d 00 00 00 cd 80 b8 01 00 00 00 bb 00 00 00 00
cd 80 68 65 6c 6c 6f 2c 20 77 6f 72 6c 64 0a
上記は16バイト毎に改行しているが,「機械語命令」毎に改行すると以下のようになる。 最後の1行は「hello, world (+改行)」という13文字の列を表している。
b8 04 00 00 00
bb 01 00 00 00
b9 a2 80 04 08
ba 0d 00 00 00
cd 80
b8 01 00 00 00
bb 00 00 00 00
cd 80
68 65 6c 6c 6f 2c 20 77 6f 72 6c 64 0a
本当は,機械の中では2進数で格納されている。
10111000 00000100 00000000 00000000 00000000
10111011 00000001 00000000 00000000 00000000
10111001 10100010 10000000 00000100 00001000
10111010 00001101 00000000 00000000 00000000
11001101 10000000
10111000 00000001 00000000 00000000 00000000
10111011 00000000 00000000 00000000 00000000
11001101 10000000
-- この下の文字列データは省略 --
先頭の命令 b8 04 00 00 00 は「0番レジスタという記憶装置に数値 4 を格納する(代入する)」命令だ。 先頭バイトの b8 は2進数では 10111000 だが,上位5ビット 10111xxx が「レジスタに数値を格納する」命令を表し,下位3ビット 000 が格納先レジスタを表す。 このコンピュータは,「10111xxx というパターンに合致する1バイトを読み出したら,続く4バイトを xxx 番レジスタに書き込む」という動作を行うよう,電子回路(論理回路)として作り込まれているわけだ。 後の章で説明するが,このコンピュータ(Intel 80386,略してi386)には自由に値を変更できるレジスタが8個ある。
以降の3命令も,代入先レジスタが違うだけの同じ命令だ。 このコンピュータでは各レジスタは32ビット(= 4バイト)なので,レジスタに代入したい値も4バイト使って表されている。ただし,このコンピュータでは「下位桁のバイトが前,上位桁のバイトが後」に並べられる(このような並べ方をリトルエンディアンという)。例えばバイト列 04 00 00 00 は32ビットの数 00000004 を表し,バイト列 a2 80 04 08 は32ビットの数 080480a2 を表す。
このプログラムは「4個(または2個)のレジスタに値を書き込んで,cd 80 を実行する」という動作を2回実行している。 cd 80 は特殊な命令で,OSが提供する各種サービス(システムコールと言う)を呼び出す命令だ。 「現在実行中のプログラムを一旦離れて,OS内部のプログラムを実行する」と考えればよい。 0番レジスタに呼び出したいサービスの番号を格納して cd 80 を実行すると,そのサービスが実行される(i386用Linuxの場合)。 ここでは,文字列を外部に出力する「write」(サービス番号4)と,プログラムを終了する「exit」(サービス番号1)の2つを使っている。
- 参考:
ausyscall i386 --dump
を実行すると,システムコールとその番号の一覧が表示される。
各システムコールの詳細はman 2 exit
,man 2 write
等で見ることができる。 システムコール全般の説明はman 2 intro
にある。
exitシステムコールを実行するとプログラムが終了し,その先には実行が進まない。
このプログラムでは,機械語命令列の後に出力したい文字列を表すデータが続いている。 機械語命令であるかデータであるかは,どちらもただの2進数列なので,値そのものでは区別できない。 コンピュータは,「この位置から実行せよ」と指示されればその位置のビット列を機械語命令だと見なして実行するし,「この位置のデータを読み出せ」という命令を実行すればその位置のビット列をデータだと見なして読み出す。
アセンブリ言語
機械にとってのプログラムは「主記憶装置(メモリ)に格納された2進数の並び」だが,2進数や16進数の列を人間が読み書きするのはあまりに非効率だ。 そこで普通は,各機械語命令に付けられた「意味を表す名前」(ニーモニック; mnemonic)を使ってプログラムを表記する。 この表記法をアセンブリ言語 (assembly language) と言う。
◆演習1.1-4 下記のコマンドを実行して,hello.hex
中の機械語プログラムに対するアセンブリ言語表記を確認しなさい。
$ xxd -r -p hello.hex > h.bin
$ ndisasm -b32 h.bin
00000000 B804000000 mov eax,0x4
00000005 BB01000000 mov ebx,0x1
0000000A B9A2800408 mov ecx,0x80480a2
0000000F BA0D000000 mov edx,0xd
00000014 CD80 int 0x80
00000016 B801000000 mov eax,0x1
0000001B BB00000000 mov ebx,0x0
00000020 CD80 int 0x80
00000022 68656C6C6F push dword 0x6f6c6c65
00000027 2C20 sub al,0x20
00000029 776F ja 0x9a
0000002B 726C jc 0x99
0000002D 64 fs
0000002E 0A db 0x0a
ndisasmの出力は,左から,先頭から何バイト目かを表す16進数(オフセットとも言う),機械語命令を表す16進数列,その命令をアセンブリ言語で表記したもの,である。16進数列よりアセンブリ言語表記の方がずっと読み書きが楽であることがわかるだろう。
(なお,第00000022バイト以降は文字コードの列であって機械語命令の列ではないが,上記の出力では機械語命令列と誤認識されている。)
演習1.1-5 hello.hex
の機械語プログラムの命令数(文字列データを除く)は8だった。同様にして,演習1.1-3の機械語プログラムのアセンブリ言語表記を確認し,命令数がいくつか調べなさい(演習1.1-3のプログラムにはデータ部はなく,すべて機械語命令である)。また,writeシステムコールを実行している箇所,及び,exitシステムコールを実行している箇所を見つけなさい。
逆アセンブルとアセンブル
演習1.1-4, 1.1-5では「機械語プログラムであるビット列をアセンブリ言語表記に変換」した。この作業を逆アセンブル (disassemble) と言い,すでに存在する機械語プログラムの中身を確認する際などに行う。
反対に,機械語プログラムを作る際は,普通まずアセンブリ言語で記述して,それを機械語のビット列に変換する。この作業をアセンブル (assemble) と言い,それを行うソフトウェアをアセンブラ (assembler) と言う。
演習1.1-6 以下のアセンブリ言語プログラムを打鍵入力し,hello.s
というファイル名で保存しなさい。その後,下記の手順でアセンブルし,実行しなさい。
section .text
global _start
_start:
mov eax, 4 ; writeのサービス番号
mov ebx, 1 ; 標準出力
mov ecx, msg ; 文字列の開始番地
mov edx, msglen ; 文字列の長さ
int 0x80
mov eax, 1 ; exitのサービス番号
mov ebx, 0 ; 終了コード
int 0x80
msg: db "I'm fine.", 0x0a
msglen: equ $ - msg
$ nasm -felf hello.s -- アセンブルし,結果をhello.oに出力
$ ld -m elf_i386 hello.o -- ELFファイルa.outを生成
$ ./a.out -- 実行
I'm fine.
ここまでのまとめ
- 機械語プログラムとは,主記憶装置(メモリ)に格納された2進数の列だ。コンピュータは,「特定のビットパターンを読み出すと特定の動作をする」ように,論理回路として作り込まれている。
- 人間が2進数や16進数でプログラムを読み書きするのは非効率的なので,人間が読み書きする際はアセンブリ言語という記法を使って記述する。アセンブリ言語表記を機械語のビット列に変換する作業をアセンブルと言い,それを行うソフトウェアをアセンブラと言う。
コマンド入力を楽にするための設定
今後,nasm
コマンドと ld
コマンドを頻繁に使うが,上記のようにオプション引数が多いと面倒だしわかりにくい。シェルの設定に追記して,オプション引数無しで実行できるようにしよう。
~/.bashrc
の末尾に以下の2行を追加する(.bashrcがなければ新規作成する)。alias nasm='nasm -felf' export LDEMULATION=elf_i386
以降,新しく端末を起動すると設定が反映される。
alias
コマンドを引数無しで実行し,alias nasm='nasm -felf'
という行が表示されたら,設定がうまくいっている。
この設定を行った後では,上記のアセンブル手順は次のように簡潔になる。
$ nasm hello.s -- アセンブルし,結果をhello.oに出力
$ ld hello.o -- a.outを生成
$ ./a.out -- 実行
I'm fine.