Linux详解——进程地址空间

Posted HinsCoder

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux详解——进程地址空间相关的知识,希望对你有一定的参考价值。

📖 前言:本节将以新的视角看的地址空间的特点,与以前对指针的认识做区分。


目录

🕒 1. C/C++ 地址空间回顾

我们以前在学习 C/C++ 的动态内存管理的时候,通常把地址空间划分为如下几个区域:

那么这个C/C++地址空间是什么,是内存吗?我们以一个例子来测试:

# Makefile
mytest:mytest.c
	gcc -o $@ $^ #-std=c99
.PHONY:clean
clean:
	rm -f mytest
// mytest.c
#include <stdio.h>
#include <unistd.h>

int global_value = 100;

int main()

    pid_t id = fork();
    if(id < 0)
    
        printf("fork error\\n");
        return 1;
    
    else if(id == 0)
    
        int cnt = 0;
        while(1)
        
            printf("I'm a child process, pid: %d, ppid: %d | global_value: %d, &global_value: %p\\n", getpid(), getppid(), global_value, &global_value);
            sleep(1);
            cnt++;
            if(cnt == 10)
            
                global_value = 300;
                printf("The child process has changed the global value\\n");
            
        
    
    else
    
        while(1)
        
            printf("I'm a parent process, pid: %d, ppid: %d | global_value: %d, &global_value: %p\\n", getpid(), getppid(), global_value, &global_value);
            sleep(2);
        
    
    sleep(1);

输出结果:
I'm a parent process, pid: 26634, ppid: 23075 | global_value: 100, &global_value: 0x60105c
I'm a child process, pid: 26635, ppid: 26634 | global_value: 100, &global_value: 0x60105c
I'm a child process, pid: 26635, ppid: 26634 | global_value: 100, &global_value: 0x60105c
I'm a parent process, pid: 26634, ppid: 23075 | global_value: 100, &global_value: 0x60105c
I'm a child process, pid: 26635, ppid: 26634 | global_value: 100, &global_value: 0x60105c
I'm a child process, pid: 26635, ppid: 26634 | global_value: 100, &global_value: 0x60105c
          								...当cnt=10后...
The child process has changed the global value
I'm a child process, pid: 26635, ppid: 26634 | global_value: 300, &global_value: 0x60105c
I'm a child process, pid: 26635, ppid: 26634 | global_value: 300, &global_value: 0x60105c
I'm a parent process, pid: 26634, ppid: 23075 | global_value: 100, &global_value: 0x60105c
I'm a child process, pid: 26635, ppid: 26634 | global_value: 300, &global_value: 0x60105c
I'm a child process, pid: 26635, ppid: 26634 | global_value: 300, &global_value: 0x60105c
I'm a parent process, pid: 26634, ppid: 23075 | global_value: 100, &global_value: 0x60105c

观察结果,对于值,子进程改变成300,父进程仍然是100可以理解,但是我们惊讶的发现,两个进程中global_val的都指向同一个地址。与我们之前理解的指针大相径庭。指针是内存中变量的地址,我们现在打印的地址同样是以指针的形式打印的,那么只能说明我们之前理解的指针是错误的,指针指向的位置并不是物理内存。因为如果是物理内存,那么不可能发生同一个地址的变量的值不相同的情况。

结论:我们所看到的打印出来的地址空间分布都是虚拟地址(又叫线性地址、逻辑地址)。 我们称这种地址为虚拟地址空间。它并不是真正的物理内存上的空间。

🕒 2. 进程地址空间

🕘 2.1 感性理解概念

设计进程的理念——进程它会认为自己是独占系统资源的(事实上并不是)

我们以虚拟机来举例子,假设我们的物理机有16G内存,预先分配给虚拟机是4G,那么这个虚拟机自己就会认为这4G就是全部,使用它浏览网页、安装软件,这些操作都不会影响到物理机因为我们的虚拟机管理软件会控制它不会干扰到物理机。同理对于一个进程,它认为自己需要16G空间,操作系统会直接给你16G的空间吗?那是不可能的,因为操作系统还要兼顾其他的进程,但每一个进程被操作系统拒绝后,也仍然会认为自己拥有全部的内存的使用权。因此,操作系统给进程画的大饼,就是进程(虚拟)地址空间

生活中也有这样的例子,比如银行存钱,我们所存的钱一定是在银行原封不动的存放着吗?事实上是不可能的,你的钱可能会被放贷,也可能会被用来理财,但你的余额在你看来仍是那些没有变化。但如果有人说银行要倒闭,这时候一旦所有人都去取钱,也就是挤兑,这时银行是不能实现将每一个人的钱都取出来的。

🕘 2.2 如何“画饼”

我们以迅雷为例

