封面

🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》【C++】【Linux】
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然

在这里插入图片描述


🏠博主简介
在这里插入图片描述

前言

欢迎来到本期博客!🌟

在探索Linux操作系统的浩瀚宇宙时,很多初学者往往会“知其然而不知其所以然”。为了帮你打牢底层基础,本文将带你从硬件底层的冯·诺依曼体系结构出发,深入浅出地理解计算机内部数据的真实流动过程;随后步入操作系统的核心殿堂,揭开OS作为“大管家”背后“先描述,再组织”的管理哲学以及系统调用的本质。

在此基础上,我们将硬核剖析Linux中最重要的概念之一——进程(Process)。从进程控制块(PCB)的认知、进程信息的查看与路径管理(cwd),一直推演到使用代码调用 fork() 创建子进程,并带你亲自验证神奇的“写时拷贝”机制。

无论你是刚接触Linux的新手,还是想夯实系统编程基础的C/C++学习者,相信这篇文章都能为你拨开迷雾,理清软硬件交互的底层逻辑。准备好了吗?让我们一起开启这场底层原理的探索之旅吧!🚀

一、认识冯诺依曼系统

   我们常见的计算机 ,如笔记本。我们不常见的计算机 ,如服务器 ,大部分都遵守冯诺依曼体系。
在这里插入图片描述
   截至目前 ,我们所认识的计算机 ,都是由一个个的硬件组件组成

  • 输入设备:包括键盘, 鼠标 ,扫描仪, 写板,网卡磁盘
  • 中央处理器(CPU):含有运算器和控制器等
  • 输出设备:显示器 ,打印机,网卡磁盘
  • 存储器:内存

⚠️ 核心注意注意:
1. 在诺依曼系统体系中,我们将输入设备和输出设备称为外设,即外部存储
2. 在学语言时,我们进行文件读取时,将磁盘的数据读到内存里,写文件就是将内存的数据写入到磁盘中,这种读写的动作称为I/O,因此这类设备不一定严格意义上只是输入或者输出设备,既可以是输入也可以是输出设备(理解I/O站在内存的角度,外设将数据交给内存是I(写入),内存将数据给外设即输出O)

1.1 软件为什么要“加载到内存”?

   ❓ 灵魂拷问:编译好的程序,为什么运行前必须加载到内存?程序运行之前在哪?

   在 Linux 中“一切皆文件”,程序在运行前就是一个存放在磁盘(外设)上的普通文件。

   而在计算机体系结构中,有这样一条铁律:数据的流动只能从外设流到内存,CPU 只能从内存中读取数据和代码。CPU 无法直接越过内存去读取外设!因此,程序必须从外设加载到内存(这个加载的本质就是 I/O 操作)。

   💡 阶段总结:
      1.数据流动本质就是从一个设备拷贝到新的设备上,因此推导出体系结构的效率的高低是由设备的拷贝效率决定的
      2.cpu在数据层面只和内存打交道,外设只和内存打交道

1.2存储分级:为什么不能只要 CPU 和外设?

   ❓ 灵魂拷问:能不能去掉内存?外设直接连 CPU,效率不是更高吗?

   我们先来看一张“存储分级图”:
在这里插入图片描述
在如上图中,我们发现离cpu越近,存储容量越小,速度更快,离cpu越远,存储容量越大,速度更慢,这就是存储分级的概念,有了这个概念,我们便知道为什么计算机不是下图这样设计的
在这里插入图片描述

输入输出设备属于外设,在存储分级图中,离cpu远,运算效率低下(假设是毫秒级别),cpu的运算速度为纳秒级别,这样输入设备将数据交给cpu后其快速处理后交给输出设备,但是输出设备处理速度慢,cpu需要等输出处理完,并且由于cpu快速将输入设备处理完因此需要等输入设备将新数据交给cpu,导致整个体系结构的效率是由外设效率决定(木桶原理,效率是由最慢的决定),速度太慢
在这里插入图片描述
那么能不能帮我们的外设如磁盘全部变成寄存器,当然是可以的,但是由于成本太高不适合绝大数有需求的人,为了在价格和效率中找平衡,因此引入内存(假设微秒),让cpu和外设之间的效率进行适配(以内存效率决定)

可是不是说效率是由最低的决定吗,在这个架构中外设效率依然是最低的???

操作系统加载时就在内存里,其提前将外设预先的大量数据批量给给内存,因此便只是内存和cpu的交互速度来决定效率

