45

假设有一个指针,我们用 NULL 对其进行初始化。

int* ptr = NULL;
*ptr = 10;

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

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

4

5 回答 5

72

简短回答:这取决于很多因素,包括编译器、处理器架构、特定处理器型号和操作系统等。

长答案(x86 和 x86-64):让我们深入到最低级别:CPU。在 x86 和 x86-64 上,该代码通常会编译成如下指令或指令序列:

movl $10, 0x00000000

其中说“将常量整数 10 存储在虚拟内存地址 0 处”。英特尔® 64 位和 IA-32 架构软件开发人员手册详细描述了执行此指令时会发生什么,因此我将为您总结一下。

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

对于每个进程,操作系统都保留一个页表,该页表指示地址的映射方式。页表以 CPU 理解的特定格式存储在内存中(并受到保护,以便用户代码无法修改它们)。对于发生的每一次内存访问,CPU 都会根据页表对其进行转换。如果转换成功,它会对物理内存位置执行相应的读/写操作。

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

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

在其他情况下,操作系统将在内部处理页面错误并从其当前位置重新启动进程,就好像什么都没发生一样。例如,保护页被放置在堆栈的底部,以允许堆栈按需增长到一个限制,而不是为堆栈预先分配大量内存。类似的机制用于实现写时复制内存。

在现代操作系统中,通常设置页表以使地址 0 成为无效的虚拟地址。但有时可以改变这一点,例如在 Linux 上通过将 0 写入 pseudofile /proc/sys/vm/mmap_min_addr,之后可以使用mmap(2)映射虚拟地址 0。在这种情况下,取消引用空指针不会导致页面错误。

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

另请参阅有关 NULL 的废话:利用内核 NULL 取消引用,以获取攻击者如何利用内核内部的空指针取消引用错误以获得 Linux 机器上的 root 权限的示例。

于 2012-09-28T19:04:25.283 回答
6

作为旁注,为了强调架构上的差异,由一家以其三字母缩写名称而闻名的公司开发和维护的某个操作系统通常被称为大原色,具有最令人着迷的 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.
}

它的东西你必须看到相信。我什至无法想象为维护该位图而进行的内务管理,尤其是在复制指针值或释放动态内存时。

于 2012-09-28T19:28:31.620 回答
4

在支持虚拟内存的 CPU 上,如果您尝试读取内存地址,通常会发出页面错误异常0x0。操作系统页面错误处理程序将被调用,然后操作系统将确定页面无效并中止您的程序。

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

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

(C99, 6.5.3.2.p4) “如果一个无效值被分配给指针,一元 * 操作符的行为是未定义的。87)”

87):“一元 * 运算符取消引用指针的无效值包括空指针、与指向的对象类型不恰当对齐的地址,以及对象在其生命周期结束后的地址。”

于 2012-09-28T18:59:26.497 回答
4

典型情况下,int *ptr = NULL;将设置ptr为指向地址 0。C 标准(和 C++ 标准)非常谨慎地要求这样做,但它仍然非常普遍。

当你这样做*ptr = 10;时,CPU 通常会在地址线和10数据线上生成 0,同时设置 R/W 线以指示写入(并且,如果总线有这样的东西,则断言内存与 I/ O 行表示写入内存,而不是 I/O)。

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

在较旧的 CPU(或未启用 CPU 保护功能的较旧操作系统)上,尝试写入通常会成功。例如,在 MS-DOS 下,通过 NULL 指针写入将简单地写入地址零。在小型或中型模型(具有 16 位数据地址)中,大多数编译器会将一些已知模式写入数据段的前几个字节,当程序结束时,他们会检查该模式是否保持不变(并且如果失败,请做一些事情以表明您已通过 NULL 指针写入)。在紧凑型或大型模型(20 位数据地址)中,它们通常只会写入地址零而不会发出警告。

于 2012-09-28T19:07:25.990 回答
0

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

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

于 2012-09-28T18:49:53.833 回答