struct 进程信息

    char* name;		// 迅雷
    char* when;		// 什么时候运行与结束
    char* target;	// 下载完一部电影
    char* memory;	// 需要多少内存(假设100M)
    // 程序运行

地址空间的本质:是内核的一种数据结构
在Linux中这种数据结构叫mm_struct,操作系统会为每个进程创建一个 mm_struct 对象,然后通过管理结构体对象来间接管理进程地址空间。

🕘 2.3 区域划分

在此之前,我们需要一些预备知识:

  1. 地址空间描述的基本空间大小是字节
  2. 32位下,有232个地址。232×1字节 = 4GB的空间范围
  3. 每一个字节都有唯一的一个地址,并且都是虚拟地址(unsigned int (32bits))

根据上图我们知道,对于区域划分,就是通过改变边界的大小,从而实现地址空间的动态分配。

struct mm_struct 
    //uint32_t:32位系统下的无符号整型
	uint32_t code_start, code_end;
    uint32_t date_start, code_end;
    uint32_t heap_start, heap_end;
    unit32_t stack_start, stack_end;
    ...
;

我们上面也提到过,操作系统会给进程画大饼,也就是说,每一个进程被创建出来,形成对应的task_struct(进程控制块),都会有232次方个空间(4GB)里面包括进程的pid、ppid、进程优先级、进程属性等,每个task_struct都会对应一个mm_struct(每一个都是大饼),task_struct通过其中的指针变量指向对应的mm_struct

查阅源码后可以印证我们的结论,我们之前一直所谈的C/C++地址空间实际上是进程的地址空间。

🕒 3. 进程地址空间与内存

🕘 3.1 虚拟地址和物理地址

通过上面知识我们知道,虚拟地址是连续的,因此我们也称之为线性地址。而物理地址是数据在内存与磁盘间传输的过程(即IO),IO的单位是4KB,那么我们就将内存中4KB的大小空间看成一个page,因此对于内存的数据来说,如果内存的全部大小为4GB,那么我们可以把内存分割成4GB/4KB个page,即我们可以将内存想象为一个结构体数组:struct page mem[4GB/4KB],通过偏移量就可以访问内存中所有的page,也就可以访问到内存的所有数据。

对于这些虚拟地址,作为数据来说,也需要存放在物理地址的某一个位置,因此这就会与内存产生关联。而虚拟地址与物理地址产生关联的媒介就产生了,我们将这个媒介称之为页表

举个例子,如果内存中的某一个位置a=10,当我们编写代码时,代码的数据首先会被加载到虚拟地址中,通过页表的映射,映射到了相应的物理地址,之后就会将原有的数据修改为新的数据。

因此我们能做的,就是编辑代码让其在虚拟地址上保存,而通过页表映射到内存等其他的所有工作,都是由操作系统自动帮你完成的。

🕘 3.2 多进程的映射关系


这两个进程只能看到自己所对应的mm_struct(虚拟地址空间),就像我们前面提到的大饼,操作系统在处理这两个进程时将其编译到虚拟地址空间以及页表的过程就是操作系统给进程画的大饼,因为mm_struct都对应着2^32个地址,对于进程而言似乎可以使用全部,实际上操作系统并不允许任何一个进程完全占用所有的内存空间。

🕒 4. 地址空间存在的意义

🕘 4.1 保证安全性

进程直接访问物理内存可能会出现越界、恶意进程读取信息等非法操作,通过页表可以对非法的虚拟地址进行拦截,相当于变相的保护物理内存

🕘 4.2 保证独立性

还是开篇那个例子,为什么相同地址下父进程和子进程的数值不同呢?

当我们编译完代码生成.c文件时,数据已经存储在磁盘了,当程序运行时,其数据会被加载到物理内存中,global_val=100也就被存放在了内存的某一块地址,由于父进程和子进程都需要访问global_val,于是global的内存中的地址就会通过页表映射到虚拟空间的某一个地址中,从而正常访问global_val,并且对应的虚拟地址也是相同的,因此开始时我们能看到父进程和子进程对应的global_val的数值和地址都相同。

当子进程要改变global_val的值,由于进程与进程之间的独立性,子进程一旦要改变global_val,操作系统就会将子进程页表与内存的物理地址之间的联系断开,并在物理内存的另一个位置将原来物理地址的数据拷贝过来,这一操作被称为写时拷贝。 这样子进程改变global_val的值也不会影响到父进程的global_val。因此我们所看到的子进程与父进程的虚拟地址仍是相同的地址。

进程 = 内核数据结构 + 进程对应的代码和数据

内核数据结构是独立的,不同进程对应的代码和数据也是不一样的,因此进程就是独立的。因此我们也得出结论:地址空间的存在,可以更方便的进行进程和进程的数据代码的解耦,保证了进程的独立性。