1.3 聊个QQ,数据是怎么流动的?

对冯诺依曼的理解 ,不能停留在概念上 ,要深入到对软件数据流理解上 ,解释下 ,从你登录上qq开始和某位朋友聊天开始 ,数据的流动过程。从你打开窗口 ,开始给他发消息 ,到他的到消息之后的数据流动过程。如果是在qq上发送文件呢?
在这里插入图片描述
电脑手机等设备本质就是冯诺依曼体系,因此和朋友聊天本质就是两台冯诺依曼体系在聊天,因此用户在键盘输入数据,qq被启迪要加载到内存里,键盘要将数据交给qq,因此数据就从输入设备流动到存储器,数据发出去前需要将其加密(使用对应的算法),因此肯定要经过cpu进行处理,然后将数据再返回给存储器,再通过qq将数据输出到我们的网卡(输出设备),网卡经过网络将数据交给你朋友的机器的输入设备—网卡,同样也要启动qq到内存里,内存读到数据将其给给cpu进行解密,再将其返给存储器,最后输出到你朋友的输出设备-----显示器

同理文件传输是从输入设备----磁盘上,将文件拷贝到qq上即加载到内存中,内存将文件交给cpu并加密,cpu将加密文件返回给内存,内存将其写到网卡中,后面步骤就是类似的了(朋友输入设备是网卡,输出设备是磁盘)

注意:上述的流动,写入,加载等名词的本质均是数据的拷贝

二、操作系统(OS):计算机系统的大管家

2.1 概念

任何计算机系统都包含一个基本的程序集合 ,称为操作系统(OS),操作系统包括:

  • 内核(进程管理 ,内存管理 ,文件管理 ,驱动管理)
  • 其他程序(例如函数库 ,shell程序等等)

简单来说操作系统是一款进行软硬件管理的软件

在这里插入图片描述
在日常我们说操作系统(狭义上的)即内核,广义上讲就是操作系统上内核之上安装的外壳shell,glibc,预装的系统软件等

安卓的底层用的是Linux内核,这句话的理解就是安卓使用的是Linux的内核,但是其有自己的外壳程序等

2.2 设计OS的目的

整个计算机结构示意图如下:
在这里插入图片描述

操作系统

对下,与硬件交互,管理所有软硬件资源(手段)

对上,为用户程序(应用程序)提供一个良好的执行环境(目的)

💡 分层架构的精妙之处:

  1. 高内聚低耦合:软硬件体系结构是层状结构,在软件工程上体现为高内聚,即将相同数据代码放同一层(驱动和驱动放一起,硬件放一起),低耦合(层和层之间使用接口方式进行调用,在数据和逻辑上面没有强耦合),这样方便以后哪里错误改哪里,其他不需要变,方便后续代码的可维护性

  2. 贯穿体系:访问操作系统,必须使用系统调用——其实就是函数,只不过是系统提供的,因为操作系统不允许用户直接访问它,我们便知道了printf可以向显示器上打印数据,并不是我们写的c程序直接写到硬件上,而是c标准库的方法封装了系统调用,通过操作系统对显示器的驱动进行访问,驱动再访问显示器将数据再显示器上进行显示

  3. 通过结论2,我们程序,只要判断其访问了硬件,那么它必须贯穿整个软硬件体系结构

  4. 库可能在底层封装了系统调用

2.3理解管理

在整个计算机软硬件架构中 ,操作系统的定位是:一款纯正的“搞管理”的软件

为了方便理解,我们可以带入一个校园比喻:

  • 校长(OS): 拥有最高决策权(管理者)。

  • 辅导员(驱动程序): 负责跑腿执行(执行者)。

  • 学生(底层硬件): 被安排得明明白白(被管理者)。

校长需要每天盯着每个学生学习吗?不需要!他只需要看辅导员递上来的期末成绩单(数据),就能决定谁拿奖学金,谁要被退学。

结论:

  1. 要管理,管理者和被管理者可以不需要见面
  2. 不见面怎么管理,通过被管理者身上的数据进行管理
  3. 不见面怎么得到数据?由中间层(辅导员)获取,因此OS不与硬件交互,直接从驱动来拿数据即可管理硬件

