返回

Arm64 Linux信号处理:跳过故障指令的奥秘

Linux

Arm64 Linux 信号处理:跳过故障指令

在Arm64 Linux环境下,通过用户空间程序“处理”信号,并跳转到 (PC + 8) 位置,这种看似简单的操作实际涉及一些复杂的底层机制。本文将探讨其背后的原理、问题以及可能的解决方案。

问题解析

核心问题在于当程序发生例如 SIGSEGV 信号时,是否能让程序跳转到紧随导致错误指令之后的指令地址继续执行。 初步的尝试仅是让程序跳过错误的指令,但这并非简单地跳转指令那么简单。实际操作中,由于操作系统在CPU异常和用户空间信号处理程序之间存在着介入(interposition),所以signal_handler运行时的栈、帧、链接寄存器(LR)等都可能与发生错误访问处的寄存器状态大相径庭。另外,信号处理函数(signal_handler)运行在信号上下文,这限制了信号处理函数内部的复杂操作。

具体来说,在SIGSEGV发生后:

  1. 寄存器状态不一致 :进入 signal_handler 时,寄存器的值并非发生 SIGSEGV 时的值,因为操作系统内核在处理异常时,上下文会发生改变。
  2. 信号上下文限制 :在信号处理程序中执行代码有一定的限制。例如,不应该执行可能会产生阻塞操作的函数。

简单的跳过指令无法解决根本问题,需要保存发生异常前的寄存器状态,并在处理后恢复。

解决方案

以下提出了保存、恢复寄存器,以及执行跳转的具体步骤。

1. 保存与恢复寄存器

为了让程序在跳转后能够顺利执行,需要在信号发生前保存所有的通用寄存器(包括 sp 堆栈指针),在信号处理程序中恢复它们,使用 SAVE_REGISTERSLOAD_REGISTERS 宏进行寄存器保存和恢复。

static volatile uint64_t register_save_set[32];

#define SAVE_REGISTERS() do {          \
    __asm__ volatile (                 \
        "stp x0, x1, [%0]\n"          \
        "mov x0, sp\n"                \
        "str x0, [%0, #248]\n"       \
        "stp x2, x3, [%0, #16]\n"     \
        "stp x4, x5, [%0, #32]\n"     \
        "stp x6, x7, [%0, #48]\n"     \
        "stp x8, x9, [%0, #64]\n"     \
        "stp x10, x11, [%0, #80]\n"   \
        "stp x12, x13, [%0, #96]\n"   \
        "stp x14, x15, [%0, #112]\n"  \
        "stp x16, x17, [%0, #128]\n"  \
        "stp x18, x19, [%0, #144]\n"  \
        "stp x20, x21, [%0, #160]\n"  \
        "stp x22, x23, [%0, #176]\n"  \
        "stp x24, x25, [%0, #192]\n"  \
        "stp x26, x27, [%0, #208]\n"  \
        "stp x28, x29, [%0, #224]\n"  \
        "str x30, [%0, #240]\n"       \
        :                               \
        : "r" (register_save_set)     \
        : "memory", "x0"              \
    );                                  \
} while (0)

#define LOAD_REGISTERS() do {          \
    __asm__ volatile (                 \
        "ldr x0, [%0, #248]\n"        \
        "mov sp, x0\n"                \
        "ldp x0, x1, [%0]\n"          \
        "ldp x2, x3, [%0, #16]\n"     \
        "ldp x4, x5, [%0, #32]\n"     \
        "ldp x6, x7, [%0, #48]\n"     \
        "ldp x8, x9, [%0, #64]\n"     \
        "ldp x10, x11, [%0, #80]\n"   \
        "ldp x12, x13, [%0, #96]\n"   \
        "ldp x14, x15, [%0, #112]\n"  \
        "ldp x16, x17, [%0, #128]\n"  \
        "ldp x18, x19, [%0, #144]\n"  \
        "ldp x20, x21, [%0, #160]\n"  \
        "ldp x22, x23, [%0, #176]\n"  \
        "ldp x24, x25, [%0, #192]\n"  \
        "ldp x26, x27, [%0, #208]\n"  \
        "ldp x28, x29, [%0, #224]\n"  \
        "ldr x30, [%0, #240]\n"       \
        :                               \
        : "r" (register_save_set)     \
        :                     \
    );                                  \
} while (0)

