泰晓科技 -- 聚焦 Linux - 追本溯源,见微知著!
网站地址:https://tinylab.org

儿童Linux系统,可打字编程学数理化
请稍侯

RISC-V Ftrace 实现原理(2)- 编译时原理

song 创作于 2022/12/27

Corrector: TinyCorrect v0.1-rc3 - [spaces header codeblock epw] Author: sugarfillet sugarfillet@yeah.net Date: 2022/08/19 Revisor: Falcon falcon@tinylab.org Project: RISC-V Linux 内核剖析 Proposal: RISC-V ftrace 相关技术调研与分析 Sponsor: PLCT Lab, ISCAS

前言

上文 中提到,实现函数跟踪需要在编译时指定 -pg 选项来在函数入口插入 call _mcount,内核中,这个 -pg 选项是通过 CC_FLAGS_FTRACE 来指定的,我们同时也观察到 CC_FLAGS_FTRACE 选项还有其他的赋值,比如 -mrecord-mcount-mnop-mcount-mfentry

上篇文章简单说明这些选项是用来使能动态 ftrace,那么本篇文章就展开来分析动态 ftrace 的编译时原理。首先介绍关于 ftrace 编译选项的几个配置选项,然后从 git log 角度介绍 ftrace 编译选项的演进过程,引出本文的主角 -fpatchable-function-entry,并对此选项做简单介绍,随后重点分析基于此选项的内核编译链接过程,最后分析 ftrace 初始化阶段对此编译选项特性的处理。

说明

  • ftrace 默认编译配置为动态 ftrace,后文如果不做特殊说明,ftrace 就表示动态的 ftrace。
  • 本文的 Linux 版本采用 Linux 5.18-rc1

配置选项到编译选项

kernel/trace/Kconfig 中可以看到,FTRACE_MCOUNT_USE_*CCOBJTOOLPATCHABLE_FUNCTION_ENTRYRECORDMCOUNT 四个选择,且基本是互斥的。

  1. FTRACE_MCOUNT_USE_CC

    表示使用编译器的 -mrecord-mcount 选项,如果配置了 CONFIG_HAVE_NOP_MCOUNT,会添加 -mnop-mcount 选项

  2. FTRACE_MCOUNT_USE_RECORDMCOUNT

    表示使用 scripts/recordmcount.{pl,c} 脚本来收集函数入口,如果配置了 CONFIG_HAVE_C_RECORDMCOUNT 使用对应的二进制版本

  3. FTRACE_MCOUNT_USE_PATCHABLE_FUNCTION_ENTRY

    表示不选择其他的选项,需要通过自定义的 CC_FLAGS_FTRACE 来指定编译选项

  4. CONFIG_FTRACE_MCOUNT_USE_OBJTOOL

    表示采用 tool/objtool 来收集函数入口

我们来看一下这几个选项在 Kconfig 中是如何定义的:

// kernel/trace/Kconfig :711

config FTRACE_MCOUNT_RECORD
        def_bool y
        depends on DYNAMIC_FTRACE
        depends on HAVE_FTRACE_MCOUNT_RECORD

config FTRACE_MCOUNT_USE_PATCHABLE_FUNCTION_ENTRY
        bool
        depends on FTRACE_MCOUNT_RECORD

config FTRACE_MCOUNT_USE_CC
        def_bool y
        depends on $(cc-option,-mrecord-mcount)
        depends on !FTRACE_MCOUNT_USE_PATCHABLE_FUNCTION_ENTRY
        depends on FTRACE_MCOUNT_RECORD

config FTRACE_MCOUNT_USE_OBJTOOL
        def_bool y
        depends on HAVE_OBJTOOL_MCOUNT
        depends on !FTRACE_MCOUNT_USE_PATCHABLE_FUNCTION_ENTRY
        depends on !FTRACE_MCOUNT_USE_CC
        depends on FTRACE_MCOUNT_RECORD
        select OBJTOOL

config FTRACE_MCOUNT_USE_RECORDMCOUNT
        def_bool y
        depends on !FTRACE_MCOUNT_USE_PATCHABLE_FUNCTION_ENTRY
        depends on !FTRACE_MCOUNT_USE_CC
        depends on !FTRACE_MCOUNT_USE_OBJTOOL
        depends on FTRACE_MCOUNT_RECORD

Makfile 文件中,可以看到上述对应的配置选项对编译选项的影响,值得一提的是,FTRACE_MCOUNT_USE_PATCHABLE_FUNCTION_ENTRY 并没有对应的处理逻辑,它只是表示不选择其他的 FTRACE_MCOUNT_USE 选项。

