操作系统管理先描述,再组织

1.进程是什么?

进程就是一个运行起来的程序,就是一个task_struct和对应的资源

vfork的作用是什么?是与父进程共享进程地址空间,子进程先运行,父进程被挂起,直到子进程调用exec或者exit。父进程调用vfork,内核为进程创建一个pcb,分配对应的资源,但是不创建进程地址空间。父进程调用vfork父进程会被挂起,直到子进程调用exec。

创建进程主要就是要注意一个写实拷贝技术。fork的时候系统为子进程创建pcb,分配对应的资源,也创建独立的进程地址空间,但是它们的页表对应的物理内存是相同的,但是内核会把这些页标记为只读,当进行修改的时候就会触发缺页中断,分配新的物理内存。

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    int var = 100; // 一个在栈上的变量
    pid_t pid = fork();

    if (pid == 0) {
        // 子进程
        printf("Child: Initial var = %d\n", var); // 读操作,不触发拷贝
        var = 200; // 写操作!这里会触发写时拷贝!
        printf("Child: Modified var = %d\n", var);
    } else if (pid > 0) {
        // 父进程
        sleep(1); // 确保子进程先执行修改(并非绝对,仅为演示)
        printf("Parent: var = %d\n", var); // 读操作,读取的仍然是原始的 100
        wait(NULL); // 等待子进程结束
    }
    return 0;
}

2.进程终止的本质

进程终止的本质就是释放进程的资源。

进程退出只有3种状态,代码运行完毕,正确,代码运行完毕,错误,代码未运行完,异常中断

exit可以退出这个进程,这个返回的是进程的退出码,除了0或1,其他退出码都认为进程代码执行不正确。

有两种exit和_exit退出进程的防止,exit是封装的库函数,它底层也会调用_exit系统调用,但是它在调用_exit之前会调用用户定义的清理函数,比如析构函数,还会刷新缓冲区,_exit不会,它是直接退出进程。

3.进程等待的必要性

为什么要进程等待呢?因为子进程退出,父进程不管不顾,子进程就会变成僵尸进程,这个僵尸进程无法被Kill杀死,也没有人能够杀死它,虽然僵尸进程占用的资源不多,但是随着僵尸进程越来越多,内存就会被打满,卡死。而且进程等待可以拿到子进程运行的结果,进程通过进程等待的方式,拿到父进程的退出信息。

4.进程等待的方法

进程等待有wait,和waitpid,wait就是单纯的阻塞等待,拿到子进程的退出信息,退出信息在status里。waitpid,有三个参数,第一个参数>0的话代表等待指定的进程,等于-1的话代表等待任意第三个选项可以设置为非阻塞和阻塞等待。如果在传递status的位置传递了NULL,代表不关心进程的退出。

僵尸进程,孤儿进程,守护进程,理解这三种进程的核心是理解进程的创建是为了什么?进程的创建就是为了完成任务,完成任务之后,我们要拿到进程执行的结果,如何拿到,去pcb里拿,它里面保存了进程的退出信息,谁来拿,父进程来拿。知道了这些,再来理解僵尸进程,僵尸进程就是进程退出后,父进程还没来回收它的退出信息的状态。

孤儿进程,孤儿进程就是父进程先于子进程退出了,父进程要来回收子进程的退出信息,它退出来谁来回收,就是1号进程操作系统来回收,因为操作系统也是一个进程。

守护进程,它就是独立于终端,不和用户交互,运行直到进程结束。

为什么pwd能够查到当前的工作目录,这是因为环境变量把它记录下来了,环境变量用来存储系统的配置信息,用户偏好等,它可以被子进程继承。它是全局的。键对值存储,kv存储。

动态性:可以在不重启程序的情况下修改,并影响后续启动的程序

5.进程替换

当用fork创建一个子进程之后,如果用exec进行进程替换的话,该子进程的用户空间的代码和数据都被替换了、exec最根本的就是进程地址空间的替换,所以其他的进程ID就并不会因为进程替换而改变,它还是父进程的子进程,这一点并不会变

6.理解文件

文件狭义上是一种存储在磁盘上的数据,但是Linux的设计哲学是Linux下一切皆文件。

文件包括文件属性和文件内存,对文件操作无非就是对属性进行操作,对内容进行操作。

打开文件本质是进程打开文件,进程肯定知道自己在哪里,所以进程默认就可以找到它当前所在目录下的文件。

