MATHGRAM

主に数学とプログラミング、時々趣味について。

【低レベルプログラミング】アセンブリ言語【その2】

前回の続きです。

本記事では、前記事で書いたハローワールドを読み解くことを目標とします。 ほとんど、低レベルプログラミングを2章までのまとめに近い内容になってるはずです。 また、付属している設問にも回答していこうと思います。

レポジトリはここです。

github.com

目次

  1. 第1章 「コンピュータアーキテクチャの基礎」のまとめ
  2. ハローワールドの解説

1. 第1章 「コンピュータアーキテクチャの基礎」のまとめ

前回は、実行環境の構築のみで各専門用語の説明を全くしていませんでした。 解説に入る前に、ちょうど第 1 章の設問が用語のまとめになりそうなので、本記事ではそこから始めようと思います。 また書籍にある、全設問を記載しているわけではないのでご注意ください。

第1章の問題と回答

  1. フォン・ノイマンアーキテクチャの主な原則は?

    1章では、フォン・ノイマンアーキテクチャの主要な機能として以下が述べられている。

    • 0 と 1 で表されるビット(bit)という情報単位のみがメモリに保存される
    • 命令とデータが区別されることなくメモリに保存される
    • メモリはラベルによってインデックスが付与された、複数の cell によって組織化されている
    • 特別な命令をのぞいて、プログラムは逐次的にフェッチされる命令軍で構成されている
  2. レジスタとは?

    CPU に直接備わっているメモリセルのこと。 レジスタにより、CPU とメモリ間のデータ交換時に生じる CPU タイムを削減できる。

  3. ハードウェアスタックとは?

    2つのマシン語命令(push と pop)と1個のレジスタrsp)によって実装された、スタックを実現するエミュレーションのこと。

  4. 割り込みとは?

    外部イベントを基準としてプログラムの実行順序を変更すること。ゼロによる除算なども割り込みによって特別なルーチンを実行する。

  5. フォン・ノイマンのモデルの主な問題点で、現在の拡張が解決しているのは?

    • メモリへの問い合わせが必須だった問題をレジスタによって解決
    • 対話性がなかった問題を割り込みによって解決
    • コードを効果的に隔離できなかった問題をハードウェアスタックにより解決
    • プログラムがどんな命令でも実行できてしまう問題をプロテクションリングによって解決
    • プログラムそのものを互いに隔離できなかった問題を仮想メモリによって解決
  6. スタックポインタの目的は?

    ハードウェアスタックのもっとも上にある要素のアドレスを格納すること。

  7. スタックは空になるか?

    ならない。push していなくても pop は実行可能であり、何らかの値を返す。

  8. スタック内の要素は数えられるか?

    不可能。7. と同じ理由で pop は任意の回数実行できる。そのため要素数を数えることはできない。

以上が、第1章の問題と回答です。 知っている人にとってはかなり当たり前の内容だと思いますが、これで一旦用語が整理できました。 それではこれらの用語を使いつつ、前記事で扱ったハローワールドを紐解いて行きましょう。

2. ハローワールドの解説

まずはハローワールドを表示するアセンブリを再掲します。

section .data
message: db 'hello, world!', 10

section .text
global _start

_start:
  mov rax, 1
  mov rdi, 1
  mov rsi, message
  mov rdx, 14
  syscall

  mov rax, 60
  xor rdi, rdi
  syscall

それでは、まずそれぞれの記述が何を意味しているのかに注目して、上から順に紐解いてみましょう。

※ 注意: この書籍で扱っているアセンブリは NASM です。GAS とは異なるので注意してください!

2.1. 文法編

2.1.1. section

まずは1行目にあるsection から。

前節で説明したように、

命令とデータが区別されることなくメモリに保存される

というのがフォン・ノイマン型の主要な機能としてあげられます。 そのため、プログラマが命令とデータを簡易的に区別できるように用いられるのがセクションです。

1行目には

section .data

と記述されていますが、section .dataグローバル変数を記述するためのセクションであることを意味します。 一方、4行目にあるsection .textは命令を記述するセクションを意味します。

セクションは機械語コンパイルされず、コンパイル時の補助的な役割を担います。 このように直接機械語に変換されず、変換処理を制御する要素をディレクティブと呼びます。

2.1.2. label

次に2行目

message: db 'hello, world!', 10

で使われている message: について。これはラベルと呼ばれます。 ラベルを用いることで、プログラマがわかりやすい名前をアドレス値に付与することができます。

高級言語の変数に似ている概念ですが、アセンブリでは変数や手続きが厳密に区別されないため、ラベルという言葉を用いるのが一般的だそうです。

参考: NASM Manual: Layout of a NASM Source Line

また、_startもラベルです。 アセンブリは複数のファイルに分けて書くことが可能ですが、どこの処理から始めるかを宣言して上げる必要があります。その時に用いられるのがこの_startラベルです。

main()関数みたいなものですかね。

2.1.3. db

