做什么
上一篇我们搞清楚了"我在谁的上下文里跑",这一篇接着问下一个更基本的问题:每个进程都"以为"自己独占了整个内存,这套幻觉是谁搭起来的,又是怎么搭的? 你写用户态程序时 malloc 拿到的地址、函数里局部变量的地址、printf 调到 glibc 时访问的 .so 里的地址——这些地址其实都不是物理内存条上的真实位置,而是"虚拟地址"。每个进程活在自己的一套虚拟地址空间(Virtual Address Space, VAS)里,各自有各自的 0x0000...0000 到 0xffff...ffff,互不打扰。这层"骗术"是现代操作系统之所以能同时跑几百个进程而不互相踩踏的根本。
作为要往内核钻的我们,光知道"有虚拟内存"远远不够,得在脑子里建立起 VAS 的精确地形图:从低地址到高地址依次摆着哪些段、哪一段往哪个方向长、用户空间到哪儿为止、内核空间从哪儿开始、又是哪个内核结构体在替每个进程"记住"它这套布局。这篇我们就把这张地图彻底画清楚,该用 6.19 源码钉死的地方(PAGE_OFFSET、TASK_SIZE 那一票)都钉死。和前几篇一样,运行输出还是标注成"待亲测",坐到 QEMU 前再升级。
要了解什么
一、VAS 是个沙盒:每个进程各看各的
先刻一条铁律:内存是沙盒化的。一个进程从它的视角往外看,看到的是一整片连续的、独属于自己的虚拟地址空间;它看不到别的进程的内存,也看不到物理 RAM 的真实布局——更别说往"外"够别的进程了,那是根本不存在的路径。进程之间、进程与物理硬件之间,被内核和 MMU(memory management unit)用页表这套机制彻底隔开。
那物理内存去哪了?它被内核当成一份"全局共享资源",在所有进程的 VAS 之间动态分配:进程 A 的虚拟页 0x4000 可能落在物理内存的 0x1_0000,进程 B 的虚拟页 0x4000 可能落在物理的 0x2_0000——虚拟地址相同,物理位置毫不相干,全靠各自的页表翻译。这就是为什么两个进程能各自占着 0x4000 这个地址相安无事。
进程的这套 VAS 不是铁板一块,它被内核划分成若干个同质的小区段,习惯上叫段(segment),更技术性的叫法是映射(mapping)——因为它们本质上是内核通过 mmap 这一类系统调用,把"某段虚拟地址"和"某种后端"(文件、匿名内存、共享库)绑定出来的产物。下面我们从低地址往高地址,把一个标准 Linux 用户进程的 VAS 挨段走一遍。
二、从低到高走一遍 VAS
第 0 页:专门抓 NULL 的陷阱
0x0000...0000 附近那一小段(通常一个页,4KB)是故意留空且不可访问的。它存在的唯一目的,就是让空指针 NULL 解引用立刻炸——*ptr 当 ptr 是 0 时,MMU 一翻译就发现"这一页没映射",直接触发缺页异常,内核给你送个 SIGSEGV(段错误)。如果 0 地址是可用的,空指针 bug 就会悄无声息地写出脏数据,而不是当场暴毙,排查难度直接升一个数量级。所以这"第 0 页"是个蓄意的陷阱。
Text 段(代码段):只读、可执行
接下来是 Text 段,存的是编译出来的机器指令,指令指针(PC/RIP/LR)就在这片地址上跳舞。权限是 r-x(读 + 执行,不可写)——可写就乱套了,程序能改自己的指令,安全防线秒破。Text 段是只读的另一个好处:多个进程跑同一个程序(比如 10 个 bash)可以共享同一份物理 Text 段,内核只要把它映射到各自的 VAS 即可,物理内存省下一大笔。
Data 段(数据段):全局变量的家
紧跟 Text 段的是 Data 段,权限 rw-,存全局变量和静态变量。它实际上分三小块:
- 已初始化数据(Initialized Data):代码里写了
int x = 5;这种带初值的全局/静态变量,初值编进可执行文件,加载时原样映射进来。 - 未初始化数据(BSS):代码里
int y;这种没给初值的全局/静态变量。可执行文件里 BSS 段不占空间(只记个大小),加载时内核直接分配清零的页——所以 BSS 变量天生就是 0,这是内核送的性质,不是 C 语言的魔法。 - 堆(Heap):
malloc的老家。它紧跟 BSS,而且它是整个 VAS 里唯一向高地址增长的段。堆当前最高的合法地址叫 Program Break(brk/sbrk系统调用就是动这个边界,sbrk(0)可以查当前值)。注意现代 glibc 已经不只靠brk分配堆了——小于某个阈值(典型MMAP_THRESHOLD = 128KB)的小块才走堆,大于阈值的分配 glibc 会改用mmap在别处单独开一块匿名映射,免得把堆顶顶得太高、产生内部碎片。
共享库:夹在堆和栈之间
所有动态链接的共享库(.so 文件,比如 libc.so、ld-linux.so)被映射在堆和栈之间的某段区域。这就是为什么同一个 printf 实现能被成百上千个进程共用——物理上一份,各自 VAS 里映射到不同(或相同)虚拟地址。
Stack(栈):从天花板往地上长
最后是 Stack(栈),函数调用、参数传递、局部变量全压在这里。和堆相反,栈向低地址增长——这是现代 CPU(x86、ARM)的约定,术语叫 Fully Descending Stack(完全递减栈)。栈"顶"逻辑上是 VAS 的最高端(用户空间的上限附近),每次函数调用栈指针往低地址挪一格、开新栈帧,return 时再挪回来,轻量到几乎没有分配开销。
这里藏着一个对多线程编程极其重要的细节:一个进程至少有一个线程(跑 main 的那个),它的栈就安在 VAS 的最顶端;可如果进程有多个线程,这些线程共享进程的整片 VAS(Text、Data、堆、共享库大家都能看见),唯独栈不共享——每个线程各有自己独立的私有栈,主线程的栈在顶端,其他线程的栈则在堆和栈之间的"空地"里由内核分配。这正是多线程编程里"局部变量天然安全、全局变量必须加锁"的物理根源:局部变量在各自私有的栈篮子里,别人看不见;全局变量在共享的 Data 段,谁都能伸手。
💡 一个能记住的类比 把进程的 VAS 想成一个四合院(沙盒):Text/Data 是地基和承重墙,建好就不动;堆是院子里临时搭的棚子,可以往院子中心(高地址)不断扩张;栈是从天花板往下挂的篮子,东西越多绳子放得越长(往低地址走)。而线程是住在这个四合院里的一家人——大家共用厨房和厕所(Text、Data、堆),但每个人有自己的私房钱藏在各自的行李箱(私有栈)里,你不能去翻别人的行李箱。
三、堆往上、栈往下:为什么方向相反
你可能会问:为什么堆和栈非得一个往上、一个往下长,不都往一个方向?答案是为了把中间那大片空闲区域留给将来,两边相向增长、在中间某处碰头才用尽,空间利用率最高。如果都往一个方向长,另一端的空间永远用不上,白瞎。
- 堆增长靠
brk系统调用抬升 Program Break:进程要更多堆,内核把堆顶边界往高地址推一页(同时在新页建页表映射)。sbrk(increment)是 libc 包装,正数增量=扩张,负数=收缩,sbrk(0)=查当前边界。 - 栈增长则是"懒"的:栈区域在 VAS 里只规定了一个上限(不能越过
TASK_SIZE - 1页的 guard),但实际不预先分配。函数调用时栈指针往低地址走、踩到一个还没映射的页,MMU 触发缺页异常,内核这时才当场分配一页给它。所以栈是"用到哪分到哪",用ulimit -s可以看到栈大小的软上限(典型 8MB),超过这个边界再踩才报栈溢出。
四、用户空间到哪为止、内核空间从哪开始
前面三节讲的全是用户空间那半边。可虚拟地址空间不止用户这一半——每个进程的 VAS 其实是"用户空间 + 内核空间"两段拼起来的(从进程视角看,内核空间就在用户空间的"正上方")。当进程切入内核(系统调用、异常),CPU 切换到内核态,地址指针就走进内核空间那半边,那里映射着内核镜像、内核数据结构、以及一大片直接映射物理内存的"线性映射区"。
用户和内核的分界线在 ARM64 上由几个宏钉死,我们查 6.19 源码:
/* arch/arm64/include/asm/processor.h:56 (6.19) */
#define TASK_SIZE_64 (UL(1) << vabits_actual)
/* arch/arm64/include/asm/processor.h:76 */
#define TASK_SIZE TASK_SIZE_64 /* (64 位任务,简化) */
/* arch/arm64/include/asm/memory.h:43,45 */
#define VA_BITS (CONFIG_ARM64_VA_BITS)
#define PAGE_OFFSET (_PAGE_OFFSET(VA_BITS))TASK_SIZE 是一个进程用户空间能寻址的上限——它就是 1 << vabits_actual,即"用 vabits_actual 这么多位地址能表达的最大值 + 1"。vabits_actual 基本等于 CONFIG_ARM64_VA_BITS(开了 KASLR 的 52 位扩展时略有不同)。ARM64 的 VA_BITS 是可配的,常见 39 位(4K/16K 页的经典默认)或 48 位:
VA_BITS = 39时,用户空间上限TASK_SIZE = 1 << 39 = 512GB,内核空间从512GB起到2^39之上的高位。VA_BITS = 48时,TASK_SIZE = 1 << 48 = 256TB,用户和内核各得 256TB。
PAGE_OFFSET 则是线性映射区(linear map / direct map)的起点——内核把物理 RAM "平铺"映射到从 PAGE_OFFSET 开始的一片虚拟地址上,这样内核想访问任意物理页,只要拿"物理地址 + 一个固定偏移"就能算出对应的虚拟地址,不用建独立页表,极快。它的确切位置随 VA_BITS 变(定义在 memory.h:45)。
⚠️ 架构差异提醒 这些数字是 ARM64 的;同一套概念在 x86_64 上对应
TASK_SIZE_MAX(典型 47 或 48 位用户地址)、PAGE_OFFSET、__START_KERNEL_map等,数值和布局都不同。本藤统一以 ARM64 为准,[x86_64]标注处给出对照。内核地址空间的精细布局(线性映射、vmalloc 区、模块区、VMEMMAP)这篇只点到,深入展开留给 mm-buddy 那一藤。
五、mm_struct:谁在替进程记住这张地图
说了这么多段和边界,那么"一个进程的 VAS 长什么样"——具体由哪些映射、每段从哪到哪、什么权限——这件事在内核里是谁记着的?答案挂在上一篇讲的 task_struct 上:每个(用户)线程的 task_struct 里有个指针 mm,指向一个叫 mm_struct 的结构体(纯内核线程没有 mm,它们的 mm 是 NULL,因为它们不享有用户空间)。
mm_struct 就是"一个进程地址空间"的内核化身。它里头记着:页表根指针(pgd,指向顶级页表)、代码段/数据段/堆/栈的起止边界(start_code/end_code/start_data/end_data/start_brk/brk/start_stack)、所有 mmap 映射区域组成的红黑树和链表(每棵节点是一个 vm_area_struct,简称 VMA,描述"这一段虚拟地址对应哪种映射")、以及这块地址空间一共占了多少内存(total_vm)等统计。
/* 关联上一篇:current 指向当前 task_struct,它的 mm 就是当前进程地址空间 */
struct mm_struct *mm = current->mm;
pr_info("code: 0x%lx ~ 0x%lx\n", mm->start_code, mm->end_code);
pr_info("heap: brk=0x%lx\n", mm->brk);
pr_info("stack: 0x%lx\n", mm->start_stack);所以"进程以为自己独占内存"这个幻觉的物理载体,就是这套 task_struct → mm_struct → 页表 → 物理页 的链条:每个进程一份 mm_struct、一份页表,MMU 按各自的页表把虚拟翻译成物理,翻译不到就缺页,翻译到了就隔离访问——沙盒就是这么筑起来的。mm_struct 的细节、VMA 怎么管理、页表怎么走,都属于 layer-1 内存管理藤的领地,这里先建立"它存在、它挂在 current->mm 上"的认知就够了。
六、内核地址空间简览(点到为止)
最后用一句话带过内核空间那半边的布局,免得读者心里空一块。ARM64 的内核虚拟地址空间里(从 PAGE_OFFSET 往上的高位)大致排着:
- 线性映射区(linear map):从
PAGE_OFFSET起,物理 RAM 一比一平铺,内核访问物理内存的主通道。 vmalloc区:内核动态分配大块虚拟连续(但不一定物理连续)内存的地盘,vmalloc()就往这儿要。- 模块区:
MODULES_VADDR ~ MODULES_END(memory.h:48/46),我们加载的.ko模块就映射到这一小段——这也是为什么前面几篇的模块代码跑在这片地址上。 - VMEMMAP:
VMEMMAP_START(memory.h:50)起,是struct page数组的家,每个物理页对应一个struct page,全部排在这里供内核按页号查。 - 内核镜像(KImage):
vmlinux加载的位置,KIMAGE_VADDR附近。
这些区的精确边界随 VA_BITS 和页大小变,展开是 mm 藤的活。本篇只让你知道"内核空间也不是一团混沌,它一样分了区,模块区是跟我们写 .ko 最相关的那块"。
七、实战观察:/proc/<pid>/maps 把地图摊给你看
讲了半天布局,怎么亲眼看到它?最直接的工具是 /proc/<pid>/maps(或 /proc/self/maps 看自己),它把目标进程当前所有 VMA 段逐行列出来。一个跑在 BusyBox 里的进程,maps 长这样:
00000000_aaaa0000-00000000_aaaa1000 r-xp 00000000 00:00 0 [...text...]
00000000_aaab0000-00000000_aaac0000 rw-p 00000000 00:00 0 [heap...]
...
0000ffff_xxxx0000-0000ffff_xxxx1000 r-xp ... [共享库]
0000ffff_yyyy0000-0000ffff_yyyyf000 rw-p ... [stack]⚠️ 待亲测:上面是 ARM64
maps的格式示意(地址带 ARM64 64 位的高位0x0000_.../0xffff_...前缀)。我们会在 QEMU 里cat /proc/self/maps,贴回 BusyBox 进程真实的段布局——尤其要看 Text 的r-xp、Heap 的rw-p和[heap]标注、Stack 在最高地址带[stack]、以及用户空间上限如何对应本篇第四节的TASK_SIZE。
每行五列:起止地址、权限(r/w/x/p,p=私有、s=共享)、文件内偏移、设备号/inode、映射来源(文件路径或 [heap]/[stack]/[anon:...])。把它从上到下读一遍,就等于把这个进程整张 VAS 地图从头走了一遍——第二节的那些抽象段,在这里全是活生生的数据行。
动手试试
这一篇概念为主,动手侧重"观察"——用现成的
/proc接口把抽象布局验成具体数据。example 骨架(读current->mm的极简模块)随亲测补到example/mini/。
- QEMU 启动后,在 BusyBox shell 里
cat /proc/self/maps,对照本篇第二节从低到高把每一段认出来:空指针陷阱页(最低地址不可访问区)、Text(r-xp)、Data/BSS(rw-p)、[heap]、共享库、最高端的[stack] - 同样
cat /proc/self/maps里找到栈段,确认它在 VAS 的最高端附近(对照第四节TASK_SIZE),确认堆段地址低于栈段(堆往上、栈往下) - 跑一个会递归很深的 BusyBox 命令(或写个简单测试程序),用
cat /proc/<pid>/status里的VmStk(栈大小)观察栈增长 - 进阶(内核侧):写一个极简模块,
insmod时打印current->mm->start_code/end_code/start_brk/brk/start_stack,与/proc/self/maps的对应字段对拍,验证"mm_struct记的就是这张地图" - 思考题:两个 BusyBox 进程
cat /proc/<pid1>/maps和/proc/<pid2>/maps,它们的 Text 段虚拟地址很可能相同——但物理上是同一份吗?结合本篇第一节"共享 Text 段"和第四节"页表隔离"想清楚
延伸阅读
- 源码(本仓库
third_party/linux/,6.19.9):arch/arm64/include/asm/processor.h:56的TASK_SIZE_64、arch/arm64/include/asm/memory.h:43-51的VA_BITS/PAGE_OFFSET/MODULES_VADDR/VMEMMAP_START是 ARM64 地址空间布局的根;include/linux/mm_types.h的struct mm_struct看内核如何描述一个进程地址空间;include/linux/sched.h里task_struct->mm字段(约:820起的定义中)看它怎么挂上去。 - 命令行:
man proc,重点/proc/[pid]/maps、/proc/[pid]/status、/proc/[pid]/smaps三节,是把 VAS 看活的日常工具;man mmap、man brk看用户态分配虚拟内存的两个底层入口。 - 关联本站:本篇承接 10-process-thread-kernel——
task_struct是"执行流身份证",mm_struct就是它怀里那张"地址地图";VAS 的内核侧细节(线性映射、页表、struct page、VMEMMAP)在 mm 藤展开,从 mm-buddy 起步;而栈尺寸、内核栈的紧约束,上一篇第六节已结合 ARM64THREAD_SIZE讲过。