C言語はどのようにアセンブリ言語になるのか
前処理とリンクからx86-64呼び出し規約、stdio.hの全体像まで
C言語はどのようにアセンブリ言語になるのか
このブログの記事は講義を目的として作成しています。コンパイラのバージョン、OS、ABI、最適化オプションによって生成結果は変わるため、重要な内容は自分の環境でも確認してください。
gcc -S file.cを実行するだけなら簡単だ。しかし、生成された.sファイルを初めて開くと、#include <stdio.h>は消え、長いprintfの実装は一つのcallになり、LinuxとWindowsの例では引数レジスタまで異なる。
個々の命令を暗記するより、Cソースが実行ファイルになるまでの流れを分けて理解した方がよい。ここでは前処理、コンパイル、アセンブル、リンク、x86-64呼び出し規約、最適化、そしてstdio.hの中核45関数をまとめて扱う。
特に断らない限り、例はx86-64 Linux、System V ABI、Intel構文を基準とする。C規格自体はCPUやアセンブリ構文を定めない。
ソースから実行までの五段階
- プリプロセッサが
#include、#define、#ifを処理する。 - コンパイラが前処理済みCコードを解析し、対象CPUのアセンブリを生成する。
- アセンブラが命令とメタデータをオブジェクトファイルに変換する。
- リンカがシンボルを解決し、オブジェクトとライブラリから実行ファイルを作る。
- OSのローダが実行ファイルと共有ライブラリをメモリに配置する。
「Cをアセンブリに変換する」と言う場合、多くは二段階目で止めて.sまたは.asmを保存することを意味する。
小さな観察用プログラム
#include <stdio.h>
static int add(int left, int right)
{
return left + right;
}
int main(void)
{
int value = add(20, 22);
printf("value = %d\n", value);
return 0;
}
関数呼び出し、ローカル変数、文字列リテラル、可変長引数関数を含みながら、逆アセンブルを読むには十分小さい。
GCCで各段階を止める
gcc -std=c17 -E stdio_trace.c -o stdio_trace.i
gcc -std=c17 -O0 -fno-omit-frame-pointer -S -masm=intel -fverbose-asm stdio_trace.c -o stdio_trace.s
gcc -std=c17 -c stdio_trace.c -o stdio_trace.o
gcc stdio_trace.o -o stdio_trace
-Eは前処理結果、-Sはアセンブリ、-cはオブジェクトファイルで停止する。-masm=intelはGCCの出力をIntel構文にする。学習では-O0と-O2の両方を比較すると、ソースとの対応と実際の最適化を別々に見られる。
gcc -std=c17 -O2 -save-temps=obj stdio_trace.c -o stdio_trace
-save-temps=objを使うと複数の中間ファイルをまとめて残せる。
ClangとMSVC
clang -std=c17 -O0 -S -masm=intel stdio_trace.c -o stdio_trace.s
clang -std=c17 -O2 -S -emit-llvm stdio_trace.c -o stdio_trace.ll
LLVM IRはCPUのアセンブリではなく、最終命令選択前の中間表現である。
MSVCではDeveloper Command Promptから次のように一覧を生成できる。
cl /nologo /std:c17 /Od /FAs /Fa:stdio_trace.asm stdio_trace.c
cl /nologo /std:c17 /O2 /FAs /Fa:stdio_trace_opt.asm stdio_trace.c
コンパイラ出力と逆アセンブル
コンパイラのアセンブリにはラベル、セクション、ディレクティブ、ソースコメントが残ることがある。逆アセンブルはすでにエンコードされたオブジェクトまたは実行ファイルから命令を読み出す。
objdump -d -Mintel stdio_trace.o
objdump -d -Mintel stdio_trace
リンク後の実行ファイルには動的リンク用スタブや再配置済みアドレスが見える。デバッグシンボルは名前を読みやすくするが、元のCコードを完全には復元しない。
Intel構文の最低限
Intel構文では通常、宛先を先に書く。
mov eax, 42
add eax, 8
cmp eax, 50
je .equal
movは値を移し、leaはアドレス式を計算する。cmpはフラグを設定し、条件ジャンプがそのフラグを読む。callは戻り先を保存して制御を移し、retは呼び出し元へ戻る。Intel構文の角括弧はメモリアクセスを表す。
add関数と呼び出し規約
最適化しないSystem Vビルドでは次のような形が見える。
add:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov DWORD PTR [rbp-8], esi
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-8]
add eax, edx
pop rbp
ret
最初の整数引数はediとesi、戻り値はeaxに置かれる。最適化するとスタックへの保存が消え、lea一つになるか、呼び出し自体がインライン化される。
System V x86-64では整数とポインタの先頭六引数を通常rdi、rsi、rdx、rcx、r8、r9で渡す。戻り値はrax、浮動小数点引数はXMMレジスタを使う。
Windows x64は先頭四引数にrcx、rdx、r8、r9を使い、呼び出し側が32バイトのshadow spaceを確保する。対象ABIを確認せずにレジスタの意味を決めてはいけない。
printf呼び出しを読む
mov esi, 42
lea rdi, .LC0[rip]
xor eax, eax
call printf@PLT
rdiには書式文字列、esiには整数42が入る。System Vの可変長引数呼び出しではalが浮動小数点引数に使ったベクタレジスタ数を伝えるため、ここではeaxを0にしている。printf@PLTは動的リンクの入口であり、printfの全実装ではない。
最適化するとCの形は消える
コンパイラはaddをインライン化し、20 + 22をコンパイル時に計算し、valueのスタック領域を作らず、到達不能コードを削除できる。定数のprintf("hello\n")をputsに置き換える場合もある。観測可能な動作を保つ範囲なら合法である。
<stdio.h>が提供するもの
ヘッダをincludeしても標準I/Oライブラリ全体がソースへ貼り付くわけではない。前処理後に見えるのは型、マクロ、関数宣言であり、実装本体は通常Cランタイムにある。
中心となる抽象化はFILE *で表されるストリームだ。FILEはバッファ、位置、EOF、エラー、同期などの状態を持てるが、内部メンバは実装詳細である。プログラム開始時にはstdin、stdout、stderrが用意される。
主な型はFILE、fpos_t、size_tである。主なマクロにはEOF、NULL、BUFSIZ、FOPEN_MAX、FILENAME_MAX、L_tmpnam、TMP_MAX、_IOFBF、_IOLBF、_IONBF、SEEK_SET、SEEK_CUR、SEEK_ENDがある。
中核45関数
ファイル操作、4個
| 関数 | 用途 |
|---|---|
remove | 名前でファイルを削除する |
rename | ファイル名を変更する |
tmpfile | 一時バイナリストリームを作る |
tmpnam | 一時名を作るが、安全な利用が難しい |
オープン、クローズ、バッファ、6個
| 関数 | 用途 |
|---|---|
fclose | flushしてストリームを閉じる |
fflush | 保留中の出力をflushする |
fopen | 名前付きファイルを開く |
freopen | 既存ストリームを開き直す |
setbuf | 簡単なバッファ設定を行う |
setvbuf | モードとサイズを指定する |
fopenの代表的なモードはr、w、a、r+、w+、a+で、必要ならbを付ける。wは既存内容を切り詰める。戻り値は必ず確認する。移植可能なCではfflush(stdin)で入力を捨てない。
書式付き出力、8個
fprintf、printf、sprintf、snprintf、vfprintf、vprintf、vsprintf、vsnprintfがある。書式は概ね%[flags][width][.precision][length]specifierの形を取る。指定子と実引数の型が一致しなければ未定義動作になる。利用者入力をそのまま書式文字列にしてはいけない。
可変長引数ではfloatがdoubleへ、狭い整数型が通常intへ昇格する。この規則はABIでの引数配置にも影響する。
書式付き入力、6個
fscanf、scanf、sscanf、vfscanf、vscanf、vsscanfがある。変換先には正しい型の書き込み可能なポインタが必要だ。%sには幅制限を付け、成功した代入数である戻り値を確認する。対話入力ではfgetsで一行を読み、別に解析する方法が扱いやすい。
文字と文字列の入出力、10個
fgetc、fgets、fputc、fputs、getc、getchar、putc、putchar、puts、ungetcがある。文字入力はcharではなくintで受け、すべてのunsigned char値とEOFを区別する。
ブロック入出力、2個
freadとfwriteは配列単位でデータを転送する。戻り値は完全に転送できた要素数であり、要素サイズが1でない限りバイト数ではない。構造体をそのまま保存しても、padding、エンディアン、型サイズの違いがあるため移植可能な形式にはならない。
ファイル位置、5個
fgetpos、fseek、fsetpos、ftell、rewindがある。テキストストリームの位置は任意のバイト番号として扱えない場合がある。巨大ファイルにはプラットフォーム固有の64ビットAPIが用意されることもある。
状態と診断、4個
clearerr、feof、ferror、perrorがある。EOFは読み取りが末尾を越えようとした後で分かるため、while (!feof(file))ではなく読み取り関数の結果でループを制御する。
Annex Kと拡張
fopen_s、fprintf_s、scanf_sなどを提供する実装もあるが、すべてのC環境で使える中核APIではない。Microsoftの_s関数には固有の引数規則もある。POSIXやWindowsの拡張とISO Cの機能を区別して説明する必要がある。
標準I/Oとシステムコール
printfは通常、利用者空間で文字列を整形し、FILEのバッファへ書く。バッファをflushするときにPOSIXのwriteなどのOS APIへ到達することがある。したがって標準I/O関数一回がシステムコール一回と対応するわけではない。
getcやputcはマクロで実装してもよく、バッファ内の高速経路がインライン展開されることがある。call getcが見えなくても操作が消えたとは限らない。
バイナリファイルコピーの要点
安全なコピー処理では、fopenの失敗、freadの短い読み取り、ferror、fwriteの部分書き込み、fclose時の遅延エラーをすべて確認する。典型的なループは次の形になる。
unsigned char buffer[64 * 1024];
size_t read_count;
while ((read_count = fread(buffer, 1, sizeof buffer, source)) > 0)
{
size_t written = 0;
while (written < read_count)
{
size_t count = fwrite(buffer + written, 1,
read_count - written, destination);
if (count == 0)
{
break;
}
written += count;
}
}
if (ferror(source))
{
perror("fread");
}
学習の順序
整数引数と戻り値だけの関数から始め、-O0と-O2を比較する。次にローカル配列、通常の関数呼び出し、printfを一つずつ追加する。オブジェクトファイルとリンク後の実行ファイルを比べ、最後にWindows x64など別のABIで同じコードを見る。一度に一つだけ変更すると差が読みやすい。
よくある間違い
- 一つのCプログラムに唯一のアセンブリ結果があると思う。
-O0をCの機械的な逐語訳だと考える。- 書式指定子と引数型を一致させない。
- 信頼できない文字列を
printfの書式にする。 scanf("%s", buffer)に幅制限を付けない。while (!feof(file))を使う。- 移植可能なコードで
fflush(stdin)に依存する。 - 出力時の
fclose失敗を無視する。 - 構造体の生バイトを移植可能なファイル形式だと考える。
- 未定義動作から一般的なアセンブリ規則を推測する。
printf一行が出力されるまで
<stdio.h>が宣言を提供し、コンパイラが対象ABIに合わせて引数を配置する。アセンブラはシンボル参照を含む命令を作り、リンカまたはローダがCランタイムへ接続する。実行時にはライブラリが値を整形してストリームバッファを更新し、必要な時点でOSへバイト書き込みを依頼する。
参考資料
- ISO C作業草案N1570
- GCC code generation options
- Clang command-line reference
- MSVC 0
- System V AMD64 ABI
- Microsoft x64 calling convention
- GNU objdump
まとめ
Cは前処理、コンパイル、アセンブル、リンク、ロードを経て実行される。見えるアセンブリは対象ABIと最適化に依存する。<stdio.h>は宣言とマクロを提供し、実装はCランタイムにある。この層を分けて考えると、命令、関数呼び出し、バッファ、OS APIが一つの流れとして読めるようになる。