1. 初识Tracepoint:内核里的“预埋传感器”

如果你用过汽车的OBD接口,或者给家里的智能设备加装过传感器,那你对Tracepoint的理解会非常快。简单来说,Tracepoint就是Linux内核开发者预先在代码关键路径上埋好的“传感器”。当内核执行到这些特定位置时,如果“传感器”被开启了,它就会记录下当时的状态信息,比如哪个函数被调用了、传递了哪些参数、某个变量的值是多少等等。

这和我们熟悉的另一个工具kprobe有本质区别。kprobe更像一个“动态探针”,它可以在运行时动态地插入到内核的几乎任何指令位置,功能强大但开销也相对较高。而Tracepoint是静态的、编译时就确定好的。内核开发者觉得某个地方很重要,值得监控,就会在那里手动插入一个trace_xxx()函数调用。比如调度器切换任务时,就会调用trace_sched_switch()。因为它是静态的,所以它的位置和能获取的信息在编译时就已经固定了,这也带来了一个巨大的优势:性能开销极低。

默认情况下,所有的Tracepoint都是关闭的,对系统性能的影响微乎其微。这得益于内核的static_key机制,它通过修改代码段,让CPU在Tracepoint关闭时能几乎无代价地跳过检查。只有当你通过tracefs文件系统显式地启用某个Tracepoint后,它才会开始工作,将数据记录到内核的环形缓冲区(ring buffer)中。

你可以把/sys/kernel/debug/tracing/events/(或/sys/kernel/tracing/events/)目录想象成一个“传感器控制中心”。这里按照子系统(如sched、irq、syscalls)分门别类地列出了所有可用的Tracepoint。想监控系统调用?那就去syscalls目录下,把sys_enter_open或sys_exit_read这样的“传感器”开关打开。

我刚开始用Tracepoint的时候,觉得它比printk高级太多了。printk是“撒胡椒面”,打出来的日志又多又杂,还严重影响性能。而Tracepoint是“精准狙击”,只在需要的时候、在指定的位置,采集我需要的信息,并且格式规整,易于后续分析。这就像从用对讲机喊话,升级到了用专业的数据采集器。

2. 解剖一个Tracepoint:从TRACE_EVENT宏说起

内核里成百上千个Tracepoint,都不是手写出来的,而是通过一个强大的宏——TRACE_EVENT()——定义出来的。这个宏可以看作一个“代码生成器”,它把开发者对事件的描述,自动转换成一整套跟踪所需的代码。理解它,就掌握了Tracepoint的核心。

TRACE_EVENT()宏有六个部分,我把它类比成填写一份“事件出生证明”:

TRACE_EVENT(
    // 1. 名字:孩子叫啥?
    sched_switch,
    // 2. 原型:孩子长什么样?(参数类型)
    TP_PROTO(struct rq *rq, struct task_struct *prev, struct task_struct *next),
    // 3. 参数:具体是谁?(参数变量名)
    TP_ARGS(rq, prev, next),
    // 4. 结构:要记录哪些信息到小本本上?
    TP_STRUCT__entry(
        __array( char, prev_comm, TASK_COMM_LEN )
        __field( pid_t, prev_pid )
        __field( int, prev_prio )
        __field( long, prev_state )
        __array( char, next_comm, TASK_COMM_LEN )
        __field( pid_t, next_pid )
        __field( int, next_prio )
    ),
    // 5. 赋值:怎么把信息填到小本本里?
    TP_fast_assign(
        memcpy(__entry->next_comm, next->comm, TASK_COMM_LEN);
        __entry->prev_pid = prev->pid;
        __entry->prev_prio = prev->prio;
        __entry->prev_state = prev->state;
        memcpy(__entry->prev_comm, prev->comm, TASK_COMM_LEN);
        __entry->next_pid = next->pid;
        __entry->next_prio = next->prio;
    ),
    // 6. 打印:怎么把本子上的信息念给人听?
    TP_printk("prev_comm=%s prev_pid=%d prev_prio=%d prev_state=%s ==> next_comm=%s next_pid=%d next_prio=%d",
        __entry->prev_comm, __entry->prev_pid, __entry->prev_prio,
        __entry->prev_state ? __print_flags(__entry->prev_state, "|",
                { 1, "S"} , { 2, "D" }, { 4, "T" }, { 8, "t" },
                { 16, "Z" }, { 32, "X" }, { 64, "x" }, { 128, "W" }) : "R",
        __entry->next_comm, __entry->next_pid, __entry->next_prio)
);

