Linux : 进程概念
进程概念
理解进程
进程,在教科书里是这样定义的“运行中的程序”。可这并不足够让我们理解进程。
首先程序要运行,就要先将代码数据加载到内存中。但要运行的程序有很多,内存中可能要存很多个程序的代码数据,之后OS就要对这些程序进行管理。由于管理的底层是“先描述,再组织”,所以OS会先创建struct统一描述这些程序的各种属性,有了描述程序属性的struct,之后就能够以合适的方式组织管理这些程序了。
在上述提到的对程序属性加以描述的结构体,就是教科书中的PCB,即Process Control Block。但PCB是一个一般的概念,在Linux系统中,有具体的叫做 task_struct 的结构体对应。
很显然,我们应该跟着操作系统管理程序的方式来理解进程:
进程 = 内核数据结构 + 内存中的代码数据。(PCB是内核数据结构之一)
PCB--task_struct
进程信息被放在被叫做进程控制块的数据结构(即PCB)中,所以PCB就是进程信息的集合。
task_struct是PCB在Linux下的具体表现形式,即在Linux中用task_struct这个结构体来存储进程信息。
task_struct内所包含的进程信息有:标识符,状态,优先级,程序计数器,上下文数据、内存指针、I/O状态信息、记账信息、其他信息等。
标识符(pid)就是对每一个进程的编号。
状态指的是程序任务状态,用enum来枚举。
优先级是相对于其他进程来说执行的先后顺序的优先级。
上下文数据:每个进程运行在CPU时都有个时间片,时间到了,就要换别的进程运行了。而在时间片到的那个点,CPU中的一组寄存器留存着临时数据。为了下次运行该进程时能够无缝衔接,所以需要记录下这些CPU寄存器中的临时数据。我们将其统称为“上下文数据”。
程序计数器:程序计数器记录了某个进程在上次时间片终止时运行到的代码位置。实际上,程序计数器也是一个寄存器,这个记录也是上下文数据。程序计数器的数据就是上下文数据的一部分。
内存指针:指向进程代码及数据在内存中的地址,还有和其他进程共享的内存指针。
I/O状态信息:包含显示的I/O请求,分配给进程的I/O设备和被进程使⽤的⽂件列表。
记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
基本操作
查看进程信息
1.通过系统目录/proc查看
举例:pid为1的进程,其信息就在/proc/1这个目录下。
2.当然也可以使用用户级工具top和ps来获取。
ps axj head -1 && ps axj | grep 9527
&&或者; 可以将两条命令连接起来,逐次执行。先把头输出来,再查找进程9527的信息。
getpid() 获取标识符的系统调用
pid_t getpid(void)这个系统调用会返回当前进程的pid标识符
需要包含<sys/types.h>和<unistd.h>两个头文件。
还有一个getppid()是获取当前进程的父进程的pid标识符。所以当前进程是子进程。
实际上,在Linux中,一个进程都是被另一个进程创建而来,前者被称为子进程,后者被称为父进程。
当我们在命令行上执行一条命令时,就是bash(命令行解释器)这个父进程创建了执行我们输入的命令的子进程。
fork() 创建进程的系统调用
而要创建进程,当然有其相关的系统调用--fork()。直接查man手册未必能看得懂,我们需要具体现象才能辅助理解fork()。
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>int main(){fork();while(1){printf("I am a process, my pid is %d, my ppid is %d", getpid(), getppid());sleep(1);}return 0;
}
明明代码里只有一个printf(),可是实际执行时却是两句话不停的输出。
观察可以发现,第一个process的pid是18962,第二个process的pid是18963。
且第二个process的ppid就是18962(17655是bash的pid)。所以第一个进程是第二个进程的父进程。这里会发现父子进程在同时执行相同的代码,打印各自的pid与ppid。
为什么子进程会执行与父进程一样的代码呢?
因为创建子进程,就像创建了父进程的副本,会执行相同的代码。内核会复制父进程的代码段、数据段、堆、栈、文件描述表、环境变量等。
此时父子是共用物理内存空间的,只有当其中一个进程修改内存时,才会真正给另一个进程开空间复制被修改的内存。(写时拷贝)
再来看第二份代码。
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>void main(){printf("I am a process, my pid is %d, my ppid is %d\n", getpid(), getppid());pid_t id = fork();while(1){if(id<0){perror("fork");return;}else if(id == 0){printf("I am the child, my pid is %d, my ppid is %d\n", getpid(), getppid());}else{printf("I am the parent, my pid is %d, my ppid is %d\n", getpid(), getppid());}sleep(1);
}

第一句printf()是原进程执行的,此时未fork(),还没有创建子进程。
当调用fork()时,父子都从fork()返回的这条语句开始执行,此时变量id要被修改,这个变量不能被二者共享,因为fork()给子进程返回0,给父进程返回子进程的pid,各自进程接收不同的返回值,此时会进行写时拷贝,让子进程有自己的空间存变量id。
正因如此,接下来的判断,可以让父子进程进入各自的逻辑之中,从而实现分流,这都多亏了fork()的返回值。
整个流程就是,在fork()前原进程正常运行,fork()后分出两个进程,一开始指向同一块物理内存空间,但用id接收fork返回值时,进行了写时拷贝,让父子进程有了不同的id值,之后再通过条件语句进行分流,各走各的路。
总结一下,fork()创建子进程后,如果未分流,父子进程会执行相同的代码;但利用fork()的返回值进行分流,就能够实现父子进程各玩各的。
进程状态
教科书中的进程状态

教科书对进程状态的阐释复杂难懂,分出了各种概念:创建、就绪、运行、阻塞、挂起、结束等。
但最核心的状态就只有:运行、阻塞、挂起。
以下面的代码来说明。
#include<stdio.h>int main(){int a = 0;scanf("%d", &a);printd("a = %d", a);return 0;
}进程正常运行到scanf()处时,如果我们不从键盘上输入,程序就不会往下继续执行,此时进程的状态就从运行到阻塞。
原本该进程处于运行状态时,该进程的task_struct处于CPU的运行队列run_queue中,但是当其要从键盘中读取输入时,其task_struct会被OS放到键盘这个硬件下的wait_queue中等待输入,此时进程就进入了阻塞状态。当OS获取到输入,将输入给到进程,于是将进程的task_struct入队run_queue,此时进程等待CPU执行的状态就是就绪状态。
进程状态的本质,就是进程的task_struct处在不同硬件的队列下。(再次认识到,OS只管理进程的task_struct,并不care在内存中进程的代码数据。)
但是还有一个挂起状态,会比较特殊一些,无法通过代码直接解释。
内存在特殊情况下,可能会出现内存资源紧张的问题。这时OS为了保证内存继续工作,可以正常使用内存,OS会扫描每个进程,对于那些还在run_queue中排队的进程以及在某个硬件的wait_queue中的阻塞进程,他们暂时还不需要执行,那么这些进程对应在内存中的代码和数据就是占用内存的“垃圾”。
此时,OS会将这些内存中暂时用不到的数据“清理”掉,将他们放入磁盘中的swap区,从而为内存清理出空间。如果对一个进程这么做,能腾出来的空间当然不多;但如果有一百个进程呢?那么就能腾出可观的空间。
对于这些只剩task_struct,而在内存中缺少代码和数据的进程,就处于挂起状态。
只不过要注意的是,挂起时将内存中的代码数据拷贝到磁盘,是内存和磁盘间的I/O,效率不高。这件事的本质就是以时间换空间。
Linux中的进程状态
在Linux内核代码的task_struct中可以看到进程有下列状态:
/*
*The task state array is a strange "bitmap" of
*reasons to sleep. Thus "running" is zero, and
*you can test for combinations of others with
*simple bit tests.
*/
static const char *const task_state_array[] = {
"R (running)", /*0 */
"S (sleeping)", /*1 */
"D (disk sleep)", /*2 */
"T (stopped)", /*4 */
"t (tracing stop)", /*8 */
"X (dead)", /*16 */
"Z (zombie)", /*32 */
};
(理论模型是终究是理论,Linux在实践中有自己的进程状态,二者的关系可以理解为普遍性与特殊性的关系)
1. running(R)
在Linux中,running状态就包含了运行和就绪两种状态。表明该进程 正在运行 或 处于运行队列中。
2.sleeping(S)
sleeping就是休眠的意思,其对应的就是阻塞状态。
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>int main(){while(1){printf("I am a process, my pid is %d\n", getpid());int a = 10;scanf("%d", &a);printf("a = %d\n", a);}return 0;
}当我运行这段代码后,并在另一个窗口输入以下命令后,就能查看进程状态。
ps axj | head -1 && ps axj | grep 进程的pid