🕘 4.3 保证统一性

  1. 在程序编译链接的时候,磁盘中的程序就有了地址,这个地址也被我们称为逻辑地址(虚拟地址)(在Linux下,虚拟地址和逻辑地址是一样的)
  2. 虚拟地址空间的规则不仅OS需要遵守,编译器同样需要遵守!也就是说在编译链接的过程中,编译器在编译你的代码的时候,就是按照虚拟地址空间的方式进行编址的。


假设这个exe是以32位地址空间编址的。在编译时,main中的fun()会通过逻辑地址跳转到定义的fun()函数,当代码加载到内存时,这个逻辑地址仍然存在,也就是程序内部使用的地址在加载到内存中时仍然存在,但当我们将代码加载到内存时,代码既然也是数据,那么就一定需要在物理内存中的某个物理地址进行保存,此时这段代码既有外部的物理地址,也有内部的逻辑地址,相当于有了两套地址。

那么,当这段代码通过页表的映射加载到进程的mm_struct时,这段代码就被存放在这个进程对应的进程地址空间中,这个过程就是物理地址通过映射传输的,那么当CPU的寄存器通过指令读取此代码时,出来的一定是虚拟地址。

当CPU找到了虚拟地址之后,就会通过页表的映射,按照来时的路线去寻找内存中的main()函数的代码,将这个实际存在的代码通过CPU读取。

由此可以得出结论:地址空间让进程以统一的视角,来看待进程对应的代码和数据等各个区域,方便编译器也以统一的视角来进行编译代码(使用和编译的统一是指都是在虚拟地址空间的统一,因为规则一样,所以编完即可使用。)


OK,以上就是本期知识点“进程地址空间”的知识啦~~ ,感谢友友们的阅读。后续还会继续更新,欢迎持续关注哟📌~
💫如果有错误❌,欢迎批评指正呀👀~让我们一起相互进步🚀
🎉如果觉得收获满满,可以点点赞👍支持一下哟~

Linux——进程概念进程创建僵尸进程孤儿进程环境变量程序地址空间详解

Linux——进程概念、进程创建、僵尸进程、孤儿进程、环境变量、程序地址空间详解

进程概念

进程基本概念

从用户角度:进程就是一个正在运行中的程序。

操作系统角度:操作系统运行一个程序,需要描述这个程序的运行过程,这个描述通过一个结构体task_struct(task_struct是Linux内核中的一种数据结构,被装载在RAM里,里面包含着进程的信息)来描述,统称为PCB,因此对操作系统来说进程就是PCB(process control block)程序控制块

进程的描述信息有:标识符PID,进程状态,优先级,程序计数器,上下文数据,内存指针,IO状态信息,记账信息。都需要操作系统进行调度。

查看进程

进程的信息可以通过ls /proc系统文件来查看(如果要获取PID为1的进程信息,通过ls /proc/1来查看):

也可以使用ps -ef -aux(较详细)指令来直接显示进程状态:

进程创建

Linux中非常重要的函数——fork(),它从已存在的进程中创建一个新进程。新进程为子进程,而原进程为父进程。
创建进程:pid_t fork(void)

为什么创建子进程?
根据需求,区分父子进程,让各自运行不同代码去解决不同问题,根据返回值不同来区分。
返回值:
父进程:返回值大于0,子进程的pid
子进程:返回值等于0
若失败则返回-1;

可明显看出子进程和父进程的输出结果不一样

总的来说:子进程复制pcb的信息,代码共享,但是子进程并非从头开始,而是从fork()函数之后开始,数据独有

网络上对fork()的理解:
(1)一个进程进行自身的复制,这样每个副本可以独立的完成具体的操作,在多核处理器中可以并行处理数据。这也是网络服务器的其中一个典型用途,多进程处理多连接请求。
(2)一个进程想执行另一个程序。比如一个软件包含了两个程序,主程序想调起另一个程序的话,它就可以先调用fork来创建一个自身的拷贝,然后通过exec函数来替换成将要运行的新程序。

进程状态

进程状态一般有:就绪态,阻塞态,运行态

在Linux下:R运行状态,S睡眠状态,D磁盘休眠状态,T停止状态,X死亡状态

这些当我们使用指令ps -aux和 ps -aux | grep status 就可以看到

僵尸进程

在进程状态中有两个比较特殊的存在:僵尸进程和孤儿进程
僵尸进程是进程退出后,但是资源没有释放,处于僵死状态的进程。

产生原因

子进程先于父进程退出,操作系统检测到进程的退出,通知父进程,但是父进程这时候正在执行其他操作,没有关注这个通知,这时候操作系统为了保护子进程,不会释放子进程资源,因为子进程的PCB中包含有退出原因。这时候因为既没有运行也没有退出,因此处于僵死状态,成为僵尸进程。

