🌈环境变量
🍄初识
系统带的命令可以直接运行(ls ll命令等),但是我们自己写的命令必须要带上路径才能运行(./myproc),这是什么原因导致的?如果我们也想自己写的命令直接运行不带路径怎么搞? ----接下来揭晓答案
用ll明了直接显示处理三个文件。但是要运行我们自己创建的文件就要带上目录:这些就和环境变量有关。
- 看环境变量内容:
echo $PATH //显示操作系统搜索可执行文件的路径
- 可以看到环境变量PATH当中有多条路径,这些路径由冒号隔开,当你使用ls命令时,系统就会查看环境变量PATH,然后默认从左到右依次在各个路径当中进行查找。
- 像上面的ls命令啥的都在环境变量中储存了相应路径。
🐴设置自己的环境变量
这里我们也可以将自己写的命令设置成环境变量,一般有两种做法,这里我只推荐一种。
将我们的命令复制到环境变量下:
- pwd查看变量路径
- 将路径复制到环境变量路径set PATH=$PATH+复制的路径
- echo $PATH,如果目录上没有就把2命令的set换成export再来一遍就行了。
🐴常见的环境变量:
PATH:用于指定系统中可执行文件的路径,当你在命令行中输入一个命令时,系统会在 PATH 变量指定的路径中查找该命令的可执行文件。
HOME:用于指定当前用户的主目录路径。
USER:用于指定当前用户名。
SHELL:用于指定当前用户所使用的 Shell 程序路径。
🐴对于 Linux 操作系统,下面是一些常用的环境变量命令:
env
:显示当前所有环境变量。export var=value
:设置一个环境变量。echo $var
:显示一个环境变量的值。echo $PATH
:显示当前的 PATH 环境变量设置。export PATH=$PATH:new_path
:在 PATH 环境变量中添加一个新的路径。clear
:清除屏幕上的输出。exit
:关闭当前的终端窗口。
🍄环境变量对进程的作用
环境变量对进程的作用非常重要,因为它们提供了进程可以访问的信息和资源,包括但不限于以下内容:
系统配置信息:例如PATH环境变量包含了可执行文件的路径列表,进程可以通过访问该变量来查找和执行命令。
用户信息:例如HOME环境变量包含了当前用户的家目录路径,进程可以通过访问该变量来获取用户的工作目录。
语言和区域设置:例如LANG环境变量包含了当前系统的语言设置,进程可以通过访问该变量来确定如何显示和处理文本数据。
总之,环境变量是进程与操作系统之间进行通信和协作的一种重要方式。它们为进程提供了必要的信息和资源,使得进程能够正常运行和完成任务。
🌈程序地址空间
程序地址空间指的是一个进程在运行时所使用的虚拟内存空间。在现代操作系统中,每个进程都被分配了一个独立的虚拟地址空间,该空间包含了程序的代码、数据、堆栈等部分。
- 程序地址空间通常被分为以下几个部分:
-
代码段(text segment):包含程序的机器指令,通常是只读的。
-
数据段(data segment):包含程序中已经被初始化的全局变量和静态变量。
-
BSS段(bss segment):包含程序中未被初始化的全局变量和静态变量,其值通常为0。
-
堆(heap):包含由程序在运行时动态分配的内存,通常是由调用malloc等函数分配的。
-
栈(stack):包含函数调用时的局部变量、函数参数以及函数返回地址等信息。
程序地址空间在虚拟内存中,需要由操作系统管理,操作系统为每个进程分配一段独立的虚拟地址空间,并映射到物理内存中,以便程序能够访问所需的内存。操作系统还提供了一些系统调用,使得进程可以动态地请求更多的内存,或者释放已经不需要的内存。
在这个代码中,变量a是第一个被定义的,因此它被分配了最高的地址。变量b是第二个被定义的,因此它被分配在a的下方。同样,变量c是第三个被定义的,因此它被分配在b的下方。因此,这些变量在栈上的地址大小应该是按照以下顺序分配的:
- 变量a:最高的地址,地址值最大。(栈底)
- 变量b:次高的地址,地址值稍小于a的地址。
- 变量c:最低的地址,地址值最小。(栈顶)
这个栈是倒着放的,不是我们固定思维的正放。
接下来再看一个代码:
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
int un_g_val; //未初始化全局变量
int g_val = 100; //初始化全局变量
int main(int argc, char *argv[])
{
printf("text: %p\n", main);
printf("uninit: %p\n", &un_g_val);
printf("init: %p\n", &g_val);
char* p = (char*)malloc(16);
printf("heap: % p\n", p); //堆
printf("stack:%p\n", &p); //栈
return 0;
}
这个代码就验证了上面地址的情况,注意!我写的未初始化全局区和已初始化全局区的顺序错了,所以地址才不是连续增大的。
- 接下来我们 我们看一段代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 0;
}
else if(id == 0)
{ //child,子进程肯定先跑完,也就是子进程先修改,完成之后,父进程再读取
g_val=100;
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
else
{ //parent
sleep(3);
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
两者输入的内容不一样,但是地址确实一样的那说明啥?他们肯定不是存在物理内存上。如果存的物理地址一样,那这个地址存的内容必定一样。就比如从学校回家。今天可以做1路汽车在少年宫下,然后转2路到家。你也可以坐66路公交到天涯海角栈在坐666路到家。所以他们一定不是存在物理内存。而是存在虚拟地址上
👿进程地址空间不是物理地址
铁汁们,进程地址空间不是实际上的物理地址,而是虚拟的地址。所以原来我们在C语言学的地址空间也不是真正意义上的物理地址。
- 进程地址空间通常是虚拟地址空间,而不是物理地址空间。在操作系统中,每个进程都有自己的地址空间,其中包含该进程的代码、数据和堆栈等。这个地址空间是虚拟的,因为它只是一个进程视为自己的地址空间,而不是实际的物理地址空间。
- 当进程在执行时,它的虚拟地址需要被映射到实际的物理地址。这个映射由操作系统内核中的内存管理单元(MMU)完成,它将进程的虚拟地址转换为对应的物理地址。这个转换是透明的,因此进程可以像它正在访问实际物理内存一样访问它的虚拟地址空间。
- 总之,进程的地址空间是虚拟地址空间,需要通过内存管理单元将其映射到物理地址空间才能在实际的硬件上执行。
- 以前学的地址都不是物理地址全部都是虚拟地址。
🍄进程地址空间
每一个进程在创建后操作系统就会给他配置一个虚拟地址用来存放这个进程的内容。进程地址空间的本质也是内核的一种数据结构和PCB相似。在Linux当中进程地址空间具体由结构体mm_struct实现。进程地址空间就类似于一把尺子,尺子的刻度由0x00000000到0xffffffff,尺子按照刻度被划分为各个区域,例如代码区、堆区、栈区等。而在结构体mm_struct当中,便记录了各个边界刻度,例如代码区的开始刻度与结束刻度,如下图所示:
而想把进程地址空间和物理空间进行联系起来需要用到一个介质页表(也是一种数据结构)虚拟地址通过页表的映射存储到物理内存的不同地方。到这里,肯定有很多疑问,别急,我们来一一讲解。
👿为什么不是直接存到物理地址
内存管理的需要:操作系统需要对内存进行管理,包括内存的分配、释放、保护和共享等。如果让进程直接存在物理内存,就会给内存管理带来很大的困难,而虚拟地址空间可以方便地进行内存管理。
多道程序设计的需要:现代操作系统通常支持多道程序设计,这意味着多个进程可以同时运行在计算机上。如果让进程直接存在物理内存,就会导致不同进程之间的内存冲突和资源竞争,从而影响系统的正常运行。
内存保护的需要:操作系统需要保护系统的内存不被未经授权的进程访问和破坏。如果让进程直接存在物理内存,就很难进行内存保护,而虚拟地址空间可以为每个进程分配独立的地址空间,从而实现内存保护。
内存利用率的需要:直接存到物理内存会导致物理内存的使用不充分,出现碎片化现象。
👿给进程虚拟地址空间的好处
提高内存利用率:虚拟地址空间允许多个进程共享同一块物理内存,从而提高内存利用率。当一个进程需要更多的内存时,操作系统可以动态地为其分配更多的虚拟地址空间,而不必实际增加物理内存的大小。
实现内存保护:虚拟地址空间可以为每个进程分配单独的地址空间,从而防止进程相互干扰或破坏。操作系统可以利用内存保护机制来确保每个进程只能访问其分配的虚拟地址空间,从而保护系统的安全性和稳定性。
支持多道程序设计:虚拟地址空间支持多道程序设计,使得多个进程可以在同一时间运行。每个进程都可以拥有自己的地址空间,从而避免了不同进程之间的地址冲突和资源竞争,确保了系统的正常运行。
支持动态链接库:虚拟地址空间还可以支持动态链接库,这使得多个进程可以共享同一份库文件,从而减少了内存使用和磁盘空间的占用。
便于内存管理:虚拟地址空间的使用使得操作系统可以更方便地进行内存管理。
👿虚拟地址空间和物理内存的链接
虚拟地址要想和物理内存联系起来就需要一种内核数据结构----页表。
为什么会有页表?
- 如果这个进程是错误的,那它的虚拟地址写到物理内存上后操作系统无法正确读取这个信息,相当于浪费了很多不必要的空间。
- 其次进程这么多,这个虚拟地址映射到这里,那个映射到那里。如果我们像运行这个进程,那咋去找到它?所以操作系统需要记录下每一个进程存在的位置。
所以进程的作用:
内存管理:页表可以将虚拟地址空间划分为固定大小的页,每个页对应着一段连续的物理内存区域。这样一来,多个进程可以共享同一块物理内存,而不需要每个进程都分配独立的物理内存空间。
虚拟化:页表实现了虚拟内存的概念,使得进程可以在自己的虚拟地址空间中执行,而不需要考虑物理内存的具体细节。这样一来,操作系统可以更加灵活地管理内存资源,从而提高系统的整体性能和稳定性。
动态分配:当一个进程需要更多的内存时,操作系统可以动态地为其分配新的虚拟地址空间,并将其映射到物理内存上,而不需要为其分配新的物理内存空间。反之,当一个进程不再需要某个虚拟地址空间时,操作系统可以将其对应的物理内存释放,并将虚拟地址空间与物理内存之间的映射关系删除,以便于更好地利用内存资源。
内存保护:页表还可以提供一些额外的保护和安全特性,如权限控制、内存保护等。例如,操作系统可以将某些页面标记为只读,从而防止程序对其进行写入操作,保护系统的稳定性和安全性。
🍄写时拷贝
我们再回到上面那个奇怪的代码:为啥父子进程的物理内存一样,但是内容却不一样?
这里就用到了一门新技术:写时拷贝
- 写时拷贝(Copy-On-Write,COW)是一种优化技术,用于优化在进程复制时涉及的内存使用。在写时拷贝技术中,操作系统在内存中为子进程创建一个共享原始内存区域的虚拟副本,而不是创建一个完整的父进程物理副本。
- 写时拷贝技术的主要优点是可以避免在进程复制时产生额外的物理内存开销。因为新进程与原始进程共享相同的物理内存页面,所以只有在其中一个进程需要修改这些页面时,才会产生额外的物理内存开销。
- 如果说无脑为子进程创建父进程的副本,那子进程的很多代码和数据用不到就会造成资源和空间的浪费。所以写时拷贝非常不错。
👿写时拷贝的优点
节省内存开销:写时拷贝技术可以在进程复制时避免不必要的物理内存开销。因为新进程与原始进程共享相同的物理内存页面,所以只有在其中一个进程需要修改这些页面时,才会产生额外的物理内存开销。
提高程序运行效率:写时拷贝技术可以提高程序的运行效率,因为它避免了不必要的物理内存拷贝。如果进程在复制时需要完全复制物理内存,那么进程复制的时间和开销将会很大。使用写时拷贝技术,操作系统只需要复制虚拟地址空间的页表条目,而不需要真正复制整个物理内存页面,因此可以节省很多时间和资源。
保护数据安全:写时拷贝技术可以保护原始数据的安全性。因为新进程与原始进程共享相同的物理内存页面,所以当一个进程对共享页面进行写操作时,它不会影响到原始进程的数据。这可以避免数据的意外修改和损坏。
支持共享内存:写时拷贝技术支持共享内存,使得多个进程可以共享同一块物理内存区域。如果没有写时拷贝技术,共享内存将会很困难,因为多个进程可能会同时修改同一块物理内存,导致数据的混乱和错误。而使用写时拷贝技术,多个进程可以共享相同的物理内存页面,并且只有在其中一个进程需要修改页面时才会发生复制,从而保证了共享内存的正确性和安全性。
所以我们再分析上面的代码:
- 刚开始父进程拷贝了一个子进程,然后父与子进程共享数据和代码。后面子进程的全局变量g_val修改,修改后的全局变量区映射到页表的其他地方,然后页表又映射到 物理内存的其他位置。所以才会出现地址相同,但是打印的结果不同。
- 这里通过写时拷贝把修改后的子进程虚拟地址给页表。而不是子进程完全复制一份父进程。
- 物理地址肯定是发生改变的,那个不变的地址是虚拟地址,别搞混虚拟地址和物理地址。
注意:之所以页表映射有蓝色,有红色是因为不同内容要存在不同地方。全局变量存在已初始化全局区。函数等存在栈区等等,所以有蓝色,有红色 。
🌈进程终止
🍄进程退出的场景
- 代码运行完毕, 结果正确------皆大欢喜
- 代码运行完毕,结果错误------- 小小失落
- 代码异常终止------挠头抓耳
- 进程终止是指进程的运行被终止或者结束。进程终止可以是正常的结束,也可以是异常的终止。正常的结束通常是指进程完成了它的任务,或者根据某些条件主动地调用了系统调用来终止自己。在这种情况下,操作系统会释放进程所占用的资源,并将进程从系统中移除。
- 而异常的终止则是指进程在执行过程中出现了错误或者被强制终止。例如,进程访问了不存在的内存地址,或者收到了操作系统发出的信号来强制终止。在这种情况下,操作系统会立即中止进程的执行,并回收进程所占用的资源。
🍄进程退出码
echo $?
#include<stdio.h>
#include<stdlib.h>
int main()
{
printf("hello world!");
return 0;
}
0就表示进程正常退出,如果是非0值代表的进程异常退出,并且这个值还代表这个进程所报错误。可以通过strerror来看错误码!
#include<stdio.h>
#include<string.h>
int main()
{
int i=0; //有的编译器不支持把i=0写道for里面
for(i;i<150;i++)
{
printf("%d:%s\n",i, strerror(i));
}
return 0;
}
- 如果我们随便输入一个命令:
🍄进程正常退出
👿return退出
在函数中我们经常都是return 0 ,这个就告诉函数程序执行完要退出了。但是要想要想进程退出那就不能用它了。
👿exit/_exit退出
如果我们想让进程退出,那我们就要用到其他的函数exit, _exit。
exit函数
使用exit函数退出进程也是我们常用的方法,exit函数可以在代码中的任何地方退出进程,并且exit函数在退出进程前会做一系列工作:
- 执行用户通过atexit或on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入。
- 调用_exit函数终止进程。
这个细节就是在执行exit进行退出时编译器打印了上面的命令 I am a precess!.注意下面操作
_exit
这个不常用,相比exit,但是我们可以了解一下,_exit函数也可以在代码中的任何地方退出进程,但是_exit函数会直接终止进程,并不会在退出进程前会做任何收尾工作。
我们把前面的代码稍微修改一下即可:
👿return, exit, _exit区别
return
语句用于函数返回一个值,并将控制流程返回到调用该函数的地方。exit()
函数用于终止程序的运行并返回到操作系统,它会在退出之前执行所有必要的清理操作(例如关闭打开的文件、释放内存等)。在调用exit()
函数之前,程序可以正常地执行所有必要的操作,因此exit()
函数是安全的程序退出方式。_exit()
函数与exit()
函数类似,都用于终止程序的运行并返回到操作系统,但它会立即退出程序,不会执行任何清理操作,这可能会导致数据损坏或资源泄漏等问题。因此,除非必须要立即退出程序且不需要进行清理操作,否则应该使用exit()
函数。
🌈进程等待
🍄什么是?为啥有?
进程等待是指父进程在创建子进程后,通过调用
wait()
或waitpid()
等函数,等待子进程结束并获取子进程的退出状态。进程等待的主要目的是让父进程能够控制子进程的执行顺序,并在必要时对子进程进行处理。进程等待的必要性
- 获取子进程退出状态:进程等待还可以让父进程获取子进程的退出状态,这对于调试和排错非常有用。父进程可以根据子进程的退出状态来判断子进程是否正常退出,以及出错的原因。
- 控制子进程执行顺序:如果父进程需要等待多个子进程执行完毕后再进行后续操作,那么可以使用进程等待来控制子进程的执行顺序。父进程可以在需要等待的地方调用
wait()
或waitpid()
函数,以确保子进程已经执行完毕。- 避免子进程成为僵尸进程:如果父进程没有及时处理子进程的退出状态,那么子进程就会成为僵尸进程,占用系统资源并导致一些问题。通过进程等待,父进程可以及时处理子进程的退出状态,避免出现僵尸进程。
🍄进程等待的方法
👿wait等待
wait()是一个系统调用,用于等待子进程结束并获取子进程的退出状态。它的原型如下:
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
- wait() 函数的返回值是子进程的进程号,如果出错则返回 -1。
- 如果父进程没有子进程,那么 wait() 函数会立即返回。
wait()
函数的参数status
是一个整型指针,用于存储子进程的退出状态。如果子进程正常结束,它的退出状态会被存储在status
中,父进程可以通过WIFEXITED(status)
和WEXITSTATUS(status)
宏来获取子进程的退出状态。如果子进程异常结束,例如被信号终止,那么status
中存储的是一个描述异常结束原因的信号值。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
pid_t id = fork();
if(id == 0){
//child
int count = 10;
while(count--){
printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
exit(0);
}
//father
int status = 0;
pid_t ret = wait(&status);
if(ret > 0){
//wait success
printf("wait child success...\n");
if(WIFEXITED(status)){
//exit normal
printf("exit code:%d\n", WEXITSTATUS(status));
}
}
sleep(3);
return 0;
}
我们用这个脚本:
while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep;echo "######################";sleep 1;done
👿waitpid等待
waitpid
是一个系统调用函数,它用于等待指定进程的状态改变,例如进程终止或暂停等。waitpid允许父进程等待它的一个子进程的状态改变,而不必阻塞整个进程。这使得父进程能够同时等待多个子进程的状态改变。
- 函数原型:
pid_t waitpid(pid_t pid, int *status, int options);
pid
参数指定要等待的进程的进程ID,如果指定为-1
,则等待任何子进程。status
参数是一个整数指针,用于存储子进程的退出状态或其他信息。options
参数用于指定等待的进程状态,如WNOHANG
选项表示在没有状态改变的情况下立即返回,而不是阻塞等待状态改变。当
waitpid
成功时,返回终止或暂停进程的进程ID,如果没有进程处于指定状态,则返回0。如果出错,则返回-1。请注意,
waitpid
只能等待子进程的状态改变,如果要等待其他进程的状态改变,可以使用其他函数,如wait
和waitid
。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
pid_t id = fork();
if (id == 0){
//child
int count = 10;
while (count--){
printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
exit(0);
}
//father
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret >= 0){
//wait success
printf("wait child success...\n");
if (WIFEXITED(status)){
//exit normal
printf("exit code:%d\n", WEXITSTATUS(status));
}
else{
//signal killed
printf("killed by siganl %d\n", status & 0x7F);
}
}
sleep(3);
return 0;
}
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
pid_t id = fork();
if (id == 0){
int count = 10;
while (count--){
printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
exit(0);
}
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret >= 0){
printf("wait child success...\n");
if (WIFEXITED(status)){
printf("exit code:%d\n", WEXITSTATUS(status));
}
else{
printf("killed by siganl %d\n", status & 0x7F);
}
}
sleep(3);
return 0;
}
在父进程运行过程中,我们可以尝试使用kill -9命令将子进程杀死,这时父进程也能等待子进程成功。 但是被信号杀死而退出的进程,其退出码将没有意义。
🍄获取子进程status
- wait, waitpid函数都有status,status存的是进程的状态。
- 如果传递NULL,表示不关心子进程的退出状态信息。
- 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
- status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位):
在status的低16比特位当中,高8位表示进程的退出状态,即退出码。进程若是被信号所杀,则低7位表示终止信号,而第8位比特位是core dump标志。
如果我们想得到进程的退出码和退出信号就可以使用位运算:
exitCode = (status >> 8) & 0xFF; //退出码
exitSignal = status & 0x7F; //退出信号
对于此,系统当中提供了两个宏来获取退出码和退出信号。
- WIFEXITED(status):用于查看进程是否是正常退出,本质是检查是否收到信号。
- WEXITSTATUS(status):用于获取进程的退出码。
exitNormal = WIFEXITED(status); //是否正常退出
exitCode = WEXITSTATUS(status); //获取退出码
需要注意的是,当一个进程非正常退出时,说明该进程是被信号所杀,那么该进程的退出码也就没有意义了。
👿wait和waitpid的区别:
- wait函数会等待任何子进程结束并获取其退出状态,而waitpid函数只会等待指定的子进程结束并获取其退出状态。
- wait函数没有提供获取子进程终止原因的选项,而waitpid函数可以通过指定选项来获取子进程的终止原因(例如,被信号终止还是正常退出)。
waitpid函数的原型为:
pid_t waitpid(pid_t pid, int *status, int options);
其中,pid参数指定要等待的子进程的PID。如果pid为-1,则等待任何子进程结束。status参数用于存储子进程的退出状态。options参数可以指定等待子进程的行为,例如是否阻塞等待、是否只等待被特定信号中断的子进程等等。
wait函数的原型为:
pid_t wait(int *status);
其中,status参数用于存储子进程的退出状态。
- 如果没有子进程在运行,wait函数会阻塞程序,直到有子进程结束为止,而waitpid函数可以通过指定WNOHANG选项来避免阻塞。
- 如果子进程已经结束,wait函数和waitpid函数都会立即返回,且获取到子进程的退出状态。如果子进程尚未结束,wait函数和waitpid函数都会等待子进程结束并获取其退出状态。
- 在多进程程序中,waitpid函数可以通过指定pid参数来等待特定的子进程结束。如果不指定pid参数或pid为-1,则等待任何子进程结束。
- 在使用这些函数时,需要注意避免出现僵尸进程,即已经结束但父进程尚未调用wait或waitpid来获取退出状态的子进程。可以使用信号处理函数或者使用waitpid函数中的WNOHANG选项来避免出现僵尸进程。