可以看到,在STAT栏,我的进程的状态是S+,我没有输入,进程就在等我输入,此时他的状态就是sleeping。
此时,如果不想输入,而是想直接将这个进程关闭,可以输入以下命令
kill -9 进程id![]()
效果自然是进程不再等待我输入了,而是直接被杀了。
看完了现象,我们对sleeping状态应该有更深刻的理解:
sleeping是可中断休眠状态(浅休眠),进程等待某事件完成,并且可以直接被中断(杀掉(关闭))。
3.disk sleep(D)
disk sleep 是Linux特有的休眠状态,区别于前者,即 不可中断休眠(深休眠)。
如何理解为什么Linux要单独搞出这样一个状态?
假设我有一个进程,他在等待磁盘写一个500M的数据,正在内存中休眠。可是此时,内存资源十分紧张,紧张到将进程的代码数据转移到swap区都不够了的程度,OS开始直接杀进程了。正好OS就看到了这个闲置等待的进程,OS将其杀掉了。可是他还在等待磁盘写完数据的完成信号呢!在该进程被杀后,磁盘写到400M时却失败了,并且磁盘还找不到进程返回任务状态,十分尴尬。为了不占用磁盘空间,磁盘只好将已经写好的数据先删了。这份数据就这样消失了。
如果这份数据是什么国家机密、某银行一天的流水等等,怎么办?到底是谁的责任?
这个故事里的三个角色,进程、OS、磁盘,他们都没错,都在履行自己的职责,却酿出了大祸。
从此,我们就当知道,正在于磁盘进行交互的这类进程,是不可以被随便中断的。
于是,disk sleep的存在也合理了。
不过实际上disk sleep状态是很少见的,如果真的见到了,只能说明内存或者某个磁盘硬件出问题了。
4.stopped(T)
stopped状态会令人疑惑,不是已经有sleeping了吗?暂停和休眠有什么区别呢?
sleeping是进程主动进入等待状态。就像我在家门口主动等一个快递。
stopped是进程由于接收到某个信号被动地暂停执行。就像遥控器暂停电视画面。
进程在收到SIGSTOP信号后,会暂停,处于冻结的状态,不会执行,但是一切又原封不同。
可以在收到SIGCONT信号后,恢复并按照原先运行时的状态..继续执行。
常见的场景一般有gdb调试、用户使用Ctrl+Z手动暂停等等。
5.tracing stop(t)
tracing stop也是stopped的一种,只不过分的更细,表明进程正在被调试器跟踪。
6.Zombie(Z)
Zombie进程就是“僵尸进程”。别怕,不是林正英电影里的跳跳,但二者的共同点就是“不死”。
一般进程在被杀后不会马上死亡,而是会陷入僵尸状态。如何理解呢?
一个进程的一生:出生(fork),生活(运行、睡眠、停止),死亡(调用exit()或收到致命信号),处理后事(父进程读取作为死亡讯息的退出状态码)。
在死亡后,处理后事之前的状态就是僵尸状态。此时进程不执行代码,且已释放了大部分资源,但还在进程表中留了个墓碑,上面记录了自己的退出状态码,就等着父进程来读遗言收尸,才能宣判死亡。
为什么要有“僵尸”这个机制?很明显,上面的故事一直在强调僵尸进程留下的退出状态码,这个死亡讯息是告诉父进程自己是正常退出还是被某信号所杀的重要信息。如果没有这个状态,父进程就永远不会知道子进程的死因了。
还有一点,僵尸进程的资源大部分已经释放了,只是占用了一个pid,用kill -9是杀不死僵尸进程的。只能通过通知父进程来收尸或者杀死父进程的方式,清理僵尸进程。
但如果僵尸进程太多,还一直不死,最终还是有内存泄漏的风险。不过进程一旦死了,是不会占用任何资源的,内存泄漏问题也就不存在了。
补充:孤儿进程
在上述清理僵尸进程的方法中提到,杀死父进程可以清理僵尸进程。
这应该会让读者产生疑惑:僵尸进程的父进程被杀死后,变成了僵尸孤儿,那么这个僵尸孤儿不就彻底没有人给他收尸了吗?这很可怕,因为如此该进程的PCB就无法被清理了才对。
但是但是,Linux自然考虑了这一点,
对于孤儿进程(父进程结束,作为子进程仍在运行),为了让其死后有人善后,在其成为孤儿后,OS就会让pid为1的systemd进程领养他,并让他由前台转后台。systemd会定期清理其领养的所有已终止的子进程。如此孤儿在僵尸后,也会有人给他收尸,能够正常彻底死亡了。
所以,杀死僵尸进程的父进程,僵尸进程就会成为孤儿进程,由pid为1的systemd进行领养,systemd会定期给领养的孤儿收尸(清理它接管的所有已终止的子进程),既然有人收尸(读取收养的僵尸进程的状态码),僵尸进程也就被清理了。
7.dead(X)
死亡状态,进程终止,且资源被完全回收。这就是僵尸进程的宿命了。
不过这个状态是一个瞬时状态,无法观察,毕竟不能一直在死。
前后台进程
在使用ps查询进程信息时,

