进程地图

点一个框看它放什么、在哪篇讲过;选一条路径,一步一步看变量落在哪、一块内存怎么来的。蓝字可以直接点开对应的文章,“硬件地图”会打开硬件地图上接着走的那条路。

进程地图:一个 C++ 程序的虚拟地址空间 左边是一个进程的虚拟地址空间,高地址在上(x86-64、4 级页表、开了 PIE;不按比例)。右边是这些地址怎么落到物理内存上,以及出了错落在哪一段。 蓝字 = 在哪里讲过(#N = 交易系统笔记 #N,速查 = 速查页的小节,硬件地图 = 打开硬件地图上对应的路径)。数字都是量级。 红线 = 缺页时内核往页表里填一项。 高地址 ↑ 低地址 ↓ 0xffff ffff ffff ffff 0xffff 8000 0000 0000 0x0000 7fff ffff ffff ≈ 0x7ffc … ≈ 0x7f… ≈ 0x55… / 0x56… 0x0000 0000 0001 0000 0x0 用户空间(128 TiB):每个进程一份 mmap 区 ↓:从高往低分配,下面四类都在这里 没映射的空地:堆往上长,mmap 区往下长,中间留得最大 内核空间(128 TiB):所有进程共用同一份 内核代码和数据、每个进程的页表、每个线程的内核栈(16 KiB) 系统调用、缺页、中断时,CPU 切到这里执行 还有全部物理内存的线性映射、页缓存(读写文件时缓存的文件内容) 用户态的代码读写这段地址 → SIGSEGV #19 调度策略与内核调优 非规范地址空洞(约 16 M TiB) 地址只有 48 位有效:bit 47 往上必须全和 bit 47 相同,中间这一大段不能用 主线程的栈 ↓ 局部变量、函数参数、返回地址;每调用一层,往低地址长一段 上限默认 8 MiB(ulimit -s),用超了碰到保护页 → SIGSEGV #11 返回值优化:返回的对象直接构造在调用者的栈上 [vvar] [vdso]:内核映射进来的一小段代码和数据 clock_gettime、gettimeofday 在这里直接读 TSC 算时间,不进内核 #20 1.2 clock_gettime 走 vDSO 共享库(.so) libstdc++、libc、ld.so 每个库也分代码段、数据段 代码页所有进程共用一份 从源码到进程:.so 怎么加载 大块 malloc(≥ 128 KiB) glibc 直接 mmap 一块新映射 free 时立刻 munmap 还给内核 门槛会随使用自动调高 #3 2.1 mmap 陷阱 其他线程的栈 + TLS 每个线程一块,默认 8 MiB 最低处留一页保护页 thread_local 在栈顶那头 文件映射、共享内存 mmap 文件:读写的就是页缓存 /dev/shm:几个进程映射同一批页 大页(hugetlbfs)也映射在这里 #5 2 放进共享内存 堆 ↑(brk) new、malloc 的小块从这里切:先查本线程的缓存(tcache),不够再找 arena arena 不够了,brk 把堆顶往上推;推出来的只是虚拟地址 #3 内存池与分配器 .bss:没有初值的全局变量和 static 变量 初值都是 0,文件里只记大小,启动时映射成全 0 的页 .data:有初值的全局变量和 static 变量 初值存在可执行文件里,启动时映射进来,可读可写 .rodata:只读数据 字符串字面量、有常量初值的 const 全局变量、编译期算好的查找表 vtable 在 .data.rel.ro:启动时填好函数地址,再改成只读 虚函数 1.2 vptr 和 vtable .text:机器码(只读 + 可执行) 函数编译出来的指令;.plt 是调 .so 里函数的跳板 PIE 每次加载到随机地址;gdb 默认关掉随机化,基址常见 0x5555 5555 4000 #6 2.6 函数分组:代码也走缓存 0 – 64 KiB:不映射 空指针解引用落在这里 → SIGSEGV(vm.mmap_min_addr = 65536) C++ 里写的东西放在哪 int g = 1; // .data int z; // .bss const char* s = "hi"; // s:.data,"hi":.rodata thread_local int t; // 每个线程一份(TLS) void f() { // 指令:.text int x = 0; // 栈 int* p = new int(2); // p:栈,*p:堆 char* b = new char[1 << 20]; // 1 MiB:mmap 区 } 线程:共享什么,各有什么 共享:整个地址空间,同一份页表 (代码、全局变量、堆、mmap 区、文件描述符) 各一份:栈、TLS、寄存器、内核栈 所以全局和堆上的数据要同步; 栈上的变量地址不传出去,就只有自己用 #2 内存序与缓存行 #18 线程安全队列 页表:虚拟地址 → 物理地址 每个进程一份;4 级,每级用 9 位,页内偏移 12 位 TLB 命中不额外花时间;没命中查页表约 15–30 ns 2 MiB 大页少查一级,一项 TLB 管 512 倍的地址 硬件地图:dTLB → 一次 load 走到内存 #6 2 MiB 大页 速查 2.1 TLB 覆盖范围 物理内存(内存条) 物理页零散地分在内存条上 连续的虚拟地址,背后的物理页不一定连续 同一个 .so 的代码页、/dev/shm 的页:几个进程共用 硬件地图:内存控制器 → 通道 每格 = 一页物理内存(4 KiB),颜色 = 属于左边哪一段; 白格 = 空闲或属于别的进程。同一种颜色的格子并不挨着。 缺页:第一次碰到才给物理页 new、mmap、brk 只拿到虚拟地址,页表里还没有 第一次写:陷入内核,分一页、清零、填进页表 约 0.5–2 µs;要读磁盘的(major)几十 µs 到 ms 地址不在任何映射里,或权限不对 → SIGSEGV #3 3.2 预先缺页 + mlock 出了错,落在图上哪里 空指针解引用 → 最低 64 KiB 的不映射区:SIGSEGV 递归太深、局部数组太大 → 越过栈底的保护页:SIGSEGV 改字符串字面量 → 写只读的 .rodata:SIGSEGV 越界、用已经释放的内存 → 多半还落在堆的映射里,不一定立刻崩,要用 ASan 查 内存泄漏 → 堆或 mmap 区一直涨,RSS 跟着涨;double free → glibc 检查到后 abort 在自己机器上看 cat /proc/<pid>/maps # 每一段的地址范围、权限、来源文件(r-xp 就是 .text) pmap -x <pid> # 每一段实际占了多少物理内存(RSS) size a.out # .text、.data、.bss 各多大 perf stat -e page-faults,dTLB-load-misses ./a.out # 缺页、TLB 未命中各几次 ulimit -s # 栈上限(KiB) 几个常见说法,哪里不对 “堆比栈慢”:访问一样快,都是普通内存;慢在 malloc 耗时不固定,以及对象散开以后局部性差 “用户空间 3 GB、内核 1 GB”:那是 32 位。64 位上用户空间是低 128 TiB,内核是高 128 TiB “全局区空间大”:.data、.bss 的大小链接时就定死了,要能变大的数据放堆上 “new 了就占内存”:第一次写之前不占物理页,RSS 不涨 “栈上的变量别的线程碰不到”:地址传过去就能读写,栈也在同一个地址空间里 虚拟 地址 物理地址 内核填一项 地址范围按 x86-64 Linux 的默认布局;开了 ASLR,栈、mmap 区、堆和程序本身的起点每次运行都随机挪一点。 图随文章更新:讲到 fork、动态链接、IO 时再往上补。