Arm64 Linux信号处理:跳过故障指令的奥秘
2024-12-26 01:16:16
Arm64 Linux 信号处理:跳过故障指令
在Arm64 Linux环境下,通过用户空间程序“处理”信号,并跳转到 (PC + 8) 位置,这种看似简单的操作实际涉及一些复杂的底层机制。本文将探讨其背后的原理、问题以及可能的解决方案。
问题解析
核心问题在于当程序发生例如 SIGSEGV
信号时,是否能让程序跳转到紧随导致错误指令之后的指令地址继续执行。 初步的尝试仅是让程序跳过错误的指令,但这并非简单地跳转指令那么简单。实际操作中,由于操作系统在CPU异常和用户空间信号处理程序之间存在着介入(interposition),所以signal_handler
运行时的栈、帧、链接寄存器(LR)等都可能与发生错误访问处的寄存器状态大相径庭。另外,信号处理函数(signal_handler
)运行在信号上下文,这限制了信号处理函数内部的复杂操作。
具体来说,在SIGSEGV
发生后:
- 寄存器状态不一致 :进入
signal_handler
时,寄存器的值并非发生SIGSEGV
时的值,因为操作系统内核在处理异常时,上下文会发生改变。 - 信号上下文限制 :在信号处理程序中执行代码有一定的限制。例如,不应该执行可能会产生阻塞操作的函数。
简单的跳过指令无法解决根本问题,需要保存发生异常前的寄存器状态,并在处理后恢复。
解决方案
以下提出了保存、恢复寄存器,以及执行跳转的具体步骤。
1. 保存与恢复寄存器
为了让程序在跳转后能够顺利执行,需要在信号发生前保存所有的通用寄存器(包括 sp
堆栈指针),在信号处理程序中恢复它们,使用 SAVE_REGISTERS
和 LOAD_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
宏从数组中恢复这些寄存器值,str
和 ldr
指令用来保存和恢复单个寄存器值,stp
和 ldp
指令用来成对的保存和恢复寄存器值。 使用汇编指令可直接操作寄存器。 此外,这些宏定义使用了 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
循环。
操作步骤
-
将上述代码保存为
test.cpp
文件。 -
编译代码:
g++ test.cpp -o test
-
运行代码:
./test
预期程序应输出 "Got a SIGSEGV..." 并且进入
while(true)
死循环,而不再是直接终止程序。
补充说明
- 安全建议 : 虽然通过跳过错误指令可以在某些特定情况下继续执行程序,但它不是一种推荐的错误处理方式。这种方法可能会导致程序在未知状态下运行,掩盖更深层次的程序错误。应该尽可能地捕获和解决错误,而不是忽略或跳过它们。通常推荐的做法是在
signal_handler
中处理好上下文切换的问题, 例如抛出一个异常或者退出程序,进行善后的资源清理操作。 - 可移植性 : 此方法依赖于 arm64 的汇编代码,在其他架构(例如 x86_64)上无法直接使用,需要进行架构相关的适配调整。
- volatile关键字 :在内联汇编代码中合理使用
volatile
关键字能确保代码的预期执行顺序,这避免编译器因优化产生不符合预期的结果。但也要注意它的负面作用。例如过度的使用volatile
关键字可能降低编译后的执行效率。
结论
虽然使用 (PC+8) 跳转的方法能让程序在 SIGSEGV
之后继续执行,但应该深入了解其原理和潜在问题,并且尽可能采取更稳妥的处理方法,保证程序的稳定运行。 理解底层细节可以更好地分析,排查和处理系统错误。