会看到STAT栏有个+号,这个+号的含义是:表明该进程是前台进程。
反之,如果没有+号,则表明该进程是后台进程。
前后台进程的区分,有个比较简单的方式:谁能从键盘获取数据输入,谁就是前台。
键盘只有一个,在获取输入时,只能由一个进程进行获取。所以前台进程只有一个,而后台进程可以有很多个。
证明就是,对于一个后台进程,在键盘上用ctrl+c是杀不死的。原因是后台进程读不到键盘输入,获取不到ctrl+c触发的SIGINT 信号,自然杀不死。
以上是简单的理解。下列是优化后的技术型解释。
区分前后台进程的一个简单方法是:观察哪个进程正在与控制终端进行交互。
在同一个终端会话中,任一时刻只能有一个前台进程组。这个前台进程会独占终端的标准输入(stdin,如键盘输入)、标准输出(stdout,如屏幕显示),并且能接收来自终端的键盘信号(如 Ctrl+C 产生的 SIGINT 中断信号)。
而后台进程则没有这些权限,它们通常会在后台静默运行。一个典型的证明就是:当我们对一个运行中的后台进程使用 Ctrl+C 时,是无法将其终止的。因为这 SIGINT 信号只会发送给前台进程组,后台进程接收不到这个信号。
进程优先级
概念
与权限对比来理解,权限解决能不能的问题,而优先级是在能的基础上决定先后顺序。
进程优先级:进程在能够使用某资源的前提下,使用该资源的先后顺序。
重要性
在计算机内部,许多资源是稀缺的,为了合理分配计算机资源,就需要为进程安排使用资源的先后顺序。
而不只在计算机内,只要分配匮乏的资源,就无可避免的要设置优先级,决定重要的人能够优先使用资源。
Linux是如何设计进程优先级的?
在task_struct内部有两个整型变量,PRI与NI。(使用ps命令的-l选项即可展示)

