专栏首页Linux内核深入分析NULL指针的奇妙之旅

NULL指针的奇妙之旅

今天带大家了解下NULL指针是如何形成的? 当然了我们要深入到操作系统中去看看为何访问一个NULL指令会报Segment Fault的错误。

想必大家在接触计算机时都写过NULL指针的程序,尤其是玩C语言的小伙伴们。比如刚初始化的一个int类型指针,还没给分配内存空间时就往这个指针赋值,然后运行就会出现Segment Fault的错误。

#include <stdio.h>

int main()
{
    int*p = NULL;

    *p = 123;
    return 0;
}


root:~/test$ ./a.out
Segmentation fault (core dumped)

就这么短短的几行代码,在操作系统中却经历了漫长的"旅行",今天就带大家去探索这段奇妙的旅行。

启动旅行

当我们编译完程序后,使用./a.out运行,在操作系统中bash就用来负责创建一个子进程,这个子进程就是我们的NULL指针程序。至于如何去创建一个子进程,可以去翻阅进程创建的相关文章。当创建一个子进程后,会通过exec程序来装载该NULL指针程序的内容。当程序运行起来后,操作系统就会为NULL指针程序load好各个段

一个程序跑起来,操作系统会自动为其挂载好各个段,我们常见的几个段有:

  • 数据段:分为只读数据段,和可读可写数据段
  • 代码段:就是我们写的code,一般权限都是RX的
  • 堆: 一般用来映射mallo申请的内存区域或者mmap
  • 栈:一般用来函数调用存放函数的参数,用来保存函数跳转使用的。
  • 共享库:这个是每个进程必须存在的,有些程序需要借助gibc中封装的函数,则需要glic的库等。

运行旅行

当一切环境都搭建好之后,程序就需要去执行它的使命了,我们可以将NULL指针程序反汇编,反汇编的内容很多,我们只看main函数的反汇编,这里使用的是aarch64-linux-gnu-objdump工具链

