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;     /* 写入基址变量 */
    }
}

三个精妙之处:

  1. 顺序抽样防止碰撞:三个区依次选随机间隙,每次分得的熵是"剩余熵 ÷ 剩余区数"——先抽的区不会把后面的区挤进死角;
  2. PUD 对齐(& PUD_MASK,:150):随机偏移都是 1GB 的整数倍,保持 20.1.1 节"PGD/PUD 边界隔离各区"的不变式,页表建立代码零感知;
  3. 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——把直接映射区的页切成对象的艺术。