アセンブリ言語に触る
私はコンピューターサイエンスのバックグラウンドがないままJavaScript からプログラミングを始めました。業務でプログラムを書く中で、分からないことに出会うたびに、その背景にあるコンピューターの動作原理に興味を持つようになりました。最近、CPU 周りの基礎を学ぶ機会があったため、今回の記事ではアセンブリ言語を使って簡単なプログラムを書いてみたいと思います。低級言語に触れたことがない方が、その雰囲気を掴むきっかけになれば嬉しいです!
前提知識
プログラミング言語の分類
プログラミング言語は、高級言語と低級言語の二つに大きく分類することができます。それぞれの特徴は以下の通りです。
- 高級言語
- C、C++、Ruby、JavaScript など
- 人間が理解しやすい記述方式
- 書き方は特定の CPU アーキテクチャ(ISA)に依存しない
- 低級言語
- アセンブリ言語や機械語など
- 高級言語と比べてコンパイルプロセスが少ないため実行時のパフォーマンスが良い
- 高級言語よりも人間にとって理解しづらい
- 書き方は特定の CPU アーキテクチャ(ISA)に依存する
プログラムはどのように実行されるのか
CPUは0と1で書かれた機械語を処理します。そのため、エンジニアが作成した高級言語のプログラムを実行するには、機械語に翻訳する必要があります。この翻訳プロセスは、高級言語 → アセンブリ言語 → 機械語の順で実行されます。
高級言語からアセンブリ言語への翻訳はコンパイルというプロセスで、これはコンパイラというプログラムが実行します。また、アセンブリ言語から機械語への翻訳はアセンブルというプロセスで、これはアセンブラというプログラムが実行します。
アセンブリ言語について
アセンブリ言語では、0 と 1 で書かれた機械語をadd
などの特定の機械語に対応する文字列を使って表現します。機械語だけだと人間には解読しづらいですが、アセンブリ言語だと比較的理解しやすくなります。基本的な記述方法としては、一つの操作コード(オペコード)とその引数(オペランド)を組み合わせて使用します。
例えば、x86 assembly language の Intel 記法で「eax レジスタに 6 を足す」という操作は以下のようになります。この場合、add
がオペコード、eax
と6
がオペランドになります。
add eax,6 ;Intel記法
参考:https://www.cs.virginia.edu/~evans/cs216/guides/x86.html
一方、x86 assembly languageのAT&T記法で同じ操作を書いた場合は、以下のようになります。add
がオペコードであることは同じですが、オペランドの順番が逆になり、$6
、%eax
というように記法も異なります。
add $6,%eax ;AT&T記法
参考:https://flint.cs.yale.edu/cs421/papers/x86-asm/asm.html
また、ARM assembly languageでは足し算のオペコードをADD
と表現するなど、アセンブリ言語の種類によって書き方が変わります。ただし、オペコードとオペランドの基本的な概念を理解すれば、コードの雰囲気は掴めるはずです。
簡単なプログラムを書いて実行してみる
それでは実際に簡単なプログラムを書いて、アセンブリ言語の雰囲気を掴んでいきましょう。
実行環境など
今回は、アセンブリ言語として x86 assembly language、アセンブラとして NASM を使用します。x86 assembly language は、Intel の X86 アーキテクチャに基づくプロセッサ向けの低級プログラミング言語です。
コードの実行には、オンライン実行環境である Compiler Explorer(以下のリンク)を使用します。このサイトでは、他のアセンブリ言語やアセンブラも簡単に試すことができます。
Helloを出力してみる
以下のコードで「Hello」を出力できます。Compiler Explorer上での実行結果は以下の通りです。
SECTION .data
msg db 'Hello'
SECTION .text
global _start
_start:
mov eax, 4
mov ebx, 1
mov ecx, msg
mov edx, 5
int 80h
mov eax, 1
mov ebx, 0
int 80h
それでは順番に処理を解説していきます。
まず、.data
セクションで、msgという名前の変数を定義します。ここで「Hello」という5バイトのデータがメモリに格納され、msgがこのデータのメモリ上の開始位置(先頭アドレス)を表すラベルとなります。
SECTION .data
msg db 'Hello'
次に、.text
セクションでCPUによって実行されるコードを定義します。ここでプログラム全体から参照可能なグローバルラベルとして_start
を定義します。この_start
がプログラムのエントリーポイントとなります。
SECTION .text
global _start
_start
の処理内容は以下のように定義されています。eax、ebx、ecx、edxはそれぞれレジスタを表します。
_start:
mov eax, 4
mov ebx, 1
mov ecx, msg
mov edx, 5
int 80h
mov eax, 1
mov ebx, 0
int 80h
mov
命令はmov レジスタ, 値
のように記述し、値
をレジスタ
に移動させます。このコードでは、「Hello」の出力に必要な値を各レジスタに設定します。
mov eax, 4
mov ebx, 1
mov ecx, msg
mov edx, 5
続いて、int
命令を使って「Hello」の出力を行います。int
はint 割り込み番号
の形式で使用し、指定した割り込み番号
に対応する Linux の機能を呼び出します。今回のint 80h
は、Linux のシステムコールを呼び出します。
int 80h
システムコール実行時には、eax、ebx、ecx、edxの値が参照されます。eaxはシステムコール番号を表し、ebx、ecx、edxの意味はeaxの値によって変化します。
今回のコードでは、以下のように各レジスタを設定しています。
mov eax, 4 ;eaxに書き込み処理(sys_write)を表すシステムコール番号4を設定
mov ebx, 1 ;ebxに標準出力(STDOUT)を表す1を設定
mov ecx, msg ;ecxにmsg変数に格納されている「Hello」の先頭アドレスを設定
mov edx, 5 ;edxに出力する文字列の長さ(5バイト)を設定
int 80h ;システムコールを実行し、「Hello」を標準出力に出力
最後にプログラムを正常終了させます。eax にプログラム終了(sys_exit)を表すシステムコール番号 1 を設定し、ebx に正常終了を表す終了ステータス 0 を設定します。これらの値を設定した後、int 80h
でシステムコールを実行してプログラムを終了します。
mov eax, 1
mov ebx, 0
int 80h
Helloを反転して出力してみる
続いて、「Hello」を反転して出力してみましょう。
SECTION .data
msg db 'Hello', 0h
SECTION .text
global _start
_start:
mov esi, msg
push_loop:
cmp byte [esi], 0
je pop_and_print_loop
mov eax, esi
push eax
inc esi
jmp push_loop
pop_and_print_loop:
cmp esi, msg
je finish
pop eax
mov ecx, eax
mov eax, 4
mov ebx, 1
mov edx, 1
int 80h
dec esi
jmp pop_and_print_loop
finish:
mov eax, 1
mov ebx, 0
int 80h
このコードで「Hello」を反転した「olleH」を出力できます。Compiler Explorer上での実行結果は以下の通りです。
このプログラムでは、スタックを使用して文字列を反転しています。スタックは後入れ先出し(LIFO = Last In First Out)方式でデータを管理するメモリ構造です。スタックでは、データの追加を「プッシュ」、データの取り出しを「ポップ」と呼びます。
処理の流れとしては、「Hello」という文字列をH、e、l、l、oの順でスタックにプッシュし、その後、最後に追加された順序(o、l、l、e、H)でスタックからポップすることで、文字列を反転して出力します。
まず、.data
セクションでは、「Hello」の末尾に NULL 文字(0h)を追加し、合計 6 バイトのデータをメモリに格納します。msg はこのデータのメモリ上の開始位置を表すラベルとなります。
SECTION .data
msg db 'Hello', 0h
.text
セクションでは、先ほどと同様にプログラムのエントリーポイントとなる_start
を定義します。
SECTION .text
global _start
文字列のスタックへのプッシュ処理では、まず msg に格納されている文字列の先頭アドレスを esi レジスタに設定します。esi は文字列内の現在の位置を指すポインタとして機能し、初期状態では「H」のアドレスを指しています。
_start:
mov esi, msg
次に、文字列をスタックにプッシュする処理をまとめて、push_loop
というラベルを定義します。push_loop
では、まず esi が指しているアドレスの値と NULL 文字(0)をcmp
命令で比較します。比較結果が等しい場合、つまり esi が文字列末尾の NULL 文字を指している場合、je
命令によってpop_and_print_loop
へジャンプします。比較結果が等しくない場合は、以降の処理に進みます。
続く処理では、push
命令を使ってesiに格納されている現在の文字のアドレスをスタックにプッシュします。その後、inc
命令でesiの値を1増やし、次の文字のアドレスを指すようにします。最後に、jmp
命令でpush_loop
の先頭に戻り、「Hello」の全ての文字がスタックにプッシュされるまでこの処理を繰り返します。
push_loop:
cmp byte [esi], 0 ;esiが指している文字と0を比較。
je pop_and_print_loop ;NULL文字なら次のセクションへジャンプ
mov eax, esi ;現在の文字のアドレスをeaxに移動
push eax ;そのアドレスをスタックにプッシュ
inc esi ;次の文字へ進む
jmp push_loop ;push_loopの最初に戻る
次に、スタックから文字列をポップして出力する処理をpop_and_print_loop
というラベルで定義します。まずcmp
命令で esi の値と msg の値(文字列「Hello」の先頭アドレス)を比較し、等しい場合はje
命令でfinish
ラベルへジャンプして終了処理を行います。等しくない場合は、以降の処理に進みます。
pop
命令でスタックから文字のアドレスを取り出して eax に設定し、sys_write システムコールを使用してその文字を出力します。その後、dec
命令で esi の値を 1 減らし、前の文字位置に戻ります。この処理をpop_and_print_loop
の先頭に戻って繰り返します。
このループ処理により、スタックに積まれた文字が逆順(「olleH」)で出力されます。
pop_and_print_loop:
cmp esi, msg ;文字列の先頭に戻ったかチェック
je finish ;先頭なら終了処理finishへジャンプ
pop eax ;スタックから文字のアドレスを取り出す
mov ecx, eax ;ecxに、スタックから取り出した文字のアドレスを設定
mov eax, 4 ;eaxにsys_writeを表すシステムコール番号4を設定
mov ebx, 1 ;ebxにSTDOUT(標準出力)を表す1を設定
mov edx, 1 ;edxに出力する文字数(1文字)を設定
int 80h ;システムコールを実行
dec esi ;前の文字位置に戻る
jmp pop_and_print_loop ;pop_and_print_loopの最初に戻る
最後に、プログラムを正常終了させます。
finish:
mov eax, 1 ;eaxにsys_exitを表すシステムコール番号1を設定
mov ebx, 0 ;ebxに正常終了を表すプログラム終了ステータス0を設定
int 80h ;sys_exitを実行
最後に
文字列の出力や反転出力は高級言語を使えば数行で書ける処理ですが、アセンブリ言語では多くのステップが必要でした。実際にアセンブリ言語を使ってプログラミングすることで、高級言語が自動的に行ってくれているメモリ管理の複雑さや、高級言語の使いやすさを改めて実感できました。プログラミング言語の進化は偉大ですね。
参考
Discussion