SAVE_REGISTERS 宏将 x0-x29 通用寄存器,x30(lr),以及 sp 寄存器的值存储到全局变量 register_save_set 数组中, LOAD_REGISTERS 宏从数组中恢复这些寄存器值,strldr指令用来保存和恢复单个寄存器值,stpldp 指令用来成对的保存和恢复寄存器值。 使用汇编指令可直接操作寄存器。 此外,这些宏定义使用了 volatile ,阻止了编译器优化,以保证汇编代码中的寄存器保存和读取按照预期顺序进行。

2. 实现信号处理程序

信号处理程序首先执行 LOAD_REGISTERS 来恢复先前保存的寄存器状态,随后使用 br (Branch Register)指令来跳转到 (PC + 8) 位置。

void signal_handler(int signal, siginfo_t* info, void* unused) {
        if (signal != SIGSEGV) {
                std::cerr << "Got an unexpected signal " << signal << std::endl;
                exit(1);
        }
        std::cout << "Got a SIGSEGV with si_addr = " << info->si_addr << std::endl;
        LOAD_REGISTERS();
        __asm__ volatile (
                "br %0" : : "r" (info->si_addr + 8)
        );
        return;
}

这里,通过 info->si_addr + 8 来计算目标地址。 info->si_addr 保存着触发 SIGSEGV 的地址,而加 8 的目的是跳过发生错误访问的指令。

3. 在 main 函数中执行保存操作和跳转指令

在可能会产生错误的内存访问指令之前执行 SAVE_REGISTERS() , 保证程序运行过程中遇到段错误信号能及时响应。 内存访问之后加上无限循环。

int main() {
        struct sigaction action;
        memset(&action, 0, sizeof(action));
        action.sa_flags = SA_SIGINFO;
        action.sa_sigaction = signal_handler;
        sigaction(SIGSEGV, &action, nullptr);

        const volatile uint64_t* root_ptr = reinterpret_cast<uint64_t*>(0xdaaf3254 << 12);
        SAVE_REGISTERS();
        *root_ptr;
        volatile int spin = 0;
        while (true) { spin += 1; }
}

这里,注册信号处理函数,并且强制访问非法地址触发信号。 程序首先通过 SAVE_REGISTERS() 宏保存当前寄存器状态,尝试进行非法内存访问触发 SIGSEGV 信号。 信号处理函数会恢复寄存器值,然后跳过错误指令, 随后进入 while 循环。

操作步骤

  1. 将上述代码保存为 test.cpp 文件。

  2. 编译代码:

    g++ test.cpp -o test
    
  3. 运行代码:

    ./test
    

    预期程序应输出 "Got a SIGSEGV..." 并且进入 while(true) 死循环,而不再是直接终止程序。

补充说明

  • 安全建议 : 虽然通过跳过错误指令可以在某些特定情况下继续执行程序,但它不是一种推荐的错误处理方式。这种方法可能会导致程序在未知状态下运行,掩盖更深层次的程序错误。应该尽可能地捕获和解决错误,而不是忽略或跳过它们。通常推荐的做法是在 signal_handler 中处理好上下文切换的问题, 例如抛出一个异常或者退出程序,进行善后的资源清理操作。
  • 可移植性 : 此方法依赖于 arm64 的汇编代码,在其他架构(例如 x86_64)上无法直接使用,需要进行架构相关的适配调整。
  • volatile关键字 :在内联汇编代码中合理使用volatile 关键字能确保代码的预期执行顺序,这避免编译器因优化产生不符合预期的结果。但也要注意它的负面作用。例如过度的使用volatile关键字可能降低编译后的执行效率。

结论

虽然使用 (PC+8) 跳转的方法能让程序在 SIGSEGV 之后继续执行,但应该深入了解其原理和潜在问题,并且尽可能采取更稳妥的处理方法,保证程序的稳定运行。 理解底层细节可以更好地分析,排查和处理系统错误。