进程地址空间
概述
示例代码
图示
内核空间
- 内核空间是什么
- 内核空间是进程虚拟地址空间的高地址部分,在
64位Linux上位于0xFFFF800000000000以上,占128TB
- 内核空间是进程虚拟地址空间的高地址部分,在
- 它和用户空间的根本区别不是"地址高低",而是页表属性:
- 内核空间的页表项
Supervisor位为 1,CPU在Ring 3(用户态)执行时硬件直接拒绝访问这些地址,触发缺页异常,OS再转化为SIGSEGV
- 内核空间的页表项
-
里面住着什么
- 内核自己的代码和数据(调度器、内存管理、网络栈)住在这里,这和用户进程的
.text/.data性质相同,只是权限更高 - 每个进程/线程各有一个内核栈(
32-linux8kb,64-linux16kb),进入内核态时rsp自动切换到这里,用户栈和内核栈完全独立 - 物理内存直接映射区是内核最重要的区域之一,全部物理
RAM以固定偏移映射到这里,内核不需要额外的ioremap就能通过虚拟地址直接读写任意物理页 vmalloc区域用于内核动态分配的大块内存,以及加载内核模块的代码
- 内核自己的代码和数据(调度器、内存管理、网络栈)住在这里,这和用户进程的
-
进入内核的唯一合法途径:用户代码不能"跳进"内核,只能通过三条受控的门:
- 系统调用(主动,程序执行
syscall指令请求内核服务) - 硬件中断(被动,网卡/键盘等设备触发
IRQ) - 异常(被动,缺页、除零等
CPU检测到的错误) - 三条路都由硬件强制跳转到内核预先注册好的入口点,不存在自由跳转
- 系统调用(主动,程序执行
栈
- 栈是由
CPU指令直接驱动的,无需操作系统参与,分配和释放都在纳秒级 - 特点
- 栈是后进先出(
LIFO)结构,由CPU的rsp/rbp寄存器直接管理,不需要操作系统参与 - 每次函数调用压入一个栈帧,函数返回时弹出,分配就是
sub rsp, N一条指令,速度极快
- 栈是后进先出(
- 重要的陷阱:返回局部变量的地址
- 函数一旦返回,那块栈帧就"销毁"了(实际上只是
rsp移回去了,内容还在,但随时会被下一次调用覆盖),返回的指针就成了悬空指针
- 函数一旦返回,那块栈帧就"销毁"了(实际上只是
- 常见问题:无限递归
- 每层调用都要压栈,栈空间默认只有
8MB,递归太深直接踩到guard page,触发SIGSEGV,也就是stack overflow
- 每层调用都要压栈,栈空间默认只有
堆
- 堆由用户态分配器(
Linux上是ptmalloc)管理,分配器维护一条空闲链表,new时先查链表有没有合适的空闲块,有就直接用,没有才通过sbrk()或mmap()向OS申请- 超过
128KB的大块直接走mmap,不走brk指针,分配在独立的地址区域
- 超过
- 堆内存的生命周期完全由程序员控制,这带来了灵活性,也带来了四种经典问题:
- 内存泄漏(
new没有对应delete) double free(delete同一块两次,破坏分配器内部链表)- 悬空指针(
delete之后继续访问) - 堆溢出(越界写破坏相邻块的
header,导致下次new/delete时崩溃,而不是溢出的那一刻崩溃,这使得bug极难定位)
- 内存泄漏(
bss段
- 存放没有初始值(或初始值为
0)的全局变量和静态变量 - 文件里只记录一个数字:这块区域有多大
- 程序加载时,
OS直接mmap一块全零的匿名内存,不需要从文件读任何东西
- 程序加载时,
- 这就是为什么一个有
int arr[10000000] = {0}的程序,编译出来的可执行文件很小,但运行时占40MB内存
|
1 2 3 |
int g2; // .bss,文件里只记 size+=4 int arr[1000]; // .bss,文件里只记 size+=4000,而不是存 4000 个 0 int g3 = 0; // 也进 .bss,0 和没初始化等价,没必要占文件空间 |
data段
- 存放有初始值的全局变量和静态变量
- 初始值必须存在可执行文件里,程序加载时由
OS原样复制到内存 - 本质是:
- 初始值在文件里占了真实空间,加载时原样搬进内存,之后可读可写
|
1 2 |
int g = 42; // .data,文件里存着 0x0000002A 这 4 个字节 static int s = 1; // .data,函数内的 static 也在这里 |
rodata段
- 只读数据段,和
.data的本质区别在于"加了写保护"- 核心就是"只读",
OS在页表层面加了写保护,所以vtable才能安全地被所有对象共享
- 核心就是"只读",
- 主要内容:
- 字符串字面量
"hello"存在这里,const char* p = "hello"中p这个指针变量本身在.data,但它指向的字节序列68 65 6C 6C 6F 00在.rodata,所以p[0] = 'H'直接崩溃 vtable也存在这里,这也是为什么vtable不可能被运行时意外破坏,所有同类对象放心共享同一份表const全局变量、编译器生成的switch跳转地址数组都在此
- 字符串字面量
text段
- 存放编译后的机器指令,权限是"可读+可执行,不可写"
- 主要内容:
- 每个函数的机器码顺序排列,函数名到地址的映射由符号表记录(调试用)
- 普通函数调用时编译器把地址硬编码进
call指令 - 虚函数调用时地址存在
.rodata的vtable里,运行时再取出来call rdx,这就是多态的底层代价,比直接调用多了两次内存读取 __cxa_pure_virtual也在.text,它是运行时库注入的一个函数,纯虚函数槽位指向它,调用即abort()
ELF header
- 可执行文件最开头的
64字节(64位) - 见下文
可执行文件
概述
图示
ELF header
概述
ELF(Executable and Linkable Format)header是可执行文件最开头固定的64字节(64位),是整个文件的"目录页"OS加载器只靠这64字节就能知道去哪里找一切
三组字段
e_ident[16]是身份信息,前4字节是魔数7F 45 4C 46,OS加载时第一件事就是验证它,不匹配直接返回ENOEXEC拒绝执行- 接下来一字节
EI_CLASS决定是32位还是64位,这一个字节会影响后续所有地址字段的宽度 - 再下一字节
EI_DATA是字节序,必须在解析任何其他字段前先读它 e_type和e_machine描述文件种类和目标CPUET_EXEC是普通可执行文件,ET_DYN是共享库(.so),e_machine = 0x3E表示x86-64
- 最重要的三个导航字段是
e_entry(程序入口地址,加载完成后跳到这里,对应_start函数)e_phoff(程序头表在文件中的偏移,OS用它找到各个LOAD段并mmap进内存)e_shoff(节头表偏移,链接器和调试器用,运行时不需要,可以strip掉)
两张表的职责分工
- 程序头表(
PHT)是运行时加载的蓝图,按"段"组织,一个LOAD段可能横跨.text + .rodata两个节,OS按段权限统一mmap - 节头表(
SHT)是编译链接的目录,按"节"组织,描述每个.text、.data、.symtab的名字、大小、类型,strip命令删除的就是这张表,删完之后程序照样运行,只是没法用gdb查符号了
完整字段
ELF 文件整体结构
魔数的字节级细节
一些问题
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ Linux 高性能服务器编程:TCP二11/24
- ♥ STL_vector05/02
- ♥ vim编辑器的配置03/18
- ♥ Linux fork&&守护进程03/30
- ♥ Linux 高性能服务器编程:高级I/O函数11/28
- ♥ gcc/g++编译器03/21