同じく二行目のdbについて。これも、section と同様にディレクティブの一種です。 db ディレクティブはバイトデータを初期化するために用いられます。

つまり、以下のように記述することで、文字列hello, world!に対応する ASCII コードと、改行を示す特殊コードの10が、messsageラベルに格納されます。

message: db 'hello, world!', 10

db の他にもワードデータを初期化するためのdwやダブルワードを初期化するためのddなどが存在します。 詳しくは以下を参照してください。

参考: NASM Manual: DB and Friends: Declaring Initialized Data

2.1.4. global

次に、5 行目

global _start

にあるglobalです。 globalsectiondbと同じくディレクティブであり、プログラムの実行を開始するアドレスを指定します。 _startはラベルであり、8行目以下の命令群が格納されています。

つまりこのプログラムは_startの先頭に記述されているmov rax, 1から実行されることを意味します。

2.2. 命令編

ここまでの解説で、とりあえず各コマンドの意味は掴めてきたと思います。

次に、プログラムの主役である、命令部分を詳しく見て行きましょう。

命令部分のみを再掲します。

mov rax, 1
mov rdi, 1
mov rsi, message
mov rdx, 14
syscall

mov rax, 60
xor rdi, rdi
syscall

文法編と同様に一つずつ紐解いて行きます。

2.2.1 mov

mov とは、ある値をレジスタかメモリに書き込むために用いる命令です。 高級言語で「代入」として扱っている操作が近いです。

mov には以下のルールが存在します。

  • メモリからメモリへの移動はできない。
  • 移動元と移動先のオペランドのサイズが同じでなければならない。

mov を使って、システムコールやラベルなどが示す様々な値をレジスタに格納します。

2.2.2. syscall

syscall とは、*nix システムでシステムコールを実行するために用いる命令です。システムコールには様々な種類が存在し、一意に値が定義されています。

上のプログラムでは以下の行の1, 60システムコールを表しています。

mov rax, 1
mov rax, 60

1write60exitを意味しており、mov によって、raxレジスタに格納されています。

どちらもraxレジスタに値を格納しているのは、システムコールを実行するために以下の手順を踏む必要があるからです。

  1. rax レジスタシステムコールの番号を入れる
  2. システムコールが使用する引数は、rdi,rsi,rdx,r10,r8,r9のいずれかに格納する(これ以上の引数、つまり6個以上の引数を受け取ることはできない)
  3. syscall命令を実行する。

もう一度ハローワールドのプログラムを見てみましょう。

mov rax, 1 ; raxレジスタにwriteを格納
mov rdi, 1 ; rdiレジスタに1つ目の引数として、1を格納
mov rsi, message ; rsiに2つ目の引数として、messageを格納
mov rdx, 14 ; rdxに3目の引数として、14を格納
syscall ; syscallを実行

以上のように、システムコールを実行する手順をちゃんと踏んでいたことがわかると思います。

また、ここで linux の write システムコールのドキュメントを読んでみます。 https://linuxjm.osdn.jp/html/LDP_man-pages/man2/write.2.html

ssize_t write(int fd, const void *buf, size_t count);
write() は、 buf が指すバッファーから、ファイルディスクリプター fd が参照するファイルへ、最大 count バイトを書き込む。

このドキュメントの通りに先ほどのプログラムをみてみると、以下の操作を行なっていたことがわかります。

  • 1つ目の引数でファイルディスクリプタを指定
  • 2つ目の引数でバッファのアドレス(書き込むバイト列の先頭の値)を指定
  • 3つ目の引数で書き込むバイト数を指定

このプログラムでファイルディスクリプタ1を指定していますが、これはstdoutを示すもので「hello, world!」をターミナルに表示するための命令になります。

2.3 まとめ

それではここまで解説したことを念頭におきながらもう一度、プログラムを眺めてみます。

; .dataセクション。以下にglobal変数を定義することを宣言
section .data
; dbディレクションを用いて、
; 'hello, world!'と改行文字を示す10のバイト列を、
; messageラベルとして定義
message: db 'hello, world!', 10

; .textセクション。以下に命令を記述することを宣言
section .text
; _startラベルをgloabalディレクティブで宣言
global _start

; _startラベルとして以下の命令群を定義
_start:
  mov rax, 1 ; システムコールwriteをraxに格納
  mov rdi, 1 ; ファイルディスクリプタの値をrdiに格納
  mov rsi, message ; messageラベルの中身をrsiに格納
  mov rdx, 14 ; バイト数をrdxに格納
  syscall ; システムコールを実行

  mov rax, 60 ; システムコールexitを60に格納
  xor rdi, rdi ; 同値のxorをとってrdiの値を0に
  syscall ; システムコールを実行

うるさいくらいコメントを記述しましたが、これでわからない部分がなくなりました。

まとめ

前記事で記述したハローワールドのアセンブリを解説しました。

前回の記事で更新してなかったら死んでるとかどうの言ってましたが、生きてました。