本篇博客来认识一下linux下程序地址空间的概念
演示所用系统:CentOS 7.6
1.引入程序地址空间
之前学习C/C++ 的时候,多少应该都听过栈区/堆区/静态区/全局区的概念,还有一张很经典的演示图,大部分讲解这几个内存区域的图片都和下图类似
但是有一个问题,这里的程序地址空间,是我们的物理内存上的东西吗?
并不是!
- 程序/进程地址空间是操作系统上的概念,它和我们物理内存本身不是一个东西
1.1 验证不同区域
用下面这个代码来简单验证一下不同区域上的区别
#include<stdio.h>
#include<stdlib.h>
int un_global_val;
int global_val=100;
int main(int argc, char *argv[], char *env[])
{
printf("code addr : %p\n", main);
printf("init global addr : %p\n", &global_val);
printf("uninit global addr: %p\n", &un_global_val);
char *m1 = (char*)malloc(100);
char *m2 = (char*)malloc(100);
char *m3 = (char*)malloc(100);
char *m4 = (char*)malloc(100);
int a = 100;
static int s = 100;
printf("heap addr : %p\n", m1);
printf("heap addr : %p\n", m2);
printf("heap addr : %p\n", m3);
printf("heap addr : %p\n", m4);
printf("stack addr : %p\n", &m1);
printf("stack addr : %p\n", &m2);
printf("stack addr : %p\n", &m3);
printf("stack addr : %p\n", &m4);
printf("stack addr a : %p\n", &a);
printf("stack addr s : %p\n", &s);
printf("\n");
for(int i = 0; i < argc; i++)
{
printf("argv addr : %p\n", argv[i]);
}
printf("\n");
for(int i =0 ; env[i];i++)
{
printf("env addr : %p\n", env[i]);
}
return 0;
}
通过上面的测试,可以看到其结果和文章最开始的那张图相同。这里解释一下向上/向下 的含义
- 向上增长:向地址增大的方向增长
- 向下增长:向地址减小的方向增长
不过那个图片内部还少了一些东西,比如命令行参数和环境变量其实是存放在栈区之上的。补全之后的图片如下
其中我们还可以发现,栈区和堆区之间有非常大的内存空隙
heap addr : 0x1a140f0
heap addr : 0x1a14160
stack addr : 0x7ffe6671ec60
stack addr : 0x7ffe6671ec58
因为在C/C++中定义的变量都是在栈上保存的,栈向下增长,先定义的变量地址较高!
int a = 100;
static int s = 100;
关于函数中static 修饰的变量,可以看到其地址空间属于全局静态区。虽然在函数中用static 修饰是限制其只能在该函数内访问,但是该变量的声明周期是跟随整个程序的!
stack addr a : 0x7ffe6671ec44
stack addr s : 0x601048
说了这么多,我们也没看看出来程序地址空间在哪儿啊?
1.2 fork感知地址空间的存在
下面可以用一个简单的fork代码来确认程序地址空间的存在!
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
int main()
{
int test = 10;
int ret = fork();
if(ret == 0)
{
while(1)
{
printf("我是子进程%d,ppid:%d,test:%d,&test: %p\n\n",getpid(),getppid(),test,&test);
sleep(1);
}
}
else
{
while(1)
{
printf("我是父进程%d,ppid:%d,test:%d,&test: %p\n\n",getpid(),getppid(),test,&test);
sleep(1);
}
}
return 0;
}
依旧是最简单的一个fork代码,正常情况下,二者打印的结果应该是一样的!
可如果我们在子进程中修改一下test呢?
这时候就会发现一个离谱的现象:子进程和父进程打印的test值不一样,但是其地址却完全相同!
如果我们在C/C++ 中使用的地址就是物理地址,是不可能出现这种情况的!怎么可能在物理内存的同一个地址访问出两个不同的结果呢?
就好比张三和李四在同一天的同一时间去了AA路30号 这个地址,不可能会出现张三去了发现是超市,而李四去了发现是医院的情况
这便告诉我们了程序地址空间的存在,亦或者说,我们在编程中使用的地址都是虚拟地址
2.简述程序地址空间
每一个进程在启动的时候,都会让操作系统给其分配一个地址空间,这就是进程地址空间
- 以
先描述再组织 的理念,进程地址空间其实是操作系统内核的一个数据结构struct mm_struct - 之前提到过进程具有独立性,在多进程运行的时候,需要独享各种资源。而进程地址空间的作用,就是让进程认为自己是独占操作系统中的所有资源!
这个操作,其实就是操作系统给该进进程画了一个假的内存(虚拟地址)进程需要内存的时候,操作系统就会在页表里面画一个地址给他,再将该地址映射到物理内存上面
在Linux源码中可以看到这玩意的存在,其中的struct vm_area_struct * mmap; 就是一个我们的页表
这里就能看到虚拟地址空间的start和end了!
2.1 程序地址空间和代码编译
我们直到,C语言代码需要经过预处理-编译-链接-汇编 这几个步骤
- 程序编译出来,没有被加载的时候,程序内部有地址(如果没有地址,无法进行链接)
- 程序编译出来,没有被加载的时候,程序内部有区域(
readelf -s 可执行文件 可以查看区域)
[muxue@bt-7274:~/git/raspi/code/22-10-07_程序地址空间]$ readelf -S test
There are 30 section headers, starting at offset 0x19f8:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 0000000000400238 00000238
000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.ABI-tag NOTE 0000000000400254 00000254
0000000000000020 0000000000000000 A 0 0 4
[ 3] .note.gnu.build-i NOTE 0000000000400274 00000274
0000000000000024 0000000000000000 A 0 0 4
[ 4] .gnu.hash GNU_HASH 0000000000400298 00000298
000000000000001c 0000000000000000 A 5 0 8
[ 5] .dynsym DYNSYM 00000000004002b8 000002b8
00000000000000c0 0000000000000018 A 6 1 8
[ 6] .dynstr STRTAB 0000000000400378 00000378
0000000000000059 0000000000000000 A 0 0 1
[ 7] .gnu.version VERSYM 00000000004003d2 000003d2
0000000000000010 0000000000000002 A 5 0 2
[ 8] .gnu.version_r VERNEED 00000000004003e8 000003e8
0000000000000020 0000000000000000 A 6 1 8
[ 9] .rela.dyn RELA 0000000000400408 00000408
0000000000000018 0000000000000018 A 5 0 8
[10] .rela.plt RELA 0000000000400420 00000420
00000000000000a8 0000000000000018 AI 5 23 8
[11] .init PROGBITS 00000000004004c8 000004c8
000000000000001a 0000000000000000 AX 0 0 4
[12] .plt PROGBITS 00000000004004f0 000004f0
0000000000000080 0000000000000010 AX 0 0 16
[13] .text PROGBITS 0000000000400570 00000570
00000000000001e2 0000000000000000 AX 0 0 16
[14] .fini PROGBITS 0000000000400754 00000754
0000000000000009 0000000000000000 AX 0 0 4
[15] .rodata PROGBITS 0000000000400760 00000760
000000000000005e 0000000000000000 A 0 0 8
[16] .eh_frame_hdr PROGBITS 00000000004007c0 000007c0
0000000000000034 0000000000000000 A 0 0 4
[17] .eh_frame PROGBITS 00000000004007f8 000007f8
00000000000000f4 0000000000000000 A 0 0 8
[18] .init_array INIT_ARRAY 0000000000600e10 00000e10
0000000000000008 0000000000000008 WA 0 0 8
[19] .fini_array FINI_ARRAY 0000000000600e18 00000e18
0000000000000008 0000000000000008 WA 0 0 8
[20] .jcr PROGBITS 0000000000600e20 00000e20
0000000000000008 0000000000000000 WA 0 0 8
[21] .dynamic DYNAMIC 0000000000600e28 00000e28
00000000000001d0 0000000000000010 WA 6 0 8
[22] .got PROGBITS 0000000000600ff8 00000ff8
0000000000000008 0000000000000008 WA 0 0 8
[23] .got.plt PROGBITS 0000000000601000 00001000
0000000000000050 0000000000000008 WA 0 0 8
[24] .data PROGBITS 0000000000601050 00001050
0000000000000004 0000000000000000 WA 0 0 1
[25] .bss NOBITS 0000000000601054 00001054
0000000000000004 0000000000000000 WA 0 0 1
[26] .comment PROGBITS 0000000000000000 00001054
000000000000002d 0000000000000001 MS 0 0 1
[27] .symtab SYMTAB 0000000000000000 00001088
0000000000000648 0000000000000018 28 46 8
[28] .strtab STRTAB 0000000000000000 000016d0
000000000000021e 0000000000000000 0 0 1
[29] .shstrtab STRTAB 0000000000000000 000018ee
0000000000000108 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
需要注意的是,程序内部的地址,和内存的地址没有关系
可以理解为,我们程序内部都存放的是一个相对地址。编译程序的时候,认为程序是按照0000~FFFF 进行编址的。
当程序被加载到内存当中时,假设系统将该程序的代码从内存0x100 开始加载,就可以依照程序编址的数据加上这个偏移量,从而存放在内存中。
比如程序中有一个代码段的位置是0x1F ,这时候在加载程序的时候,就会把这个代码段加上偏移量来加载
代码地址 | 虚拟地址 |
---|
0x1f | 0x11f | 0x20 | 0x120 |
大概就是这样,吧哩吧啦……
2.2 写时拷贝
现在就可以来解答一下1.2 中出现的问题了
当子进程尝试修改test变量的时候,操作系统就会开始一个写时拷贝,开辟一个新的空间,将对应的值考入该空间,再重新映射页表。
这时候,虽然页表左侧的虚拟地址没有变化,但是映射的物理地址已经不一样了!
这样就能保证父子进程的独立性,谁修改变量都互不影响!
类似C++中实现的深拷贝!
fork两个返回值的解释
pid_t id 这个变量属于父进程栈空间中定义的变量,但是fork内部,return会被执行两次(return的本质是通过寄存器将返回值写入到接收返回值的变量中)
当id = fork() 的时候,谁先返回,谁就会发生一次写时拷贝。所以同一个变量有不同的内容值,本质上也是同一个虚拟地址,对应了不同物理地址的体现!
- 打印
fork 的返回值,即可观察到和1.2 中一样的情况,虚拟地址相同,但是ret的值不同
3.程序地址空间的作用
需要注意的是,内存作为一个硬件,没有办法拒绝你的读写!内存是不带控制功能的!
直接让用户修改物理内存风险极大:
- 野指针问题
- 用户可能直接修改操作系统需要用到的内存地址,导致系统boom
程序地址空间让访问内存时添加了一层软硬件层,可以对转化过程进行审核,拦截非法的访问
- 保护内存
- 可以使用进程管理更好的对功能模块进行解耦(linux内存管理)
- 让程序/进程可以用统一的方式/视角来看待内存,以统一的方式编译加载所有可执行程序,简化程序本身的设计和实现
同时,程序地址空间还可以延迟用户的内存使用。比如我们现在malloc 了100个字节的空间,实际上操作系统并不会立马给你申请空间,而是操作你的mm_struct 让进程以为自己已经申请成功了。当程序真正使用这个空间的时候,操作系统才会去物理内存中进行映射!
申请的时候,是通过linux的内存管理模块进行操作的。该模块只负责开辟内存,而不管开辟内存的用途
这种“延迟访问”,可以避免某些程序申请了内存而在一段时间内没有使用的问题!避免了内存资源的无效占用(也是一种浪费)
结语
关于这部分的理解其实并不算十分透彻,或许在日后的项目实践中能加深理解呢~
|