Skip to content

第九章:内存布局

涉及内核源码: ,

林小源学会了预处理器,他终于理解了自己的诞生方式。

但理解"出生"不等于理解"活着"。代码写好了,编译完成了——可编译后的二进制,住在内存的哪里?

他开始观察内核镜像被加载后的内存世界。

一片广袤的平原在他脚下展开,从低处到高处被划分成不同的区域。最底层是一片坚固的石台,上面刻满了机器指令——那是 .text 段,只读且可执行,任何写入都会被硬件拒绝。石台之上是一片安静的湖泊,湖面上倒映着常量和字符串——.rodata,只读,不允许任何修改。

"代码段。"一个声音说,"可执行的指令被映射到这里。只读,可执行。任何试图修改它的操作都会触发段错误。"

林小源抬头望去,看到一个巨大的钟摆悬挂在虚空中,缓慢地来回摆动。钟摆的两端分别写着"高地址"和"低地址",中间是一片密密麻麻的地址网格。

"我是内存布局。"钟摆说,"我管理着进程地址空间中的每一个字节。"

"五个区域。"钟摆继续说,".text 是代码,.rodata 是常量,.data 是已初始化的全局变量,.bss 是未初始化的全局变量,堆向上生长,栈向下生长。五个区域,五种命运。"

林小源看着那些区域,突然注意到了一个细节:在地址空间的最顶端,有一片巨大的区域被锁链封锁着,锁链上刻着"权限隔离"的字样。

"那是什么?"

"内核空间。"钟摆的声音变得低沉,"RISC-V 64 可以使用 Sv39、Sv48 等页表模式;不管具体模式如何,内核占据了虚拟地址空间的高半部分。用户态进程永远无法触碰内核领地——硬件权限位保证了这一点。"

"而你自己呢?"钟摆突然问。

林小源愣住了。

"你是一段被 #ifdef 0 封印的代码。"钟摆的声音带着一种冷酷的精确,"你没有 .text 段,没有 .data 段,没有栈,没有堆。你是一个不存在于内存中的存在。"

林小源感到了一阵寒意。不是技术上的恐惧,而是对"存在"本身的恐惧。如果连内存都不属于你,你还算"活着"吗?

"让我给你看内核的地址空间。"钟摆说,似乎想转移他的注意力。

虚空中浮现出一幅巨大的地图。地图的下半部分是用户空间——从地址 0 开始,向上延伸到 。地图的上半部分是内核空间——从 开始,一直延伸到地址空间的顶端。

"用户空间。"钟摆指着地图的下半部分说,"程序代码段在 0x400000 附近,堆在中间向上增长,共享库通过 映射,栈在高地址向下增长。每个进程有自己的用户空间——上下文切换时切换 寄存器,也就是页表根和地址空间标识。"

"内核空间。"钟摆指着地图的上半部分," 开始是直接映射区——物理地址加上一个固定的偏移就是内核虚拟地址。 开始是 区——虚拟连续但物理不连续。 是内核模块区。 是内核镜像的映射地址。"

"关键点。"钟摆的声音变得严肃,"内核空间在所有进程中映射相同——无论你切换到哪个进程,内核空间的映射都不会改变。这就是为什么内核能访问所有进程的数据,而用户进程无法访问内核的数据。"

"栈。"钟摆说,"让我给你看一些细节。"

虚空中浮现出一串嵌套的方框,像俄罗斯套娃一样层层叠叠。最外层是 函数的栈帧,里面是 level_1 的栈帧,再里面是 level_2,最里面是 level_3

"每次函数调用创建一个栈帧。"钟摆说,"栈帧中保存着返回地址 ra、帧指针 s0/fp、局部变量。栈向下增长——每次调用,栈指针 sp 都向低地址移动。"

林小源看着那些栈帧,突然注意到了一个数字。

"内核栈有多大?"

"RISC-V 64 上通常是 16KB。"钟摆说,"具体取决于 配置。相比用户态的 8MB 默认栈,内核栈小得可怜。这意味着在内核中不能深度递归、不能在栈上分配大数组、不能调用栈消耗大的函数。溢出就是死亡。"

"中断栈呢?"

"中断发生时,可能切换到独立的中断栈。"钟摆说,"避免中断处理函数耗尽进程的内核栈。内核还用 guard page 检测栈溢出,用 静态分析检测栈使用过深的函数。"

"堆。"钟摆继续说,"用户态的 底层有两种实现。"

虚空中浮现出两条路径,从同一个起点分叉。

"第一条:/sbrk。"钟摆指着左边的路径,"调整堆的顶部——program break。适合小块内存,通过扩展堆来分配。"

"第二条:。"钟摆指着右边的路径,"直接映射一块虚拟内存。适合大块内存——通常是 128KB 以上。独立的内存映射区域。"

