今天带大家了解下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好各个段
一个程序跑起来,操作系统会自动为其挂载好各个段,我们常见的几个段有:
当一切环境都搭建好之后,程序就需要去执行它的使命了,我们可以将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去访问一个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寄存器可以获取对应的异常类型的。
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是内核对缺页异常的总处理接口,里面会对各种缺页异常做处理。
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,信号通常的方法是:
信号接收的流程,这里不分析代码了:
信号也不是随时都可以处理的,只有在返回用户空间时才去检查是否有信号处理的。
/*
* 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);
}
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的。