Linux系统编程·进程地址空间_#include

你好,我是安然无虞。

文章目录

  • ​​自学网站​​
  • ​​三个验证​​
  • ​​虚拟地址引入​​
  • ​​进程地址空间​​
  • ​​练习题​​

自学网站

推荐给老铁们两款学习网站:
面试利器&算法学习:​​​牛客网​​​ 风趣幽默的学人工智能:​​人工智能学习​

三个验证

我们之前在C语言上面所学习的程序地址空间,是内存吗?
其实不是内存,而且也不应该叫做程序地址空间,应该叫进程地址空间,这是操作系统上的概念
进程地址空间如下图所示分布:

Linux系统编程·进程地址空间_c++_02


好,下面我们进行验证:

验证一:进程地址空间验证

#include <stdio.h>
#include <stdlib.h>

int un_g_val;
int g_val=100;

int main(int argc, char *argv[], char *env[])
{
printf("code addr : %p\n", main);
printf("init global addr : %p\n", &g_val);
printf("uninit global addr: %p\n", &un_g_val);
//注意哦,m1是在栈上开辟的局部变量,&m1表示的是栈空间的地址,而m1表示的是堆空间的地址
char *m1 = (char*)malloc(100);
char *m2 = (char*)malloc(100);
char *m3 = (char*)malloc(100);
char *m4 = (char*)malloc(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);
//命令行参数
for(int i = 0; i < argc; i++)
{
printf("argv addr : %p\n", argv[i]); //argv/&argc?
}
//环境变量
for(int i =0 ; env[i];i++)
{
printf("env addr : %p\n", env[i]);
}
}

Linux系统编程·进程地址空间_运维_03

验证二:验证堆和栈增长方向的问题

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[], char *env[])
{
char *m1 = (char*)malloc(100);
char *m2 = (char*)malloc(100);
char *m3 = (char*)malloc(100);
char *m4 = (char*)malloc(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);

return 0;
}

Linux系统编程·进程地址空间_#include_04


很明显:堆的地址逐渐增大,栈的地址逐渐减小。

验证了堆区向地址增大的方向增长,栈区向地址减小的地方增长,堆栈是相对而生的

所以一般在C函数中定义的变量,通常是在栈上保存的,那么先定义的变量一定是地址比较高的

验证三:如何理解 static 变量
函数内定义的变量用 static 修饰时,变量作用域不变,但是生命周期变长了,本质是因为编译器会把该变量编译进全局数据区,我们用之前的代码验证一下:

#include <stdio.h>
#include <stdlib.h>

int un_g_val;
int g_val=100;