PRI就是priority值,很直观的展现进程的优先级。PRI越小,优先级越高。
NI即nice值,作用是修正PRI,表示进程优先级的修正数据。要修改某进程的优先级,就只要修改NI即可。PRI(new) = PRI(old) + NI。
细节
修改命令
要修改NI值,只要在top命令下,输入r,再输入pid,即可修改NI值。
在ps下,如果将NI值改为10,PRI就会变成90
如果设为-10,PRI会变成70

会发现我们修改NI值,PRI总是在80的上下浮动,并且满足 PRI = 80 + NI。
没错这才是ps下PRI的计算方法。前面提到的PRI(old)其实就是一个默认值80。
并且这两次修改操作时间上很接近,高频修改优先级对于普通用户是不被允许的,只有root才能。
PRI和NI的变化范围
只不过当我分别尝试将NI值修改为100和-100时,却出了问题。

两次结果如上,于是发现PRI和NI也是有范围的。PRI的范围是[60, 99],NI则是[-20, 19]。
补充一点,普通用户只能降低优先级。
为什么是这个范围?为什么不能为任意值?
回答这个问题前,要先引出一个概念:分时操作系统。
分时操作系统是现代主流操作系统,给每个进程都分配了时间片,以相对公平公正的方式调度进程,较为均衡的保证每个进程在一定时间内,都能够使用到CPU资源。
一个进程执行一点再切换另一个进程,由于CPU运行速度很快,就好像许多进程都在一起执行。从而达到同时做很多任务的效果。(类似视觉残留)
这也是我们所希望计算机拥有的功能。如果计算机每次只能做一件事,做完一件事才能做下一件,那样体验会很差。比方说,我想边打游戏边听歌,PC却只允许我要么打游戏,要么听歌,做完一件事才能做另一件事。(这实际上就是实时操作系统)
了解了分时操作系统,回到刚才的问题。假设我设置一个进程的优先级为-1000,而其他进程都是默认80;那么操作系统让CPU不停执行这个优先级高的进程,使得其他进程没法运行。所以为了让进程调度能够满足分时系统的特性,必须给优先级限定范围。
其他命令
nice和renice
nice可以在运行某个程序的同时设置其NI值
nice -n ./process //默认增加10nice -n 15 ./process //指定NI值nice -n 15 ./process & //指定NI值并以后方式运行renice可以在某进程运行期间,修改其NI值
renice -n 10 -p 12316系统函数
getpriority与setpriority要用时查一下即可。
补充概念:竞争、独立、并行、并发
竞争:计算机内部资源稀缺,但进程有很多,它们之间就形成了竞争关系,以优先级来确认占有资源的先后顺序。
独立:进程之间是独立的,互不影响。因为进程的PCB是独立的,且代码与数据也是独立的。关闭进程A也就不会影响进程B了。对于父子进程,二者的PCB是独立的;虽然共享代码,但代码不会修改,所以无所谓;而父子共享数据,情况比较特殊,但写时拷贝能够维护二者数据的独立性;所以父子进程间也是独立的。
并行:多个CPU各自同时运行多个进程,每个进程同时进行。
并发:CPU将多个进程分时运行,一个进程运行一定时间,再切换另一个进程。通过进程切换,在一段时间内,推进多个进程,使得多个进程好像在同步执行一样。(类似视觉残留原理)
进程切换
在补充并发时提到,进程切换这个概念。这是怎样一个过程呢?
进程A加载入CPU运行,CPU内的寄存器存其运行数据。当运行了一个时间片后,要开始切换进程了。此时,要将寄存器中的数据保存起来,这些数据被称为上下文数据,当进程A下次再载入进来时,也一同将这些数据拷进寄存器中,这样就知道上次停下的地方,并且恢复了原状,可以继续往下运行。
可以发现,进程切换最关键的地方就是切换时拷贝寄存器中的上下文数据,载入时往寄存器中加载上下文数据。
来时带来自己的东西,走时带走自己的东西。从而保证再来时,可以直接恢复到走时的状态,相当于我没走过。
进程调度
补充
利用偏移量计算结构体地址
在C语言中,创建变量,取其地址所得一定是其最低位。
struct A{int a;int b;int c;double d;
};当我在创建了一个struct A的变量obj后,
&obj == &(obj.a)如果已知某结构体中一个成员的地址,就可以算出结构体的起始地址,访问其每个成员。
关键在于利用好偏移量。&((struct A*)0->c)就是从结构体首地址到c成员地址的偏移量offset。
(这里((struct A*)0 就是把地址0处当作一个struct A的首地址,找到其成员c后取地址,所得就是 struct A从首地址到成员c的偏移量)
那么已知&(obj.c),&(obj.c) - offset 就是 obj 的首地址。
库里也封装了宏,当然知道原理最好。想用就去问AI吧。
进程组织
那么为什么讲进程调度之前要了解这个呢?
进程调度本质上还是操作系统在管理进程。而管理的本质是先描述,再组织。进程的描述是PCB(task_struct),那么是如何组织的?
OS需要有一种方式来追踪和管理所有进程,通常使用链表这样的数据结构。
但是奇怪的是,我们在学习C语言时接触过链表,知道链表是一个个的结点,结点里面包含数据域和指针域。这样一个结点只能在一张表里。
然而在了解了进程状态后,一个进程要同时在多个队列中。即task_struct要同时在很多张表中。
此时,曾经的链表结构给我们带来了问题:如果进程的task_struct要在多张表里,还要定义出一堆相同的冗余结点出来,太麻烦了,也不好管理。
所以,Linux选择将原本同级的数据域和指针域分离,将指针域降级到数据域里。即在task_struct中内嵌next指针和prev指针。(内嵌链表)此时一个task_struct就可以通过多组指针同时加入多个链表
能这样做正是前面补充的知识所支持的,只要有next指针地址就一定找得到整个task_struct的地址。所以不怕由head找不到原task_struct。
并且内嵌结构也可以应付二叉树、哈希表等数据结构,不只是链表。
进程调度算法
在计算机系统中,CPU、内存等关键资源是稀缺的,而竞争这些资源的进程却有很多。进程调度算法的使命就是让每个进程都能公平合理地获取资源,并确保系统整体的吞吐效率和响应速度。
一个CPU有一个调度队列,在Linux2.6内核中的调度队列的结构如下,

其中要关注的如下:
*active,*expired以及prio_array_t结构体。
*active指向活跃队列,*expired指向过期队列
struct prio_array_t{ nr_active, bitmap[5],queue[140]
};nr_active表示在queue数组中的进程总数;
bitmap则是用位图来标识queue数组中队列为空与否;
queue数组,⼀个元素就是⼀个进程队列,相同优先级的进程按照FIFO规则进⾏排队调度,所以,
数组下标就是优先级!
queue[140]有140个队列,其中前0~99个队列为实时优先队列,后100~139为分时优先队列。分时优先队列有40个,恰好进程PRI的变化范围也是40。
进程的PRI正是queue数组的下标索引,进程的task_struct会根据PRI值放入queue数组下对应索引(PRI+40)的队列中。

这个模型是否有些熟悉?
调度过程
总过程:一轮调度下,OS会先在活跃队列中,根据优先级从高到低,即按顺序从100-139的队列中找到不为空的队列,再选择进程到CPU内运行。当前进程的CPU时间片结束后,会把当前进程的task_struct放入过期队列,再进行进程切换,运行下一个进程。如此重复,直到活跃队列中的进程总数nr_active为0,再swap一下*active与*expired,最后开始新一轮调度。
加入新进程:某个进程在运行过程中,如果加入了新进程,新进程将被放入过期队列,等待下一轮调度。
修改优先级:某个进程在运行期间优先级被修改,那么在其时间片结束后会根据nice值计算新的优先级,放入对应的过期队列中。
选择进程的时间复杂度
进程调度的过程是好理解的,调度过程中选择进程的时间复杂度怎么样?
其实再看一次queue[140]模型,你应该会想起一个很熟悉的数据结构--哈希表。
所以,在系统当中查找⼀个最合适调度的进程的时间复杂度是⼀个常数,不随着进程增多⽽导致时间成本增加。

在了解了进程调度后,现在脑海中的进程应该动起来了~
命令行参数
概念
main()函数其实一直有两个参数
int main(int argc, char* argv[]);argc是指命令行参数的个数,argv则是各个命令行参数的集合。
#include<stdio.h>int main(int argc, char* argv[]){printf("argc = %d\n", argc);for(int i = 0; i<argc; i++){printf("argv[%d]: %s\n", i, argv[i]);}return 0;
}
可以看到命令行参数,就是我们在命令框中一行的输入,用空格分隔出不同的参数。
作用
但这玩意有什么用?请看下面的代码。
#include<stdio.h>
#include<string.h>int main(int argc, char* argv[]){if(argc != 2){printf("使用错误,使用规范:%s -a|-b|-c\n", argv[0]);}char* option = argv[1];if(strcmp(option, "-a")==0){printf("使用功能a\n");}else if(strcmp(option, "-b")==0){printf("使用功能b\n");}else if(strcmp(option, "-c")==0){printf("使用功能c\n");}else{printf("无此选项,使用默认功能a\n");}return 0;
}
有没有感觉很熟悉?这不就是我们每个指令的使用嘛,并且可以带上选项。
同时,这也再次验证了,指令其实只是用C语言写的编译成的可执行文件罢了。通过命令行参数执行指令,也就是运行程序,无非再由其他命令行参数控制选项功能。这是对指令的祛魅。
环境变量
问题引入
为什么要运行可执行程序时,要在程序名前加上./ ?
./a.out而同样是运行可执行程序,ls、top、cd等命令就不需要呢?
top首先,要运行可执行程序,就要将其加载到内存中,但是在加载之前,首先要找到该程序。
./表明a.out可执行文件在当前目录下,明确了文件的位置,所以才能加载并运行。
那么对应的,ls、top等命令也是程序,要加载运行也首先要明确位置,可从结果来看,直接ls、top能运行,好像OS本来就知道命令在哪似的。
没错,OS本来就知道,当用文件名执行命令时,OS会去固定的路径去查找。而这固定的路径就是提前设置好的环境变量PATH。
所以,当我们在命令行输入 ls 时,Shell会按照 PATH 变量中列出的路径顺序去查找名为 ls 的可执行文件。而输入 ./a.out 时,./ 明确地告诉Shell:不要去 PATH 里找了,就在我当前这个目录下。
查询环境变量值
要查看环境变量的值可以用以下命令
echo $环境变量名![]()
显示了一大串路径,其中 : 是分隔符。
使用whereis确认一下,ls的所属路径/user/bin/的确是PATH的值之一
显然,PATH就是为OS引路的环境变量,告诉OS执行某个可执行文件名时,该到哪里去找这个文件。
修改环境变量
那么如果我们想让自己的程序也能通过文件名运行,就有了两种方式:一是将文件放入PATH所存路径之中,二为将当前文件的路径加入PATH。
这就涉及到了环境变量的修改:
PATH = $PATH:新增路径1:新增路径2实际上就是赋值操作,只不过用:作为分割符,$PATH为原本PATH的值。
基本概念
通过对PATH环境变量的了解,我们对环境变量下个定义:环境变量是用来指定OS运行环境的一些参数。本身就只是个变量而已,特点还有环境变量具有全局属性。
常见的环境变量
envenv命令可以在命令行中显示所有环境变量