// Makefile :844
ifdef CONFIG_FUNCTION_TRACER

ifdef CONFIG_FTRACE_MCOUNT_USE_CC
  CC_FLAGS_FTRACE       += -mrecord-mcount
  ifdef CONFIG_HAVE_NOP_MCOUNT
    ifeq ($(call cc-option-yn, -mnop-mcount),y)
      CC_FLAGS_FTRACE   += -mnop-mcount
      CC_FLAGS_USING    += -DCC_USING_NOP_MCOUNT
    endif
  endif
endif

ifdef CONFIG_FTRACE_MCOUNT_USE_OBJTOOL
  CC_FLAGS_USING        += -DCC_USING_NOP_MCOUNT
endif

ifdef CONFIG_FTRACE_MCOUNT_USE_RECORDMCOUNT
  ifdef CONFIG_HAVE_C_RECORDMCOUNT
    BUILD_C_RECORDMCOUNT := y
    export BUILD_C_RECORDMCOUNT
  endif
endif

ifdef CONFIG_HAVE_FENTRY
  # s390-linux-gnu-gcc did not support -mfentry until gcc-9.
  ifeq ($(call cc-option-yn, -mfentry),y)
    CC_FLAGS_FTRACE     += -mfentry
    CC_FLAGS_USING      += -DCC_USING_FENTRY
  endif
endif

export CC_FLAGS_FTRACE
KBUILD_CFLAGS   += $(CC_FLAGS_FTRACE) $(CC_FLAGS_USING)
KBUILD_AFLAGS   += $(CC_FLAGS_USING)
endif

ftrace 编译选项演进过程

由于 ftrace 最早的实现是在 x86 架构,下文基本以 x86 的 ftrace 编译选项变更进行介绍。

  • ftrace: add basic support for gcc profiler instrumentation

    最早的 ftrace 使用 -pg 和 mcount,来实现静态 ftrace,静态 ftrace 只能做全局跟踪,并且每个函数运行时都会调用 mcount,有着较大的运行时开销。

  • ftrace: dynamic enabling/disabling of function calls

    引入了动态 ftrace,在系统启动过程或者关闭 ftrace 时,通过更改 ftrace_trace_funtionftrace_record_ip 记录当前 mcount 的调用点到 ftrace_hash。并启动执行周期为 1s,名称为 ftraced 的内核线程,将 ftrace_hash 中所有的调用点代码更改为 ftrace_jmpftrace_jmp 是个小 trick,可以理解为 nop。后续如果开启 ftrace,则把 ftrace_jmp 替换为原来的调用点代码,并启用对应的 tracer 来进行跟踪。

  • ftrace: add filter select functions to trace

    在动态 ftrace 基础上实现函数的过滤,比较好的解决了静态跟踪的问题。

  • ftrace: create __mcount_loc section; ftrace: mcount call site on boot nops core

    引入 recordmcount,在编译后链接前,调用 objdump 记录所有 mcount 调用点,存储在 __mcount_loc 段中,在 kernel 启动时执行 ftrace_init 预先把所有 mcount 调用点修改成 nop,避免守护进程的开销。

  • ftrace/x86: Add support for -mfentry to x86_64

    引入 -mfentry 选项,用 __fentry__ 取代 mcount,相比 mcount 在函数帧建立后被调用,__fentry__ 在函数帧建立前被调用,能够获取函数参数。

  • ftrace/x86: Remove mcount support

    x86 架构移除了 mcount 支持,默认采用 __fentry__ 来做函数跟踪。

  • tracing: add support for objtool mcount

    配合 -mfentry 使用 tools/objtool 来生成 __mcount_loc

  • trace: Use -mcount-record for dynamic ftrace

    引入 -mrecord-mcount 用于代替 record_mcount,仍然采用 __mcount_loc 来做 mcount 调用点记录。

  • tracing: Add -mcount-nop option support

    引入 -mnop-mcount 在编译时将 mcount 调用点替换为 nop。

  • module/ftrace: handle patchable-function-entry

    由 parisc 架构引入 -fpatchable-function-entry 选项,用 __patchable_function_entries 替换 __mcount_loc,且将 mcount 调用点替换为 nop。

  • arm64: disable recordmcount with DYNAMIC_FTRACE_WITH_REGS

    arm64 架构跟进采用 -fpatchable-function-entry 选项。

  • riscv: Using PATCHABLE_FUNCTION_ENTRY instead of MCOUNT

    RISC-V 架构也使用 -fpatchable-function-entry 选项。