当学生人数较少时,校长管理学生可以通过excel来获取学生的信息进行管理(对数据管理),可是当学生过多时,遍历excel需要花费过多时间O(n)的效率,管理数据的本质就是对数据进行增删查改,因此可以通过将学生的所有属性通过struct结构体来进行描述,在通过数据结构(如链表)将独立的结构体进行组织起来,我们得出对学生的管理可以转变为对链表的增删查改

因此操作系统管理硬件,将网卡,硬盘,显示器等统一定一个struct类,其包含硬件的所有属性,再通过数据结构进行组织起来
在这里插入图片描述

🔥 核心定律:先描述,再组织!

  1. 先描述: OS 会为网卡、硬盘等硬件统一抽取属性,定义成 struct 结构体(就像定义学生的姓名、学号、绩点)。

  2. 再组织: 利用双向链表等高效的数据结构,将这些 struct 串联起来。

从此,OS 对硬件的管理,就华丽转身变成了对“数据结构(链表)的增删查改”!

2.4 系统调用和库函数的概念

操作系统向上提供对应的服务,但操作系统不相信任何用户或人

举个例子:printf将数据写入到显示器上,操作系统提供系统调用实现了访问硬件的能力,但用户不能直接访问操作系统里面的数据

就像去银行取钱,银行提供存取款服务,但绝不会让你自己进金库拿钱。必须隔着防弹玻璃,由柜员(系统调用)帮你操作。

但直接使用系统调用门槛较高(需要了解底层参数),所以前辈大佬们对部分系统调用进行了适度封装,形成了各种标准库,大大降低了开发者的二次开发难度。

在这里插入图片描述
因此操作系统为我们提供了系统调用(函数调用)来进行为用户服务,进行了封装,用户也无法直接访问操作系统,系统调用既然是函数则大部分都有输入参数和输出参数,用户将参数传给系统调用,操作系统经过处理将输出参数输出,因此系统调用的本质就是方便用户和操作系统之间进行数据交互

总结:

  • 在开发角度 ,操作系统对外会表现为一个整体 ,但是会暴露自己的部分接口 ,供上层开发使用,这部分由操作系统提供的接口 ,叫做系统调用。
  • 系统调用在使用上 ,功能比较基础 ,对用户的要求相对也比较高 ,所以 ,有心的开发者可以对部分系统调用进行适度封装 ,从而形成库 ,有了库 ,就很有利于更上层用户或者开发者进行二次开发。

三、理解进程概念

3.1 什么是进程?

课本概念:程序的一个执行实例,正在执行的程序等
内核观点:担当分配资源系统(CPU时间,内存)的实体

分析:没有运行起来的程序相当于是文件,文件在磁盘里,当程序被执行,会将其的代码和数据加载到内存中(计算机体系结构决定,拷贝),磁盘上有很多可执行程序可以在同一时刻加载到无数程序到内存中,同理操作系统(软件)要被执行也要被加载到内存中(开机加载),程序被加载有很多状态,如已经执行完的,正在执行,暂停运行的状态,因此OS需要对加载到内存的多个可执行程序进行管理:先描述再组织,在OS内创建struct对象(包含属性集和指向自己代码和数据的指针,指向下一个节点的指针)即进程控制块 PCB,因此在OS内形成了一个程序列表(进程列表),那么进程就等于进程列表+其的代码和数据

在操作系统中struct对象的统称均是PCB,在具体Linux中是struct tast_struct(任务),相当于shell和bash的关系,一个是统称,一个是具体

总结:

  1. 进程=内核数据结构对象(PCB)+自己的代码和数据
  2. 进程的所有属性,都可以直接或间接通过task_struct找到
  3. 操作系统对进程的管理变为对数据结构的增删查改

3.2 PCB的属性集

内容分类

  • 标示符PID: 描述本进程的唯一标⽰符 ,用来区别其他进程(类似身份证号)
  • 状态: 任务状态 ,退出代码 ,退出信号等。
  • 优先级: 相对于其他进程的优先级。
  • 程序计数器: 程序中即将被执行的下一条指令的地址。
  • 内存指针: 包括程序代码和进程相关数据的指针 ,还有和其他进程共享的内存块的指针
  • 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子 ,要加图CPU ,寄存器]。
  • I∕O状态信息: 包括显⽰的I/O请求,分配给进程的I∕O设备和被进程使用的文件列表。
  • 记账信息: 可能包括处理器时间总和 ,使用的时钟数总和 ,时间限制 ,记账号等。
  • 其他信息
  • 具体详细信息后续会介绍
    在这里插入图片描述

