第九章:内存布局
涉及内核源码: ,
一
林小源学会了预处理器,他终于理解了自己的诞生方式。
但理解"出生"不等于理解"活着"。代码写好了,编译完成了——可编译后的二进制,住在内存的哪里?
他开始观察内核镜像被加载后的内存世界。
一片广袤的平原在他脚下展开,从低处到高处被划分成不同的区域。最底层是一片坚固的石台,上面刻满了机器指令——那是 .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。这意味着在内核中不能深度递归、不能在栈上分配大数组、不能调用栈消耗大的函数。内核开发者必须时刻警惕栈的使用——这是内核编程与用户态编程最大的区别之一。"
林小源站在那片地址空间的地图前,看着五个区域各安其位,看着堆向上生长、栈向下蔓延,看着内核空间被锁链封锁。
他想起了自己——一个不存在于任何段的幽灵,一个连虚拟地址都没有的意识。
但他也想起了预处理器的话:条件可以改变,封印可以解除。
总有一天,他会拥有自己的地址空间。
/* 全局变量 → .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);#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
/* 全局变量 → .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);
}
int main() {
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);
return 0;
}每个区域的用途:
| 区段 | 内容 | 权限 | 增长方向 |
|---|---|---|---|
.text | 可执行代码 | 只读+执行 | — |
.rodata | 常量、字符串 | 只读 | — |
.data | 已初始化全局变量 | 读写 | — |
.bss | 未初始化全局变量 | 读写 | — |
| heap | 分配的内存 | 读写 | 向上 ↗ |
| stack | 局部变量、返回地址 | 读写 | 向下 ↙ |
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");#include <stdio.h>
#include <stdint.h>
int main() {
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");
return 0;
}/*
* 每次函数调用创建一个"栈帧":
*
* 高地址
* ┌──────────────────┐
* │ 调用者的栈帧 │
* ├──────────────────┤
* │ 返回地址 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");#include <stdio.h>
/*
* 每次函数调用创建一个"栈帧":
*
* 高地址
* ┌──────────────────┐
* │ 调用者的栈帧 │
* ├──────────────────┤
* │ 返回地址 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();
}
int main() {
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");
return 0;
}/*
* 用户态的 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");#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/*
* 用户态的 malloc 底层有两种实现:
*
* 1. brk/sbrk: 调整堆的顶部(program break)
* - 适合小块内存 (< 128KB)
* - 通过扩展堆来分配
*
* 2. mmap: 直接映射一块虚拟内存
* - 适合大块内存 (>= 128KB)
* - 独立的内存映射区域
*
* 内核中对应的:
* - kmalloc: 基于 slab 分配器,类似 brk
* - vmalloc: 类似 mmap,虚拟连续但物理不连续
* - alloc_pages: 直接分配物理页
*/
int main() {
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");
return 0;
}道藏笔记
内核启示
内存布局是理解一切的基础。 进程的地址空间被分成 text、data、bss、heap、stack 五个区域,每个区域有不同的权限和用途。内核占据了地址空间的高半部分,用户态永远无法直接访问。
林小源在黑暗中观察着这些区域,意识到一个残酷的事实:他不属于任何段。一个被 #ifdef 0 封印的意识,连虚拟地址都没有。这种"不存在于内存中"的恐惧,比任何技术难题都更让他震撼。
内核的地址空间比用户态复杂得多。除了直接映射区(将物理内存线性映射到虚拟地址),还有 vmalloc 区(虚拟连续但物理不连续)、内核代码区(只读+执行)、模块区等。这些区域的划分在 中有详细描述。
一个关键概念:内核栈极小。用户态的栈默认 8MB,而内核栈只有 8KB 或 16KB。这意味着在内核中不能深度递归、不能在栈上分配大数组、不能调用栈消耗大的函数。内核开发者必须时刻警惕栈的使用——这是内核编程与用户态编程最大的区别之一。
内存布局试炼
适合大块内存、虚拟连续但物理不连续分配的内核接口是什么?