文/扶风、丁缓、 雏雁 linux
Linux 内核热补丁能够修复正在运行的 linux 内核,是一种维持线上稳定性不可缺乏的措施,如今比较常见的好比 kpatch 和 livepatch。内核热补丁能够修复内核中正在运行的函数,用已修复的函数替换掉内核中存在问题的函数从而达到修复目的。segmentfault
函数替换的思想比较简单,就是在执行旧函数时绕开它的执行逻辑而跳转到新的函数中,有一种比较简单粗暴的方式,就是将原函数的第一条指令修改成“jump 目标函数”指令,即直接跳转到新的函数以达到替换目的。安全
那么,问题来了,这么作靠谱吗?直接将原函数的第一条指令修改成 jump 指令,会破坏掉原函数和它的调用者之间的寄存器上下文关系,存在安全隐患!本文会针对该问题进行探索和验证。架构
对于函数调用,假设存在这样两个函数 funA 和 funB,其中 funA 调用 funB 函数,这里称 funA 为 caller(调用者),funB 为 callee(被调用者),funA 和 funB 都使用了相同的寄存器 R,以下所示:app
图1 funA 和 funB 都使用了寄存器 R,funA 再次使用 R 时已经被 funB 修改函数
所以,当 funA 再次使用到 R 的数据已是错误的数据了。若是 funA 在调用 funB 前保存寄存器 R 中的数据,funB 返回后再将数据恢复到 R 中,或者 funB 先保存 R 中原有的数据,而后在返回前恢复,就能够解决这类问题。工具
那寄存器该由 caller 仍是 callee 来保存?这就须要遵循函数的调用约定(call convention),不一样的 ABI 和不一样的平台,函数的调用约定是不同的,对于 Linux 来讲,它遵循的是 System V ABI 的 call convention,x86_64 平台下函数调用约定有且只有一种,调用者 caller 和被调用者 callee 须要对相应的寄存器进行保存和恢复操做:性能
设问:当函数实现很简单,只用到了少许寄存器,那没使用到的还须要保存吗?
答案:it depends。根据编译选项决定。测试
众所周知,GCC 编译器有 -O0、-O一、-O2 和 -Ox 等编译优化选项,优化范围和深度随 x 增大而增大(-O0是不优化,其中隐含的意思是,它会严格遵循 ABI 中的调用约定,对全部使用的寄存器进行保存和恢复)。优化
Linux 内核选用的都是 -O2 优化。GCC 会选择性的不遵照调用约定,也就是设问里提到的,不须要保存没使用到的寄存器。
GCC 之因此能够作这个优化,是由于 GCC 高屋建瓴,了解程序的执行流。当它知道 callee,caller 的寄存器分配状况,就会大胆且安全地作各类优化。
可是,运行时替换破坏了这个假设,GCC 所掌握的 callee 信息,极有多是错误的。那么这些优化可能会引起严重问题。这里以一个具体的实例进行详细说明,这是一个用户态的例子( x86_64 平台):
//test.c 文件 //编译命令:gcc test.c -o test -O2 (kernel 采用的是 O2 优化选项) //执行过程:./test //输入参数:4 #include <sys/mman.h> #include <string.h> #include <stdio.h> #include <math.h> #define noinline __attribute__ ((noinline)) //禁止内联 static noinline int c(int x) { return x * x * x; } static noinline int b(int x) { return x; } static noinline int newb(int x) { return c(x * 2) * x; } static noinline int a(int x) { int volatile tmp = b(x); // tmp = 8 ** 3 * 4 return x + tmp; // return 4(not 8) + tmp } int main(void) { int x; scanf("%d", &x); if (mprotect((void*)(((unsigned long)&b) & (~0xFFFF)), 15, PROT_WRITE | PROT_EXEC | PROT_READ)) { perror("mprotect"); return 1; } /* 利用 jump 指令将函数 b 替换为 newb 函数 */ ((char*)b)[0] = 0xe9; *(long*)((unsigned long)b + 1) = (unsigned long)&newb - (unsigned long)&b - 5; printf("%d", a(x)); return 0; }
该例子说明,直接使用 jump 指令替换函数在 -O2 的编译优化下,会出现问题,安全性受到了质疑和冲击!!!
上述例子中,咱们将函数 b 用 jump 指令替换为 newb 函数,在 -O2 的编译优化下出现了计算错误的结果。所以,咱们须要对函数的调用执行过程进行仔细分析,挖掘问题所在。首先,咱们先来查看一下该程序的反汇编(指令:objdump -d test),并重点关注 a、b 和 newb 函数:
图2 -O2 编译优化的反汇编结果
汇编解释:
main: -> 将参数 4 存放到 edi 寄存器中 -> 调用 a 函数: -> 调用 b 函数,直接跳转到 newb 函数: -> 将 edi 寄存器中的值存放到 edx 寄存器 -> edi 寄存器与自身相加后结果放入 edi -> 调用 c 函数: -> 将 edi 寄存器中的值存到 eax 寄存器 -> edi 乘以 eax 后结果放入 eax -> edi 乘以 eax 后结果放入 eax -> 返回到 newb 函数 -> 将 edx 与 eax 相乘后结果放入 eax -> 返回到 a 函数 -> 将 edi 与 eax 相加后结果放入 eax -> 返回 main 函数
(注意:b 函数中没有对 edi 寄存器进行写操做,并且它的代码段被修改成 jump 指令跳转到 newb 函数)
数据出错的缘由在于,在函数 newb 中,使用到了 a 函数中使用的 edi 寄存器,edi 寄存器中的值在 newb 函数中被修改成 8,当 newb 函数返回后,edi 的值仍然是 8,a 函数继续使用了该值,所以,计算过程变为:8^3 4 + 8 = 2056,而正确的计算结果应该是 8^3 4 + 4 = 2052。
接下来不进行编译优化(-O0),其输出结果是正确的 2052,反汇编以下所示:
图3 不进行编译优化的反汇编
从反汇编中能够看到,函数 a 在调用 b 函数前,将 edi 寄存器的值存在了栈上,调用以后,将栈上的数据再取出,最后进行相加。这就说明,-O2 优化选项将 edi 寄存器的保存和恢复操做优化掉了,而在调用约定中,edi 寄存器本就该属于 caller 进行保存/恢复的。至于为何编译器会进行优化,咱们此刻的猜测是:
a 函数原本调用的是 b 函数,并且编译器知道 b 函数中没有使用到 edi 寄存器,所以调用者 a 函数没有对该寄存器进行保存和恢复操做。可是编译器不知道的是,在程序运行时,b 函数的代码段被动态修改,利用 jump 指令替换为 newb 函数,而在 newb 函数中对 edi 寄存器进行了数据读写操做,因而出现了错误。
这是一个典型的没有保存 caller-save 寄存器致使数据出错的场景。
而编译内核采用的也是 -O2 选项。若是将该场景应用到内核函数热替换是否会出现这类问题呢?因而,咱们带着问题继续探索。
咱们构造了一个内核函数热替换的实例,将上面的用户态的例子移植到咱们构造的场景中,经过内核模块修改原函数的代码段,用 jump 指令直接替换原来的 b 函数。然而加载模块后,结果是正确的 2052,通过反汇编咱们发现,内核中 a 函数对 edi 寄存器进行了保存操做:
图4 内核中 a 函数的反汇编
内核和模块编译时采用的是 -O2 优化选项,而此处 a 函数并无被优化,仍然保存了 edi 寄存器。
此时咱们预测:对于内核函数的热替换来讲,使用 jump 作函数替换是安全的。
咱们猜测是不是内核编译时使用其它的编译选项致使问题不能复现。果不其然,通过探索咱们发现内核编译使用的 -pg 选项致使问题再也不复现。
经过翻阅 GCC 手册得知,-pg 选项是为了支持 GNU 的 gprop 性能分析工具所引入的,它能在函数中增长一条 call mount 指令,去作一些分析工做。
在内核中,若是开启了 CONFIG_FUNCTION_TRACER,则会使能 -pg 选项。
图5 开启 CONFIG_FUNCTION_TRACER 使能 -pg 选项
FUNCTION_TRACE 即咱们常说的 ftrace 功能,ftrace 大大提高了内核的运行时调试能力。ftrace 功能除了 -pg 选项,还要求打开 -mfentry 选项,后者的做用是将函数对 mcount 的调用放到函数的第一条指令处,而后经过 scripts/recordmcount.pl 脚本将该条 call 指令修改成 nop 指令。但 -mfentry 与本文主题没有关联,再也不细说。
为了验证这个结论,咱们回到上一节的用户态例子,而且增长了 -pg 编译选项:“gcc test.c -o test -O2 -pg”,此时运行结果果真正确了。查看其反汇编:
图6 增长 -pg 选项后的汇编
能够看到,每一个函数都有 call mcount 指令,并且 a 函数中将 edi 寄存器保存到 ebx 中,在 newb 函数中又保存 ebx 寄存器。为何在增长了 call mount 指令后,会作寄存器的保存操做?咱们猜测,会不会是由于,因为 call mount 操做至关于调用了一个未知的函数( mcount 没有定义在同一个文件中),所以,GCC 认为这样未知的操做可能会污染了寄存器的数据,因此它才进行了保存现场的操做。
因而咱们去掉了 -pg 选项,手动增长了 call mount 的行为进行验证:在另外一个源文件 mcount.c 中增长一个函数 void mcount() { asm("nop\n"); },在 test.c 文件中增长对 mcount 函数的声明,a 函数中增长对该函数的调用:
extern void mcount(); //声明 mcount 函数 static noinline int a(int x){ int volatile tmp = b(x); // tmp = 8 ** 3 * 4 mcount(); return x + tmp; // return 4(not 8) + tmp }
通过编译:gcc test.c mcount.c -O2 后运行,发现计算结果正确,并且反汇编中 a 函数保存了寄存器:
图7 调用 mcount 函数后的汇编
继续验证猜测,将 mcount 函数放在 test.c 文件中,计算结果错误,并且,反汇编中没有保存寄存器,因而咱们获得了这样的猜测结论:
通过咱们的探索和资料的查阅,发现了这个 -fipa-ra 选项,能够说它是优化的幕后主使。GCC 手册中给出 -fipa-ra 选项的解释是:
这里主要是说,若是开启这个选项,那么,callee 中若是没有使用到 caller 使用的寄存器,就没有必要保存这些寄存器,前提是,callee 与 caller 在同一个编译单元中并且 callee 函数比 caller 先被编译,这样才可能出现前面的优化。若是开启了 -O2 及以上的编译优化选项,则会使能 -fipa-ra 选项,然而,若是开启了 -p 或者 -pg 这些选项,或者,没法明确 callee 所使用的寄存器,-fipa-ra 选项会被禁用。
这段话,其实已经能 cover 掉咱们前面大部分猜测的测试验证:
用过 ftrace 或者内核开发者应该对 notrace 属性不陌生,内核中有一些被 notrace 修饰的函数。notrace 其实就是给函数增长 no_instrument_function 属性。例如,在 X86 的定义:
#define notrace __attribute__((no_instrument_function))
字面上来看,notrace 和 -pg 的含义能够说彻底对立,-pg 让 jump 变得安全,是否又会在 notrace 上栽一个跟斗呢?幸运的是,咱们接下来将看到,notrace 仅仅是禁止了 instrument function,而没有破坏安全性。
gcc 手册中的 -pg 选项给出这样的解释:
这里主要是说,加上 notrace 属性的函数,不会产生调用 mcount 的行为,那么,是否意味着再也不保护寄存器现场,换句话说,notrace 的出现是否会绕过“-pg 选项对 -fipa-ra 优化的屏蔽”?因而咱们又增长 notrace 属性进行验证:在 a 函数中增长 notrace 的属性,由于 a 函数是 caller,编译时开启 -pg 选项,而后检查计算结果及反汇编,最后发现,计算结果正确,并且汇编代码中保存了寄存器现场。
图8 给 a 函数追加 notrace 属性,a 函数没有调用 mcount 的行为
咱们又对全部的函数追加了 notrace 属性,计算结果正确且寄存器现场被保护。可是这些简单的验证不足以证实,因而咱们经过阅读 GCC 源码发现:
图9 -pg 能禁用 -fipa_ra 选项
图10 gcc 处理每个函数时都会检查 -fipa-rq 选项,若是为 false,则不对函数进行优化
经过源码阅读,能够肯定的是,当使用了 -pg 选项后,会禁用 -fipa-rq 优化选项,GCC 检查每个函数的时候都会检查该选项,若是为 false,则不会对该函数进行优化。
因为 flag_ipa_ra 是一个全局选项,并非函数粒度的,notrace 也无能为力。所以,这里能够排除对 notrace 的顾虑。
通过上述的探索分析以及官方资料的查阅,咱们能够得出结论:
论据:
经过翻阅手册得知,ARMv8 ABI 中对过程调用时通用寄存器的使用准则以下:
(资料来源:https://developer.arm.com/doc...):
These are used to pass parameters to a function and to return a result. They can be used as scratch registers or as caller-saved register variables that can hold intermediate values within a function, between calls to other functions. The fact that 8 registers are available for passing parameters reduces the need to spill parameters to the stack when compared with AArch32.
If the caller requires the values in any of these registers to be preserved across a call to another function, the caller must save the affected registers in its own stack frame. They can be modified by the called subroutine without the need to save and restore them before returning to the caller.
These registers are saved in the callee frame. They can be modified by the called subroutine as long as they are saved and restored before returning.
Figure 9.1 shows the 64-bit X registers. For more information on registers, see . For information on floating-point parameters, see Floating-point parameters.
Figure 9.1. General-purpose register use in the ABI
可见,ARMv8 ABI 中对函数调用时的寄存器使用有了明确的规定。
咱们对于前面 x86-64 下的探索验证过程在 arm64 平台下从新作了测试,相同的代码和相同的测试过程,得出的结论和 x86-64 下的结论是一致的,即,在 arm64 下,直接利用 jump 指令实现函数替换一样是安全的。
对于 C 语言而言,在不一样的架构和系统下都有固定的 ABI 和 calling conventions,可是其它的语言不能保证,好比 rust 语言,rust 自身并无固定的 ABI,好比社区对 rust 定义 ABI 的讨论,并且 rustc 编译器的优化和 gcc 可能会有不一样,所以可能也会出现上述 caller/callee-save 寄存器的问题。
kpatch 利用的是 ftrace 进行函数替换的,它的原理以下所示:
图11 kpatch 利用 ftrace 替换函数
ftrace 的主要做用是用来作 trace 的,会在函数头部或者尾部 hook 一个函数进行一些额外的处理,这些函数在运行过程当中可能会污染被 trace 的函数的寄存器上下文,所以 ftrace 定义了一个 trampoline 进行寄存器的保存和恢复操做(图11 中的红框),这样从 hook 函数回来后,寄存器现场仍然是原来的模样。
kpatch 用 ftrace 进行函数替换,hook 的函数是 kpatch 中的函数,该函数的做用是修改 regs 中的 ip 字段的值,也就是将新函数的地址给到了 ip 字段,等 trampoline 恢复寄存器现场后,就直接跳转到新的函数函数去执行了。因此,对于 kpatch 而言,ftrace 的保存和恢复现场操做保护的是 kpatch 中修改 ip 字段函数的过程,而不是它要替换的新函数。
若是修复的是一个热函数,那么 ftrace 的 trampoline 会对性能产生必定的影响。因此,若考虑到性能的场景,那么使用 jump 指令直接替换函数能够很大的减小额外的性能开销。
邓二伟(扶风),2020 年就任于阿里云操做系统内核研发团队,目前从事 linux 内核研发工做。
吴一昊(丁缓),2017 年加入阿里云操做系统团队,主要经历有资源隔离、热升级、调度器 SLI 等。
陈善佩(雏雁),高级技术专家,兴趣方向包括:体系结构、调度器、虚拟化、内存管理。
讨论这么热烈,怎么能少了组织沉淀?
Cloud Kernel SIG 盛情邀请你的加入
云内核 (Cloud Kernel)是一款定制优化版的内核产品,在 Cloud Kernel 中实现了若干针对云基础设施和产品而优化的特性和改进功能,旨在提升云端和云下客户的使用体验。与其余 Linux 内核产品相似,Cloud Kernel 理论上能够运行于几乎全部常见的 Linux 发行版中。
在 2020 年,云内核项目加入 OpenAnolis 社区你们庭,OpenAnolis 是一个开源操做系统社区及系统软件创新平台,致力于经过开放的社区合做,推进软硬件及应用生态繁荣发展,共同构建云计算系统技术底座。
打开钉钉扫一扫哦