组织进程:可以在内核源代码里找到它。所有运行在系统里的进程都以 task_struct 双链表的形式存在内核里。
在这里插入图片描述

3.3. 查看进程

我们历史上执行的所有指令,工具,自己的程序,运行起来,全部都是进程

查看进程自己的标示符ID,使用系统调用 getpid,其的返回值就是自己进程ID,getppid是获取父进程ID
在这里插入图片描述

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{

while(1)
{

   sleep(1);
   printf("我是一个进程!,我的pid:%d \n"getpid());
   
}
    return 0;
}

在这里插入图片描述
方法1:
查看当前系统内启动的进程有哪些:ps axj,查看特定进程 ps agx | head -1 (将头,第一条属性列的名字显示出来) ; ps ajx | grep code,将两条指令连接起来,当然也可以使用&&将其连接起来: ps ajx | head -1 && ps ajx | grep code也可以

在这里插入图片描述
注意:为什么使用grep过滤时会查到grepcode,这是因为查进程使用grep做过滤时候,其也是进程,跑起来后其自己过滤的关键字里也包含code,那么当然会将自己也查出来,如果不想看见可以在后面再跟 grep -v grep(-v是反向匹配,包括grep的内容都不匹配),即 ps ajx | head -1 ; ps ajx | grep code | grep -v grep
在这里插入图片描述
方法2:
通过文件的方式查看进程,操作系统不仅可以将磁盘的内容通过ls查看到,还可以将内存的内容也以内存的方式呈现,动态看到内存的数据

通过Linux的proc目录结构来查找:ls /proc,proc就是内存级的系统文件

举例如 :要获取PID为1的进程信息 ,你需要查看 /proc/1 这个文件夹
在这里插入图片描述
方法3:top指令
在这里插入图片描述

在上述代码中我们加入getppid来获取当前进程的父进程,并不断ctrl+c杀掉进程并不断启动进程,子进程的PID不断变化,而父进程的PID不变

在这里插入图片描述
通过ps来查询,我们发现父进程是命令行解释器bash,因此我们可以得出bash本质也是一个进程,OS会给每个用户分配一个bash
在这里插入图片描述
bash是一个进程,那么命令行就是bash用C语言写的一串字符串,当我们没有输入指令时,命令行通过scanf函数使其就卡在这,当用户输入指令,也就是输入到bash上

3.4 理解cwd与chdir

在启动我们进程时,我们知道在proc文件里一定有我们启动进程的内容,我们通过 ls /proc/进程的PID -l ,里面的cwd是当前可执行程序所在的路径,exe则是进程对应的可执行文件

在我们之前学习的fopen函数中,我们打开文件可以带绝对路径,也可以不带路径,因为进程在启动时会记录自己的当前路径cwd,将cwd拼接到要打开的文件前面,因此文件便在当前路径下了
在这里插入图片描述
注意:当我们删掉进程的exe,进程还是在运行的,因为删掉的是磁盘上的文件,进程在启动时其的拷贝已经加载到内存了,因此不影响进程的运行

更改进程所处的当前路径:chdir
在这里插入图片描述

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


int main()
{

while(1)
{
   chdir("/root");
  fopen("hello.txt","a");
   sleep(1);
   printf("我是一个进程!,我的pid: %d\n",getpid());

}
    return 0;
}

在这里插入图片描述
总结:因此当我们执行cd命令是,shell底层便执行系统调用chdir来进行切换

3.5 杀掉进程

方法1:ctrl+c可以杀掉进程
在这里插入图片描述
方法2:kill -9 进程的PID即可
在这里插入图片描述

在这里插入图片描述

3.6 代码创建子进程(fork)

fork没有参数,只有一个返回值
在这里插入图片描述
当下面代码开始运行时是一个执行流,当fork创建子进程后,应该变成两个执行流,子进程和父进程均要执行fork后续printf代码

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
  printf("父进程开始运行,pid: %d\n",getpid());
  fork();
  printf("进程开始运行,pid: %d\n",getpid());

    return 0;
}

在这里插入图片描述
果然是两个进程在运行,一个进程=PCB+自己的代码和数据,那么创建子进程就必然要为其创建PCB,将父进程PCB的内容拷贝给子进程(大部分代码是一样的,浅拷贝),因此子进程默认指向父进程的代码和数据,因此子进程被调度时就会执行父进程的代码,即子进程没有自己的代码和数据,因为目前没有程序新加载

