2深入理解Linux内核(第3版)

内存寻址

内存管理基础

虚拟内存物理内存页表

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第2章:内存寻址(Memory Addressing)

一、导读

2.1 本章学习目标

第2章深入讲解x86架构下的内存寻址机制,这是理解Linux内存管理的基础。在Linux内核中,每一次内存访问——无论是读取进程数据、执行内核代码,还是处理I/O操作——最终都要通过硬件的地址翻译机制将虚拟地址转换为物理地址。不理解这个翻译过程,就无法真正理解内存管理的实现。

本章的核心学习目标包括:

  • 理解x86处理器的内存寻址模型:段机制和分页机制如何协作完成地址翻译
  • 掌握虚拟地址到物理地址的完整翻译路径:段描述符、页表、物理页框的关系
  • 理解Linux的内存模型:为什么Linux主要使用分页机制,段机制如何被"扁平化"
  • 掌握三级页表(PAE)的结构:PGD→PMD→PTE→物理页框的翻译过程
  • 理解TLB(Translation Lookaside Buffer)的作用:硬件如何加速地址翻译
  • 掌握内核空间和用户空间的划分:3G/1G(用户/内核)的内存布局

2.2 为什么本章如此重要

内存寻址是操作系统最核心的硬件基础之一。没有对内存寻址的深入理解,后续章节中的很多概念都将变得晦涩难懂:为什么vmalloc和kmalloc返回的地址不同?为什么内核需要直接映射区?为什么缺页异常的处理如此复杂?这些问题的答案都根植于本章的内容。

此外,内存寻址机制直接影响程序的性能。TLB未命中、页表遍历、缓存不命中等问题,在高性能系统编程中都是需要仔细考虑的因素。理解硬件层面的地址翻译过程,可以帮助你编写更高效的代码,也可以帮助你理解内核性能调优的底层逻辑。

2.3 核心问题引导

在阅读本章时,请带着以下问题思考:

CPU执行一条mov rax, [0x7fff12345678]指令时,硬件是如何找到对应的物理内存单元的?

为什么Linux要将段基址设为0?这种"扁平模型"有什么好处?

三级页表相比二级页表解决了什么问题?每一级页表分别索引多少位?

TLB是什么?为什么TLB未命中会导致显著的性能损失?

为什么内核空间要映射到每个进程地址空间的最高1GB?

物理地址扩展(PAE)如何让32位系统访问超过4GB的物理内存?


二、核心概念详解

2.1 x86的内存寻址模型

x86处理器使用两层地址翻译机制:段机制(Segmentation)和分页机制(Paging)。完整的地址翻译路径如下:

程序中的逻辑地址(段选择符 + 偏移量)
        ↓ 段机制
线性地址(32位或64位)
        ↓ 分页机制
物理地址

2.1.1 段机制

段机制是x86架构最初设计的内存管理方式。在保护模式下,每个内存访问都包含一个段选择符(Segment Selector)和一个偏移量(Offset)。段选择符索引全局描述符表(GDT)或局部描述符表(LDT)中的一个段描述符,段描述符定义了段的基地址、限长和访问权限。

c
/* 段描述符的结构(8字节) */
struct desc_struct {
    union {
        struct {
            unsigned int a;  /* 低32位 */
            unsigned int b;  /* 高32位 */
        };
        struct {
            u16 limit0;      /* 段限长低16位 */
            u16 base0;       /* 基地址低16位 */
            unsigned base1:8;/* 基地址中8位 */
            unsigned type:4; /* 段类型 */
            unsigned s:1;    /* 0=系统段, 1=代码/数据段 */
            unsigned dpl:2;  /* 特权级(0-3) */
            unsigned p:1;    /* 段是否存在 */
            unsigned limit:4;/* 段限长高4位 */
            unsigned avl:1;  /* 可用位 */
            unsigned l:1;    /* 64位模式标志 */
            unsigned d:1;    /* 默认操作大小(0=16位, 1=32位) */
            unsigned g:1;    /* 粒度(0=字节, 1=4KB) */
            unsigned base2:8;/* 基地址高8位 */
        } __attribute__((packed));
    };
};

