Linux进程概念(2万字精讲)
目录
- 基本概念
- 描述进程-PCB
- task_struct-PCB的一种
- task_struct内容分类
- 组织进程
- 查看进程
- 通过系统目录查看
- 通过ps命令查看
- 通过系统调用获取进程的PID和PPID
- 通过系统调用创建进程- fork初始
- fork函数创建子进程
- 使用if进行分流
- 1为什么父进程返回子进程的 PID,子进程返回 0?
- 2. 为什么 `fork()` 函数会返回两次:
- 3.为什么 `fork()` 会返回两次,而返回值又不冲突?
- Linux进程状态
- 三大状态(进程行为的抽象描述):
- 1. 运行态 (Running)
- 2. 阻塞态 (Blocked/Waiting)
- 3. 挂起态 (Suspended)
- 运行状态-R
- 睡眠状态-S
- 深度睡眠状态-D
- 暂停状态-T
- 僵尸状态-Z
- 死亡状态-X
- 僵尸进程
- 僵尸进程的危害
- 孤儿进程
- 进程优先级
- 基本概念以及分类
- 查看系统进程
- PRI与NI
- 查看进程优先级信息
- 通过top命令更改进程的nice值
- 通过renice命令更改进程的nice值
- 四个重要概念
- 进程切换
- Linux2.6内核进程调度队列
- 一个CPU拥有一个runqueue
- 优先级
- 活动队列**(Active Queue)**
- 进程选择过程:
- **优化**:`bitmap[5]`
- 过期队列
- **过期队列的作用**:
- active指针和expired指针
- **指针交换优化**:
- 环境变量
- 基本概念
- 常见环境变量
- 查看环境变量的方法
- 测试PATH
- 测试HOME
- 测试SHELL
- 和环境变量相关的命令
- 环境变量的组织方式
- 环境变量的特性
- 获取环境变量
- 命令行参数
- 环境变量表
- 通过全局变量 environ (char** 类型) 获取
- 通过系统调用获取环境变量
- 本地变量(Local Variables)
- 程序地址空间
- 代码1.
- 代码2.
- 故事理解进程地址空间
- 进程地址空间
基本概念
课本概念: 在课本中,进程指的是程序的一个执行实例。它是程序在计算机中的实际运行表现,涉及到程序的执行、资源的分配、执行状态的管理等。每个程序的执行都会生成一个进程,且同一个程序可能会有多个进程实例在不同的时间运行。
内核观点: 在操作系统的内核中,进程是系统分配和管理资源的基本单位。具体而言,进程是一个实体,它拥有执行时所需要的资源,如CPU时间和内存,并负责这些资源的合理使用与调度。在内核的视角下,进程不仅仅是程序代码的执行,而是资源调度、任务管理的重要部分。
只要写过代码的都知道,当你的代码进行编译链接后便会生成一个可执行程序,这个可执行程序本质上是一个文件,是放在磁盘上的。当我们双击这个可执行程序将其运行起来时,本质上是将这个程序加载到内存当中了,因为只有加载到内存后,CPU才能对其进行逐行的语句执行,而一旦将这个程序加载到内存后,我们就不应该将这个程序再叫做程序了,严格意义上将应该将其称之为进程。
描述进程-PCB
系统当中可以同时存在大量进程,使用命令ps aux便可以显示系统当中存在的进程。
而当你开机的时候启动的第一个程序就是我们的操作系统(即操作系统是第一个加载到内存的),我们都知道操作系统是做管理工作的,而其中就包括了进程管理。而系统内是存在大量进程的,那么操作系统是如何对进程进行管理的呢?
这时我们就应该想到管理的六字真言:先描述,再组织。
- 描述(Description)
描述指对管理对象进行属性抽象,将其关键特征封装为数据结构。例如,操作系统需要管理进程时,首先需定义进程的完整属性集合(如标识符、状态、优先级等),并以结构体形式固化这些信息。这种抽象化实现了对实体的标准化建模。- 组织(Organization)
组织指通过数据结构对已描述的实体建立逻辑关联。例如,将进程控制块(PCB)通过链表、树或哈希表等结构关联,形成可遍历和操作的数据集合。这种结构化使得批量管理和动态调整成为可能。
操作系统管理进程也是一样的,操作系统作为管理者是不需要直接和被管理者(进程)直接进行沟通的,当一个进程出现时,操作系统就立马对其进行描述,之后对该进程的管理实际上就是对其描述信息的管理。
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合,课本上称之为PCB(process control block)。
在操作系统中,每个进程都会有一个描述自身状态和信息的数据结构,称为进程控制块(PCB)。PCB保存了与进程相关的所有重要信息,是操作系统管理进程的核心。
- 课本定义
进程控制块(PCB)是进程属性的集合,它包含了进程的各类信息。操作系统通过它来管理进程的生命周期,包括创建、调度、执行和终止等。
- Linux中的PCB
在Linux操作系统中,进程控制块的实现是通过task_struct
结构体来表示的。task_struct
是Linux内核的一种数据结构,它在内存中保存了与进程相关的所有信息,包括进程的标识符、状态、资源等。Linux内核会使用这个结构体来控制、调度和管理进程。
操作系统将每一个进程都进行描述,形成了一个个的进程控制块(PCB),并将这些PCB以双链表的形式组织起来。
这样一来,操作系统只要拿到这个双链表的头指针,便可以访问到所有的PCB。此后,操作系统对各个进程的管理就变成了对这条双链表的一系列操作。
例如创建一个进程实际上就是先将该进程的代码和数据加载到内存,紧接着操作系统对该进程进行描述形成对应的PCB,并将这个PCB插入到该双链表当中。而退出一个进程实际上就是先将该进程的PCB从该双链表当中删除,然后操作系统再将内存当中属于该进程的代码和数据进行释放或是置为无效。
总的来说,操作系统对进程的管理实际上就变成了对该双链表的增、删、查、改等操作。
task_struct-PCB的一种
进程控制块(PCB)是描述进程的,在C++当中我们称之为面向对象,而在C语言当中我们称之为结构体,既然Linux操作系统是用C语言进行编写的,那么Linux当中的进程控制块必定是用结构体来实现的。
task_struct
是Linux操作系统中描述进程的关键结构体,操作系统通过它来管理每个进程的状态、资源和执行信息。所有运行在系统中的进程都有一个对应的task_struct
,并且这些结构体会被组织成一个链表,方便进程的调度和管理。
- PCB实际上是对进程控制块的统称,在Linux中描述进程的结构体叫做task_struct。
- task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含进程的信息。
task_struct内容分类
task_struct就是Linux当中的进程控制块,task_struct当中主要包含以下信息:
- 标识符(PID):
- PID(进程标识符)是一个整数值,它唯一地标识着系统中的每个进程。通过PID,操作系统可以对进程进行调度和管理。
- 另外,
task_struct
还会包含父进程ID(PPID)以及线程信息,这有助于支持多线程和进程间的关系管理。
- 状态(State):
- 进程的状态描述了进程当前所处的执行阶段,常见的状态有运行中、就绪、阻塞等。Linux内核根据进程的状态来决定其调度顺序。
- 除了进程状态外,
task_struct
还保存了进程的退出代码、退出信号等信息,以便在进程终止时进行管理。
- 优先级(Priority):
- 每个进程都有一个优先级,优先级决定了在多个进程同时就绪时,哪个进程优先获得CPU时间。Linux内核采用不同的调度算法来调整进程的优先级,使系统资源能够公平、有效地分配。
- 程序计数器(Program Counter):
- 程序计数器保存了进程当前正在执行的指令的地址。它在进程执行时不断更新,确保程序按顺序执行。每当进程被调度时,程序计数器会指向下一个要执行的指令。
- 内存指针(Memory Pointers):
- 内存指针包含指向进程地址空间的指针,包括程序代码、数据段、堆栈等区域。内存指针可以帮助操作系统管理进程的内存使用,并支持进程间的内存共享。
- 例如,
task_struct
中可能包含指向共享内存区域或动态分配的内存块的指针。
- 上下文数据(Context):
- 上下文指的是在进程执行过程中,进程占用的CPU寄存器内容和其他状态信息。当进程从CPU中被中断或者切换时,操作系统会保存当前进程的上下文,以便后续恢复并继续执行。上下文包括程序计数器、寄存器值和其他系统状态。
- I/O状态信息(I/O Status):
- 描述了与输入输出相关的所有状态信息,包括显示的I/O请求、进程使用的I/O设备、文件句柄等。操作系统会通过这些信息来管理和调度I/O操作,确保进程在需要时能获取到相应的硬件资源。
- 记账信息(Accounting Information):
- 记账信息记录了进程的CPU时间、内存使用、I/O操作等资源消耗。操作系统通过这些信息来监控系统性能、优化调度策略,或者进行资源限制。
组织进程
在Linux操作系统中,所有进程的信息都保存在task_struct
结构体中,并且以链表的形式组织。这些结构体构成了一个内核中的进程调度队列。通过这个队列,操作系统可以方便地管理和调度所有正在运行的进程。
- 内核中的进程链表
每个进程的task_struct
结构体都会被放入到一个进程队列中,内核通过遍历这个队列来调度进程。根据进程的优先级、状态以及其他因素,内核决定下一个执行的进程。
- 进程的生命周期
进程的生命周期通常包括:
- 创建:操作系统通过调用
fork()
等系统调用创建一个新进程,并为其分配一个新的task_struct
。 - 执行:进程被调度到CPU上执行,执行过程中会不断更新进程状态。
- 阻塞/等待:当进程等待某些资源(如I/O设备或内存)时,它的状态会被设置为阻塞。
- 终止:当进程完成任务或遇到错误时,操作系统会将其状态设置为退出,并释放资源。
查看进程
通过系统目录查看
在Linux中,所有进程的信息都可以通过/proc
目录来访问。每个进程在/proc
目录下都有一个以其进程ID(PID)命名的目录。例如,如果你想查看PID为1的进程的信息,你只需查看/proc/1
这个目录。
- /proc/1 目录下包含了该进程的信息,例如
status
,cmdline
,environ
等文件,它们分别包含进程的状态、命令行参数、环境变量等详细数据。
这些数字其实是某一进程的PID,对应文件夹当中记录着对应进程的各种信息。我们若想查看PID为1的进程的进程信息,则查看名字为1的文件夹即可。
通过ps命令查看
使用 ps
或 top
等命令,也能方便地查看进程信息。比如,ps aux | grep test
命令可以过滤出所有包含"test"的进程。并且,通过使用 grep -v grep
过滤掉grep
命令本身的进程信息,精准找到目标进程。ll
ps命令与grep命令搭配使用,即可只显示某一进程的信息。
ps aux | head -1 && ps aux | grep proc | grep -v grep
通过系统调用获取进程的PID和PPID
在C语言中,使用 getpid()
和 getppid()
函数可以获取当前进程的PID和父进程的PID。
getpid()
返回调用进程的PID。getppid()
返回调用进程的父进程的PID。
我们可以通过一段代码来进行测试。
当运行该代码生成的可执行程序后,便可循环打印该进程的PID和PPID。
我们可以通过ps命令查看该进程的信息,即可发现通过ps命令得到的进程的PID和PPID与使用系统调用函数getpid和getppid所获取的值相同。
通过系统调用创建进程- fork初始
fork函数创建子进程
fork()
是用于创建一个子进程的系统调用。它会创建一个新的进程,这个新进程是调用fork()
的进程(父进程)的一个副本。子进程会与父进程几乎完全相同,除了以下几个区别:
- 父子进程的返回值不同:
- 在父进程中,
fork()
返回子进程的PID(进程ID)。 - 在子进程中,
fork()
返回0。 - 如果
fork()
调用失败(例如,由于系统资源限制),则返回负值。
- 在父进程中,
执行流程:
例如,运行以下代码:
若是代码当中没有fork函数,我们都知道代码的运行结果就是循环打印该进程的PID和PPID。而加入了fork函数后,代码运行结果如下:
运行结果是循环打印两行数据,第一行数据是该进程的PID和PPID,第二行数据是代码中fork函数创建的子进程的PID和PPID。我们可以发现fork函数创建的进程的PPID就是proc进程的PID,也就是说proc进程与fork函数创建的进程之间是父子关系。
每出现一个进程,操作系统就会为其创建PCB,fork函数创建的进程也不例外。
我们知道加载到内存当中的代码和数据是属于父进程的,那么fork函数创建的子进程的代码和数据又从何而来呢?
我们看看以下代码的运行结果:
运行结果:
实际上,使用fork函数创建子进程,在fork函数被调用之前的代码被父进程执行,而fork函数之后的代码,则默认情况下父子进程都可以执行。需要注意的是,父子进程虽然代码共享,但是父子进程的数据各自开辟空间(采用写时拷贝)。
- 父子进程共享代码,独立数据:
- 父进程和子进程共享代码段(即程序的实际指令),但它们各自拥有独立的数据段(堆、栈等)。这是因为操作系统使用了“写时拷贝(copy-on-write,COW)”技术。
- 写时拷贝意味着,当父子进程都访问同一块内存时,操作系统不会立即为子进程复制数据,而是延迟复制。只有在父子进程中的某个进程修改数据时,操作系统才会为该进程创建该内存的副本。这可以提高内存的使用效率。
小贴士: 使用fork函数创建子进程后就有了两个进程,这两个进程被操作系统调度的顺序是不确定的,这取决于操作系统调度算法的具体实现。
使用if进行分流
上面说到,fork函数创建出来的子进程与其父进程共同使用一份代码,但我们如果真的让父子进程做相同的事情,那么创建子进程就没有什么意义了。
实际上,在fork之后我们通常使用if语句进行分流,即让父进程和子进程做不同的事。
fork()
函数的返回值根据调用的上下文不同而不同:
- 在父进程中:
fork()
返回子进程的PID。 - 在子进程中:
fork()
返回0。 - 在调用失败时:
fork()
返回一个负值,表示无法创建新进程。
既然父进程和子进程获取到fork函数的返回值不同,那么我们就可以据此来让父子进程执行不同的代码,从而做不同的事。
例如,以下代码:
fork创建出子进程后,子进程会进入到 if 语句的循环打印当中,而父进程会进入到 else if 语句的循环打印当中。
对于上文我们引出三个问题
1.给父进程返回子进程的pid,给子进程返回0。为什么?
2.fork函数为什么会返回两次
- id怎么可能为同一个变量,即等于0,又大于0?
1为什么父进程返回子进程的 PID,子进程返回 0?
- 父子进程的区分:
fork()
通过返回值来区分父进程和子进程。父进程会得到一个大于 0 的值(子进程的 PID),这个值可以用来跟踪和管理子进程;而子进程会得到 0,这样它可以知道自己是一个新创建的进程,可以执行不同于父进程的逻辑。通过这个设计,父进程能够方便地管理多个子进程,并在需要时使用它们的 PID 来进行操作(例如,等待子进程结束)。 - 操作系统的实现: 返回 PID 给父进程,0 给子进程,是为了确保父进程能够继续执行相关的管理工作,同时子进程不会依赖父进程的控制。实际上,这是操作系统通过系统调用将父进程和子进程区分开的方式。
2. 为什么 fork()
函数会返回两次:
fork()
会返回两次,因为它在父进程和子进程中都返回,分别对应着两种不同的情况:
- 父进程的返回: 当父进程调用
fork()
时,操作系统创建了一个新的进程(子进程),并返回子进程的 PID。父进程会继续执行其后的代码,并且可以通过返回的 PID 来管理子进程。 - 子进程的返回: 与此不同,子进程会从
fork()
返回的地方继续执行,并且返回值是 0,表示这是子进程。
为什么会返回两次?
fork()
的工作原理是:父进程在调用 fork()
时,会被复制出一个新的进程(子进程)。子进程和父进程共享相同的代码,但拥有不同的进程空间(尤其是堆和栈)。父子进程在调用 fork()
后会从调用点继续执行,父进程继续运行后面的代码,而子进程从同一位置开始执行。由于它们在同一时刻都运行,因此它们都需要各自的 fork()
返回值来区分自己是父进程还是子进程。
- 父进程会得到子进程的 PID: 父进程的
fork()
返回值是新创建子进程的 PID(进程标识符),这是一个正数,表示父进程创建的子进程。 - 子进程会得到 0: 子进程的
fork()
返回值是 0,表示它是由父进程创建的。它会用这个值来执行与父进程不同的操作。
3.为什么 fork()
会返回两次,而返回值又不冲突?
为什么这两个进程可以共享 fork()
返回值?
每当 fork()
被调用时,操作系统会创建一个全新的进程。由于 fork()
会在父进程和子进程中分别返回,它的行为是分开执行的。以下是对 fork()
返回值的详细分析:
- 父进程:
- 父进程的
fork()
返回值是 子进程的 PID。这个值大于 0。 - 父进程使用这个值来跟踪和管理子进程。比如,父进程可能需要等待子进程的结束或者检查子进程的状态。
- 父进程的
- 子进程:
- 子进程的
fork()
返回值是 0。这是为了告诉子进程它是通过fork()
创建的,因此需要执行与父进程不同的逻辑。
- 子进程的
为什么同一个 fork()
调用会返回两次不冲突的值?
fork()
是一个系统调用,操作系统在内部管理父进程和子进程。它为父进程和子进程分别设置了不同的返回值,以便区分它们。具体来说,操作系统在创建子进程时,会复制父进程的内存空间和其他资源,然后根据进程的身份(父进程或子进程)返回不同的值。- 父进程会得到一个大于 0 的 PID: 这表示父进程成功地创建了一个子进程,且这个 PID 唯一标识子进程。
- 子进程会得到 0: 这是为了明确表示子进程是由父进程创建的,它需要执行自己的代码。
Linux进程状态
一个进程从创建而产生至撤销而消亡的整个生命期间,有时占有处理器执行,有时虽可运行但分不到处理器,有时虽有空闲处理器但因等待某个时间的发生而无法执行,这一切都说明进程和程序不相同,进程是活动的且有状态变化的,于是就有了进程状态这一概念。
Linux 内核通过 task_struct
结构体中的 state
字段管理进程状态。状态定义在 include/linux/sched.h
中:
// 状态标志位说明(非连续数值,支持位掩码组合)
#define TASK_RUNNING 0x0000
#define TASK_INTERRUPTIBLE 0x0001
#define TASK_UNINTERRUPTIBLE 0x0002
#define __TASK_STOPPED 0x0004
#define __TASK_TRACED 0x0008
#define EXIT_DEAD 0x0010
#define EXIT_ZOMBIE 0x0020
对应的可读状态名在 kernel/sched/core.c
中定义:
const char *task_state_array[] = {"R (running)", // 0x0000"S (sleeping)", // 0x0001"D (disk sleep)", // 0x0002"T (stopped)", // 0x0004"t (tracing stop)", // 0x0008"X (dead)", // 0x0010"Z (zombie)" // 0x0020
};
小贴士: 进程的当前状态是保存到自己的进程控制块(PCB)当中的,在Linux操作系统当中也就是保存在task_struct当中的。
在Linux操作系统当中我们可以通过 ps aux 或 ps axj 命令查看进程的状态。
**ps aux**
- 功能:显示所有用户的进程(包括后台进程),并提供详细的进程信息。
**ps axj**
- 功能:以 BSD 风格显示所有进程(包括无终端的进程),并提供额外的作业控制信息。
三大状态(进程行为的抽象描述):
在Linux系统中,进程的状态可以分为多种,主要包括运行态、阻塞态、挂起态等。这些状态与磁盘分区、swap分区、内存和CPU的使用有着密切的关系。下面我们将详细讲解这些状态及其与这些资源的关系。
1. 运行态 (Running)
运行态表示进程正在执行或等待CPU分配资源。在这个状态下,进程已经被加载到内存中,并且正在使用CPU。Linux中的调度器会根据进程的优先级、时间片、调度策略等因素来决定哪个进程获得CPU执行权。
资源与运行态的关系:
- 内存:进程在运行态时,它的代码、数据和堆栈已经加载到内存中。CPU与内存之间的交换速度影响了进程执行的效率。每个进程都有自己的虚拟地址空间,操作系统通过内存管理单元 (MMU) 来进行地址映射。
- CPU:CPU是进程在运行态时最关键的资源。操作系统的调度器会选择合适的进程来执行,确保多个进程公平地共享CPU资源。
- 磁盘与swap:当进程需要更多的内存资源时,操作系统可能会将一些不活跃的进程数据交换到磁盘的swap分区中,以释放内存给当前的运行进程。运行态的进程在正常情况下不依赖swap分区。
2. 阻塞态 (Blocked/Waiting)
阻塞态的进程在等待某些资源或事件时会进入此状态,通常是在等待I/O操作完成、等待锁或等待其他进程的资源时发生。进程不会占用CPU,它处于挂起状态,直到它等待的条件满足才能恢复执行。
资源与阻塞态的关系:
- 内存:进程仍然占用着它的内存空间(即内存中的进程控制块),但它不再执行。这意味着进程的内存仍然存在,但不会被CPU访问。
- 磁盘与swap:当进程处于阻塞态时,它的资源需求(如I/O操作)可能会被系统安排到磁盘。对于磁盘I/O操作,进程会在等待数据时阻塞,直到数据从磁盘读入内存或完成其他I/O任务。
- CPU:在阻塞态下,进程不需要CPU。阻塞的进程会被操作系统的调度器剥夺CPU资源,CPU会去执行其他进程。
3. 挂起态 (Suspended)
挂起态指的是进程已经被暂停,但不属于正常的阻塞状态。在此状态下,进程完全被暂停,它不占用CPU资源,也不占用内存资源。在这种情况下,进程的执行被挂起,直到某个信号或条件发生变化,进程可以被恢复。
资源与挂起态的关系:
- 内存:挂起的进程可能依然占用内存,但操作系统可能会将其内存内容移至磁盘的swap区,以腾出内存给其他活跃进程。具体情况取决于操作系统的内存管理策略和挂起的具体原因。
- swap:当进程被挂起时,它的内存数据有可能会被转移到swap分区。这样,操作系统可以为其他进程释放内存空间。挂起的进程可能会在恢复执行时,从swap分区中重新加载它的内存数据。
- CPU:挂起状态下的进程不占用CPU。进程的执行会被操作系统暂停,它会被调度器排队等待恢复。挂起进程的恢复通常通过特定的信号(如
SIGCONT
)或某个外部事件触发。
总结:
- 运行态 (Running):进程正在执行或等待CPU分配。此时,进程占用内存和CPU资源。
- 阻塞态 (Blocked/Waiting):进程等待某些事件(如I/O操作)完成,不占用CPU,但仍占用内存。磁盘和swap分区可能会用于支持I/O等待。
- 挂起态 (Suspended):进程被暂停,可能将内存数据转存至swap分区,不占用CPU和内存资源。恢复时需要通过信号或条件触发。
这三种状态在操作系统中是动态变化的,通过进程调度和内存管理机制,操作系统有效地管理系统资源,确保每个进程的合理执行。
状态间转换:
下面几大状态是Linux 内核中具体的状态代码,用于精确标识进程状态:
运行状态-R
一个进程处于运行状态(running),并不意味着进程一定处于运行当中,运行状态表明一个进程要么在运行中,要么在运行队列里。也就是说,可以同时存在多个R状态的进程。
- 本质:进程正在CPU执行 或 处于就绪队列等待调度。
- 关键特征:
- 不表示持续占用CPU(时间片轮转)。
- 进程的
task_struct
存在于运行队列。
小贴士: 所有处于运行状态,即可被调度的进程,都被放到运行队列当中,当操作系统需要切换进程运行时,就直接在运行队列中选取进程运行。
睡眠状态-S
一个进程处于可中断睡眠状态(sleeping),意味着该进程正在等待某件事情的完成,处于可中断睡眠状态的进程随时可以被唤醒,也可以被杀掉。
- 触发条件:等待事件完成,如:
- 用户输入(
read()
) - 网络数据到达(
recv()
) - 信号量释放
- 用户输入(
- 特性:
- 可被信号唤醒(如
SIGINT
)。 - 响应速度快,适合短期等待。
- 可被信号唤醒(如
sleep(10); // 进入S状态,可通过Ctrl+C唤醒
例如执行以下代码:
代码当中调用sleep函数进行休眠100秒,在这期间我们若是查看该进程的状态,则会看到该进程处于浅度睡眠状态。
ps aux | head -1 && ps aux | grep proc | grep -v grep
命令拆解与解释
- ps aux
ps 是 Linux 系统中用于查看进程状态的命令。
aux 是 ps 命令的参数组合:
a:显示所有与终端相关的进程,包括其他用户的进程。
u:以用户格式显示进程信息,会输出进程的所有者、CPU 使用率、内存使用率等详细信息。
x:显示无控制终端的进程,即后台进程。- head -1
head 命令用于显示文件的开头部分内容。
-1 表示只显示第一行。在这里,ps aux 的输出作为 head -1 的输入,所以只会输出 ps aux 结果的第一行,也就是表头信息,包含 USER、PID、%CPU、%MEM 等列名。- &&
&& 是逻辑与运算符,用于连接两个命令。只有当 && 前面的命令执行成功(返回状态码为 0)时,才会执行后面的命令。在这里,就是先执行 ps aux | head -1 输出表头,若执行成功,再执行后面的命令。- ps aux | grep proc
再次执行 ps aux 命令获取所有进程信息。
grep proc 用于在 ps aux 的输出结果中查找包含 proc 关键字的行。grep 是一个强大的文本搜索工具,它会逐行检查输入内容,将包含指定关键字的行输出。- grep -v grep
grep -v 中的 -v 是反向匹配选项,即输出不包含指定关键字的行。
grep 是要排除的关键字。因为在执行 grep proc 时,grep 命令本身也会作为一个进程存在于 ps aux 的输出结果中,为了避免显示 grep proc 这个进程的信息,就使用 grep -v grep 来排除所有包含 grep 关键字的行。
而处于浅度睡眠状态的进程是可以被杀掉的,我们可以使用kill命令将该进程杀掉。
kill -9 PID
是强制终止进程的命令,其含义和使用场景如下:
- 命令解析
kill:向进程发送信号。
-9:指定信号类型为 SIGKILL(信号编号 9),表示强制终止进程。
:进程的 ID(Process ID)。- 作用
立即终止进程:无论进程处于何种状态(运行、阻塞、僵死),SIGKILL 信号会立即终止它。
无法被忽略或捕获:与其他信号(如 SIGTERM)不同,进程无法通过代码拦截或处理 SIGKILL。- 使用场景
进程无响应:当程序卡死或无法通过正常方式退出时。
紧急终止恶意进程:例如无法通过常规方式停止的病毒或异常进程。
深度睡眠状态-D
一个进程处于深度睡眠状态(disk sleep),表示该进程不会被杀掉,即便是操作系统也不行,只有该进程自动唤醒才可以恢复。该状态有时候也叫不可中断睡眠状态(uninterruptible sleep),处于这个状态的进程通常会等待IO的结束。
- 触发条件:等待不可中断的I/O操作:
- 磁盘写入(必须等待完成,防止数据损坏)
- 硬件设备响应
- 特性:
- 不响应任何信号(包括
SIGKILL
)。 - 通常持续时间短,但硬件故障可能导致长期挂起。
- 不响应任何信号(包括
例如,某一进程要求对磁盘进行写入(不可中断的操作)操作,那么在磁盘进行写入期间,该进程就处于深度睡眠状态,是不会被杀掉的,因为该进程需要等待磁盘的回复(是否写入成功)以做出相应的应答。(磁盘休眠状态)
暂停状态-T
- 触发方式:
- 用户发送
SIGSTOP
信号(kill -STOP <PID>
)。 - 终端挂起(如 Ctrl+Z)。
- 用户发送
- 恢复方式:
kill -CONT <PID> # 发送SIGCONT信号
在Linux当中,我们可以通过发送SIGSTOP信号使进程进入暂停状态(stopped),发送SIGCONT信号可以让处于暂停状态的进程继续运行。
例如,我们对一个进程发送SIGSTOP信号,该进程就进入到了暂停状态。
我们再对该进程发送SIGCONT信号,该进程就继续运行了。
小贴士: 使用kill -l
命令可以列出当前系统所支持的信号集。
僵尸状态-Z
- 产生条件:
- 子进程已终止(调用
exit()
)。 - 父进程未调用
wait()
/waitpid()
回收。
- 子进程已终止(调用
- 危害:
- 占用内核进程表项(导致新进程无法创建)。
- 不消耗内存或CPU资源。
- 生命周期:
- 持续到父进程回收或父进程终止(由
init
接管回收)。
- 持续到父进程回收或父进程终止(由
当一个进程将要退出的时候,在系统层面,该进程曾经申请的资源并不是立即被释放,而是要暂时存储一段时间,以供操作系统或是其父进程进行读取,如果退出信息一直未被读取,则相关数据是不会被释放掉的,一个进程若是正在等待其退出信息被读取,那么我们称该进程处于僵尸状态(zombie)。
首先,僵尸状态的存在是必要的,因为进程被创建的目的就是完成某项任务,那么当任务完成的时候,调用方是应该知道任务的完成情况的,所以必须存在僵尸状态,使得调用方得知任务的完成情况,以便进行相应的后续操作。
例如,我们写代码时都在主函数最后返回0。
实际上这个0就是返回给操作系统的,告诉操作系统代码顺利执行结束。在Linux操作系统当中,我们可以通过使用echo $?命令获取最近一次进程退出时的退出码。
[cl@VM-0-15-centos exitcode]$ echo $?
小贴士: 进程退出的信息(例如退出码),是暂时被保存在其进程控制块当中的,在Linux操作系统中也就是保存在该进程的task_struct当中。
死亡状态-X
- 本质:进程已终止且资源完全释放。
- 用户空间无法观测此状态。
- 内核在
do_exit()
函数中短暂设置。
死亡状态只是一个返回状态,当一个进程的退出信息被读取后,该进程所申请的资源就会立即被释放,该进程也就不存在了,所以你不会在任务列表当中看到死亡状态(dead)。
僵尸进程
1. 什么是僵尸进程?
僵尸进程(Zombie Process)是操作系统中的一种特殊进程状态。它指的是子进程已经结束执行,但其父进程没有使用 wait()
系统调用读取其退出状态代码(即没有通过父进程获取子进程的返回信息)。这种情况下,子进程虽然已经完成任务并退出,但仍然占据进程表中的一项记录,处于“终止状态”,并等待父进程去读取它的退出状态。
当进程退出后,操作系统会将该进程的资源释放,通常会移除该进程的记录,但在僵尸状态下,子进程的退出状态依然被保留。这是为了让父进程能够读取到子进程的退出状态(返回值)。如果父进程一直不读取这些状态,子进程就会一直停留在僵尸状态。
2. 产生僵尸进程的原因
僵尸进程的形成通常发生在子进程退出后,但父进程没有及时调用 wait()
来读取其退出状态。wait()
是父进程用来等待子进程终止并获取子进程退出状态的系统调用。
具体来说:
- 子进程先退出,进入终止状态,但仍然保留在进程表中。
- 父进程没有读取退出状态,所以子进程会一直停留在进程表中,直到父进程通过
wait()
或waitpid()
来读取子进程的退出状态。
例如,对于以下代码,fork函数创建的子进程在打印5次信息后会退出,而父进程会一直打印信息。也就是说,子进程退出了,父进程还在运行,但父进程没有读取子进程的退出信息,那么此时子进程就进入了僵尸状态。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{printf("I am running...\n");pid_t id = fork();if(id == 0){ //childint count = 5;while(count){printf("I am child...PID:%d, PPID:%d, count:%d\n", getpid(), getppid(), count);sleep(1);count--;}printf("child quit...\n");exit(1);}else if(id > 0){ //fatherwhile(1){printf("I am father...PID:%d, PPID:%d\n", getpid(), getppid());sleep(1);}}else{ //fork error}return 0;
}
运行该代码后,我们可以通过以下监控脚本,每隔一秒对该进程的信息进行检测。
while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep;echo "######################";sleep 1;done
检测后即可发现,当子进程退出后,子进程的状态就变成了僵尸状态。
while ;; do ps ajx | head -1 && ss | grep -v grep; sleep 1; done
while ;; do ... done
: 这是一个无限循环,意味着do
和done
之间的代码会不断被执行。由于条件部分(;;
)是空的,它会一直执行下去,直到手动停止。ps ajx
: 这个命令用来显示当前系统上所有进程的信息。ps
命令的选项解释如下:
a
: 显示所有用户的进程。j
: 显示进程的作业控制信息,包括进程组ID、父进程ID等。x
: 显示所有没有控制终端的进程(即包括后台进程)。head -1
: 这个命令只显示ps
输出的第一行(通常是列头),即进程的标题行。ss
: 这是用来显示套接字(socket)统计信息的命令,类似于netstat
,它显示网络连接、监听端口等信息。grep -v grep
: 用来过滤掉grep
命令本身的进程信息,因为在ps
输出中grep
会被列出。sleep 1
: 每执行一次循环,暂停1秒钟。
僵尸进程的危害
- 僵尸进程的退出状态必须一直维持下去,因为它要告诉其父进程相应的退出信息。可是父进程一直不读取,那么子进程也就一直处于僵尸状态。
- 僵尸进程的退出信息被保存在task_struct(PCB)中,僵尸状态一直不退出,那么PCB就一直需要进行维护。
- 若是一个父进程创建了很多子进程,但都不进行回收,那么就会造成资源浪费,因为数据结构对象本身就要占用内存。
- 僵尸进程申请的资源无法进行回收,那么僵尸进程越多,实际可用的资源就越少,也就是说,僵尸进程会导致内存泄漏。
如何避免?
为了避免僵尸进程,父进程应该在子进程退出后及时调用 wait()
或 waitpid()
函数来读取子进程的退出状态。这样,子进程的资源就可以被操作系统回收,不会再占用进程表的空间。
wait()
函数:使父进程等待一个子进程退出并获取其退出状态。waitpid()
函数:类似于wait()
,但可以指定等待特定的子进程。
孤儿进程
在Linux当中的进程关系大多数是父子关系,若子进程先退出而父进程没有对子进程的退出信息进行读取,那么我们称该进程为僵尸进程。但若是父进程先退出,那么将来子进程进入僵尸状态时就没有父进程对其进行处理,此时该子进程就称之为孤儿进程。
若是一直不处理孤儿进程的退出信息,那么孤儿进程就会一直占用资源,此时就会造成内存泄漏。因此,当出现孤儿进程的时候,孤儿进程会被1号init进程进程ID为1的进程,通常是系统中的初始化进程)领养,此后当孤儿进程进入僵尸状态时就由int进程进行处理回收。
例如,对于以下代码,fork函数创建的子进程会一直打印信息,而父进程在打印5次信息后会退出,此时该子进程就变成了孤儿进程。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{printf("I am running...\n");pid_t id = fork();if(id == 0){ //childint count = 5;while(1){printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid(), count);sleep(1);}}else if(id > 0){ //fatherint count = 5;while(count){printf("I am father...PID:%d, PPID:%d, count:%d\n", getpid(), getppid(), count);sleep(1);count--;}printf("father quit...\n");exit(0);}else{ //fork error}return 0;
}
观察代码运行结果,在父进程未退出时,子进程的PPID就是父进程的PID,而当父进程退出后,子进程的PPID就变成了1,即子进程被1号进程领养了。
进程优先级
基本概念以及分类
什么是进程优先级?
进程优先级是操作系统用来决定多个进程使用CPU资源的先后顺序的一种机制。操作系统中的每个进程都有一个与之相关联的优先级,操作系统会根据优先级来决定哪个进程应该获得CPU时间。优先级高的进程会优先获得CPU资源,而优先级低的进程则需要等待。
在Linux中,进程优先级的表示是一个数值,数值越小的进程优先级越高,即越早被执行。比如,PRI
(优先级)值为10的进程优先级高于PRI
值为20的进程。
为什么要设置进程优先级?
- 资源竞争:在多任务操作系统中,系统通常会有多个进程在运行,而每个进程都需要CPU资源。因为CPU资源有限,多个进程之间就存在竞争,操作系统需要通过优先级来合理分配CPU时间,确保关键任务能优先完成。
- 提高性能:通过合理配置进程的优先级,可以让不太重要的进程(如后台进程)在CPU资源紧张时被推迟,从而避免它们阻碍重要任务的执行,提升系统的整体性能。
静态优先级与动态优先级
类型 | 描述 |
---|---|
静态优先级(Static Priority) | 在进程生命周期内不变,适用于实时任务和固定优先级的场景。 |
动态优先级(Dynamic Priority) | 根据系统负载或进程行为动态调整,用于提高系统公平性或响应性。 |
静态优先级示例:某些实时系统中,关键任务的优先级设为固定值,确保其优先执行。
动态优先级示例:在Linux中,交互式任务的优先级可能会根据任务等待时间调整,以避免长期饥饿。
用户优先级与内核优先级
操作系统通常将进程分为用户态和内核态,不同状态下的优先级可能有不同的策略:
类型 | 描述 |
---|---|
用户优先级 | 应用程序中的进程优先级,由操作系统和用户共同控制,通常范围较宽。 |
内核优先级 | 内核中的任务优先级,通常用于驱动程序或内核服务,范围较高且不可由普通用户修改。 |
查看系统进程
在Linux或者Unix操作系统中,用ps -l命令会类似输出以下几个内容:
列出的信息当中有几个重要的信息,如下:
- UID:代表执行者的身份。
- PID:代表这个进程的代号。
- PPID:代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号。
- PRI:代表这个进程可被执行的优先级,其值越小越早被执行。
- NI:代表这个进程的nice值。
PRI与NI
- PRI(Priority)
PRI
是进程的优先级数值,值越小表示进程的优先级越高。操作系统会使用此值来决定进程何时获得CPU的执行时间。
- 优先级与执行顺序:操作系统会根据进程的优先级来排队调度,优先级较高的进程会先执行。
- 优先级数值范围:在Linux中,优先级值通常范围从100到139,这些是普通进程的优先级。如果是实时进程,它们的优先级范围是0到99。
- NI(Nice值)
NI
是nice值,它是进程优先级的调整因子,允许用户修改进程的优先级。nice值范围是从 -20 到 19,数值越低表示优先级越高。
- Nice值和优先级:虽然
PRI
表示进程的优先级,但NI
是用来调整进程优先级的。如果进程的nice值为负数(如-10),该进程的优先级会提高,意味着它会更快获得CPU的执行时间。如果nice值为正(如+10),进程的优先级会降低,执行速度变慢。 - 调整nice值:在Linux中,进程的
nice
值可以通过命令行命令或程序修改。命令行中,使用<u>nice</u>
和<u>renice</u>
可以调整进程的nice值。程序中则可以通过调用系统函数来调整。
- PRI 和 NI 之间的关系
- PRI 和 NI 的关系:虽然
PRI
和NI
是两个不同的概念,但它们是紧密相关的。PRI
是进程的优先级,而NI
是调整这个优先级的修正因子。通过修改NI
值,可以动态调整进程的优先级。 - 这意味着
nice
值的改变会直接影响PRI
的计算。如果nice
值为负,那么优先级(PRI
)会变小,进程的执行优先权会变高。如果nice
值为正,则优先级(PRI
)会变大,进程的执行优先权变低。 - 例如:
- 如果一个进程的
PRI
是 20,nice
值为 +10,那么PRI(new) = PRI(old) + NI = 20 + 10 = 30
。 - 如果
nice
值为 -5,则PRI(new) = 20 + (-5) = 15
,这意味着该进程的优先级提高,它会优先被调度执行。
- 如果一个进程的
注意: 在Linux操作系统当中,PRI(old)默认为80,即PRI = 80 + NI。
查看进程优先级信息
当我们创建一个进程后,我们可以使用ps -al
命令查看该进程优先级的信息。
注意: 在Linux操作系统中,初始进程一般优先级PRI默认为80,NI默认为0。
通过top命令更改进程的nice值
top命令就相当于Windows操作系统中的任务管理器,它能够动态实时的显示系统当中进程的资源占用情况。
使用top命令后按“r”键,会要求你输入待调整nice值的进程的PID。
输入进程PID并回车后,会要求你输入调整后的nice值。
输入nice值后按“q”即可退出,如果我们这里输入的nice值为10,那么此时我们再用ps命令查看进程的优先级信息,即可发现进程的NI变成了10,PRI变成了90(80+NI)。
注意: 若是想将NI值调为负值,也就是将进程的优先级调高,需要使用sudo命令提升权限。
通过renice命令更改进程的nice值
使用renice命令,后面跟上更改后的nice值和进程的PID即可。
之后我们再用ps命令查看进程的优先级信息,也可以发现进程的NI变成了10,PRI变成了90(80+NI)。
注意: 若是想使用renice命令将NI值调为负值,也需要使用sudo命令提升权限。
四个重要概念
- 竞争性:多个进程之间会争夺有限的CPU资源,操作系统通过优先级来合理分配这些资源。
- 独立性:每个进程需要独享系统资源,如内存、文件描述符等,进程之间的操作不能干扰彼此。
- 并行:当系统中有多个CPU时,多个进程可以在不同的CPU核心上同时执行,这就是并行。
- 并发:如果系统只有一个CPU,多个进程将通过时间片轮转的方式共享CPU,这就是并发。
时间片和调度
操作系统使用时间片来分配给每个进程一定的执行时间。每个进程被分配的时间片结束后,CPU会切换到下一个进程。时间片轮转调度有助于确保系统中的所有进程都能获得足够的CPU时间。
进程切换
一、进程切换
1.什么是进程切换?
- 进程切换(Process Switch)是操作系统内核的一项基本功能,它允许操作系统在多个并发执行的进程之间高效地分配处理器时间。进程切换涉及保存当前正在运行的进程的状态,并恢复另一个进程的状态,以便后者可以在处理器上继续执行
(1)其他相关概念
- Linux中有时间片的概念,时间片到了,当前进程就会被切换
- Linux是基于时间片的调度轮转
- 一个进程在时间片到了的时候,不一定完全跑完了,可在任何地方被重新切换
2.进程运行过程的理解
- 进程切换时会有临时数据,在CPU内部寄存器中保存
pc:当前正在执行的指令的下一条的地址
ir:保存正在执行的指令的代码
- CPU内部寄存器数据,是执行时的瞬时状态信息的数据
- 寄存器内部数据是指上下文数据
3.进程切换的核心
- 核心:进程上下文数据的保存和恢复
(1)保护当前进程的上下文
- 当一个进程因时间片用完、产生异常或外界强制等原因即将被中断运行时,操作系统需要保存该进程的上下文信息
- 这包括保存当前正在运行的进程的状态信息,如程序计数器(记录当前进程正在执行指令的下一行指令的地址)、寄存器(存储指令执行中间结果的小型存储设备)的值等
- 对于Linux系统而言,硬件上下文大部分保存在
struct thread_struct thread
中,但通用寄存器(如eax、ebx等)则保存在内核栈里
(2)恢复当前进程的上下文
- 当操作系统选择一个新的进程来执行时,它需要从该进程的PCB中读取其上下文信息
- 这包括恢复该进程的硬件上下文(如程序计数器和寄存器的值)以及用户级上下文(如存储管理数据,如页表、TLB等)
- 通过恢复这些上下文信息,新进程可以从上一次停止的地方开始执行,并获得CPU的控制权
Linux2.6内核进程调度队列
一个CPU拥有一个runqueue
在 Linux 系统中,每个 CPU 都有一个独立的 runqueue
(运行队列),它用于管理当前 CPU 上所有处于运行状态的进程。runqueue
是一个关键的数据结构,它帮助操作系统有效地调度进程。
runqueue
是一个进程队列,其中包含了待执行的进程。每个进程根据其优先级被放入不同的队列中,操作系统根据这些队列来决定哪个进程应该获得 CPU 时间。- 如果系统中有多个 CPU,操作系统就必须考虑负载均衡,确保每个 CPU 上的进程数量和系统负载是均衡的。这是为了防止某些 CPU 上的进程过多,导致该 CPU 的负载过高,而其他 CPU 的负载过低,从而影响系统性能。
如果有多个CPU就要考虑进程个数的父子均衡问题。
优先级
Linux 内核使用优先级来管理进程,确保高优先级的进程能够获得更多的 CPU 时间。进程的优先级分为两类:
- 普通优先级:优先级范围是 100 至 139。普通进程的优先级值是由操作系统根据各种因素计算得出的。在 Linux 系统中,普通进程的优先级值与进程的
nice
值(一个调整进程优先级的数值)密切相关。nice
值越小,进程的优先级就越高;nice
值越大,进程的优先级越低。 - 实时优先级:优先级范围是 0 至 99。实时进程通常用于要求精确时间调度的应用程序,如音频处理、实时通信等。它们的优先级通常高于普通进程。
注意: 实时优先级对应实时进程,实时进程是指先将一个进程执行完毕再执行下一个进程,现在基本不存在这种机器了,所以对于queue当中下标为0~99的元素我们不关心。
活动队列**(Active Queue)**
活动队列存放的是时间片还没有耗尽的进程。这些进程处于活跃状态,按照优先级排队,等待 CPU 时间片。
nr_active
:表示活动队列中正在运行的进程数量。这个数量通常表示当前活跃的进程数。queue[140]
:每个优先级对应一个队列,这里有 140 个队列,表示不同优先级的进程。每个队列根据进程的优先级被安排在队列中的特定位置。每个队列使用 FIFO(先入先出)规则,确保进程按顺序被调度执行。
进程选择过程:
- **遍历 **
queue[140]
:操作系统会从下标为 0 的队列开始遍历。每个下标对应一个优先级队列。 - 找到第一个非空队列:当遍历到第一个非空队列时,这个队列必定是当前优先级最高的队列。
- 选择队列中的第一个进程:从该队列中选出第一个进程,进行调度。
- 调度该进程:操作系统将 CPU 分配给该进程,让它执行,直到该进程的时间片用完。
优化:bitmap[5]
bitmap[5]
:为了提高查找非空队列的效率,Linux 内核使用了 5*32 位的位图(bitmap[5]
)。这个位图表示每个优先级队列是否为空。通过位图,操作系统可以快速检查每个队列是否有进程,从而提高查找非空队列的效率。
过期队列
过期队列和活动队列结构相同,但它存放的是那些时间片已经用完的进程。进程会在时间片耗尽后从活动队列转移到过期队列。过期队列上的进程需要等待新的时间片分配。
- 当活动队列中的进程都被处理完毕,操作系统会将过期队列中的进程重新加入活动队列,并重新计算它们的时间片。
过期队列的作用:
- 过期队列存储已经使用完时间片的进程,直到操作系统能够为它们重新分配时间片。
- 当活动队列为空时,操作系统会从过期队列中挑选进程,继续进行调度。
active指针和expired指针
active
** 指针**:总是指向当前活动队列,即那些时间片还没有用完、准备被调度的进程队列。expired
** 指针**:总是指向过期队列,即那些时间片已经用完、需要等待重新分配时间片的进程队列。
指针交换优化:
- 为什么需要交换
active
和expired
指针:随着进程时间片的使用,活动队列上的进程会越来越少,而过期队列上的进程会越来越多。因此,操作系统在适当的时机交换active
和expired
指针,实际上就是将过期队列中的进程转换为活动进程,开始新的调度周期。
通过交换这两个指针,操作系统能够保持进程的高效调度,同时避免频繁地重新计算和移动进程,提高了调度的效率。
环境变量
基本概念
环境变量(environment variables)是操作系统中用于存储关于系统、进程和会话配置的参数。这些变量帮助程序在运行时获取操作系统提供的各种信息,如当前用户的主目录、命令的搜索路径、所用的 Shell 等。它们通常以 KEY=VALUE
的形式存储。
在 Linux 中,环境变量是通过字符指针数组存储的,每个指针指向一个以 \0
结尾的字符串。操作系统会在进程启动时将这些环境变量传递给进程。进程可以使用这些变量来调整自身的行为。
环境变量通常具有以下几个特性:
- 全局特性:环境变量是全局有效的,通常会在进程的生命周期内对该进程及其所有子进程有效。
- 影响进程的执行:程序可以通过环境变量读取或修改系统参数,如命令路径、临时目录、文件权限等。
例如,我们编写的C/C++代码,在各个目标文件进行链接的时候,从来不知道我们所链接的动静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
常见环境变量
PATH
- 作用:
PATH
是最常用的环境变量之一,它指定了操作系统查找可执行命令的目录路径。当用户输入一个命令时,操作系统会按照PATH
中指定的目录顺序查找该命令的可执行文件。 - 示例:
echo $PATH
输出可能是:
/usr/sbin:/usr/bin:/bin:/sbin
这意味着系统会在 /usr/sbin
、/usr/bin
、/bin
和 /sbin
等目录下查找用户输入的命令。
HOME
- 作用:
HOME
变量指定当前用户的主目录。它是用户登录系统后默认的工作目录。 - 示例:
echo $HOME
输出可能是:
/home/username
这表示当前用户的主目录是 /home/username
。
- `~`** 与 **`$HOME`:在 Linux 中,`~` 是 `HOME` 的快捷方式,`cd ~` 等价于 `cd $HOME`,都将用户带到主目录。
SHELL
- 作用:
SHELL
变量指定当前用户使用的 Shell 程序。它通常设置为/bin/bash
或/bin/zsh
等,表示用户当前使用的 Shell 环境。 - 示例:
echo $SHELL
输出可能是:
/bin/bash
表示用户正在使用 Bash shell。
USER
- 作用:
USER
变量指定当前登录系统的用户名。 - 示例:
echo $USER
输出可能是:
username
表示当前登录的用户名是 username
。
查看环境变量的方法
我们可以通过echo命令来查看环境变量,方式如下:
echo $NAME
//NAME为待查看的环境变量名称
例如,查看环境变量PATH。
echo $PATH
env//查看所有环境变量
set//查看所有环境变量和shell变量
测试PATH
大家有没有想过这样一个问题:为什么执行ls命令的时候不用带./就可以执行,而我们自己生成的可执行程序必须要在前面带上./才可以执行?
容易理解的是,要执行一个可执行程序必须要先找到它在哪里,既然不带./就可以执行ls命令,说明系统能够通过ls名称找到ls的位置,而系统是无法找到我们自己的可执行程序的,所以我们必须带上./,以此告诉系统该可执行程序位于当前目录下。
而系统就是通过环境变量PATH来找到ls命令的,查看环境变量PATH我们可以看到如下内容:
可以看到环境变量PATH当中有多条路径,这些路径由冒号隔开,当你使用ls命令时,系统就会查看环境变量PATH,然后默认从左到右依次在各个路径当中进行查找。
而ls命令实际就位于PATH当中的某一个路径下,所以就算ls命令不带路径执行,系统也是能够找到的。
那可不可以让我们自己的可执行程序也不用带路径就可以执行呢?
当然可以,下面给出两种方式:
方式一:将可执行程序拷贝到环境变量PATH的某一路径下。
既然在未指定路径的情况下系统会根据环境变量PATH当中的路径进行查找,那我们就可以将我们的可执行程序拷贝到PATH的某一路径下,此后我们的可执行程序不带路径系统也可以找到了。
方式二:将可执行程序所在的目录导入到环境变量PATH当中。
将可执行程序所在的目录导入到环境变量PATH当中,这样一来,没有指定路径时系统就会来到该目录下进行查找了。
export PATH=$PATH:/home/cl/dirforproc/ENV
将可执行程序所在的目录导入到环境变量PATH当中后,位于该目录下的可执行程序也就可以在不带路径的情况下执行了。
测试HOME
任何一个用户在运行系统登录时都有自己的主工作目录(家目录),环境变量HOME当中即保存的该用户的主工作目录。
普通用户示例:
超级用户示例:
cd ~
来切换到当前用户的主目录,等同于cd $HOME
。
测试SHELL
我们在Linux操作系统当中所敲的各种命令,实际上需要由命令行解释器进行解释,而在Linux当中有许多种命令行解释器(例如bash、sh),我们可以通过查看环境变量SHELL来知道自己当前所用的命令行解释器的种类。
而该命令行解释器实际上是系统当中的一条命令,当这个命令运行起来变成进程后就可以为我们进行命令行解释。
和环境变量相关的命令
1、echo:显示某个环境变量的值。
2、export:设置一个新的环境变量。
3、env:显示所有的环境变量。
部分环境变量说明:
环境变量名称 | 表示内容 |
---|---|
PATH | 命令的搜索路径 |
HOME | 用户的主工作目录 |
SHELL | 当前Shell |
HOSTNAME | 主机名 |
TERM | 终端类型 |
HISTSIZE | 记录历史命令的条数 |
SSH_TTY | 当前终端文件 |
USER | 当前用户 |
邮箱 | |
PWD | 当前所处路径 |
LANG | 编码格式 |
LOGNAME | 登录用户名 |
4、set:显示本地定义的shell变量和环境变量。
5、unset:清除环境变量。
环境变量的组织方式
在系统当中,环境变量的组织方式如下:
每个程序都会收到一张环境变量表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串,最后一个字符指针为空。
环境变量的特性
环境变量的继承
环境变量在进程的生命周期中由父进程传递给子进程。当一个进程创建子进程时,子进程会继承父进程的环境变量。
- 父进程修改环境变量,不影响已运行的子进程。
- 子进程修改环境变量,也不会影响父进程。
示例:父子进程环境变量继承
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>int main() {printf("Parent: PATH = %s\n", getenv("PATH"));pid_t pid = fork();if (pid == 0) {// 子进程setenv("PATH", "/tmp", 1);printf("Child: PATH = %s\n", getenv("PATH"));} else {// 父进程sleep(1); // 等待子进程修改完成printf("Parent after fork: PATH = %s\n", getenv("PATH"));}return 0;
}
子进程的PATH环境变量修改了,但是父进程的没有被修改
输出说明:
- 子进程的修改不会影响父进程的环境变量。
环境变量的生命周期
- 环境变量的生命周期与进程绑定。
- 进程终止时,其环境变量也会被销毁。
获取环境变量
命令行参数
你知道main函数其实是有参数的吗?
main函数其实有三个参数,只是我们平时基本不用它们,所以一般情况下都没有写出来。
我们可以在Windows下的编译器进行验证,当我们调试代码的时候,若是一直使用逐步调试,那么最终会来到调用main函数的地方。
在这里我们可以看到,调用main函数时给main函数传递了三个参数。
我们先来说说main函数的前两个参数。
在Linux操作系统下,编写以下代码,生成可执行程序并运行。
运行结果如下:
现在我们来说说main函数的前两个参数,main函数的第二个参数是一个字符指针数组,数组当中的第一个字符指针存储的是可执行程序的位置,其余字符指针存储的是所给的若干选项,最后一个字符指针为空,而main函数的第一个参数代表的就是字符指针数组当中的有效元素个数。
命令行参数数组的元素个数是动态变化的,有几个参数就有对应的长度大小:
在命令行中传递的各种各样的数据最终都会传递给main函数,由main函数一次保存在 argv 中,由argc 再表明个数 。
数组结尾是NULL,那么可以不使用argc吗?不可以,原因有两个:
- 作为数组传参,一般建议把个数带上
- 用户填参数到命令行,如果想限定用户输入命令行参数的个数,就要用到argc,例如:
if(argc != 5)
{//TODO
}
命令行参数的作用在于,同一个程序可以用给它带入不同参数的方式来让它呈现出不同的表现形式或完成不同功能,例如:
下面我们可以尝试编写一个简单的代码,该代码运行起来后会根据你所给选项给出不同的提示语句。
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[], char* envp[])
{if(argc > 1){if(strcmp(argv[1], "-a") == 0){printf("you used -a option...\n");}else if(strcmp(argv[1], "-b") == 0){printf("you used -b option...\n");}else{printf("you used unrecognizable option...\n");}}else{printf("you did not use any option...\n");}return 0;
}
代码运行结果如下:
命令行参数的意义在于,指令有很多选项,用来完成同一个命令的不同子功能。选项底层使用的就是命令行参数。
假如函数没有参数,那么可以使用可变参数列表去获取。
现在我们来说说main函数的第三个参数。
环境变量表
每个进程在启动的时候都会收到一张环境遍历表,环境变量表主要指环境变量的集合,每个进程都有一个环境变量表,用于记录与当前进程相关的环境变量信息。
环境变量表采用字符指针数组的形式进行存储,然后使用全局变量char** envrion来记录环境变量表的首地址,使用NULL表示环境表的末尾:
main 函数有两种写法:带参与不带参,平常我们都是使用不带参数的 main 函数作为程序入口,对于函数参数很少关注,今天就来看看 main 函数中的参数:
int main(int argc, char* argv[], char* envp[])
{}
- int argc 传入程序中的元素数,./程序名 算一个
- char* argv[] 传入程序中的元素表,由 bash 制作,传给 main 函数
- char* envp[] 环境变量表,所谓全局性就是指 main 函数可以通过此参数获取到环境变量表的信息
main函数的第三个参数接收的实际上就是环境变量表,我们可以通过main函数的第三个参数来获取系统的环境变量。
例如,编写以下代码,生成可执行程序并运行。
运行结果就是各个环境变量的值:
通过全局变量 environ (char** 类型) 获取
除了使用main函数的第三个参数来获取环境变量以外,我们还可以通过第三方变量environ来获取。
运行该代码生成的可执行程序,我们同样可以获得环境变量的值:
注意: libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时要用extern进行声明。
通过系统调用获取环境变量
除了通过main函数的第三个参数和第三方变量environ来获取环境变量外,我们还可以通过系统调用getenv函数来获取环境变量。
getenv函数可以根据所给环境变量名,在环境变量表当中进行搜索,并返回一个指向相应值的字符串指针。
例如,使用getenv函数获取环境变量PATH的值。
运行结果:
本地变量(Local Variables)
本地变量 是指在函数或代码块内部定义的变量,它们的作用范围仅限于该函数或代码块。这些变量在函数或代码块调用时被创建,并在执行完毕后销毁。
- 本地变量的特点
- 作用域:本地变量的作用域仅限于它所在的函数或代码块内。其他函数或代码块无法访问该变量。
- 生命周期:本地变量的生命周期仅限于函数调用期间。当函数调用结束时,本地变量会被销毁,内存空间被释放。
- 存储位置:本地变量通常存储在栈区(Stack)。栈区用于存储函数的局部变量和调用信息。每次函数调用时,会在栈上为本地变量分配空间,并在函数返回时释放该空间。
- 本地变量的定义
在 C 语言中,本地变量通常是在函数内部通过关键字 int
、char
等类型定义的。
#include <stdio.h>void example() {int local_var = 10; // 本地变量printf("Value of local_var: %d\n", local_var);
}int main() {example(); // 调用函数 examplereturn 0;
}
- 在这个例子中,
local_var
是一个本地变量,它仅在example
函数的作用域内有效。当example
函数调用结束时,local_var
被销毁,无法再访问。
- 本地变量的生命周期与栈空间
在函数调用时,本地变量通常会被分配在栈区。当函数执行结束时,这些变量会被销毁。
- 栈区:栈是计算机内存的一种结构,操作系统为每个函数调用分配一个栈帧,栈帧存储函数的局部变量、返回地址等信息。每当一个函数被调用时,会为该函数分配新的栈帧,当函数返回时,这个栈帧会被销毁,释放掉相关的内存空间。
本地变量在栈上的分配和释放是自动的,因此开发者不需要手动管理内存。
- 本地变量与内存模型
程序的内存结构一般包括以下几个区域:
- 代码区(text section):存放程序的代码。
- 数据区:存放全局变量、静态变量等。
- 堆区(heap):动态分配内存区域,通过
malloc
和free
来管理。 - 栈区(stack):存放函数的局部变量和函数调用信息。
本地变量通常存储在 栈区。每次调用函数时,操作系统会在栈上为本地变量分配空间,调用结束时栈空间自动释放。
- 本地变量的初始化
在 C 语言中,本地变量如果没有显式初始化,其初始值是 未定义的。这意味着未初始化的本地变量会含有不确定的值(通常是栈中残留的数据),使用这样的变量会导致未定义的行为。
#include <stdio.h>void example() {int local_var; // 未初始化的本地变量printf("Value of local_var: %d\n", local_var); // 输出不确定的值
}int main() {example(); // 调用函数 examplereturn 0;
}
注意:为了确保程序的正确性,应该始终初始化本地变量,避免使用未定义的值。
- 本地变量与参数
在函数中,函数参数 也是一种特殊的本地变量。当我们定义函数时,传递给函数的参数在函数内部充当本地变量的角色。例如:
#include <stdio.h>void add(int a, int b) { // 参数a和b是本地变量int result = a + b; // result是本地变量printf("Result: %d\n", result);
}int main() {add(5, 10); // 调用 add 函数,传递参数 5 和 10return 0;
}
在这个例子中,a
、b
和 result
都是 本地变量。a
和 b
是从 main
函数传递给 add
函数的参数,它们在 add
函数内充当本地变量的角色。每次调用 add
函数时,a
和 b
会根据传入的参数值进行初始化,而 result
是在 add
函数内定义的本地变量。
- 本地变量与作用域
本地变量的作用域是 局部的,即只能在函数内部或代码块中访问。超出该范围后,这些变量将不可见,无法访问。
例如:
#include <stdio.h>void example() {int local_var = 100; // 只在 example 函数内有效printf("Value of local_var: %d\n", local_var);
}int main() {example();// printf("local_var: %d\n", local_var); // 错误:local_var 在这里不可见return 0;
}
在上面的代码中,local_var
只能在 example
函数内部访问,main
函数无法访问它。这样,局部变量的作用域能够避免在其他函数中出现命名冲突。
- 本地变量的作用与应用场景
- 作用:本地变量用于在函数内部存储临时数据。例如,函数的参数、临时计算结果等。
- 应用场景:本地变量通常用于存储局部数据,保证不同函数间的独立性。它们避免了全局变量可能带来的命名冲突或数据不一致问题。
程序地址空间
下面这张空间布局图相信大家都见过:
我们通过两段代码来验证一下
代码1.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>int g_val = 10; // 全局变量,属于已初始化数据区void function() {int local_var = 20; // 局部变量,属于栈区printf("Stack variable address: %p\n", (void*)&local_var);
}int main(int argc, char *argv[]) {// 命令行参数区printf("Command line argument address: %p\n", (void*)&argv);// 初始化数据区(全局变量 g_val)printf("Global variable address (initialized data): %p\n", (void*)&g_val);// 动态分配的内存,属于堆区int *heap_var = (int*)malloc(sizeof(int));if (heap_var) {*heap_var = 30;printf("Heap variable address: %p\n", (void*)heap_var);free(heap_var); // 释放堆内存}// 调用函数,验证栈区function();// 在堆区动态分配并验证未初始化数据区static int static_var; // 静态变量,属于已初始化数据区printf("Static variable address (initialized data): %p\n", (void*)&static_var);// 代码区是程序代码存储的区域,无法通过标准C代码获取,但我们可以通过程序本身执行的地址来隐式理解。printf("Address of main function (code section): %p\n", (void*)main);return 0;
}
代码解释
- 全局变量
g_val
:属于已初始化数据区。当你声明全局变量并给它赋初值时,它会被存储在 已初始化数据区 中。 - 局部变量
local_var
:属于 栈区。局部变量是在函数调用时创建并分配的,它们存储在栈中,并在函数调用结束时销毁。 - 命令行参数
argv
:存储在程序的 命令行参数环境变量区 中。在main
函数的参数中,我们可以访问传递给程序的命令行参数。 - 动态分配的内存:通过
malloc
动态分配的内存属于 堆区,可以在运行时根据需要分配。 - 静态变量
static_var
:属于 已初始化数据区。静态变量的生命周期与程序的整个生命周期一致,不管它是否在函数内部定义。 - 代码区:程序的可执行代码存在于 代码区。通过打印
main
函数的地址,我们间接展示了代码区的存在。
运行结果
假设运行上述代码时,我们可能会看到如下输出(根据实际内存分配情况有所不同):
Command line argument address: 0x7fff5fbff7a0
Global variable address (initialized data): 0x601020
Heap variable address: 0x604000
Stack variable address: 0x7fff5fbff5b4
Static variable address (initialized data): 0x601010
Address of main function (code section): 0x4005d6
解释输出:
- 命令行参数地址:显示
argv
参数的地址,这个区域属于 命令行参数环境变量区。 - 全局变量地址:显示全局变量
g_val
的地址,这个变量属于 已初始化数据区。 - 堆区变量地址:显示通过
malloc
分配的内存地址,这个地址属于 堆区。 - 栈区变量地址:显示局部变量
local_var
的地址,它属于 栈区。 - 静态变量地址:显示静态变量的地址,它属于 已初始化数据区。
- 代码区地址:显示
main
函数的地址,程序的代码通常存储在 代码区。
通过这个程序,我们能够看到不同的内存区域(如栈区、堆区、数据区等)如何通过变量地址映射到不同的内存区域。尽管操作系统通过虚拟内存技术将这些区域管理得很灵活,使用代码打印这些变量的内存地址可以帮助我们理解内存的布局,进而验证图片中的虚拟地址空间的各个部分。
代码2.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>int g_unval; // 未初始化的全局变量,属于 BSS 区
int g_val = 100; // 已初始化的全局变量,属于数据区int main(int argc, char *argv[], char *env[]) {printf("code addr: %p\n", main); // 打印代码区地址,即 main 函数的地址printf("init data addr: %p\n", &g_val); // 打印已初始化数据区的地址printf("uninit data addr: %p\n", &g_unval); // 打印未初始化数据区的地址// 动态分配内存,属于堆区char *heap = (char*)malloc(20);char *heap1 = (char*)malloc(20);char *heap2 = (char*)malloc(20);char *heap3 = (char*)malloc(20);int c = 0; // 局部变量,属于栈区printf("heap addr: %p\n", heap);printf("heap1 addr: %p\n", heap1);printf("heap2 addr: %p\n", heap2);printf("heap3 addr: %p\n", heap3);printf("stack addr: %p\n", &c); // 打印栈区变量 c 的地址// 打印命令行参数的地址for (int i = 0; i < argc; i++) {printf("argv[%d]=%p\n", i, argv[i]);}// 打印环境变量的地址for (int i = 0; env[i]; i++) {printf("env[%d]=%p\n", i, env[i]);}return 0;
}
- 内存区域分析
这段代码展示了程序内存中的各个区域和它们的地址:
- 代码区(Code Section):存放程序的指令。
main
函数的地址通过main
打印。 - 数据区(Data Section):存放已初始化的全局变量,如
g_val
。其地址通过&g_val
打印。 - BSS 区(未初始化的数据区):存放未初始化的全局变量,如
g_unval
。其地址通过&g_unval
打印。 - 堆区(Heap):通过
malloc
动态分配内存的区域。程序动态分配了 4 块 20 字节的内存,并打印它们的地址。 - 栈区(Stack):用于存储局部变量。
int c = 0;
在栈中分配内存,通过&c
打印栈区的地址。
- 命令行参数和环境变量
- 命令行参数(
argv
):argc
是命令行参数的数量,argv
是一个字符串数组,其中存储了每个命令行参数。通过printf
打印出每个命令行参数的地址。 - 环境变量(
env
):环境变量存储了与系统环境相关的变量(如PATH
、USER
等)。通过env
数组可以访问这些变量,并打印出它们的地址。
- 输出示例
假设我们执行以下命令:
./myenv arg1 arg2 arg3
输出将类似于:
code addr: 0x4005f6
init data addr: 0x601020
uninit data addr: 0x601028
heap addr: 0x604000
heap1 addr: 0x604020
heap2 addr: 0x604040
heap3 addr: 0x604060
stack addr: 0x7fff5fbff7b8
argv[0]=0x601000
argv[1]=0x601020
argv[2]=0x601030
argv[3]=0x601040
env[0]=0x601050
env[1]=0x601060
- 解释输出
- 代码区:
main
函数所在的地址(code addr
)显示了程序代码的位置。 - 数据区:
g_val
是已初始化的全局变量,它的地址显示了其存储在内存中的位置。 - 未初始化数据区:
g_unval
是未初始化的全局变量,显示了它的内存地址,通常是在 BSS 区。 - 堆区:通过
malloc
分配的内存块,显示了它们的内存地址。每次调用malloc
都会分配一块新的堆内存。 - 栈区:局部变量
c
存储在栈区,显示了栈中局部变量的地址。 - 命令行参数:显示了通过命令行传递的各个参数的内存地址。
- 环境变量:显示了程序的环境变量在内存中的地址。
下面我们来看一段奇怪的代码:
代码当中用fork函数创建了一个子进程,其中让子进程相将全局变量g_val该从100改为200后打印,而父进程先休眠3秒钟,然后再打印全局变量的值。
按道理来说子进程打印的全局变量的值为200,而父进程是在子进程将全局变量改后再打印的全局变量,那么也应该是200,但是代码运行结果如下:
可以看到父进程打印的全局变量g_val的值仍为之前的100,更奇怪的是在父子进程中打印的全局变量g_val的地址是一样的,也就是说父子进程在同一个地址处读出的值不同。
如果说我们是在同一个物理地址处获取的值,那必定是相同的,而现在在同一个地址处获取到的值却不同,这只能说明我们打印出来的地址绝对不是物理地址!!!
实际上,我们在语言层面上打印出来的地址都不是物理地址,而是虚拟地址。物理地址用户一概是看不到的,是由操作系统统一进行管理的。
g_val
** 的地址相同,但值不相同**。这是因为:
- 虚拟地址相同:在调用
fork()
后,父子进程的虚拟地址空间初始时是完全相同的(包括全局变量的地址)。操作系统将虚拟地址映射到物理内存的过程中,子进程和父进程使用相同的虚拟地址。- 物理地址不同:由于写时复制的机制,虽然父子进程的虚拟地址相同,但它们的物理地址是不同的。当子进程修改
g_val
时,操作系统为子进程分配了新的物理内存,而父进程的物理内存则保持不变。
所以就算父子进程当中打印出来的全局变量的地址(虚拟地址)相同,但是两个进程当中全局变量的值却是不同的。
注意: 虚拟地址和物理地址之间的转化由操作系统完成。
故事理解进程地址空间
Linux进程地址空间的故事:富翁与虚拟王国的奥秘
引入
在遥远的数字王国里,有一位极其富有的富翁,名叫“操作系统”。他拥有无尽的“内存宝藏”,这些宝藏被巧妙地划分为一个个独立的“虚拟王国”。每个王国都由一个叫做“进程”的领主统治,他们各自在自己的领域内繁衍生息,互不干扰。今天,我们就来揭开这个虚拟王国的神秘面纱,探索Linux进程地址空间的奥秘。
进程地址空间
让我们先通过一个故事来引入进程地址空间的概念。
故事开始:
在数字王国里,富翁“操作系统”拥有1000亿单位的内存宝藏(为了简化,我们假设这是一个非常大的数字,代表Linux 64位系统中的巨大地址空间)。他有无数个私生子,每个私生子都代表一个进程。这些私生子并不知道彼此的存在,都以为自己是富翁唯一的继承人,可以独自享用这无尽的宝藏。
富翁“操作系统”给每个私生子都画了一张“大饼”——一个虚拟的4GB(或更大,取决于系统位数)地址空间。这张饼上,从0x00000000到0xFFFFFFFF(对于32位系统)的地址范围,都被私生子们视为自己的领地。他们可以在这个领地上自由分配空间,存放代码、数据和堆栈等。
然而,这些私生子们并不知道,他们实际上占用的物理内存远小于这个虚拟地址空间。富翁“操作系统”通过一种叫做“页表”的神奇机制,将虚拟地址映射到物理地址上。这样,每个私生子(进程)都以为自己拥有整个宝藏,但实际上他们只能访问到富翁(操作系统)分配给他们的那一小部分。
父子进程的地址空间
现在,让我们来看看父子进程之间的地址空间是如何工作的。
假设富翁“操作系统”有一个私生子叫“父进程”,他通过“fork”系统调用生了一个孩子叫“子进程”。子进程几乎完全继承了父进程的所有财产(包括虚拟地址空间)。然而,他们之间的财产(内存)并不是共享的。
当子进程想要修改某个变量时,富翁“操作系统”会施展一种叫做“写时拷贝”的魔法。他会在物理内存中为子进程分配一块新的空间,并将这个变量的值复制过去。然后,他会修改子进程的页表,使这个变量的虚拟地址指向新的物理地址。这样,子进程对这个变量的修改就不会影响到父进程了。
这就是为什么我们在Linux中经常看到父子进程中的某个变量地址相同但值不同的原因。它们虽然拥有相同的虚拟地址,但通过页表映射到了不同的物理地址上。
地址空间的意义
进程地址空间在Linux系统中扮演着至关重要的角色。它不仅防止了进程之间的非法访问和干扰,还实现了进程管理和内存管理的解耦合。每个进程都可以在自己的虚拟地址空间中自由分配和使用内存,而不需要关心实际的物理内存布局。
此外,进程地址空间还提高了内存管理的效率和安全性。通过写时拷贝和页表机制,Linux系统能够在需要时动态地分配和回收内存资源,从而满足不同进程的需求。
malloc的本质
在Linux中,我们经常使用malloc
函数来动态申请内存空间。那么,malloc
的本质是什么呢?
其实,malloc
就是在进程的虚拟地址空间中分配一块空间,并在页表中建立相应的映射关系。然而,这块空间在物理内存中可能并不存在(直到你真正使用它时才会被分配)。这种延迟分配的策略大大提高了内存的使用效率。
当你向malloc
申请一块内存时,它只是改变了进程地址空间中的某个边界值(如heap_end
),并在页表中为这块虚拟地址空间预留了一个位置。只有当你真正向这块空间写入数据时,Linux系统才会通过缺页中断机制在物理内存中分配相应的空间,并更新页表。
重新理解地址空间
通过上面的故事和解释,相信你已经对Linux进程地址空间有了更深入的理解。进程地址空间并不是真实的物理内存空间,而是操作系统为进程提供的一个虚拟的、独立的内存环境。每个进程都在这个环境中运行和管理自己的内存资源。
当我们编写程序时,编译器和链接器会按照进程地址空间的规则来生成可执行文件。当我们运行程序时,操作系统会为每个进程创建一个独立的地址空间,并通过页表机制将虚拟地址映射到物理地址上。这样,每个进程都能在自己的虚拟王国中自由翱翔,而不会受到其他进程的干扰和影响。
希望这个故事能够帮助你更好地理解Linux进程地址空间的概念、作用以及在Linux系统中的重要性。
进程地址空间
我们之前将那张布局图称为程序地址空间实际上是不准确的,那张布局图实际上应该叫做进程地址空间,进程地址空间本质上是内存中的一种内核数据结构,在Linux当中进程地址空间具体由结构体mm_struct实现。
进程地址空间就类似于一把尺子,尺子的刻度由0x00000000到0xffffffff,尺子按照刻度被划分为各个区域,例如代码区、堆区、栈区等。而在结构体mm_struct当中,便记录了各个边界刻度,例如代码区的开始刻度与结束刻度,如下图所示:
在结构体mm_struct当中,各个边界刻度之间的每一个刻度都代表一个虚拟地址,这些虚拟地址通过页表映射与物理内存建立联系。由于虚拟地址是由0x00000000到0xffffffff线性增长的,所以虚拟地址又叫做线性地址。
扩展知识:
1、堆向上增长以及栈向下增长实际就是改变mm_struct当中堆和栈的边界刻度。
2、我们生成的可执行程序实际上也被分为了各个区域,例如初始化区、未初始化区等。当该可执行程序运行起来时,操作系统则将对应的数据加载到对应内存当中即可,大大提高了操作系统的工作效率。而进行可执行程序的“分区”操作的实际上就算编译器,所以说代码的优化级别实际上是编译器说了算。
虚拟到物理地址的转换
既然是虚拟地址,最终我们肯定要在物理内存上存储数据,那么从虚拟地址到物理地址就需要一个转换过程!
每个进程被创建时,其对应的进程控制块(task_struct)和进程地址空间(mm_struct)也会随之被创建。而操作系统可以通过进程的task_struct找到其mm_struct,因为task_struct当中有一个结构体指针存储的是mm_struct的地址。
例如,父进程有自己的task_struct和mm_struct,该父进程创建的子进程也有属于其自己的task_struct和mm_struct,父子进程的进程地址空间当中的各个虚拟地址分别通过页表映射到物理内存的某个位置,如下图:
而当子进程刚刚被创建时,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改。
例如,子进程需要将全局变量g_val改为200,那么此时就在内存的某处存储g_val的新值,并且改变子进程当中g_val的虚拟地址通过页表映射后得到的物理地址即可。
这种在需要进行数据修改时再进行拷贝的技术,称为写时拷贝技术。
1、为什么数据要进行写时拷贝?
进程具有独立性。多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程。
2、为什么不在创建子进程的时候就进行数据的拷贝?
子进程不一定会使用父进程的所有数据,并且在子进程不对数据进行写入的情况下,没有必要对数据进行拷贝,我们应该按需分配,在需要修改数据的时候再分配(延时分配),这样可以高效的使用内存空间。
3、代码会不会进行写时拷贝?
90%的情况下是不会的,但这并不代表代码不能进行写时拷贝,例如在进行进程替换的时候,则需要进行代码的写时拷贝。
为什么要有进程地址空间?
1、有了进程地址空间后,就不会有任何系统级别的越界问题存在了。例如进程1不会错误的访问到进程2的物理地址空间,因为你对某一地址空间进行操作之前需要先通过页表映射到物理内存,而页表只会映射属于你的物理内存。总的来说,虚拟地址和页表的配合使用,本质功能就是包含内存。
2、有了进程地址空间后,每个进程都认为看得到都是相同的空间范围,包括进程地址空间的构成和内部区域的划分顺序等都是相同的,这样一来我们在编写程序的时候就只需关注虚拟地址,而无需关注数据在物理内存当中实际的存储位置。
3、有了进程地址空间后,每个进程都认为自己在独占内存,这样能更好的完成进程的独立性以及合理使用内存空间(当实际需要使用内存空间的时候再在内存进行开辟),并能将进程调度与内存管理进行解耦或分离。
对于创建进程的现阶段理解:
一个进程的创建实际上伴随着其进程控制块(task_struct)、进程地址空间(mm_struct)以及页表的创建。