内核追踪机制

Linux 存在众多 tracing tools,比如 ftrace、perf,他们可用于内核的调试、提高内核的可观测性。这些工具的背后是内核提供的一系列探测点,这些工具可以在这些探测点注入自定义函数,在函数中实现获取想要的上下文信息并保存下来。内核提供了许多类型的探测点,这里主要讨论kprobe、uprobe、tracepoint。

kprobes

Linux kprobes调试技术是内核开发者们专门为了便于跟踪内核函数执行状态所设计的一种轻量级内核调试技术。利用kprobes技术,内核开发人员可以在内核的绝大多数指定函数中动态的插入探测点来收集所需的调试状态信息而基本不影响内核原有的执行流程。

kprobes技术依赖硬件架构相关的支持,主要包括CPU的异常处理和单步调试机制,前者用于让程序的执行流程陷入到用户注册的回调函数中去,而后者则用于单步执行被探测点指令。需要注意的是,在一些架构上硬件并不支持单步调试机制,这可以通过一些软件模拟的方法解决(比如riscv)

在x86架构上,典型的kprobe流程如下图所示:

image-20250918204348697

img

kprobe的工作原理如下:

  1. 注册kprobe后,注册的每一个kprobe对应一个kprobe结构体,该结构中记录着探测点的位置,以及该探测点本来对应的指令。
  2. 探测点的位置被替换成了一条异常的指令,这样当CPU执行到探测点位置时会陷入到异常态,在x86_64上指令是int3(如果kprobe经过优化后,指令是jmp)
  3. 当执行到异常指令时,系统换检查是否是kprobe 安装的异常,如果是,就执行kprobe的pre_handler,然后利用CPU提供的单步调试(single-step)功能,设置好相应的寄存器,将下一条指令设置为插入点处本来的指令,从异常态返回;
  4. 再次陷入异常态。上一步骤中设置了single-step相关的寄存器,所以原指令刚一执行,便会再次陷入异常态,此时将single-step清除,并且执行post_handler,然后从异常态安全返回.
  5. 当卸载kprobe时,探测点原来的指令会被恢复回去。

kprobes技术包括3种探测手段分别为kprobe、jprobe和kretprobe,其中:

  • kprobe是最基本的探测方式,是实现后两种的基础,它可以在内核的任何指令位置插入探测点;
  • jprobe基于kprobe实现,只能插入到一个内核函数的入口,它用于获取被探测函数的入参值;(已被弃用)
  • kretprobe也是基于kprobe实现,可以在指定的内核函数返回时才被执行。利用该方式可以获取被探测函数的返回值,还可以用于计算函数执行时间等方面。

uprobes

User-space probes 简称 Uprobes,它能够动态的介入应用程序的任意函数,采集调试和性能信息,且不引起混乱。目前,用户态探针有两种类型: uprobes 和 uretprobes(也叫 return 探针)。可以在应用程序的虚拟地址空间的任意指令上插入 uprobe,当用户函数返回的时候触发 uretprobe

当一个 uprobe 被注册后,Uprobes 会创建一个被探测指令的副本,停止被探测的应用程序,用断点指令替换被探测指令的首字节(在 i386 和 x86_64 上是 int3),之后让应用程序继续运行。(在插入断点的时候,Uprobes 使用与 ptrace 使用的相同的 copy on write 机制,这样断点也只影响那个进程,不会影响其他运行相同程序的进程。甚至是被探测的指令在共享库中也一样。)

当 CPU 命中断点指令的时候,发生了一个软件中断 trap,CPU 用户模式的寄存器都被保存起来,产生了一个 SIGTRAP 信号。Uprobes 拦截 SIGTRAP 信号,找到关联的 uprobe。然后,用 uprobe 结构体和先前保存的寄存器地址调用与 uprobe 关联的回调函数。这个回调函数可能会阻塞,但要记住回调函数执行期间,被探测的线程一直是停止的。

接下来,Uprobes 会单步执行被探测指令的副本,之后会恢复被探测的程序,让它在探测点之后的指令处继续执行。被单步执行的指令副本存储在每个进程的”单步跳出(SSOL)区域”中,它是由 Uprobes 在每个被探测进程的地址空间中创建的很小的 VM 区域。

如果想使用 uretprobe 探针,需要调用 register_uretprobe() 函数,此时 Uprobes 在函数的入口处创建一个 uprobe ,当调用被探测函数的时候命中这个探针,Uprobes 会保存 return 地址的一个副本,然后用”蹦床”的地址替换 return 地址(一段包含一个断点指令代码)。蹦床存储在 SSOL 区域中。

当被探测的函数执行它的 return 指令时,控制转移到蹦床,命中断点。Uprobes 的蹦床回调函数调用与 uretprobe 关联的回调函数,然后把已保存的指令指针设置为已保存的 return 地址,再然后就从 trap 返回后的地方恢复执行。

tracepoint

Tracepoint 是一个静态的 tracing 机制,开发者在内核的代码里的固定位置声明了一些 Hook 点,通过这些 hook 点实现相应的追踪代码插入,一个 Hook 点被称为一个 tracepoint.

Tracepoint由内核维护,一旦编入内核,以后基本不会变动,因此能提供稳定的ABI,而另一种内核跟踪机制kprobe则可能因为内核函数的更名或修改而在接口上发生变化。内核会尽力确保旧版本内核中的Tracepoint会继续出现在新版中。当然,由于需要人工维护,因此Tracepoint并不能覆盖所有的Linux子系统。我们可以编写基于Tracepoint的数据收集和分析工具,发挥其稳定性优势。

和其它静态插桩方式一样,Tracepoint也会和内核源码一起编译。默认情况下,Tracepoint是关闭的,因此在插桩点,Tracepoint的实际指令为nop,表示什么都不做。

在内核运行时,若用户使能了某一Tracepoint,Tracepoint处的nop指令会被动态改写为跳转指令jmpjmp指令会跳转到当前函数的末尾,这里存放了一个数组,记录了当前Tracepoint的回调函数。用户开启Tracepoint时,探针函数也会以RCU的形式注册到这个数组中。

当Tracepoint被关闭后,跳转指令再次覆盖为nop,同时用户的探针函数被移除。