一、 冯诺依曼体系结构。
输入设备,存储器,输出设备,运算器,控制器。数据总线,控制总线,系统总线。
二、一个程序要运行,必须先加载到内存中,为什么?
因为冯诺依曼体系结构所确定。
三、操作系统。
是什么?
操作系统是一款管理软硬件资源的软件!
为什么?
1、操作系统帮助用户,管理好下面的软硬件资源!
2、为了给用户提供一个良好(稳定,高效,安全)的运行环境。
操作系统通过管理好底层的软硬件资源(手段),为用户提供一个良好的执行环境(目的)
用户:普通用户,程序员,普通用户使用程序员开发出来的软件。
操作系统里面会有各种数据,可是,操作系统不相信任何用户!操作系统为了保证自己数据安全,也为了保证给用户提供服务,操作系统以接口的方式给用户提供调用的入口,来获取操作系统内部的数据。接口是操作系统提供的用C实现的,自己内部的函数调用 --- 系统调用。所有访问操作系统的行为,都只能通过系统调用完成!
怎么办? 如何管理?
1、管理者和被管理者是不需要见面的。
2、管理者在不见面的情况下,如何做好管理呢?
只要能够得到管理信息,就可以在未来进行管理决策 --- 管理的本质是通过对数据的管理,达到对人的管理。
3、管理者和被管理者面都不见,我们怎么拿到对应的数据呢?
通过执行者。
管理者就是操作系统,执行者就是软硬件资源,
在操作系统中,管理任何对象,最终都可以转化成为对某种数据结构的增删查改
先描述,再组织就是我们的管理方法。
操作系统中一定存在各种各样的数据结构。
四、进程的概念
加载到内存的一段程序成为进程。正在运行的程序也是进程。
ps ajx | grep myprocess
理解:
一个操作系统,不仅仅只能运行一个进程,也可以同时运行多个进程,操作系统必须将进程管理起来。先描述,再组织。任何一个进程,在加载到内存的时候,形成真正的进程时,操作系统,要先创建描述进程的结构体对象 ----- PCB,process ctrl block ----- 进程控制块。想一想,人是通过什么来认识一个事物的,答案是都是通过属性认识的,当属性够多的是时候,这一堆属性的集合,就是目标对象。那么PCB里面包含的就是进程属性的集合。由于操作系统是由C语言编写的,所以使用结构体来描述这些属性。进程可能有进程编号、进程的状态、进程的优先级、相关的指针信息等属性。根据进程的PCB来为进程创建PCB对象。所以:进程 = 内核PCB数据结构对象 + 你自己的代码和数据到此我们完成了描述阶段的任务。在操作系统中,对进程进行管理,变成了对单链表进行增删查改!到此我们完成了组织阶段的任务。
那么Linux中,是怎么做的呢?
在Linux中的PCB是task_struct结构体
内容分类:
标识符:描述本进程的唯一标识符,用来区别其他进程。状态,优先级,程序计数器,内存指针,上下文数据,I/O状态信息,记账信息,其他信息等。这里查看源码可以看到
struct task_struct {
......
/* 进程状态 */
volatile long state;
/* 指向内核栈 */
void *stack;
/* 用于加入进程链表 */
struct list_head tasks;
......
/* 指向该进程的内存区描述符 */
struct mm_struct *mm, *active_mm;
........
/* 进程ID,每个进程(线程)的PID都不同 */
pid_t pid;
/* 线程组ID,同一个线程组拥有相同的pid,与领头线程(该组中第一个轻量级进程)pid一致,保存在tgid中,线程组领头线程的pid和tgid相同 */
pid_t tgid;
/* 用于连接到PID、TGID、PGRP、SESSION哈希表 */
struct pid_link pids[PIDTYPE_MAX];
........
/* 指向创建其的父进程,如果其父进程不存在,则指向init进程 */
struct task_struct __rcu *real_parent;
/* 指向当前的父进程,通常与real_parent一致 */
struct task_struct __rcu *parent;
/* 子进程链表 */
struct list_head children;
/* 兄弟进程链表 */
struct list_head sibling;
/* 线程组领头线程指针 */
struct task_struct *group_leader;
/* 在进程切换时保存硬件上下文(硬件上下文一共保存在2个地方: thread_struct(保存大部分CPU寄存器值,包括内核态堆栈栈顶地址和IO许可权限位),内核栈(保存eax,ebx,ecx,edx等通用寄存器值)) */
struct thread_struct thread;
/* 当前目录 */
struct fs_struct *fs;
/* 指向文件描述符,该进程所有打开的文件会在这里面的一个指针数组里 */
struct files_struct *files;
........
/* 信号描述符,用于跟踪共享挂起信号队列,被属于同一线程组的所有进程共享,也就是同一线程组的线程此指针指向同一个信号描述符 */
struct signal_struct *signal;
/* 信号处理函数描述符 */
struct sighand_struct *sighand;
/* sigset_t是一个位数组,每种信号对应一个位,linux中信号最大数是64
* blocked: 被阻塞信号掩码
* real_blocked: 被阻塞信号的临时掩码
*/
sigset_t blocked, real_blocked;
sigset_t saved_sigmask; /* restored if set_restore_sigmask() was used */
/* 私有挂起信号队列 */
struct sigpending pending;
........
} 作者:补给站Linux内核 https://www.bilibili.com/read/cv15744437/ 出处:bilibili
Linux内核中,最基本的组织进程task_struct的方式,采用双向链表组织的。
查看进程属性的方法:
查看 ls/proc 目录,开机的时候有,关机的时候就不在了
里面也是目录,都是通过进程ID来标识的。注意PID标识符是不确定的。
其中cwd就是当前的工作目录,current work dir:当前进程的工作目录
标识符:
杀掉进程
那么如何拿自己的pid呢?利用系统调用getpid(),pid是唯一的
一个监控的小脚本
获得父进程的pid叫做ppid
我们可以发现父进程ID是不变的,21823进程号是谁呢?
它是bash进程,每次登录后,它的进程号也会变化
自己创建进程,运行期间创建子进程,使用fork函数
fork的返回值:
成功时对父进程返回子进程的pid,给子进程返回0
#include <stdio.h>
2 #include <unistd.h>
3 #include <sys/types.h>
4
5 int main()
6 {
7 printf("我是父进程,pid: %d,ppid:%d",getpid(),getppid());
8
9 pid_t id = fork();
10 if(id == 0)
11 {
12 //子进程
13 while(1)
14 {
15 printf("我是子进程,pid: %d,ppid:%d\n",getpid(),getppid());
16 sleep(1);
17 }
18 }
19 else if(id > 0 )
20 {
21 while(1)
22 {
23
24 printf("我是父进程,pid: %d,ppid:%d\n",getpid(),getppid());
25 sleep(1);
26 }
27
28 }
29 else
30 {
31 printf("执行错误\n");
32 }
33
34 return 0;
35
36 }
问题来了这里的if else都满足了,这是为什么呢,下面解释
首先我们提出四个问题:
1、为什么fork要给子进程返回0,给父进程返回子进程pid
返回不同的返回值,是为了区分让不同的执行流执行不同的代码块,一般而言fork之后的代码父子共享,给父进程返回子进程pid是为了让父进程标识子进程,在为了我们可能创建多个子进程,那么这些子进程都得交由父进程进行管理,那么父进程怎么区分他们呢,这就需要用到fork给父进程返回子进程的pid。
2、一个函数是如何做到返回两次的?如何理解?
fork也是一个函数,pid_t fork(void)它有对应的实现代码块,走到return语句时,它的主要工作就已经完成了。创建子进程,(创建子进程PCB,填充PCB对应的内容,让子进程和父进程指向同样的代码,父子进程都有独立的task_struct,可以被CPU调度执行了。)return是代码,属于父子共享的所以就被返回了两次
3、一个变量怎么会有不同的内容,如何理解?
在任何平台,进程在运行的时候,是具有独立性的。因为数据可能被修改,不能让父子进程共享同一份数据!操作系统必须把父进程的数据拷贝一份给子进程,为了避免浪费系统资源,可以进行数据层面的写时拷贝,返回时是写入id所以要发生写时拷贝,那么怎么实现用一个id就可以实现这种方法呢?需要在进程地址空间里进行讲解。
4、fork函数,究竟干了什么,如何理解?
进程 = 内核数据结构 + 代码和数据
创建子进程:系统中多了一个进程,那么系统里面就多了一个task_struct结构体,fork之后,父子进程代码共享,运行时代码是不可以修改的。我们为什么要创建子进程呢?为了让父和子执行不同的事情,需要想办法让父和子执行不同的代码块。让fork具有不同的返回值。
如果父子进程被创建好,fork()之后,父子进程谁先运行呢?谁先运行,由调度器来确定,我们时左右不了的。这里可以参考进程调度算法。
bash如何创建子进程?调用fork(),让子进程执行解释新的命令。
进程的状态
1、一般的操作系统学科对进程状态的解释,进程状态,运行,阻塞,挂起
运行时队列
在运行队列中的进程,称为运行状态的进程。 表示我已经准备好了,可以随时被调度
一个进程只要把自己放到CPU上开始运行了,不是一直要执行完毕,才把自己放下来。每一个进程都有一个时间片的概念,它只会在时间片内才会在CPU上执行。在一个时间段内,所有的进程代码都会被执行,并发执行。大量的把进程从CPU上放上去,拿下来的动作成为进程切换。
阻塞状态
操作系统对设备的管理,先描述,再组织。
在等待某个资源就绪时,该进程的PCB会放到对应设备的阻塞队列中,该进程的状态就变成了阻塞状态。就绪后把阻塞队列中的进程PCB放到运行队列中。
挂起状态:
在等待时,操作系统内部的内存资源严重不足了,在保证正常的情况下,省出来对应的内存资源,会把进程的代码和数据交换到磁盘中,内存充足后换入到内存中。磁盘中的交换分区就是干这个事的磁盘空间。
2、具体的Linux状态是如何维护的
Linux中具体的状态有以下几种
R 运行态
S 睡眠态也就是阻塞状态,浅度睡眠,可以被唤醒
D 磁盘睡眠,也是阻塞状态,称为深度睡眠,页面置换以后也不会解决资源不足的问题,操作系统会杀死该进程。让进程在等待磁盘写入完毕期间,这个进程不能被任何人杀掉。这种状态叫做D状态。写完后再转入运行状态。系统中出现大量D状态,系统很大可能会挂掉。磁盘的压力很大了。D状态不能响应任何请求。dd命令可以实现D状态的实验。
T 停止态,暂停状态发送19号信号。可以转为停止态,发送18号信号恢复。
t 暂停的一种,gdb调试时碰到断点进程的状态转为t
X 死亡态,也就是终止态。释放所有的系统资源
Z 僵尸态,死亡之时会先进入该状态,再进入X状态。父进程关心进程退出时的情况。子进程一般退出的时候,如果父进程没有主动回收子进程信息,子进程会一直让自己处于Z状态,进程的相关资源尤其时task_struct结构体不能被释放。僵尸进程的资源会被一直占用。造成了内存泄露的问题。
退出父进程不进行等待子进程为什么没了呢?
父进程先退出,子进程不退出。子进程的PPID变成了1。那么这个1是systemd这就是操作系统本身。父子进程,父进程先退出,子进程的父进程会被改成1号进程(操作系统),父进程是1号进程的子进程称为孤儿进程,该进程被系统领养。被领养是因为孤儿进程未来也会退出,也要被释放。
进程状态转换图
进程的优先级
是什么?
优先级(对于资源的访问,谁先访问,谁先访问) vs 权限(能不能的问题)
为什么?
因为资源是有限的,进程是多个的,注定了进程之间是竞争关系 --- 竞争性。操作系统必须保证大家良性竞争,确认优先级。如果进程长时间得不到CPU资源,该进程的代码长时间无法得到推进 --- 该进程的饥饿问题。
怎么办?
查看进程的优先级 ps -al
PRI 优先级
NI nice这个是进程优先级的修正数据,Linux不想让nice值过多的参与优先级的调整,需要在我们对应的范围内进行优先级调整,范围为nice:[-20,19] ,80->[60,99]。命令nice和renice直接调整。
我们这里通过top命令更改优先级
PRI(new) = PRI(old) + nice
优先级值越小,优先级越高,优先级值越大,优先级越低
PRI(old)永远是80。
操作系统是如何根据优先级开展调度呢?
每一个CPU都要维护一个运行队列
Linux 2.6 内核中O(1)调度算法,利用位图实现的。
环境变量(《深入理解Linux内核》《深入理解计算机系统》)
首先理解一个概念,并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。它是基于进程切换和时间片轮转的调度算法来实现的。
进程是怎么样进行切换呢?
进程在从CPU上离开的时候,要将自己的上下文数据保存好,保存好的目的是为了在下一次时间片到达的时候恢复该进程的数据,接着之前的运行结果再次运行。进程在切换的时候要经历两个阶段1、保存上下文。2、恢复上下文。这个过程称为进程切换。
struct reg_info
{
int eax;
int ebc;
int eip;
......
}
//实际上我们CPU保存上下文数据是软硬件结合,在这里我们认为
//数据是保存到进程PCB中的。
为什么函数返回值,会被外部拿到呢?
因为它会被放到 eax 寄存器中比如 mov eax 10。所以我们的返回值是通过CPU寄存器拿到的。
系统如何得知我们的进程当前执行到那行代码了?
我们的CPU里有一个程序计数器PC或eip,这个计数器会记录当前进程正在指令的下一行指令的地址!
我们的CPU里面有很多的通用寄存器比如:eax,ebx,ecx,edx等,形成栈帧结构的ebp,esp,eip。状态寄存器:status,寄存器在整个CPU起着提高效率,进程高频数据放入寄存器中的作用。CPU内部的寄存器里面保存的是进程相关的数据!CPU寄存器里面保存的是进程的临时数据。把这部分数据称为当前运行进程的上下文数据。
接下里我们了解环境变量的基本概念
1、认识环境变量是干什么的,
查看环境变量的内容echo $PATH,
用冒号作为分割符
PATH:Linux系统的指令搜索路径,我们也可以为自己的程序写入环境变量,步骤如下
这样写会把原有的环境变量覆盖。重新登录Xshell就可恢复了。
所以我们可以这样写
这样我们就可以用指令的方式跑自己的程序了
HOME环境变量
查找所有的环境变量env
在程序中获得环境变量可以使用系统调用接口
USER环境变量。
2、什么是环境变量
环境变量是系统提供的一组name=value形式的变量,不同的环境变量有不同的用户,通常具有全局属性。
穿插一个知识点:命令行参数
C/C++中的main函数是可以传参的。
int main(int argc,char* argv[],char* env[]){}
argc 指针数组有多少个元素
argv 指针数组
命令行参数为指令,工具,软件等提供命令行选项的支持。
观察下面的例子我们就可以明白上面一句话的原理
main函数还有其他的参数,这个参数称为char* env[]
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
//main函数的三个参数
int main(int argc,char* argv[],char* env[])
{
if(argc!=2)
{
printf("Usage:%s -[a|b|c|d]\n",argv[0]);
return 0;
}
//env数组会以NULL指针结尾
if(strcmp(argv[1],"-a") == 0)
{
printf("功能一:查看环境变量!");
int i = 0;
for(;env[i];i++)
{
printf("env[%d]:%s",i,env[i]);
printf("\n");
}
}
else if(strcmp(argv[1],"-b")== 0)
{
printf("功能二\n");
}
else if(strcmp(argv[1],"-c")== 0)
{
printf("功能三\n");
}
else if(strcmp(argv[1],"-d")== 0)
{
printf("功能四\n");
}
else
{
printf("default功能\n");
}
// char user[32];
// strcpy(user,getenv("USER"));
// if(strcmp(user,"root") == 0)
// {
// printf("你是一个root用户可以做任何事情\n");
// }
// else
// {
// printf("你是一个普通用户受权限的限制\n");
// }
return 0;
}
会有两张核心向量表:
1、命令行参数表
2、环境变量表:每个程序都会收到一张环境表,环境表就是一个字符指针数组,每个指针指向一个以'\0'结尾的环境字符串。
结论:我们所运行的进程都是子进程,bash本身在启动的时候,会从操作系统的配置文件中读取环境变量信息。子进程会继承父进程交给我的环境变量。所以环境变量有全局属性。
那么怎么证明这件事情呢?
增加我们的环境变量
去查看我们的进程是否有这条环境变量。
验证:
取消环境变量unset
本地变量和内建命令
在命令行中直接定义,set查找系统中的所有变量
本地变量只在bash内部有效,它的子进程不会继承本地变量。提示符就是在bash内部使用的包括
命令可以被分为两批:
1、常规命令 --- 通过创建子进程完成的。
2、内建命令 --- bash不创建子进程,而是由自己亲自执行,类似于bash调用了自己写,或者系统提供的函数。包括echo,cd命令
设置自己的cd命令
也可以这样获取环境变量
进程地址空间
首先来一段代码来验证一下这个概念
运行结果:
下面的图称为地址空间
栈区向下增长,堆区向上增长。
未初始化全局变量区和已初始化全局变量区称为全局变量区。
解释static:static修饰的局部变量,编译的时候已经被编译到全局变量区了。
下来我们再看一个代码:
#include <stdio.h>
#include <unistd.h>
int gl_val = 10;
int main()
{
// 创建子进程
pid_t id = fork();
int cnt = 5;
if (id == 0)
{
// 子进程
while (1)
{
printf("我是子进程,pid:%d,ppid:%d,gl_val:%d,&gl_val:%p\n", getpid(), getppid(), gl_val, &gl_val);
cnt-=1;
if (cnt == 0)
{
gl_val = 100;
printf("子进程改变gl_val 10 => 100\n");
}
sleep(1);
}
}
else
{
// 父进程
while (1)
{
printf("我是父进程,pid:%d,ppid:%d,gl_val:%d,&gl_val:%p\n", getpid(), getppid(), gl_val, &gl_val);
sleep(1);
}
}
return 0;
}
怎么可能同一个变量,同一个地址同时读取,读到了不同的内容
结论:如果变量的地址,是物理地址,不可能存在上面的现象!所以它绝对不是物理地址。而是线性地址或者虚拟地址。所以我们平时写的C/C++,用的指针,指针里面的地址绝对不是物理地址。
引入新概念,初步理解这种现象 --- 引入地址空间的概念
先经过写时拷贝 --- 是由操作系统自动完成的!
重新开辟空间,但是在这个过程中,左侧的虚拟地址时0感知的,不关心不会影响它。
历史问题
fork返回值,id接收后为什么会有两个,就是上面的原因,虚拟地址。
细节问题
1、地址空间究竟是什么?
什么叫做地址空间?
在32位计算机当中,有32位的地址和数据总线 ---每一根总线只通过高低电平也就是0,1,有32根地址总线总共有2^32种0,1序列。那么就可以表示2^32*1byte = 4GB的空间。
地址总线排列组合形成的地址范围就似乎地址空间。
如何理解地址空间上的区域划分?
这里可以理解为三八线,这就叫做区域划分,用计算机语言描述就是:
所谓的空间区域的调整:
不仅仅要看到划分的地址空间范围,在范围内,连续的空间中,每一个最小单位都可以有地址,这个地址可以被直接使用。
所以什么是地址空间呢?
所谓的地址空间,本质是一个描述进程可视范围的大小。地址空间内一定要存在各种区域的划分,对线性地址进行start,end管理即可。地址空间本质是内核的一个数据结构对象,类似PCB 一样,地址空间也是要被操作系统管理的:先描述再组织。如下。在32位操作系统内的区域为4GB的空间。
2、所以什么叫做进程,以及进程地址空间,为什么要有进程地址空间?
让所有的进程以统一的视角看待内存。
增加进程虚拟地址空间可以让我们访问内存的时候,则更加一个转换的过程,在这个转化的过程中,可以对我们的寻址请求进行审查,所以一旦异常访问,直接拦截,该请求不会到达物理内存,保护物理内存。
因为有地址空间和页表的存在,将进程管理模块和内存管理模块进行解耦合。
3、页表
页表当下认为有三个字段虚拟地址,物理地址,标志位读写(标识该物理地址是否为可读可写)
CPU有中cr3寄存器保存页表的地址,这个地址为物理地址。本质属于软件的上下文。
代码是只读的,字符常量区只读的,为什么?
这是因为,页表的标识符字段为只读的。就会拦截进程的读操作。
进程是可以被挂起的,怎么会知道被挂起的在不在内存?
共识:现代操作系统,几乎不做任何浪费时间和空间的事情。操作系统对大文件可以实现分批加载。惰性加载方式用多少加载多少。那么我们的页表中就必须有一个字段:对应的代码和数据是否已经被加载到内存。若没有加载到内存,操作系统会触发缺页中断把代码和数据从磁盘中加载到内存中,然后建立对应的虚拟地址和物理地址的映射关系。写时拷贝也是这样做到的。
进程在被创建的时候,是先创建内核数据结构,在加载对应的可执行程序和数据。Linux的内存管理模块实现的。
所以我们的进程概念就变成了
进程 = 内核数据结构(task_struct&&mm_struct&&页表) + 程序的代码和数据。
进程的在切换的时候这些数据结构就随着PCB的切换就都切换了。
进程具有独立性,怎么做到的?
因为页表的存在。
命令行参数和环境变量是在栈区之上的。