当我们在C中取消引用NULL指针时,在操作系统中会发生什么?

假设有一个指针,我们用NULL初始化它。

int* ptr = NULL; *ptr = 10; 

现在,程序会崩溃,因为ptr没有指向任何地址,我们正在给它赋值,这是一个无效的访问。 所以,问题是,在操作系统内部发生了什么? 页面错误/分段错误是否发生? 内核甚至会在页面表中search? 或者之前发生了崩溃?

我知道我不会在任何程序中做这样的事情,但这只是为了知道在这种情况下在操作系统或编译器内部发生了什么。 这不是一个重复的问题。

简而言之,这取决于许多因素,包括编译器,处理器架构,特定处理器模型和操作系统等等。

长的答案(x86和x86-64) :让我们下降到最低的水平:CPU。 在x86和x86-64上,该代码通常会编译成如下所示的指令或指令序列:

 movl $10, 0x00000000 

这说“存储在虚拟内存地址0的常数整数10”。 英特尔®64和IA-32架构软件开发人员手册详细描述了执行该指令后会发生什么情况,因此我将为您进行总结。

CPU可以在几种不同的模式下运行,其中有几种模式是为了向后兼容较老的CPU。 现代操作系统以一种称为保护模式的模式运行用户级代码,该模式使用分页将虚拟地址转换为物理地址。

对于每个进程,操作系统保留一个页面表 ,这个表格指明了地址是如何映射的。 页表以特定的格式存储在内存中(并且受到保护,使得它们不能被用户代码修改)。 对于发生的每个内存访问,CPU都会根据页表进行翻译。 如果翻译成功,则执行对物理存储单元的相应读取/写入。

地址转换失败时会发生有趣的事情。 并非所有的地址都是有效的,并且如果任何存储器访问产生了无效的地址,则处理器引发页面错误exception 。 这触发了从用户模式 (也就是x86 / x86-64上的当前特权级别(CPL)3 )转换到内核模式 (又名CPL 0)到内核代码中由中断描述符表 (IDT)定义的特定位置。

内核重新​​获得控制权,根据exception信息和进程的页表,找出发生的事情。 在这种情况下,它意识到用户级进程访问了一个无效的内存位置,然后相应地作出反应。 在Windows上,它将调用结构化的exception处理来允许用户代码处理exception。 在POSIX系统上,操作系统将向进程发送一个SIGSEGV信号。

在其他情况下,操作系统将在内部处理页面错误,并从当前位置重新启动进程,如同没有任何事情发生一样。 例如, 守卫页放置在堆栈的底部,以允许堆栈根据需要增长到最大限度,而不是为堆栈预分配大量的内存。 类似的机制被用于实现写入时拷贝存储器。

在现代操作系统中,页表通常设置为使地址0成为无效的虚拟地址。 但有时候可以改变这个,比如在Linux上写0到伪文件/proc/sys/vm/mmap_min_addr ,之后可以使用mmap(2)映射虚拟地址0.在这种情况下,指针不会导致页面错误。

上面的讨论是关于当原始代码在用户空间中运行时发生的情况。 但是这也可能发生在内核中。 内核可以(当然比用户代码更可能)映射虚拟地址0,所以这样的内存访问是正常的。 但是,如果没有被映射,那么会发生什么大致相似的事情:CPU引发一个页面错误错误,陷入内核的预定义点,内核检查发生了什么,然后做出相应的反应。 如果内核无法从exception中恢复,那么通过打印出一些debugging信息到控制台或串行端口,然后停止,通常会以某种方式出现混乱内核恐慌内核哎呀 ,或Windows上的BSOD)。

另请参阅有关NULL的更多内容:利用内核NULL解引用 ,以举例说明攻击者如何利用内核中的空指针解引用错误,以获得Linux计算机上的root权限。

作为一个侧面说明,为了强制体系结构上的差异,由一个以三个字母的缩写名称而闻名的公司开发和维护的某个操作系统(通常称为大原色)具有最为准确的NULL确定。

它们在一个巨大的“东西”中为所有数据(内存和磁盘)使用128位线性地址空间。 根据其操作系统, 必须在该地址空间内的128位边界上放置“有效”指针。 这,顺便说一下,会导致令人着迷的副作用,包装或不包装,结构指针。 无论如何,隐藏在每个进程的专用页面是一个位图,为进程地址空间中的每个有效位置分配一个有效指针。 所有在其硬件和操作系统上的操作码可以生成并返回一个有效的内存地址,并将其分配给一个指针,将设置表示该指针(目标指针)所在的内存地址的位。

那么为什么要关心呢? 出于这个简单的原因:

 int a = 0; int *p = &a; int *q = p-1; if (p) { // p is valid, p's bit is lit, this code will run. } if (q) { // the address stored in q is not valid. q's bit is not lit. this will NOT run. } 

真正有趣的是这个。

 if (p == NULL) { // p is valid. this will NOT run. } if (q == NULL) { // q is not valid, and therefore treated as NULL, this WILL run. } if (!p) { // same as before. p is valid, therefore this won't run } if (!q) { // same as before, q is NOT valid, therefore this WILL run. } 

它的东西你必须相信。 我甚至无法想象维护该位图所做的维护,特别是在复制指针值或释放dynamic内存时。

在支持虚拟内存的CPU上,如果尝试读取内存地址0x0通常会发生页面错误exception。 操作系统页面error handling程序将被调用,操作系统将决定该页面是无效的,并中止您的程序。

请注意,在某些CPU上,您也可以安全地访问内存地址0x0

正如C标准所说,解引用空指针是未定义的,如果编译器能够在编译时(甚至是运行时)检测到你正在解引用一个空指针,它可以做任何想要的事情,比如用详细的错误消息中止程序。

(C99,6.5.3.2.p4)“如果指针指定了一个无效的值,则一元运算符的行为是未定义的。

87):“在由一元运算符取消引用指针的无效值中,有一个是空指针,一个地址与所指向的对象types不匹配,另一个对象的地址在其生命周期结束之后。

典型的情况下, int *ptr = NULL; 将设置ptr指向地址0.C标准(和C ++标准)是非常小心的要求,但它是非常普遍的。

当你做*ptr = 10; ,CPU通常在地址线上产生0,在数据线上产生10 ,同时设置R / W线来指示写入(并且,如果总线具有这种情况,则断言存储器与I / O线指示写入内存,而不是I / O)。

假设CPU支持内存保护(并且您使用的是启用它的操作系统),CPU将在它发生之前检查(尝试)访问。 例如,一个现代的英特尔/ AMD CPU将使用将虚拟地址映射到物理地址的分页表。 在典型情况下,地址0不会映射到任何物理地址。 在这种情况下,CPU将产生访问冲突exception。 对于一个相当典型的示例,Microsoft Windows将保留前4兆字节的未映射,因此该范围内的任何地址通常都会导致访问冲突。

在较旧的CPU(或不支持CPU保护function的较早的操作系统)上尝试写入操作通常会成功。 例如,在MS-DOS下,通过一个NULL指针写入将只写入地址零。 在小型或中型模式(数据为16位地址)中,大多数编译器会在数据段的前几个字节中写入一些已知模式,当程序结束时,他们会检查模式是否完好无损做一些事情来表明如果失败,你是通过一个NULL指针来写的)。 在紧凑或大型模式(20位数据地址)中,他们通常只是在没有警告的情况下写入零地址。

我想这是平台和编译器依赖。 NULL指针可以通过使用NULL页面来实现,在这种情况下,您可能会出现页面错误,或者可能低于展开分段的段限制,在这种情况下,您会遇到分段错误。

这不是一个明确的答案,只是我的猜想。