CUBE SUGAR CONTAINER

技術系のこと書きます。

GNU/Linux (x86/x86-64) のシステムコールをアセンブラから呼んでみる

今回は、表題の通り x86/x86-64 の GNU/Linux でシステムコールをアセンブラから呼んでみる。 システムコールは、OS (ディストリビューション) のコアとなるカーネルがユーザ空間のプログラムに向けて提供しているインターフェースのこと。

なお、アセンブラの実装に関しては以下の二つを試した。

  • NASM (Netwide Assembler)
  • GAS (GNU Assembler)

アセンブラには INTEL 記法と AT&T 記法という二つのシンタックスがある。 NASM はデフォルトで INTEL 記法を、GAS はデフォルトで AT&T 記法を使うことになる。

使った環境は次の通り。

$ uname -sr
Linux 4.15.0-65-generic
$ nasm -v
NASM version 2.13.02
$ as -v
GNU assembler version 2.30 (x86_64-linux-gnu) using BFD version (GNU Binutils for Ubuntu) 2.30
$ ld -v
GNU ld (GNU Binutils for Ubuntu) 2.30
$ gcc --version
gcc (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"

もくじ

下準備

あらかじめ、使うパッケージをインストールしておく。

$ sudo apt -y install nasm binutils gcc

NASM / x86 で exit(2) を呼び出すだけのプログラム

まずは、ハローワールドですらなく「終了するだけ」のプログラムから書いてみる。 GNU/Linux には、プログラムを終了するためのシステムコールとして exit(2) がある。

以下のサンプルコードは NASM (Netwide Assembler) 向けの x86 アセンブラで Linux の exit(2) を呼び出すだけのプログラムとなっている。 ちょっとゴチャゴチャしてるけど ; はコメントで、決まりきった内容も含むので実質的には 3 行しかない。

global _start

section .text

_start:
  ; exit system call (Linux / x86)
  mov eax, 1  ; system call id
  mov ebx, 0  ; return code
  int 0x80  ; soft interrupt

まず、global _start_start というシンボルを外部に公開することを示している。 そして、シンボル _start: 以下が実際の処理になっている。 section .text については、プログラムのテキスト領域がここからありますよ、という指示になっている。

そして、Linux / x86 (32bit) の場合、システムコールを呼ぶには以下の手順が必要になる。

  1. eax レジスタに呼びたいシステムコールの識別子を代入する
  2. 必要に応じて各レジスタにシステムコールの引数を代入する
  3. 割り込み番号 0x80 を指定して INT 命令を実行する

先ほどのサンプルコードの _start では、各行が上記の (1) ~ (3) に対応している。

  1. eax レジスタに exit(2) システムコールに対応する識別子の 1 を代入している
  2. exit(2) システムコールの第一引数として、プログラムの返り値 0ebx レジスタに代入している
  3. int 0x80 を呼ぶことで、ソフトウェア割り込み経由でシステムコールを呼び出している

x86 (32bit) 向けにビルドする

それでは、実際にサンプルコードをビルドしてみる。 今どきそうそう必要になることはないだろうけど、まずは x86 (32bit) 向けのバイナリにしてみよう。

nasm コマンドを使ってソースコードをコンパイル…じゃなくてアセンブルする。 出力フォーマットには 32bit 版の ELF (Executable and Linkable Format) を指定する。

$ nasm -f elf32 nop.asm

これで、32bit 版の再配置可能オブジェクトファイルができた。

$ file nop.o 
nop.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped

リンカを使って上記を実行可能オブジェクトファイルにする。

$ ld -m elf_i386 -o nop nop.o
$ file nop
nop: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped

ちなみに、エントリポイントとなるシンボルを指定したいときは -e オプションを使えば良い。 デフォルトでは _start が使われるので、先ほどのサンプルコードを使っている限り特に指定する必要はない。

できあがった実行可能オブジェクトファイルを実際に実行してみよう。

$ ./nop

何も起こらないけど、そもそも何もエラーにならずプログラムが終了している、という点が上手くいっていることを示している。

プログラムの返り値を確認しても非ゼロなので正常終了している。

$ echo $?
0

x86-64 (64bit) 向けにビルドする

x86-64 (64bit) アーキテクチャは x86 (32bit) アーキテクチャと後方互換性がある。 なので、先ほどの x86 向けのアセンブラは x86-64 向けにもビルドできる。

実際に、x86-64 向けにビルドしてみよう。 今度は 64bit 版の ELF としてアセンブルする。

$ nasm -f elf64 nop.asm
$ file nop.o
nop.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

そして、リンカを使って実行可能オブジェクトファイルにリンクする。

$ ld -m elf_x86_64 -o nop nop.o
$ file nop
nop: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped

できたファイルを実行してみよう。

$ ./nop

こちらも、エラーにならずちゃんとプログラムが終了している。

返り値についても非ゼロなので正常終了しているようだ。

$ echo $?
0

NASM / x86-64 で exit(2) を呼び出すだけのプログラム

次こそはハローワールド…の前に、x86-64 アセンブラを扱っておく。 内容は先ほどと同じ exit(2) を呼び出すだけのプログラム。 純粋にシステムコールの呼び出し方の違いが分かりやすいはず。

以下が NASM 向けの x86-64 アセンブラで Linux の exit(2) を呼び出すだけのサンプルコード。 変わっているのは _start の中だけ。

global _start

section .text

_start:
  ; exit system call (Linux / x86-64)
  mov rax, 60  ; system call id
  mov rdi, 0  ; return code
  syscall  ; system call op code

x86 アセンブラとの違いを見ていくと、まず最初の 2 行で使うレジスタが異なっていることがわかる。 これは、x86-64 でレジスタが 64bit に拡張されたことに伴って名前が変わっている。

また、exit(2) システムコールを表す識別子も 1 から 60 に変わっていることがわかる。 この識別子はどこにあるの、という話なんだけど以下のように探せる。

$ locate syscalls | grep 32.h
/usr/src/linux-headers-4.15.0-62/arch/sh/include/asm/syscalls_32.h
/usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/.syscalls_32.h.cmd
/usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/syscalls_32.h
$ locate syscalls | grep 64.h
/usr/src/linux-headers-4.15.0-62/arch/sh/include/asm/syscalls_64.h
/usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/.syscalls_64.h.cmd
/usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/syscalls_64.h
$ grep sys_exit /usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/syscalls_32.h
__SYSCALL_I386(1, sys_exit, )
__SYSCALL_I386(252, sys_exit_group, )
$ grep sys_exit /usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/syscalls_64.h
__SYSCALL_64(60, sys_exit, )
__SYSCALL_64(231, sys_exit_group, )

ソースコードにあるテーブルを見ても良いかも。

linux/syscall_32.tbl at v4.15 · torvalds/linux · GitHub

linux/syscall_64.tbl at v4.15 · torvalds/linux · GitHub

そして 3 行目が決定的な違いで、x86-64 アセンブラでは syscall 命令という専用のオペコードが用意されている。

それでは、上記をビルドして実行してみよう。 x86-64 のアセンブラは x86 アーキテクチャでは動かないので、x86-64 向けにのみビルドする。

まずは NASM で x86-64 向けにアセンブリする。

$ nasm -f elf64 nop.asm
$ file nop.o
nop.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

リンクして実行可能オブジェクトファイルにする。

$ ld -m elf_x86_64 -o nop nop.o
$ file nop
nop: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped

次の通り、ちゃんと動くことがわかる。

$ ./nop
$ echo $?
0

NASM / x86-64 で write(2) も呼んでハローワールドしてみる

次は呼び出すシステムコールを増やしてコンソールの標準出力にハローワールドを出してみよう。 変わるのは、出力する文字列を扱うことになるのと、write(2) システムコールを呼び出すところ。

以下がコンソールに Hello, World! と出力するサンプルコードになる。 新たに増えた .data のセクションはプログラムのデータ領域を示している。 ただし、今回の本題はシステムコールなので、ここでは深入りしない。

global _start

section .data
  msg db 'Hello, World!', 0x0A  ; 0x0A = \n
  msg_len equ $ - msg

section .text

_start:
  ; write system call (Linux / x86-64)
  mov rax, 1
  mov rdi, 1
  mov rsi, msg
  mov rdx, msg_len
  syscall

  ; exit system call (Linux / x86-64)
  mov     rax, 60
  mov     rdi, 0
  syscall

上記のサンプルコードは先ほどに比べると syscall の呼び出しが増えている。 増えているのは write(2) の呼び出しで、これはファイルディスクリプタへのデータの書き込みをするシステムコールになっている。 mov rax, 1write(2) の識別子を rax レジスタに代入していて、その後ろがシステムコールの引数となっている。 rdi に代入している 1 は、標準出力のファイルディスクリプタの番号を示している。 rsi に代入している msg は出力する文字列のアドレスで、rdx に代入している msg_len が長さを示している。

それでは、上記を実際にビルドして実行してみよう。 先ほどと同じように実行可能オブジェクトファイルを作る。

$ nasm -f elf64 greet.asm
$ ld -m elf_x86_64 -o greet greet.o

できたファイルを実行すると、ちゃんと文字列が標準出力に表示される。

$ ./greet 
Hello, World!
$ echo $?
0

GAS / x86-64 で exit(2) を呼び出すだけのプログラム

続いては GAS (GNU Assembler) 向けのアセンブラを書いてみる。 書いていることは同じだけど記法が若干異なる。 NASM は INTEL 記法だったけど、GAS はデフォルトで AT&T 記法が使われる。 違いは、オペランドを書く順番が逆だったり、値やレジスタに記号が必要だったりする。

以下が AT&T 記法で書いた GAS 向けの exit(2) だけを呼び出す x86-64 アセンブラのサンプルコード。

.global _start

.text

_start:
  # exit system call (Linux / x86-64)
  mov $60, %rax
  mov $00, %rdi
  syscall

上記を as でアセンブルする。

$ as -o nop.o nop.s
$ file nop.o
nop.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

リンクして実行可能オブジェクトファイルにする。

$ ld -o nop nop.o
$ file nop
nop: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped_
$ ./nop
$ echo $?
0

GAS でプリプロセッサを使う

ちなみに GAS の場合はアセンブルに gcc を使うとプリプロセッサが使えるようだ。 プリプロセッサが使えると、例えばヘッダファイルを読み込んでシンボルを解決できたりする。

以下は GAS / x86-64 のハローワールドだけど、ヘッダとして <sys/syscall.h> を読み込んでいる。 ヘッダに含まれるシンボルはシステムコールを呼び出すところで $SYS_* という風に使っている。

#include <sys/syscall.h>

.global _start

.data
  message:
    .ascii "Hello, World!\n"
  message_len = . - message

.text

_start:
  # write system call
  mov $SYS_write, %rax
  mov $1, %rdi
  mov $message, %rsi
  mov $message_len, %rdx
  syscall

  # exit system call
  mov $SYS_exit, %rax
  mov $0, %rdi
  syscall

ファイル名の拡張子を大文字の .S にして gcc でアセンブルする。

$ gcc -c greet.S
$ ld -o greet greet.o

できたファイルを実行してみよう。

$ ./greet 
Hello, World!
$ echo $?
0

うまく動いているようだ。

参考

https://blog.packagecloud.io/eng/2016/04/05/the-definitive-guide-to-linux-system-calls/blog.packagecloud.io