koturnの日記

転職したい社会人1年生の技術系日記

不思議なHello World

背景

最近になって,x64をちゃんと勉強したので,少しだけひねったHello WorldC言語で書いてみることにした. (※x64のLinux環境でしか動作しない)

const char main[] = "\x48\xc7\xc0\x01\x00\x00\x00\xba\x0d\x00\x00\x00\xbf\x01\x00\x00\x00\xe8\x0c\x00\x00\x00\x48\x81\xc6\x11\x00\x00\x00\x0f\x05\x31\xc0\xc3\x48\x8b\x34\x24\xc3Hello World!\n";

何とワンライナーである. ヘッダのインクルードが無く,main関数ではなく,main配列となっている点が通常のHello Worldと比較すると異質な点であろう.

ワンライナーではわかりづらいので,コメントを入れてみる. 要は機械語である.

const char main[] =
  // mov $0x01,%rax
  "\x48\xc7\xc0\x01\x00\x00\x00"
  // mov $0x0d,%edx
  "\xba\x0d\x00\x00\x00"
  // mov $0x01,%edi
  "\xbf\x01\x00\x00\x00"
  // callq 0x0c
  "\xe8\x0c\x00\x00\x00"
  // add $0x11,%rsi
  "\x48\x81\xc6\x11\x00\x00\x00"
  // syscall
  "\x0f\x05"
  // xor %eax,%eax
  "\x31\xc0"
  // ret
  "\xc3"
  // mov (%rsp),%rsi
  "\x48\x8b\x34\x24"
  // ret
  "\xc3"
  // String data
  "Hello World!\n";

先にタネ明かしをすると,以下を行っているだけにすぎない.

// 第1引数:ファイルディスクリプタ (1: stdout)
// 第2引数:文字列領域へのポインタ
// 第3引数:出力文字数
write(1, "Hello World!\n", 13);

このことを念頭に置き,解説を行っていく.

解説

全体像

mainというのは所詮 crt*.o 中から呼び出しされているに過ぎない. 従って,main という名前の指し示すアドレスの先に関数と同様のデータがあれば問題はない.

基本的に1つの配列に納めたかったので,コードとデータを同一の領域に置いてある. 別々にした場合,データのアドレスを取得するのが面倒になるためだ. (引数でデータのポインタを渡す等の処理が必要になるが,今回はmainを作っているため難しい)

配列 main 中の "Hello World!\n" より前は実行するコードであり,"Hello World!\n" は当然データである. 後は,コード中のいずれかの位置で,その地点のアドレスを取得し, "Hello World!\n" 領域のアドレスを取得すればよい.

システムコール

x64におけるシステムコールでは各レジスタが以下のように役割を果たす. 今回利用するのは第三引数まで.

レジスタ 役割
rax システムコール番号
rdi システムコールの第1引数
rsi システムコールの第2引数
rdx システムコールの第3引数

今回利用する write システムコールC言語APIは以下の通り.

size_t write(int fd, const void *buf, size_t count)

引数 役割
fd ファイルディスクリプタ
buf 出力データのサイズ
count 出力文字数

以上より,レジスタを以下の状態にすることを目指す.

レジスタ 中身
rax 1 (writeシステムコールの番号は1)
rdi 1 (stdoutは1)
rsi Hello Worldへのアドレス
rdx 13 (“Hello World!\n” の文字数は13)

現在位置の取得

Hello World! 文字列アドレスの取得のために,実行しているコードの位置を取得する必要がある. 現在位置はレジスタripに入っているが,直接値を取り出すことはできない. したがって,

  1. call命令を利用し,rspレジスタの管理アドレスにripの値を書き込ませる.
  2. rspが書き込んだ領域からripを取得(mov (%rsp),%rsi

という手段で取得する.

コードまとめ

ここまでの話をまとめると,配列の中身は以下の通りになる.

命令 動作
mov $0x01,%rax raxに1を格納
mov $0x0d,%edx rdxの下位32bitに0x0d(0x14)をセット
mov $0x01,%edi rdiの下位32bitに0x01をセット
callq 0x0c 12bbyte先を関数として呼び出し
add $0x11,%rsi 0x0fをrsiに加算.この命令位置から17byte先が "Hello World!\n"
syscall システムコール
xor %eax,%eax mainの返り値を0に設定(eaxは関数の返り値)
ret main関数のreturn
(%rsp),%rsi 呼出元のアドレスをrsiに格納
ret 呼出位置アドレス取得関数のreturn(add $0x0f,%rsiに復帰)
"Hwllo World!\n" 文字列データ

その他いろいろ

通常,関数の返り値はraxに格納するものだが,面倒なので今回は直接rsiに放り込むという手段を取っている. 自分でコードを書く分には,x64の呼出規約を無視しても構わないだろう.

まとめ

この記事では,少し奇妙な形のC言語Hello Worldを紹介した. 一見,難解に見えるHello Worldのコードであるが,わかってしまえば大したことはない.

今回はx64用のコードを紹介した. なので,Wandboxに貼り付ければ実行できる.

x86については読者の課題としよう(笑)