目录
环境变量
基本概念
常见环境变量
查看环境变量的方法
测试PATH
测试HOME
测试SHELL
和环境变量相关的命令
环境变量的组织方式
通过代码获取环境变量
通过系统调用获取环境变量
程序地址空间
进程地址空间
Linux2.6内核进程调度队列
一个CPU拥有一个runqueue
优先级
活动队列
过期队列
active指针和expired指针
环境变量
基本概念
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。
例如:我们编写的C/C++代码,在各个目标文件进行链接的时候,从来不知道我们所链接的动静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
环境变量通常具有某些特殊用途,并且在系统当中通常具有全局特性。
常见环境变量
- PATH: 指定命令的搜索路径。
- HOME: 指定用户的主工作目录(即用户登录到Linux系统中的默认所处目录)。
- SHELL: 当前Shell,它的值通常是/bin/bash。
查看环境变量的方法
我们可以通过echo命令来查看环境变量,方式如下:
echo $NAME //NAME为待查看的环境变量名称
例如:查看环境变量PATH。
russleo@VM-0-2-ubuntu:~$ echo $PATH
测试PATH
当你在计算机的终端界面输入ls
命令时,瞬间就能看到当前目录下的文件和子目录列表,整个过程流畅且高效,仿佛魔法一般。但这个“魔法”背后的原理却并非神秘莫测,而是遵循了一套清晰的规则——这就是Linux和Unix系统中命令执行机制的奥秘所在。
在深入探讨之前,让我们先来了解一下为何像ls
这样的常用命令可以不加任何前缀就直接执行。这是因为这些命令通常会被预装在系统的特定目录中,比如/bin
、/usr/bin
等,而这些目录被包含在一个环境变量PATH中。每当你在终端输入一个命令时,Shell(即命令解释器)就会在PATH所列出的目录里寻找与之匹配的可执行文件。由于ls
等基础命令已经被放置在这些目录下,所以Shell可以轻易地找到它们并执行。
相比之下,当你尝试运行一个你自己编译或创建的可执行程序时,情况就大不相同了。假设你的自定义程序存放在当前的工作目录中,Shell并不会自动在这里搜索可执行文件,因为这样做会带来安全风险,同时也可能引起命名冲突。为了确保每次执行的程序都是用户有意为之,系统设计了一个额外的步骤:你需要在命令前加上./
。这个小小的点斜杠组合实际上是在告诉Shell:“请在当前目录下查找并执行接下来指定的程序。”
系统就是通过环境变量PATH来找到ls命令的,查看环境变量PATH我们可以看到如下内容:
可以看到环境变量PATH当中有多条路径,这些路径由冒号隔开,当你使用ls命令时,系统就会查看环境变量PATH,然后默认从左到右依次在各个路径当中进行查找。
而ls命令实际就位于PATH当中的某一个路径下,所以就算ls命令不带路径执行,系统也是能够找到的。
那么我们怎么样能实现自己的可执行程序也不用带路径就可以执行呢?
方式一:将可执行程序拷贝到环境变量PATH的某一路径下
在操作系统中,可执行程序的执行是通过查找环境变量PATH指定的路径来完成的。因此,如果希望在不指定路径的情况下执行程序,可以将该程序拷贝到环境变量PATH包含的某个路径下,系统就能自动找到并执行该程序。
举例:如果我们有一个名为
myprogram
的可执行程序,并希望在任何路径下都能直接执行它,可以按照以下步骤操作:
- 找到系统环境变量PATH包含的路径之一,比如
/usr/local/bin
(在Linux和macOS系统中常见)。- 将
myprogram
程序拷贝到该路径下:cp myprogram /usr/local/bin/
此后,无论当前位于哪个目录,只需输入
myprogram
即可执行该程序,系统会自动在环境变量PATH指定的路径中查找并执行它。
方式二:将可执行程序所在的目录导入到环境变量PATH中
如果希望多个可执行程序所在的目录都能被系统自动找到,可以将这些目录添加到环境变量PATH中。这种方式适用于同时管理多个程序的情况。
找到你想要添加的目录路径,假设为
/path/to/your/executables
。将该路径添加到环境变量PATH中,可以通过以下命令实现(假设使用bash shell):
export PATH="/path/to/your/executables:$PATH"
这样做会将
/path/to/your/executables
目录添加到PATH的最前面,使得系统优先在该目录查找可执行程序。确认修改生效,可以在当前终端中输入
echo $PATH
查看PATH变量是否包含了新增的目录。
测试HOME
任何一个用户在运行系统登录时都有自己的主工作目录(家目录),环境变量HOME当中即保存的该用户的主工作目录。
普通用户示例:
超级用户示例:
测试SHELL
我们在Linux操作系统当中所敲的各种命令,实际上需要由命令行解释器进行解释,而在Linux当中有许多种命令行解释器(例如bash、sh),我们可以通过查看环境变量SHELL来知道自己当前所用的命令行解释器的种类。
而该命令行解释器实际上是系统当中的一条命令,当这个命令运行起来变成进程后就可以为我们进行命令行解释。
和环境变量相关的命令
- echo:显示某个环境变量的值。
- export:设置一个新的环境变量。
- env:显示所有的环境变量。
- 部分环境变量说明:
环境变量名称 | 表示内容 |
---|---|
PATH | 命令的搜索路径 |
HOME | 用户的主工作目录 |
SHELL | 当前Shell |
HOSTNAME | 主机名 |
TERM | 终端类型 |
HISTSIZE | 记录历史命令的条数 |
SSH_TTY | 当前终端文件 |
USER | 当前用户 |
邮箱 | |
PWD | 当前所处路径 |
LANG | 编码格式 |
LOGNAME | 登录用户名 |
- set:显示本地定义的shell变量和环境变量。
- unset:清除环境变量。
环境变量的组织方式
在系统当中,环境变量的组织方式如下:
每个程序都会收到一张环境变量表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串,最后一个字符指针为空。
通过代码获取环境变量
你知道main函数其实是有参数的吗?
main函数其实有三个参数,只是我们平时基本不用它们,所以一般情况下都没有写出来。
我们可以在Windows下的编译器进行验证,当我们调试代码的时候,若是一直使用逐步调试,那么最终会来到调用main函数的地方。在这里我们可以看到,调用main函数时给main函数传递了三个参数。
下面我们先通过代码解释一下前两个参数:
在Linux操作系统下,编写以下代码,生成可执行程序并运行。
main函数的参数解释如下:第一个参数表示字符指针数组中有效元素的数量,而第二个参数是一个字符指针数组。该数组的第一个元素存储可执行程序的位置,其余元素存储给定的选项,而数组的最后一个元素为NULL。
下面我们可以尝试编写一个简单的代码:
该代码运行起来后会根据你所给选项给出不同的提示语句。
#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函数的第三个参数实际上是环境变量表,通过这个参数可以访问系统的环境变量。
例如,编写以下代码:
运行结果就是各个环境变量的值:
除了使用main函数的第三个参数来获取环境变量以外,我们还可以通过第三方变量environ来获取。
运行该代码生成的可执行程序,我们同样可以获得环境变量的值:
注意: libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时要用extern进行声明。
通过系统调用获取环境变量
除了通过main函数的第三个参数和全局变量environ来获取环境变量,我们还可以使用系统调用的getenv函数。该函数接受环境变量的名称作为参数,在环境变量表中进行搜索,并返回指向对应值的字符串指针。
例如,使用getenv函数获取环境变量PATH的值。
程序地址空间
在我们之前的学习过程中,对于下面这张空间布局图都有所了解
在Linux操作系统中,我们可以通过以下代码对该布局图进行验证:
代码结果:
下面我们来再看一段代码
在代码中,我们使用 fork
函数创建了一个子进程。子进程将全局变量 g_val
从 100 修改为 200 后进行打印。而父进程则先休眠 3 秒钟,然后再打印全局变量的值。
理论上来说,子进程打印的全局变量值应该是 200,由于父进程是在子进程修改全局变量之后才进行打印,所以父进程打印的全局变量值也应该是 200。然而,实际运行结果如下所示:
可以看到,父进程打印的全局变量 g_val
的值仍然是之前的 100。更奇怪的是,父进程和子进程中打印的全局变量 g_val
的地址是相同的,这意味着父子进程在同一个地址读取到了不同的值。
如果我们在同一个物理地址获取的值,那这些值应该是相同的。然而,现在在同一个地址获取到的值却不同,这只能说明我们打印出来的地址并不是物理地址!
实际上,在编程语言层面打印出来的地址都是虚拟地址。物理地址是用户无法看到的,由操作系统统一管理。因此,即使父子进程打印出来的全局变量地址(虚拟地址)相同,两个进程中的全局变量值仍然是不同的。
注意: 虚拟地址和物理地址之间的转化由操作系统完成。
进程地址空间
我们之前将那张布局图称为程序地址空间实际上是不准确的,实际上那张布局图应该叫做进程地址空间。进程地址空间本质上是内存中的一种内核数据结构,在 Linux 系统中,进程地址空间具体由结构体 mm_struct
来实现。
进程地址空间类似于一把尺子,这把尺子的刻度范围从 0x00000000 到 0xffffffff。尺子按照刻度被划分为不同的区域,例如代码区、堆区、栈区等。在 mm_struct
结构体中,记录了这些区域的边界刻度,例如代码区的开始刻度和结束刻度。如下图所示:
在 mm_struct
结构体中,各个边界刻度之间的每一个刻度都代表一个虚拟地址,这些虚拟地址通过页表映射与物理内存建立联系。由于虚拟地址是从 0x00000000 到 0xffffffff 线性增长的,所以虚拟地址也被称为线性地址。
扩展:
- 堆向上增长和栈向下增长实际上是通过改变
mm_struct
中堆和栈的边界刻度来实现的。- 我们生成的可执行程序实际上也被分为了多个区域,例如初始化区、未初始化区等。当该可执行程序运行时,操作系统将对应的数据加载到相应的内存区域中,这样可以大大提高操作系统的工作效率。实际上,执行程序的“分区”操作是由编译器完成的,因此代码的优化级别实际上是由编译器决定的
每个进程被创建时,其对应的进程控制块(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不会错误地访问到进程2的物理地址空间,因为每次对某一地址空间进行操作之前,需要先通过页表映射到物理内存,而页表只会映射属于进程自己的物理内存。虚拟地址和页表的配合使用,本质上是为了包含内存。
- 统一的地址空间视图:每个进程都认为自己拥有相同的空间范围,包括进程地址空间的构成和内部区域的划分顺序。这样在编写程序时,只需关注虚拟地址,而无需关心数据在物理内存中的实际存储位置。
- 实现进程的独立性和内存空间的合理使用:每个进程都认为自己在独占内存,从而更好地完成进程的独立性。同时,合理使用内存空间,即在实际需要使用内存时再进行分配,可以将进程调度与内存管理解耦或分离。
对于创建进程的现阶段理解
答:一个进程的创建实际上伴随着其进程控制块(
task_struct
)、进程地址空间(mm_struct
)以及页表的创建。
Linux2.6内核进程调度队列
一个CPU拥有一个runqueue
一个 CPU 拥有一个 runqueue
,用于管理进程的调度。如果系统中有多个 CPU,就需要考虑进程的负载均衡问题,以确保每个 CPU 的负载相对均匀。
优先级
优先级队列的下标说明如下:
- 普通优先级:100~139
- 实时优先级:0~99
我们讨论的进程大多数都是普通优先级的。之前提到的 nice
值范围是 -20 到 19,共有 40 个级别,依次对应 queue
中普通优先级的下标 100~139。
需要注意的是,实时优先级用于实时进程,即在执行一个进程之前必须完成另一个进程。现代计算机系统中几乎不再使用这种模型,因此对于 queue
中下标为 0~99 的元素,我们不需要关注。
活动队列
活动队列中包含时间片还没有结束的所有进程,这些进程根据优先级排列,其中 nr_active
代表当前运行状态的进程总数。queue[140]
数组中的每个元素都是一个进程队列,具有相同优先级的进程按 FIFO 规则排队调度。
调度过程如下:
- 从
queue[140]
的下标 0 开始遍历。- 找到第一个非空队列,该队列必定是优先级最高的队列。
- 选择该队列中的第一个进程进行调度。
- 调度完第一个进程后,继续调度该队列中的其他进程,直到该队列的所有进程都被调度完。
- 继续向后遍历
queue[140]
,寻找下一个非空队列。
为了提高查找非空队列的效率,可以使用 5 × 32 个比特位的 bitmap[5]
来表示 queue
数组中的 140 个元素(即 140 个优先级),这大大提高了查找效率。
总结来说,在系统中查找最合适的调度进程的时间复杂度是常数级别的,不会因为进程数量的增加而增加时间成本,这就是所谓的 O(1) 调度算法。
过期队列
- 过期队列和活动队列的结构相同。
- 过期队列上放置的进程都是时间片耗尽的进程。
- 当活动队列上的进程被处理完毕之后,对过期队列的进程进行时间片重新计算。
active指针和expired指针
- active指针永远指向活动队列。
- expired指针永远指向过期队列。
由于活动队列中的时间片未到期的进程会越来越少,而过期队列中的进程数量会越来越多(新创建的进程会放到过期队列中),所以会出现活动队列中的所有进程时间片都到期的情况。此时,将 active
指针和 expired
指针的内容交换,就相当于将过期队列变成活动队列,活动队列变成过期队列,从而重新拥有一批新的活动进程。这样循环进行即可。