Cでhello, world(詳細解説)

2025/01/30

切腹倶楽部のCでhello, worldをようやくしっかりと読んだ。

ソースコード

無断てんのりをば。怒られたら消す。

print(s,d) void*s,*d; {
  __asm__(".quad 0xcfff48aef2fcffb1" : "=D"(d) : "D"(s), "a"(0));
  __asm__("syscall" :: "a"(1), "D"(1), "S"(s), "d"(d-s));
}
 
main() {
  print("hello, world\n");
  return 0;
}

解説

(原文ママ)

  mov   $0xff,%cl
  cld
  repne scasb
  dec   %rdi

とっかかり

このコードを見たとき、最初は何もわからなかった。解説が何をしているのかもわからなかった。そもそもC言語で:が使われている記法なんて知らないので本当に何もわからなかった。

更に言えばK&R的な昔懐かしいような関数定義も、軽く存在を知っていただけで書いたことはなく、どうして引数にdがあるにも関わらず引数は文字列のみなのか?という疑問から始まった。

詳細な解説

現代語に書き下す

void print(void* s){
  void* d;
  __asm__(".quad 0xcfff48aef2fcffb1" : "=D"(d) : "D"(s), "a"(0));
  __asm__("syscall" :: "a"(1), "D"(1), "S"(s), "d"(d-s));
}
 
int main() {
  print("hello, world\n");
  return 0;
}

K&Rの表記では引数は必須ではないらしく、原文では単なるdの初期化のためだけに引数として書かれているらしい(?)

後述するとこのdは文字列の終端文字のアドレスを指しているのが、引数で渡したとしても結局上書きされてしまうので引数として定義する意味はよくわからなかった。

void*ポインタ

私も知ったのは最近のことだが、void型のポインタというのは任意の型を取りうるポインタである。

Additional C features

malloc等の返り型にもなっており、実質的にCのanyである。

GCCインラインアセンブリ記法

世の常だが記号というのはGooglabilityが非常に低い。どうにかたどり着いたのが「GCC インラインアセンブリ」という構文であった。

上記のサイトではasm volatile()という記法が紹介されており、__asm__についても言及されている。(残念ながら上記サイトの参考文献はリンク切れしているのだが)

この記法について要約すると以下の通りになる。

void* output;
void* input1, input2;
__asm__(
    "hoge" // ここにアセンブリを書く
    : "=r"(output) // 最終的な出力となるレジスタ
    : "r"(input1), "r"(input2) // 入力として使うレジスタ
)

:ではレジスタとCの変数を結びつけ(Vueのv-bind的な)ている。正確には、コンパイラに使用するレジスタを教えてあげることで安全に利用できるようにするらしい。

指定形式は"レジスタの種類"(C変数)で行い、レジスタの種類は以下に規定されている。この種類についての情報が日本語ではほぼ存在しておらず苦しかった。

GCCインラインアセンブラの使い方 - bamboo’s blog

こちらのサイトでは日本語で説明を行っているが、この一覧は完全なものではない。現に山Dが利用しているDについては書かれていない。

GCC-Inline-Assembly-HOWTO

完全な一覧はこちらにかかれている。以下にその表を転載する。(怒られポイント+1)

記号レジスタ
aRAX, EAX, AX, AL
bRBX, EBX, BX, BL
cRCX, ECX, CX, CL
dRDX, EDX, DX, DL
SRSI, ESI, SI
DRDI,EDI, DI

更に、私はアセンブリ初心者なのでRAX,EAX,AX, ALの違いがわからなかったがこれらはサイズの違いだけで同じ用途として利用されていることがわかった。(Wikipedia参照)

RAXは64bit、EAXは32bit、AXは16bit、ALは8bitであるらしい。

GCC Inline Assembler

こちらのサイトにかかれている通り、asm__asm__は基本的には同一である。しかしANCIなC言語で書いている場合asmは予約後になっているらしく、__asm__を使用する必要がある。

quadについて

インライン記法については理解できたものの、相変わらずquadはわからない。これは64bitの数値を定義するものなので、最初はなにかの外部ライブラリのメモリアドレスを定義しているのだと思った。

しかしこのアドレスで検索しても出てくるのは切腹倶楽部のみという、非常に絶望的な状態であったため何かアドレスではない独自の意味合いなのではないかと考え始めた。

Google先生の絶望的な回答

山D先生に質問してみるものの、返答は「ちゃんと解説書いてるよ(鼻ほじ)」「いつからqword幅の16進数がアドレスだと勘違いしていた!」という感動的なスパルタ方式だったため諦めて調査を続行。

以前にMWS CupというCTFに参加するために勉強したGhidraでコンパイルされたバイナリを解析することで何かヒントが得られないかと、何気なく検索をかけたところビンゴ!

Ghidra先生の有り難いお言葉

quadで定義されているのはリトルエンディアンで逆向きになったマシン語そのものだったらしい。かなりご丁寧にデコンパイルしたCコードまで付けてくれている。

というものの、デコンパイルされたCコードには見たことのない変数が多く定義されておりかなり意味不明なので、アセンブリから地道に読んでいくことにした。

