图片无法显示,请右键点击新窗口打开图片

在缺页异常处理过程中,进程可以因为收到 SIG_BUS 信号而 SegmentFault 异常退出,进而出现上图的异常信息. 上图是一个标准由 SIG_BUS 引起的 Segmentfault, 其各字段的含义如下:

  • BiscuitOS-PAGIN[197]: 应用程序的名字和进程 ID, 可以发生异常的进程名字是 BiscuitOS-PAGIN,名字并没有完整显示截取其中一部分,197 则是进程 PID.
  • segfault at 6000000000: 表示发生非法内存行为的虚拟地址是 0x6000000000
  • ip 00005586faab228b: 表示发生非法内存行为时的指令指针为 0x00005586faab228b
  • sp 00007ffe8a008380: 表示发生非法内存行为时的堆栈指针是 0x00007ffe8a008380
  • error 7: 表示发生缺页的原因是 0x7
  • in BiscuitOS-PAGING-PF-ANON-RO-default[5586faab2000+1000]: 表示发生非法内存行为时指令正在执行代码的位置
  • Code: 00 00 00 e8 …: 表示发生非法内存行为时出错位置前 2/3 的二进制代码和后 1/3 的二进制代码, 其通过 <> 进行分割
  • Segmentation fault: 表示进程发生了 SegmentFault

在 Linux 和许多其他 UNIX-like 操作系统中,SIGBUS 是一个信号, 信号是软件中断的一种,用于通知进程某些类型的事件已经发生。每种信号都有一个与之关联的默认动作,但进程也可以捕获信号并定义自己的处理逻辑。SIGBUS 信号通常指示某种非法的内存访问,其原因与 SIGSEGV(段错误) 略有不同。这两个信号都是由于进程试图访问其不应该访问的内存而被触发的,但它们的具体场景和原因不同,具体不同如下:

  • 产生原因: SIGSEGV(Segmentation Fault) 通常表示进程试图访问其虚拟地址空间中不存在的内存位置,或试图访问一个它没有权限访问的位置(例如写入只读内存); SIGBUS(Bus Error) 通常表示某种不正确的内存访问尝试,这是由于某种物理或者系统级的限制导致的。例如非对齐的内存访问在某些架构上可能会触发 SIGBUS,或者一个进程试图访问超过了其通过 mmap() 映射的文件的末尾位置
  • 常见的发生场景: SIGSEGV 当试图访问 NULL 指针、堆中已被释放的内存、只读内存区域等时,通常会收到此信号; SIGBUS 当执行非对齐的内存访问,或当访问被映射到进程地址空间但文件实际上并不覆盖的那部分内存时,可能会收到此信号
  • 硬件和平台差异: 在某些硬件和操作系统上,SIGSEGVSIGBUS 之间的区别可能不那么明显。例如某些平台可能在所有的非法内存访问场景下都发送 SIGSEGV

图片无法显示,请右键点击新窗口打开图片

尽管 SIGBUS 和 SIGEGV 在许多场景下都与非法的内存访问相关,但它们通常是由于不同的原因和上下文而被触发的。不过,对于大多数应用程序开发者来说,两者的具体区别可能不那么重要,因为在大多数情况下,无论是哪种信号,都表明程序中存在严重的错误,需要进行调查和修复. 以下是可能导致 SIGBUS 的一些常见情况:

  • 非对齐访问: 在某些平台上,如某些 ARM、SPARC 或 PA-RISC 架构,如果试图访问不符合其硬件要求的内存对齐的地址,可能会收到 SIGBUS
  • 内存映射问题: 如果进程使用 mmap() 映射了一个文件到内存,并试图访问超出文件大小的部分,那么它可能会收到 SIGBUS
  • 坏的共享内存或 IPC 机制: 例如,当一个进程试图访问它已经分离的共享内存段时,可能会触发 SIGBUS
  • HugeTLB 大页池子内存异常: 当映射 HugeTLB 大页的虚拟内存发生缺页时,HugeTLB 池子无法提供 HugeTLB 大页也会触发 SIGBUS