0000000000400530 <main>:
  400530:       d10043ff        sub     sp, sp, #0x10
  400534:       f90007ff        str     xzr, [sp,#8]
  400538:       f94007e0        ldr     x0, [sp,#8]
  40053c:       52800f61        mov     w1, #0x7b                       // #123
  400540:       b9000001        str     w1, [x0]
  400544:       52800000        mov     w0, #0x0                        // #0
  400548:       910043ff        add     sp, sp, #0x10
  40054c:       d65f03c0        ret

之所有能跑到main函数,是操作系统帮忙做了一些事情,暂且不关注这部分。当运行到main函数后,就会先做压栈的操作,接着CPU就会去执行str w1,[x0]的指令,这句对应的C语言就是*p=123。当CPU去执行这条语句时,就会发生如下操作。

  • CPU首先会将虚拟地址送给MMU,让MMU硬件单元做虚拟地址到物理地址的查表,转化。
  • 同时MMU硬件单元也会做一些虚拟地址权限的检查,查看虚拟地址是否访问越界之类的,以及读写权限等
  • 当MMU硬件单元中已经存在虚拟地址到物理地址的映射关系,则直接返回物理地址让CPU去执行访问
  • 如果MMU硬件单元中没有虚拟地址到物理地址的映射关系,则就会触发缺页异常,去建立虚实映射。
  • 同时因为虚实映射比较耗时,则使用TLB来缓存最近访问过的虚实映射关系,查表之前先访问TLB,加快转换速度。
  • 对于我们的例子,*p对于的地址是NULL的,如果CPU去执行访问,MMU会判断此地址是非法的,则就会触发data abort异常
  • 触发异常会跳转到对应体系结构的异常向量表出执行,这里以ARM64为例

异常旅行

CPU去访问一个NULL地址,MMU检测到是非法访问,则会触发一个异常,跳转到ARM64的异常向量表去执行

/*
 * Exception vectors.
 */
	.pushsection ".entry.text", "ax"

	.align	11
ENTRY(vectors)
	kernel_ventry	1, sync_invalid			// Synchronous EL1t
	kernel_ventry	1, irq_invalid			// IRQ EL1t
	kernel_ventry	1, fiq_invalid			// FIQ EL1t
	kernel_ventry	1, error_invalid		// Error EL1t

	kernel_ventry	1, sync				// Synchronous EL1h
	kernel_ventry	1, irq				// IRQ EL1h
	kernel_ventry	1, fiq_invalid			// FIQ EL1h
	kernel_ventry	1, error			// Error EL1h

	kernel_ventry	0, sync				// Synchronous 64-bit EL0
	kernel_ventry	0, irq				// IRQ 64-bit EL0
	kernel_ventry	0, fiq_invalid			// FIQ 64-bit EL0
	kernel_ventry	0, error			// Error 64-bit EL0

ARM64架构定义了EL0, EL1, EL2, EL3四种异常级别,其中EL0就是userspace, EL1就是Linux kernel, El2 是hyper, EL3是Secure mode。当前我们的异常是从EL0触发的,则会跳转到EL0 异常处理handler处

/*
 * EL0 mode handlers.
 */
	.align	6
el0_sync:
	kernel_entry 0
	mrs	x25, esr_el1			// read the syndrome register
	lsr	x24, x25, #ESR_ELx_EC_SHIFT	// exception class
	cmp	x24, #ESR_ELx_EC_SVC64		// SVC in 64-bit state
	b.eq	el0_svc
	cmp	x24, #ESR_ELx_EC_DABT_LOW	// data abort in EL0
	b.eq	el0_da
	cmp	x24, #ESR_ELx_EC_IABT_LOW	// instruction abort in EL0
	b.eq	el0_ia
	cmp	x24, #ESR_ELx_EC_FP_ASIMD	// FP/ASIMD access
	b.eq	el0_fpsimd_acc
	cmp	x24, #ESR_ELx_EC_SVE		// SVE access
	b.eq	el0_sve_acc
	cmp	x24, #ESR_ELx_EC_FP_EXC64	// FP/ASIMD exception
	b.eq	el0_fpsimd_exc
	cmp	x24, #ESR_ELx_EC_SYS64		// configurable trap
	ccmp	x24, #ESR_ELx_EC_WFx, #4, ne
	b.eq	el0_sys
	cmp	x24, #ESR_ELx_EC_SP_ALIGN	// stack alignment exception
	b.eq	el0_sp_pc
	cmp	x24, #ESR_ELx_EC_PC_ALIGN	// pc alignment exception
	b.eq	el0_sp_pc
	cmp	x24, #ESR_ELx_EC_UNKNOWN	// unknown exception in EL0
	b.eq	el0_undef
	cmp	x24, #ESR_ELx_EC_BREAKPT_LOW	// debug exception in EL0
	b.ge	el0_dbg
	b	el0_inv

可以看到有异常有很多种类,比如数据异常DateAbort, 指令异常IAbort,以及栈对齐异常,PC对齐异常等。而怎么知道当前是处于何种异常呢? 这是通过读取ESR寄存器可以获取对应的异常类型的。

  • Bits[31:26] 用来确定异常的类型,Exception class
  • Bit[25]: 用来确定异常指令的长度,0代表16位异常指令,1代表32位异常
  • Bits[24:0]: 用来确定具体的异常,每种异常类型独立定义此字段
  • 详细信息大家可以去看ARM手册
el0_da:
	/*
	 * Data abort handling
	 */
	mrs	x26, far_el1
	enable_daif
	ct_user_exit
	clear_address_tag x0, x26
	mov	x1, x25
	mov	x2, sp
	bl	do_mem_abort
	b	ret_to_user

我们这里发生的是data abort异常,会跳转到el0_da处,最终会跳转到do_mem_abort处理函数处

static const struct fault_info fault_info[] = {
	{ do_bad,		SIGKILL, SI_KERNEL,	"ttbr address size fault"	},
	{ do_bad,		SIGKILL, SI_KERNEL,	"level 1 address size fault"	},
	{ do_bad,		SIGKILL, SI_KERNEL,	"level 2 address size fault"	},
	{ do_bad,		SIGKILL, SI_KERNEL,	"level 3 address size fault"	},
	{ do_translation_fault,	SIGSEGV, SEGV_MAPERR,	"level 0 translation fault"	},
	{ do_translation_fault,	SIGSEGV, SEGV_MAPERR,	"level 1 translation fault"	},
	{ do_translation_fault,	SIGSEGV, SEGV_MAPERR,	"level 2 translation fault"	},
	{ do_translation_fault,	SIGSEGV, SEGV_MAPERR,	"level 3 translation fault"	},

asmlinkage void __exception do_mem_abort(unsigned long addr, unsigned int esr,
					 struct pt_regs *regs)
{
	const struct fault_info *inf = esr_to_fault_info(esr);

	if (!inf->fn(addr, esr, regs))
		return;
}

通过ESR寄存器的值就可以获取此次对应的异常类型,然后再fault_info数组中以异常类型为下标获取对应的异常处理函数,此处例子对应的异常处理函数是do_translation_fault,因为我们是发生在EL0的地址翻译错误。

static int __kprobes do_translation_fault(unsigned long addr,
					  unsigned int esr,
					  struct pt_regs *regs)
{
	if (is_ttbr0_addr(addr))
		return do_page_fault(addr, esr, regs);

	do_bad_area(addr, esr, regs);
	return 0;
}

在这里根据异常地址来确定当前是EL0还是别的模式异常,因为addr=0x0,属于EL0异常,则就跳转到do_page_fault进一步处理异常,do_page_fault是内核对缺页异常的总处理接口,里面会对各种缺页异常做处理。

  • 如果虚拟地址是合法的,则会对虚拟地址创建页表,建立虚实映射的
  • 如果虚拟地址访问非法,而且地址属于内核地址空间,则直接就会panic
  • 如果虚拟地址合法的,同时也会坚持权限,如果此虚拟地址只读的,而如果去写,则也会发生异常等等
  • 而对于用户空间的虚拟非法虚拟地址,通常是通过信号的方式去通知上层,来达到终结此程序
  • 对于我们的NULL指针程序,最终会发生SIGSEGV的信号通知给应用程序的
arm64_force_sig_fault(SIGSEGV,fault == VM_FAULT_BADACCESS ? SEGV_ACCERR : SEGV_MAPERR,
		(void __user *)addr, inf->name);

内核最终会调用arm64_force_sig_fault的方式通知应用程序,而此处的信号类型是SIGSEGV,非法访问。

信号接收旅行

信号是一种异步通信的方式,一个进程可以给另外一个进程发生信号,但是信号的处理是在内核中实现的。信号的类型有:

 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

我们例子中发生的信号是SIGSEGV,信号通常的方法是:

  • 进程安装信号,可以用sigaction系统调用,安装信号肯定要设置信号的回调函数,用来当信号发生时处理信号。
  • 比如通过Kill -9 PID就可以来杀死进程,同时此进程会收到信号,就会处理信号的安装函数

信号接收的流程,这里不分析代码了:

  • 当sigaction去安装一个信号时,会触发系统调用,trap到内核空间去设置此进程的信号action
  • 当此进程收到一个信号时,比如SIGSEGV时,为了不防止信号丢失,会使用sigqueue结构来管理信号
  • 可以理解为一个信号接收队列,将接收的信号通过入队的方式进行管理。当然也有优先级之类的策略
  • 当有信号入队列时,会将此信号挂入到pending队列等待处理,此时会唤醒需要处理信号的进程。

信号处理旅行

信号也不是随时都可以处理的,只有在返回用户空间时才去检查是否有信号处理的。

/*
 * Ok, we need to do extra processing, enter the slow path.
 */
work_pending:
	mov	x0, sp				// 'regs'
	bl	do_notify_resume
	ldr	x1, [tsk, #TSK_TI_FLAGS]	// re-check for single-step
	b	finish_ret_to_user
/*
 * "slow" syscall return path.
 */
ret_to_user:
	disable_daif
	ldr	x1, [tsk, #TSK_TI_FLAGS]
	and	x2, x1, #_TIF_WORK_MASK
	cbnz	x2, work_pending
finish_ret_to_user:
	enable_step_tsk x1, x2
	kernel_exit 0
ENDPROC(ret_to_user)

在ret_to_user返回用户空间时会去检查是否要有extra事情去处理,有的话则跳转到do_notify_resume处,通过判断thread_info中的flag标志位来判断是否有额外的事情要处理

asmlinkage void do_notify_resume(struct pt_regs *regs,
				 unsigned long thread_flags)
{

	do {
		/* Check valid user FS if needed */
		addr_limit_user_check();

		if (thread_flags & _TIF_NEED_RESCHED) {
			/* Unmask Debug and SError for the next task */
			local_daif_restore(DAIF_PROCCTX_NOIRQ);

			schedule();
		} else {
			if (thread_flags & _TIF_SIGPENDING)
				do_signal(regs);

		}

	} while (thread_flags & _TIF_WORK_MASK);
}
  • 常见的两种需要返回到用户空间时处理的事情,
    • 一个就是检查当前进程是否需要调度,通过检查是否设置了NEED_RESCHEd标志位
    • 一个是检查是否有pending信号,有的话则通过do_signal去处理信号

do_signal函数代码就不分析了,大致流程是通过get_signal找到优先级高的信号处理,返回对应信号的处理handler,就是通过sigaction设置的回调函数。最终调用hanle_signal函数去处理信号。

static void setup_return(struct pt_regs *regs, struct k_sigaction *ka,
			 struct rt_sigframe_user_layout *user, int usig)
{
	__sigrestore_t sigtramp;

	regs->regs[0] = usig;
	regs->sp = (unsigned long)user->sigframe;
	regs->regs[29] = (unsigned long)&user->next_frame->fp;
	regs->pc = (unsigned long)ka->sa.sa_handler;

	if (ka->sa.sa_flags & SA_RESTORER)
		sigtramp = ka->sa.sa_restorer;
	else
		sigtramp = VDSO_SYMBOL(current->mm->context.vdso, sigtramp);

	regs->regs[30] = (unsigned long)sigtramp;
}

这里需要建立一个信号栈的概念,通过将信号的处理函数设置到返回用户空间的PC指针上,当返回到用户空间,则会调用信号的处理函数。处理完毕后又会通过sigreturn系统调用返回到内核clean stack frame的操作、

注册旅行

从我们的NULL指针程序中看,是没有安装信号的啊,为何会收到Segmentation Fault呢? 其实这都是glibC帮我们做好的。通过下载一个glibc的code。

/* Standard signals  */
  init_sig (SIGHUP, "HUP", N_("Hangup"))
  init_sig (SIGINT, "INT", N_("Interrupt"))
  init_sig (SIGQUIT, "QUIT", N_("Quit"))
  init_sig (SIGILL, "ILL", N_("Illegal instruction"))
  init_sig (SIGTRAP, "TRAP", N_("Trace/breakpoint trap"))
  init_sig (SIGABRT, "ABRT", N_("Aborted"))
  init_sig (SIGFPE, "FPE", N_("Floating point exception"))
  init_sig (SIGKILL, "KILL", N_("Killed"))
  init_sig (SIGBUS, "BUS", N_("Bus error"))
  init_sig (SIGSEGV, "SEGV", N_("Segmentation fault"))

可以看到glibc已经帮我们安装了一些标志信号的处理函数。所以放我们访问NULL指令则后发生Segmentation fault的。

旅行总结

  • 当应用程序启动时,glibc中就会调用sigaction系统调度为标志信号设置信号处理函数
  • 当CPU去访问虚拟地址为0x0的时候,则触发data abort异常,陷入内核态
  • 内核态根据ESR寄存器获取对应的异常类型,然后回调对应的异常处理函数do_translation_fault
  • 对地址无法处理的userspace地址则发SIGSEGV信号给sigqueue队列,然后唤醒对应的信号处理函数
  • 在返回到用户空间时会去检查是否有信号处理,有则跳转到do_signal函数处理信号
  • do_signal函数中通过get_signal函数获取信号对应的回调处理函数,然后建立信号的栈帧
  • 将信号处理函数handler设置到应用程序的PC指针,返回到用户层则会处理信号的回调函数
  • 这时候就会调用到glibc设置的SIGSEGV信号对应的回调函数,则发出"Segmetation fault"错误
  • 处理完毕后会通过sigreturn系统调用返回到内核空间clean建立的栈帧,然后会再次返回用户空间接着执行。
  • 至此一个简单的NULL指针的旅行就完毕了,可见还是相当的复杂的。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • SLUB的引入及举例说明

    我们都知道Buddy分配器是按照页的单位分配的(Buddy系统分配器实现),如果我们需要分配几十个字节,几百个字节的时候,就需要用到SLAB分配器。

    DragonKingZhu
  • Epoll 机制

    epoll 是poll系统调用的升级版。可以用做单边沿(level-triggered)和双边沿(edge-triggered)的两种工作模式,同样也可以用于检...

    DragonKingZhu
  • Linux电源管理-Linux regulator framework概述

    regulator翻译为"调节器",分为voltage regulator(电压调节器)和current(电流调节器)。一般电源管理芯片(Power ...

    DragonKingZhu
  • Python办公自动化 | 批量word报告生成工具

    有时候我们需要按照某种规则生成一种固定模板的word报告,python能够很好的完成这项工作。本文通过一个小示例说明一下如何通过Python实现自动生成word...

    披头
  • 终于有人做出了比百度更好用的搜索引擎!

    提起搜索引擎,大家可能马上就会想到百度,几乎每天都会用到,然而多数人对它的看法可以用一句话概括:

    数据森麟
  • 干货 | Java 中不得不知的异常和处理详解

    简介 程序运行时,发生的不被期望的事件,它阻止了程序按照程序员的预期正常执行,这就是异常。异常发生时,是任程序自生自灭,立刻退出终止,还是输出错误给用户?或者用...

    老九君
  • 字节跳动宣布要做全网搜索,百度真正的危机来了

    7月31日晚,字节跳动在其“字节跳动招聘”公众号上正式对外为“字节跳动搜索部门”招聘员工。

    谭庆波
  • 【转】java中异常与try catch finally详解

    程序运行时,发生的不被期望的事件,它阻止了程序按照程序员的预期正常执行,这就是异常。异常发生时,是任程序自生自灭,立刻退出终止,还是输出错误给用户?或者用C语言...

    洋仔聊编程
  • Java异常详解及如何处理

    Tanyboye
  • 如何做出优雅的搜索功能?

    前言 搜索从宏观上来看有两种,一种是搜索引擎,另一种是垂直搜索。搜索引擎有 Google,Bing,百度,搜狗等等,而垂直搜索则是在大多数产品内置的对内容的检...

    前朝楚水

扫码关注云+社区

领取腾讯云代金券