Open6

ftraceの仕組みの軽い調査

ISATOISATO

概要

Linux Kernel には version 4.0から Kernel Live Patchという仕組みが存在し、
この仕組みにより再起動をせずともKernelのupdateが可能になる。
そして、Kernel Live Patchはftraceと呼ばれる技術により実現されている。
ftraceはその名の通り、カーネルの関数のトレースを行う技術でありカーネル関数呼び出しに関する情報を取得することが可能である。
ここでは、Kernel Live Patchを理解するための1歩目として、ftraceがどのように動作するのかを仄かに理解することを目指す。

ftraceを支える技術

このftraceのトレーシングは、GCCのプロファイリング機能に支えられている。
GCCのプロファイリング機能は簡単に利用することができ、以下のようにコンパイル時に -pgオプションを付けるだけで利用できる。

$ ~/Sources/mcount-test$ gcc -o profile_target_binary -pg main.c

-pgをつけることによりバイナリにどのような変化が現れるかを以下で示す。
まずは、コンパイル対象のCソースファイルである。

#include<stdio.h>

int my_func_1(int i) {
        return i + 1;
}

int my_func_2(int i) {
        return i + 2;
}

int main(void) {
        int j = 0;
        for(int i=0; i < 100000; i++){
                j = my_func_1(j);
        }
        for(int i=0; i < 1000000; i++){
                j = my_func_2(j);
        }
        return 0;
}

このCソースファイルを-pg付きでアセンブリへコンパイルしたものを profile_enabled.sに、そうでなく普通にアセンブリにコンパイルしたものをdefault.sにそれぞれ出力した。
この2つのファイルのdiffを取ったものが以下である。

*** default.s   2022-07-21 00:44:02.792136058 +0900
--- profile_enabled.s   2022-07-21 00:43:12.669131352 +0900
***************
*** 11,20 ****
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movl    %edi, -4(%rbp)
        movl    -4(%rbp), %eax
        addl    $1, %eax
!       popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
--- 11,22 ----
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
+       subq    $8, %rsp
+ 1:    call    *mcount@GOTPCREL(%rip)
        movl    %edi, -4(%rbp)
        movl    -4(%rbp), %eax
        addl    $1, %eax
!       leave
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
***************
*** 31,40 ****
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movl    %edi, -4(%rbp)
        movl    -4(%rbp), %eax
        addl    $2, %eax
!       popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
--- 33,44 ----
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
+       subq    $8, %rsp
+ 1:    call    *mcount@GOTPCREL(%rip)
        movl    %edi, -4(%rbp)
        movl    -4(%rbp), %eax
        addl    $2, %eax
!       leave
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
***************
*** 52,57 ****
--- 56,62 ----
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        subq    $16, %rsp
+ 1:    call    *mcount@GOTPCREL(%rip)
        movl    $0, -12(%rbp)
        movl    $0, -8(%rbp)
        jmp     .L6

これを見ると-pg付きはどうやら各関数(全行を載せれていないため申し訳ないが、この各関数とはmain, my_func_1, my_func_2のことである)が呼ばれた際にその関数のメインの処理を実行する前段階で*mcount@GOTPCREL(%rip)が追加されてcallされているようである。

筆者は現時点で *GOTPCRELの意味はわからないが、どうやらglibcのmcount関数が呼ばれる挙動になるらしい。
順番にざっくり見るなら以下の順番に読めば内部実装にそれっぽく行き着く。

  1. https://elixir.bootlin.com/glibc/glibc-2.25/source/sysdeps/x86_64/sysdep.h#L77
  2. https://elixir.bootlin.com/glibc/glibc-2.25/source/sysdeps/x86_64/_mcount.S#L27
  3. https://elixir.bootlin.com/glibc/glibc-2.25/source/sysdeps/x86_64/machine-gmon.h#L32
  4. https://elixir.bootlin.com/glibc/glibc-2.25/source/gmon/mcount.c#L59

ここでは、これ以上GCCのプロファイリング機能に深入しないが、この機能を使うことによってある関数のメイン処理以前にある処理(ここでは、mcount関数によりプロファイリング処理)を仕込むことが可能なことがわかる。

ISATOISATO

ftraceを追う

前述したようにftraceはGCCのプロファイリング機能を利用したものである。
というより、プロファイリング機能をカーネルの関数に適応させたものである。
ここで2つの問題が想像できる。

  1. カーネルはglibcを使えない
  2. カーネル関数全てにこれが適応されると重そう