默认情况下,收到 SIGBUS 会导致进程终止并生成核心转储(core dump),但进程可以选择忽略它、处理它,或者将其动作更改为其他行为. SIGBUS 是指示某种特定类型的非法内存访问的信号。开发者在编写代码时应确保避免此类非法访问,或者正确处理此信号以确保程序的稳定性和可靠性. 另外在 Linux 触发 SIGEGV 的场景就很多,包括如下:

  • 空指针解引用: 当一个指针未被初始化(即它指向 NULL 或 0) 并被解引用时,会引发此错误
  • 堆错误: 使用free()释放已经释放的内存, 或者 在释放内存后,尝试使用这块内存.
  • 栈溢出: 当一个函数递归调用自身,而没有合适的终止条件,或者有一个非常大的本地数组,超过了栈的容量,这可以导致栈溢出
  • 访问受保护的内存区域: 例如一个进程尝试写入只读内存区域
  • 数组越界: 访问数组的非法索引,尤其是当访问的位置超出当前进程的地址空间时
  • 非法指令: 虽然这通常会引发 SIGILL,但在某些情况下,执行非法或无效指令可能会导致 SIGSEGV
  • 错误的函数指针调用: 如果函数指针被错误地设置到一个无效的地址,并被调用,它可能会引发 SIGSEGV
  • 使用 mmap 映射的内存问题: 如果你使用 mmap 映射了一个文件,然后尝试访问超出该文件长度的地址,这可能会引发 SIGSEGV

以上是一些可能导致 SIGSEGV 的常见场景, 在开发中,遇到此类错误通常意味着代码中存在严重的问题,需要使用调试工具,如gdb,进行调查。那么接下来通过实践案例了解 SIGBUS 和 SIGEGV 场景:

图片无法显示,请右键点击新窗口打开图片


SIGBUS 缺页场景

图片无法显示,请右键点击新窗口打开图片

HugeTLB 大页机制存在一个现象,就是进程使用 mmap 映射一段虚拟内存到 HugeTLB 大页,此时这段虚拟内存并未建立任何页表,按普通内存来说只有缺页的时候才会真正分配物理内存和建立页表,而 HugeTLB 机制就不一样,它在分配虚拟内存时就将对应数量的 HugeTLB 大页进行预留,一旦预留这些 HugeTLB 就不能分配给其他进程使用,另外只有虚拟内存缺页的时候才会真正建立页表映射到预留的 HugeTLB 大页上。这个机制的存在又会带来一个问题,就是某个进程分配了一个映射 HugeTLB 大页的虚拟内存,但该进程只有很少部分在使用,因此大部分预留 HugeTLB 大页是浪费的,并且此时系统内存压力特别大,出现了旱的旱死涝的涝死. 解决这个问题的办法不是没有,其中一种解法就是分配虚拟内存的时候,不对 HugeTLB 大页内存进行预留,这样可以最大程度利用 HugeTLB 内存,但也存在一定的风险,如果这个方案缺页的时候 HugeTLB 池子里没有可用内存,而且无法动态从系统分配新的大页,那么会出现不可预期的问题。接下来通过一个实践案例深入了解这个问题,实践案例在 BiscuitOS 上的部署逻辑是:

cd BiscuitOS
make menuconfig
  
  [*] DIY BiscuitOS/Broiler Hardware  --->
      [*] Support Host CPU Feature Passthrough
  [*] Package  --->
      [*] Paging Mechanism  --->
          [*] Page Fault with HUGETLB Memory on OOM --->

# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-HUGETLB-OOM-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build

BiscuitOS-PAGING-PF-HUGETLB-OOM-default Source Code on Gitee

图片无法显示,请右键点击新窗口打开图片

实践案例由一个应用程序构成,程序首先在 21 行调用 mmap 函数分配一段 2MiB 的虚拟内存,这段虚拟内存采用了 MAP_HUGETLB 和 MAP_ANONYMOUS,因此绑定了公共 2MiB HugeTLB 大页池子了的大页, 另外使用了 MAP_NORESERVE 标志,该标志不会预留 HugeTLB 大页. 程序接着在 33 行对这段虚拟内存执行写操作,由于此时页表没有建立会触发缺页异常,程序接着在 35 行对虚拟内存执行读操作,此时页表已经建立,因此不会触发缺页,最后释放内存. 以上便是一个最基础的实践案例,可以知道 33 行写操作会触发缺页,为了可以看到内存在缺页异常里的流动,本次在 33 行前后加上 BS_DEBUG 开关:

