- 文档
- 教程
- 操作系统
【免费下载链接】linux-insides
A book-in-progress about the Linux kernel and its insides.
kmemcheck是 Linux 内核提供的内存调试机制,专门用于捕获内核代码对未初始化内存的非法访问。本文是 linux-insides 内存管理章节的第三部分,在介绍 fix-mapped 地址与 ioremap(见 MM/linux-mm-2.md)之后,系统讲解 kmemcheck 的启用方法、工作原理,并逐层剖析其在内核中的实现:从命令行参数解析、影子页分配,到页面隐藏、缺页异常(page fault)处理与单步执行的完整闭环。读完本文,你将掌握 kmemcheck 的配置参数含义、它在 x86_64 上"以缺页异常为陷阱"的设计思想,以及每个关键代码路径的职责。
1. 背景:为什么在深入学习内存管理前要先讲调试机制
在上一部分我们了解了两个内存管理概念:
- Fix-Mapped 地址:虚拟内存中的特殊区域,其对应的物理映射在编译期即可计算,代表地址在编译期恒定、物理地址仅在启动过程中设定的页面;
- ioremap:将输入/输出相关的物理内存映射到虚拟内存空间的能力。
所有 memory-mapped I/O 地址都不会被内核直接使用。在内核使用这类内存之前,必须先将其映射到虚拟地址空间,这正是ioremap机制的核心目的。注意,上一部分我们只看到了早期(early)ioremap,非早期(non-early)版本要等到我们了解 vmalloc 等各类内存分配器之后才能深入理解。
因此在进入 Linux 内核非早期的内存管理之前,需要先了解一些提供特殊调试能力的机制,例如内存泄漏检测、内存控制等。理解了这些机制之后,再回头理解内存管理的全貌会容易得多。kmemcheck 正是这样一个调试机制,本部分围绕它展开。
2. 从/proc/iomem与/proc/ioports看物理设备内存布局
在深入 kmemcheck 之前,先看两个观察系统内存布局的入口。执行:
$ sudo cat /proc/iomem 00000000-00000fff : reserved 00001000-0009d7ff : System RAM 0009d800-0009ffff : reserved 000a0000-000bffff : PCI Bus 0000:00 000c0000-000cffff : Video ROM 000d0000-000d3fff : PCI Bus 0000:00 000d4000-000d7fff : PCI Bus 0000:00 000d8000-000dbfff : PCI Bus 0000:00 000dc000-000dffff : PCI Bus 0000:00 000e0000-000fffff : reserved ... ... ...可以看到系统为每个物理设备展示的内存映射:第一列是各类型内存使用的寄存器地址范围,第二列列出了位于这些寄存器范围内的内存种类。再比如:
$ sudo cat /proc/ioports 0000-0cf7 : PCI Bus 0000:00 0000-001f : dma1 0020-0021 : pic1 0040-0043 : timer0 0050-0053 : timer1 0060-0060 : keyboard 0064-0064 : keyboard 0070-0077 : rtc0 0080-008f : dma page reg 00a0-00a1 : pic2 00c0-00df : dma2 00f0-00ff : fpu 00f0-00f0 : PNP0C04:00 03c0-03df : vga+ 03f8-03ff : serial 04d0-04d1 : pnp 00:06 0800-087f : pnp 00:01 0a00-0a0f : pnp 00:04 0a20-0a2f : pnp 00:04 0a30-0a3f : pnp 00:04 ... ... .../proc/ioports列出当前已注册的、用于与设备进行输入/输出通信的端口区域。可以看到,这些 I/O 相关内存都不能被内核直接使用,必须先经ioremap映射,而 kmemcheck 正是接下来要掌握的调试工具。
3. kmemcheck 是什么:内核版的 Valgrind
从名字即可猜出,kmemcheck 用于"检查内存"。其核心作用是检查内核代码是否访问了未初始化的内存。举个简单的 C 程序例子:
#include <stdlib.h> #include <stdio.h> struct A { int a; }; int main(int argc, char **argv) { struct A *a = malloc(sizeof(struct A)); printf("a->a = %d\n", a->a); return 0; }这里为A结构分配了内存,然后尝试打印a字段的值。如果直接编译:
gcc test.c -o test编译器不会提示a字段未初始化。但用 valgrind 运行:
~$ valgrind --leak-check=yes ./test ==28469== Memcheck, a memory error detector ==28469== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al. ==28469== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info ==28469== Command: ./test ==28469== ==28469== Conditional jump or move depends on uninitialised value(s) ==28469== at 0x4E820EA: vfprintf (in /usr/lib64/libc-2.22.so) ==28469== by 0x4E88D48: printf (in /usr/lib64/libc-2.22.so) ==28469== by 0x4005B9: main (in /home/alex/test) ==28469== ==28469== Use of uninitialised value of size 8 ==28469== at 0x4E7E0BB: _itoa_word (in /usr/lib64/libc-2.22.so) ==28469== by 0x4E8262F: vfprintf (in /usr/lib64/libc-2.22.so) ==28469== by 0x4E88D48: printf (in /usr/lib64/libc-2.22.so) ==28469== by 0x4005B9: main (in /home/alex/test) ... ... ...kmemcheck 对内核所做的事情,正如 valgrind 对用户态程序所做的事情:检测未初始化内存的使用。这是理解整个机制的一把钥匙。
4. 启用 kmemcheck:内核配置与架构支持
要启用该机制,需要在内核配置菜单中打开CONFIG_KMEMCHECK:
Kernel hacking -> Memory Debugging对应的 menuconfig 界面如下:
进入 "kmemcheck: trap use of uninitialized memory" 子菜单后,可以看到 kmemcheck 提供的一系列运行时配置选项:
这些选项的核心含义包括:
kmemcheck: default mode at boot (one-shot):设置 kmemcheck 启动时的默认工作模式,可选 one-shot 模式;kmemcheck: error queue size:kmemcheck 记录内存错误使用的环形队列大小(默认值 64);kmemcheck: shadow copy size (5 => 32 bytes, 6 => 64 bytes):影子内存拷贝大小,默认值 5 对应 32 字节;kmemcheck: allow partially uninitialized memory:允许内核代码使用部分初始化的内存;kmemcheck: allow bit-field manipulation:允许对未初始化的位域进行操作。
4.1 架构限制:目前仅支持 x86_64
kmemcheck 目前只实现于 x86_64 架构。在arch/x86/Kconfig中可以看到:
config X86 ... ... ... select HAVE_ARCH_KMEMCHECK ... ... ...也就是说,其他架构并没有与之对应的专属支持。
4.2 内核命令行参数:kmemcheck=0 / 1 / 2
除了配置选项,还需要向内核命令行传入以下三个参数之一:
kmemcheck=0:禁用;kmemcheck=1:启用;kmemcheck=2:one-shot 模式——在检测到第一次使用未初始化内存后自动关闭。事实上该模式在内核中是默认启用的。
4.3 工作原理概述:把页面标记为 non-present
kmemcheck 的检测思路非常巧妙。当内核尝试分配内存时,例如:
struct my_struct *my_struct = kmalloc(sizeof(struct my_struct), GFP_KERNEL);即有人想要访问一个页面时,就会产生page fault 异常。这是通过让 kmemcheck 把内存页面标记为non-present(非驻留)实现的(关于页表与 P 位的详细知识可参考 X86/linux-x86-1.md 中关于 Paging 的章节)。当 page fault 异常发生时,异常处理程序知道这一点,并在 kmemcheck 已启用的情况下把控制权转交给它。kmemcheck 完成检查后,页面会被重新标记为present,被中断的代码得以继续执行。这里有一个微妙的细节:当被中断代码的第一条指令执行时,kmemcheck 会再次把页面标记为non-present,这样下一次内存访问就会被再次捕获。
5. 内核实现(一):初始化与命令行解析
kmemcheck 的实现分为两部分:
- 通用部分:位于
mm/kmemcheck.c; - x86_64 架构特定部分:位于
arch/x86/mm/kmemcheck目录。
5.1 早期阶段:param_kmemcheck
kmemcheck 子系统由两个阶段组成。第一阶段是早期阶段。回顾内核初始化章节(Initialization/linux-initialization-7.md),内核命令行是在初始化过程中由do_initcall_level、do_early_param函数解析的。在mm/kmemcheck.c中可以看到param_kmemcheck函数,它会在早期命令行解析期间被调用:
static int __init param_kmemcheck(char *str) { int val; int ret; if (!str) return -EINVAL; ret = kstrtoint(str, 0, &val); if (ret) return ret; kmemcheck_enabled = val; return 0; } early_param("kmemcheck", param_kmemcheck);如前所述,param_kmemcheck可能取0(禁用)、1(启用)或2(one-shot)。实现很简单:用kstrtoint把kmemcheck命令行选项的字符串值转换为整数,并存入全局变量kmemcheck_enabled。early_param("kmemcheck", param_kmemcheck)将该解析函数注册为早期启动参数回调。
5.2 第二阶段:kmemcheck_init 与自检
第二阶段在内核初始化早期 initcall 阶段执行,由kmemcheck_init表示:
int __init kmemcheck_init(void) { ... ... ... } early_initcall(kmemcheck_init);kmemcheck_init的主要目标是调用kmemcheck_selftest并检查其返回值:
if (!kmemcheck_selftest()) { printk(KERN_INFO "kmemcheck: self-tests failed; disabling\n"); kmemcheck_enabled = 0; return -EINVAL; } printk(KERN_INFO "kmemcheck: Initialized\n");若自检失败则返回EINVAL并关闭 kmemcheck。kmemcheck_selftest检查各种与内存访问相关的**操作码(opcode)**的编码长度,例如rep movsb、movzwq等。若操作码的尺寸与期望值一致,返回true,否则返回false。关于 initcall 机制的细节可以参考 Concepts/linux-cpu-3.md。
6. 内核实现(二):影子页分配与页面隐藏
现在看分配路径。当有人调用:
struct my_struct *my_struct = kmalloc(sizeof(struct my_struct), GFP_KERNEL);经过一系列函数调用后,会到达kmem_getpages。该函数定义于mm/slab.c,主要目标是以给定标志分配物理页。其末尾的关键代码:
if (kmemcheck_enabled && !(cachep->flags & SLAB_NOTRACK)) { kmemcheck_alloc_shadow(page, cachep->gfporder, flags, nodeid); if (cachep->ctor) kmemcheck_mark_uninitialized_pages(page, nr_pages); else kmemcheck_mark_unallocated_pages(page, nr_pages); }这里的逻辑是:如果 kmemcheck 已启用,且SLAB_NOTRACK位没有在 flags 中置位(SLAB_NOTRACK表示不追踪未初始化内存),则为刚分配的页面设置non-present位。此外,若缓存对象带有构造函数(ctor,后续章节详解),则将页面标记为未初始化;否则标记为未分配。
6.1 kmemcheck_alloc_shadow:影子页的建立
kmemcheck_alloc_shadow定义于mm/kmemcheck.c:
void kmemcheck_alloc_shadow(struct page *page, int order, gfp_t flags, int node) { struct page *shadow; shadow = alloc_pages_node(node, flags | __GFP_NOTRACK, order); for(i = 0; i < pages; ++i) page[i].shadow = page_address(&shadow[i]); kmemcheck_hide_pages(page, pages); }首先为**影子位(shadow bits)**分配内存空间。若页面中该位被置位,表示该页面被 kmemcheck 追踪。分配影子空间后,用该位填充所有已分配的页面,最后调用kmemcheck_hide_pages,参数为已分配页面的指针与页面数量。
6.2 kmemcheck_hide_pages:清除 present 位、设置 hidden 位
kmemcheck_hide_pages是架构特定函数,实现位于arch/x86/mm/kmemcheck/kmemcheck.c。其主要目标是为给定页面设置non-present位:
void kmemcheck_hide_pages(struct page *p, unsigned int n) { unsigned int i; for (i = 0; i < n; ++i) { unsigned long address; pte_t *pte; unsigned int level; address = (unsigned long) page_address(&p[i]); pte = lookup_address(address, &level); BUG_ON(!pte); BUG_ON(level != PG_LEVEL_4K); set_pte(pte, __pte(pte_val(*pte) & ~_PAGE_PRESENT)); set_pte(pte, __pte(pte_val(*pte) | _PAGE_HIDDEN)); __flush_tlb_one(address); } }这里遍历所有页面,为每个页面查找对应的页表项(page table entry)。若查找成功,则:
- 通过
pte_val(*pte) & ~_PAGE_PRESENT清除 present 位; - 通过
pte_val(*pte) | _PAGE_HIDDEN设置隐藏位_PAGE_HIDDEN; - 调用
__flush_tlb_one(address)冲刷 TLB(translation lookaside buffer),因为部分页面已改变。
从此开始,这些页面被 kmemcheck 追踪。由于 present 位被清除,kmalloc返回指针后代码一旦访问该内存,就会立即触发 page fault。
7. 内核实现(三):page fault 处理路径
回顾内核初始化章节(Initialization/linux-initialization-2.md),page fault 处理程序位于arch/x86/mm/fault.c,由do_page_fault函数表示。__do_page_fault开头可以看到如下检查:
static noinline void __do_page_fault(struct pt_regs *regs, unsigned long error_code, unsigned long address) { ... ... ... if (kmemcheck_active(regs)) kmemcheck_hide(regs); ... ... ... }7.1 kmemcheck_active:per-CPU 上下文的 balance 字段
kmemcheck_active获取 per-CPU 的kmemcheck_context结构,并返回该结构balance字段与零比较的结果:
bool kmemcheck_active(struct pt_regs *regs) { struct kmemcheck_context *data = this_cpu_ptr(&kmemcheck_context); return>if (kmemcheck_fault(regs, address, error_code)) return;7.2 kmemcheck_fault:判断缺页原因
首先,kmemcheck_fault检查该缺页是否由正确的原因触发。先检查标志寄存器,确认处于普通内核模式:
if (regs->flags & X86_VM_MASK) return false; if (regs->cs != __KERNEL_CS) return false;若这些检查未通过,则从kmemcheck_fault返回,视为与 kmemcheck 无关的 page fault。之后,尝试查找与缺页地址相关的页表项,若找不到则返回:
pte = kmemcheck_pte_lookup(address); if (!pte) return false;kmemcheck_fault的最后两步是调用kmemcheck_access函数——它检查对给定页面的访问,并通过设置 present 位来重新显示页面地址。
7.3 kmemcheck_access:错误记录与环形队列
kmemcheck_access承担主要工作:检查触发 page fault 的当前指令。若发现错误,其上下文会被保存到环形队列中:
static struct kmemcheck_error error_fifo[CONFIG_KMEMCHECK_QUEUE_SIZE];这里CONFIG_KMEMCHECK_QUEUE_SIZE对应第 4 节配置菜单中的"错误队列大小"。
kmemcheck 还声明了一个特殊的tasklet(tasklet 机制详见 Interrupts/linux-interrupts-9.md):
static DECLARE_TASKLET(kmemcheck_tasklet, &do_wakeup, 0);当它被调度执行时,运行的是arch/x86/mm/kmemcheck/error.c中的do_wakeup函数。do_wakeup会调用kmemcheck_error_recall,打印由 kmemcheck 收集到的错误。
8. 内核实现(四):恢复 present 位与单步执行陷阱
kmemcheck_fault函数末尾会调用:
kmemcheck_show(regs);该函数会为给定页面重新设置 present 位:
if (unlikely(data->balance != 0)) { kmemcheck_show_all(); kmemcheck_error_save_bug(regs); >static unsigned int kmemcheck_show_all(void) { struct kmemcheck_context *data = this_cpu_ptr(&kmemcheck_context); unsigned int i; unsigned int n; n = 0; for (i = 0; i <>int kmemcheck_show_addr(unsigned long address) { pte_t *pte; pte = kmemcheck_pte_lookup(address); if (!pte) return 0; set_pte(pte, __pte(pte_val(*pte) | _PAGE_PRESENT)); __flush_tlb_one(address); return 1; }8.1 TF 标志:单步模式让隐藏-检查循环自动继续
在kmemcheck_show函数末尾,若 TF 标志未设置则设置之:
if (!(regs->flags & X86_EFLAGS_TF)) >赞- 文档
- 教程
- 操作系统
【免费下载链接】linux-insides
A book-in-progress about the Linux kernel and its insides.
相关推荐
Linux 内核内存管理揭秘(三):kmemcheck —— 内核未初始化内存访问检测机制的原理与实现
Linux 内核内存管理揭秘(三):kmemcheck —— 内核未初始化内存访问检测机制的原理与实现 导读 本文是华中科技大学开放原子开源俱乐部《linux
如何永久保存微信聊天记录:3步实现数据留痕的终极指南
如何永久保存微信聊天记录:3步实现数据留痕的终极指南 你是否曾担心更换手机后,那些珍贵的微信对话会永远消失?家人的温馨对话、重要的工作沟通、朋友的日常分享,这些
Oumi 配置系统详解:YAML 配置的组织、命名约定与 Omegaconf 加载机制
Oumi 配置系统详解:YAML 配置的组织、命名约定与 Omegaconf 加载机制 configs/ 是 Oumi 框架的配置中枢,存放着用于训练、评估、推
文档教程操作系统