C言語のread関数がシステムコールを呼ぶまで: アセンブリ編
はじめに
前回のつづきです。
筆者について
- C言語は滅多に書かない
- アセンブリがどんなものかがわかる程度
- コードリーディングは初めて
筆者の環境
| 項目 | 詳細 |
|---|---|
| 本体 | GMKtec Nucbox G3Plus |
| CPU | 第12世代 Intel N150 |
| 仮想化プラットフォーム | Proxmox 8.4.1 |
| Processors | 1 (1 sockets, 1 cores)[host] |
| OS | Ubuntu 24.04.2 LTS |
| https://www.amazon.co.jp/dp/B0D56KM5HL?ref=ppx_yo2ov_dt_b_fed_asin_title&th=1 |
コンパイルする
glibcのバージョン。
jugeeeemu@glibc-reading:~/verification$ ldd --version
ldd (Ubuntu GLIBC 2.39-0ubuntu8.6) 2.39
Copyright (C) 2024 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.
Written by Roland McGrath and Ulrich Drepper.
ソースコード。
# include <stdio.h>
# include <fcntl.h>
# include <unistd.h>
int main(){
int fd = open("file.txt", O_RDONLY);
char buf[32];
read(fd, buf, 32);
printf("%s\n", buf);
close(fd);
return 0;
}
コンパイル
gcc -O0 -g -static main.c -o main
実行ファイルを読む
main関数
コンパイルした実行ファイルの内容を見てみましょう。
jugeeeemu@glibc-reading:~/verification$ gdb main
// ≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈
// 略
// ≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈
(gdb) disassemble main
Dump of assembler code for function main:
0x00000000004018b5 <+0>: endbr64
0x00000000004018b9 <+4>: push %rbp
0x00000000004018ba <+5>: mov %rsp,%rbp
0x00000000004018bd <+8>: sub $0x40,%rsp
0x00000000004018c1 <+12>: mov %fs:0x28,%rax
0x00000000004018ca <+21>: mov %rax,-0x8(%rbp)
0x00000000004018ce <+25>: xor %eax,%eax
0x00000000004018d0 <+27>: mov $0x0,%esi
0x00000000004018d5 <+32>: lea 0x7d734(%rip),%rax # 0x47f010
0x00000000004018dc <+39>: mov %rax,%rdi
0x00000000004018df <+42>: mov $0x0,%eax
0x00000000004018e4 <+47>: call 0x4192e0 <open64>
0x00000000004018e9 <+52>: mov %eax,-0x34(%rbp)
0x00000000004018ec <+55>: lea -0x30(%rbp),%rcx
0x00000000004018f0 <+59>: mov -0x34(%rbp),%eax
0x00000000004018f3 <+62>: mov $0x20,%edx
0x00000000004018f8 <+67>: mov %rcx,%rsi
0x00000000004018fb <+70>: mov %eax,%edi
0x00000000004018fd <+72>: call 0x419400 <read>
0x0000000000401902 <+77>: lea -0x30(%rbp),%rax
0x0000000000401906 <+81>: mov %rax,%rdi
0x0000000000401909 <+84>: call 0x404d90 <puts>
0x000000000040190e <+89>: mov -0x34(%rbp),%eax
0x0000000000401911 <+92>: mov %eax,%edi
0x0000000000401913 <+94>: call 0x4191f0 <close>
0x0000000000401918 <+99>: mov $0x0,%eax
0x000000000040191d <+104>: mov -0x8(%rbp),%rdx
0x0000000000401921 <+108>: sub %fs:0x28,%rdx
0x000000000040192a <+117>: je 0x401931 <main+124>
0x000000000040192c <+119>: call 0x41a6e0 <__stack_chk_fail_local>
0x0000000000401931 <+124>: leave
0x0000000000401932 <+125>: ret
End of assembler dump.
readにまつわる処理は以下の部分です。
0x00000000004018e9 <+52>: mov %eax,-0x34(%rbp)
0x00000000004018ec <+55>: lea -0x30(%rbp),%rcx
0x00000000004018f0 <+59>: mov -0x34(%rbp),%eax
0x00000000004018f3 <+62>: mov $0x20,%edx
0x00000000004018f8 <+67>: mov %rcx,%rsi
0x00000000004018fb <+70>: mov %eax,%edi
0x00000000004018fd <+72>: call 0x419400 <read>
- mov %eax,-0x34(%rbp)
-
fdをローカルへ保存
-
- lea -0x30(%rbp),%rcx
-
bufの先頭アドレスをレジスタにセット
-
- mov -0x34(%rbp),%eax
- fdの値をレジスタにセット
- mov $0x20,%edx
-
nbytes=32をレジスタにセット rdx=nbytes
-
- mov %rcx,%rsi
rsi=buf
- mov %eax,%edi
rdi=fd
- call 0x419400 <read>
-
readに処理を移す
-
ここでレジスタと値のペアは関数呼び出し規約どおりになっていて、glibcのinternal_syscall3マクロの実装とも対応している。
#define internal_syscall3(number, arg1, arg2, arg3) \ ({ \ /* ... (1. ローカル変数コピー) ... */ TYPEFY (arg3, __arg3) = ARGIFY (arg3); \ TYPEFY (arg2, __arg2) = ARGIFY (arg2); \ TYPEFY (arg1, __arg1) = ARGIFY (arg1); \ \ /* ... (2. レジスタへの割り当て) ... */ register TYPEFY (arg3, _a3) asm ("rdx") = __arg3; \ register TYPEFY (arg2, _a2) asm ("rsi") = __arg2; \ register TYPEFY (arg1, _a1) asm ("rdi") = __arg1; \ \ /* ... (3. インラインアセンブリ) ... */ asm volatile ( \ "syscall\n\t" \ : "=a" (resultvar) \ : "0" (number), "r" (_a1), "r" (_a2), "r" (_a3) \ : "memory", REGISTERS_CLOBBERED_BY_SYSCALL); \ \ /* ... (4. 戻り値) ... */ (long int) resultvar; \ })* `read`の場合以下のようになる * `_a1`(`fd`の値)を`rdi`レジスタに配置 * `_a2`(`buf`の値)を`rsi`レジスタに配置 * `_a3`(`nbytes`の値)を`rdx`レジスタに配置
read関数
処理が移った先のreadを見てみるとしよう。
(gdb) disassemble read
Dump of assembler code for function read:
0x0000000000419400 <+0>: endbr64
0x0000000000419404 <+4>: cmpb $0x0,0x91c4d(%rip) # 0x4ab058 <__libc_single_threaded>
0x000000000041940b <+11>: je 0x419420 <read+32>
0x000000000041940d <+13>: xor %eax,%eax
0x000000000041940f <+15>: syscall
0x0000000000419411 <+17>: cmp $0xfffffffffffff000,%rax
0x0000000000419417 <+23>: ja 0x419468 <read+104>
0x0000000000419419 <+25>: ret
0x000000000041941a <+26>: nopw 0x0(%rax,%rax,1)
0x0000000000419420 <+32>: push %rbp
0x0000000000419421 <+33>: mov %rsp,%rbp
0x0000000000419424 <+36>: sub $0x20,%rsp
0x0000000000419428 <+40>: mov %rdx,-0x18(%rbp)
0x000000000041942c <+44>: mov %rsi,-0x10(%rbp)
0x0000000000419430 <+48>: mov %edi,-0x8(%rbp)
0x0000000000419433 <+51>: call 0x4418c0 <__pthread_enable_asynccancel>
0x0000000000419438 <+56>: mov -0x18(%rbp),%rdx
0x000000000041943c <+60>: mov -0x10(%rbp),%rsi
0x0000000000419440 <+64>: mov %eax,%r8d
0x0000000000419443 <+67>: mov -0x8(%rbp),%edi
0x0000000000419446 <+70>: xor %eax,%eax
0x0000000000419448 <+72>: syscall
0x000000000041944a <+74>: cmp $0xfffffffffffff000,%rax
0x0000000000419450 <+80>: ja 0x419480 <read+128>
0x0000000000419452 <+82>: mov %r8d,%edi
0x0000000000419455 <+85>: mov %rax,-0x8(%rbp)
0x0000000000419459 <+89>: call 0x441940 <__pthread_disable_asynccancel>
0x000000000041945e <+94>: mov -0x8(%rbp),%rax
0x0000000000419462 <+98>: leave
0x0000000000419463 <+99>: ret
0x0000000000419464 <+100>: nopl 0x0(%rax)
0x0000000000419468 <+104>: mov $0xffffffffffffffc0,%rdx
0x000000000041946f <+111>: neg %eax
0x0000000000419471 <+113>: mov %eax,%fs:(%rdx)
0x0000000000419474 <+116>: mov $0xffffffffffffffff,%rax
0x000000000041947b <+123>: ret
0x000000000041947c <+124>: nopl 0x0(%rax)
0x0000000000419480 <+128>: mov $0xffffffffffffffc0,%rdx
0x0000000000419487 <+135>: neg %eax
0x0000000000419489 <+137>: mov %eax,%fs:(%rdx)
0x000000000041948c <+140>: mov $0xffffffffffffffff,%rax
0x0000000000419493 <+147>: jmp 0x419452 <read+82>
End of assembler dump.
0x4ab058にてこのプロセスがシングルスレッドであるかどうかを保持しているらしい。
中身を見てみよう。
(gdb) x 0x4ab058
0x4ab058 <__libc_single_threaded>: 0x00000001
現時点では__libc_single_threaded==1なので、シングルスレッドとして扱われ、高速経路(<read+32>へ飛ばない方)が実行される。
この記述は以下のマクロが展開された結果であると考えられる。
#if IS_IN (rtld)
/* All cancellation points are compiled out in the dynamic loader. */
# define NO_SYSCALL_CANCEL_CHECKING 1
#else
# define NO_SYSCALL_CANCEL_CHECKING SINGLE_THREAD_P
#endif
このことからこのプログラムは<read+32>へジャンプせず、以下の箇所が実行されることになる。
0x000000000041940d <+13>: xor %eax,%eax
0x000000000041940f <+15>: syscall
0x0000000000419411 <+17>: cmp $0xfffffffffffff000,%rax
0x0000000000419417 <+23>: ja 0x419468 <read+104>
0x0000000000419419 <+25>: ret
- xor %eax,%eax
-
raxにシステムコール番号を入れる -
readのシステムコール番号は0
-
- syscall
-
rdi=fd,rsi=buf,rdx=nbytesをもとにシステムコールを呼び出す
-
- cmp $0xfffffffffffff000,%rax
-
0xfffffffffffff000とシステムコールの戻り値を符号なしで比較
-
- ja 0x419468 <read+104>
-
raxに0xfffffffffffff001=-4095~0xffffffffffffffff=-1が入っているとエラーハンドラへジャンプする
-
実行する
アドレス指定でブレークポイントを設定します。
(gdb) break *0x00000000004018e9
Breakpoint 1 at 0x4018e9: file main.c, line 6.
(gdb) break *0x00000000004018fd
Breakpoint 2 at 0x4018fd: file main.c, line 10.
(gdb) break *0x000000000041940f
Breakpoint 3 at 0x41940f
(gdb) break *0x0000000000401902
Breakpoint 4 at 0x401902: file main.c, line 11.
(gdb) break *0x0000000000419411
Breakpoint 5 at 0x419411
レジスタ、メモリの値を見ながら処理を進めていきます。
(gdb) run
Starting program: /home/jugeeeemu/verification/main
Downloading separate debug info for system-supplied DSO at 0x7ffff7ffd000
Breakpoint 1, 0x00000000004018e9 in main () at main.c:6
6 int fd = open("file.txt", O_RDONLY);
(gdb) info register rax rdi rsi rdx
rax 0x3 3
rdi 0xffffff9c 4294967196
rsi 0x47f010 4714512
rdx 0x0 0
(gdb) x/s 0x47f010
0x47f010: "file.txt"
(gdb) continue
Continuing.
Breakpoint 2, 0x00000000004018fd in main () at main.c:10
10 read(fd, buf, 32);
(gdb) info register rax rdi rsi rdx
rax 0x3 3
rdi 0x3 3
rsi 0x7fffffffe1f0 140737488347632
rdx 0x20 32
(gdb) x/s 0x7fffffffe1f0
0x7fffffffe1f0: " \262I"
(gdb) x/s buf
0x7fffffffe1f0: " \262I"
(gdb) continue
Continuing.
Breakpoint 3, 0x000000000041940f in read ()
(gdb) info register rax rdi rsi rdx
rax 0x0 0
rdi 0x3 3
rsi 0x7fffffffe1f0 140737488347632
rdx 0x20 32
(gdb) x/s 0x7fffffffe1f0
0x7fffffffe1f0: " \262I"
(gdb) continue
Continuing.
Breakpoint 5, 0x0000000000419411 in read ()
(gdb) info register rax rdi rsi rdx
rax 0xe 14
rdi 0x3 3
rsi 0x7fffffffe1f0 140737488347632
rdx 0x20 32
(gdb) x/s 0x7fffffffe1f0
0x7fffffffe1f0: "Hello, World!\n"
(gdb) continue
Continuing.
Breakpoint 4, main () at main.c:11
11 printf("%s\n", buf);
(gdb) info register rax rdi rsi rdx
rax 0xe 14
rdi 0x3 3
rsi 0x7fffffffe1f0 140737488347632
rdx 0x20 32
(gdb) x/s 0x7fffffffe1f0
0x7fffffffe1f0: "Hello, World!\n"
(gdb) x/s buf
0x7fffffffe1f0: "Hello, World!\n"
(gdb) continue
Continuing.
Hello, World!
[Inferior 1 (process 19255) exited normally]
(gdb)
open関数から帰ってきてread関数に移るまでの間に各レジスタに想定した通りの値が設定されていますね。
read関数内でsyscallを実行する直前に、raxにreadのシステムコール番号である0が設定されているのも確認できます。
システムコールから帰ってきた直後のレジスタ、メモリの内容を見てみると、bufにしっかりとfile.txtファイルの内容であるHello, World!\nが格納されています。
戻り値が入るraxについても見てみると、Hello, World!\nのバイト数である14が格納されていますね。
ただ、文字列の終わりを示す終端文字\0が見当たりません。
どうやらread関数では終端に\0を付け足してくれないので呼び出した側でバッファに追記してやる必要があるようです。
おわりに
ライブラリなどにより抽象化されたプログラムが実際にCPUで動作する命令にまで落とし込まれる過程を確認することで、プログラムがどのように動作するかの理解が深まった(今回はコンパイラによる最適化を最低レベルにしているので実際はまた違っているでしょうが...)。
今回はユーザ空間からsyscall実行までを追いかけてみたが、その先の処理やシステムコール番号に対応したハンドラを実行するまでの仕組みについても興味が湧いてきた。
これらはまた別記事で書いていこうと思います。
Discussion