如果取消引用空指针,在 CPU 级别会发生什么?
What happens at CPU-Level if you dereference a null pointer?
假设我有以下程序:
#include <signal.h>
#include <stddef.h>
#include <stdlib.h>
static void myHandler(int sig){
abort();
}
int main(void){
signal(SIGSEGV,myHandler);
char* ptr=NULL;
*ptr='a';
return 0;
}
如您所见,我注册了一个信号处理程序,并进一步注册了一些行,我取消引用了一个空指针 ==> SIGSEGV 被触发。
但它是如何触发的呢?
如果我 运行 它使用 strace
(输出剥离):
//Set signal handler (In glibc signal simply wraps a call to sigaction)
rt_sigaction(SIGSEGV, {sa_handler=0x563b125e1060, sa_mask=[SEGV], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7ffbe4fe0d30}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
//SIGSEGV is raised
--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=NULL} ---
rt_sigprocmask(SIG_UNBLOCK, [ABRT], NULL, 8) = 0
rt_sigprocmask(SIG_BLOCK, ~[RTMIN RT_1], [SEGV], 8) = 0
但是缺少一些东西,信号如何从 CPU 传送到程序?
我的理解:
[Dereferences null pointer] -> [CPU raises an exception] -> [??? (How does it go from the CPU to the kernel?) ] -> [The kernel is notified, and sends the signal to the process] -> [??? (How does the process know, that a signal is raised?)] -> [The matching signal handler is called].
标有???
的这两个地方发生了什么?
通常发生的情况是,当 CPU 的内存管理单元发现程序试图访问的虚拟地址不在任何到物理内存的映射中时,它会引发中断。 OS 将设置一个中断服务例程以防万一。该例程将在 OS 内做任何必要的事情来用 SEGV 向进程发出信号。在来自 ISR 的 return 中,违规指令尚未完成。
接下来会发生什么取决于是否为 SEGV 安装了处理程序。该语言的运行时可能已经安装了一个将其作为异常引发的。该过程几乎总是终止,因为它无法恢复。像 valgrind 这样的东西会对信号做一些有用的事情,例如告诉你程序在代码中的确切位置。
当您查看 glibc 等 C 运行时库使用的内存分配策略时,就会变得有趣。 NULL 指针解引用有点明显,但是访问超出数组末尾的地方呢?通常,调用 malloc()
或 new
会导致库请求比已请求的更多的内存。赌注是它可以使用该内存来满足对内存的进一步请求,而不会打扰 OS - 这既好又快。然而,CPU 的 MMU 不知道发生了什么。因此,如果您确实访问了数组末尾之外的内容,您仍在访问 MMU 可以看到的映射到您的进程的内存,但实际上您已经开始践踏不该被践踏的地方了。一些非常防御性的 OSes 不这样做,特别是为了让 MMU 确实捕获越界访问。
这会导致有趣的结果。我遇到过在 Linux 上构建和运行得很好的软件,它是为 FreeBSD 编译的,开始抛出 SEGV。 GNURadio 就是这样一款软件(它是一个复杂的流程图)。这很有趣,因为它大量使用 boost / c++11 智能指针来专门帮助避免内存滥用。我还不能确定错误在哪里提交错误报告...
大多数(但不是全部)C 实现中的 NULL
指针是地址 0
。通常此地址不在有效(映射)页面中。
对硬件页表未映射的虚拟页的任何访问都会导致页面错误异常。例如在 x86 上,#PF
.
这会调用 OS 的页面错误异常处理程序来解决这种情况。例如,在 x86-64 上,CPU 将 exception-return 信息压入内核堆栈,并从对应于该异常编号的 IDT (Interrupt Descriptor Table) 条目加载 CS:RIP。就像 user-space 触发的任何其他异常一样,例如整数除以零 (#DE
),或一般保护错误 #GP
(尝试 运行 user-space 中的特权指令,或需要对齐的未对齐 SIMD 指令,或许多其他可能的事情)。
页面错误处理程序可以找出用户-space 试图访问的地址。例如在 x86 上,有一个 control register (CR2) 保存导致错误的线性(虚拟)地址。 OS 可以通过 mov rax, cr2
.
将其复制到通用寄存器中
其他 ISA 有其他机制让 OS 告诉 CPU 它的页面错误处理程序在哪里,并让该处理程序找出 user-space 的地址试图访问。但是对于具有虚拟内存的系统来说,具有本质上等效的机制是非常普遍的。
访问无效。 OS 可能懒得将进程分配的内存“连接”到硬件页表的原因有多种。这就是分页的意义所在:让 OS 纠正这种情况,例如写时复制、惰性分配或从交换 space.
中返回一个页面
页面错误分为三类:(从我的回答中复制 ). Wikipedia's page-fault article 说了类似的话。
- valid(进程在逻辑上映射了内存,但是 OS 是懒惰的或者玩写时复制这样的把戏):
- 硬:页面需要从磁盘调入,可以是交换区 space 也可以是磁盘文件(例如内存映射文件,如可执行文件或共享库的页面)。通常 OS 会在等待 I/O 时安排另一个任务:这是 hard(主要)和 soft(次要)之间的关键区别。
- soft:不需要磁盘访问,例如分配+清零一个新的物理页面以支持用户-space 刚刚尝试写入的虚拟页面。或多个进程已映射的可写页面的写时复制,但其中一个进程的更改不应该对另一个进程可见(如 mmap(MAP_PRIVATE))。这会将共享页面变成私有脏页。
- 无效:该页面甚至没有逻辑映射。 A POSIX OS 喜欢 Linux 将向违规的 process/thread.
发送 SIGSEGV 信号
所以只有在 OS 查阅它自己的数据结构以查看一个进程 应该 拥有哪些虚拟地址后,才能确定内存访问是无效的.
页面错误是否无效完全取决于软件。正如我在 上写的那样 - 如果 HW 可以解决所有问题,它就不需要陷入 OS.
有趣的事实:在 Linux 上可以配置系统,使虚拟地址 0
是(或可以是)有效 . 设置 mmap_min_addr
= 0 允许进程到 mmap
那里。例如WINE 需要这个来模拟 16 位 Windows 内存布局。
由于这不会将 NULL
指针的内部对象表示更改为 0
以外的其他形式,因此这样做将意味着 NULL 取消引用将不再出错。这使得调试更加困难,这就是为什么 mmap_min_addr
的默认值是 64k。
在没有虚拟内存的更简单的系统上,OS 可能仍然能够配置 MMU 以捕获对地址 space 的某些区域的内存访问。 OS 的陷阱处理程序不必检查任何内容,它知道触发它的任何访问都是无效的。 (除非它也在为地址 space 的某些区域模拟某些东西...)
正在向用户发送信号-space
这部分是纯软件。传递 SIGSEGV 与传递另一个进程发送的 SIGALRM 或 SIGTERM 没有什么不同。
当然,来自 SIGSEGV 处理程序的 return 用户进程 return 没有解决问题将使主线程再次 运行 相同的错误指令. (OS 将 return 指向引发页面错误异常的指令。)
这就是 SIGSEGV 的默认操作是终止的原因,以及将行为设置为“忽略”没有意义的原因。
假设我有以下程序:
#include <signal.h>
#include <stddef.h>
#include <stdlib.h>
static void myHandler(int sig){
abort();
}
int main(void){
signal(SIGSEGV,myHandler);
char* ptr=NULL;
*ptr='a';
return 0;
}
如您所见,我注册了一个信号处理程序,并进一步注册了一些行,我取消引用了一个空指针 ==> SIGSEGV 被触发。
但它是如何触发的呢?
如果我 运行 它使用 strace
(输出剥离):
//Set signal handler (In glibc signal simply wraps a call to sigaction)
rt_sigaction(SIGSEGV, {sa_handler=0x563b125e1060, sa_mask=[SEGV], sa_flags=SA_RESTORER|SA_RESTART, sa_restorer=0x7ffbe4fe0d30}, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=0}, 8) = 0
//SIGSEGV is raised
--- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=NULL} ---
rt_sigprocmask(SIG_UNBLOCK, [ABRT], NULL, 8) = 0
rt_sigprocmask(SIG_BLOCK, ~[RTMIN RT_1], [SEGV], 8) = 0
但是缺少一些东西,信号如何从 CPU 传送到程序? 我的理解:
[Dereferences null pointer] -> [CPU raises an exception] -> [??? (How does it go from the CPU to the kernel?) ] -> [The kernel is notified, and sends the signal to the process] -> [??? (How does the process know, that a signal is raised?)] -> [The matching signal handler is called].
标有???
的这两个地方发生了什么?
通常发生的情况是,当 CPU 的内存管理单元发现程序试图访问的虚拟地址不在任何到物理内存的映射中时,它会引发中断。 OS 将设置一个中断服务例程以防万一。该例程将在 OS 内做任何必要的事情来用 SEGV 向进程发出信号。在来自 ISR 的 return 中,违规指令尚未完成。
接下来会发生什么取决于是否为 SEGV 安装了处理程序。该语言的运行时可能已经安装了一个将其作为异常引发的。该过程几乎总是终止,因为它无法恢复。像 valgrind 这样的东西会对信号做一些有用的事情,例如告诉你程序在代码中的确切位置。
当您查看 glibc 等 C 运行时库使用的内存分配策略时,就会变得有趣。 NULL 指针解引用有点明显,但是访问超出数组末尾的地方呢?通常,调用 malloc()
或 new
会导致库请求比已请求的更多的内存。赌注是它可以使用该内存来满足对内存的进一步请求,而不会打扰 OS - 这既好又快。然而,CPU 的 MMU 不知道发生了什么。因此,如果您确实访问了数组末尾之外的内容,您仍在访问 MMU 可以看到的映射到您的进程的内存,但实际上您已经开始践踏不该被践踏的地方了。一些非常防御性的 OSes 不这样做,特别是为了让 MMU 确实捕获越界访问。
这会导致有趣的结果。我遇到过在 Linux 上构建和运行得很好的软件,它是为 FreeBSD 编译的,开始抛出 SEGV。 GNURadio 就是这样一款软件(它是一个复杂的流程图)。这很有趣,因为它大量使用 boost / c++11 智能指针来专门帮助避免内存滥用。我还不能确定错误在哪里提交错误报告...
大多数(但不是全部)C 实现中的 NULL
指针是地址 0
。通常此地址不在有效(映射)页面中。
对硬件页表未映射的虚拟页的任何访问都会导致页面错误异常。例如在 x86 上,#PF
.
这会调用 OS 的页面错误异常处理程序来解决这种情况。例如,在 x86-64 上,CPU 将 exception-return 信息压入内核堆栈,并从对应于该异常编号的 IDT (Interrupt Descriptor Table) 条目加载 CS:RIP。就像 user-space 触发的任何其他异常一样,例如整数除以零 (#DE
),或一般保护错误 #GP
(尝试 运行 user-space 中的特权指令,或需要对齐的未对齐 SIMD 指令,或许多其他可能的事情)。
页面错误处理程序可以找出用户-space 试图访问的地址。例如在 x86 上,有一个 control register (CR2) 保存导致错误的线性(虚拟)地址。 OS 可以通过 mov rax, cr2
.
其他 ISA 有其他机制让 OS 告诉 CPU 它的页面错误处理程序在哪里,并让该处理程序找出 user-space 的地址试图访问。但是对于具有虚拟内存的系统来说,具有本质上等效的机制是非常普遍的。
访问无效。 OS 可能懒得将进程分配的内存“连接”到硬件页表的原因有多种。这就是分页的意义所在:让 OS 纠正这种情况,例如写时复制、惰性分配或从交换 space.
中返回一个页面页面错误分为三类:(从我的回答中复制
- valid(进程在逻辑上映射了内存,但是 OS 是懒惰的或者玩写时复制这样的把戏):
- 硬:页面需要从磁盘调入,可以是交换区 space 也可以是磁盘文件(例如内存映射文件,如可执行文件或共享库的页面)。通常 OS 会在等待 I/O 时安排另一个任务:这是 hard(主要)和 soft(次要)之间的关键区别。
- soft:不需要磁盘访问,例如分配+清零一个新的物理页面以支持用户-space 刚刚尝试写入的虚拟页面。或多个进程已映射的可写页面的写时复制,但其中一个进程的更改不应该对另一个进程可见(如 mmap(MAP_PRIVATE))。这会将共享页面变成私有脏页。
- 无效:该页面甚至没有逻辑映射。 A POSIX OS 喜欢 Linux 将向违规的 process/thread. 发送 SIGSEGV 信号
所以只有在 OS 查阅它自己的数据结构以查看一个进程 应该 拥有哪些虚拟地址后,才能确定内存访问是无效的.
页面错误是否无效完全取决于软件。正如我在
有趣的事实:在 Linux 上可以配置系统,使虚拟地址 0
是(或可以是)有效 . 设置 mmap_min_addr
= 0 允许进程到 mmap
那里。例如WINE 需要这个来模拟 16 位 Windows 内存布局。
由于这不会将 NULL
指针的内部对象表示更改为 0
以外的其他形式,因此这样做将意味着 NULL 取消引用将不再出错。这使得调试更加困难,这就是为什么 mmap_min_addr
的默认值是 64k。
在没有虚拟内存的更简单的系统上,OS 可能仍然能够配置 MMU 以捕获对地址 space 的某些区域的内存访问。 OS 的陷阱处理程序不必检查任何内容,它知道触发它的任何访问都是无效的。 (除非它也在为地址 space 的某些区域模拟某些东西...)
正在向用户发送信号-space
这部分是纯软件。传递 SIGSEGV 与传递另一个进程发送的 SIGALRM 或 SIGTERM 没有什么不同。
当然,来自 SIGSEGV 处理程序的 return 用户进程 return 没有解决问题将使主线程再次 运行 相同的错误指令. (OS 将 return 指向引发页面错误异常的指令。)
这就是 SIGSEGV 的默认操作是终止的原因,以及将行为设置为“忽略”没有意义的原因。