其实通过变量名就大概能知道变量作用:
HOSTNAME就是笔者一直擦去的主机名,为了避免每次截图都要擦,可以直接修改HOSTNAME环境变量“一劳永逸”(并非如此)。
HISTSIZE限制了OS记录的历史命令的条数。
USER记录当前用户的身份,whoami就是输出这个值。
PWD记录当前工作路径,pwd也是输出该值。
HOME记录家目录,cd ~回到的就是HOME记录的目录。
SHELL记录当前shell软件是谁--bash。
代码与环境变量
在C\C++代码中,获取环境变量有三种方式。
【1】main函数的第三个参数。
#include<stdio.h>int main(int argc, char* argv[], char* env[]){for(int i = 0; env[i]; i++){printf("env[%d] : %s\n", i, env[i]);}return 0;
}【2】第三方变量environ。environ是libc中定义的全局变量,指向环境变量表,没在任何头文件中,使用前要用extern声明。
#include<stdio.h>int main(){extern char** environ;for(int i = 0; environ[i]; i++){printf("%s\n", environ[i]);}return 0;
}【3】使用getenv()、putenv()访问(查询或设置)特定环境变量
#include<stdio.h>
#include<stdlib.h>int main(){printf("%s\n", getenv("PATH"));return 0;
}环境变量的全局性
给出一个结论:
在bash进程启动时,都会先配置一下环境变量,做一张环境变量表。由于bash是所有进程的父进程,而环境变量表作为父进程的数据,是可以被其每一个子进程继承的。因此在各个进程中都有一张环境变量表,使得每个进程都有环境变量,因此说环境变量具有全局性。
上述是鄙人粗浅的理解,下面是AI帮我修正的版本。(误导的地方画了删除线)
“环境变量的‘全局性’,本质上是通过 进程继承 来实现的。
继承而非共享:当Bash(父进程)启动一个新程序(子进程,如
ls,a.out)时,它会将自己的环境变量表的一个 完整副本 交给子进程。子进程可以随意读取和修改自己的这份副本,但这 不会影响 父进程Bash或其他任何进程的环境变量表。Bash的角色:Bash是 我们当前会话中所有命令的直接或间接父进程。因此,在Bash中设置的环境变量,可以被我们运行的所有程序继承到,从而在“这个会话树”中表现得像是全局的。
结论:正是这种 “父进程设置,子进程继承” 的机制,使得环境变量具有了影响后续所有程序的“全局属性”。”
本地变量(普通变量)

