📌

アセンブリ言語に触る

2024/11/07に公開

私はコンピューターサイエンスのバックグラウンドがないままJavaScript からプログラミングを始めました。業務でプログラムを書く中で、分からないことに出会うたびに、その背景にあるコンピューターの動作原理に興味を持つようになりました。最近、CPU 周りの基礎を学ぶ機会があったため、今回の記事ではアセンブリ言語を使って簡単なプログラムを書いてみたいと思います。低級言語に触れたことがない方が、その雰囲気を掴むきっかけになれば嬉しいです!

前提知識

プログラミング言語の分類

プログラミング言語は、高級言語と低級言語の二つに大きく分類することができます。それぞれの特徴は以下の通りです。

  • 高級言語
    • C、C++、Ruby、JavaScript など
    • 人間が理解しやすい記述方式
    • 書き方は特定の CPU アーキテクチャ(ISA)に依存しない
  • 低級言語
    • アセンブリ言語や機械語など
    • 高級言語と比べてコンパイルプロセスが少ないため実行時のパフォーマンスが良い
    • 高級言語よりも人間にとって理解しづらい
    • 書き方は特定の CPU アーキテクチャ(ISA)に依存する

プログラムはどのように実行されるのか

CPUは0と1で書かれた機械語を処理します。そのため、エンジニアが作成した高級言語のプログラムを実行するには、機械語に翻訳する必要があります。この翻訳プロセスは、高級言語 → アセンブリ言語 → 機械語の順で実行されます。

高級言語からアセンブリ言語への翻訳はコンパイルというプロセスで、これはコンパイラというプログラムが実行します。また、アセンブリ言語から機械語への翻訳はアセンブルというプロセスで、これはアセンブラというプログラムが実行します。

アセンブリ言語について

アセンブリ言語では、0 と 1 で書かれた機械語をaddなどの特定の機械語に対応する文字列を使って表現します。機械語だけだと人間には解読しづらいですが、アセンブリ言語だと比較的理解しやすくなります。基本的な記述方法としては、一つの操作コード(オペコード)とその引数(オペランド)を組み合わせて使用します。

例えば、x86 assembly language の Intel 記法で「eax レジスタに 6 を足す」という操作は以下のようになります。この場合、addがオペコード、eax6がオペランドになります。

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(以下のリンク)を使用します。このサイトでは、他のアセンブリ言語やアセンブラも簡単に試すことができます。

https://godbolt.org/

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

Helloの出力

それでは順番に処理を解説していきます。
まず、.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」の出力を行います。intint 割り込み番号の形式で使用し、指定した割り込み番号に対応する 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上での実行結果は以下の通りです。

olleHの出力

このプログラムでは、スタックを使用して文字列を反転しています。スタックは後入れ先出し(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を実行

最後に

文字列の出力や反転出力は高級言語を使えば数行で書ける処理ですが、アセンブリ言語では多くのステップが必要でした。実際にアセンブリ言語を使ってプログラミングすることで、高級言語が自動的に行ってくれているメモリ管理の複雑さや、高級言語の使いやすさを改めて実感できました。プログラミング言語の進化は偉大ですね。

参考

https://gihyo.jp/dev/serial/01/ll-future/0002#:~:text=低級言語と高級言語というのはプログラミング言語,のことを言います。

https://www.baeldung.com/cs/compiler-linker-assembler-loader

https://godbolt.org/

https://asmtutor.com/

https://flint.cs.yale.edu/cs421/papers/x86-asm/asm.html

https://www.cs.virginia.edu/~evans/cs216/guides/x86.html

Linc'well, inc.

Discussion