进程打开文件会对文件进行操作,问题来了进程要不要管理它打开的文件,当然要,如何管理,数组来管理,进程里面有一个文件描述符表,通过文件描述符表的下标就可以找到对应的文件。

dup2重定向。dup2(int oldfd,int newfd);这个系统调用的含义是把oldfd下标对应的内容覆盖到newfd下标对应的内容。dup(fd,1);这样往1对应的屏幕打印的时候就是往fd里进行输入。

理解Linux下一切皆文件。

文件结构体file里面有一个指针,这个指针指向对应的系统调用,来对对应的底层进行相应的操作,这样我们只需要使用简单的write就可以往几乎任何硬件和软件进行写入了,接口调用是一致的,但是底层指向的系统调用可是不一样的,这些都是透明的。

看下面的图,磁盘和显示器都被抽象为文件,当进行读写的时候会掉指针去调用,但是它们底层对应的read和write都是不一样的,这样我们通过write和read就可以对磁盘,显示器,键盘等进行读写,但是底层的实现其实是不一样的。这就是Linux一切皆文件。通过函数指针回调的方式进行不同的调用。上层统一,下层实现。

7.理解缓冲区

缓冲区就是一块内存的空间,分为读缓冲区和写缓冲区。缓冲区的作用就是避免大量系统调用从用户到内核,从内核到用户的转换。比如当我们想往磁盘里写的时候,如果没有缓冲区,我们会直接调系统调用把它写到磁盘里,但是这样一来磁盘的速度很慢,而且谁能往磁盘里写,只有OS,所以设计到用户到内核的切换,这样频繁的切换消耗资源,会变的非常缓慢。

open,write,read都是系统调用,fopen是库函数,它们也有缓冲区,不过是语言自己实现的。

8.库

库里面封装了许多写好的函数,库是一种二进制代码,可以被加载进内存使用,Linux下.a是静态库.so是动态库。

静态库是在编译链接的时候把库的代码加载到可执行文件里,在这之后就即使静态库被删除,程序也可以继续运行,但是这也会导致代码的占用空间比较大。

程序在运行的时候,可能会用到许多库,我们程序链接的时候默认链接的是动态库,即.so库,如果没有才会去链接同名静态库,也可以编译的时候gcc -static指定我们必须使用静态库。

动态库是程序在运行的时候才会去链接动态库的代码,动态库是在内存里的,通过虚拟地址空间可以让不同的进程链接到同一个动态库,大大减少了代码的体积,而且链接只会链接代码用到的动态库函数的地址,不会把动态库全部的函数地址全部链接。

编译是在干什么?编译就是在把我们写的代码翻译成机器可以识别的代码。

为了了解一下编译链接,我们先来了解一下ELF文件。

ELF头,这个部分是为了定位ELF文件的其他部分。

程序头表,列举了所有的有效的段和他们的属性。表里的每个位置都是紧密挨着的二进制,需要段表的描述信息,把每个段分开

节头表,包含对节的描述。

节:ELF文件的基本组成单位,包含了特定的数据类型,有不同的节,代码节,数据节等。

节:告诉哪些地方是代码节,哪些地方是全局变量等。是给链接看的。

段是从操作系统如何加载和运行程序的角度来看待ELF文件的。一个段会告诉加载器:“请把文件的这一部分,以某种权限(读/写/执行)映射到内存的某个地址”。

  • 目的:在程序加载时,告诉操作系统:

    • 哪些部分需要加载到内存

    • 每个部分在内存中的位置(虚拟地址)

    • 每个部分的访问权限(读R、写W、执行E)。

    • 程序执行的入口点在哪里。

    • 节与段的映射关系

      这是理解二者联系的关键。一个段是由一个或多个节“拼凑”而成的

将多份C++代码翻译成.o文件并进行合并,节也会进行合并。

静态链接的过程,当多个.c文件进行编译的时候,其实并不知道对方的存在当.c文件调用别的.c文件的函数的时候,是不知道地址的,编译器会把这个地址设为0.链接的时候进行地址的修正。代码段会存有一个表,这张表在未来链接的时候,会根据表里的记录的地址进行地址修正。

最后的结果就是.c文件被编译成.o文件,链接的时候对其中的函数地址进行重定向,完成代码调用

