大家好!我是大聪明-PLUS!
有一天,我们遇到了一个任务:在 Linux 内核和 OpenSBI 中实现对硬件触发器的支持。这促使我开展了一个研究项目,在这个项目中,我从调试器的角度研究了硬件触发器的含义、设计以及它们在观察点和断点中的应用。我还参与了 RISC-V Linux 和 OpenSBI 对硬件触发器支持的改进工作。
本文旨在分享这方面的知识。我将通过示例演示调试器中断点和监视点的工作原理,比较它们的软件和硬件实现,并深入探讨它们在 Linux 内核中的工作原理。首先,我将介绍一种简单的 GDB 调试方法,您将在下文中了解其影响。

首先,我们来编写一个函数,用于判断一个数是否为质数。
int` `result`;
`__attribute__`((`noinline`))
`int` `is_prime`(`uint64_t` `val`) {
`result` `=` `1`;
`for`(`uint64_t` `i` `=` `2`; `i` `*` `i` `<` `val`; `++i`) {
`if`(`val` `%` `i` `==` `0`) {
`result` `=` `0`;
`break`;
}
}
`return` `result`;
}`
全局变量result虽然有点麻烦,但以后会方便很多。接下来,我们将使用第一个命令行参数的值调用该函数,并以更方便的格式输出其结果:
int` `main`(`int` `argc`, `char*` `argv`[]) {
`uint64_t` `val`;
`sscanf`(`argv`[`1`], `"%"PRIu64`, `&val`);
`int` `prime` `=` `is_prime`(`val`);
`if` (`prime`) {
`printf`(`"%"PRIu64` `" is prime\n"`, `val`);
} `else` {
`printf`(`"%"PRIu64` `" is not prime\n"`, `val`);
}
}`
现在我们来看一下函数的标准调试周期is_prime:在函数上设置断点is_prime,执行某些操作continue,然后等待:
-
调试器会捕获到断点;
-
应用程序将在我们设置此断点的位置精确停止;
-
调试器会将控制权移交给我们;
-
我们将执行所有必要的调试程序;
-
我们会再次致电
continue; -
该项目即将结束。
$` `gdb` `--args` `is_prime`.`elf` `479001599`
`...`
(`gdb`) `break` `is_prime`
`...`
(`gdb`) `continue`
`Breakpoint` `1`, `is_prime` (`val=val@entry=479001599`) `at` `is_prime`.`c`:`11`
`11` `for`(`uint64_t` `i=` `2`; `i` `*` `i<` `val`; `++i`) {
`...`
(`gdb`) `continue`
`Continuing`.
`Child` `exited` `with` `status` `0
事情就是这样发生的。
现在是时候破解它了。让我们使用 objdump 命令导出我们正在检查的函数,并捕获它的第一条指令------更准确地说,是它的操作码,0x4791:
`000000000000085e <is_prime>:
85e: 4791 li a5,4
860: 02a7f263 bgeu a5,a0,884
864: 4789 li a5,2
866: a029 j 870
...`
在这个例子中,我们并不关心每条指令的具体作用,也不关心它是 RISC-V、ARM 还是 x86 架构。重要的是操作码。
让我们尝试一些看似奇怪的操作:在运行时,执行函数之前,is_prime我们将重写其第一条指令的操作码,使其与原操作码相同。为此,我们将编写一个函数,该函数:
-
这将允许我们向该页面写入我们感兴趣的指令;
-
将第一条指令重写为相同的指令;
-
会关注缓存,以确保自修改代码能够正常工作;
-
将撤销对已修改页面的写入权限------作为预防妄想症的措施之一。
最终形成的结构大致如下:
void` `smc_magic`() {
`size_t` `page_size` `=` `getpagesize`();
`size_t` `is_prime_addr` `=` (`size_t`)`is_prime`;
`size_t` `is_prime_page` `=` `is_prime_addr` `&` `~`(`page_size` `-` `1`);
`if` (`mprotect`((`void*`)`is_prime_page`, `page_size`,
`PROT_WRITE` `|` `PROT_EXEC` `|` `PROT_READ`) `<` `0`)
`exit`(`errno`);
`*`(`uint16_t*`)`is_prime` `=` `0x4791`;
`asm` `volatile`(`"fence` `rw`, `rw"` ::: `"memory"`);
`if` (`mprotect`((`void*`)`is_prime_page`, `page_size`,
`PROT_EXEC` `|` `PROT_READ`) `<` `0`)
`exit`(`errno`);
}`
调用函数smc_magic不会以任何方式影响指令的执行顺序,这意味着程序执行的结果将保持不变。
C 的黑暗角落
现在,奇迹发生了。在测试函数之前,is_prime我们将调用该函数smc_magic:
int` `main`(`int` `argc`, `char*` `argv`[]) {
`smc_magic`();
`uint64_val`;
`sscanf`(`argv`[`1`], `"%"PRIu64`, `&val`);
`int` `prime` `=` `is_prime`(`val`);
`if`(`prime`) {
`printf`(`"%"PRIu64` `" is prime\n"`, `val`);
} `else`{
`printf`(`"%"PRIu64` `" is not prime\n"`, `val`);
}
}`
现在让我们启动调试器,并完全重复前面描述的调试场景:
$` `gdb--args` `is_prime`.`elf` `479001599`
`...`
`0x0000003ff7fec022` `in` `_start` () `from` `target`:`/lib/ld-linux-riscv64-lp64d`.`so.1`
(`gdb`) `break` `is_prime`
`Breakpoint` `1` `at` `0x2aaaaaa862`: `file` `is_prime`.`c`, `line` `12.`
(`gdb`) `continue`
`Continuing`.
`Child` `exited` `with` `status` `0
这个例子可能看起来有点牵强。然而,它突显了一个在调试会动态修改代码的应用程序时可能出现的问题。例如,使用即时 (JIT) 编译器时------无论是庞大而复杂的 JVM、V8,还是小巧的 luajit 脚本引擎。
但这究竟是什么怪现象?为什么断点不起作用?要理解这一点,我们需要了解调试器如何与操作系统协同实现软件断点。
调试器如何设置断点
让我们看看 GDB 执行这些命令时会发生什么break。continue这将帮助我们找到解决方案。
在第一个例子中,我们调用了命令break is_prime:
$` `gdb` `--args` `is_prime`.`elf` `479001599`
`...`
(`gdb`) `break` `is_prime`
`...`
(`gdb`)`
然后调试器实际上将函数的第一条指令替换is_prime为ebreak:
`000000000000085e <is_prime>:
85e: 4791 ebreak
860: 02a7f263 bgeu a5,a0,884
864: 4789 li a5,2
866: a029 j 870
...`
许多架构都有类似的指令:int 3例如 x86 和bkptARM。这些指令只有一个作用:执行时会引发硬件异常。
调用调试器后,continue调试器继续执行被调试的应用程序,等待操作系统通知它应用程序事件。在本例中,直到被调试进程捕获到异常SIGTRAP(操作系统在执行过程中因硬件异常而生成的异常)为止ebreak。当 GDB 检测到被调试的应用程序已收到异常时SIGTRAP,它会将该异常ebreak指令替换回原始指令,并将控制权返回给用户。
当我们第二次继续执行程序时,在断点处停止后,调试器面临着两个非常重要的任务:执行被替换的指令和恢复ebreak断点地址,以便后者有机会再次执行。
为此,调试器将断点设置ebreak在下一条指令处,并继续执行应用程序。当收到下一个通知时SIGTRAP,它将断点恢复到原始地址,将第二ebreak条指令替换为上一条指令,并继续执行,从而有效地执行了命令step。
这里就清楚地说明了第二个例子中出了什么问题。我们第一次在执行函数之前就获得了进程控制权_start,而该函数位于crt0.sC 运行时的深处。我们在该函数上设置了一个断点is_prime。因此,调试器将第一条指令替换成了ebreak。执行流程随后到达了函数smc_magic,该函数ebreak用原始操作码重写了这条指令。当我们执行时continue,函数正常执行,ebreak但是,调试器没有"识别"到应该触发断点。
软件实现问题及解决方案
一直以来,GDB 默认使用的是软件实现的断点。其描述指出,断点不能设置在写禁用地址上。例如,如果代码段位于共享内存中。
任何修改源代码的应用程序都会给软件调试带来风险。如果应用程序在设置断点到执行该地址的指令之间修改了代码,断点可能会意外失效。应用程序甚至可能故意这样做,从而使逆向工程变得更加复杂!
这个问题有可能解决吗?可以,使用硬件断点。但在讨论硬件断点之前,我想先看看软件实现的另一个缺点:监视点的工作原理。
软件监控点
让我们再次打开 GDB,运行我们正在调查的应用程序,比如说,使用数字 479001599。它很简单,但数据量很大。让我们调用之前"愚蠢的"结果变量,为其设置一个软件监视点,然后运行continue:
$` `gdb` `--args` `is_prime`.`elf` `479001599`
`...`
`0x0000003ff7fec022` `in` `_start` () `from` `target`:`/lib/ld-linux-riscv64-lp64d`.`so.1`
(`gdb`) `set` `can-use-hw-watchpoints` `0`
(`gdb`) `watch` `result`
`Watchpoint` `1`: `result`
(`gdb`) `continue`
`Continuing`.`
当函数is_prime改变表达式的值时,应用程序就会停止运行result。但在此之前,我们有时间泡杯茶,喝上一杯,然后再泡一杯------大概需要等待 15 分钟。软件监视点虽然让调试器用起来方便得多,但速度实在慢得令人难以忍受。除了其他检测表达式值是否发生变化的方法之外,我想到的还有两种:一是等待step达到预期结果,二是向代码中添加大量的打印输出。
在表达式上设置监视点时,调试器会记住它的最后一个值,然后逐条执行被调试的应用程序指令,并检查它是否发生了变化。所以,它和断点类似:
-
调试器将第一条指令替换为
ebreak, -
停止应用程序。
-
看看这个表达方式的含义是否发生了变化,
-
执行
ebreak下一条指令, -
循环往复,周而复始。
每次抛出硬件异常时,程序执行流程都会进入内核,SIGTRAP然后带着异常信号返回用户空间,最后再返回调试器。
既然我们已经解决了软件实现中显而易见的缺点,我建议接下来采用一种能够消除这些缺点的解决方案。
硬件触发器
处理器核心硬件包含一个模块,该模块包含若干称为触发器的实体------通常为 4、8 或 16 个。每个触发器都被分配了一组地址;当对其进行读/写/执行操作时,触发器会生成一个硬件异常------就像......一样ebreak。得益于这种硬件特性,GDB 无需修改应用程序代码即可实现调试。
GDB 默认使用硬件监视点。要使用硬件断点,需要break使用[ ] 而不是 [ hbreak]。这样,上面的两个示例都能快速运行且不会出错。这似乎是一个万全之策,但为什么它没有被广泛应用呢?
关键在于,与软件实现不同,硬件触发器数量很少。在 x86 架构中只有四个触发器。在 ARM 架构中,大约有 2 到 6 个(Cortex-M),有时有 16 个(AArch64 系统),尽管该架构最多允许 64 个触发器。在 RISC-V 架构中,触发器的数量也大约为 16 个(SiFive)。
两种实现方式的优点在于它们可以互相弥补对方的不足。让我们总结一下两种方法的优缺点。
硬件实现:
-
允许您在不修改应用程序代码的情况下进行调试。因此,您无需获得相应的权限即可进行此类更改。您可以调试 ROM、没有写入权限的页面或使用 SMC 的应用程序。
-
让您能够更快地实现监视点。
-
触发器的数量有限且非常少。
软件实现:
-
适用于大多数调试场景。
-
触发器的数量没有限制。
同时使用这两种实现方式可以提高调试效率和可靠性。但这并非硬件触发器最有趣的地方。对我这个操作系统开发者而言,这仅仅是一个冗长的序幕,因为真正的精彩之处在于内核底层------那些根据用户空间请求配置硬件触发器的机制。它们需要:
-
处理硬件触发器产生的异常,并向进程发送信号;
-
确定触发器属于哪个进程,避免将
SIGTRAP其发送到未设置触发器的进程; -
区分用户空间、进程和内核本身使用的触发器;
-
仅当进程运行时才激活触发器。
为了理解其工作原理,我建议先绕道讨论一下著名的性能分析工具 perf。通过实验,我们将了解如何在内核中实现对硬件触发器的支持。但要做到这一点,我们需要实现页面错误,因为它的处理方式与我们希望通过硬件触发器实现的方式非常相似!
穿孔
让我们继续模拟这个例子------我们将使用 perf 进行性能分析!例如,让我们让它找出示例中哪些组件出现了轻微的页面错误,因为我预计函数中至少会出现一个页面错误smc_magic。这是为什么呢?
当尝试读取或写入没有关联物理地址的虚拟地址的数据时,处理器会生成一个硬件异常供内核处理。这种情况可能发生在以下两种情况下:
-
用户空间应用程序解引用指向不属于进程虚拟地址空间的地址的指针。作为响应,内核会向进程发送一个信号
SIGSEGV。这被称为主缺页错误。 -
内核有意允许出现虚拟地址无法与物理地址关联的情况,并且它知道如何透明地修复此问题,从而避免进程出错。这被称为次要缺页错误。
最初,我预计该部分.text(具有只读和只执行权限)位于共享内存页上,因为内核会将文件中的共享内存页映射到内存中。这样可以在多个进程运行同一个可执行文件的情况下节省内存。
当mprotect内核授予代码页写入权限时,它会在进程的页表中分配一个新页,但不会将其与物理页关联起来。这样做几乎总是因为:
-
可能无法访问已分配的内存页。
-
mmap而且mprotect工作效率更高。
内核仅在首次写入新分配的虚拟页面时才会捕获硬件异常,将其与一个物理页面关联,并将数据复制到该页面。这是一种写时复制 (Copy-on-Write) 优化,对所有进程都有利。修改该页面的进程可以.text继续执行,其他进程也可以从原始的、已加载的可执行文件启动。此时会发生轻微的缺页错误。
让我们来验证这个假设:
`$ perf record -e faults ./is_prime.elf
$ perf report
# Samples: 29 of event 'minor-faults'
# Event count (approx.): 48
#
# Overhead Command Shared Object Symbol
# ........ ............... ........................... ...........................
#
...
4.17% is_prime.elf is_prime.elf [.] smc_magic
...`
它奏效了!但是,perf如果它是一个用户空间应用程序,它是如何知道内核事件的呢?内核又是如何统计这些事件的呢?答案就在内核系统(恰如其分地命名为 Perf Events)以及系统调用中perf_event_open。
性能事件
Perf Events 是一个用于 Linux 内核的性能分析和跟踪框架。它的主要目的是收集用户空间和内核软件与硬件交互的信息。该框架驻留在一个文件中/kernel/events/core.c,是一个功能极其强大的系统,能够:
-
收集硬件和软件事件的概况;
-
确定事件所属的进程、线程和访问级别;
-
与 eBPF、其他内核系统等进行交互。
所有赛事均分为若干组别,其中最值得关注的是:
-
硬件事件------架构和微架构事件,例如处理器周期数、执行的指令数、缓存访问和未命中数、流水线空闲周期数等。
-
软件事件------软件事件,例如前面提到的次要和主要页面错误、上下文切换。
每组事件都会被合并成一个独立的绩效监控单元(PMU),并由以下人员进行报告perf:
`$ perf list
List of pre-defined events (to be used in -e or -M):
branch-instructions OR branches [Hardware event]
branch-misses [Hardware event]
cache-misses [Hardware event]
cache-references [Hardware event]
...
major-faults [Software event]
minor-faults [Software event]
page-faults OR faults [Software event]
task-clock [Software event]`
我们甚至可以在无需任何帮助的情况下手动采样我们感兴趣的事件perf------例如,非常轻微的页面错误------如果我们使用系统调用perf_event_open并稍微修改示例的话。
由于 glibc 没有定义该函数,让我们为其添加一个包装器:
#include <linux/perf_event.h>`
`#include <sys/syscall.h>`
`#include <sys/ioctl.h>`
`#include <unistd.h>`
`int` `perf_event_open`(`struct` `perf_event_attr` `*attr`, `pid_t` `pid`, `int` `cpu`,
`int` `group_fd`, `unsigned` `long` `flags`) {
`return` `syscall`(`SYS_perf_event_open`, `attr`, `pid`, `cpu`, `group_fd`, `flags`);
}`
这pid指定了被跟踪事件所属的进程cpu以及应跟踪该事件的逻辑核心。这些选项都是可选的!这意味着我们可以强制 Perf Events 统计特定进程和特定逻辑核心的事件!
让我们为感兴趣的事件创建一个计数器,并存储其文件描述符。我们还将禁用内核和虚拟机管理程序中发生的事件的跟踪。
int` `event_fd`;
`void` `prepare_event`() {
`struct` `perf_event_attr` `attr` `=` {};
`attr`.`type` `=` `PERF_TYPE_SOFTWARE`;
`attr`.`size` `=` `sizeof`(`struct` `perf_event_attr`);
`attr`.`config` `=` `PERF_COUNT_SW_PAGE_FAULTS_MIN`;
`attr`.`disabled` `=` `1`;
`attr`.`exclude_kernel` `=` `1`;
`attr`.`exclude_hv` `=` `1`;
`event_fd` `=` `perf_event_open`(`&attr`, `0`, `-1`, `-1`, `0`);
`if` (`event_fd` `==` `-1`)
`exit`(`EXIT_FAILURE`);
}`
实际上,我们将smc_magic用启动和停止采样的代码来覆盖我们感兴趣的函数行:
void` `start_sampling`() {
`ioctl`(`event_fd`, `PERF_EVENT_IOC_RESET`);
`ioctl`(`event_fd`, `PERF_EVENT_IOC_ENABLE`);
}
`void` `finish_sampling`() {
`long` `long` `count`;
`ioctl`(`event_fd`, `PERF_EVENT_IOC_DISABLE`);
`read`(`event_fd`, `&count`, `sizeof`(`count`));
`printf`(`"%lld minor page faults happen\n"`, `count`);
}
`void` `smc_magic`() {
`size_t` `page_size` `=` `getpagesize`();
`size_t` `is_prime_addr` `=` (`size_t`)`is_prime`;
`size_t` `is_prime_page` `=` `is_prime_addr` `&` `~`(`page_size` `-` `1`);
`if` (`mprotect`((`void*`)`is_prime_page`, `page_size`, `PROT_WRITE` `|` `PROT_EXEC` `|` `PROT_READ`) `<` `0`)
`exit`(`errno`);
`start_sampling`();
`*`(`uint16_t*`)`is_prime` `=` `0x4791`;
`finish_sampling`();
`asm` `volatile`(`"fence` `rw`, `rw"` ::: `"memory"`);
`if` (`mprotect`((`void*`)`is_prime_page`, `page_size`, `PROT_EXEC` `|` `PROT_READ`) `<` `0`)
`exit`(`errno`);
}`
让我们启动它,实现我们的目标......
$ `./is_prime.elf `13`
`1` minor page faults happen
`13` is prime`
我们先来弄清楚内核是如何检测到轻微页面错误并通知用户空间的。之后,我保证:我们会回到硬件触发器的话题。
正如我们之前发现的那样,在重写函数的第一条指令时is_prime,发生了硬件异常,导致为该函数分配了一个新的物理页is_prime,并且该函数也被意外调用了:
perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS_MIN, 1, regs, address);
它通知 Perf Events 发生了一起事件 PERF_COUNT_SW_PAGE_FAULTS_MIN**------这正是我们正在追踪的事件。**
Perf Events 的功能似乎与我们对内核硬件触发处理程序的要求非常相似。再次提醒,它必须能够:
-
处理硬件触发器产生的异常,并向进程发送信号;
-
确定触发器属于哪个进程,避免将
SIGTRAP其发送到未设置触发器的进程; -
区分用户空间进程使用的触发器和内核本身使用的触发器;
-
仅当进程运行时才激活触发器。
类似的功能也用于检测轻微的页面错误。所有匹配项都是特意设置的,我已经用粗体标出。要是性能事件功能每次事件发生时都能调用一个回调函数就好了。这样我们就可以SIGTRAP从这个回调函数向用户空间发送信息。我们可以在每个项目符号前加上一个绿色对勾,然后欢呼雀跃!等等,它居然可以做到!
硬件断点是一个性能事件
实际上,每个硬件触发都是一个性能事件,其溢出计数器限制设置为 1。此溢出的回调函数如下所示:
static` `void` `ptrace_hbptriggered`(`struct` `perf_event` `*bp`,
`struct` `perf_sample_data` `*data`,
`struct` `pt_regs` `*regs`)
{
`force_sig_fault`(`SIGTRAP`, `TRAP_HWBKPT`, (`void` `__user` `*`)`regs->badaddr`);
}`
也就是说,它会向SIGTRAP给定触发器所属的进程发送信号。
就像处理轻微页面错误一样,内核会捕获由触发器产生的硬件异常。然后,它会进行一些调查,以找出导致异常的原因:
void` `handle_break`(`struct` `pt_regs` `*regs`)
{
`if` (`probe_single_step_handler`(`regs`))
`return`;
`if` (`probe_breakpoint_handler`(`regs`))
`return`;
`...
然后将其发送给处理程序:
` if (notify_die(DIE_DEBUG, "EBREAK", regs, 0, regs->cause, SIGTRAP)
== NOTIFY_STOP)
return;
...
}`
它会通知 Perf Event 发生了硬件触发事件,就像之前收到有关轻微页面错误的通知一样:
static` `int` `hw_breakpoint_handler`(`struct` `die_args` `*args`) {
`...`
`perf_bp_event`(`any_triggered_event`, `args->regs`);
`...`
}`
为了实现这一切,内核注册了一个单独的 PMU 来处理硬件断点:
static` `struct` `pmu` `perf_breakpoint` `=` {
.`task_ctx_nr` `=` `perf_sw_context`, `/* could eventually get its own */`
.`event_init` `=` `hw_breakpoint_event_init`,
.`add` `=` `hw_breakpoint_add`,
.`del` `=` `hw_breakpoint_del`,
.`start` `=` `hw_breakpoint_start`,
.`stop` `=` `hw_breakpoint_stop`,
.`read` `=` `hw_breakpoint_pmu_read`,
};
`...`
`perf_pmu_register`(`&perf_breakpoint`, `"breakpoint"`, `PERF_TYPE_BREAKPOINT`);`
文档中指出,每次进程开始执行时,都必须调用一个函数hw_breakpoint_add来设置所有硬件触发器:
static` `int` `hw_breakpoint_add`(`struct` `perf_event` `*bp`, `int` `flags`)
{
`...`
`return` `arch_install_hw_breakpoint`(`bp`);
}`
当一个进程暂停执行时,必须使用该函数将其移除hw_breakpoint_del,否则它将在另一个进程中触发:
static` `void` `hw_breakpoint_del`(`struct` `perf_event` `*bp`, `int` `flags`)
{
`arch_uninstall_hw_breakpoint`(`bp`);
}`
每个架构都定义了相应的函数arch_*,这些函数会配置控制硬件触发器的寄存器。Perf Events 会处理其余部分!我想我们甚至可以尝试使用 [[Perf Events]] 来捕获硬件触发器生成的事件perf_event_open。但这还不能确定。
事实上,Perf Events 是一个功能强大且特性丰富的内核框架,它不仅支持事件跟踪,还支持其他调试和性能分析工具,例如 kprobes 和 tracepoints。它还与 eBPF 接口,使其在调试和性能分析方面具有极大的灵活性。一些驱动程序也在积极使用它。正如 Linux 系统中常见的情况一样,名称具有欺骗性:我个人在第一次接触到 eBPF 之后就对它们感到失望了。
结果
持续且协作地使用软件和硬件的断点和监视点实现,并了解它们的优缺点,有助于提高调试的速度和质量。这一切都得益于强大而全面的 Perf Events 框架,该框架位于核心层,易于使用,并且可供您使用。