在早期的x86系统中,段机制被广泛用于内存保护和代码/数据分离。但在Linux中,段机制被极大地简化了。

2.1.2 Linux的扁平内存模型

Linux对段机制采取了一种"最小化使用"的策略:将所有段的基地址设为0,段限长设为最大值(4GB)。这样,逻辑地址的偏移量就等于线性地址,段机制实际上被"绕过"了。

c
/* Linux 初始化段描述符(arch/x86/kernel/setup.c 简化) */
/* GDT 中的段描述符 */
/*
 * 段选择符    基地址    限长      类型        DPL
 * __KERNEL_CS  0        0xFFFFF   可执行/可读   0 (内核态)
 * __KERNEL_DS  0        0xFFFFF   可读写        0 (内核态)
 * __USER_CS    0        0xFFFFF   可执行/可读   3 (用户态)
 * __USER_DS    0        0xFFFFF   可读写        3 (用户态)
 */

/* 在 Linux 中:
 * 线性地址 = 逻辑地址中的偏移量
 * 因为段基地址 = 0
 */

这种设计的好处是简化了内存管理代码——内核不需要处理复杂的段切换逻辑。同时,代码段和数据段的区分仍然通过不同的段选择符保留了,这保证了特权级保护的正确性。

2.2 分页机制

分页机制是Linux内存管理的核心。它将线性地址空间划分为固定大小的页(Page),将物理内存划分为同样大小的页框(Page Frame)。在x86上,标准的页大小为4KB(2^12字节)。

2.2.1 页表的结构

页表是地址翻译的关键数据结构。它将线性地址映射到物理页框。Linux在x86-64上使用四级页表(标准模式):

64位线性地址的划分(4级页表,4KB页):
┌────────┬────────┬────────┬────────┬────────────┐
│  47-39 │ 38-30  │ 29-21  │ 20-12  │   11-0     │
│  PGD   │  PUD   │  PMD   │  PTE   │  页内偏移   │
│  9位   │  9位   │  9位   │  9位   │   12位     │
└────────┴────────┴────────┴────────┴────────────┘
    ↓        ↓        ↓        ↓         ↓
  页全局   页上级   页中间   页表项    物理页框内
  目录     目录     目录      偏移      的字节位置

翻译过程:
1. CR3寄存器 → PGD基地址
2. PGD[47:39] → PUD基地址
3. PUD[38:30] → PMD基地址
4. PMD[29:21] → PTE基地址
5. PTE[20:12] → 物理页框基地址
6. 物理页框基地址 + 偏移[11:0] → 最终物理地址
c
/* 页表项的结构(x86-64,每个页表项8字节) */
typedef struct {
    unsigned long pgd;  /* 页全局目录项 */
} pgd_t;

typedef struct {
    unsigned long pud;  /* 页上级目录项 */
} pud_t;

typedef struct {
    unsigned long pmd;  /* 页中间目录项 */
} pmd_t;

typedef struct {
    unsigned long pte;  /* 页表项 */
} pte_t;

/* 页表项的标志位 */
#define _PAGE_PRESENT   0x001  /* 页是否存在 */
#define _PAGE_RW        0x002  /* 0=只读, 1=可读写 */
#define _PAGE_USER      0x004  /* 0=内核页, 1=用户页 */
#define _PAGE_PWT       0x008  /* 页级写穿透 */
#define _PAGE_PCD       0x010  /* 页级缓存禁用 */
#define _PAGE_ACCESSED  0x020  /* 是否被访问过 */
#define _PAGE_DIRTY     0x040  /* 是否被写过 */
#define _PAGE_PSE       0x080  /* 大页标志(2MB或1GB页) */
#define _PAGE_GLOBAL    0x100  /* 全局页(不刷新TLB) */
#define _PAGE_NX        (1ULL<<63) /* 不可执行 */

2.2.2 地址翻译的硬件实现

地址翻译完全由CPU中的MMU(Memory Management Unit)硬件完成。翻译过程需要多次内存访问(每次页表遍历都需要一次内存读取),这就是TLB存在的原因。