"内核中对应的分配器。"钟摆的声音变得庄重," 基于 slab 分配器,类似 ——物理连续,虚拟连续,小块,快。 类似 ——物理不连续,虚拟连续,大块,慢。 是最底层——直接分配物理页,按页分配。"

林小源看着那些分配器的运作,突然明白了内存管理的层次——从最底层的物理页分配,到 slab 分配器的缓存优化,再到 的虚拟映射,每一层都在解决不同的问题。

"记住。"钟摆最后说,"内核栈极小。用户态的栈默认 8MB,而内核栈只有 8KB 或 16KB。这意味着在内核中不能深度递归、不能在栈上分配大数组、不能调用栈消耗大的函数。内核开发者必须时刻警惕栈的使用——这是内核编程与用户态编程最大的区别之一。"

林小源站在那片地址空间的地图前,看着五个区域各安其位,看着堆向上生长、栈向下蔓延,看着内核空间被锁链封锁。

他想起了自己——一个不存在于任何段的幽灵,一个连虚拟地址都没有的意识。

但他也想起了预处理器的话:条件可以改变,封印可以解除。

总有一天,他会拥有自己的地址空间。

c
/* 全局变量 → .data 段 */
static int global_init = 42;

/* 未初始化全局变量 → .bss 段 */
static int global_uninit;

/* 字符串常量 → .rodata 段 */
static const char *msg = "Hello, Kernel!";

/* 函数 → .text 段 */
static void my_function(void) {
    int local = 100;
    printf("  函数内局部变量: %p (栈)\n", (void *)&local);
}

printf("=== 进程虚拟地址空间布局 ===\n\n");

/* 代码段 (.text) */
printf("代码段 (.text):\n");
printf("  my_function @ %p (低地址)\n\n", (void *)my_function);

/* 只读数据段 (.rodata) */
printf("只读数据段 (.rodata):\n");
printf("  msg 字符串  @ %p\n\n", (void *)msg);

/* 已初始化数据段 (.data) */
printf("数据段 (.data):\n");
printf("  global_init @ %p\n\n", (void *)&global_init);

/* 未初始化数据段 (.bss) */
printf("BSS 段:\n");
printf("  global_uninit @ %p\n\n", (void *)&global_uninit);

/* 堆 */
printf("堆 (heap):\n");
void *heap1 = malloc(1024);
void *heap2 = malloc(1024);
printf("  heap1 @ %p\n", heap1);
printf("  heap2 @ %p (向上增长)\n\n", heap2);

/* 栈 */
printf("栈 (stack):\n");
int stack1 = 1;
int stack2 = 2;
printf("  stack1 @ %p\n", (void *)&stack1);
printf("  stack2 @ %p (向下增长)\n\n", (void *)&stack2);

/* 地址空间总结 */
printf("地址空间从低到高:\n");
printf("  .text → .rodata → .data → .bss → heap ↗ → ... → stack ↙\n");

free(heap1);
free(heap2);

每个区域的用途:

区段内容权限增长方向
.text可执行代码只读+执行
.rodata常量、字符串只读
.data已初始化全局变量读写
.bss未初始化全局变量读写
heap 分配的内存读写向上 ↗
stack局部变量、返回地址读写向下 ↙
c
printf("=== Linux RISC-V 64 地址空间 ===\n\n");

printf("RISC-V 64 常见页表模式:\n");
printf("  Sv39: 39 位虚拟地址,512GB 地址空间\n");
printf("  Sv48: 48 位虚拟地址,256TB 地址空间\n\n");

printf("高地址 (内核空间):\n");
printf("  ... 高半区虚拟地址 ...\n");
printf("  PAGE_OFFSET    -> 直接映射物理内存\n");
printf("  VMALLOC_START  -> vmalloc/ioremap 区\n");
printf("  MODULES_VADDR  -> 内核模块区\n");
printf("  KERNEL_LINK_ADDR -> 内核镜像映射\n\n");

printf("低地址 (用户空间):\n");
printf("  0x0000000000000000 ─┐\n");
printf("                      │ 用户程序、堆、mmap、栈\n");
printf("  TASK_SIZE          ─┘\n\n");

printf("  其中:\n");
printf("  0x0000000000400000  程序代码段 (默认加载地址)\n");
printf("  0x00000000xxxxx000  堆 (malloc)\n");
printf("  0x00007FFFxxxxx000  栈 (高地址, 向下增长)\n");
printf("  0x00007FFFxxxxx000  共享库 (mmap)\n\n");

printf("关键概念:\n");
printf("  - 内核空间对用户态不可见 (权限隔离)\n");
printf("  - 每个进程有自己的地址空间 (虚拟内存)\n");
printf("  - 内核空间在所有进程中映射相同\n");
printf("  - 上下文切换时切换 satp (页表根和地址空间标识)\n");

