今天带大家了解下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, #0x10400534:       f90007ff        str     xzr, [sp,#8]400538:       f94007e0        ldr     x0, [sp,#8]40053c:       52800f61        mov     w1, #0x7b                       // #123400540:       b9000001        str     w1, [x0]400544:       52800000        mov     w0, #0x0                        // #0400548:       910043ff        add     sp, sp, #0x1040054c:       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 EL1tkernel_ventry    1, irq_invalid          // IRQ EL1tkernel_ventry    1, fiq_invalid          // FIQ EL1tkernel_ventry    1, error_invalid        // Error EL1tkernel_ventry  1, sync             // Synchronous EL1hkernel_ventry    1, irq              // IRQ EL1hkernel_ventry    1, fiq_invalid          // FIQ EL1hkernel_ventry    1, error            // Error EL1hkernel_ventry  0, sync             // Synchronous 64-bit EL0kernel_ventry  0, irq              // IRQ 64-bit EL0kernel_ventry  0, fiq_invalid          // FIQ 64-bit EL0kernel_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 0mrs  x25, esr_el1            // read the syndrome registerlsr    x24, x25, #ESR_ELx_EC_SHIFT // exception classcmp   x24, #ESR_ELx_EC_SVC64      // SVC in 64-bit stateb.eq  el0_svccmp  x24, #ESR_ELx_EC_DABT_LOW   // data abort in EL0b.eq    el0_dacmp   x24, #ESR_ELx_EC_IABT_LOW   // instruction abort in EL0b.eq el0_iacmp   x24, #ESR_ELx_EC_FP_ASIMD   // FP/ASIMD accessb.eq  el0_fpsimd_acccmp   x24, #ESR_ELx_EC_SVE        // SVE accessb.eq   el0_sve_acccmp  x24, #ESR_ELx_EC_FP_EXC64   // FP/ASIMD exceptionb.eq   el0_fpsimd_exccmp   x24, #ESR_ELx_EC_SYS64      // configurable trapccmp    x24, #ESR_ELx_EC_WFx, #4, neb.eq    el0_syscmp  x24, #ESR_ELx_EC_SP_ALIGN   // stack alignment exceptionb.eq    el0_sp_pccmp    x24, #ESR_ELx_EC_PC_ALIGN   // pc alignment exceptionb.eq   el0_sp_pccmp    x24, #ESR_ELx_EC_UNKNOWN    // unknown exception in EL0b.eq el0_undefcmp    x24, #ESR_ELx_EC_BREAKPT_LOW    // debug exception in EL0b.ge   el0_dbgb    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_el1enable_daifct_user_exitclear_address_tag x0, x26mov x1, x25mov  x2, spbl    do_mem_abortb   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) SIGTRAP6) 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_resumeldr x1, [tsk, #TSK_TI_FLAGS]    // re-check for single-stepb    finish_ret_to_user
/** "slow" syscall return path.*/
ret_to_user:disable_daifldr x1, [tsk, #TSK_TI_FLAGS]and x2, x1, #_TIF_WORK_MASKcbnz x2, work_pending
finish_ret_to_user:enable_step_tsk x1, x2kernel_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;elsesigtramp = 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指针的旅行就完毕了,可见还是相当的复杂的。

NULL指针的奇妙之旅相关推荐

  1. C++ 笔记(14)— 指针(指针声明、取地址、取值、new/delete、NULL指针、指针运算、指针数组、数组指针、指针传递给函数、从函数返回指针)

    1. 声明指针 指针是一个变量,其值为另一个变量的地址,即,内存位置的直接地址.就像其他变量或常量一样,您必须在使用指 针存储其他变量地址之前,对其进行声明. 指针变量声明的一般形式为: type * ...

  2. C++ NULL指针学习 - Win32版本

    在变量声明的时候,如果没有确切的地址可以赋值,为指针变量赋一个 NULL 值是一个良好的编程习惯.赋为 NULL 值的指针被称为空指针.NULL 指针是一个定义在标准库中的值为零的常量. 先看一下控制 ...

  3. c6011取消对null指针的引用_C++中的野指针及其规避方法

    今天在调试程序过程中,用到了一些指针的方法,这里记录一下野指针的概念. 1.概念 野指针,也就是指向不可用内存区域的指针.通常对这种指针进行操作的话,将会使程序发生不可预知的错误. 野指针与空指针(N ...

  4. C语言visual studio警告:取消对NULL指针“p”的引用

    如图: main.cpp #include <stdio.h> #include <stdlib.h>int main(int argc, char* argv[]) {int ...

  5. 《Arduino奇妙之旅:智能车趣味制作天龙八步》一2.4 准备好了吗?

    本节书摘来华章计算机<Arduino奇妙之旅:智能车趣味制作天龙八步>一书中的第2章 ,第2.4节,(美)James Floyd Kelly Harold Timmis 著 程晨 译更多章 ...

  6. 取消对 null 指针“l”的引用。_C语言编程笔记丨C 语言指针 5 分钟教程

    指针.引用和取值 什么是指针?什么是内存地址?什么叫做指针的取值?指针是一个存储计算机内存地址的变量.在这份教程里"引用"表示计算机内存地址.从指针指向的内存读取数据称作指针的取值 ...

  7. c语言中将函数指针作为形参_在C中将有效指针作为NULL指针

    c语言中将函数指针作为形参 Prerequisite: An Example of Null pointer in C 先决条件: C中的空指针示例 Any pointer that contains ...

  8. C/C++中NULL指针

    先谈一下C/C++的强制类型转换Type cast.与强制类型转换相对应的是自动类型转换.或者强制类型转换叫显示类型转换,自动类型转换叫隐式类型转换.自动类型转换会在赋值运算.混合运算.参数传递.返回 ...

  9. c6011取消对null指针的引用_C/C++学习笔记——C提高:指针强化

    指针是一种数据类型 指针变量 指针是一种数据类型,占用内存空间,用来保存内存地址. void test01(){ int* p1 = 0x1234; int*** p2 = 0x1111; print ...

  10. 瑶琳c语言,来桐庐瑶琳仙境,开启一场18°C的奇妙之旅

    在杭州桐庐的瑶琳镇的瑶琳仙境,常年恒温在18摄氏度,是华东沿海中部亚热带湿润区喀斯特洞穴的典型代表. 瑶琳仙境得名于清朝,据清<桐庐县志>记载:"瑶琳洞,在县西北四十五里,洞口阔 ...

最新文章

  1. TypeScript基础入门 - 函数 - 简介
  2. python优先队列_python实现最大优先队列
  3. git 远程分支和tag标签的操作
  4. 【干货】如果你的推广周期是一年,怎样激活100万目标用户
  5. sqlite3x library
  6. 关闭网页如何接收服务器消息,WebSocket服务端发消息给客户端,浏览器收到消息就关闭了...
  7. vs简易计算机等于号代码,等于(=)vs.
  8. XP共享拒绝访问,全面解决
  9. 英伟达日入2.3亿净赚8千万,挖矿业务断崖跳水,老黄喊话马斯克
  10. 除法运算、商、余数与取模
  11. 斐讯K2刷华硕固件教程
  12. Excel VBA VBA去重复的几种方法
  13. IDEA安装插件(在线/离线)
  14. 基于C语言设计的唐诗三百首
  15. LabVIEW formula node
  16. 360浏览器自动填充表单
  17. 无线攻击及密码破解的四种方式详解
  18. thinkphp5+阿里短信发送验证码
  19. python可以编写成手机吗_python可以编写手机应用吗
  20. android java 写文件操作_Android编程之文件的读写实例详解

热门文章

  1. Java通过BCrypt加密
  2. Linux 基础——ls 命令
  3. CDOJ--1012
  4. WPF中Mvvm实现类似List的ObservableCollection在WPF中
  5. 成员函数指针有多态的效果吗?
  6. vs2008补丁发布
  7. 怎样把DataSet转换成ArrayList
  8. 雷蛇灯光配置文件_突破极限!Razer雷蛇发布高性能V2版炼狱蝰蛇和巴塞利斯蛇...
  9. Win10下安装MySQL5.6
  10. Win10周年升级新增52000个emoji表情