c
/* 地址翻译的C语言模拟 */
unsigned long translate_address(unsigned long linear_addr,
                                 unsigned long cr3) {
    /* 提取各级索引 */
    unsigned long pgd_index = (linear_addr >> 39) & 0x1FF;
    unsigned long pud_index = (linear_addr >> 30) & 0x1FF;
    unsigned long pmd_index = (linear_addr >> 21) & 0x1FF;
    unsigned long pte_index = (linear_addr >> 12) & 0x1FF;
    unsigned long offset    =  linear_addr        & 0xFFF;

    /* 第1级:PGD */
    unsigned long *pgd_base = (unsigned long *)(cr3 & PAGE_MASK);
    unsigned long pgd_entry = pgd_base[pgd_index];
    if (!(pgd_entry & _PAGE_PRESENT))
        return PAGE_FAULT;  /* 缺页异常 */

    /* 第2级:PUD */
    unsigned long *pud_base = (unsigned long *)(pgd_entry & PAGE_MASK);
    unsigned long pud_entry = pud_base[pud_index];
    if (!(pud_entry & _PAGE_PRESENT))
        return PAGE_FAULT;

    /* 第3级:PMD */
    unsigned long *pmd_base = (unsigned long *)(pud_entry & PAGE_MASK);
    unsigned long pmd_entry = pmd_base[pmd_index];
    if (!(pmd_entry & _PAGE_PRESENT))
        return PAGE_FAULT;

    /* 检查是否是大页(2MB) */
    if (pmd_entry & _PAGE_PSE) {
        unsigned long frame = pmd_entry & ~((2UL << 20) - 1);
        return frame | (linear_addr & ((2UL << 20) - 1));
    }

    /* 第4级:PTE */
    unsigned long *pte_base = (unsigned long *)(pmd_entry & PAGE_MASK);
    unsigned long pte_entry = pte_base[pte_index];
    if (!(pte_entry & _PAGE_PRESENT))
        return PAGE_FAULT;

    /* 权限检查 */
    /* 此处省略权限检查细节 */

    /* 合成物理地址 */
    unsigned long frame = pte_entry & PAGE_MASK;
    return frame | offset;
}

2.3 TLB(Translation Lookaside Buffer)

TLB是CPU内部的一个高速缓存,用于缓存最近的页表翻译结果。没有TLB,每次内存访问都需要4次额外的内存读取(遍历四级页表),性能将下降数十倍。

TLB的结构:
┌──────────────────────────────────────────┐
│  TLB Entry                               │
│  ┌────────────┬────────────┬───────────┐ │
│  │ 虚拟页号   │ 物理页框号 │ 标志位    │ │
│  │ (tag)      │ (data)     │           │ │
│  └────────────┴────────────┴───────────┘ │
│  ...                                     │
│  典型大小:L1 DTLB 64项, L1 ITLB 64项   │
│  L2 STLB 1024项                          │
└──────────────────────────────────────────┘

TLB未命中时,CPU需要遍历页表来获取翻译结果。如果页表项也不在缓存中,每次内存访问可能需要数百个时钟周期。这就是为什么TLB对性能至关重要。

bash
# 查看系统的 TLB 信息
cat /proc/cpuinfo | grep -i "tlb"
# 或使用 cpuid 工具
cpuid -1 | grep "TLB"

2.4 内核空间的内存布局

在x86-64 Linux中,虚拟地址空间被划分为用户空间和内核空间:

x86-64 虚拟地址空间布局(47位地址空间,128TB):

