第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)中的一个段描述符,段描述符定义了段的基地址、限长和访问权限。
/* 段描述符的结构(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)。这样,逻辑地址的偏移量就等于线性地址,段机制实际上被"绕过"了。
/* 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] → 最终物理地址/* 页表项的结构(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语言模拟 */
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对性能至关重要。
# 查看系统的 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
└──────────────────────────────┘
低地址/* 查看进程的内存映射 */
/* /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位,从而可以索引更多的物理页框。
/* 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 分析进程的虚拟地址空间
/* 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;
}编译运行:
gcc -o show_maps show_maps.c
./show_maps3.2 手动遍历页表
/* 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刷新
# 查看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未命中。