C
Linux
GCC
assembly
systemcall

64bitのOS + C言語でライブラリを使わずにHello Worldをしてみた

この記事は、OthloTech Advent Calendar 2017 8日目の記事として書かれています。

昨日から2日連続で自分が担当しています。昨日はモバイルに関してでしたが、今日は打って変わってC言語でカーネルに近づいていきたいと思います。
具体的にはOSで定義されているシステムコールのみを利用してHello Worldをしていきます。

対象読者

  • Linuxカーネルに興味がある人。
  • システムコールに興味がある人。
  • Hello Worldの出し方が気になる人。
  • ArchLinuxが好きな人。

記事の内容

  • ライブラリを利用せずに文字列を出力します。
  • システムコールの使い方をかんたんにまとめます。

動作環境

  • OS Arch Linux x86_64
  • カーネルバージョン 4.14.3-1-ARCH
  • gcc 7.2.1
  • nasm 2.13.01
  • gdb 8.0.1

初めてのHello World

C言語を利用して最もかんたんだと思われるHello Worldを書いてみます。
学校での勉強等多くの人が書いたことがあるものです。

Hello.c
#include<stdio.h>

int main(){
  printf("Hello World!\n");
  return 0;
}

print formatを使って書いてみました。文字列を指定するだけで簡単に表示できます。
しかしながらこのprintfという関数はstdio.hをインクルードしないと動作しません。
いわゆるおまじないというやつです。中の動作はこれだけではいまいちよくわかりません。

コンパイルして実行ファイルがどんな感じなのか見てみます。gdbでデバックするので-gオプションをつけて、動的リンクはだるいので-staticつけています。

$ gcc Hello.c -static -g
$ gdb -q a.out
(gdb) break main
(gdb) layout asm
(gdb) run
(gdb) si
(gdb) si
     ...

それなりにstepしてみましたが、なかなか終わりません。終わるまでやる気も起きません
どんな風に動作しているのかよくわからないので、システムコールを列挙するコマンドのstraceを叩いてみました。

$ strace ./a.out
                                                 ~/hello
execve("./a.out", ["./a.out"], 0x7ffdd8a3ad60 /* 43 vars */) = 0
brk(NULL)                               = 0x1182000
brk(0x11831c0)                          = 0x11831c0
arch_prctl(ARCH_SET_FS, 0x1182880)      = 0
uname({sysname="Linux", nodename="Juju-62q", ...}) = 0
readlink("/proc/self/exe", "/home/(username)/hello/a.out", 4096) = 23
brk(0x11a41c0)                          = 0x11a41c0
brk(0x11a5000)                          = 0x11a5000
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
write(1, "Hello World\n", 12Hello World
)           = 12
exit_group(0)                           = ?
+++ exited with 0 +++

なんかメモリ確保したり色々しているようです。しかしwriteってなんか文字列を表示できそうな気がします。
次はこれを使って文字列を出してみます。

C言語でwrite関数をつかってみる

write関数を探してみると下記のように定義されていました。(/usr/include/unistd.h)
extern ssize_t write (int __fd, const void *__buf, size_t __n) __wur;
fdはファイルディスクリプタです。今回は標準出力に出力するので1となります。
bufは出力する内容で、nは文字数です。printfよりは不便そうですが不便ってことは便利にされていないということでなんか使っていないものが増えてそうです()
じゃあこれをインクルードしてみましょう。

Write.c
#include<unistd.h>

int main(){
  const void *string = "Hello Write!\n";
  write(1, string, 13);
  return 0;
}

標準出力に文字列を表示してみました。
同じように動作させてみます。

$ gcc Write.c -static -g
$ gdb -q a.out
(gdb) break main
(gdb) layout asm
(gdb) run
(gdb) si
(gdb) si
     ...

今度は割と早く文字の表示にたどり着きました。
どうやらsyscallを呼ぶと表示されています。
しかし#include<unistd.h>が気に入りません。
syscallで文字列が表示されるのなら設定をしてsyscallを呼べば良さそうなのでsyscallの使い方を調べてみました。
どうやら64bitCPUで利用されるレジスタのうち

rax = 1(writeシステムコールであることの指定)
rdi = 1(ファイルディスクリプタ、つまり標準出力なら1となる)
rsi = (文字列の先頭番地) (表示文字列の指定)
rdx = (文字列の長さ) (文字数)

