【Linux】进程概念

目录

1、冯诺依曼体系结构

2、操作系统

3、进程

4、环境变量

5、进程地址空间

6、页表(简单初步理解)


1、冯诺依曼体系结构

a、存储器指的是:内存

外设:

b、输入设备:鼠标,键盘,摄像头,话筒,磁盘(计算机可以读取磁盘中的文件数据),网卡(计算机通过网络可以接收到对方发来的信息,一定是网卡先收到了,然后再交给操作系统,再交给应用程序)......

c、输出设备:显示器,播放器硬件,磁盘(计算机不想将数据显示给人,而是将数据保存在电脑文件里),网卡(可以将数据发给别人).......

CPU:

d、运算器:对我们的数据进行计算任务(算数运算、逻辑运算)

e、控制器:对我们的计算硬件流程进行一定的控制

a,b,c,d,e 五大部分都是独立的! 因此需要数据联通就要用'"线"把他们连接起来。

总线:1、系统总线 2、IO总线

内存(存储器)和CPU之间的线叫做系统总线。CPU和输入输出设备之间的叫作IO总线。

数据是从输入设备到存储器,CPU从存储器中拿数据,处理完之后将数据再返还给存储器,再将数据给输出设备。

CPU中的寄存器的存储效率是很高的,存储器的存储效率也不错,但是输入设备和输出设备的存储效率是比较低的。(存储金字塔)

为什么冯诺依曼体系结构要有存储器,而不是直接将输入输出设备和CPU连接???

因为 外设和中央处理器之间的代差太大了!! "木桶效应"

如果让外设和中央处理器直接进行交互,那么整个计算的处理效率就和外设的效率相等了。导致整机效率特别低下。内存就是为了适配CPU的外设之间的速度差。

但是,又有一个疑问,这个体系结构不就是个串行的吗,那它的速率也快不了多少吧.....

我们平时执行的二进制程序,本质是在文件上,一个程序要运行就必须先把他加载到内存。只要在文件上我们就可以从磁盘进行读取。这个文件再进行cpu处理之前,可能已经预加载到了存储器里,那么当cpu要进行处理的时候。当数据从磁盘正加载到内存中的时候,cpu可能正在进行其他的处理。当加载完成的时候,cpu开始执行代码,但是要执行的时候代码已经都加载到内存了。所以只需要开始加载一下哎,后面直接运行就好了,就是CPU和内存之间的交互。所以加载和cpu的计算同时进行,就从串行变成了并行。因此就提高了效率。(上面的这些工作都是由操作系统完成的)

因此,存储器也可以叫作 硬件级别的缓存空间

存储金字塔:

2、操作系统

**是什么???**操作系统是一款进行管理的软件!可以管硬件 也可以管软件

**为什么???**操作系统通过管理好底层的软硬件资源(手段 ),为用户(普通用户,程序员,其实普通用户使用的是程序员开发出来的应用软件)提供一个良好的执行环境(目的)。

操作系统里面会有各种数据。可是。**操作系统不相信任何用户!**操作系统为了保证自己数据安全,也为了给用户提供服务,操作系统以接口的方式给用户提供调用的接口。来获取系统内部的数据。

接口:接口时用c语言实现的,是操作系统提供的,自己内部的函数调用。 这种调用被称为系统调用。 因此,所有访问操作系统的行为,都只能通过系统调用完成。

怎么做???管理:操作系统 ----驱动程序---软硬件资源 管理者和被管理者不需要见面,同过数据进行管理, 获取数据是由管理者和被管理者之间的来获取的,即驱动程序。

当操作系统拿到数据,由于数据很多,就需要对数据进行管理。将数据描述成一个struct结构体,将一些有相同属性的软硬将在串联起来(struct student* next)。这个过程就叫做,先描述再组织。因此,在操作系统中,管理任何对象,最终都可以转化成为对某种数据结构的增删改查

因此,我们就可以知道,操作系统内部就注定有大量的数据结构。

库函数和系统调用是上下层和调用和被调用的关系。

3、进程

一个已经加载到内存中的程序叫做进程。

操作系统内部,不仅仅只能运行一个进程,它是可以同时运行多个进程的。因此,操作系统必须将进程管理起来! 如何进行管理呢?? 先描述,再组织

任何一个进程,加载到内存中的时候,形成真正的进程时,操作系统要先创建描述进程属性的结构体对象- --PCB(process ctrl block)--- --进程控制块

pcb是进程属性的集合

pcb里面包括:进程编号,进程状态,优先级,相关的指针信息(以便可以找到对应的代码和数据),struct PCB* next (将操作系统中无数个PCB结构体连接起来)......

进程 = 内核PCB数据结构对象 (描述这个进程的所有属性值)+ 自己的代码和数据(从磁盘加载到内存中的) 而要对进程做管理,不是直接对代码和数据进行管理,而是对PCB进行管理。

那么具体 Linux 是怎么做的?

1、描述进程:

pcb-> task_struct 结构体,里面包含进程的所有属性。

