今回は、表題の通り 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"
もくじ
- もくじ
- 下準備
- NASM / x86 で exit(2) を呼び出すだけのプログラム
- NASM / x86-64 で exit(2) を呼び出すだけのプログラム
- NASM / x86-64 で write(2) も呼んでハローワールドしてみる
- GAS / x86-64 で exit(2) を呼び出すだけのプログラム
- GAS でプリプロセッサを使う
- 参考
下準備
あらかじめ、使うパッケージをインストールしておく。
$ 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) の場合、システムコールを呼ぶには以下の手順が必要になる。
eax
レジスタに呼びたいシステムコールの識別子を代入する- 必要に応じて各レジスタにシステムコールの引数を代入する
- 割り込み番号
0x80
を指定してINT
命令を実行する
先ほどのサンプルコードの _start
では、各行が上記の (1) ~ (3) に対応している。
eax
レジスタにexit(2)
システムコールに対応する識別子の1
を代入しているexit(2)
システムコールの第一引数として、プログラムの返り値0
をebx
レジスタに代入している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, 1
が write(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
- 作者:もみじあめ
- 発売日: 2020/02/29
- メディア: Kindle版