Linux内核分析之内存管理-02
This language version is unavailable; showing the other language.
20.1 内核虚拟地址空间划分
64 位虚拟地址是一把双刃剑:2^64 字节的空间远超任何物理设备的寻址需要,CPU 实际只实现 48 位(LA57 下 57 位),未实现的高位必须符号扩展——由此产生 canonical hole(非规范洞):48 位实现下 bit 63:47 全 0 或全 1 的地址才合法,中间是 16384PB 的禁地。内核把自己的全部版图安放在地址顶端的内核半区,本节依据权威文档 Documentation/arch/x86/x86_64/mm.rst 逐段解析这一划分。
20.1.1 完整版图
// Documentation/arch/x86/x86_64/mm.rst:53-81(四级布局, 摘录整理)
//
// 0000000000000000 | 0 | 00007ffffffff000 | 128TB | 用户空间(每 mm 不同)
// 00007ffffffff000 | 128TB | 00007fffffffffff | 4kB | ... guard hole
// ─────────────── canonical hole: bit63:47 非全0非全1 ───────────────
// ffff800000000000 | -128TB | ffff87ffffffffff | 8TB | guard hole(hypervisor)
// ffff880000000000 | -120TB | ffff887fffffffff | 0.5TB | LDT remap for PTI
// ffff888000000000 | -119.5TB| ffffc87fffffffff | 64TB | direct mapping
// ffffc90000000000 | -55TB | ffffe8ffffffffff | 32TB | vmalloc/ioremap
// ffffea0000000000 | -22TB | ffffeaffffffffff | 1TB | vmemmap
// ffffec0000000000 | -20TB | fffffbffffffffff | 16TB | KASAN shadow
// ─────────────── 以下区间在五级布局下完全相同 ───────────────
// fffffe0000000000 | -2TB | fffffe7fffffffff | 0.5TB | cpu_entry_area
// ffffffff80000000 | -2GB | ffffffff9fffffff | 512MB | kernel text
// ffffffffa0000000 | -1536MB | fffffffffeffffff | 1520MB | module 映射
// FIXADDR_START | ~-11MB | ffffffffff5fffff | ~0.5MB | fixmap
// ffffffffff600000 | -10MB | ffffffffff600fff | 4kB | legacy vsyscall
几个划分数值的来源:
// arch/x86/include/asm/page_64_types.h:42
#define __PAGE_OFFSET_BASE_L4 _AC(0xffff888000000000, UL)
// arch/x86/include/asm/pgtable_64_types.h:107-116
#define __VMALLOC_BASE_L4 0xffffc90000000000UL
#define VMALLOC_SIZE_TB_L4 32UL
#define __VMEMMAP_BASE_L4 0xffffea0000000000UL
这些宏是默认值;真正的访问者读的是变量 page_offset_base/vmalloc_base/vmemmap_base(arch/x86/mm/kaslr.c:54/58/61 注册进随机化表,20.5 节),KASLR 启用时变量在启动期被改写。
20.1.2 划分的原则
各区间不是随意排布的,背后有四条原则:
[1] 按页表层级对齐。每个大区的基址都落在 PGD 边界(512GB)上——0xffff888000000000、0xffffc90000000000、0xffffea0000000000 全部是 512GB 的整数倍。这使每个区恰好占用若干完整的 PGD 表项,顶级页表就能隔离各区:一个区不存在即对应 PGD 项为空,越界访问直接触发缺页而不会窜入邻区。五级布局下对应关系平移到 P4D 边界(mm.rst:110-140)。
[2] 大区按"最坏需要"预留。直接映射区 64TB = 52 位物理地址的全部可能(MAX_PHYSMEM_BITS),vmalloc 32TB、vmemmap 1TB 同样按最大物理内存的换算上限预留。地址空间免费、预留零成本——没有映射就没有页表页,区间存在但中间级表为空,不占任何物理内存。
[3] 区间之间留 guard hole。相邻大区间插入了 0.5-1TB 的未用洞(mm.rst:59-80 的 "unused hole" 行),它们吸收两类冲击:其一,KASLR 把各基址在总范围内随机摆放(20.5 节),洞保证任何随机偏移都不会让区间重叠;其二,越界指针(vmalloc 溢出写穿到直接映射)会先落入空洞触发缺页,错误被及时拦截。
[4] 高端 1GB 集中"身份敏感"区。内核 text、module、fixmap、vsyscall 全部挤在 0xffffffff_xxxxxxxx 附近 4GB 内——这一段在所有进程中映射一致且几乎从不切换,也是早期 x86_64 用 2MB 大页映射内核映像的对齐原点(2.6 节引导期页表)。
20.1.3 区间清单与本章分工
| 区间 | 基址(L4) | 大小 | 谁在用 | 详解 |
|---|---|---|---|---|
| guard hole | 0xffff8000_00000000 | 8TB | 虚拟机/调试工具 | — |
| LDT remap | 0xffff8800_00000000 | 0.5TB | KPTI 的 LDT 影子映射 | 1.6 节 |
| direct map | 0xffff8880_00000000 | 64TB | 一切物理内存访问 | 20.2 |
| vmalloc/ioremap | 0xffffc900_00000000 | 32TB | 模块/大缓冲/设备寄存器 | 20.3 |
| vmemmap | 0xffffea00_00000000 | 1TB | struct page 数组 | 20.4 |
| KASAN shadow | 0xffffec00_00000000 | 16TB | 调试: 8:1 影子字节 | — |
| cpu_entry_area | 0xfffffe00_00000000 | 0.5TB | 每核入口栈/异常栈(KPTI) | 1.4/2.8 节 |
| kernel text | 0xffffffff_80000000 | 512MB | 内核代码 | 2.8 节 |
| module | 0xffffffff_a0000000 | 1520MB | 可加载模块代码 | 20.3 |
| fixmap | FIXADDR_START(变量) | ~0.5MB | 编译期未知的固定页 | 20.4 |
| vsyscall | 0xffffffffff600000 | 4KB | 旧 ABI(7.6 节) | — |
20.1.4 PTI 时代的三重视界
KPTI(内核页表隔离,1.6 节)给这张版图叠加了"进程可见性"维度——并非所有区间都在用户可见的影子页表中:
完整内核页表 用户影子页表 (每进程)
───────────── ─────────────
direct mapping (不映射!)
vmalloc/ioremap (不映射!)
vmemmap (不映射!)
cpu_entry_area ───> 映射(用户态中断入口必需)
kernel text ───> 只映射可执行段(无数据!)
LDT remap ───> 映射(用户程序可能用 LDT)
KPTI 漏洞(Meltdown)的直接教训是"映射即可被侧信道读取",因此影子页表按最小必需裁剪。vaddr_end 对 KASLR 的约束(mm.rst:76 "vaddr_end for KASLR")也源于此:随机化范围的上限是 CPU_ENTRY_AREA_BASE(kaslr.c:90 的 BUILD_BUG_ON(vaddr_end != CPU_ENTRY_AREA_BASE)),确保随机化不会把大区推进 PTI 特殊区。
20.1.5 划分的运行期证据:/proc 与符号
版图不只是文档描述,运行期处处可见它的边界在执法:
观察版图的窗口:
/proc/iomem 物理侧: System RAM / 内核映像 / 保留区
/proc/vmallocinfo vmalloc 区逐段视图 (每个 vmap_area 一行)
含 caller、页数、物理页列表 — 20.3 节结构的报表
/proc/kallsyms 内核符号的虚拟地址 (KASLR 后带随机偏移,
与 /boot/System.map 对比即可测出本次随机化量)
/proc/self/maps 的内核视角: 20.2 节直接映射别名
(page_address 的宿主区)
crash/pahole 工具 按版图常量解内核转储
越界执法的证据:
vmalloc 区指针误入直接映射算式 → __pa() 抛
WARN/BUG (20.2.4 节的 virt_addr_valid)
KPTI 下用户态对 "不映射区" 的侧信道尝试 → 双重故障路径
__virt_addr_valid() 这类判别函数是版图的运行期编码:它们把 mm.rst 的区间表编译成一串比较,供 devmem 类工具、BPF 校验器、usercopy 检查(21.5.4 节)复用。当攻击面研究把"内核任意读"当作前提时,版图知识直接决定泄露指针的价值——拿到 0xffffc900xxxxxxxx 即知是 vmalloc 侧(模块/大缓冲),拿到 0xffff8880xxxxxxxx 即知可换算物理地址——这也是 20.5 节 KASLR 必须整区平移的原因。
小结
x86_64 内核半区按 PGD 边界切成直接映射(64TB)、vmalloc(32TB)、vmemmap(1TB)三大功能区和 fixmap、module、cpu_entry_area 等小区,区间以 guard hole 隔离,基址是可被 KASLR 改写的变量而非编译期常量。划分服从四原则:层级对齐、最坏预留(空区零成本)、防护洞隔离、高端集中身份敏感区。KPTI 再叠加一重"影子页表最小映射"的可见性维度,而 __virt_addr_valid() 等判别函数把版图编码成运行期执法。下一节进入第一大区——直接映射。
20.2 直接映射区 (Direct Mapping)
直接映射区是内核半区中唯一线性覆盖全部物理内存的区间:任意物理地址 paddr 都能经一次减法变成虚拟地址,反之亦然。这层恒等映射让内核得以访问自己可见的一切内存——伙伴系统的页、slab 的对象、页表本身。本节分析 __pa()/__va() 的实现、映射的建立过程与它不能覆盖的三种情形。
20.2.1 换算算式
// arch/x86/include/asm/page.h:34-52
#ifndef __pa
#define __pa(x) __phys_addr((unsigned long)(x))
#endif
...
#ifndef __va
#define __va(x) ((void *)((unsigned long)(x)+PAGE_OFFSET))
#endif
__va() 是纯加法:物理地址加 PAGE_OFFSET(运行期为 page_offset_base)。__pa() 经 __phys_addr()(arch/x86/include/asm/page_64.h:34 声明,实现在 arch/x86/mm/physaddr.c)分流:
__phys_addr(x) 的分流逻辑:
x >= __START_KERNEL_map ?
├─ 是: 内核映像内 → phys = x - __START_KERNEL_map + phys_base
│ (text 映射在 0xffffffff80000000 但物理页在别处,
│ phys_base 记录装载偏移 —— KASLR 时非零)
└─ 否: 直接映射区 → phys = x - page_offset_base
两套偏移的原因: 内核映像在 kernel text 区(0xffffffff80000000)
与直接映射区(0xffff8880_00000000+phys) 是同一物理内存的两份映射!
一个物理地址、两个虚拟地址是理解内核地址的关键:给内核 .data 里某全局变量打印地址,得到的是 text 区地址;__pa() 把它折算成物理页;__va() 再从物理页得到直接映射区里的别名地址——两者指向同一字节。virt_to_page()(page.h:62)与 page_address()(page.h:68)正是建立在这层换算上的:
// arch/x86/include/asm/page.h:62-68
#define virt_to_page(kaddr) pfn_to_page(__pa(kaddr) >> PAGE_SHIFT)
...
static __always_inline void * __must_check
__phys_to_virt(phys_addr_t paddr) /* 内部实现返回 __va(pfn << PAGE_SHIFT) */
20.4 节将看到 pfn_to_page() 在 vmemmap 布局下就是一次乘加——于是"虚拟地址→page 结构"的完整链路是两次线性换算,无页表遍历。
20.2.2 映射的建立
直接映射的页表在启动期分层建成(呼应 2.6-2.9 节):
压缩器阶段(2.7节) head_64.S 阶段(2.8节) setup_arch 阶段(3.2节)
──────────── ────────────────── ─────────────────────
仅映射解压所需 startup_64 建初始页表: init_mem_mapping():
的物理范围 映射内核映像(2MB/1GB大页) 自低向高按 max_pfn
(自底向上的小映射) 直接映射区首段 补全映射
覆盖 max_pfn page_size_mask 决定
尽量用大页
v
init_mem_mapping 完成后:
memblock_set_current_limit
放开, 18.1 节的限制解除
映射优先使用大页(page_size_mask,19.2.4 节的 PS 位):64TB 全 4KB 映射需要 512GB 中间级表页;全 1GB 页只需 64 个 PUD 项。init_mem_mapping() 依据 E820 探测的物理内存顶点(而非 64TB 上限)建表——空区间没有页表页,这是 20.1.2 节"最坏预留零成本"的执行处。
CONFIG_HIGHMEM 的缺席是 64 位布局的解放:直接映射覆盖全部物理内存,page_address() 对任何页都返回 __va() 常量结果——18.4 节 struct page 中 virtual 字段(WANT_PAGE_VIRTUAL)在 x86_64 上不存在的根本原因。
20.2.3 直接映射的三条边界
[1] 它是别名,不是所有权。 用户进程的页同时出现在直接映射区(内核可访问)与用户地址(应用可访问)——内核对所有用户数据的一切访问(copy_to_user 除外)都走这层别名。别名带来缓存一致性问题(aliasing,VIPT 缓存上两地址不同物理行偏移可共存两份缓存副本),x86 的物理索引缓存(PIPT)免疫此问题;ARM64 部分 VIPT 实现需要 flush_dcache_page() 维护一致性——5.8 节架构差异的又一实例。
[2] 权限与区域默认不同。 直接映射区的 PTE 默认 PAGE_KERNEL(RW+NX,19.2.5 节),因此内核在直接映射别名上执行代码是被 NX 拒绝的——这就是 set_memory_x()(改变 text 别名权限)必须同时处理两个映射的原因。直接映射区整体也被 KPTI 从用户影子页表剔除(20.1.4 节)。
[3] 部分物理地址不进直接映射。 MEMBLOCK_NOMAP 标记的固件区域(18.1 节)与 ZONE_DEVICE 的某些段刻意绕开直接映射,防止内核无意写入;它们只能经 ioremap/专用映射访问。
20.2.4 谁在消费直接映射
alloc_pages() 返回 struct page
│ page_address(page) = __va(pfn<<12) page.h:68
v
直接映射区虚拟地址 ──────────────> slab/bio/skb 的数据缓冲
│ 内核代码读写这里
│ __pa() 折算物理地址
v
DMA 编程(43章)/页表项PFN(19章)/vmemmap 换算(20.4节)
反面例子: vmalloc() 返回的地址不在直接映射区!
vmalloc_to_page(vaddr) 必须遍历页表 (20.3 节)
DMA 编程拿到 vmalloc 地址直接算 __pa() 会得到错误物理页
→ 正确姿势: vmalloc 后用 vmap 物理页数组或逐页处理
典型陷阱:对 vmalloc 地址调用 __pa()/virt_to_phys()。这类 bug 在现代内核会被 __virt_addr_valid()/WARN_ON_ONCE(vmalloc_addr) 类检查捕获——本质上是 20.1 节分区版图的运行期校验。
20.2.5 惰性同步与 NOMAP:映射的例外路径
直接映射区有两个值得单独记录的例外机制,它们解释了"这个区不是铁板一块":
[1] vmalloc 区的内核页表惰性同步(非本区功能,但入口在本章逻辑内)。19.4.3 节提到 RISC-V 64 位因内核页表全局共享而无此问题;x86 的各进程 mm->pgd 各有一份内核半区拷贝,vmalloc 建立的映射如何让正在运行的其他进程看见?答案:vmalloc_fault() 在缺页路径发现"内核区地址缺项"时,把 reference 页表(init_mm.pgd)中对应中间级表项同步进当前进程页表(25.1.4 节流程图的"惰性同步"分支)——每个进程的内核半区在第一次访问新 vmalloc 区时完成补页。ARM64 机制类似(内核半区独立成树,19.3.2 节)。
[2] MEMBLOCK_NOMAP 区域的刻意缺席。18.1.2 节的 NOMAP 标志在直接映射建立时被跳过——这些物理地址没有直接映射别名。内核必须访问它们时走显式 ioremap。设计动机:某些固件保留区被意外写入会造成不可诊断的故障(设备固件交互区、调试区),"无别名"是比"有别名但别用"更强的保护。
三区对照速查 (同一物理内存的三种可达性):
物理内存区域状态 直接映射区 可达方式
─────────────────────────────────────────────────────
普通可用内存 (18.1 节) 有别名 __va() / page_address
内核映像 有别名 + text 区 __pa 经 phys_base (20.2.1)
MEMBLOCK_NOMAP 无别名! ioremap 后 __iomem 访问
ZONE_DEVICE (pmem) 部分无 struct page + 专用 kmap
尚未上线 (热插拔空闲段) 无 (未建表) online 后出现 (18.5 节)
小结
直接映射区以 PAGE_OFFSET 为界做纯加减法换算,覆盖全部物理内存(含内核映像的别名视角,由 phys_base 区分);页表在启动期按物理内存顶点分层建成并尽量使用大页。它的边界——别名非所有权、默认 RW+NX、NOMAP 区域除外、未上线区间无映射——决定了内核哪些操作必须绕道 vmalloc/ioremap;跨进程的内核半区惰性同步与 NOMAP 刻意缺席则是这张"简单"地图上仅有的两处机关。下一节进入物理不连续分配的世界。
20.3 vmalloc 区域与 vmap
伙伴系统的最小连续承诺是 4MB(MAX_PAGE_ORDER),且物理连续的内存随运行日益稀缺。vmalloc 换了一条路:虚拟连续、物理随意——在 vmalloc 区切一段地址,把分散的物理页逐页填进页表。代价是每次分配都要建页表、每次访问都多走 TLB,收益是"永远分配得出"与"按页粒度不占连续物理内存"。本节分析 vmap_area 的红黑树管理、懒惰页表填充、vfree 的异步回收,以及 vmap 家族把已有页"借视"进地址空间的能力。
20.3.1 数据结构:vm_struct 与 vmap_area
// include/linux/vmalloc.h:52-70
struct vm_struct {
union {
struct vm_struct *next; /* 早期注册的链表 */
struct llist_node llnode; /* 错误路径异步释放 */
};
void *addr; /* 虚拟起始地址 */
unsigned long size; /* 区域大小(含 guard) */
unsigned long flags; /* VM_ALLOC/VM_MAP/VM_IOREMAP... */
struct page **pages; /* 物理页数组(物理不连续的凭证) */
unsigned int nr_pages;
phys_addr_t phys_addr; /* ioremap 时的物理地址 */
const void *caller;
unsigned long requested_size;
};
vm_struct 记录"这块虚拟地址用了哪些物理页";真正管理地址空间本身的是 vmap_area(vmalloc.c:890-905 注释区):
vmalloc 区的地址管理 (vmap_area 双索引):
free_vmap_area_root (红黑树)
┌────────────────────────────────────────────┐
│ 节点 = 空闲段[va_start, va_end] │
│ subtree_max_size = 子树中最大空闲段 │ ← O(log n) 找到
│ (vmalloc.c:894 注释; :1064 的宏展开) │ "第一个放得下的段"
└────────────────────────────────────────────┘
vmap_area_root (红黑树, 已分配段, 按地址排序)
busy 最大段即 VMALLOC_START..END 的已被占用视图
分配 = 在空闲树中按 subtree_max_size 快速定位 → 切出请求大小
→ (可选)首块 PUD 对齐加速页表复用 → 挂入已分配树
ne_fit_preload_node(vmalloc.c:905)是 per-CPU 的预占节点:并发分配时先预占"最合适空闲段",防止两个 CPU 同时看中同一段导致低效碎片切分。
20.3.2 分配路径:__vmalloc_node_range_noprof
// mm/vmalloc.c:3986(入口签名)
void *__vmalloc_node_range_noprof(unsigned long size, unsigned long align,
unsigned long start, unsigned long end, gfp_t gfp_mask,
pgprot_t prot, unsigned long vm_flags, int node,
const void *caller)
完整流程五步:
vmalloc_noprof(size) vmalloc.c:4157
└─ __vmalloc_node_range_noprof( :3986
VMALLOC_START, VMALLOC_END, ...) :4124
│
├─ [1] __get_vm_area_node() :3203
│ vmap_area 树切地址段, 建 vm_struct
│ 首尾各留 guard page (越界立即缺页)
v
├─ [2] __vmalloc_area_node() :3827
│ ├─ area->pages 数组自身:
│ │ > 4KB 用 vmalloc 分配! (递归一层, :3857-3860)
│ └─ vm_area_alloc_pages() :3641
│ 优先高阶(大页 vmalloc)分配, 失败降为 order-0
│ (:3873-3880 注释: 高阶 NOFAIL 危险, 自动回退)
v
├─ [3] map_kernel_range() → vmap_range_noflush() :298
│ 逐页填 PTE: prot = pgprot_nx(prot) ← vmalloc 区不可执行!
│ (3265-3268: 物理页经 flush_kernel_vmap_range 处理缓存)
v
├─ [4] 返回 area->addr (虚拟连续, 物理散乱)
└─ 失败路径: free_vm_area + 释放已得页
步骤 [2] 的自我递归值得注意:页数多时 area->pages 数组自身超过一页,于是用 __vmalloc_node_noprof 分配它(vmalloc.c:3857-3860,注释 "Please note that the recursion is strictly bounded"——内层是 kmalloc 大小级别的简单分配,不会再递归)。步骤 [3] 的 pgprot_nx() 则是安全基线:vmalloc 区默认不可执行,模块等需要执行的映射走 VM_FLUSH_RESET_PERMS 特殊路径显式翻转权限(1.6 节 W^X 讨论)。
20.3.3 大页 vmalloc 与页表成本
vm_area_alloc_pages() 尝试按 page_shift(可到 PMD 级)分配物理大页,成功则映射时用大页 PTE——32TB 的 vmalloc 区若全 4KB 映射,一次 1GB 的 vmalloc 就要写 256K 个 PTE。大页 vmalloc 让映射代价除以 512,同时 TLB 压力同步下降。高阶分配失败自动降级为 order-0 页(注释见 vmalloc.c:3868-3873),这是"物理连续 vs 分配成功率"的运行期权衡,与 18.3 节 migratetype 机制衔接。
20.3.4 vfree:异步与延迟
// mm/vmalloc.c:3442-3520(要点)
void vfree(const void *addr)
{
...
if (unlikely(in_interrupt())) { /* 中断上下文不能睡眠 */
... __vfree_deferred(addr); /* 挂到 per-CPU 延迟链 */
schedule_work(&work);
return;
}
...
__vunmap(addr, 1); /* 解映射 + 释放页 */
}
vfree 需要释放页(可能睡眠于回收),因此中断上下文的 vfree 被转成 per-CPU 延迟链 + 工作队列(__vfree_deferred)。vfree_atomic()(vmalloc.c:3408)是原子变体,仅做地址段归还。释放顺序同样服从"先刷 TLB 后还资源"的铁律——解映射走 vmap_range 的 flush 路径(与 19.5 节 mmu_gather 同款语义),页帧最后交还伙伴系统。
20.3.5 vmap 家族:借视已有页
vmalloc 是"分配页 + 建立映射",vmap 则只建立映射——把调用者已有的页数组搬进 vmalloc 区视图:
// mm/vmalloc.c:3532
void *vmap(struct page **pages, unsigned int count,
unsigned long flags, pgprot_t prot)
三个典型消费者:
| 接口 | 用途 | 与 vmalloc 的差异 |
|---|---|---|
vmap() |
把 bio 的页数组拼成连续缓冲 | 页归调用者所有,vunmap 只拆映射 |
ioremap() |
设备 MMIO 区映射(VM_IOREMAP 标志) | 物理,无 page 结构,不可直接 memcpy 大块 |
| 模块加载 | 模块代码/数据映入 module 区 | 带执行权限翻转流程 |
vmap_range_noflush()(vmalloc.c:298)是它们的共同地基——vmap_pages_range() 系列最终都落到它:给定 [addr, end) 与物理起始,逐级填 PGD→P4D→PUD→PMD→PTE,PUD/PMD 级在物理对齐允许时直接用大页项。ioremap 的特殊性在于物理地址没有 struct page(ZONE_DEVICE 之外的 MMIO),vm_struct.phys_addr(vmalloc.h:68)记录物理起点以便 unmap 反查。
20.3.6 阴影调用栈之外的消费者清单
vmalloc 区的长期住户值得清点一遍——它是内核"大对象"的集体宿舍:
消费者 典型用途 备注
────────────────────────────────────────────────────────────────
模块加载 module 代码/数据段的临时中转 落定 module 区
bpf 程序与映射 JIT 后的镜像 (bpf_prog_pack) 与模块同区协作
线程栈 vmalloc 化的内核线程栈 CONFIG_VMAP_STACK
(守护页防栈溢出静默破坏! 1.4 节) 安全加固的代表
内核大缓冲 percpu 区首建、哈希大表 上电早期用 memblock
shmem/tmpfs 内页 (不占 vmalloc! 常见误解) 在直接映射/页缓存
gpu/drm scatter 表 大页表结构的虚拟连续视图 vmap 家族
vmap_stack 的安全语义值得展开:内核栈溢出曾是最致命的漏洞类型之一(写穿相邻对象);vmalloc 化栈的每根栈尾带守护页(guard page),溢出立即缺页而不是静默改写邻居——20.3.3 节 vmalloc 首尾 guard page 的惯例在栈场景从"诊断友好"升级为"安全边界"。/proc/vmallocinfo 可直接观测每根栈的位置与守护页。
20.3.7 与 memblock 的接力边界
vmalloc 区的页表操作依赖通用页表基础设施,而后者在启动极早期尚不可用——于是内核形成了一条接力链:memblock(18.1 节)供早期大块 → 伙伴系统就绪后 vmalloc 接管非常驻大块 → 模块/线程栈等长期住户迁入。vmalloc_init() 在 mm_init() 尾声把早期 vmap_area 记账从链表版切换到红黑树版——启动日志中 vmalloc area 相关行为切换点。阅读 vmalloc 代码时见到的 early_ 前缀变体(如 early ioremap,20.4 节 fixmap 槽)都是这条接力的中间站。
小结
vmalloc 区以"虚拟连续、物理随意"补足伙伴系统的连续性约束:vmap_area 红黑树以 subtree_max_size 加速空闲段定位,__vmalloc_node_range_noprof 走"切地址段→供页→vmap_range 填表"五步,页表默认 NX 且优先大页;vfree 处理中断上下文用 per-CPU 延迟链,释放遵循先刷 TLB 后还页。vmap/ioremap 家族共享同一映射引擎,把"已有页的连续视图"与"设备寄存器空间"接进同一片 32TB 地址区。下一节看两个特殊区:vmemmap 让 page 数组获得专属空间,fixmap 为启动期关键映射预留槽位。
20.4 vmemmap 与 fixmap
两个特殊区把地址空间用到了极致。vmemmap 区给 struct page 数组本身划出专属空间,使"page 与物理页帧"的互译从查表变成一次乘加;fixmap 区则以"编译期枚举槽位、运行期才填页表"的方式,为启动早期与热路径固定映射提供服务(页表自举、CPU 入口、ioremap 前的早期 IO、kmap_atomic)。本节分析两者的地址算式、填充机制与它们的消费者全景。
20.4.1 vmemmap:struct page 数组的专属地址空间
18.4.5 节给出了结论,本节展开实现:pfn_to_page() 之所以是乘加,是因为 page 数组被放进了一个基址固定的区间:
vmemmap 布局 (x86_64 L4):
vmemmap_base = 0xffffea0000000000 (1TB 空间) pgtable_64_types.h:113
page 虚拟地址 = vmemmap_base + pfn × sizeof(struct page) [64B]
物理页帧 pfn = (page_addr - vmemmap_base) / 64
1TB 空间 ÷ 64B = 2^34 个 page 槽位 → 覆盖 2^34×4KB = 64TB 物理
(与直接映射区 64TB 上限精确匹配 —— 两个区的大小是耦合的!
KASLR 随机化时必须保持这一比例, 见 20.5 节 vmemmap_size 计算)
一个 2MB 物理大页在 vmemmap 中的形态:
512 个连续 page 槽位 = 512×64B = 32KB? 不!
= 8 页 vmemmap 内存 (512×64 = 32768B = 8×4KB)
头 page 承载真实元数据, 其余 511 个槽是"存在标记"
关键洞察:这段虚拟地址背后的物理内存不是连续的!每个 PMD 块(2GB 对应 32K 个 page 槽,占 2MB vmemmap 空间)的页表页按需建立——这由 SPARSEMEM_VMEMMAP 的填充器完成:
// mm/sparse-vmemmap.c:237-296(节选)
pgd_t * __meminit vmemmap_pgd_populate(unsigned long addr, int node) /* :237 */
{ ... /* memblock 分配 PGD 页, 填表 */ }
static pte_t * __meminit vmemmap_populate_address(unsigned long addr, /* :249 */
int node, struct vmem_altmap *altmap, ...)
{
...
pgd = vmemmap_pgd_populate(addr, node); /* 逐级补表 */
...
/* 分配承载 page 结构的物理页, 填入 PTE */
}
static int __meminit vmemmap_populate_range(unsigned long start, /* :280 */
unsigned long end, ...)
vmemmap 的两级稀疏性 (为什么"空区零成本"):
虚拟槽位: pfn=0 pfn=1 ... pfn=100万 ... pfn=2^34
│ │ │ │
页表: 只为"真实存在的物理内存区段"建中间级表
一个 128MB section = 32768 page 槽 = 256KB
vmemmap 空间 → 对应 64 个 4KB 页表页
热插拔联动 (18.5 节): 内存 add → vmemmap_populate_range
补表; 内存 remove → 撤表 — page 数组随内存生灭
vmemmap_populate_range()(sparse-vmemmap.c:280)在内存上线(18.5 节热插拔 add 路径)或 SPARSEMEM 区段激活时逐级补表(PGD/P4D/PUD/PMD/PTE),使 page 数组的对应槽位获得真实物理承载。struct vmem_altmap 参数支持用被映射内存自身承载 page 数组(pmem 场景,不占用系统内存)——18.5 节热插拔的 memmap_on_memory 正是它。
vmemmap 的两个红利
[1] 全线性互译。 page_to_pfn()/pfn_to_page() 在通用代码路径上是宏分支——FLATMEM 查 mem_map[] 数组、SPARSEMEM 算区段偏移、SPARSEMEM_VMEMMAP 直接乘加。互译出现在热路径(GUP、回收、迁移),乘加版本 eliminating 了区段查找与间接跳转。
三种布局的 pfn↔page 互译成本对照:
FLATMEM: page = mem_map + pfn (一维数组, 需连续物理)
SPARSEMEM: section = 索引区段表 → page = base + 偏移
(两次内存访问 + 区段空判)
VMEMMAP: page = vmemmap_base + pfn×64 (一次乘加, 64 位默认)
热路径出现频率: GUP/回收扫描/迁移/DMA 映射 — 每页操作
至少一次互译, 亿页级机器上差别是真金白银
[2] 大页 page 数组的自然对齐。 2MB 物理大页对应 512 个连续 page 槽 = 恰好一页 vmemmap 物理内存;THP/hugetlb 的 vmemmap 优化(hugetlb_vmemmap.c,23.2.5 节 HVO)利用这一对齐把尾页槽位重定向到头页同一物理页,省下 7/8 的元数据内存。
20.4.2 fixmap:编译期枚举的固定映射
fixmap 解决一个鸡生蛋问题:某些映射必须在页表建立流程中被访问,而此时无法调用任何动态分配/映射接口。方案是预定义一段"地址固定、内容后填"的槽位区:
// arch/x86/include/asm/fixmap.h:55-153(节选)
extern unsigned long __FIXADDR_TOP;
#define FIXADDR_TOP (round_up(VSYSCALL_ADDR + PAGE_SIZE, 1<<PMD_SHIFT) - ...)
enum fixed_addresses {
VSYSCALL_PAGE = (FIXADDR_TOP - VSYSCALL_ADDR) >> PAGE_SHIFT, /* :86 */
...
FIX_IO_APIC_BASE_END = FIX_IO_APIC_BASE_0 + MAX_IO_APICS - 1, /* :99 */
...
FIX_KMAP_END = FIX_KMAP_BEGIN + (KM_MAX_IDX * NR_CPUS) - 1, /* :103 */
...
FIX_BTMAP_END = ..., /* :130 早期 ioremap 槽位 */
FIX_BTMAP_BEGIN = FIX_BTMAP_END + TOTAL_FIX_BTMAPS - 1, /* :137 */
...
__end_of_fixed_addresses /* :144 */
};
#define FIXADDR_SIZE (__end_of_permanent_fixed_addresses << PAGE_SHIFT) /* :150 */
#define FIXADDR_START (FIXADDR_TOP - FIXADDR_SIZE) /* :151 */
槽位从 FIXADDR_TOP 向下排列,fix_to_virt(idx) 是编译期可折叠的算式(idx 必须是常量,否则 BUG),物理页由 set_fixmap(idx, phys) / __set_fixmap(idx, phys, flags) 在运行期填入 PTE:
fixmap 区 (靠近地址顶, ~0.5MB) 典型槽位消费者
┌────────────────────┐ FIXADDR_TOP
│ VSYSCALL_PAGE │ vsyscall 页(7.6 节)
│ FIX_IO_APIC_BASE_* │ Local APIC / IO-APIC 寄存器
│ FIX_TEXT_POKE* │ 代码补丁(写只读 text 的窗口)
│ FIX_KMAP_BEGIN.. │ kmap_atomic 临时映射(高内存架构)
│ FIX_BTMAP_BEGIN.. │ early_ioremap(2.9 节 earlyprintk!)
└────────────────────┘ FIXADDR_START
"鸡生蛋"的三个经典时刻:
[1] 页表自举: 建页表的代码自己需要访问页表
(fixmap 提供早期地址, 此时 vmem/直接映射未就绪)
[2] earlyprintk/ACPI 解析: 串口寄存器/ACPI 表
必须在 memblock 分配器可用前映射
[3] CPU 热插/唤醒: 每核的固定入口页
FIX_KMAP_* 是 32 位/高内存时代 kmap_atomic() 的遗产,64 位上保留为"原子临时映射"通用机制;FIX_BTMAP_* 支撑 early_ioremap()——3.2 节 setup_arch() 早期读 ACPI 表、解析 SMBIOS 都依赖它。fixmap 的纪律:__set_fixmap 写 PTE 后无需刷 TLB 的理由是地址从未被别的翻译使用过(槽位专属);用完必须 clear_fixmap 释放。
20.4.3 两者与直接映射的协作
三区联动的完整图景 (一个 4KB 物理页的元数据与数据视图):
物理页帧 pfn 的数据视图:
直接映射区: page_offset_base + pfn*4096 (20.2 节)
物理页帧 pfn 的元数据视图:
vmemmap 区: vmemmap_base + pfn*64 (本节)
page→数据 地址: page_address(page) = __va(pfn<<12) page.h:68
低内存: 直接映射区的常量换算 (64 位恒真)
fixmap 的角色: 在以上两区尚未建立时提供映射能力,
以及"写只读页"的临时旁路 (FIX_TEXT_POKE)
时序视角 (启动期谁先谁后):
fixmap (最早, 编译期地址) → 直接映射 (init_mem_mapping)
→ vmemmap (随 SPARSEMEM 激活) → vmalloc (mm_init 后)
小结
vmemmap 区把 struct page 数组安放在固定基址的 1TB 空间,使 page↔pfn 互译退化为乘加,区间大小与直接映射的 64TB 上限精确耦合,两级稀疏性(区段级+页表级)保证空区零成本;填充器 vmemmap_populate_range 在内存上线时按需补表,altmap 机制支持元数据自承载。fixmap 区以编译期枚举槽位提供"地址固定、内容后填"的映射能力,支撑早期 ioremap、APIC 寄存器、代码补丁窗口与 kmap_atomic 三个鸡生蛋时刻。两区与直接映射共同构成内核地址算式的全部线性段。下一节看 KASLR 如何在不破坏这些算式的前提下随机化各基址。
20.5 KASLR —— 内核地址空间随机化
前四节的版图有一个致命弱点:所有基址都是公开常量。攻击者只要获得一个内核指针,就推算出内核映像、所有全局变量、page 数组的位置,ROP/SMEP 绕过等攻击链的每一步都因此变得确定。KASLR(Kernel Address Space Layout Randomization)在两个位置注入随机性:内核映像的物理与虚拟装载地址(在压缩器里完成,2.7 节的残留悬念),以及内存大区基址(kernel_randomize_memory())。本节解剖两侧实现,并分析随机化的边界与已知弱点。
20.5.1 第一半:内核映像装载随机化
映像随机化发生在压缩器里(arch/x86/boot/compressed/kaslr.c)——这是唯一既能感知物理内存布局、又尚未把内核写进最终位置的阶段:
// arch/x86/boot/compressed/kaslr.c:861(入口)
void choose_random_location(unsigned long input,
unsigned long input_size,
unsigned long *output,
unsigned long output_size,
unsigned long *virt_addr)
流程四步:
choose_random_location() kaslr.c:861
│
├─ [1] mem_avoid_init(input, input_size, output) :354
│ 收集"绝不能碰"的物理区间集合 mem_avoid[]:
│ 压缩器自身(正在运行!) / initrd / 引导参数页
│ / 显式 reserved 区 / 已知 E820 保留
v
├─ [2] process_efi_entries() / process_e820_entries() :678
│ 从 EFI 内存映射(优先)或 E820 提取可用物理区段,
│ __process_mem_region() 剔除与 mem_avoid 重叠部分 :551
│ process_mem_region() 按 image_size 切出候选槽位数组 :595
v
├─ [3] 槽位选择: slot = kaslr_get_random_long("Physical")
│ % slot_max; :536
│ 随机源: RDRAND/RDTSC 交织 (引导期无熵池可用!)
v
└─ [4] *output = 选中的物理地址; *virt_addr = __START_KERNEL_map
+ 物理偏移 (虚拟侧同步随机, 保持两者映射关系)
→ 解压器把 vmlinux 写到 *output, 2.8 节 head_64.S
从新位置直接继续执行
虚拟地址侧的约束:内核 text 必须映射在 __START_KERNEL_map(0xffffffff80000000)起 512MB 内(20.1 节版图),虚拟随机化的自由度只有这 512MB 中约 1GB 对齐后的槽位数;物理侧自由度大得多——整个物理内存减去避让区。两侧通过 phys_base(20.2.1 节)耦合:虚拟地址偏移与物理地址偏移之差记录于此,__pa() 换算靠它修正。
CONFIG_RANDOMIZE_BASE(arch/x86/Kconfig)控制此功能;nokaslr 命令行参数可禁用(调试 KASLR 本身引发的问题时的标准操作)。
20.5.2 第二半:内存大区基址随机化
映像挪走之后,三大内存区的基址仍可整体平移——kernel_randomize_memory()(arch/x86/mm/kaslr.c:79-150):
// arch/x86/mm/kaslr.c:79-150(节选)
void __init kernel_randomize_memory(void)
{
...
vaddr_start = pgtable_l5_enabled() ? __PAGE_OFFSET_BASE_L5
: __PAGE_OFFSET_BASE_L4; /* :90 */
vaddr = vaddr_start;
...
BUG_ON(kaslr_regions[0].base != &page_offset_base); /* :112 */
memory_tb = DIV_ROUND_UP(max_pfn << PAGE_SHIFT, 1UL << TB_SHIFT) +
CONFIG_RANDOMIZE_MEMORY_PHYSICAL_PADDING; /* :116-117 */
/* ZONE_DEVICE 要把任意物理地址映射进直接映射,
与 KASLR 窃取物理地址位的设计冲突 —— 有它就不收窄 */
if (!IS_ENABLED(CONFIG_ZONE_DEVICE) && (memory_tb < kaslr_regions[0].size_tb))
kaslr_regions[0].size_tb = memory_tb; /* :127-129 */
/* vmemmap 大小必须跟随直接映射: page 槽位 = 物理页数 */
vmemmap_size = (kaslr_regions[0].size_tb << (TB_SHIFT - PAGE_SHIFT)) *
sizeof(struct page); /* :133-134 */
kaslr_regions[2].size_tb = DIV_ROUND_UP(vmemmap_size, 1UL << TB_SHIFT);
/* 总熵 = 起止范围 - 各区必要宽度, 按剩余区数均分 */
remain_entropy = vaddr_end - vaddr_start;
for (i = 0; i < ARRAY_SIZE(kaslr_regions); i++)
remain_entropy -= get_padding(&kaslr_regions[i]); /* :138-140 */
for (i = 0; i < ARRAY_SIZE(kaslr_regions); i++) {
entropy = remain_entropy / (ARRAY_SIZE(kaslr_regions) - i);
prandom_bytes_state(&rand_state, &rand, sizeof(rand));
entropy = (rand % (entropy + 1)) & PUD_MASK; /* :146-150 PUD 对齐 */
... vaddr += entropy + get_padding(...);
*kaslr_regions[i].base = vaddr; /* 写入基址变量 */
}
}
三个精妙之处:
- 顺序抽样防止碰撞:三个区依次选随机间隙,每次分得的熵是"剩余熵 ÷ 剩余区数"——先抽的区不会把后面的区挤进死角;
- PUD 对齐(
& PUD_MASK,:150):随机偏移都是 1GB 的整数倍,保持 20.1.1 节"PGD/PUD 边界隔离各区"的不变式,页表建立代码零感知; - vmemmap 与直接映射的耦合(:133-135):20.4.1 节揭示的"1TB vmemmap ↔ 64TB direct map"比例在这里被显式维护——
vmemmap_size按实际物理内存重算,随size_tb一起平移。
kaslr_regions[] 表(kaslr.c:49-61)按序注册 page_offset_base/vmalloc_base/vmemmap_base 三个变量——所有 __va()/__pa()/vmalloc 代码通过变量间接访问基址,这就是 20.1.2 节"随机化对代码零侵入"的实现机制。
20.5.3 随机化的边界与已知弱点
熵的账本 (x86_64 四级):
内核映像虚拟侧: __START_KERNEL_map 起 512MB / 2MB 对齐 ≈ 8 bit
内核映像物理侧: 物理内存 / 2MB 槽位 (16GB 内存 ≈ 13 bit)
直接映射基址: 总熵按区均分, PUD 粒度 ≈ 13 bit (典型)
vmalloc/vmemmap: 同上再分
一次指针泄露 = 摧毁对应区全部熵 —— KASLR 防的是
"无信息初始攻击", 不是信息泄露 (KPTI/SMAP 才是对抗泄露的)
三个结构性弱点:
- 熵天花板:PUD 对齐 + 区间必要宽度决定了单区只有十几个比特的熵,暴力覆盖或多次泄露即可击穿;
- 全局一致:所有进程共享同一份内核布局,一次成功泄露全网有效——这与用户态 ASLR 的"每进程独立"形成对比(用户态布局随机化的进程级隔离见 22 章 mmap_base);
- 时序侧信道:缓存计时可探测内核符号地址(如 prefetch 攻击),需要 KPTI/硬件防御补足。
因此 1.6 节的结论在此仍然成立:KASLR 是纵深防御的一层,不是终点。它与 KPTI(隐藏数据页)、SMEP/SMAP(阻断用户态跳转)组合才构成完整的地址空间防线。
20.5.4 配置与调试
CONFIG_RANDOMIZE_BASE 内核映像位置随机化 (物理+虚拟)
CONFIG_RANDOMIZE_MEMORY 内存大区基址随机化 (20.5.2 节)
CONFIG_RANDOMIZE_MEMORY_PHYSICAL_PADDING 直接映射预留余量(热插拔)
命令行: nokaslr 全部禁用
kaslr_mem_padding=N 调整物理填充
调试: 启动日志 "KASLR" 行与 /proc/cmdline 核对;
两次启动对比 dmesg 的内核 text 地址差值验证生效
ARM64(5.7 节)与 RISC-V(6.7 节)各有 KASLR 实现:ARM64 用 CONFIG_RANDOMIZE_BASE 在 head.S 里完成映像重定位,RISC-V 经 KASLR 补丁集支持映像随机化;三者的内存区基址随机化以 x86 方案为蓝本。
小结
KASLR 在两个阶段注入随机性:压缩器内的 choose_random_location() 以避让区集合约束下从 E820/EFI 槽位随机选择内核映像的物理与虚拟装载点(phys_base 记录耦合偏移);kernel_randomize_memory() 以顺序抽样 + PUD 对齐重排三大内存区基址,并显式维护 vmemmap 与直接映射的大小耦合。实现的关键是"基址即变量"——全部访问者经 page_offset_base 等间接层,随机化不触碰任何算法代码。KASLR 的熵天花板与全局一致性决定了它必须与 KPTI、SMEP/SMAP 组合使用。至此内核地址空间布局全部展开;下一章进入 SLAB/SLUB——把直接映射区的页切成对象的艺术。