としてsyscallを呼ぶと文字列が表示されるようです。
次はアセンブラからsyscallを直接呼び出してみましょう。そうすれば憎きunistd.hともおさらばできそうです。

下記参考にしています
http://blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64/

CPUのレジスタについてはこちら
https://software.intel.com/en-us/articles/introduction-to-x64-assembly

アセンブラでsyscallを呼んで見る

C言語で呼び出す際には少なくとも文字列と文字の長さは指定しないと上記の仕様は満たせなさそうなので引数を指定したいです。
64bitのアセンブラで引数を利用する際には次のようにするそうです。

rdi 1番目の引数
rsi 2番目の引数
rdi 3番目の引数
rcx 4番目の引数
r8 5番目の引数
r9 6番目の引数

参考
http://p.booklog.jp/book/34047/page/613687

今回は2つ引数があるのでrdi, rsiを利用します。そしてrsiに文字列の先頭番地を、rdxに文字数を、rax, rdiに1を格納します。
以上を踏まえてnasmのアセンブラを書いてみました。関数名はhelloとなっています。

syscall.asm
bits 64

global hello

hello:
  mov rdx, rsi
  mov rsi, rdi
  mov rax, 1
  mov rdi, 1
  syscall
  ret

アセンブラで作成した関数を利用してC言語のプログラムを書きました。

main.c
void hello(char *string, int len);

int main (){
  char *string = "Hello Asm!\n";
  hello(string, 11);
  return 0;
}

ついにインクルードがなくなりました!!!
C言語ではhelloのプロトタイプ宣言を行い、関数を実行しています。
コンパイルしてみます。今回は複数のファイルを接続するためオブジェクトファイルを生成し、リンクしています。

$ nasm -f elf64 -o syscall.o syscall.asm
$ gcc -c main.c
$ gcc main.o syscall.o
$ ./a.out
Hello Asm!

無事出力したい文字列が出力できました!
なお、nasmでは64bit用のオブジェクトファイルを出力するため-f elf64を指定しています。
無事ライブラリを利用せずにOSからのシステムコールだけでHello World(仮)ができました!
当然初歩の初歩なわけですが、少しOSの使い方がわかったような気がします。

追記:スタートアップルーチンに関して

関数呼び出しの際にmainの引数等を処理や終了時にreturnの処理をするスタートアップルーチンがライブラリから呼ばれている。と、コメントでご指摘をいただきました。

とりあえず、頂いたスタートアップルーチンを使わないプログラムをそのまま載せておきます。
自分の中に落とし込めたらまた更新させていただきます。

$ cat -n main.c
     1  void hello(const char*, int);
     2  void exit(int) __attribute__((noreturn));
     3
     4  int main(void){
     5    const char* string = "Hello Asm!\n";
     6    hello(string, __builtin_strlen(string));
     7    exit(0);
     8  }
$ cat -n syscall.asm
     1  bits 64
     2
     3  global hello
     4
     5  hello:
     6    mov rdx, rsi
     7    mov esi, edi
     8    mov eax, 1
     9    mov edi, 1
    10    syscall
    11    ret
    12
    13  global exit
    14
    15  exit:
    16    mov esi, edi
    17    mov eax, 60
    18    syscall
$ cat -n makefile
     1  target:
     2          nasm -f elf64 -o syscall.o syscall.asm
     3          gcc -O2 -Wall -Wextra main.c syscall.o -nostdlib -static -Wl,-Map=main.map -Wl,-emain
$ make
nasm -f elf64 -o syscall.o syscall.asm
gcc -O2 -Wall -Wextra main.c syscall.o -nostdlib -static -Wl,-Map=main.map -Wl,-emain
$ ls -l a.out
-rwxrwxrwx 1 user user 1504 Dec  9 00:00 a.out
$ ./a.out
Hello Asm!
$

まとめ

いかがだったでしょうか?
C言語がいかに高級な言語かわかっていただけたと思います。
本記事を呼んでシステムコールの面白さや奥深さに気づいていただけたらとっても嬉しいです。
OthloTechには低レイヤをやっている人が少ないので今後増えてほしいってのが個人的な希望です 笑
それでは皆さん、良いハックライフを!