静态链接就是把库中的.o进⾏合并,和上述过程⼀样
所以链接其实就是将编译之后的所有⽬标⽂件连同⽤到的⼀些静态库运⾏时库组合,拼装成⼀个独⽴ 的可执⾏⽂件。其中就包括我们之前提到的地址修正,当所有模块组合在⼀起之后,链接器会根据我 们的.o⽂件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从⽽修正它们的地址。这 其实就是静态链接的过程。

一个ELF文件在没有被加载到内存的时候有没有地址呢?进程的虚拟地址mm_struct,vm_are_struct进程在刚刚创建的时候,初始化数据从哪里来。

一个ELF程序在没有被加载到内存的时候就有地址。这么说来,每个程序的虚拟地址在还没有加载到内存的时候就已经确定了。进程地址空间的mm_struct和vm_area_struct刚创建时候的初始化从ELF的段来的,每个段都有自己的起始地址和自己的长度,用来初始化内核中的虚拟地址。

所以编译器和内核都要支持虚拟地址,因为编译器支持虚拟地址才会在程序编译好的时候进行虚拟地址编制,内核支持虚拟地址编译器的虚拟地址才可以正确的初始化。哦,看到这里,原来虚拟地址在进程还没有创建的时候就已经创建好了。谁做的,编译器做的。

动态链接和动态库的加载

为什么说动态库只需要加载一份,是因为它把库加载到内存里,然后多个进程的共享区的页表都指向这个区域,就实现了进程间库的共享。这样多个进程只需要加载一份库就可以了。

为什么编译器不默认进行静态链接呢?这样也方便,直接把库编进程序里,不依赖外部环境,不这么干的原因是因为如果每个程序都要加载一分库,内存里就会有许多重复的库,代码体积庞大且臃肿。

动态链接其实是把链接的过程推迟到程序加载的时候,当我们加载一个程序的时候,操作系统会先将进程的代码和数据还有要用到的动态库都加载到内存里,当动态库的物理内存确定的时候,就可以根据具体的物理地址建立映射关系了。换句话说动态链接不把链接推迟到程序加载的时候也不可以,会因为动态库还没加载到内存里,无法确定它的物理地址。

动态库和程序在磁盘的时候都是假象自己从0开始编址的,在磁盘的时候只知道动态库从0开始的偏移量,当运行的时候,它们都会在虚拟地址进行偏移,需要知道动态库的偏移量,加上知道它从0编址的相对地址,就可以定位动态库在内存的虚拟地址了。

而静态库不需要,静态库直接就把整个静态库和程序数据代码进行统一编址了,它们是一个整体从0开始编址。

但是当程序运行的时候,库的方法调用地址在代码段就已经写死了呀,如何对它进行修改呢?代码段是不可以修改的呀

所以:动态链接采⽤的做法是在 .data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放数
的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变函数
的地址。

知道了动态库的加载背后,我们可以知道,动态库的方法被大量调用的时候,一定有大量的函数需要进行重新定位,为了改进这一步,我们操作系统进行了延迟绑定的操作,将函数地址计算的时间推迟到第一次动态库方法被调用。

延迟绑定延迟的并不是动态库基地址的确定时间(这个在启动时就已完成),而是延迟了“将每个外部函数的符号解析为其绝对虚拟地址,并完成GOT表项修补”这一具体重定位操作的时机,直到该函数第一次被实际调用时才会发生。

9.进程间通信

进程间通信是为什么?为了数据传输,资源共享,通知事件,进程控制。

进程间通信的方式有管道,分为匿名管道,命名管道,共享内存,消息队列等。

管道就是内核在内核开了一块区域,进程把数据发到内核,然后由内核来把数据交给另一个进程。

匿名管道的创建方式是这样,

int pipe(int fd[2]);

创建管道的从文件描述符表的角度看,就是在进程创建了两个文件。

管道的本质就是文件,Linux一切皆文件

匿名管道是由内核来维护的,它会保证操作的安全,管道没有数据的时候,读端会阻塞,管道满的时候写端阻塞,读端关闭,写端再写会导致进程关闭,写端关闭,读端再读返回0.而且在管道写入的数据不大于PIPE_BUF的时候,会保证原子性。

这种匿名管道能够通信的原理是fork之后子进程会继承父进程对应的文件描述符表,可以往同一个文件里进行写入和读取,这个内容是由内核来维护的。

管道的数据是流的,单向流动的。无需等待数据全部就绪。管道是半双工的,数据流动是单向的。