Linux内核中,最基本的组织进程task_struct的方式,采用双向链表。但是不仅仅是双向链表,因为pcb的组织数据结构是复杂的,可能是多种数据结构融合起来的。

在结构体中加 struct PCB *queue , struct PCB* XXX
task_ struct 内容分类
标示符 : 描述本进程的唯一标示符,用来区别其他进程。
状态 : 任务状态,退出代码,退出信号等。
优先级 : 相对于其他进程的优先级。
程序计数器 : 程序中即将被执行的下一条指令的地址。
内存指针 : 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
上下文数据 : 进程执行时处理器的寄存器中的数据 [ 休学例子,要加图 CPU ,寄存器 ] 。
I / O 状态信息 : 包括显示的 I/O 请求 , 分配给进程的 I / O 设备和被进程使用的文件列表。
记账信息 : 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
其他信息
2、组织进程:
可以在内核源代码里找到它。所有运行在系统里的进程都以 task_struct 链表的形式存在内核里。

3、查看进程: 使用 /proc

如何理解当前路径(应该站在进程角度去理解),本质上是当进程在启动时,进程的PCB属性里本身就记录了自身当前所在的linux的绝对路径,所以当我们在创建文件的时候,fopen在调用的时候实际是加上了当前路径/log.txt,所以,最终这个文件就创建在了当前路径下。

通过系统调用获取进程标识符****​​​​​​:
进程 id ( PID )
父进程 id ( PPID )
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("pid: %d\n", getpid());
printf("ppid: %d\n", getppid());
return 0;
}

4、初识fork

创建进程的两种方式:./运行我们的程序----指令级别

fork()------代码层面创建的子进程

看上面的代码解决几个问题:
1、为什么fork()要给子进程返回0,给父进程返回子进程的pid?

一般而言,fork之后的代码父子共享,返回不同的返回值,是为了区分让不同的执行流执行不同的代码块!父进程可能会有多个子进程,父进程可能要对子进程做控制时,就要想办法区分子进程,因此父进程要拿到子进程的pid来标识子进程的唯一性。但是子进程要想找到父进程只需要getppid就可,所以给它返回0,标识成功即可。
2、一个函数是如何做到返回两次的?如何理解?

fork是一个函数,在这个函数的代码块里面,会执行1、创建子进程的PCB 2、填充PCB对应的内容 3、让父子进程指向同样的代码 4、父子进程都是由独立的task_struct,可以直接被CPU调度运行了!.......

当这个函数执行完之后,return 语句是会被父子进程都执行的,因此就会返回两次。
3、一个变量怎么会有不同的内容?如何理解?

代码是父子进程共享的,因为代码是不会被修改的。但是代码共享不会影响父子进程的独立性。又因为数据是可以被修改的,所以不能让父子进程共享同一份数据。子进程会将父进程的数据给自己拷贝一份。但是如果子进程对父进程创建出来的数据压根就没有使用,那就会造成操作系统中能够使用的资源减少,因为有很多都是没有用的。 但是在操作系统中真正的是当子进程创建出来的时候,是和父进程共享同一份数据何代码的。因为进程具有独立性,当系统发现子进程要修改父进程的数据的时候,系统就会为子进程拷贝一份这个要修改的数据,然后再让子进程对其进行修改。用多少给多少,这个过程就叫数据层面的写时拷贝

因此,在fork函数之后的return返回父子都要执行,在接收返回值的时候系统发生了写时拷贝,因此就会有两个id值。

那同一个变量名为什么能让父子进程看到不同的东西??(后面再解决,地址空间再进行补充)

4、fork函数,究竟在干什么?

创建子进程就是系统中多了一个进程,当fork创建子进程之后就是创建了子进程的PCB,这个PCB是根据父进程的创建出来了,但是代码子进程的PCB还是会指向和父进程一样的。所以,在fork之后代码就共享了。 因此,父子都会执行fork之后的代码。 代码是不可被修改的!

我们为什么要创建子进程??为了让父和子执行不同的事情。需要想办法让父和子执行不同的代码块。让fork有不同的返回值。

如果父子进程被创建好,fork()之后,父子进程谁先运行??

答案是:谁先运行由调度器决定,是不确定的。 调度器是什么? 它是挑选一个进程放到我们cpu上去运行的。

5、进程状态

介绍一下 一般的操作系统学科 进程状态 运行 阻塞 挂起

1、运行:

进程是pcb+对应的代码和数据

有一个运行队列

cpp 复制代码
struct runqueue
{
    //运行队列
    struct task_struct *head;
    struct task_struct *tali;
}
//一个CPU只有一个runqueue

在运行队列里面的进程就是 运行态的 指的是已经准备好了可以随时被调度。

有一个问题,一个进程只要把自己放到CPU上开始运行了,是不是一直要执行完毕,才把自己放下来? 答案是:不是。因为每个进程都有时间片的概念! 因此,在一个时间段内,所有进程代码都会被执行。这也叫并发执行。 因此,就会有大量的把进程放到cpu上和拿下来的工作,这叫作进程的切换。

2、阻塞:

计算机包含各种设备,这些被称为外设。 那么在操作系统中,对这些设备作管理,就是先描述,再组织。