高地址
┌──────────────────────────────┐ 0xFFFF_FFFF_FFFF_FFFF
│                              │
│      内核空间(高128TB)       │
│                              │
│  ┌────────────────────────┐  │ 0xFFFF_C900_0000_0000
│  │   直接映射区(全部物理   │  │
│  │   内存的线性映射)       │  │
│  └────────────────────────┘  │ 0xFFFF_A000_0000_0000
│  │   vmalloc 区             │  │
│  └────────────────────────┘  │ 0xFFFF_C8FF_FFFF_FFFF
│  │   内核代码和数据          │  │ 0xFFFF_FFFF_8000_0000
│  │   (__START_KERNEL_map)   │  │
│  └────────────────────────┘  │
│  │   模块映射区              │  │
│  └────────────────────────┘  │
├──────────────────────────────┤ 0x0000_7FFF_FFFF_FFFF
│                              │
│      用户空间(低128TB)       │
│                              │
│  ┌────────────────────────┐  │
│  │   栈(向下增长)         │  │ ~0x7FFF_xxxx_xxxx
│  │   ...                  │  │
│  │   共享库映射             │  │
│  │   ...                  │  │
│  │   mmap 区域             │  │
│  │   ...                  │  │
│  │   堆(向上增长)         │  │
│  │   ...                  │  │
│  │   BSS(未初始化数据)    │  │
│  │   数据段(已初始化数据)  │  │
│  │   代码段(.text)        │  │ 0x0040_0000
│  └────────────────────────┘  │ 0x0000_0000_0000
└──────────────────────────────┘
低地址
c
/* 查看进程的内存映射 */
/* /proc/self/maps 示例输出 */
/*
 * 地址范围              权限   偏移    设备   inode   路径
 * 00400000-00401000    r-xp   00000000 08:01  12345   /usr/bin/ls
 * 00601000-00602000    r--p   00001000 08:01  12345   /usr/bin/ls
 * 00602000-00603000    rw-p   00002000 08:01  12345   /usr/bin/ls
 * 7f1234560000-7f1234720000 r-xp 00000000 08:01 67890 /lib/x86_64-linux-gnu/libc-2.31.so
 * 7ffd12340000-7ffd12361000 rw-p 00000000 00:00  0     [stack]
 * 7ffd123fe000-7ffd12400000 r-xp 00000000 00:00  0     [vdso]
 * ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
 */

2.5 物理地址扩展(PAE)

PAE(Physical Address Extension)是Intel在Pentium Pro处理器中引入的技术,它允许32位处理器访问超过4GB的物理内存。PAE将页表项从32位扩展到64位,从而可以索引更多的物理页框。

c
/* PAE 模式下的页表项(64位) */
/*
 * 32位线性地址的划分(PAE,3级页表):
 * ┌────────┬────────┬────────┬────────────┐
 * │ 31-22  │ 21-12  │  11-0  │
 * │  PGD   │  PTE   │  偏移  │
 * │ 10位   │  9位   │  12位  │
 * └────────┴────────┴────────┴────────────┘
 *
 * PAE 页表项(64位):
 * ┌──────────────────────────────────────────┐
 * │ 51-12: 物理页框基地址(支持52位物理地址)  │
 * │ 11-0:  标志位(Present, RW, User等)      │
 * └──────────────────────────────────────────┘
 *
 * 最大可寻址物理内存:2^52 bytes = 4 PB
 * 实际限制通常为 64 GB(2^36 bytes)
 */

三、实战案例

3.1 分析进程的虚拟地址空间

c
/* show_maps.c - 查看进程的内存映射 */
#include <stdio.h>
#include <stdlib.h>

int global_var = 42;           /* 数据段 */
static int static_var = 100;   /* 数据段 */
int bss_var;                    /* BSS段 */

int main() {
    int stack_var;              /* 栈 */
    int *heap_var;              /* 堆指针在栈上 */

    heap_var = malloc(1024);    /* 堆分配 */

    printf("=== Address Layout ===\n");
    printf("Code (main):    %p\n", main);
    printf("Data (global):  %p\n", &global_var);
    printf("BSS (bss_var):  %p\n", &bss_var);
    printf("Heap (malloc):  %p\n", heap_var);
    printf("Stack (local):  %p\n", &stack_var);

    printf("\n=== /proc/self/maps ===\n");
    FILE *f = fopen("/proc/self/maps", "r");
    char line[256];
    while (fgets(line, sizeof(line), f))
        printf("%s", line);
    fclose(f);

    free(heap_var);
    return 0;
}

