冯诺依曼体系结构
我们常见的计算机,如笔记本。我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系。
冯诺依曼规定了硬件层面上的数据流向。可执行程序运行的时候必须先加载到内存,存储器(物理内存)相当于cpu和所有外设的缓存
截至目前,我们所认识的计算机,都是有一个个的硬件组件组成
- 输入单元:包括键盘, 鼠标,扫描仪, 写板等
- 中央处理器(CPU):含有运算器和控制器等
- 输出单元:显示器,打印机等
注意:
- 这里的存储器指的是物理内存
- 不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备)
- 外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。
- 一句话,所有设备都只能直接和内存打交道。
我们用qq给朋友发送信息,键盘即是输入设备,键盘输入的数据放到了内存里,并被内存里的qq软件拿到,再从内存写到CPU里,再从CPU写回内存,qq再定时把数据刷新出去,此时的输出设备是网卡,通过我们的网卡把数据发出去,到了网络,网络转发到朋友家的电脑,朋友家的电脑也是一个冯诺依曼体系,此时的输入设备是网络/网卡,网卡作为输入设备把数据拿到,放到内存里,再经过CPU的运算等,再把处理完的结果写回存储器里,再定期刷新数据到显示器上,显示器即输出设备。
操作系统
概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
- 内核(进程管理,内存管理,文件管理,驱动管理)
- 其他程序(例如函数库,shell程序等等)
操作系统是进行软硬件资源管理的软件。
为什么要有操作系统:1.可以减少用户使用计算机的成本。2.对下管理好所有的软硬件,对上给用户提供一个稳定高效的运行环境
操作系统是对软硬件资源进行管理的软件。
管理(对数据的管理)是:先描述,再组织。管理者和被管理者并不直接交互,那么管理者如何管理被管理者?答案是根据“信息”进行管理。被管理对象一般是很多的,对被管理对象,首先要进行描述,而Linux是使用C写的,C语言中对信息进行描述的是struct结构体。描述后,再通过某种数据结构组织起来,比如双链表,将一个个结构体连接起来,管理者只需要有这个链表的头结点就可以对这些信息进行管理,即转化称为了对链表的增删查改。
系统调用和库函数
在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。
举个例子,我们最熟悉的 hello world 程序会在屏幕上打印出信息。程序中调用了 printf() 函数,而库函数 printf 本质上是调用了系统调用 write() 函数,实现了终端信息的打印功能。
进程
从内核观点看,进程的目的就是担当分配系统资源(CPU时间、内存等)的实体。
进程描述-PCB
为了管理进程,内核必须对每个进程所做的事情进行清楚的描述。例如,内核必须知道进程的优先级,它是正在CPU上运行还是因为某些事件而被阻塞,给它分配了什么样的地址空间,允许它访问哪个文件等等,这正是进程描述块的作用—进程描述符都是task_struct类型结构,它的字段包含了与一个进程相关的所有信息。它不仅包含了很多进程属性的字段,一些字段还包括了指向其他数据结构的指针。
- 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
- 课本上称之为PCB(process control block),Linux操作系统下的PCB是: task_struct
task_struct-PCB的一种
在Linux中描述进程的结构体叫做task_struct。task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
task_ struct内容分类
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据: 进程执行时处理器的寄存器中的数据。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息
组织进程
可以在内核源代码里找到它。所有运行在系统里的进程都以task_struct链表的形式存在内核里。
查看进程
进程的信息可以通过 /proc 系统文件夹查看
如:要获取PID为1的进程信息,你需要查看 /proc/1 这个文件夹。
大多数进程信息同样可以使用top和ps这些用户级工具来获取。
把test程序运行起来后,可以查看test进程:
通过系统调用获取进程标示符
进程id(PID) 、 父进程id(PPID)
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
while(1){
printf("pid:%d\n",getpid());
printf("ppid:%d\n",getppid());
sleep(1);
}
return 0;
}
通过系统调用创建进程-fork初识
fork有两个返回值;父子进程代码共享,数据各自开辟空间,私有一份(采用写时拷贝)
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("I am fathrt: %d\n",getpid());
pid_t ret = fork();
if(ret==0){
//child
while(1){
printf("I am a child,pid:%d\n,ppid:%d\n",getpid(),getppid( ));
sleep(1);
}
}
else if(ret>0){
//father
while(1){
printf("I am a father,pid:%d,ppid:%d\n",getpid(),getppid() );
sleep(1);
}
}
else{}
return 0;
}
进程状态
- R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
- S休眠状态(sleeping):浅度休眠(大部分情况),对外部事件可以做出反应 。意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep)。
- D磁盘休眠状态(Disk sleep):深度休眠,不可以被杀掉,即便是操作系统。有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
- T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
- Z僵尸状态(zombie):僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用wait()系统调用)没有读取到子进程退出的返回代码时就会产生僵死(尸)进程。僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态。
- X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
僵尸进程
进程退出的时候,会自动将自己退出时的相关信息写入进程的PCB中,供OS或者父进程来进行读取,读取成功之后,该进程才算真正死亡。如果父进程没有读取,那将变成僵尸状态。
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<stdlib.h>
4 #include<sys/types.h>
5
6 void DoThing()
7 {
8 int count=0;
9 while(count<5){
10 printf("pid: %d,ppid: %d,count: %d\n",getpid(),getppid(),count);
11 count++;
12 sleep(1);
13 }
14 }
15 int main()
16 {
17 pid_t ids[5];
18 printf("I am fathrt: %d\n",getpid());
19 for(int i=0; i<5; i++){
20 ids[i] = fork();
21 if(ids[i]==0)
22 {
23 //child
24 DoThing();
25 exit(1);
26 }
27 }
28
29 printf("%d, %d, %d, %d, %d\n",ids[0],ids[1],ids[2],ids[3],ids[4]);
30
31 getchar();
32
33 return 0;
34 }
僵尸进程危害:进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就一直处于Z状态。维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB一直都要维护。那一个父进程创建了很多子进程,就是不回收,就会造成内存资源的浪费。因为数据结构对象本身就要占用内存,想想C中定义一个结构体变量(对象),要在内存的某个位置进行开辟空间!也会有内存泄漏。
孤儿进程
父进程先退出,子进程就称之为“孤儿进程”。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
int main()
{
if(fork()>0){
sleep(5);
printf("I am father, I quit!\n");
exit(0);
}
while(1){
printf("hello world\n");
sleep(1);
}
return 0;
}
fork()大于0即父进程,父进程退出后,子进程依然存在,该进程称为孤儿进程,并被1号init进程领养,由init进程回收。成为孤儿进程后,该程序将从前台自动切换到后台运行,直接ctrl+c不能退出,需要kill -9
直接杀死该进程。
1号进程:
进程优先级
进程的优先级就是cpu资源分配的先后顺序。优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。
我们在windows中双击exe文件就是把这个程序加载到进程中,等价于Linux中的./执行程序
当我们./的一瞬就是把程序加载到内存,变成了进程。为什么要变成进程?根本原因是因为冯诺依曼体系规定,
proc是一个在磁盘上的可执行程序,proc运行之后会变成对应的一个进程,这个进程就可以被操作系统调度,被cpu取执行。加载到内存其实就是把可执行程序的代码和数据加载到内存,同时操作系统还要为该进程创建对应的控制块PCB,然后将PCB放入运行队列当中,等待调度去调度CPU执行
查看系统进程:
ps -l
-a:显示现行终端机下的所有进程,包括其他用户的进程
ps是显示瞬间进程的状态,并不动态连续。用top命令可以对进程进行实时监控:
其中,PRI是进程可被执行的优先级,NI是进程的nice值。进程优先级包括PRI和nice值。PRI值越小,优先级越大。nice值表示进程可被执行的优先级的修正数值,的范围是-20~19.
PRI(new)=PRI(old)+nice
我们使用top命令调整优先级。
输入top -> 输入R -> 输入要调整的进程PID -> 输入nice值 -> 输入q退出
再次查看
进程的优先级被调成了93,这就是(PRI)new,原来的80就是(PRI)old
再次调整优先级 10,old依然是从80开始,new是90.
也就是说,我们在多次进行优先级调整时,PRI(old)默认都是从80开始。
nice值的范围是-20~19,当我们输入比-20还小的值时,nice值会默认变成-20
如果想恢复原来的优先级,调nice值为0即可。
为什么old默认都是80? 我们既然想调整,就是说我们不想要老的优先级,有一个基准值可以方便我们下一次调整
又因为nice值固定范围是-20~19,如果我们每次都是基于上一次的优先级进行调整,我们只需每次调整nice值在这个范围内,就有可能通过重复设置让这个进程优先级达到很高或者很低的值,当然如果是这样,操作系统会考虑,但这样会使优先级设置变得比较复杂,在设计上比较简单。nice取值有一个固定范围,这让其处于可控的状态。进程是被操作系统调度的,用户如果将优先级调整为一个很高的值,
那么操作系统将每次都调度这个优先级高的进程,有失公平原则。需要可控的根本原因是OS内的调度器,要公平且较高效地调度,这是调度器调度的一个基本原则。所谓调度器调度的公平,不是把相关时间分配地平均,而是要结合当前进程的特性进行调度,即让每个进程尽量尽快更好地调度,尽量不会落下任何一个进程,但并不保证同一时间启动的所有进程在调度时间上完全一样,因为调度器是服务于整个计算机上的所有进程,而不是某一个。
其他概念:
TTY:当前进程相关的终端设备。
CMD:当前进程的命令
UID:在linux中,标识一个用户并不是通过用户名称标识的,而是通过用户UID,比如qq上,标识我们的是qq号而不是qq昵称。这样做的原因是因为计算机比较善于处理数据,而计算机处理字符串也是按照数字的方案处理的,字符串(用户名)本质是给用户方便看的。
并行:任何时刻,多个进程在多个CPU内分别同时运行
并发:在一个时间段内,多个进程在一个CPU内不断快速切换,让多个进程都得以推进。
独立性:也是操作系统设计进程的一个原则。比如我们关掉qq,并不会影响别的软件运行。比如fork创建了子进程,如果子进程出现任何问题,都不会影响父进程。
创建出来的子进程代码共享,数据各自私有一份,本质上也是为了实现独立性原则。代码能共享,是因为代码是只读的,不能改,也就意味着彼此之间不会互相影响。
竞争性:进程和资源之间,一般进程都是多数,所有进程和进程之间就有了一定的竞争性,优先级让进程更合理地享有资源。
环境变量
环境变量一般是指在操作系统中用来指定操作系统运行环境的一些参数。如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
环境变量是一种特殊的系统级的变量。在windows当中,也可以看到我们的环境变量:
常见的环境变量:
- PATH : 指定命令的搜索路径
- HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
- SHELL : 当前Shell,它的值通常是/bin/bash。
查看环境变量方法:
echo $NAME //NAME:环境变量名称
PATH:
我们自己写的代码经过编译最终形成proc,也是一个命令。程序,命令,指令,可执行程序都是一个概念,和ls等命令没有差别。但为什么当我们运行我们的proc时,要带./ ,而系统的指令不用?./是当前路径,如果不带./,就会报错(命令找不到),也就是说系统曾经找过,但没有找到。那么系统是在哪里找的?PATH:辅助系统进行指令查找。PATH里面保存的就是当前指令的搜索路径,用冒号作为分隔符,冒号间的就叫做绝对路径。比如使用ls命令时,会默认在第一个绝对路径中找ls命令,找不到就继续在下一个绝对路径中找,直到找到,如果找不到就报找不到。
如果想让我们运行proc时不用带./
第一种方案:将自己的可执行程序添加到系统路径下。将proc拷贝到绝对路径中的其中一个。
删除后再次proc运行,报错不一样,是因为linux系统已经把刚刚执行的指令缓存起来了。
第二种方案:把我们当前所在的路径添加到环境变量当中。
这样会把PATH直接清空。PATH既然是变量,即它是可以被赋值的。我们这样对它进行了第二次赋值,而最开始的路径是linux系统在我们登陆时给用户配置文件里加载的。如果不小心把PATH改了,关掉终端重新登陆环境变量就会回来。
我们自己设置的环境变量只在当前次有效,如果想要永久有效,则需要改系统在登陆时设置的环境变量相关的文件。不同用户登录时的家目录不一样,原因是当前用户登录时所处的默认路径是由系统中的环境变量HOME决定的。
HOME:指定用户的主工作目录
SHELL:当前用的命令行解释器的种类
history记录了历史的部分指令
通过系统调用获取或设置环境变量
putenv
getenv
和环境变量相关的命令
- echo: 显示某个环境变量值
- export: 设置一个新的环境变量
- env: 显示所有环境变量
- set: 显示本地定义的shell变量和环境变量
- unset: 清除环境变量
在命令行上运行的大部分指令的父进程都是bash
bash创建子进程(如fork),子进程执行你的命令。
命令行中,可以定义两种变量:本地变量、环境变量
本地变量只能在当前shell命令行解释器内被访问,不可以被子进程继承。
将本地变量导成环境变量:export
MY_VALUE属于当前bash内部定义好的变量,可以被proc(bash的子进程继承下去)获取并继续访问,即环境变量
环境变量:通常具有"全局属性",即可以被子进程继承。
env只查看环境变量,set既可以显示本地变量,又可以显示环境变量。
getenv(“PATH”)其实就是在env里进行文本操作文本匹配找到PATH内容然后返回.
echo是命令吗?如果是,在运行时要不要创建子进程?如果创建了,怎么能继承本地变量?
大部分命令属于第三方的,是需要被bash创建子进程才能执行的命令,但像echo这样的命令,被称为内建命令,可以理解成shell程序内部的一个函数。
shell在执行命令时,如果是一个内建命令,就会直接调用内建命令对应的方法;如果不是,就会fork创建子进程
本地变量在shell内部是被建立的,echo又属于shell内的一个函数,在shell内当然可以访问本地变量。
命令行参数
main函数是可以带参的,但通常是缺省的。
int argc, char *argv[ ] (指针数组,指向字符串,其中有几个有效元素由argc确定),
main函数的前两个参数是用来记录命令行上传入的一个个参数,称之为命令行参数
由于argv[]最后一个是NULL,
for(int i=0;argv[];i++)
即不用条件判断也可以获得命令行参数。
为什么会存在命令行参数?
如果现在想输入-a 1 3 就是把这两个数据相加,-s相减
ls -a -l -s 就是让这个程序带上不同的选项去有不同的功能表现,我们也模拟实现了可以用不同选项来让同一个程序具有不同的功能表现,这就是命令行参数具有的最大用处。
为什么要存在命令行参数:帮助我们设计出同一个程序,可以设计出不同的业务功能。
如windows中cmd的shutdown
还有第三个命令行参数:char *env[],指针数组,指向一个一个的环境变量。它的用处是:继承环境变量,从而实现环境变量被子进程继承下去,即”全局属性“。
通过代码如何获取环境变量
- 命令行第三个参数
#include <stdio.h>
int main(int argc, char *argv[], char *env[])
{
int i = 0; for(; env[i]; i++){
printf("%s\n", env[i]); } return 0;
}
- 通过第三方变量environ获取
#include <stdio.h>
int main(int argc, char *argv[])
{
extern char **environ;
int i = 0;
for(; environ[i]; i++){
printf("%s\n", environ[i]);
}
return 0;
}
进程地址空间
进程地址空间并不是内存,进程地址空间会在进程的整个生命周期内一直存在,直到进程退出。那么它到底是什么?
1 #include <stdio.h>
2 #include <stdlib.h>
3
4 int g_unval;
5 int g_val = 100;
6
7 int main(int argc, char *argv[], char *env[])
8 {
9 printf("code addr : %p\n", main);//main函数是函数,一定属于代码的一部分,所以main函数的地址就能够充当代码区的地址
10 const char *p = "hello world!";//p在栈区,""中在字符串常量区
11 printf("read only : %p\n", p); //p保存的是字符串常量的地址,也就可以用它来充当字符串量区的地址
12 printf("global val : %p\n", &g_val);//已初始化全局区
13 printf("global uninit val : %p\n", &g_unval);//未初始化全局区
14 char *q = (char *)malloc(10); //q在栈区,指向在堆区申请的一块空间
15 printf("heap addr :%p\n", q);//堆区
16 printf("stack addr :%p\n", &p);//栈区,p先入栈,栈向下增长,向地址减小反向增长,p地址较高
17 printf("stack addr :%p\n", &q);//栈区
18
19 printf("args addr :%p\n",argv[0]);//数组中内容就是字符串的地址,即命令行参数地址
20 printf("args addr :%p\n",argv[argc-1]);//数组中内容就是字符串的地址
21 printf("env addr :%p\n",env[0]);//环境变量
22
23
24 return 0;
25 }
以下代码说明堆向上增长,栈向下增长,堆和栈相对增长
用static修饰的局部变量在全局数据区开辟空间,也就决定了改变量的生命周期是全局的
再看以下代码:
1 #include <stdio.h>
2 #include <unistd.h>
3
4 int g_val = 0;
5
6 int main()
7 {
8 printf("begin......%d\n", g_val);
9 pid_t id=fork();
10 if(id==0){
11 //child
12 int count=0;
13 while(1){
14 printf("child: pid: %d, ppid: %d, [g_val: %d] [&g_val: %p]\n", getpid(), getppid(), g_val,&g_val);
15 sleep(1);
16 count++;
17 if(count==5){
18 g_val=100;
19 }
20 }
21 }
22 else if(id>0){
23 //parent
24 while(1){
25 printf("father: pid: %d, ppid: %d, [g_val: %d] [&g_val: %p]\n", getpid(), getppid(), g_val,&g_val);
26 sleep(1);
27 }
28 }
29 else{
30 //TODO
31 }
32
33 return 0;
34 }
注意:代码没调用fork之前,只有一个执行流即父进程,也就是这个全局变量默认是父进程的全局变量。
结果如下:
为什么g_val地址相同?
举个例子,如果有一个富豪,有10个私生子,他单独告诉每个私生子,只要好好努力,以后就把自己的所有10亿家产给他继承,那么每个私生子都觉得以后那10亿家产是自己的,实际上并没有。
地址空间是横在进程和物理内存之间的一个软件层,通过mm_struct这样的一个结构体来模拟,让操作系统给进程画大饼,让每个进程都认为自己有整个物理内存,整个物理内存划分好后,每个进程可以根据地址空间来划分自己的代码。所以地址空间只是操作系统让进程看待物理内存的方式。
地址空间本质就是一种数据结构,struct mm_struct
创建进程的时候,除了要创建task_struct,如何给每一个进程创建一个地址空间即struct mm_struct。
比如从[code_start,code_end]是[0x10000, 0x20000],
里面就有很多个地址,这些一个一个的地址就是我们刚刚打印的地址,叫做虚拟地址。
进程地址空间究竟是什么?
地址空间本质是进程看待内存的方式,抽象出来的一个概念,内核struct mm_struct,这样的每个进程,都认为自己独占系统内存资源(每个私生子都认为自己独占10亿家产)
区域划分本质就是将线性地址空间划分成为一个一个的area,[start, end]。虚拟地址本质:在[start,end]之间的各个地址叫做虚拟地址
进程可以把代码和数据加载到物理内存的任意的地址。(通过页表完成虚拟地址到物理地址之间的映射)
虚拟地址可以完全一样,相同的虚拟地址查的是不同的表,找到相应的物理内存。
物理地址可以放重,但这种重是因为父子进程代码共享,可以让他们映射到同一块数据。但要修改代码时,就重新申请内存,修改当前子进程指向新的空间,把数据再拷贝一份。否则是不会重的。
为什么要存在虚拟地址?
如果进程直接访问物理内存,那么看到的地址就是物理地址,一个进程就有可能通过指针越界访问到另一个进程的代码和数据,进程的独立性就无法保证。因为物理内存暴露,其中就有可能有恶意程序直接通过物理地址,进行内存数据的篡改或读取。
通过页表虚拟地址到物理地址的一个转化,由操作系统完成,同时也可以帮系统进行合法性检测。
内存管理模块和进程管理模块是强耦合的。内存管理只需要知道哪些内存区域(page)是无效的,哪些是有效的。
(1)保护物理内存,不收到任何进程内的地址的直接访问,方便进行合法性校验。
比如char *str="hello"; *str="a"
将会报错,因为页表中所限定的区域的权限是只读,操作系统不允许这里进行修改,直接结束这个进程。
(2)将内存管理和进程管理进行解耦。
(3)让每个进程可以以同样的方式来看待代码和数据。
可执行程序,本身就已经被划分成为了一个个的区域。链接的过程其实就是把你的代码和库的代码合在一起,你的数据和库的数据合在一起,可执行程序已经划分好了,链接过程就更快了。
所以g_val地址相同:
进程和程序有什么区别?