那么就会有一个 struct dev结构体

cpp 复制代码
struct dev
{
    int type;//设备类型
    int status;//设备状态
    struct task_struct *head;
    //还有等等很多.....
}

每一个设备都是一个对象,所以,就会有很多这样的结构体,我们管理的时候就要将这些结构体链接起来。所以,我们就将对这些设备的管理转化成对这些结构体的管理。

现在,操作系统中有一个进程,他是要从键盘中读取数据,但是现在键盘一直没有输入,那么这个进程就要等待键盘的输入。那么这个键盘设备的结构体中的指针 struct task_struct *head 就要指向等待的进程pcb,就会在特定的等待设备中去等待,如果还有等待的就再继续链入到后面。 一旦等待到了键盘资源就会去运行队列里排队。

在等待队列的进程就被称为阻塞状态。(系统中会有成百上千个阻塞队列)

3、挂起:

当进程在等待的时候,它的数据和代码是空闲的。那么现在它就是占的内存,但没有使用。 如果,此时,操作系统内部的内存资源严重不足了,操作系统就要保证正常的情况下,省出来内存资源,它就会把等待队列中的进程的数据和代码放到外设中(比如说磁盘),让该进程的PCB排队,就会省出来一部分空间。当这个进程就绪了之后,再将代码和数据换入就好了。 一个进程的代码和数据并没有在内存当中,这个进程就被称为挂起状态。

具体的Linux状态是如何维护的?

进程的状态:

R 运行状态

S 阻塞 (浅度睡眠)---可以被随时唤醒

D 深度睡眠(也是阻塞)----不相应任何请求

T t 暂停

X 终止态

Z 僵尸态---进程一般退出的时候,如果父进程,没有主动回收子进程信息,子进程会一直让自己处于Z状态,进程的相关资源尤其是task_struct 结构体不能被释放。只有当父进程回收了之后,这些才会被释放。 如果父进程不回收,资源会被一直占用。从而就会造成内存泄露!

sleep状态和暂停状态有什么区别:两个在应用场景上是有区别的,S是等待某种资源,而T可能是等待某种资源,也有可能进程单纯的被控制了(例如:gdb调试)。