既然有“全局”的环境变量,就还有“局部”的本地普通变量。
上述自定义了一个MYENV,但是运行一下程序,却无法输出root。
#include<stdio.h>
#include<stdlib.h>int main(){if(getenv("MYENV")){printf("%s\n", getenv("MYENV"));}return 0;
}原因就是这里的MYENV是bash的普通变量,不是环境变量,没能被bash启动的进程继承,也就无法打印出结果。
只需要导出一下普通变量,就能将其加入bash的环境变量表中。之后由bash启动的进程也就能够找到MYENV了。
export MYENV="root"这个实验清晰地证明了:
环境变量:通过
export导出,会被 子进程继承,具有“全局性”。本地变量:仅存在于当前bash内部,不会被任何子进程看到,是“局部”的。
export命令的作用,正是将一个本地变量‘晋升’为环境变量,从而将其放入那个可以被继承的‘环境变量表’中。
配置文件
但不管是导入普通变量还是修改环境变量,在关掉bash后,一切都会重置,曾经的改动都是临时的。
这是因为修改只作用当前bash进程的内存数据。而当我们打开新的bash时,它作为一个全新的进程,会读取磁盘上的配置文件~/.bash_profile 和~/.bashrc来初始化环境变量表。所以我们所做过的修改都是临时的。
当然,bash的配置文件可以自己加上一些命令,能够在bash启动时自动生效。
相关命令
除了env、export、echo等使用过的命令,
还有unset删除某环境变量或本地变量;set显示所有本地变量与环境变量
补充
【1】环境变量表的组织与argv是一样的字符指针数组,二者都是以NULL结尾。
【2】内建命令:在shell内部定义,实则为bash内部的一次函数调用,不依赖第三方路径的命令。例如:echo等。就算把PATH的内容全删除,也能使用内建命令。
进程地址空间/虚拟内存
疑点
这张图你一定很熟悉
在我们学习语言时,都会学到的内存分布,代码段、数据段、堆区、共享区、栈区。
可在了解了进程后,姑且这个内存分布包含了进程的代码和数据,那么进程的PCB在哪里呢?OS组织进程、硬件等的数据结构在哪呢?堆栈如何相向而生,这么规律?
其实是有挺多疑点的。
还有一个在学习fork创建进程时留下的进程独立性相关问题:同一个变量怎么能在父进程是a,在子进程就是b呢?
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>int main(){int num = 100;pid_t id = fork();if(id == 0){int ct = 5;while(ct--){printf("我是子进程,pid = %d, &num = %p, num = %d\n", getpid(), &num, ++num);}}else if(id > 0){int ct = 5;while(ct--){printf("我是父进程,pid = %d, &num = %p, num = %d\n", getpid(), &num, num);}}return 0;
}
神奇吧?!同一个地址,却有不同的值!
面对这么多疑点,此时我们不得不去怀疑,曾经的内存分布是正确的真实的物理内存吗?
难道计算机物理内存也具有二象性??
Of Course Not.
那块内存分布是虚拟出来的进程(虚拟)地址空间,也叫虚拟内存,并不是物理内存。它并不属于语言,而是一个操作系统中的概念。
实际上我们在语言层面见到的地址,都是虚拟地址,没有一个是真实的物理地址。
解释“二象性”现象
进程在启动时,要创建PCB和加载代码数据。数据在物理内存中,OS会将其通过页表与虚拟内存建立映射关系。
进程之后要访问这个数据,就要拿虚拟地址在页表中找到物理地址,从而获得物理地址中的值。