小结

ftrace 的编译选项演进其实都在解决以下两个问题:

  1. 如何记录所有的 mcount 调用点

    变化过程:ftrace_record_ip() => tools/recordmcount.{pl,c} => -mrecord-mcount => tools/objtool

  2. 如何将 mcount 调用点在启动过程默认设置为 nop

    变化过程:ftraced => ftrace_init() => -mnop-mcount

-fpatchable-function-entry 选项同时解决了上述两个问题,也是当前 RISC-V 架构 ftrace 实现函数跟踪的默认编译选项。

-fpatchable-function-entry 选项介绍

在 gcc 官方文档中,有关于此选项的介绍,链接放在 这里

大致的意思是如果编译时指定 -fpatchable-function-entry=N[,M],会在函数入口后,函数第一个指令之前插入 N 个 nop,但是要留 M 个放在函数入口之前,同时通过一个特殊的 __patchable_function_entries 段来记录所有的函数入口。我们通过一个简单的演示来感受一下:

$ echo 'void abc(){}' | riscv64-linux-gnu-gcc -x c -fpatchable-function-entry=3,1 -S -o - -
        .text
        .globl  abc
        .section        __patchable_function_entries,"a",@progbits
        .quad   .LPFE1
        .text
.LPFE1:
        nop               // M = 1
        .type   abc, @function
abc:                      //函数入口
        nop              // N =2
        nop
        pushq   %rbp
        ...

可能有些同学有疑问,这个 section 是怎么来记录所有的函数入口呢?我在后文会结合内核镜像的编译链接来说明。

在内核的编译过程中会采用这个选项在函数入口插入一定长度的 nop 指令,那单个 nop 指令长度是多大呢?

nop 指令长度

先准备一段简单的汇编,保存为 test.s:

$ cat test.s
.text
.global _start

_start:
  nop
  nop

编译并反汇编如下:

$ riscv64-linux-gnu-gcc -c -o test.o test.s
$ riscv64-linux-gnu-objdump -D test.o

test.o:     file format elf64-littleriscv

Disassembly of section .text:

0000000000000000 <_start>:
   0:   0001                    nop
   2:   0001                    nop

可以看到 nop 指令的大小为 16 bit,这里可能有的同学会比较好奇,RISC-V 的标准指令长度不是 32 bit 么?

其实,这里的 nop 代表的是 16 bit 长的 c.nop,即 RV32C 拓展表示的 nop,对应到标准的 RV32I 的指令码为 0x00000013,如果想要在编译时指定不采用 RV32C,我们可以这样编译:

$ riscv64-linux-gnu-gcc -c -march=rv64imfd -o test1.o test.s
$ riscv64-linux-gnu-objdump -D test1.o

test1.o:     file format elf64-littleriscv

Disassembly of section .text:

0000000000000000 <_start>:
   0:   00000013                nop
   4:   00000013                nop

可以看到,这里的 nop 的指令长度就是 32 bit。但我们编译内核的时候,编译器默认会开启 RV32C,所以内核编译 nop 指令的默认长度为 16 bit。那么内核函数入口插入的 nop 个数是多少呢?我们接着往下看。

支持 ftrace 的 RISC-V 内核编译与链接

配置选项

在 ftrace 编译选项演进过程中,提到 RISC-V 架构采用 -fpatchable-function-entry 选项来进行编译,那就不应该采用 mcount 或者 fentry 相关的编译选项,应该声明 FTRACE_MCOUNT_USE_PATCHABLE_FUNCTION_ENTRY,为此,我向社区提交了个 补丁,后文也以应用此补丁的环境进行分析和实验。

内核编译链接过程

内核在 arch/riscv/Makefile 中指定 CC_FLAGS_FTRACE := -fpatchable-function-entry=8 来编译内核组件。上文提到内核编译 nop 指令的默认长度为 16 bit,那么函数入口插入的 nop 总长度为 8 * 16 bit = 128 bit = 16 Bytes,这些 16 Bytes 的 nop 会在开启函数跟踪的时候修改为对应长度的跳转指令,在启动过程中或者函数跟踪关闭的时候修改为对应长度的 nop,细节可参考 afc76b8b8011 这个提交。

// arch/riscv/Makefile :11

ifeq ($(CONFIG_DYNAMIC_FTRACE),y)
        LDFLAGS_vmlinux := --no-relax
        KBUILD_CPPFLAGS += -DCC_USING_PATCHABLE_FUNCTION_ENTRY
        CC_FLAGS_FTRACE := -fpatchable-function-entry=8   // 这里
