假设有一个指针,我们用NULL初始化它。
int* ptr = NULL;
*ptr = 10;
现在,程序将崩溃,因为ptr
没有指向任何地址,我们正在为其分配一个值,这是一个无效的访问。那么,问题是,操作系统内部会发生什么?是否发生页面错误/分段错误?内核甚至会在页面表中搜索吗?或者崩溃发生在那之前?
我知道我不会在任何程序中做这样的事情,但这只是为了知道在这种情况下OS或编译器内部会发生什么。这不是一个重复的问题。
答案 0 :(得分:62)
简短回答:这取决于很多因素,包括编译器,处理器架构,特定处理器型号和操作系统等。
长答案(x86和x86-64):让我们下到最低级别:CPU。在x86和x86-64上,该代码通常会编译成如下所示的指令或指令序列:
movl $10, 0x00000000
其中说“将常数整数10存储在虚拟内存地址0”。 Intel® 64 and IA-32 Architectures Software Developer Manuals详细描述了执行此指令时会发生什么,所以我将为您总结一下。
CPU可以在几种不同的模式下运行,其中几种模式是为了向后兼容较旧的CPU。现代操作系统以称为保护模式的模式运行用户级代码,该模式使用paging将虚拟地址转换为物理地址。
对于每个进程,操作系统都会保留一个页表,它指示地址的映射方式。页表以特定格式存储在存储器中(并且受到保护,以便CPU不能通过用户代码修改)。对于发生的每次内存访问,CPU根据页表对其进行转换。如果转换成功,它将对物理内存位置执行相应的读/写操作。
地址转换失败时会发生有趣的事情。并非所有地址都有效,如果任何内存访问生成无效地址,处理器将引发页面错误异常。这会触发从用户模式(x86 / x86-64上的当前权限级别(CPL)3 )转换为内核模式(又称CPL) 0)到内核代码中的特定位置,由中断描述符表(IDT)定义。
内核重新获得控制权,并根据异常和进程页面表中的信息,确定发生了什么。在这种情况下,它意识到用户级进程访问了无效的内存位置,然后它会做出相应的反应。在Windows上,它将调用structured exception handling以允许用户代码处理异常。在POSIX系统上,操作系统将向进程发送SIGSEGV
信号。
在其他情况下,操作系统将在内部处理页面错误,并从当前位置重新启动进程,就好像什么都没发生一样。例如,guard pages放置在堆栈的底部,以允许堆栈按需增长到一个限制,而不是预先为堆栈分配大量内存。类似的机制用于实现copy-on-write内存。
在现代操作系统中,页面表通常设置为使地址0成为无效的虚拟地址。但有时可以改变它,例如在Linux上通过将0写入伪文件/proc/sys/vm/mmap_min_addr
,之后可以使用mmap(2)
映射虚拟地址0.在这种情况下,取消引用空指针不会导致页面错误。
上面的讨论是关于原始代码在用户空间中运行时会发生什么。但这也可能发生在内核中。内核可以(并且当然比用户代码更可能)映射虚拟地址0,因此这样的内存访问是正常的。但是如果没有映射,那么接下来会发生的情况大致相似:CPU引发页面错误错误,该错误陷入内核的预定义点,内核检查发生了什么,并做出相应的反应。如果内核无法从异常中恢复,则通常会以某种方式(内核崩溃,内核oops 或Windows上的BSOD,例如)打印出来一些调试信息到控制台或串口,然后暂停。
另请参阅Much ado about NULL: Exploiting a kernel NULL dereference 以获取攻击者如何利用内核内部的空指针解除引用错误以获取Linux计算机上的root权限的示例。
答案 1 :(得分:6)
作为旁注,为了强制体系结构的差异,由一家以三字母缩写名称而闻名的公司开发和维护的某个操作系统通常被称为大型原色,其确定性最强。
它们在一个巨大的“东西”中利用128位线性地址空间来存储所有数据(内存和磁盘)。根据它们的OS,“有效”指针必须放置在该地址空间内的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.
}
你必须要相信它。我甚至无法想象维护该位图所做的内务处理,特别是在复制指针值或释放动态内存时。
答案 2 :(得分:4)
在支持虚拟存储器的CPU上,如果您尝试读取内存地址0x0
,通常会发出页面错误异常。将调用操作系统页面错误处理程序,操作系统将确定该页面无效并中止您的程序。
请注意,在某些CPU上,您还可以安全地访问内存地址0x0
。
正如C标准所说,取消引用空指针是未定义的,如果编译器能够在编译时(或甚至运行时)检测到您正在取消引用空指针,它可以执行任何想要的操作,例如使用详细错误消息。
(C99,6.5.3.2.p4)“如果为指针指定了无效值,则unary *运算符的行为未定义.87)”
87):“unary *运算符取消引用指针的无效值是空指针,地址与指向的对象类型不一致,以及对象在其生命周期结束后的地址。 “
答案 3 :(得分:3)
在典型的情况下,int *ptr = NULL;
会将ptr
设置为指向地址0.C标准(和C ++标准)非常小心 not 要求,但它仍然非常。
执行*ptr = 10;
时,CPU通常会在地址行上生成0,在数据行上生成10
,同时设置R / W行以指示写入(如果总线有这样的事情,断言内存与I / O线表示写入内存,而不是I / O.
假设CPU支持内存保护(并且您正在使用启用它的操作系统),CPU将在它发生之前检查(尝试)访问。例如,现代Intel / AMD CPU将使用将虚拟地址映射到物理地址的分页表。在典型情况下,地址0不会映射到任何物理地址。在这种情况下,CPU将生成访问冲突异常。对于一个相当典型的示例,Microsoft Windows保留了未映射的前4兆字节,因此该范围内的任何地址通常会导致访问冲突。
在较旧的CPU(或不支持CPU保护功能的较旧操作系统)上,尝试写入通常会成功。例如,在MS-DOS下,通过NULL指针写入只会写入地址零。在小型或中型模型(数据的16位地址)中,大多数编译器会将一些已知模式写入数据段的前几个字节,当程序结束时,他们会检查该模式是否保持不变(和做一些事情表明如果失败就通过NULL指针写入。在紧凑型或大型模型(20位数据地址)中,它们通常只是在没有警告的情况下写入地址为零。
答案 4 :(得分:0)
我想这是依赖于平台和编译器的。 NULL指针可以通过使用NULL页面来实现,在这种情况下,您有页面错误,或者它可能低于扩展段的段限制,在这种情况下,您会出现段错误。 / p>
这不是一个明确的答案,只是我的推测。