老师从小就教育我们,C语言编程一定要注意指针的有效性,一定要注意指针的有效性,一定要注意指针的有效性。所以当定义一个指针时,我们通常将它初始化为空指针:
int *p = NULL;
这样即使后续有代码不小心对该指针做解引用访问,系统也能侦测到这个错误,程序能及时被终止。如果没有初始化为空指针,而是一个随机值,也就是所谓的野指针,那程序就有可能“成功”地修改掉其他地址的内存,产生一个非预期的结果。程序误改其他内存导致的错误和系统崩溃,通常更难分析定位,因为问题现场已经离错误源头有了一定的距离。
那么系统是如何能侦测到空指针的呢?不妨从Linux内核里面看一看蛛丝马迹。如下是在Linux内核中做的几个实际测试,测试环境:内核版本5.10,体系结构ARM64,编译工具链 aarch-linux-gnu-*,运行环境qemu。
测试代码1:
static int gpio_keys_probe(struct platform_device *pdev)
{
...
char *test_pointer;
test_pointer = NULL;
*test_pointer = 0xAA;
...
运行结果1:
看看这幅图的第一行,内核发生了异常,提示在地址0处发生了空指针解引用的操作。这个很符合我们的预期,别着急,再看看第2个例子。
测试代码2:
static int gpio_keys_probe(struct platform_device *pdev)
{
...
char *test_pointer;
test_pointer = 0x10;
*test_pointer = 0xAA;
...
运行结果2:
有没有发现,内核还是提示了NULL pointer的异常,但是发生的地址是0x10。所以看起来,空指针不仅仅是指向地址0的指针。别着急,再看看第3个例子。
测试代码3:
static int gpio_keys_probe(struct platform_device *pdev)
{
...
char *test_pointer;
test_pointer = 4095;
*test_pointer = 0xAA;
...
测试结果3:
看到没有,4095(0xfff)这个地址,内核还是会报NULL pointer异常。看起来空指针包括了某个范围内的一段地址?那这个范围是多大呢?别着急,继续看看第4个例子。
测试代码4:
static int gpio_keys_probe(struct platform_device *pdev)
{
...
char *test_pointer;
test_pointer = 4096;
*test_pointer = 0xAA;
...
测试结果4:
情况终于发生了变化,这次内核也报异常了,但是不再是NULL pointer的异常,而是paging request异常,无法通过页表找到4096(0x1000)这个地址。到这里,我们大概能推测出空指针的定义范围是0~4095。是时候查阅源码了。
内核异常时的信息由如下函数打印,可以看到具体的错误提示和地址是通过参数msg和addr传入的:
static void die_kernel_fault(const char *msg, unsigned long addr,
unsigned int esr, struct pt_regs *regs)
{
bust_spinlocks(1);
pr_alert("Unable to handle kernel %s at virtual address %016lx\n", msg,
addr);
mem_abort_decode(esr);
show_pte(addr);
die("Oops", regs, esr);
bust_spinlocks(0);
make_task_dead(SIGKILL);
}
https://elixir.bootlin.com/linux/v5.10.179/source/arch/arm64/mm/fault.c#L283
该函数会被__do_kernel_fault函数调用,__do_kernel_fault函数中有如下关键代码:
} else if (addr < PAGE_SIZE) {
msg = "NULL pointer dereference";
} else {
msg = "paging request";
}
可以看到,当addr小于PAGE_SIZE时,错误信息是"NULL pointer dereference",否则是"paging request"。
__do_kernel_fault函数又会被do_page_fault函数调用。do_page_fault函数是缺页异常的核心处理函数,先进行正常的缺页处理,如果处理失败,那么就会进入错误处理流程。错误处理会针对user mode和kernel mode做不同的处理。如果是user mode,则向用户进程发送段错误(SIGSEGV)的信号。如果是kernel mode,则调用__do_kernel_fault函数实现如前文所示的内核报错。
static int __kprobes do_page_fault(unsigned long addr, unsigned int esr,
struct pt_regs *regs)
{
...
/*
* If we are in kernel mode at this point, we have no context to
* handle this fault with.
*/
if (!user_mode(regs))
goto no_context;
...
no_context:
__do_kernel_fault(addr, esr, regs);
return 0;
}
可以看出,空指针异常和其他地址访问异常,没有本质区别,都是访问到了用户进程或者内核的无效地址区域,只不过空指针异常是在0地址附近的一段特定区域,这个区域的大小,在不同的操作系统、不同的处理器体系结构上可能是不同的。
--- END ---