总结:
R 运行状态( running ) : 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
S 睡眠状态( sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠 。
D 磁盘休眠状态( Disk sleep )有时候也叫不可中断睡眠状态( uninterruptible sleep ),在这个状态的进程通常会等待IO 的结束。
T 停止状态( stopped ): 可以通过发送 SIGSTOP 信号给进程来停止( T )进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
X 死亡状态( dead ):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。

僵尸进程
僵死状态( Zombies )是一个比较特殊的状态。当进程退出并且父进程没有读取到子进程退出的返回代码时就会产生僵死( 尸 ) 进程。
僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入 Z 状态。
进程的退出状态必须被维持下去 ,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就一直处于Z 状态?是的!
维护退出状态本身就是要用数据维护 ,也属于进程基本信息,所以保存在 task_struct(PCB) 中,换句话说,Z 状态一直不退出,PCB一直都要维护?是的!
那一个父进程创建了很多子进程,就是不回收 ,是不是就会造成内存资源的浪费?是的!因为数据结构对象本身就要占用内存,想想C 中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间! 就会造成内存泄漏? 是的

孤儿进程

父子进程,父进程先退出,子进程的父进程就会改为1号进程(操作系统),父进程是1号进程的进程就叫作孤儿进程。 这个过程就叫这个进程被系统领养了。

为什么被领养?? 因为孤儿进程未来也会退出,也要被释放。

补充:

struct node //节点 //双链表

{

struct node* next;

struct node* prev;

}

struct node1 //二叉树

{

struct node* left;

struct node*right;

}

struct task_struct //PCB

{

//包含所有属性

struct node link;

struct node1 link1;

}

没有把整个PCB链接起来,而是把内部的元素组织起来。当我们想遍历这个链表的时候,就遍历中间的心就好了。那么我们怎么能读到pcb里面除了node的其他字段?

task struct.link的next指向下一个节点的node

把0号地址强转成task_struct,那么从0号地址开始就存在一个结构体对象就叫task_struct,从0号地址向上访问sizeof(task struct)个字节,就能访问task_struct对象。

&(task_struct*)0->link拿到link的地址

(task_struct*)(start-&(task struct*)0->link)->other

访问link字段,link是结构体内的某个成员,因为结构体的起始位置为0,link为结构体内的元素,结构体变量的起始位置为0,内部元素取出来的地址代表的是link在整个task_struct结构体内的偏移量结构体变量在取地址时,我们取出来的地址是它内部成员地址当中所有地址中最小的一个, start是link这个字段在task_struct结构体内真实的地址,用link字段真实的地址减link在task_struct内的偏移量,就是task_struct的起始地址 。然后再让它强转。**我们就可以访问这个结构体内的其他字段。**因此,就算是两个不同的对象我们也可以用双链表将它链接起来,只要我们知道它的类型我们就可以强转。 所以,pcb的结构不是我们想象的单纯的某一种数据结构(如上图所示)。

6、进程优先级

是什么??

优先级(对于资源的访问,谁先访问,谁后访问)vs 权限(能不能)

为什么??

因为资源是有限的,进程是多个的,注定了,进程之间是竞争关系!----竞争性

操作系统必须保证大家良性竞争,确认优先级。

如果我们进程长时间得不到CPU资源,该进程的代码长时间无法得到推进----就叫进程的饥饿问题(17.11如何进行批量化注释,看回放哦)

怎么办??Linux中是怎么实现的??


PRI :代表这个进程可被执行的优先级,其值越小越早被执行
NI :代表这个进程的nice值,这个是进程优先级的修正数据

那么,这样的话,进程的优先级可以被调整喽!那么我可以大大的更改nice值,大大提高我们进程的优先级?控制优先级让我们的进程一直被调度。
Linux不想让过多的让用于参与的优先级调整,应该在对应的范围内进行优先级调整。
nice: [-20,19]
那么,PRI 80->[60,99]
PRI 也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被 CPU 执行的先后顺序,此值越小
进程的优先级别越高
那 NI 呢 ? 就是我们所要说的 nice 值了,其表示进程可被执行的优先级的修正数值
PRI 值越小越快被执行,那么加入 nice 值后,将会使得 PRI 变为: PRI(new)=PRI(old)+nice
注意,只要开始调整,这个PRI都是从80开始的
这样,当 nice 值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行
所以,调整进程优先级,在 Linux 下,就是调整进程 nice 值
nice 其取值范围是 -20 至 19 ,一共 40 个级别。
//CPU调度

cpp 复制代码
struct runqueue
{
    bitmap isempty;//位图
    
    task_struct** run;//指向running
    task_struct** wait;//指向waiting
    task_struct* running[140];
    task_struct* waiting[140];
    //[0,99]:其他种类进程使用的,我们不用管。我们用的是[100,139],刚好是40个
    //running和waiting是指针数组
    //[60,99]->[100,139]
}

//[0,99]:其他种类进程使用的,我们不用管。我们用的是[100,139],刚好是40个
//running和waiting是指针数组
//[60,99]->[100,139]

如下图,指针数组的下标就是进程的优先级。

当这个进程的优先级为60的时候,排到运行队列里,再来一个60的排到它的后面,来一个61的就排到下一个数组的下标位置。诸如此类。当我在运行这个队列的时候又有其他进程来了,现在已经排了很多了,我们就会将进程按照相同的方法排到下面的那个指针数组中,当上面的执行完之后,只在需要将将两个数组即可swap(&run,&wait)

我们可以遍历这个数组,就可以知道这些进程有没有运行完。

这些进程都是R状态,我们可以根据优先级将他们打散到数组中,先调哪个再调哪个。因此,改变优先级就是改变进程在数组中存放的位置。

其他概念:

竞争性 : 系统进程数目众多,而 CPU 资源只有少量,甚至 1 个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
独立性 : 多进程运行,需要独享各种资源,多进程运行期间互不干扰
并行 : 多个进程在多个 CPU 分别,同时进行运行,这称之为并行( 同一时刻同时 运行,就叫并行
并发 : 多个进程在一个 CPU 下采用进程切换的方式, 在一段时间之内,多个进程都得以推进,称之为并发

并发????

基于进程切换和时间片轮转的调度算法。就可以让进程在一段时间内并发运行。

cpu的runqueue 会维护两个表,加入有一个优先级是60的进程,当他运行完之后它不是再去第一张表的队列中去排队的,而是去第二张表。等第一张表的执行完之后,再去执行第二张的。

为什么函数返回值会被外部拿到? 通过CPU寄存器

系统如何得知我们的进程当前执行到哪行代码了?

程序计数器/eip:记录当前进程正在执行指令的下一行地址!

加入寄存器里面有一个叫eip的,值是50,那么现在进程就是在执行第49行代码。所谓的函数跳转,执行的顺序都是靠改变这个eip的值来搞定的。

CPU内有很多寄存器

通用寄存器:eax ebx ecx edx

形成栈帧的寄存器:ebp sep eip

状态寄存器: status

CPU里面为什么要有这么多的寄存器?它在扮演上面角色呢?

寄存器具有对数据临时保存的能力,那就注定了在计算机运行的时候一些重要的数据就要放在cpu内部,因为离cpu更近,数据的存取效率就越高,其实寄存器内的数据放在哪都可以,那我为什么要放的离cpu这么近,主要就是为了提高存取的效率,就注定了将进程的高频数据放到寄存器中。 总而言之,就是寄存器里面保存的是进程的相关数据(该数据就是会被cpu访问或修改的) CPU寄存器里面保存的是进程的临时数据----进程的上下文!

进程在CPU上离开的时候,要将自己的上下文数据保存好,甚至带走。保存的目的,都是为了未来的恢复。 一个长时间运行的进程注定会被高频的随时切换。

进程在被切换的时候:1、保存上下文 2、恢复上下文

cpu内的寄存器数据被保存到哪里比较好呢?

当前进程的上下文数据被放到 进程的PCB里 一人一份

在task_struct结构体中再套一个struct reg_info结构体即可

struct reg_info

{

int eax;

int ebc;

int eip;

.....

}

4、环境变量

系统当中的指令

使用 which pwd

/usr/bin/pwd-----系统当中指令的默认路径

我们自己写的程序要让它执行就要带./ 而系统的指令执行时直接输入就ok,那这是为什么呢???

答案是:系统当中针对于指令的搜索Linux系统会为我们提供一个环境变量PATH

使用 echo $PATH

使用" :"作为分隔符,定义出来了非常多的路径。那么为什么在执行pwd的时候系统知道它的路径呢?就是因为在执行pwd时,shell会首先在PATH一个一个路径下去找,当找到/usr/bin之后就直接执行该路径下的pwd程序。当我们想让执行我们的程序的程序的时候,不加./ 就可以使用PATH=$PATH:+我们程序的路径

这样我们就将我们的自己的路径添加到了环境变量里。

当我们重新登陆shell时,环境变量就会恢复到最初

常见的环境变量

PATH : 指定命令的搜索路径
HOME : 指定用户的主工作目录 ( 即用户登陆到 Linux 系统中时 , 默认的目录 )
SHELL : 当前 Shell, 它的值通常是 /bin/bash
查看环境变量
echo $NAME //NAME: 你的环境变量名称
env 查一批环境变量

echo $PATH一个环境变量

cpp 复制代码
#include<stdio.h>
#include<stdlib.h>

int main()
{
    printf("PATH: %s\n", getenv("PATH");
    return 0;
}

上面的代码我们直接./mycmd就可以得到PATH环境变量的内容,就是一些指令的搜索路径

cpp 复制代码
#include<stdio.h>
#include<string>
#include<stdlib.h>

int main()
{
    char who[32];
    strcpy(who, getenv("USER"));
    
    if(strcmp(who, "root") == 0)
    {
        printf("让他做任何事情");
    }
    else
    {
        printf("你就是一个普通用户,受权限约束\n");
    }
    return 0;
}

getenv是获取指定的一个环境变量

上面代码,当你是普通用户的时候,./mycmd 输出你是普通用户

当你是root用户的时候,./mycmd输出你可以做任何事情

这就是以为环境变量改变了,系统就会执行不同的操作。

综上所述,什么是环境变量??

环境变量是系统的一组name = value形式的变量,不同的环境变量有不同的用户,通常具有全局属性。对于系统中存在环境变量,这个环境变量不因为你的进程被创建而存在,它是在系统启动的时候已经有了,环境变量在配置的时候是先被bash进程先获得到。

穿插一个概念,命令行参数

cpp 复制代码
#include<stdio.h>
#include<string.h>
#include<stdlib.h>

int main(int argc, char *argv[])
{
    int i = 0;
    for(; i < argc; i++)
    {
        printf("argv[%d]->%s\n",i,argv[i]);
    }
    return 0;
}

命令行输入:./mycmd

argv[0] -> ./mycmd

命令行输入./mycmd -a ​​​​​​​

argv[0] -> ./mycmd

argv[1] -> -a

命令行输入./mycmd -a -b

argv[0] -> ./mycmd

argv[1] -> -a

argv[2] -> -b

命令行输入./mycmd -a -b -c

argv[0] -> ./mycmd

argv[1] -> -a

argv[2] -> -b

argv[3] -> -c

命令行输入./mycmd -a -b -c -d -e​​​​​​​

argv[0] -> ./mycmd

argv[1] -> -a

argv[2] -> -b

argv[3] -> -c

argv[4] -> -e
int argc, char *argv[] argv是一个指针数组,数组中有多少个元素由grgc决定。在用户层面上,我们认为main函数是第一次被调用的,但是,其实在c编译器编译之后它其实不是第一个被调用的。第一个被调用的函数其实是Startup(); 那么main()的参数其实是可以被传递的。我们在命令函中输入 ./mycmd -a -b -c ​​​​​​​命令 其实在bash看来 输入的是"./mycmd-a-b-c" 这样的字符串。 bash在作命令函解释的时候,其实就是将字符串打散成

"./mycmd" "-a" "-b" "-c"四个字符串。由几个字符串,就初始化argc是几,现在argc是4。

再将字符串的地址保存到argv这个指针数组里。在系统中搞定这些之后,再将这两个参数传递给main函数。( 打散字符串的过程就叫作命令解析工作。)

那系统中为什么要这样操作呢???

cpp 复制代码
#include<stdio.h>
#include<string.h>
#include<stdlib.h>

int main(int argc, char* argv[])
{
    if(argc !=2)  //必须要是命令加一个选项
    {
        printf("Usage: %s -[a|b|c|d]\n",argv[0]);
        return 0;
    }
//等于2
    if(strcmp(argv[1], "-a") == 0)
    {
        printf("功能1\n");
    }
    else if(strcmp(argv[2], "-b") == 0)
    {
        printf("功能2\n");
    }
    else if(strcmp(argv[3], "-c") == 0)
    {
        printf("功能3\n");
    }
    else if(strcmp(argv[4], "-d") == 0)
    {
        printf("功能4\n");
    }
    else 
    {
        printf("default功能\n");
    }
    return 0;
}

由上面的代码我们可以得出,命令行参数是为指令、工具、软件等提供命令行选项的支持!使用不同的选项选择不同的功能。

除了上面提到的两个参数之外,main函数还有一个参数,char* env[]

int main(int argc, char* argv[ ], char* env[ ])

env和argv一样都是指针数组,那么C/C++代码会有两张核心向量表:

1、命令行参数表****2、环境变量表​​​​​​​

环境向量表的组织形式:(指针数组,将每个环境变量都存起来)

我们可以来验证一下,env数组是不是存的环境变量

cpp 复制代码
#include<stdio.h>
#include<string.h>
#include<stdlib.h>

int main(int argc, char *argv[],char *env[])
{
    int i = 0;
    for(; i < argc; i++)
    {
        printf("env[%d]->%s\n",i,env[i]);
    }
    return 0;
}

上面这段代码让它运行的话,打印出来的就是所有的环境变量。

因此,进程的启动不是单纯的把程序加载到内存中,而是当我们自己的程序变成进程在启动时一定要有人调main函数,给main函数把这命令行参数表和环境变量表传进来。

在我们没有./mycmd时,这些环境变量就已经存在了,是由bash维护的。当我们 ./mycmd时,打印出的环境变量和本来的是一样的。我们所运行的进程都是子进程,bash本身在启动后的时候,会从操作系统的配置文件中读取环境变量信息,子进程是会继承父进程的环境变量表的!这也就证明了环境变量具有全局属性。

本地变量&&内建命令

$ my_108 = 108 //创建本地变量---本地变量不能被子进程所继承

$ echo $my_108 //因为我们之前说过,命令行都是bash进程的子进程,但是输入这行命令 却可以查到108 这是为什么呢??

108

答案是:命令分为两批命令:

1、常规命令:通过bash创建子进程来完成的

2、内建明令:bash不创建子进程,而是由自己亲自执行,类似于bash调用了自己写的或者系统提供的函数。

5、进程地址空间

历史核心问题:
pid_t id = fork();

if(id == 0)

else if(id > 0)

回顾程序地址空间

cpp 复制代码
#include<stdio.h>
#include<stdlib.h>

int g_val_1;
int g_val_2 = 100;

int mian()
{
    printf("code addr: %p\n",main);//代码区
    const char *str = "hello bit";
    printf("read only string addr: %p\n",str);//字符串常量区//这个指针字符串里面保存的就是这个字符串的地址,所以打印str就好了,而不是&str/*str
    printf("init global value addr: %p\n",&g_val_2);//已初始化变量区
    printf("uninit global value addr: %p\n",&g_val_1);//未初始化变量区
    char *mem = (char*)malloc(100);
    printf("heap addr: %p\n",mem);//堆区 mem保存堆申请的地址,&mem是mem这个指针变量保存的位置
    printf("stack addr: %p\n",&str);//栈区 &str和&mem都是临时变量,都在栈区。

    return 0;
}

由结果可知,地址是不断变大的,由此,我们就可以验证上面每个分区的相对位置

堆区和栈区是相向增长的,堆区是向上增长,栈区是向下增长,我们来验证一下。

cpp 复制代码
#include<stdio.h>
#include<stdlib.h>

int g_val_1;
int g_val_2 = 100;

int mian()
{
    printf("code addr: %p\n",main);//代码区
    const char *str = "hello bit";
    printf("read only string addr: %p\n",str);//字符串常量区//这个指针字符串里面保存的就是这个字符串的地址,所以打印str就好了,而不是&str/*str
    printf("init global value addr: %p\n",&g_val_2);//已初始化变量区
    printf("uninit global value addr: %p\n",&g_val_1);//未初始化变量区
    char *mem = (char*)malloc(100);
    char *mem1 = (char*)malloc(100);
    char *mem2 = (char*)malloc(100);
    printf("heap addr: %p\n",mem);//堆区 mem保存堆申请的地址,&mem是mem这个指针变量保存的位置
    printf("heap addr: %p\n",mem1);//堆
    printf("heap addr: %p\n",mem2);//堆
    printf("stack addr: %p\n",&str);//栈区 &str和&mem都是临时变量,都在栈区。
    printf("stack addr: %p\n",&mem);//栈区
    int a;
    int b;
    int c;
    printf("stack addr: %p\n",&a);//栈区
    printf("stack addr: %p\n",&b);//栈区
    printf("stack addr: %p\n",&c);//栈区

    return 0;
}

先定义的变量先入栈,由于栈向地址减小方向增长,后定义的变量后入栈,所以后定义的变量的地址当然小啦

栈地址不断变小:

堆地址不断变大:

我们还可以发现一个问题,栈和堆的地址差别很大,它两之间有一段漏空,这个以后再说。

再验证一个语法问题:

static int a = 0;//用static修饰这个变量为什么这个变量就不会随着函数的结束被释放了?

是因为static修饰的局部变量,在编译的时候,已经被编译到全局数据区了,已经是一个全局变量了,这个变量的作用域只能在这个函数里,但是它的生命周期和全局变量是一致的。

cpp 复制代码
#include<stdio.h>
#include<stdlib.h>
#include<unstd.h>

int g_val = 100;

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        //子进程 --不断的获取全局变量
        while(1)
        {
            printf("i am child, pid: %d, ppid: &d, g_val, &g_val: %p\n",getpid(),getppid(),g_val,&g_val);
            sleep(1);
            if(cnt) cnt--;
            else {
                    g_val = 200; //将全局变量由100改成200
                    printf("子进程change g_val : 100->200\n");
                    cnt--;
                 }
        }
    }
    else
    {
        //父进程
        while(1)
        {
           printf("i am parent, pid: %d, ppid: &d, g_val, &g_val: %p\n",getpid(),getppid(),g_val,&g_val);
            sleep(1);
        }
        
    }
    return 0;
}

我们由代码运行的结果可以看出,在没有改之前,父子进程打印出来都是100,在改变之后子进程变为200,父进程是200.我们由可以想到,是因为子进程发生了写时拷贝,将这个数据自己拷贝了一份,然后改了。但是!我们又发现他们两个的这个全局变量的地址竟然是相同的,地址相同,变量名相同,但是变量的值却不相同,换句话说,就是父子进程读取同一份地址,同一个变量,同时读取,但是却读出了不同的内容,这是为什么呢???

先给出一个结论:如果变量的地址是物理地址,就不可能出现上面的现象!!所以,这个地址绝对不是物理地址!这个地址其实是 线性地址 也叫 虚拟地址!

我们其实平时写的C/C++,用的指针,指针里面的地址,全部都不是物理地址!是虚拟地址!

首先,初步理解这种现象。

在之间我们简单的理解进程是PCB+程序,其实并没有那么简单。

在父进程创建出子进程之后,操作系统并不只是简单的给新的进程创建了一个PCB,而且操作系统还要为进程创建进程地址空间。struct task_struct 结构体中有相应的指针指向进程地址空间。程序员在编程的时候,用的是0000 0000到FFFF FFFF的虚拟地址。但是其实真正的程序代码和数据是存在物理地址上的。

描述一个过程(简单的过程描述):父进程fork创建子进程,父进程有它的PCB结构,进程地址空间,页表假如它的程序地址空间的初始化数据区有一个地址 0x40405c这个地址会被放到页表里面(页表中一边存的是虚拟地址 另一边是 物理地址) 在页表中,这个虚拟地址会有一个物理地址(假如是0x11223344)与它对应,这个物理地址里面存的就是数据的值(假如说是100),然后,父进程创建子进程,子进程将父进程的pcb、进程地址空间、页表都给自己拷贝一份,然后此时,父子进程就是共享同一份数据和代码了。当子进程要对数据进行修改的时候,操作系统检测出它和父进程用的是同一份数据,此时就会发生写时拷贝,将这个数据复制一份给子进程,然后将值改为200,此时,这个物理地址就变量,因为为这个数据重新找了一个地方,因为物理地址不会堆虚拟地址所影响,所以在页表中,对应一下还是之前的虚拟地址。这样,就可以解释上面的那种一个地址两个值的情况。

几个细节问题:

1、地址空间是什么??如何理解地址空间上的区域划分??

CPU和内存这两个硬件,使用线连接起来的。从CPU向内存拷贝数据,的过程其实说简单一点就是,每一根地址总线只有 0和1,公有32根, 因此有2^32种, 2^32*1byte = 4GB(物理内存的最大范围) 因此,地址空间就是 地址总线排列组合形成的地址范围 [0, 2^32]

如何理解地址空间上的区域划分?

cpp 复制代码
struct area
{
    int start;
    int end;
};
struct destop_area//最大范围100
{
    struct area xiaopang;
    struct area xiaohua;
};

    struct destop_area line_area = {{1,50},{51,100}};

//空间区域调整
line_area.xiaopang.end -= 10;
line_area.xiaohau1.start -= 10;

在范围内,连续的空间中,每一个最小单位都可以有地址,这个地址可以被直接使用。

综上所述,所谓的地址空间,本质上是一个描述进程可视范围的大小。地址空间内一定要存在各种区域划分,对线性地址进行start和end即可。

地址空间本质是内核的一个结构体对象,类似PCB一样,地址空间也是要被操作系统管理的:先描述,再组织

cpp 复制代码
struct mm_struct //默认划分的区域是4GB
{
    long code_start,long code_end;
    long read_only_start,long read_only_end;
    long init_start,long init_end;
    long uninit_start,long uninit_end;
    long heap_start,long heap_end;
    long stack_start,long stack_end;
};

因此,每一个进程创建的时候,都要有PCB结构(task_struct结构),mm_struct结构。task_struct结构体里面一定有指针指向自己对应的 mm_struct结构体。 所以,一个进程就可以初步知道它对应的数据在哪里,代码在哪里.....

2、为什么要有进程地址空间??

a、让进程以统一的视角去看待内存

如果一个进程没有虚拟地址空间和页表的话,一个进程再物理内存当中,那么它的代码和数据也只能在物理内存当中,所以,这个进程就要在自己的PCB里记录下来,自己的代码在哪个地址出,数据在哪个地址处,每个进程都要做,而且,每个进程直接使用的就是物理内存了。这样的话进程就必须对自己的代码和数据的使用情况做管理工作。如果说有一个进程以为阻塞被挂起了,代码和数据需要被换出到外设里,当它被重新换入的时候它的代码和数据对应的物理内存就变了,变了就需要重新改对应的PCB,这样太麻烦了! 但是如果有了进程地址空间,进程根本就不关心代码和数据在什么位置,以进程地址空间的视角去看待就好了。进程在访问物理内存的时候都需要通过进程地址空间和页表来对地址进行映射。

b、因此,增加进程虚拟地址空间可以让我们的寻址请求进行审查,所以一旦异常访问,直接拦截,该请求不会到达物理内存,保护物理内存。

c、因为有地址空间和页表的存在,将进程管理模块和物理内存管理模块进行解耦。

6、页表(简单初步理解)

首先可以简单的将页表理解为一个数组,下标是虚拟地址,值是物理地址。当一个进程访问进程时就是已经被调度了。 CPU里面有一个叫作cr3的寄存器 ,该寄存器里面存的是页表地址(本质上属于进程硬件上下文 )。当一个进程的时间片到了,进程要走的时候,进程就会带走这个cr3寄存器里面的东西,回来的时候又会恢复回来,所以就不会担心进程找不到页表。假如有一个代码区的地址是0x1111,在页表中这个地址对应的地址是0x12,那么cpu的cr3寄存器读到0x1111地址时去页表里面找对应的物理地址,发现虚拟地址是代码段的就规定权限是r(只读),如果尝试向该地址写入数据,操作系统识别到之后发现页表是只读,就会直接进行拦截,这个进程进行非法操作,czxt就会直接杀掉这个进程。因此,页表可以很好的为我们提供权限管理。

代码是只读的,字符常量区是只读的,为什么??
因为在页表的映射关系的权限标志位是r

进程是可以被挂起的,那我们怎么知道进程是被挂起了?你怎么知道你的进程的代码和数据在不在内存呢??

操作系统不会做浪费时间和空间的事情。OS对大文件可以实现分批加载,采用惰性加载的方式,就是如果需要10G其实先给即使KB的空间,因为给多了也暂时不会用到,就会造成浪费。 页表中还有一个标志位 填的是对应的代码和数据是否已经被加载到内存,1代表已加载,0代表未加载。当将虚拟地址填到页表中时,物理地址先不填,先去检查代码和数据是否已经被加载到内存,如果发现是1,就直接找页表中它对应的物理地址,找到物理内存那块地址进行访问,如果发现是0,OS就会触发缺页中断。 OS就会自动重现将剩下的代码加载到物理内存里,然后将物理地址填到页表里,然后回到刚才访问的那个过程,继续访问。

进程在被创建的时候,是先创建内核数据结构呢?还是先加载对应的可执行程序呢?

先创建,再加载程序。

更新进程的定义:进程 = 内核数据结构(task_struct && mm_struct && 页表)+程序的代码和数据。

切换进程就要切换PCB、进程地址空间、页表。当前进程的PCB一旦切换他所匹配的地址空间自动被切换,因为PCB指向对应的地址空间,又因为页表的地址属于cr3上下文,所以只要进程寄存器上下文一切换页表就会自动切换,因此,只要进程cpu内的上下文一切换那么这些全部就会切换。

进程具有独立性是怎么做到的? 1、有独立的内核数据结构 2、每个进程物理内存是相互解耦的,虽然不同的进程的虚拟地址可以一样,但是物理地址是绝对不会一样的,就可以做到每个进程的代码和数据不相互影响。

**任意一个可执行程序加载到物理内存的什么地方重要吗?**不重要,因为有页表映射,代码和数据可以在物理内存的任何地方存放,右侧的物理地址可以乱序,但是左侧的虚拟地址是线性的,就可以把无序变有序。

相关推荐
内核程序员kevin41 分钟前
TCP Listen 队列详解与优化指南
linux·网络·tcp/ip
朝九晚五ฺ5 小时前
【Linux探索学习】第十四弹——进程优先级:深入理解操作系统中的进程优先级
linux·运维·学习
自由的dream5 小时前
Linux的桌面
linux
xiaozhiwise6 小时前
Makefile 之 自动化变量
linux
意疏8 小时前
【Linux 篇】Docker 的容器之海与镜像之岛:于 Linux 系统内探索容器化的奇妙航行
linux·docker
BLEACH-heiqiyihu8 小时前
RedHat7—Linux中kickstart自动安装脚本制作
linux·运维·服务器
一只爱撸猫的程序猿8 小时前
一个简单的Linux 服务器性能优化案例
linux·mysql·nginx
我的K84099 小时前
Flink整合Hudi及使用
linux·服务器·flink
19004310 小时前
linux6:常见命令介绍
linux·运维·服务器