同時に、意味が完全に不明だった解説もようやく理解した。この難読化されたquadに書かれているアセンブリのことだったのだ。

1つめの__asm__の意味

改めて山D先生のコードを掲載する。

__asm__(".quad 0xcfff48aef2fcffb1" : "=D"(d) : "D"(s), "a"(0));

ここまでの調べでquadにかかれている実態は以下のアセンブリであることがわかった。また、実行結果がRDIレジスタに格納され、それをvoid* dが受け取ることもわかった。

そしてこのコードは何かしらの関数であり、引数?としてRDIに文字列のアドレス(void* s)を渡しておりRAX=0で初期化していることもわかった。

更に、Ghidraのデコンパイルをなんとなく眺める限りでは、\0を探しているようなことが書かれている。

ここまで判明するのに2時間ほどかかった気がする。で、そのアセンブリの実態は以下である。

b1 ff           mov cl,0xff
fc              cld
f2 ae           repnz scas al,BYTE PTR es:[rdi]
48 ff cf        dec rdi

ということで、これを擬似コードに書き起こしてみる。

// 以下はインラインアセンブリによる初期化
rdi=s // "D"(s)
al=0 // "a"(0)
 
ecx=256 // mov cl, 0xff
df=0 // cld
 
// repnz scas al, BYTE PTR es:[rdi]
while (ecx != 0){
  zf = (al == *(char *)rdi);
  rdi++ // dfが0なので左から右に読んでいく
  ecx--
  if (zfF) break;
}
rdi-- // dec rdi

実のところ、f2 aeの部分の理解が怪しい。 whileecxのデクリメント、zfに応じたループの終了を担当しているのがrepneで、 scacbalとの比較やrdiのインクリメント、zfの変更を行っているという認識である。

このあたりが最もよくわからず、理解に時間がかかった。最終的に先輩が以下の資料を見つけてくれたので解決できた。

x86 - REPNZ SCAS Assembly Instruction Specifics - Stack Overflow

要約すると、alに入っている文字コードが見つかるまでrdiをインクリメントさせている。

今回の場合は"a"(0)により0が入っているので、ヌル文字を見つけていることになる。

この時点でdにはヌル文字のあるアドレスが格納されている。この後、文字列の末尾を示すdは出力対象の文字列の長さを取得するために利用されるのだが、長さにヌル文字は不要である。

したがって、ヌル文字分を除外し、正しい文字列長へ調整を行うのが最後のdec rdiである。

余談1 dec rdiを削除してみる

この検証を確認するために、dec rdiを削除して出力の実験を行った。

dec rdiを消してみる

\0まで出力されていることがわかる。

おそらく

あと,NULLまでwriteしてたのも修正した。

と言っているのはこれ。

2つめの__asm__の意味

こちらのアセンブリ文はかなりすぐに解決できた。

__asm__("syscall" :: "a"(1), "D"(1), "S"(s), "d"(d-s));

(Googlabilityの高い)syscallという明確なキーワードが非常に助かる。

アセンブリでシステムコールを呼び出す方法はかなりすぐに見つけることができる。

Syscall Number for x86-64 linux (A)

raxにシステムコール番号、その後引数を順番にrdi,rsi,rdxに設定していく。

Linuxカーネルに見る、システムコール番号と引数、システムコール・ラッパーとは:main()関数の前には何があるのか(7)(1/2 ページ) - @IT

今回はターミナルに出力を行うので、raxwriteのシステムコール番号1を指定("a"(a))。

続いてrdiではファイルディスクリプタを指定。"D"(1)であるため、/dev/fd/1すなわち標準出力に対応している。 rsiにはバッファの指定を行う。今回の場合はvoid* sのアドレスの指定を行う。 rdxは出力する長さの指定を行う。sdには文字列の先頭と末尾のアドレスが指定されているため、d-sで文字列長を取得できる。

以上の引数の指定によってシステムコールを発行し出力を行える。

リトルエンディアンのありがたみ

Javaの仮想マシンはビッグエンディアンだとか、Armはどっちでもいけるだとか、色々話を聞きますが結局どういうときにどっちがどう嬉しいのかがわからない。

単にプロトコル(アーキテクチャ)の違いでしか無いんだろうか。

quadで定義した数値が実行される理由

これ調べてもよくわからなくて山Dに聞きました。quadは単に64bitの数値を埋め込めるという理解で正しいらしい。

C言語の関数内において数値を埋め込んでいるために実行権限のあるメモリ内に展開され、実行できるという仕組みらしい。

この挙動はどこで説明されているのかもよくわからない。時間があるときに調べようと思う。

終わりに

基本的な命令しか知らなかった上に、知識として知っているだけで実際に書いたり読んだりといったことをしたことがなかったため非常に勉強になった。

理解するのにトータルで5時間ほどかかったが、最初から最後まで付き合ってくれた分類器先輩には感謝しかない。

そしてこれを数年前に書いており、色々と質問に答えてくれた山Dにも感謝。私はまだまだ弱い……