如此结构,在fork之后,子进程的PCB会浅拷贝父进程的PCB。使得父子进程都有一个指向一个物理地址的num。

这之后由于子进程修改了num值,由于OS要维护进程的独立性,于是进行了写时拷贝:在物理内存中申请了一块新空间,修改了子进程num虚拟地址的映射关系,再进行修改。

就是这样一个过程,才展现了一个地址,有不同的值的现象。
整个过程重要的结论有三:
【1】每个进程都有自己的虚拟地址空间和页表
【2】fork时子进程会以父进程为模板进行创建(浅拷贝),二者共享数据是因为页表内容相同。
【3】OS为了维护进程的独立性,当父子进程有人要修改共享数据,就要进行写时拷贝(相当于一次深拷贝)。(维护独立性在进程优先级有谈到)
解释过程引入了一些新概念,虚拟地址空间,页表等,接下来会一一介绍。
虚拟地址空间
本质
在Linux中,每个进程都有一个独立的虚拟地址空间。内核通过在PCB(task_struct)中维护一个指向mm_struct的指针来管理这个虚拟地址空间。
mm_struct是描述虚拟地址空间所有属性和内容的数据结构,管理着vm_area_struct、页表等信息。
因此虚拟地址空间的本质是由mm_struct来具体化管理的。虚拟地址空间本身只是一个概念,而mm_struct是其真实存在的对象。
空间划分
结构体内通过整数记录每块区域的start与end,来进行空间区域划分。(内核也只把地址作为一个整数看待而已)
如果start_data = 99,end_data = 1000,这个范围内的地址都处于数据段。
如果像堆区、栈区要动态增长,也只需要修改其start or end即可。
具体结构
上图是mm_struct对整个虚拟地址空间的宏观描述,但虚拟地址空间中的各区域还可以进行更细粒度的描述。
mm_struct中有一个struct vm_area_struct *mmap的数据结构。(当虚拟空间区域较少时用链表组织,较多时用红黑树组织。)
每一个虚拟空间区域都是一个vm_area_struct结构体,结构体内有当前区域更细致具体的属性信息。
大致结构图如下:

子问题
static静态变量、全局变量为什么生命周期是全局的?
静态变量和全局变量在数据段,这块空间在程序启动时就被分配和初始化,并且在程序的整个运行期间持续存在,不会被回收或移动。即进程在,虚拟空间在,这些数据就在:其生命周期随进程。因此说静态变量、全局变量的生命周期是全局的。
页表
概念
页表是一张存起有效数据的虚拟地址与物理地址的映射表,且还附带若干标志位。

这里只提两个标志位:权限位与存在位。
权限位对进程能否读写物理地址进行了限制。
存在位告诉进程其数据在物理地址处是否存在,即是否被挂起到磁盘交换区,如果进程要用就换回来。
补充:MMU(Memory Management Unit)是集成在CPU中的硬件内存管理单元。由MMU负责平时的页表查询工作。
子问题
用以上概念就足以解释:常量字符串为什么具有常性了。
常量字符串在页表中的权限位是r(只读)。如果写了修改的代码,在执行修改操作时,要通过页表访问物理内存,但此时发现页表对应权限为r只读,但要执行修改操作,该进程就会被OS杀掉,程序由此崩溃。
重要性
[1]虚拟地址空间和页表的机制,限制了进程对物理内存的访问,能够拦截进程的非法行为,由此保护物理内存。(野指针、权限等)
虚拟地址空间+页表是一套软件控制层,可以通过MMU硬件触发错误,使得OS介入,进而拦截非法操作,从而保护物理内存。
[2]物理内存对数据的管理是无序的,虚拟内存+页表能将数据进行有序管理,按区域空间划分。
如此,代码数据就可以加载在物理内存的任意位置,映射在虚拟内存的固定位置。
将内存布局有序化。
[3]解耦 进程管理和内存管理。
Linux在创建进程时,先创建进程内核数据结构,再加载代码数据。
而如果某个进程要创建,但不急着使用,那就可以先创建好内核数据结构,暂时不加载代码数据。有影响吗?没有。而且这样,内存中腾出的空间还可以给别的进程使用,岂不美哉?
等你要用的时候再加载代码数据进来也不迟。(详细过程请了解“按需调页”“缺页中断”。)
上面所提懒加载机制,就得益于虚拟地址空间将进程管理与内存管理解耦了。进程管理只需维护虚拟空间的布局和映射规则,而内存管理则专注于物理资源的分配与调度,两者通过清晰的接口(如页错误)协同工作。
malloc、new等也遵循懒加载机制。
