🔥

C言語のread関数がシステムコールを呼ぶまで: アセンブリ編

に公開

はじめに

前回のつづきです。
https://zenn.dev/jugeeeemu/articles/8a2f6342ef8e00

筆者について

  • 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.

ソースコード。

main.c
# 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>へ飛ばない方)が実行される。
この記述は以下のマクロが展開された結果であると考えられる。

c:glibc-2.39/sysdeps/unix/sysdep.h
#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>
    • rax0xfffffffffffff001=-40950xffffffffffffffff=-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を実行する直前に、raxreadのシステムコール番号である0が設定されているのも確認できます。
システムコールから帰ってきた直後のレジスタ、メモリの内容を見てみると、bufにしっかりとfile.txtファイルの内容であるHello, World!\nが格納されています。
戻り値が入るraxについても見てみると、Hello, World!\nのバイト数である14が格納されていますね。
ただ、文字列の終わりを示す終端文字\0が見当たりません。
どうやらread関数では終端に\0を付け足してくれないので呼び出した側でバッファに追記してやる必要があるようです。

おわりに

ライブラリなどにより抽象化されたプログラムが実際にCPUで動作する命令にまで落とし込まれる過程を確認することで、プログラムがどのように動作するかの理解が深まった(今回はコンパイラによる最適化を最低レベルにしているので実際はまた違っているでしょうが...)。
今回はユーザ空間からsyscall実行までを追いかけてみたが、その先の処理やシステムコール番号に対応したハンドラを実行するまでの仕組みについても興味が湧いてきた。
これらはまた別記事で書いていこうと思います。

Discussion