图片无法显示,请右键点击新窗口打开图片 图片无法显示,请右键点击新窗口打开图片

接着在 HugeTLB 内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 hugetlb_fault 函数的 5733 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:

# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-HUGETLB-OOM-default/
# 编译内核
make kernel
# 编程程序
make build

图片无法显示,请右键点击新窗口打开图片

当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “HUGETLB OOM PF on hugetlb_fault 0x6000000000”, 说明缺页异常处理函数执行过 HUGETLB 缺页. 通过实践可以看到实践案例按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.

图片无法显示,请右键点击新窗口打开图片

缺页异常处理函数在 handle_mm_fault 函数调用 is_vm_hugetlb_page 函数判断是否为 HugeTLB 内存缺页,并调用 hugetlb_fault 函数进行 HUGETLB 缺页处理,HUGETLB 缺页与其他内存缺页流程不一样. HUGETLB 缺页流程首先检查对应的 PMD/PUD Entry 是否为空,为空说明 HUGETLB 内存首次缺页,然后调用 alloc_huge_page 从预留内存池子里分配大页,从之前的分析可知,正常情况下进程分配 HUGETLB 虚拟内存时就会预留相应的 HUGETLB 大页,但该场景下由于没有预留,那么调用 alloc_buddy_huge_page_with_mpol 函数通过超发大页方式分配,但此时超发大页也无法分配,那么只能返回错误,最终返回 VM_FAULT_SIGBUS,程序异常退出.

图片无法显示,请右键点击新窗口打开图片


SIGEGV 缺页场景

图片无法显示,请右键点击新窗口打开图片

只读匿名内存是只读对匿名内存进行读操作,其可以通过 mmap() 函数进行分配,其只包含 PROT_READ 权限即可,malloc/brk 函数不可分配只读匿名内存. 只读匿名内存在缺页时存在如下几种场景:

  • 当 CPU 首次对只读匿名内存读操作: MMU 因为发现没有对应的物理内存,于是触发缺页异常. 在支持 ZERO Page 的系统,缺页异常处理函数会将页表更新映射到 ZERO Page 上,那么 CPU 可以继续对匿名内存进行读操作,读出来的值全为 0.
  • 当 CPU 首次对只读匿名内存读操作: MMU 因为发现没有对应的物理内存,于是触发缺页异常. 在不支持 ZERO Page 的系统,缺页异常处理函数会将页表更新映射到一个新的物理页上,并且页表不包含 _PAGE_RW 标志,那么 CPU 可以继续对匿名内存进行读操作,读出来的值全为 0.
  • 当 CPU 对只读匿名内存写操作: MMU 检查到权限异常,于是触发缺页异常,缺页异常处理函数检查到没有写权限,于是发送 SIG_BUS,因此程序会 SegmentFault 异常退出.

虽然都是只读内存,但访问内存的行为不同,以及 Linux 支持的功能不同,最终导致缺页的行为也是大相径庭。为了更加深刻了解只读匿名内存缺页,接下来通过一个实践案例了解该场景,实践案例在 BiscuitOS 上的部署逻辑如下:

cd BiscuitOS
make menuconfig

  [*] Package  --->
      [*] Paging Mechanism  --->
          [*] Page Fault with Anonymous on RO(Read-Only Zero Page) --->

# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-RO-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build

BiscuitOS-PAGING-PF-ANON-RO-default Source Code on Gitee

图片无法显示,请右键点击新窗口打开图片

