使用内核模块处理用户进程的异常

计算机组成课程一直使用 Xilinx ISE 作为 Verilog 自动评测工具。最近我们在调研使用 Synopsys VCS 替换 ISE,搭建新的自动化测试平台,在替换的过程中,陈昊同学发现了 VCS 的一个 bug,使得部分老代码在 VCS 上无法正常仿真。通过修改内核来劫持异常逻辑,我成功修复了这个 bug。本文介绍我的探索思路和修复代码。

我遇到问题可以用以下代码来复现。使用 VCS 编译并运行 simv 仿真程序,程序会立即崩溃,提示 Floating Point Error。

module tb;

reg signed [31:0] a, b;
wire signed [31:0] bb = a % b;

initial begin
    a = -2147483648;
    b = -1;
    $display("%d\n", bb);
end

endmodule

这段代码的功能是求 -2147483648 除以 -1 的余数。这个除法计算的特殊之处在于,其商无法用 32 位有符号数表示。

为了调查 SIGFPE 的原因,我尝试用 C 语言实现类似的计算逻辑。编译并执行以下代码,程序也会收到同样的 SIGFPE 信号。

#include <stdio.h>

int main() {
    int a, b;
    scanf("%d%d", &a, &b); // 输入 -2147483648 -1
    int bb = a % b;
    printf("%d\n", bb);
}

经过简单的调查发现,x86 平台下,有符号除法通常使用 idiv 指令实现,该指令在结果无法装下或除数为 0 时,就会引发一个 Division Error(具体可以参考 指令描述),而 Linux 内核会将 Division Error 翻译为 SIGFPE。

进一步实验发现,若在 C 语言中将求模改为除法,或将 b 的值改为 0,仍然会引发 SIGFPE 异常;在 Verilog 中,将求模改为除法或将 b 改为 0,却不会引发 SIGFPE。猜测 VCS 在实现除法和求模运算时,对两种较为常见的情况(模 0 或无效的除法)做了特判规避异常,但对于本文遇到的极端情况没有特判,直接使用 idiv 指令进行运算,导致程序崩溃。

由于我们无法修改 VCS 生成的仿真代码本身,只能在外部想办法解决。我首先尝试了使用 DPI-C 在仿真程序中插入 C 语言代码,拦截 SIGFPE 信号,但拦截并不生效,可能是因为 VCS 程序本身也添加了这个钩子,使我们的钩子失效了。Menci 建议使用 ptrace 调试并忽略 SIGFPE 信号,我认为有道理,但感觉比较麻烦,就没有尝试。

我选择了一种看起来比较扭曲的思路:修改内核中的异常处理代码,在遇到除法错误时,根据情况跳过除法指令。

我首先在内核中寻找除法异常的处理程序。首先,这种异常一定是与 CPU 的指令集架构相关的,因此相关代码一定在 Linux 内核中的 arch/x86 目录下。x86 的多数异常处理程序存放于 entry/entry_32.S 中,在此文件中翻找,不难发现除法异常的处理程序位于 1200 行左右,具体如下:

ENTRY(divide_error)
        ASM_CLAC
        pushl   $0                              # no error code
        pushl   $do_divide_error
        jmp     common_exception
END(divide_error)

这段代码将 $do_divide_error 符号的地址推入栈内,然后跳到了 common_exception 标签。在 common_exception 标签中,进行了一些诸如寄存器压栈的操作,然后跳到了 C 语言环境下的异常处理程序。那么,重点一定在 do_divide_error 符号内。经过一番寻找,在 kernel/traps.c 里面找到了此符号的定义:

#define DO_ERROR(trapnr, signr, str, name)                           \
        dotraplinkage void do_##name(struct pt_regs *regs,           \
                                     long error_code)                \
        {                                                            \
                do_error_trap(regs, error_code, str, trapnr, signr); \
        }

DO_ERROR(X86_TRAP_DE, SIGFPE, "divide error", divide_error)