第一部分和第二部分(name, proto) 定义了事件函数的“签名”。最终在内核代码中,你会看到trace_sched_switch(rq, prev, next)这样的调用。这确保了类型安全,编译器会帮你检查参数类型对不对。

第四部分(TP_STRUCT__entry) 是最关键的设计。它定义了一个结构体,用来描述要保存到环形缓冲区里的数据格式。这里用了__field、__array等辅助宏。这样做的好处是巨大的:当事件触发时,内核只需要把prev->comm、prev->pid这些原始数据,按照这个结构体的布局,拷贝一小块内存到环形缓冲区。这比直接格式化一个字符串(像printk那样)再存进去,效率高得多,占用的缓冲区空间也小得多。

第五部分(TP_fast_assign) 就是执行这个拷贝动作的代码。__entry指针指向的就是环形缓冲区里为这次事件预留的那块内存。

第六部分(TP_printk) 是“事后诸葛亮”。只有当用户通过cat /sys/kernel/tracing/trace读取数据时,才会调用这里的格式化代码,将二进制数据转换成人类可读的文本。触发事件的“热路径”和格式化输出的“冷路径”被完美分离,这是Tracepoint高性能的另一个秘诀。

你可以在/sys/kernel/tracing/events/sched/sched_switch/format里看到这个“出生证明”的最终版本。所有跟踪工具(如perf、trace-cmd)都是通过解析这个文件,才知道如何理解二进制格式的事件数据。

2.1 进阶技巧:DECLARE_EVENT_CLASS

如果你要定义几十个相似的事件(比如XFS文件系统有一百多个),每个都用TRACE_EVENT独立定义,会导致内核的代码段(text)和数据段(data)急剧膨胀。因为每个事件都会生成独立的一套函数和数据结构。

这时候就需要DECLARE_EVENT_CLASS和DEFINE_EVENT这对组合拳了。它们的思路是“模板化”。

// 先定义一个“模板”,描述公共的数据结构和操作
DECLARE_EVENT_CLASS(sched_wakeup_template,
    TP_PROTO(struct rq *rq, struct task_struct *p, int success),
    TP_ARGS(rq, p, success),
    TP_STRUCT__entry(
        __array( char, comm, TASK_COMM_LEN )
        __field( pid_t, pid )
        __field( int, prio )
        __field( int, success )
        __field( int, target_cpu )
    ),
    TP_fast_assign(...),
    TP_printk(...)
);

// 然后用这个模板“批量生产”具体的事件,它们共享模板的代码
DEFINE_EVENT(sched_wakeup_template, sched_wakeup,
    TP_PROTO(struct rq *rq, struct task_struct *p, int success),
    TP_ARGS(rq, p, success)
);

DEFINE_EVENT(sched_wakeup_template, sched_wakeup_new,
    TP_PROTO(struct rq *rq, struct task_struct *p, int success),
    TP_ARGS(rq, p, success)
);

这样一来,sched_wakeup和sched_wakeup_new这两个事件会共用TP_STRUCT__entry、TP_fast_assign和TP_printk的逻辑,只在最核心的trace_xxx函数调用点上有所不同。这能显著减少内核镜像的体积。事实上,现在内核中的TRACE_EVENT宏本身,就是DECLARE_EVENT_CLASS加DEFINE_EVENT的一层包装。

2.2 灵活的记录:更多字段类型

除了基础的__field和__array,Tracepoint还提供了更灵活的字段类型来应对复杂场景:

  • __string(item, src): 用于记录以NULL结尾的字符串。它会自动计算字符串长度并存储。
  • __dynamic_array(type, item, len): 用于记录长度在运行时才能确定的数组。你需要提供一个表达式来计算长度len。
  • __get_str(name): 在TP_printk中,用于获取__string记录的字符串。
  • __print_flags()和__print_symbolic(): 在TP_printk中,将位标志或枚举值转换成人类可读的字符串。比如把0x80d这样的GFP标志打印成GFP_KERNEL|GFP_ZERO。

3. 魔法背后的实现:从宏到运行时代码

光有宏定义还不够,怎么把它们变成内核里可以调用的函数、可以注册的回调呢?秘密就在头文件的包含顺序和那个神奇的define_trace.h里。

一个标准的Tracepoint头文件(比如include/trace/events/sched.h)结构是这样的:

// 1. 定义系统(组)名
#undef TRACE_SYSTEM
#define TRACE_SYSTEM sched

// 2. 防止重复包含的保护
#if !defined(_TRACE_SCHED_H) || defined(TRACE_HEADER_MULTI_READ)
#define _TRACE_SCHED_H

// 3. 包含必要的头文件
#include <linux/tracepoint.h>

// 4. 定义你的TRACE_EVENT事件
TRACE_EVENT(sched_switch, ...);
TRACE_EVENT(sched_wakeup, ...);
// ...

// 5. 结束保护
#endif /* _TRACE_SCHED_H */

// 6. **关键一步**:包含“魔法”头文件,必须在保护宏之外
#include <trace/define_trace.h>

TRACE_HEADER_MULTI_READ这个宏允许头文件被多次包含,这正是define_trace.h施展魔法所必需的。define_trace.h会多次重新包含你写的这个头文件,每次重新定义TRACE_EVENT宏的含义,从而分阶段生成不同的代码。

这个过程可以概括为四个阶段:

  1. 生成数据结构:第一次包含,TRACE_EVENT被重定义为生成struct trace_event_raw_<name>,也就是我们TP_STRUCT__entry里定义的那个结构体,用于在环形缓冲区里存数据。
  2. 计算数据偏移:第二次包含,生成struct trace_event_data_offsets_<name>,计算动态数组等字段在结构体中的偏移量。
  3. 生成格式化函数:第三次包含,生成trace_raw_output_<name>函数。这个函数就是TP_printk的具体实现,负责把二进制数据转换成文本,也就是你在trace文件里看到的那一行行输出。
  4. 生成核心回调与注册:第四次包含,这是最复杂的一步。它会生成:
    • trace_event_raw_event_<name>函数:这是最终注册到Tracepoint上的核心回调函数。当Tracepoint被触发时,就是它负责向环形缓冲区申请空间、用TP_fast_assign的代码填充数据、并提交数据。
    • struct trace_event_call event_<name>:这是内核事件子系统管理这个Tracepoint的核心控制块。它把Tracepoint本身(tp)、事件类(class)、格式化函数(funcs)等都链接在一起。
    • 通过__attribute__((section("_ftrace_events"))),把这个控制块的指针放到一个特殊的段里。内核启动时,会遍历这个段,完成所有Tracepoint事件的自动注册。

那么,内核代码里怎么使用这个定义好的Tracepoint呢?很简单,包含头文件,然后像调用普通函数一样调用它:

// 在**一个且仅一个**.c文件中,定义CREATE_TRACE_POINTS,告诉define_trace.h“请在这里生成代码”
#define CREATE_TRACE_POINTS
#include <trace/events/sched.h>

// 在其他任何需要调用的.c文件中,直接包含头文件即可
#include <trace/events/sched.h>

void context_switch(...) {
    // ... 准备工作
    trace_sched_switch(rq, prev, next); // 插入跟踪点
    // ... 后续工作
}

这个设计非常巧妙,将声明(头文件)和实例化(.c文件中的CREATE_TRACE_POINTS)分离,既保证了类型安全,又避免了链接时的重复定义错误。

4. 核心数据结构与运作流程

理解了代码生成,我们再来看看支撑Tracepoint运行的核心数据结构。这能帮你更透彻地明白它是如何做到低开销的。

4.1 基石:struct tracepoint

这是最基础的结构,代表一个“钩子点”本身。

struct tracepoint {
    const char *name;                  // 名字,比如 "sched_switch"
    struct static_key key;             // **性能关键!** 用于快速判断是否启用
    int (*regfunc)(void);             // 当第一个回调注册时的初始化函数
    void (*unregfunc)(void);          // 当最后一个回调注销时的清理函数
    struct tracepoint_func __rcu *funcs; // **回调函数链表头**
};

struct tracepoint_func就是挂在这个链表上的节点,里面包含了回调函数指针、私有数据和一个优先级。

static_key是Tracepoint低开销的灵魂。 当Tracepoint未启用时,static_key的初始值是false,编译器会优化掉整个判断和函数调用,相当于这段跟踪代码完全不存在。启用时,内核会动态地将static_key的值改为true,并修补代码,使得条件判断得以执行。这个过程几乎是无锁且高效的。

4.2 桥梁:struct trace_event_call

如果说tracepoint是硬件接口,那么trace_event_call就是驱动。它把Tracepoint和更上层的跟踪框架(如ftrace、perf)连接起来。

struct trace_event_call {
    struct trace_event_class *class;
    union {
        char *name;
        struct tracepoint *tp; // **指向对应的tracepoint**
    };
    struct trace_event event;
    char *print_fmt;           // 保存TP_printk的格式字符串
    struct event_filter *filter; // 事件过滤器
    // ... 其他perf、BPF相关的字段
};

trace_event_class定义了一组同类事件的共同行为,比如probe函数(就是trace_event_raw_event_xxx)和reg函数(负责向tracepoint注册/注销自己)。

系统启动时,内核会扫描_ftrace_events段,找到所有的trace_event_call,并调用其class->raw_init进行初始化(主要是分配一个唯一的事件类型ID)。当你通过echo 1 > events/sched/sched_switch/enable启用事件时,内核会调用class->reg函数,最终通过tracepoint_probe_register,将class->probe(即数据记录函数)挂载到对应的tracepoint->funcs链表上。

4.3 完整流程:从启用到看到输出

让我们串起整个流程,看看从用户敲命令到屏幕上输出一行日志,内核都做了什么:

  1. 用户启用事件:echo 1 > /sys/kernel/tracing/events/sched/sched_switch/enable。
  2. 内核注册回调:tracefs文件系统操作触发trace_event_reg函数,它调用tracepoint_probe_register,将trace_event_raw_event_sched_switch函数注册到__tracepoint_sched_switch的funcs链表中。同时,该Tracepoint的static_key被启用。
  3. 代码执行到钩子点:内核运行到context_switch函数中的trace_sched_switch(...)调用。
  4. 条件跳转:static_key_false(&__tracepoint_xxx.key)判断为真,执行__DO_TRACE。
  5. 执行回调链表:__DO_TRACE遍历tracepoint->funcs链表,调用每一个注册的回调。这里主要是trace_event_raw_event_sched_switch。
  6. 记录数据:
    • 该函数检查事件是否被软禁用或过滤,如果是则直接返回。
    • 计算需要的内存大小(基础结构 + TP_STRUCT__entry定义的数据)。
    • 向环形缓冲区(ring buffer)申请一块相应大小的内存。
    • 执行TP_fast_assign的代码,将prev->comm、prev->pid等数据拷贝到申请的内存中。
    • 提交(commit)数据到环形缓冲区。
  7. 用户读取数据:cat /sys/kernel/tracing/trace。
  8. 格式化输出:tracing层的序列化代码从环形缓冲区取出二进制数据,根据事件类型ID找到对应的trace_event_call,调用其event.funcs->trace(即trace_raw_output_sched_switch函数)。
  9. 生成文本:trace_raw_output_sched_switch函数使用TP_printk定义的格式字符串和__entry中的数据,生成最终可读的文本行,通过seq_file机制输出到用户空间。

整个过程,性能敏感的数据记录路径(第4-6步)和用户体验的格式化输出路径(第8-9步)是分离的,并且通过static_key和高效的缓冲区管理,将跟踪对业务逻辑的干扰降到了最低。

5. 实战案例:系统调用跟踪的独特设计

系统调用(syscall)是用户态和内核态交互的主要接口,跟踪它们至关重要。但内核有几百个系统调用,如果每个都用TRACE_EVENT定义一遍,太繁琐了。内核采用了一种更巧妙、自动化的方式。

核心思想是“元数据(Metadata)驱动”。每个系统调用通过SYSCALL_DEFINEx宏定义时,会附带一个SYSCALL_METADATA宏,这个宏会生成一个struct syscall_metadata结构体,其中包含了:

  • name: 系统调用名(如"open")
  • syscall_nr: 系统调用号(启动时填充)
  • nb_args: 参数个数
  • types: 参数类型数组(如["const char *", "int", "mode_t"])
  • args: 参数名数组(如["filename", "flags", "mode"])

这些元数据被收集到特殊的__syscalls_metadata段中。内核启动时,init_ftrace_syscalls()函数会遍历这个段,根据系统调用地址和名称的匹配,为每个系统调用填充好调用号,并建立系统调用号到元数据的快速索引。

对于跟踪,内核只预定义了两个通用的Tracepoint:sys_enter和sys_exit。它们被放置在架构相关的系统调用入口/出口代码路径上(例如syscall_trace_enter和syscall_trace_exit函数中)。

当你在/sys/kernel/tracing/events/syscalls/下启用sys_enter_open时,发生了什么?

  1. 事件子系统根据名字知道这是sys_enter类事件,并关联到open的元数据。
  2. 启用事件时,会向通用的trace_sys_enter Tracepoint注册一个统一的回调函数(例如ftrace_syscall_enter)。
  3. 当任何系统调用发生时,都会走到trace_sys_enter。在这个统一回调函数里:
    • 通过pt_regs获取系统调用号。
    • 用系统调用号索引到对应的syscall_metadata。
    • 根据元数据中的参数个数和类型,使用syscall_get_arguments从寄存器中提取参数值。
    • 将系统调用号和参数值记录到环形缓冲区。
  4. 输出时,print_syscall_enter函数根据同样的元数据,知道如何打印出open(filename="/etc/passwd", flags=0x8000, mode=0)这样格式清晰的字符串。

这种设计极其优雅,增加一个新的系统调用,跟踪功能自动获得,无需额外定义Tracepoint。它充分体现了Linux内核“机制与策略分离”的设计哲学:通用机制(sys_enter/exit Tracepoint + 元数据)由内核提供,具体策略(每个系统调用的参数信息)由宏自动生成。

6. 不止于看:Tracepoint的现代应用

最初,Tracepoint主要是为了ftrace和perf等内核自带的跟踪工具服务的。但现在,它的重要性与日俱增,尤其是在以下两个领域:

1. 与eBPF的深度结合 这是Tracepoint当前最火热的应用场景。eBPF允许用户在内核中安全地执行自定义程序。Tracepoint为eBPF程序提供了稳定、可靠的挂载点。

  • 稳定性:Tracepoint是内核ABI的一部分,相比kprobe,它的接口更稳定,不会因为内核函数名的改变而失效。
  • 上下文丰富:Tracepoint能直接提供经过精心挑选、意义明确的参数,eBPF程序可以直接访问这些参数,无需像kprobe那样去“猜测”和解析栈或寄存器。 你可以用bpftrace一行命令挂载eBPF程序到Tracepoint上:
bpftrace -e 'tracepoint:sched:sched_switch { printf("%s -> %s\n", args->prev_comm, args->next_comm); }'

或者用更底层的libbpf编写复杂的监控、网络过滤、安全审计工具。Tracepoint+eBPF构成了现代Linux可观测性体系的基石。

2. 性能剖析与问题诊断 尽管perf record默认基于硬件采样,但它同样可以基于Tracepoint进行“事件触发式” profiling。这对于分析特定事件的延迟、频率和因果关系非常有效。

# 记录所有sched_switch事件,并生成火焰图
perf record -e sched:sched_switch -a -g -- sleep 5
perf script | stackcollapse-perf.pl | flamegraph.pl > sched_switch.svg

通过结合多个Tracepoint(如sched_switch、irq_handler_entry/exit、writeback),可以构建出复杂的性能分析场景,精准定位锁竞争、调度延迟、IO瓶颈等问题。

在我自己的工作中,就曾用irq:irq_handler_entry和irq:irq_handler_exit跟踪中断,结合eBPF计算中断处理时长,成功定位了一个因驱动编写不当导致的中断风暴问题。也用过syscalls:sys_enter_openat跟踪文件打开,来分析应用程序的文件访问模式。这种直接在内核关键路径上“搭便车”收集数据的能力,是其他任何外部工具都无法比拟的。

Tracepoint的设计,完美诠释了Linux内核的实用主义哲学:通过一套简洁而强大的静态插桩机制,为内核的透明性、可观测性和可扩展性打开了大门。从ftrace到perf,再到如今的eBPF生态,Tracepoint始终是连接内核内部状态与外部观察工具的坚固桥梁。掌握它,就等于拿到了深入Linux内核动态世界的一把万能钥匙。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