int main(int argc, char *argv[], char *env[])
{
printf("code addr : %p\n", main);
printf("init global addr : %p\n", &g_val);
printf("uninit global addr: %p\n", &un_g_val);

//注意哦,m1是在栈上开辟的局部变量,&m1表示的是栈空间的地址,而m1表示的是堆空间的地址
char *m1 = (char*)malloc(100);
char *m2 = (char*)malloc(100);
char *m3 = (char*)malloc(100);
char *m4 = (char*)malloc(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("s stack addr : %p\n", &s);
//命令行参数
for(int i = 0; i < argc; i++)
{
printf("argv addr : %p\n", argv[i]); //argv/&argc?
}
//环境变量
for(int i = 0 ; env[i];i++)
{
printf("env addr : %p\n", env[i]);
}
}

Linux系统编程·进程地址空间_虚拟地址_05

虚拟地址引入

#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>

int g_val = 0;

int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
while(1)
{
printf("我是子进程pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}else{
//父进程
while(1)
{
printf("我是父进程pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(2);
}
}

return 0;
}

Linux系统编程·进程地址空间_#include_06

上面的实验结果说明了: 当父子进程没有人修改全局数据的时候, 父子进程是共享该数据的.
这个其实我们也很好理解, 因为子进程本来就是按照父进程为模板.
但是如果我们将代码稍加改动呢:

#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>

int g_val = 0;

int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
while(1)
{
g_val = 100;//子进程修改数据
printf("我是子进程, 全局数据我已经修改完了, 请注意查看\n");
printf("我是子进程pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}else{
//父进程
while(1)
{
printf("我是父进程pid: %d, ppid: %d, g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(2);
}
}

return 0;
}

Linux系统编程·进程地址空间_c++_07


上面代码里我们在子进程中对全局数据进行了修改, 我们看到的结果是父子进程读取的是同一个变量(因为地址一样), 但是在后续没有人修改的情况下, 父子进程读取到的内容都不一样.也就是说, 同一块地址, 父子进程读取到的内容是不一样的

是不是很奇怪, 所以我们在C/C++中使用的地址, 绝对不是物理地址, 如果是物理地址, 这种现象是不可能产生的.

既然不是物理地址, 那是什么呢? 是虚拟地址, 线性地址, 逻辑地址(这三个表示的是同一个意思)

可能有老铁会好奇, 为什么操作系统不直接让我们使用物理内存(物理地址)呢?
因为我们的内存就是一个硬件, 硬件是不能阻拦你访问的, 只能被动地进行读取和写入, 所以为了保护内存, 故而设计出了虚拟地址.

进程地址空间

每一个进程在启动的时候, 都会让OS给它创建一个地址空间, 该地址空间就是进程地址空间.

Linux系统编程·进程地址空间_#include_08


每一个进程都会与一个自己的进程地址空间, 那么OS要不要管理这些进程地址空间呢? 当然,还记得吗, 先描述, 再组织. 所以进程地址空间其实就是内核的一个数据结构 struct mm_struct

究竟什么是地址空间呢?
独立性是进程的特性之一, 进程相关的数据结构是独立的, 进程的代码和数据也是独立的.

所谓的地址空间, 其实就是操作系统通过软件的方式, 给进程提供一个软件视角, 认为自己会独占所有资源(内存).

(想想十亿身家的富翁和三个私生子的故事, OS就是富翁, 三个私生子就是进程, 富翁给三个私生子画的大饼: 进程地址空间, 是逻辑上抽象出来的概念, 让每一个进程都认为自己是独占系统中所有资源的! 现实中的我们其实人人都是’‘私生子’', 银行就是最大的富翁.)

Linux系统编程·进程地址空间_虚拟地址_09

注意观察上图哦, 我们对其进行分析:
task_struct 里面有一个指针指向 mm_struct, 图中的页表可以映射, 将内存中的物理地址映射到进程地址空间的虚拟地址, 程序加载到内存, 程序变成进程之后, 由OS给每一个进程构建一个页表结构.
没改变数据之前呢, 原本的 g_val 在物理内存中是共用同一块地址的, 但由于子进程对 g_val 进行写入操作, 为了进程的独立性, 而不影响父进程, 所以为了子进程的 g_val 重新分配了一块空间, 故而页表映射到物理地址发生变化, 但是映射到虚拟地址不变(也就是常说的写时拷贝). 所以出现了上面的现象, 虚拟地址一样, 物理地址不一样.
通过页表, 将父子进程的数据按照写时拷贝的方式进行了分离, 做到了父子进程具有独立性.

此时我们再回头来看, ​​fork() 有两个返回值, pid_t id = fork(), 同一个变量 id, 怎么会有不同的值呢?​

pid_t id 是属于父进程栈空间中定义的变量, fork() 内部, return 会被执行两次, return 的本质就是通过寄存器将返回值写入到接收返回值的变量中. 所以当 id = fork() 的时候, 谁先返回, 谁就要发生写时拷贝, 所以同一个变量会有不同的内容值, 本质是因为大家的虚拟地址是一样的, 但是对应的物理地址是不一样的.

下面我们来谈谈虚拟地址空间的每个区域的划分:

什么叫做区域呢?

我们之前在小学时代, 可能会在桌子上画三八线 (哈哈, 懂的都懂)

Linux系统编程·进程地址空间_#include_10


我们对桌子进行区域划分:

struct desktop_area
{
//注意哦, start和end可以根据需要进行移动
int start;
int end;
};

struct desktop_area girl_area = {1, 50};
struct desktop_area boy_area = {1, 50};

注意哦, 每个区域范围, 都是有对应编号的, 上面的桌子我们以cm为单位.

所以有了上面的概念, 我们再回头对虚拟地址空间进行理解:
它是不是也可以像下面这样划分呢?

struct mm_struct
{
//代码区
long code_start;
long code_end;

//已初始化数据区
long init_start;
long init_end;

//未初始化数据区
long uninit_start;
long uninit_end;

//堆区
long heap_start;
long heap_end;

......
};

为什么要有虚拟地址空间, 直接使用物理地址不行吗, 何必这么麻烦呢?

  1. ​有了地址空间的存在, 我们在访问内存时添加了一层软硬件层, 可以对转换过程进行审核, 非法的访问就可以直接拦截了, 也就起到了保护内存的作用​​;
  2. ​有了地址空间的存在, 每一个进程都认为自己是独占内存空间的, 这样能更好的实现进程的独立性以及根据需要时再在内存中开辟空间, 可以做到合理地使用内存, 也就是说, 通过地址空间能使Linux进程管理与内存管理解耦或分离​​;
  3. ​有了地址空间的存在, 让进程和程序可以以统一的视角来看待内存, 方便以统一的方式来编译和加载所有的可执行程序, 从而简化进程本身的设计与实现​​.

下面我们再谈一个问题, 这个问题了解即可.

1.程序被编译出来, 没有被加载的时候, 程序内部有地址吗?
答: 当然有(想想链接)

2.程序被编译出来, 没有被加载的时候, 程序内部有区域吗?
答: 有的

说明: 程序内部的地址和内存的地址是没有关系的, 编译程序的时候, 就认为程序是按照 0000~FFFF 进行编址的, 虚拟地址空间不仅仅是OS会考虑, 编译器也会考虑.

好的, 现阶段我们对创建进程的理解:
​​​一个进程的创建实际上会伴随着task_struct, mm_struct 以及页表的创建​​.

练习题

1.在CPU和物理内存之间进行地址转换时,( )将地址从虚拟(逻辑)地址空间映射到物理地址空间
A.TCB
B.MMU
C.CACHE
D.DMA

解析:
TCB 线程控制块;

MMU 内存管理单元,一种负责处理中央处理器(CPU)的内存访问请求,功能包括虚拟地址到物理地址的转换(即虚拟内存管理)、内存保护、中央处理器高速缓存的控制;

CACHE 高速缓存;

DMA 直接内存存取

2.一个分页存储管理系统中,地址长度为 32 位,其中页号占 8 位,则页表长度是__。
A.2的8次方
B.2的16次方
C.2的24次方
D.2的32次方

解析:
页号即页表项的序号,总共占8个二进制位,意味着页表项的个数就是2^8