endif

编译后的目标文件,有如下两个特征:

  1. 每个函数入口后,第一条指令之前插入 16 Bytes 的 nop 指令
  2. __patchable_function_entries 段的重定位段中,记录了当前目标文件中所有函数的入口地址

vmlinux 的链接经历以下过程:

  1. 在链接合并阶段,将目标文件合并为 vmlinux.o
  2. 在链接重定位阶段,根据 vmlinux.o 中的 __patchable_function_entries 段的重定位段信息对目标段进行重定位
  3. 在链接脚本中,定义 MCOUNT_REC 宏,并在 __{start,stop}_mcount_loc 符号范围内保留 vmlinux.o 中的 __patchable_function_entries 段,同时也兼容 __mcount_loc
// include/asm-generic/vmlinux.lds.h :172

  #define MCOUNT_REC()    . = ALIGN(8);                          \
                        __start_mcount_loc = .;                 \
                        KEEP(*(__mcount_loc))                   \
                        KEEP(*(__patchable_function_entries))   \
                        __stop_mcount_loc = .;                  \

  #define INIT_DATA                                                       \
        KEEP(*(SORT(___kentry+*)))                                      \
        *(.init.data init.data.*)                                       \
        MEM_DISCARD(init.data*)                                         \
        KERNEL_CTORS()                                                  \
        MCOUNT_REC()                                                  \

链接后的 vmlinux 可以通过 __{start,stop}_mcount_loc 符号获取到所有函数入口,同时每个函数入口都会有 16 Bytes 的 nop,用来做指令修改。

接下来,做几个小实验,来加深对上述过程的理解。

编译链接实验

  1. 确认 read_write.c 编译时是否采用了 -fpatchable-function-entry=8 选项

    # head -n1 fs/.read_write.o.cmd  | egrep -o -e fpatchable-function-entry=8 -e pg
      fpatchable-function-entry=8
    
    ## 显然没有 `-pg` 选项
    
  2. 观察编译后的 read_write.o 中的函数入口是否是 8 个 nop,以及所有的函数入口记录在哪里?

    ftrace_patchable_nop

  3. 观察链接后的 vmlinux 中记录的所有函数入口

    ftrace_vmlinux_loc

如上图所示,vmlinux 中所有的函数入口都记录在 __{start,stop}_mcount_loc 中,那么在内核运行时,又是由谁来解析的呢?

ftrace_init

ftrace_init 读取 __{start,stop}_mcount_loc 中记录的所有函数入口地址,记录到 start_pg 链表中,遍历 start_pg 执行 ftrace_init_nop,把 8 个 RV32C 拓展的双字节的 nop,替换为 4 个 RV32I 模式的四字节的 nop。关键代码如下:

ftrace_init
- ftrace_process_locs(NULL, __start_mcount_loc, __stop_mcount_loc)
  - start_pg = ftrace_allocate_pages(count) // 创建所有函数入口链表,通过 pg->record->ip 记录函数入口地址
  - ftrace_update_code(mod, start_pg) // 遍历 start_pg,执行 ftrace_init_nop

ftrace_init_nop
- ftrace_make_nop(mod, rec, MCOUNT_ADDR) // 用 0x00000013 替换 0x00010001

实验观察 ftrace_make_nop 执行前后 vfs_read 的区别:

ftrace_ftrace_init

总结

ftrace 的编译选项在不同架构可能会有不同的选择,当前 RISC-V 采用的是 -fpatchable-funciton-entry=8。编译时,在所有内核函数入口前插入 16 Bytes 的 nop 指令,并创建 __patchable_function_entries 段用来记录所有的函数入口,链接时,把所有的函数入口归档到 __{start,stop}_mcount_loc,启动时,由 ftrace_init 把所有函数入口维护在 start_pgftrace_pages_start)链表中。

在上述的前提下,就可以针对某个特定的函数,修改其 nop 指令,让其被调用时,跳转到自定义的指令中,比如跳转到 _mcount 处,继而执行 ftrace_trace_function 函数指针,来执行特定功能的 tracer(例如:function,function_graph,mmiotrace)。

那么,ftrace 是如何修改某个函数的 nop 指令?修改后又会跳转到哪里?是 _mcount 么?且看下文分解。

速记:-fpatchable_function_entry,16 Bytes 的 nop,链接重定向,__mcount_loc__{start,stop}_mcount_locstart_pgftrace_init_nop

参考资料



Read Album:

Read Related:

Read Latest: