每个内核程序员都应该知道的 Jump Labels

Lobsters Hottest 新闻

摘要

本文提供了关于 Linux 内核中 Jump Labels 的全面指南,解释了其硬件背景、用法和实现细节,面向内核程序员。

<p><a href="https://lobste.rs/s/6xfpvw/what_every_kernel_programmer_should_know">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/09/07 18:54

# 每个内核程序员都应该了解的跳转标签 › Wander Lairson Costa 来源: https://walac.github.io/jumplabels/ - 简介 (https://walac.github.io/jumplabels/#introduction)- 1 硬件背景(为何困难) (https://walac.github.io/jumplabels/#hardware-background-why-this-is-hard)- 1.1 CPU 如何实际执行指令 (https://walac.github.io/jumplabels/#what-a-cpu-actually-does-with-instructions) - 1.2 x86 指令编码:JMP 和 NOP (https://walac.github.io/jumplabels/#x86-instruction-encoding-jmp-and-nop) - 1.3 为何不能直接在 SMP 上通过 `memcpy` 覆盖运行中的代码 (https://walac.github.io/jumplabels/#why-you-cannot-just-memcpy-over-live-code-on-smp) - 1.4 写入只读内核文本段:`text_poke()` (https://walac.github.io/jumplabels/#writing-read-only-kernel-text-text_poke) - 2 跳转标签解决的问题 (https://walac.github.io/jumplabels/#the-problem-jump-labels-solve) - 3 思维模型,一图概览 (https://walac.github.io/jumplabels/#the-mental-model-in-one-diagram) - 4 如何使用静态键(实用指南) (https://walac.github.io/jumplabels/#how-to-use-static-keys-the-cookbook)- 4.1 最小示例 (https://walac.github.io/jumplabels/#minimal-example) - 4.2 选择 TRUE 还是 FALSE,以及 likely 与 unlikely (https://walac.github.io/jumplabels/#choosing-true-vs-false-and-likely-vs-unlikely) - 4.3 布尔值启用与引用计数启用 (https://walac.github.io/jumplabels/#boolean-enable-vs-refcounted-enable) - 4.4 读取状态而不发生分支 (https://walac.github.io/jumplabels/#reading-the-state-without-taking-the-branch) - 4.5 键必须是全局或静态存储的 (https://walac.github.io/jumplabels/#keys-must-be-global-static-storage) - 4.6 初始化后只读的键 (https://walac.github.io/jumplabels/#read-only-after-init-keys) - 4.7 速率受限的禁用(面向用户空间的旋钮) (https://walac.github.io/jumplabels/#rate-limited-disable-userspace-facing-knobs) - 4.8 CPU 热插拔 / 死锁规则 (https://walac.github.io/jumplabels/#cpu-hotplug-deadlock-rule) - 4.9 什么*不*该做 (https://walac.github.io/jumplabels/#what-not-to-do) - 4.10 一个真实的使用者:跟踪点 (https://walac.github.io/jumplabels/#a-real-consumer-tracepoints) - 5 两种极性:键默认值 × 分支提示 (https://walac.github.io/jumplabels/#two-polarities-key-default-branch-hint)- 5.1 宏如何选择汇编指令 (https://walac.github.io/jumplabels/#how-the-macros-pick-the-asm) - 6 编译器生成了什么(x86_64) (https://walac.github.io/jumplabels/#what-the-compiler-emits-x86_64)- 6.1 两个汇编辅助程序 (https://walac.github.io/jumplabels/#the-two-asm-helpers) - 6.2 跳转表条目(附加元数据) (https://walac.github.io/jumplabels/#the-jump-table-entry-sidecar-metadata) - 6.3 `HAVE_JUMP_LABEL_HACK`:为何代码位置是 2*或*5 字节 (https://walac.github.io/jumplabels/#have_jump_label_hack-why-sites-are-2-or-5-bytes) - 6.4 汇编层面的视图 (https://walac.github.io/jumplabels/#assembly-level-picture) - 7 核心数据结构 (https://walac.github.io/jumplabels/#core-data-structures)- 7.1 `struct static_key` (https://walac.github.io/jumplabels/#struct-static_key) - 7.2 `struct jump_entry`(相对形式) (https://walac.github.io/jumplabels/#struct-jump_entry-relative-form) - 7.3 关系 (https://walac.github.io/jumplabels/#relationship) - 7.4 链接器段 (https://walac.github.io/jumplabels/#linker-section) - 8 x86 上可补丁位置的大小(运行时) (https://walac.github.io/jumplabels/#size-of-the-patchable-site-on-x86-runtime) - 9 静态键的生命周期:启动、启用、禁用 (https://walac.github.io/jumplabels/#life-of-a-static-key-boot-enable-disable)- 9.1 启动:`jump_label_init()` (https://walac.github.io/jumplabels/#boot-jump_label_init) - 9.2 启用:`static_key_enable()` / `static_branch_enable()` (https://walac.github.io/jumplabels/#enabling-static_key_enable-static_branch_enable) - 9.3 禁用 (https://walac.github.io/jumplabels/#disabling) - 9.4 延迟 / 速率受限的计数递减 (https://walac.github.io/jumplabels/#deferred-rate-limited-dec) - 9.5 `jump_label_update()` → `__jump_label_update()` (https://walac.github.io/jumplabels/#jump_label_update-__jump_label_update) - 10 x86 文本补丁:详细内幕 (https://walac.github.io/jumplabels/#x86-text-patching-the-gory-details)- 10.1 早期启动与运行中的 SMP (https://walac.github.io/jumplabels/#early-boot-vs-live-smp) - 10.2 跳转标签使用的批处理 API (https://walac.github.io/jumplabels/#batching-api-used-by-jump-labels) - 10.3 INT3 SMP 算法 (`smp_text_poke_batch_finish`) (https://walac.github.io/jumplabels/#the-int3-smp-algorithm-smp_text_poke_batch_finish) - 10.4 “同步”的含义 (https://walac.github.io/jumplabels/#what-sync-means) - 10.5 通过只读映射写入 (https://walac.github.io/jumplabels/#writing-through-ro-mappings) - 10.6 一次启用的端到端时间线 (https://walac.github.io/jumplabels/#end-to-end-timeline-for-one-enable) - 11 模块:最棘手的部分 (https://walac.github.io/jumplabels/#modules-the-trickiest-part)- 11.1 加载:`jump_label_add_module()` (https://walac.github.io/jumplabels/#loading-jump_label_add_module) - 11.2 切换已链接的键 (https://walac.github.io/jumplabels/#toggling-an-already-linked-key) - 11.3 卸载:`jump_label_del_module()` (https://walac.github.io/jumplabels/#unloading-jump_label_del_module) - 12 后备方案:`CONFIG_JUMP_LABEL=n` (https://walac.github.io/jumplabels/#fallback-config_jump_label-n) - 13 详实的微型示例(字节层面) (https://walac.github.io/jumplabels/#worked-micro-example-bytes-on-the-wire)- 13.1 编译 / 链接 / `objtool`(`HAVE_JUMP_LABEL_HACK`) (https://walac.github.io/jumplabels/#compile-link-objtool-have_jump_label_hack) - 13.2 运行时 `static_branch_enable(&k)` (https://walac.github.io/jumplabels/#runtime-static_branch_enable-k) - 13.3 `static_branch_disable(&k)`:相同协议,非对称运算 (https://walac.github.io/jumplabels/#static_branch_disable-k-same-protocol-asymmetric-math) - 14 内核源码树中的延伸阅读 (https://walac.github.io/jumplabels/#further-reading-in-tree) ## 简介 本教程从第一性原理出发,讲解 Linux 内核的“跳转标签”——也称为“静态键”。代码基于内核 7.2 分支。**x86_64** 是全文涵盖的架构。即使你从未接触过内核文本补丁,本文也旨在保持可读性。前面的部分奠定了 CPU 和指令编码基础,后续章节将在此基础上展开——如果已经熟悉该部分,可略读。 核心引用文件: | 文件 | 作用 | |---|---| | `include/linux/jump_label.h` (https://elixir.bootlin.com/linux/v7.2-rc7/source/include/linux/jump_label.h) | 公共 API、宏、`struct static_key` (https://elixir.bootlin.com/linux/v7.2-rc7/source/include/linux/jump_label.h#L86) | | `kernel/jump_label.c` (https://elixir.bootlin.com/linux/v7.2-rc7/source/kernel/jump_label.c) | 架构无关的核心逻辑 | | `arch/x86/include/asm/jump_label.h` (https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/x86/include/asm/jump_label.h) | 生成分支的 x86 内联汇编 | | `arch/x86/kernel/jump_label.c` (https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/x86/kernel/jump_label.c) | x86 代码补丁后端 | | `arch/x86/include/asm/text-patching.h` (https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/x86/include/asm/text-patching.h) | JMP/INT3/CALL/RET 编码、`text_gen_insn()` (https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/x86/include/asm/text-patching.h#L123) | | `arch/x86/include/asm/nops.h` (https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/x86/include/asm/nops.h) | [`x86_nops[]`](https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/x86/kernel/alternative.c#L91) 表、`BYTES_NOPx` 原始字节定义 | | `arch/x86/kernel/alternative.c` (https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/x86/kernel/alternative.c) | `text_poke()` (https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/x86/kernel/alternative.c#L2668)、`smp_text_poke_*()` — SMP 安全的补丁器 | | `include/linux/jump_label_ratelimit.h` (https://elixir.bootlin.com/linux/v7.2-rc7/source/include/linux/jump_label_ratelimit.h) | `struct static_key_deferred` (https://elixir.bootlin.com/linux/v7.2-rc7/source/include/linux/jump_label_ratelimit.h#L9) | | `include/linux/tracepoint.h` (https://elixir.bootlin.com/linux/v7.2-rc7/source/include/linux/tracepoint.h) | 一个真实、被大量使用的消费者 | | `Documentation/staging/static-keys.rst` (https://elixir.bootlin.com/linux/v7.2-rc7/source/Documentation/staging/static-keys.rst) | 上游概述(关于 x86 大小部分有些过时) | | `tools/objtool/` (https://elixir.bootlin.com/linux/v7.2-rc7/source/tools/objtool) | 在 `HAVE_JUMP_LABEL_HACK` (https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/Kconfig#L1399) 下,编译时将 `jmp` 重写为 `nop` 的工具 | **命名:****“跳转标签”** 是底层机制 — `.text` 段中一个可补丁的位置加上 `\_\_jump_table` (https://elixir.bootlin.com/linux/v7.2-rc7/source/scripts/module.lds.S#L31) 中的一个条目。**“静态键”** 是程序员使用的更高级别的 API(`DEFINE_STATIC_KEY_*`、`static_branch_*`)。人们经常将这两个名称互换使用,但本教程将它们区分开来:“静态键”指 API,“跳转标签”指其底层的补丁机制。 --- ## 1 硬件背景(为何困难) 跳转标签通过在内核运行时重写机器代码来工作——这是自修改代码,由从未设计为期望这种操作的 CPU 执行。要理解这一点,需要一些硬件背景知识:CPU 如何实际处理指令和内存加载、涉及的精确字节级编码,以及为何一个运行中的多核内核不能简单地用普通写操作覆盖它们。 ### 1.1 CPU 如何实际执行指令 现代的 x86_64 核心并非“读一条指令,执行一条,然后重复”。大致过程是: 1. 从指令缓存(I-cache / L1i)中 **获取** 字节。 2. 将这些字节 **解码** 为微操作(x86 上是变长的——一条指令可以是 1-15 字节)。 3. **乱序执行**,由 **分支预测器** 猜测条件分支的走向,以保持流水线饱满。 4. 按顺序提交结果。 这种流水线带来两个后果,它们共同构成了跳转标签的整个性能优势。 第一个关于朴素的 `if (feature_enabled)` 检查的实际开销。乱序执行和分支预测使得 *分支本身* 几乎免费:预测足够准确,就无需支付流水线刷新的代价。但预测只掩盖了猜测分支走向的成本——它对作为猜测输入的 `feature_enabled` **加载** 操作毫无帮助。无论预测器是否猜对分支,该加载操作都必须在路径的每次命中时发生,且每次都会占用一个真实的缓存访问和一个执行端口。在缓存压力下,或者如果其他 CPU 曾写入该标志并将缓存行踢出,那个“廉价”的分支就完全不廉价了。 第二个后果正是跳转标签的全部意义:无条件的 `nop` 或无条件的 `jmp` 规避了整个问题,因为无需加载标志,也无需评估条件。“关闭”路径对于前端来说可以是字面意义上的空工作——解码一个 `nop`,继续前进——没有缓存行需要反弹,分支预测器也无从置喙。 ### 1.2 x86 指令编码:JMP 和 NOP §1.1 (https://walac.github.io/jumplabels/#what-a-cpu-actually-does-with-instructions) 中描述的加载与 `nop`/`jmp` 之间的成本差异,必须用真实的字节编码,以便补丁能在两者之间切换,并且当 §1.3 (https://walac.github.io/jumplabels/#why-you-cannot-just-memcpy-over-live-code-on-smp) 讨论原子性时,大小匹配至关重要——以下是两者的精确形态。 x86 是变长指令集;跳转标签关心的编码如下: | 指令 | 操作码字节 | 总大小 | 范围 | |---|---|---|---| | `INT3`(断点) | `CC` | 1 字节 | 不适用 | | `JMP rel8`(短跳转) | `EB xx` | **2 字节** | 从指令末尾起 -128 .. +127 字节 | | `JMP rel32`(近跳转) | `E9 xx xx xx xx` | **5 字节** | ±2 GiB | | 2 字节 NOP | `66 90` | 2 字节 | — | | 5 字节 NOP | `0f 1f 44 00 00` (`nopl 0x0(%rax,%rax,1)`) | 5 字节 | — | 常量定义在 `arch/x86/include/asm/text-patching.h` (https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/x86/include/asm/text-patching.h)(`JMP8_INSN_*`、`JMP32_INSN_*`、`INT3_INSN_*`)和 `arch/x86/include/asm/nops.h` (https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/x86/include/asm/nops.h)(`BYTES_NOP5` (https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/x86/include/asm/nops.h#L60) 等)中。 相对位移是从指令的**下一字节**开始计算的: `disp = dest - (addr + insn_size)` 这正是 `text_gen_insn()` (https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/x86/include/asm/text-patching.h#L123) / `__text_gen_insn()` (https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/x86/include/asm/text-patching.h#L93) 所计算的内容。 **为何大小匹配重要:** 如果用一个 5 字节的 `jmp` 替换一个 5 字节的 `nop`,周围的地址不会移动。栈上的返回地址、其他跳转目标、异常表、ORC 展开信息——都不需要更新。补丁是等长字节的就地交换。 ### 1.3 为何不能在 SMP 上直接通过 `memcpy` 覆盖运行中的代码 补丁内核文本比修补普通数据结构更难,因为有三点同时为真: 1. 它在启动后被映射为**只读**(`CONFIG_STRICT_KERNEL_RWX` (https://elixir.bootlin.com/linux/v7.2-rc7/source/arch/Kconfig#L1581)),因此普通存储操作会导致页面错误——任何执行补丁的机制都必须故意绕过这一点,而非偶然。 2. 它正被其他 CPU **并发地获取**——没有东西会在一个 CPU 编辑一个所有核心随时可能调用的函数时,暂停机器的其余部分。 3. 它可能已经被暂存在另一个 CPU 的流水线中,片刻前获取但尚未执行。 第二点是危险的,值得具体分析。 假设有两个 CPU,A 和 B,A 正在补丁一条 B 在循环中反复调用的 5 字节指令。A 发起的存储不是单个原子操作——CPU 会根据需要发起若干总线宽度的写操作来覆盖这 5 字节,而这些写操作对系统的其余部分是分别可见的。B 的获取操作可能落在这个序列的中间,看到一些写入前的字节和一些写入后的字节: ``` time ---> CPU A (补丁器) [ 写入字节 2-4 ] [ 写入字节 0-1 ] ^ | CPU B (获取器) [ 在这里获取全部 5 字节 ] | v 逐字节来看:0=旧 1=旧 2=新 3=新 4=新 = 撕裂的混合:2 个旧字节 + 3 个新字节 = 既不是旧指令也不是新指令——垃圾 ``` 如果 CPU A 写入五个字节,而 CPU B 正在获取同一条指令的中途,B 可能会观察到这种**撕裂的**新旧字节混合——这不是一条有效的指令,B 中的解码器也无法安全执行。 x86 *并不*保证对并发执行的指令进行多字节存储在指令获取方面是原子的。相比之下,单字节存储对于指令获取*是*原子的——没有 CPU 能看到它部分写入,因为一个字节没有“一半”的概念。Intel SDM 和内核采取的方法正是基于这一事实,将一次不安全的多字节写入转化为三次安全的单步操作: - 首先将该位置设为单字节的 `INT3`(`0xCC`)。这次单字节存储是原子的,因此每个 CPU 要么仍看到旧指令,要么已经看到陷阱——永远不会看到两者的撕裂混合。 - 然后在 `INT3` 仍在其上时,重写剩余的字节。此时还没有东西会通过这些字节进行获取,因为字节 0 仍是陷阱。 - 然后

相似文章

理解Linux内核:Linux内核启动

Hacker News Top

本文以太空殖民地隐喻描述初始化阶段,解释了x86_64架构下Linux内核的启动过程,涵盖从引导程序交接至用户空间初始化的完整流程。

如何在新平台上移植Linux内核

Hacker News Top

本文以模拟RISC-V CPU为例,阐述如何在新平台上移植Linux内核,包含设置最小硬件组件的分步指导与代码实现。

理解Linux内核:调度器

Hacker News Top

一篇详细的技术文章,解释Linux内核调度器,包括task_struct、调度类、上下文切换和EEVDF算法。

C in the Linux Kernel

Lobsters Hottest

本文深入介绍了 Linux 内核中 C 语言与普通用户空间 C 的区别,涵盖资源管理、错误处理、并发、日志记录、静态分析等核心技巧,使用了大量 GNU C 扩展和内核特有模式。