1の問題は、ユーザ空間のプログラムならglibcによるmcount関数が呼び出されることによりプロファイリングが可能であるが、カーネルはglibcを利用できないため、これはできない。そのため、カーネルはmcount関数を実装する必要があり、これはおそらくここの実装であると思われる
https://elixir.bootlin.com/linux/v4.20.17/source/arch/x86/kernel/ftrace_64.S#L20

2の問題は、sysctlを利用することによるON/OFFやデフォルトではnop命令をmcount関数で呼び出すことにより解決されているらしい。

以降は今後調査を行う(寝る

参考文献

ISATOISATO

調べる環境を作る

ftraceを追うための環境を作りたくなったので作った。

大体のやり方は
https://zenn.dev/bean/scraps/63d0c89ee92180
に沿うが、以下のような追加手順を行った

  • Kernelの準備
  • Busyboxの準備

Kernelの準備

5.4.207を利用した。
config は 以下のgistのものを利用する。
https://gist.github.com/bean1310/4dbc21285091d27cbe73b2a59b3a2ef5

ftraceのデモを動かすためにKernel Moduleもビルドする

make modules -j `nproc`

Busyboxの準備

busybox-1.35.0 を利用した。
make install後に _install ディレクトリにftraceのデモのリポジトリcloneし、makefileを改変しビルドしておく。

cd _install
git clone https://github.com/ilammy/ftrace-hook.git
# 以下のように変更する
KERNEL_PATH ?= <Linux Kernelのソースディレクトリ>
make

これをしておいて、root.imgにcpioを用いて固める。

ISATOISATO

ftraceによる書き換えを実際に見てみる

動作環境ができたので、次にftraceの動きを動かしながら見てみる。

ftrace-hook は execveシステムコールとcloneシステムコールをhookする。
そのため、今回はexecveシステムコールに着目して観察する。

ソースのSystem.mapからexecveシステムコールのアドレスを取得する
おおよそ以下のような感じのものが見つかるはずだ。

ffffffff8113b160 T __x64_sys_execve

execveシステムコールのアドレスがわかったので、動作させているQEMUでこのアドレスを見てみる。
観察する時間としては、以下の3つで観察を行う。

  1. ftraceの初期化前
  2. ftraceの初期化後
  3. ftrace-hookをロード後

具体的に示すと、ftraceの初期化は以下で行われるため、この関数がよばれる前後が1と2に対応する。
https://github.com/torvalds/linux/blob/219d54332a09e8d8741c1e1982f5eae56099de85/init/main.c#L629
ftrace-hookロード後は、ftrace_hook.koをinsmodした後である。

観察結果

まずは、ftrace初期化前にexecveシステムコールのアドレスを表示したものである。

(gdb) x/gi 0xffffffff8113b160
   0xffffffff8113b160 <__x64_sys_execve>:	callq  0xffffffff81401430 <__fentry__>

この時点では、 __fentry__ を callqしている。

次に、ftrace初期化直後である

(gdb) x/gi 0xffffffff8113b160
   0xffffffff8113b160 <__x64_sys_execve>:	data16 data16 data16 xchg %ax,%ax

先ほど、callq __fentry__だった部分が書き換えられていることが見える

最後にftrace-hookロード後である。

(gdb) x/gi 0xffffffff8113b160
   0xffffffff8113b160 <__x64_sys_execve>:	callq  0xffffffffc0009000

ロード後は 後ろの方のアドレスをcallqするように変わった。

観察の解釈

まずは、__fentry__についてであるが、これは最初に述べた「GCCのプロファイリング機能」の mcountとほぼ同等のものである。(何が違うのかはいまいちわかっていない)

次に、ftrace初期化直後の該当部分の命令列である data16 data16 data16 xchg %ax,%ax であるが、これはおそらくNOP命令と同等である。
GDB上ではオペランドが ax になっているのが気になるが...。
https://ja.wikibooks.org/wiki/X86アセンブラ/データ転送命令

最後に、ftrace-hook ロード後に呼び出されようとしてるアドレスだが、これはftrace-hookが配置されている場所に近い。

/ftrace-hook # cat /proc/modules
ftrace_hook 16384 0 - Live 0xffffffffc0000000 (O)

次に、ftrace-hookが配置されている周辺について述べる。