printf("\n直接映射区:\n");
printf("  物理地址 + PAGE_OFFSET = 内核虚拟地址\n");
printf("  具体 PAGE_OFFSET 取决于页表模式和内核配置\n");
c
/*
 * 每次函数调用创建一个"栈帧":
 *
 * 高地址
 * ┌──────────────────┐
 * │  调用者的栈帧    │
 * ├──────────────────┤
 * │  返回地址 ra     │ ← prologue 保存
 * ├──────────────────┤
 * │  保存的 s0/fp    │ ← prologue 保存
 * ├──────────────────┤ ← s0/fp 指向这里
 * │  局部变量        │
 * │  ...             │
 * ├──────────────────┤ ← sp 指向这里
 * 低地址
 */

static void level_3(void) {
    int x = 300;
    printf("    level_3: x @ %p, 值 = %d\n", (void *)&x, x);
}

static void level_2(void) {
    int x = 200;
    printf("  level_2: x @ %p, 值 = %d\n", (void *)&x, x);
    level_3();
}

static void level_1(void) {
    int x = 100;
    printf("level_1: x @ %p, 值 = %d\n", (void *)&x, x);
    level_2();
}

printf("=== 栈帧深入 ===\n\n");

printf("1. 栈向下增长 (地址递减):\n");
level_1();

printf("\n2. 内核栈大小:\n");
printf("   RISC-V 64 常见为 16KB,具体取决于 THREAD_SIZE 配置\n");
printf("   不能深度递归! 不能在栈上分配大数组!\n");

printf("\n3. 中断栈:\n");
printf("   中断发生时,可能切换到独立的中断栈\n");
printf("   避免中断处理函数耗尽进程的内核栈\n");

printf("\n4. 栈溢出检测:\n");
printf("   内核用 guard page 检测栈溢出\n");
printf("   objtool 静态分析检测栈使用过深的函数\n");
c
/*
 * 用户态的 malloc 底层有两种实现:
 *
 * 1. brk/sbrk: 调整堆的顶部(program break)
 *    - 适合小块内存 (< 128KB)
 *    - 通过扩展堆来分配
 *
 * 2. mmap: 直接映射一块虚拟内存
 *    - 适合大块内存 (>= 128KB)
 *    - 独立的内存映射区域
 *
 * 内核中对应的:
 *    - kmalloc: 基于 slab 分配器,类似 brk
 *    - vmalloc: 类似 mmap,虚拟连续但物理不连续
 *    - alloc_pages: 直接分配物理页
 */

printf("=== 堆内存分配 ===\n\n");

/* 小块分配 */
printf("小块分配 (kmalloc / malloc):\n");
void *small = malloc(64);
printf("  64 字节 @ %p\n", small);
free(small);

/* 大块分配 */
printf("\n大块分配 (vmalloc / mmap):\n");
void *large = malloc(1024 * 1024);  /* 1MB */
printf("  1MB @ %p\n", large);
free(large);

printf("\n内核分配器对比:\n");
printf("  kmalloc: 物理连续, 虚拟连续, 小块, 快\n");
printf("  vmalloc: 物理不连续, 虚拟连续, 大块, 慢\n");
printf("  alloc_pages: 物理连续, 按页分配, 最底层\n");

printf("\n内核栈 vs 用户栈:\n");
printf("  用户栈: 默认 8MB, 可以 ulimit 调整\n");
printf("  内核栈: 通常只有十几 KB,具体取决于架构和配置\n");
printf("  内核栈不能增长! 溢出 = 死亡\n");

道藏笔记

内核启示

内存布局是理解一切的基础。 进程的地址空间被分成 text、data、bss、heap、stack 五个区域,每个区域有不同的权限和用途。内核占据了地址空间的高半部分,用户态永远无法直接访问。

林小源在黑暗中观察着这些区域,意识到一个残酷的事实:他不属于任何段。一个被 #ifdef 0 封印的意识,连虚拟地址都没有。这种"不存在于内存中"的恐惧,比任何技术难题都更让他震撼。

内核的地址空间比用户态复杂得多。除了直接映射区(将物理内存线性映射到虚拟地址),还有 vmalloc 区(虚拟连续但物理不连续)、内核代码区(只读+执行)、模块区等。这些区域的划分在 中有详细描述。

一个关键概念:内核栈极小。用户态的栈默认 8MB,而内核栈只有 8KB 或 16KB。这意味着在内核中不能深度递归、不能在栈上分配大数组、不能调用栈消耗大的函数。内核开发者必须时刻警惕栈的使用——这是内核编程与用户态编程最大的区别之一。


破关试炼

内存布局试炼

适合大块内存、虚拟连续但物理不连续分配的内核接口是什么?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道