编译运行:

bash
gcc -o show_maps show_maps.c
./show_maps

3.2 手动遍历页表

c
/* page_walk.c - 通过 /proc 查看页表信息 */
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdint.h>

/* 需要 root 权限读取 /proc/kpageflags */
int main(int argc, char *argv[]) {
    int fd;
    uint64_t pfn, flags;
    unsigned long addr;

    if (argc < 2) {
        printf("Usage: %s <virtual_address_hex>\n", argv[0]);
        return 1;
    }

    addr = strtoul(argv[1], NULL, 16);

    /* 读取 /proc/self/pagemap 获取物理页框号 */
    fd = open("/proc/self/pagemap", O_RDONLY);
    if (fd < 0) {
        perror("open pagemap");
        return 1;
    }

    uint64_t offset = (addr / 4096) * 8;
    lseek(fd, offset, SEEK_SET);

    uint64_t entry;
    read(fd, &entry, sizeof(entry));
    close(fd);

    if (entry & (1ULL << 63)) {
        pfn = entry & ((1ULL << 55) - 1);
        printf("Virtual address: 0x%lx\n", addr);
        printf("Page frame number: 0x%lx\n", pfn);
        printf("Physical address: 0x%lx\n",
               (unsigned long)(pfn * 4096 + (addr & 0xFFF)));
        printf("Page size: 4KB\n");
        printf("Soft dirty: %d\n", (entry >> 55) & 1);
        printf("Exclusive: %d\n", (entry >> 56) & 1);
        printf("Present: 1\n");
        printf("Swapped: 0\n");
    } else if (entry & (1ULL << 62)) {
        printf("Page is swapped out\n");
    } else {
        printf("Page is not present\n");
    }

    return 0;
}

3.3 观察TLB刷新

bash
# 查看TLB相关信息
cat /proc/cpuinfo | grep -A5 "cache"
# 使用 perf 统计TLB未命中
perf stat -e dTLB-load-misses,dTLB-store-misses,iTLB-load-misses ./your_program

四、本章小结

本章深入讲解了x86架构下的内存寻址机制。我们从段机制和分页机制的两层翻译模型出发,理解了逻辑地址到物理地址的完整翻译路径。Linux通过将所有段基址设为0的"扁平模型",极大地简化了段机制的使用,将内存管理的重心转移到了分页机制上。

四级页表(PGD→PUD→PMD→PTE)是x86-64 Linux的核心地址翻译结构。每一级页表使用9位索引,加上12位页内偏移,共48位虚拟地址。TLB作为页表的硬件缓存,对性能至关重要——TLB未命中意味着需要遍历四级页表,代价高昂。

内核空间和用户空间的划分(在x86-64上是各128TB)确保了内核代码和数据的隔离。直接映射区将全部物理内存线性映射到内核地址空间,使得内核可以快速访问任意物理页面。

这些硬件层面的知识将在后续章节中反复出现:第3章的内存管理将基于分页机制实现页框分配和虚拟内存管理,第4章的缓存机制将利用页缓存和TLB优化I/O性能。


五、思考与练习

思考题

如果将Linux的页大小从4KB改为2MB(大页),对系统性能会产生哪些正面和负面影响?

为什么TLB刷新是一个昂贵的操作?在什么情况下需要刷新TLB?

为什么内核空间需要直接映射区?如果没有直接映射区,内核访问物理内存会遇到什么问题?

在32位系统上,为什么ZONE_HIGHMEM需要临时映射(kmap)?64位系统是否还需要HIGHMEM?

编程练习

编写一个程序,分配一大块内存(如1GB),然后逐页访问,使用/proc/self/pagemap观察每一页的物理页框号。验证缺页异常的触发过程。

编写一个C程序,使用mmap创建一个匿名映射,验证映射区域的权限(读写保护)是如何通过页表项的标志位实现的。

使用perf工具统计一个矩阵乘法程序的TLB未命中率,尝试通过循环分块(loop tiling)优化来降低TLB未命中。


2/10内存寻址
0%