实践案例由一个应用程序构成,进程在 21 行调用 mmap 函数并使用 MAP_PRIVATE 和 MAP_ANONYMOUS 标志分配一段匿名内存, 并且只赋予 PROT_READ 属性,然后在 32 行对匿名内存进行读操作,并在 34 行对匿名内存进行写操作,接着为了构造另外一个场景,程序在 38 行对只读匿名内存发起写操作,以此观察程序运行情况,操作完毕之后就是释放虚拟内存。以上便是一个最基础的实践案例,可以知道 32 行读操作和 38 行写操作都会触发缺页,为了可以看到内存在缺页异常里的流动,在 32 行前后加上 BS_DEBUG 开关:

图片无法显示,请右键点击新窗口打开图片 图片无法显示,请右键点击新窗口打开图片

接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_anonymous_page 函数的 4063 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:

# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-RO-default/
# 编译内核
make kernel
# 编程程序
make build

图片无法显示,请右键点击新窗口打开图片

当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “RO-Anonymous Memory do_anonymous_page 0x6000000000”, 那么说明实践案例分配的 ZERO Page 的只读匿名内存,并非普通内存,另外也可以看到进程对只读匿名内存发起写操作,引起了 SegmentFault 导致程序异常退出,通过实践可以看到只读匿名内存按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.

图片无法显示,请右键点击新窗口打开图片

对于只读匿名内存,其缺页异常处理流程如上图,如果是读操作,并且支持 ZERO Page,那么最终进入 “R ZERO Page” 分支; 如果是对只读匿名内存进行写操作,那么进入 “SIG_BUS” 分支(上图不包括不支持 ZERO Page 场景).

图片无法显示,请右键点击新窗口打开图片

对于首次发起对只读匿名内存读操作导致的缺页,do_anonymous_page 函数作为核心处理流程其逻辑如上图, 函数首先判断到此时缺页是读操作引起的,并且 mm_forbids_zeropage 返回 0 说明系统支持 ZERO Page,那么缺页异常处理会为读操作映射到 ZERO Page. 所谓 ZERO Page 就是一块特殊的内存,内存的内容全部为 0,并且有 4KiB 粒度和 2MiB 粒度的,只要符合条件的进程都可以将匿名内存映射到 ZERO Page 上,这样可以起到节省内存的作用. 因此函数在 4063 行调用 pte_mkspecial 函数构造了一个指向 ZERO Page 的 PTE Entry 内容,并在 4065 函数调用 pte_offset_map_lock 函数获得 PTE Entry 的地址,如果发现此时 PTE Entry 不为空,那么可能是页表的内容和 TLB 的内容不一致,此时函数进入 4068 行分支调用 update_mmu_tlb 函数更新 TLB,并跳转到 unlock 处; 反之如果此时 PTE Entry 为空,说明是第一次访问匿名内存,符合预期,那么跳转到 setpte 处将准备好的 PTE Entry 内容更新到 PTE Entry 里,待缺页中断返回之后进程可以继续对匿名内存进行读操作而不会引起缺页. 细心的开发者是否发现,整个过程没有设置页表的 _PAGE_RW 标志位,那么说明这段虚拟内存是写保护的,因此接下来只要进程对虚拟内存首次发起写操作,那么还是会触发缺页.

图片无法显示,请右键点击新窗口打开图片

对于只读匿名内存发起写操作,如果是首次对只读匿名内存发起访问,那么此时 MMU 会检查到物理内存不存在,并触发缺页异常,缺页异常处理流程在 do_user_fault() 函数里调用 access_error() 函数检查进程对 VMA 的权限,其中 1110 行可以看到缺页异常的原因包含 PF_WRITE,也就是发生缺页时包括写动作,这里需要说明一下缺页异常不一定是有写操作引起,如果是首次对只读匿名内存发起的访问,物理内存不存在是触发缺页异常的主要原因,另外一种场景是对一块已经映射物理内存的只读匿名内存发起写操作,那么此时写操作是触发缺页异常的主要原因. 继续分析 1110 行检查到有写操作之后,函数继续在 1112 行检查 VMA 是否为可写的,对于只读匿名内存来说,VMA 是不可写的,因此这里条件成立函数返回 1,那么接下来就是发送 SIG_BUS 导致程序 SegmentFault 异常退出.

图片无法显示,请右键点击新窗口打开图片