下面用代码示例说明:

z这个标志就是僵尸进程的标志,僵尸进程的危害很大,kill这个命令普通进程可杀死,但是杀不死僵尸进程。

僵尸进程的危害?

1.进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就一直处于Z状态。

2.维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话
说,Z状态一直不退出,PCB一直都要维护

3.那一个父进程创建了很多子进程,就是不回收,就会造成内存资源的浪费?因为数据结构对象本身就要占用内存,想想C语言中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间!

4.内存泄漏

那么怎么避免僵尸进程的产生?
一般处理就是关闭父进程,这样僵尸子进程也随之消失了。所以我们最好设置进程等待,等待子进程完成了工作,并且通知了父进程之后,然后再退出。

孤儿进程

父进程先于子进程退出,父进程退出后,子进程成为后台进程,并且父进程为1号进程(孤儿进程被1号init进程领养,当然要有init进程回收喽)。
特性:运行在后台,父进程成为1号进程。孤儿进程退出后不会成为僵尸进程—— 退出后父进程对其资源进行读取了

守护进程

守护进程(精灵进程):是一种特殊的孤儿进程,因为先成为孤儿进程才能运行在后台 —— 特殊(脱离了与终端的关联+会话的关联)的孤儿进程

环境变量

环境变量:保存程序运行环境的变量

相关指令:

      env:查看所有的环境变量
      set:查看环境中所有变量
      echo:打印某个指定变量的数据
      
特性:具有进程之间的传递性

export:设置环境变量
unset:删除变量

常见的环境变量:HOME SHELL USER PATH

重要的环境变量:PATH —— 程序的默认运行路径

在程序中获取环境变量的接口:

char *getenv(const char *env_name)	//接口获取
name:环境变量名称
返回值:对应name环境变量的数据,如果找不到返回NULL;


如下结果直接显示环境变量的具体内容:

如果将变量的内容名称改为MYVAL,给它设值100,echo可以得到他的值是100,但是程序运行后却没有。这时用export指令设置环境变量,因此这就是环境变量具有进程间传递性的一个应用。

程序地址空间


进程的地址空间(可以说是程序地址空间),并且每个进程都有一份,整体内存也就这么大,不可能每个进程都一样,呢怎么分配?
答:给每一个进程虚拟一个完整的地址空间

地址是什么?地址是内存的编号,指向内存的一块区域

输出结果:

我们发现,输出出来的变量值和地址是一模一样的,因为子进程按照父进程为模版,父子并没有对变量进行进行任何修改。将代码稍加改动后:

输出结果:

我们发现,父子进程,输出地址是一致的,但是变量内容不一样

得出如下结论:

变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
但地址值是一样的,说明,该地址绝对不是物理地址
在Linux地址下,这种地址叫做 虚拟地址

我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理(OS必须负责将 虚拟地址 转化成 物理地址 )

程序地址空间,本质上是操作系统为进程通过 mm_struct 描述的虚拟的地址空间,让每个进程都能访问一个完整的虚拟地址,经过映射之后,实现在物理内存上的离散存储,提高内存利用率,并且提高了内存访问控制。

内存管理

问题:如何通过虚拟地址找到物理内存中的存放位置,访问数据

内存管理方式:
分段式:将虚拟地址空间分为多个段(代码段、数据段…),分段式管理中,会给每个进 程都创建一个段表。

虚拟地址组成:短号+段内偏移

分段式内存管理方式:

取出虚拟地址中的段号,在段表中通过段号找到段表项,取出物理段起始地址,加上段内偏移量得到某个变量的实际存储地址。

分页式内存管理:
虚拟地址组成:页号+业内偏移
页表:记录了页号,权限位,缺页中断位,物理块起始地址…

在虚拟地址空间中,将整个空间划分为一个个小的分页。

分段式管理:基于使用性质划分的段,将地址空间进行分段管理,对于内存利用率并没有太大提高,但是对于程序地址管理比较友好。
分页式管理:基于存储划分的分页分块,将地址空间划分成一个个小的页面进行管理,提高了内存利用率,并且进行了权限管理,提高了内存访问控制。

以上是关于Linux详解——进程地址空间的主要内容,如果未能解决你的问题,请参考以下文章

Linux详解——进程地址空间

Linux——进程概念进程创建僵尸进程孤儿进程环境变量程序地址空间详解

Linux——进程概念进程创建僵尸进程孤儿进程环境变量程序地址空间详解

Linux——进程概念进程创建僵尸进程孤儿进程环境变量程序地址空间详解

Linux进程地址空间与进程内存布局详解,内核空间与用户空间

Linux内存模型和Linux访问用户空间内存API详解