[置顶] 泰晓 RISC-V 实验箱,配套 30+ 讲嵌入式 Linux 系统开发公开课
RISC-V Ftrace 实现原理(2)- 编译时原理
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_*
有 CC
,OBJTOOL
,PATCHABLE_FUNCTION_ENTRY
和 RECORDMCOUNT
四个选择,且基本是互斥的。
FTRACE_MCOUNT_USE_CC
表示使用编译器的
-mrecord-mcount
选项,如果配置了CONFIG_HAVE_NOP_MCOUNT
,会添加-mnop-mcount
选项FTRACE_MCOUNT_USE_RECORDMCOUNT
表示使用
scripts/recordmcount.{pl,c}
脚本来收集函数入口,如果配置了CONFIG_HAVE_C_RECORDMCOUNT
使用对应的二进制版本FTRACE_MCOUNT_USE_PATCHABLE_FUNCTION_ENTRY
表示不选择其他的选项,需要通过自定义的
CC_FLAGS_FTRACE
来指定编译选项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_funtion
为ftrace_record_ip
记录当前 mcount 的调用点到ftrace_hash
。并启动执行周期为 1s,名称为 ftraced 的内核线程,将ftrace_hash
中所有的调用点代码更改为ftrace_jmp
,ftrace_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 的编译选项演进其实都在解决以下两个问题:
如何记录所有的 mcount 调用点
变化过程:
ftrace_record_ip()
=>tools/recordmcount.{pl,c}
=>-mrecord-mcount
=>tools/objtool
如何将 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
编译后的目标文件,有如下两个特征:
- 每个函数入口后,第一条指令之前插入 16 Bytes 的 nop 指令
- 在
__patchable_function_entries
段的重定位段中,记录了当前目标文件中所有函数的入口地址
vmlinux 的链接经历以下过程:
- 在链接合并阶段,将目标文件合并为 vmlinux.o
- 在链接重定位阶段,根据 vmlinux.o 中的
__patchable_function_entries
段的重定位段信息对目标段进行重定位 - 在链接脚本中,定义
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,用来做指令修改。
接下来,做几个小实验,来加深对上述过程的理解。
编译链接实验
确认 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` 选项
观察编译后的 read_write.o 中的函数入口是否是 8 个 nop,以及所有的函数入口记录在哪里?
观察链接后的 vmlinux 中记录的所有函数入口
如上图所示,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 的编译选项在不同架构可能会有不同的选择,当前 RISC-V 采用的是 -fpatchable-funciton-entry=8
。编译时,在所有内核函数入口前插入 16 Bytes 的 nop 指令,并创建 __patchable_function_entries
段用来记录所有的函数入口,链接时,把所有的函数入口归档到 __{start,stop}_mcount_loc
,启动时,由 ftrace_init
把所有函数入口维护在 start_pg
(ftrace_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_loc
,start_pg
,ftrace_init_nop
参考资料
猜你喜欢:
- 我要投稿:发表原创技术文章,收获福利、挚友与行业影响力
- 知识星球:独家 Linux 实战经验与技巧,订阅「Linux知识星球」
- 视频频道:泰晓学院,B 站,发布各类 Linux 视频课
- 开源小店:欢迎光临泰晓科技自营店,购物支持泰晓原创
- 技术交流:Linux 用户技术交流微信群,联系微信号:tinylab
支付宝打赏 ¥9.68元 | 微信打赏 ¥9.68元 | |
请作者喝杯咖啡吧 |
Read Album:
- TinyBPT 和面向 buildroot 的二进制包管理服务(3):服务端说明
- TinyBPT 和面向 buildroot 的二进制包管理服务(2):客户端说明
- TinyBPT 和面向 buildroot 的二进制包管理服务(1):设计简介与框架
- RISC-V Linux 内核及周边技术动态第 118 期
- RISC-V Linux 内核及周边技术动态第 117 期