由于写入和读取都是从内核的,所以内核会进行互斥和同步,管道的生命周期随进程。

匿名管道只能用于有血缘关系之间的进程进行通信,因为它是父进程创建一个管道,然后fork子进程进行继承。这样如果没有血缘关系,就无法拿到管道通信的文件,进而无法进行通信。

匿名管道是pipe,创建方式是pipr[fd[2]]命名管道是FIFO,创建方式是mkfifo filename(这种方式是命令行创建),程序里创建是int mkfifo(const char *filename,mode_t mode);

mkfifo("p2", 0644);p2是创建的文件名,0644是文件权限。
命名管道创建之后还需要被打开。用open系统调用进行打开。
打开一个文件open(fd,权限)。读取一个文件,read(fd,buf,1024);
共享管道就解决了没有血缘关系进程通信的问题,通过创建一个文件进行通信,只不过这个文件是由内核进行维护的。
除了匿名管道,命名管道进行进程间通信,还有共享内存。
总的来说,我们匿名管道就是用于血缘关系进程通信的,方法是pipr(int fd[2]),下标0为读,下标1为写。然后fork之后子进程会对父进程的文件描述符表进程继承,然后关闭对应的读端和写端,管道就是单向通信并且由内核来维护,当写入的字节数小于PIPR_BUF,会保证写入的原子性,由内核保证。读端关闭,写端写的时候会直接发送信号杀掉这个写进程,写端关闭,读端读的时候会返回0。命名管道就是用于没有血缘关系进程的通信,它的方法是创建一个文件,mkfifo(文件名,权限)
进行操作,open打开这个管道,然后另一个进程也可以打开这个文件,之后的流程就和匿名管道一样的,同样是由内核来维护的。
至于共享内存,它的原理就不是文件了,它是工作在用户态的,所以内核就不保证它的安全了,由用户自己维护,它的原理就是通过进程虚拟地址进行映射到同一块空间进行。
共享内存要怎么搞呢?首先需要再物理内存搞一块空间出来,然后再把进程的虚拟地址映射到这个物理内存,就好了,然后如果想的话也可以让当前共享内存段从当前进程脱离。那如何往共享内存里进行读和写呢?也要提供对应的函数来。

10.并发进程的概念

这里解释一下什么是互斥,同一时刻只能有一个执行流进入到共享资源进行访问,一般用锁来维持,什么是同步呢?多个执行流,访问临界资源的时候,具有一定的顺序性,叫做同步,一般用条件变量来实现。

多个执⾏流(进程), 能看到的同⼀份公共资源:共享资源
被保护起来的资源叫做临界资源
保护的⽅式常⻅:互斥与同步
任何时刻,只允许⼀个执⾏流访问资源,叫做互斥
多个执⾏流,访问临界资源的时候,具有⼀定的顺序性,叫做同步
系统中某些资源⼀次只允许⼀个进程使⽤,称这样的资源为临界资源或互斥资源。
在进程中涉及到互斥资源的程序段叫临界区。你写的代码=访问临界资源的代码(临界区)+不访问
临界资源的代码(⾮临界区)
所谓的对共享资源进⾏保护,本质是对访问共享资源的代码进⾏保护

11.进程信号

信号是什么?信号就好比我们生活中的红绿灯,在遇到之前,我们就知道该怎么处理了,红灯行,绿灯停,黄灯闯一闯。

信号就是内置的,提前知道的。信号处理的办法,在没有产生信号之前,就已经知道了。信号分为信号到来,信号保存,信号处理

比如:CRTL+C就是向前台进程发送一个信号,当前台进程收到这个信号后,就会退出。

但是信号可以被捕捉,下面这个就是捕捉信号,这个代码是我们捕捉了2号信号用自己的方法去处理信号。

