Linux操作系统:再谈虚拟地址空间
目录
前言:
一、编译与链接
二、ELF加载
三、再谈虚拟地址空间
四、动态库的加载
总结:
前言:
我们在上篇文章中,已经为大家介绍了动静态库的相关知识。那么本篇文章,将会为大家带来ELF加载与虚拟地址空间的联系等相关内容,希望对大家有所帮助!!
废话不多说,我们直接进入主题。
一、编译与链接
我们都知道编译和链接这两个步骤,在Windows下被我们的IDE封装的很完美,我们⼀般都是⼀键构建⾮常⽅便, 但⼀旦遇到错误的时候呢,尤其是链接相关的错误,很多⼈就束手无策了。
在Linux系统中,将源代码转换为可执行程序通常需要四个主要步骤:
-
预处理:处理源代码中的宏定义、头文件包含等
-
编译:将预处理后的代码转换为汇编代码
-
汇编:将汇编代码转换为机器码(目标文件)
-
链接:将一个或多个目标文件合并为最终的可执行文件
而在编译之后,就会生成拓展名为.o的文件,这个叫做目标文件。目标文件是一个二进制文件,文件的格式是ELF,是一种对二进制代码的封装。
#include<stdio.h>void test(){printf("hello world");
}int main(){test();return 0;
}
我们可以使用以下命令使其编译成一个目标文件:
同时,我们可以使用file命令来查看一个文件的类型:
可以看见,目标我呢间的类型,正是ELF,那么什么是ELF文件呢?
二、ELF加载
⼀个ELF文件由以下四部分组成:
我们可以通过readelf命令来查看:
。
所以我们多个目标文件,链接形成一个可执行文件时,是怎么形成的呢?
他们都是ELF文件,都有着一样的结构,我们只需要把相同结构里的数据进行合并,就生成了一个可执行文件的ELF文件
三、再谈虚拟地址空间
我们以前曾经谈论了有关虚拟地址空间的概念,说我们%p打印出的地址,都不是真正的物理地址,而是虚拟的地址空间。
但是当时还是说的太浅显了,现在我们给大家带来更具体的说法。
我们都知道,没打开的文件存储在磁盘上,等待着被记载到物理内存, 进程通过task_struct,找到mm_struct,通过页表映射,就能找到物理地址上的内容。
这是我们已经梳理清楚的一条完整的线路。
那么,假如我们要加载一个文件到物理地址上,他的加载过程与虚拟地址空间有什么关联呢?
我想提出两个疑问:
1、mm_struct,是由谁初始化的?
2、可执行程序编译好后,没有加载到内存的时候,是否有自己的地址呢?
我先来回答第二个问题:
答案是,有的!
如图,这是我们上面那个test.o链接形成的可执行文件的反汇编,可以看到,它内部其实是含有地址空间的。
在我们对可执行文件进行编址的时候,我们一般都是采用的平坦模式,而随之带来的效果就是,编出来的地址,逻辑地址=起始地址+偏移量,而起始地址全部为0.
也就是说,逻辑上的地址只会与偏移量有关。
从而允许程序将整个内存空间视为一个连续的、线性的地址空间,也就是说,地址就是从00000000.....开始,一直到FFFFFFFFFFFFF....,这样的形式,不就正是我们的虚拟地址空间吗?
所以,虚拟地址不仅仅是你在操作系统内形成进程形成的,而是在编译器编译时就已经形成的。
所以虚拟地址与编译器也有关系!!!!!
这句话就是我们想告诉大家的事情。
你的mm_struct怎么来的?
进程在新建时要加载可执行程序,加载可执行程序的时候就要初始化地址空间。
请问,初始化地址空间时,你的正文代码初始化未初始化,你的各种数据区的字段从哪里来的?
:从可执行程序中,加载可执行程序的各个数据节的具体地址得来的,所以形成了mm_strcut。
这里的0x1060,就是我们test可执行文件的入口地址啊!
而我们CPU在执行程序的时候,都用的是什么地址呢?
答案是虚拟地址,为什么可以使用虚拟地址来运行程序呢?
:页表
页表的映射关系怎么来的呢?
:把可执行加载到内存时,每个语句就放在他的物理地址上。此时,我们加载到内存前有虚拟地址,加载到内存上有了物理地址,就可以开始完善页表了
在CPU内部,有着cr3寄存器与MMU硬件,rr3寄存器会帮我们找到页表起始地址,你觉得保存的是什么地址?(cr3是操作系统自己用的寄存器不会暴露给你 )
:物理地址
随后MMU硬件加cr3就可以进行查表了。
我们最后可以得出结论:虚拟地址,是操作系统,CPU,编译器共同协作下的产物。
四、动态库的加载
在Linux内核中,每个进程的虚拟地址空间都由一个关键的数据结构mm_struct
来管理。这个结构体远比我们最初想象的复杂,它不仅保存了地址空间的边界信息,还通过精巧的链式结构实现了高效的内存管理。
struct mm_struct {struct vm_area_struct *mmap; // VMA链表头struct rb_root mm_rb; // VMA红黑树根pgd_t *pgd; // 页全局目录(Page Table)unsigned long start_code, end_code; // 代码段边界unsigned long start_data, end_data; // 数据段边界unsigned long start_brk, brk; // 堆边界unsigned long start_stack; // 栈起始地址struct list_head mmlist; // 全局mm_struct链表 };
mmap
是一个指向 虚拟内存区域(VMA)链表 的头节点的指针,用于:
-
串联所有内存映射区域:包括代码段、数据段、堆、栈、动态库映射、文件映射等。
-
提供线性遍历能力:内核可以通过该链表顺序访问所有 VMA,执行批量操作(如内存回收、权限修改)。
每个VMA代表进程地址空间中一段连续的虚拟内存区域,其定义如下:
struct vm_area_struct {unsigned long vm_start; // 起始虚拟地址(包含)unsigned long vm_end; // 结束虚拟地址(不包含)struct vm_area_struct *vm_next; // 指向下一个 VMA(单链表)struct rb_node vm_rb; // 红黑树节点(用于快速查找)pgprot_t vm_page_prot; // 访问权限(如 READ/WRITE/EXEC)unsigned long vm_flags; // 标志位(如 VM_SHARED、VM_IO)struct file *vm_file; // 关联的文件(若为文件映射)// ... 其他字段(如反向映射、操作函数集等) };
所以,我们的虚拟地址空间,哪些被用到了,实际上都会形成个链表,按照一定顺序串连起来进行管理,所有 VMA 按 虚拟地址升序 通过 vm_next
指针串联,形成单向链表。
动态库的加载过程完美体现了Linux内存管理的精妙设计,过程是一样的,由磁盘加载到内存中,虚拟地址与物理地址形成页表。
但是我们说动态库是共享库,可以多个进程同时访问,动态库的加载,本质上起始就是新建了一个节点,里面有该动态库的起始地址与结束地址,还有各种调用的虚拟起始地址。随后把这个节点连入了该链表中,就做到了共享。
在我们加载的动态库的时候,会有一个叫做动态链接器的东西,会解析符号(如 printf
),通过 mm_struct.mm_rb
红黑树快速定位包含该符号的库 VMA,随后计算符号在 VMA 中的偏移量,得到最终虚拟地址。
-
文件映射:动态链接器通过
mmap
将库文件映射到虚拟地址空间 -
VMA创建:为每个库段(代码段、数据段等)创建新的VMA
-
结构更新:
-
将新VMA插入
mmap
链表(保持地址有序) -
同步更新红黑树
mm_rb
-
-
符号解析:通过红黑树快速定位符号所在VMA
-
页表建立:内核建立虚拟地址到物理内存的映射
我们描述可能太过抽象,大家听不懂,这很正常,我们可以举一个例子为大家描述一下这个过程,大家体会一下这个例子就行了,不需要了解很清楚:
想象你有一个大仓库(虚拟地址空间),里面放着各种货物(内存区域)。仓库有个管理员叫老张(mm_struct),他手里拿着:
-
一个记事本(链表):按顺序记录每个货物区的起始和结束位置
-
一个智能地图(红黑树):能快速找到某个位置的货物
每个货物区有个标签(vm_area_struct),写着:
-
起始和结束位置(如A区:1号架-100号架)
-
货物类型(代码、数据、堆、栈等)
-
使用规则(可读?可写?可执行?)
当多个车间(进程)要用同一批原料(动态库):
-
老张在记事本上新增一条记录(创建VMA)
-
实际货物仍放在中央仓库(物理内存只存一份)
-
各车间用自己的记事本记录位置(每个进程有自己的VMA链表)
有个专门的送货员(动态链接器)负责:
-
查清单(.dynamic段)看需要哪些原料
-
去仓库取货(加载动态库)
-
贴位置标签(符号重定位)
-
第一次用才拆箱(延迟绑定)
总结:
本文的内容有些过于硬核,对于新手来说肯定看不懂(因为我讲的也很差),大有兴趣可以下来对该过程进行更加深入的探讨,但本篇文章你只需要理解1,2,3节的知识就行了。
如果有讲的不对的地方欢迎大家进行讨论指正!!