fork的返回值:创建子进程成功,会将子进程的PID给父进程,将0返回给子进程,创建失败将-1返回给父进程
在这里插入图片描述

父子进程未来执行不同的代码逻辑

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


int main()
{
  printf("父进程开始运行,pid: %d\n",getpid());
  pid_t id=  fork();
if(id<0)
{
  perror("fork");

}
else if(id==0)
{

//child
//
while(1)
{
  sleep(1);
printf("我是一个子进程!我的pid:%d,我的父进程pid:%d\n",getpid(),getppid());

}
}
else
{
//father
while(1)
{

  sleep(1);
printf("我是一个父进程!我的pid:%d,我的父进程pid:%d\n",getpid(),getppid());

}
}

printf("进程开始运行,pid: %d\n",getpid());
    return 0;
}

在这里插入图片描述

fork之后所有父子代码是共享的,不过父进程的返回值时大于0,子进程的返回值等于0,各自执行对应的语句,因此父子可以执行不同的代码。

3个问题:

  1. 为什么fork给父子返回各自不同的返回值
    解答:
    父进程:子进程=1:n,即一个父进程有多个孩子,但是一个子进程只能有一个父亲,因此一定要将子进程的PID返回给父进程,方便父进程通过PID区分子进程
  2. 为什么一个函数会返回两次?
    解答:执行fork函数时(申请新的pcb,拷贝父的pcb给子进程,子pcb放入进程list甚至放入调度队列中)在fork执行自己的return前,子进程已经被创建,return也是代码语句,代码是共享的,那么父子要同时执行return语句,因此return被执行两次
  3. 为什么一个变量既等于0,又大于0?导致 if else 同时成立???
    进程具有独立性(微信挂了不影响我qq的聊天),代码是只读的(共享的),父子进程均不可以修改,父子在数据层面是共享的,一旦一方修改数据,OS把要被修改的数据在底层拷贝一份,让目标进程修改这个拷贝(写实拷贝),在fork函数return返回时是向id写入变量,父子哪个先return,就先修改id,发生写实拷贝之后,父子就拿到不同的变量

验证写实拷贝

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

int gval=100;
int main()
{
  printf("父进程开始运行,pid: %d\n",getpid());
  pid_t id=  fork();
if(id<0)
{
  perror("fork");

}
else if(id==0)
{
//child
printf("我是一个子进程!我的pid:%d,我的父进程pid:%d,gval:%d\n",getpid(),getppid(),gval);
  sleep(5);
while(1)
{
sleep(1);
  printf("子进程修改变量:%d->%d",gval,gval+10);
  gval+=10;
printf("我是一个子进程!我的pid:%d,我的父进程pid:%d\n",getpid(),getppid());

}
}
else
{
//father
while(1)
{

  sleep(1);
printf("我是一个父进程!我的pid:%d,我的父进程pid:%d,gval:%d\n",getpid(),getppid(),gval);

}
}

printf("进程开始运行,pid: %d\n",getpid());
    return 0;
}

在这里插入图片描述

总结

📝 总结与互动
从冰冷的硬件架构,到鲜活的进程生命周期,操作系统的魅力在于它用最严谨的逻辑,管理着最庞杂的资源。希望这篇文章能帮你拨开Linux底层的迷雾,对“进程”有一个全新且立体的认识!

验证你是否真正掌握的标准,就是看能不能自己写出一段 fork() 代码,并成功向别人解释清楚“写时拷贝”的原理。


肝文不易,如果这篇文章对你有所启发,请不要吝啬你的支持:
👍 点赞:是对博主最大的鼓励!
⭐ 收藏:怕以后找不到?收藏起来随时复习!
💬 评论:有任何疑问或者不同的见解,欢迎在评论区和我激情讨论,我们一起进步!

关注 [爱和冰阔乐],带你解锁更多C++/Linux硬核干货!我们下期再见!👋

上集回顾:
【Linux工具链】从代码托管到精准追踪Bug:Git常用指令+GDB临时变量与调用栈剖析

【Linux工具链】编译效率革命:条件编译优化+动静态库管理+Makefile自动化,解决多场景开发痛点

【Linux工具链】从跨平台适配到一键部署:yum多架构支持+Vim远程编辑+gcc交叉编译,解决多场景开发效率瓶颈

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