do_divide_error 是使用 DO_ERROR 宏定义的函数,可以看到该函数会直接调用 do_error_trap,其具体实现如下(部分代码省略):

static void do_error_trap(struct pt_regs *regs, long error_code,
			  char *str, unsigned long trapnr, int signr)
{
	// ...
	if (!user_mode(regs) && fixup_bug(regs, trapnr))
		return;

	if (notify_die(DIE_TRAP, str, regs, error_code, trapnr, signr) !=
			NOTIFY_STOP) {
		// ...
		do_trap(trapnr, signr, str, regs, error_code,
			fill_trap_info(regs, signr, trapnr, &info));
	}
}

阅读代码发现 do_trap 函数是实际处理异常的部分,该函数会向当前线程发送异常信号。我不关心发送信号的具体细节,而是希望绕过当前除法指令,不要向进程发送信号。观察第 5-6 行,猜测只要使 do_error_trap 返回,不要执行 do_trap 函数,是不是就可以使 SIGFPE 信号不要发送到进程了呢?

现在必须要来实验一下了。我没有选择使用实体机器实验,而是参照这篇文章,搭建了一个 QEMU 实验环境。使用 QEMU 进行内核实验,修改和编译都比较方便,并且可以使内核控制台用虚拟串口传出,直接在系统的终端模拟器上就能和内核交互。

我在 do_error_trap 开头加入了如下代码:

if (trapnr == X86_TRAP_DE) {
    unsigned long eax = regs->ax;
    printk("You are being fucked 0x%lX!", eax);
    regs->ip += 3;
    return;
}

这段代码会在遇到 Divide error 时,打印出当前的 eax 值(eax 为被除数),并把 eip(即 PC)加 3,跳过 idiv 指令。注意 3 是我的 C 语言测试程序中 idiv 指令的长度,实际 idiv 长度也是可变的,需读取指令内容来确定长度。这段代码加入内核后,再执行 C 语言测试程序,已不会引发异常,且内核正常读取 eax 值:

成功避免 SIGFPE

为了使这个补丁更通用,我们接下来需要解决三个问题:

  1. 不修改内核源代码,直接内核模块来达到同样的效果;
  2. 动态地根据 idiv 指令的长度来调整 ip 的递增量;
  3. 加强判断条件,仅针对我们的目标程序实施拦截。

首先看第一个问题。再观察 do_error_trap 函数,可以发现,如果要规避掉 do_trap 的处理流程,除了增加代码使函数提前返回,还可以让 notify_die 函数返回 NOTIFY_STOP 。查阅资料发现,notify_die 是 Linux 内核提供的一个错误处理机制,通过 register_die_notifier 函数,我们可以注册一个侦听器,在遇到异常时可以进行判断并做出操作。使用 Die Notifier 机制来处理异常,显然比直接增加代码更为合理。

更关键的是,register_die_notifier (以及对应的 unregister_die_notifier)是 EXPORT_SYMBOL_GPL 的函数,所以我们可以在内核模块中调用。有了这两个工具,我们就无需再修改内核源代码,只需编写内核模块就可以完成任务了。

第二个问题可以通过解析指令来解决。Linux 内核的 x86 部分已经提供了基础的指令解析(arch/x86/lib/insn.c),不过很遗憾没有 Export 出来,因此只能将 insn.c 及依赖的文件直接加到 module 里面。

指令解析的具体代码是参考 kernel/umip.c 做的。首先使用 ip 的值配合 copy_from_user 函数获得具体指令,然后用 insn_init 初始化指令解析,最后使用 insn_get_length 就可以获得指令长度了。

第三个问题,还没想好怎么解决,等想好了再写吧。

最终完成内核模块,代码存放于 https://github.com/t123yh/idiv-fixup,关键代码位于 idiv-fixup.c 中。

CC BY-SA 4.0 本作品使用基于以下许可授权:Creative Commons Attribution-ShareAlike 4.0 International License.

WordPress Appliance - Powered by TurnKey Linux