void handler(int signumber)
{
std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber <<
std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
signal(SIGINT/*2*/, handler);
while(true){
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}

signal是设置信号处理的方式,并不是发出信号,当一个前台进程执行的时候,任何时刻都有可能收到信号,所以信号是异步的

信号的种类有这么多,这些都是提前设置好的,在信号到来之前就知道信号的处理方式了。

其实对内核来说就是一个宏定义。

捕捉信号可以设置为忽略

int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
signal(SIGINT/*2*/, SIG_IGN); // 设置忽略信号的宏
while(true){
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}

捕捉信号也可以是按照默认方式进行处理

void handler(int signumber)
{
std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber
<< std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
signal(SIGINT/*2*/, SIG_DFL);
while(true){
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}

信号的过程,就是信号产生,信号保存,信号处理

信号自定义捕捉就是以下的流程。

#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber <<
std::endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
signal(SIGQUIT/*3*/, handler);
while(true){
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}
我是进程: 213056
I am a process, I am waiting signal!
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^\我是: 213056, 我获得了⼀个信号: 3

信号是从软件角度模拟中断,而硬件中断是发送给CPU,信号中断是发送给进程。

函数也可以产生信号,kill -9  pid就可以杀死一个指定的进程。

raise函数可以自己给自己发送信号,哪个进程调用这个信号,它就给哪个进程发信号。

void handler(int signumber)
{
// 整个代码就只有这⼀处打印
std::cout << "获取了⼀个信号: " << signumber << std::endl;
}
// mykill -signumber pid
int main()
{
signal(2, handler); // 先对2号信号进⾏捕捉
// 每隔1S,⾃⼰给⾃⼰发送2号信号
while(true)
{
sleep(1);
raise(2);
}
}

abort函数使当前进程收到信号而异常终止

void handler(int signumber)
{
// 整个代码就只有这⼀处打印
std::cout << "获取了⼀个信号: " << signumber << std::endl;
}
// mykill -signumber pid
int main()
{
signal(SIGABRT, handler);
while(true)
{
sleep(1);
abort();
}
}

alarm函数也可以发送信号,alarm的英文是闹钟,这个很形象啊,就是设置多少秒之后向进程seconds秒之后发送SIGALRM信号。

alarm可以体验IO效率低,cout就是往屏幕上打印,这样都是1秒之后进程终止,但是右边的count数字远远大于左边。

总结,闹钟响一次,默认终止进程。IO效率低。数量级的差距。

信号的软中断,比如alarm一定时间之后向进程发送信号,默认进程终止,或者往读端关闭的管道写,会默认收到SIGPIPE信号,导致进程终止。简⽽⾔之,软件条件是因操作系统内部或外部软件操作⽽触发的信号产⽣。

除了软件可以产生软中断,

硬件异常被硬件以某种⽅式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前 进程执⾏了除以0的指令, CPU的运算单元会产⽣异常, 内核将这个异常解释为SIGFPE信号发送给进 程。再⽐如当前进程访问了⾮法内存地址, MMU会产⽣异常,内核将这个异常解释SIGSE信号发送给进程。
当我们出现除0错误或者野指针这种运行时错误,就会向系统发送信号。

子进程退出core dump标准,进程终止的时候一个是正常终止,一个是异常,被信号杀掉。

信号产生之后由操作系统来进行实现,OS内部有默认对应的信号处理方法,也可以捕捉信号进行自定义处理,一些软件操作可以触发软件中断,比如alarm过second秒之后会发送信号。一些硬件错误,比如CPU的除0错误和野指针越界访问错误,也会出现错误,本质还是向进程发送信号。
信号的产生和处理都需要OS来处理,信号产生之后不是必须立马执行的,就好比当快递到来打电话的时候我们不是必须马上就必须去取,那么随之而来的问题是信号肯定要被保存下来到合适的时候进行处理呀。
内核中有三张表,分别是阻塞,捕捉,处理。
这里重点介绍一个概念,是阻塞和忽略,初学应该会有疑惑,这不是一样吗?阻塞了不就是忽略吗?其实不是的,阻塞就不会递达,而忽略是信号递达之后的操作是忽略。
如果在进程解除对某信号的阻塞之前这种信号产⽣过多次,将如何处理?POSIX.1允许系统递送该信
号⼀次或多次。Linux是这样实现的:常规信号在递达之前产⽣多次只计⼀次,⽽实时信号在递达之
前产⽣多次可以依次放在⼀个队列⾥。
所以得出的结论是PCB里有三张表,本质就是位图,信号产生会把PENDING表设置为1。
然后去查阻塞表,看一下阻塞表是否该信号被阻塞,如果被阻塞就不会被处理。
接下来信号产生,信号也没被阻塞,那么进入信号处理,看一下处理方式,有默认,有忽略,有自定义。
由于是位图存储信号,所以只有0或1两种状态,不能记住信号产生的次数,只能知道信号产生了
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