今天,我带来冯诺依曼体系结构、操作系统、Linux进程概念、环境变量、进程地址空间。
关于冯诺依曼体系结构,需要注意以下几点
输入、输出设备称之为外围设备了,外设一般会比较慢,以磁盘为例,相对于内存,磁盘是比较慢的。
因为有了内存的存在,我们可以对数据进行预加载,CPU以后在进行数据计算的时候,根本不需要访问外设了,两只手直接向内存要就可以了。
理解:可执行程序是不是一个文件?(磁盘里)
是,那么为什么我们的程序,必须先加载到内存里?冯诺依曼体系结构决定的。
结论1:在数据层面,一般CPU不和外设直接沟通,而是直接和内存打交道。
结论2:外设只会和内存打交道——数据层面。
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
在整个计算机软硬件架构中,操作系统的定位是:一款纯正的“搞管理”的软件。
计算机管理硬件
操作系统:一款进行软硬件资源管理的软件。
- 管理:管理者和被管理者,其实是不需要直接沟通的。
- 管理和被管理者没有自己沟通,是怎么做到管理的?
管理的本质:对被管理对象的数据做管理。- 管理者是如何拿到被管理者的数据呢?
由决策被执行人去收集。如:校长是管理者,收集被管理者(学生)的信息由辅导员来做。
管理的本质:先描述,再组织。
操作系统为什么要对软硬件资源进行管理呢?操作系统对下通过管理好硬件资源(手段),对上给用户提供良好(安全、稳定、高效、功能丰富等)执行环境。
操作系统给我们提供非常良好的服务,并不代表着操作系统相信我们,反之,操作系统不相信任何人,所以,我们没有办法随心所欲的去访问操作系统内部,只能通过操作系统提供的系统调用接口去访问。
课本概念:程序的一个执行实例,正在执行的程序等
内核观点:担当分配系统资源(CPU时间,内存)的实体。
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
课本上称之为PCB(process control block),Linux操作系统下的PCB是: task_struct
在Linux中描述进程的结构体叫做task_struct。
task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
可以在内核源代码里找到它。所有运行在系统里的进程都以task_struct链表的形式存在内核里。
1.进程的信息可以通过 /proc 系统文件夹查看
如:要获取PID为1的进程信息,你需要查看 /proc/1 这个文件夹。
proc属于内存级别的文件系统,只有操作系统启动后才存在,在磁盘上并不存在proc目录。
假设某个进程的PID是13045
ls /proc/13045
该指令可以查看该进程。
2.大多数进程信息同样可以使用top和ps这些用户级工具来获取
首先,写一个简单程序,运行起来,让它变成进程。
ps axj //查看系统中所有的进程
ps axj | head -1 //查看第一行,即属性名
ps axj | grep //再加上可执行程序的名字,表示只查看该进程
while :; do ps axj | head -1 && ps axj | grep myfile | grep -v grep; sleep 1;done
所以,我们可以用该条指令,循环的间隔一秒去查看可执行程序myfile的进程。
接下来,我在另一个窗口运行该程序。
我们就可以完成,循环的查看进程了。
while :; do ps axj | head -1 && ps axj | grep myfile | grep -v grep; sleep 1;echo “############################################################”;done
我们也可以加上分隔符,这样就容易辨别了。
启动并运行程序的行为——由操作系统帮助我们将程序转换为进程——完成特定的任务。
进程:加载到内存的代码和数据 和 该进程在内核中创建的pcb/task_struct数据结构合并称为进程。
进程 = 内核关于进程的相关数据结构 + 当前进程的代码和数据
进程id(PID)
父进程id(PPID)
使用该指令,查看getpid、getppid函数
man getpid
当我们不断ctrl+c结束该进程,再重新运行可以发现。PID一直在变化,PPID一直没变。
为什么呢?
bash命令行解释器,本质也是一个进程。
命令行启动的所有程序,最终都会变成进程,而该进程对应的父进程都是bash。
所以PPID没有变化,而进程由于被终止,那么原来PID就被其他进程拿去使用,重新运行时,会重新分配PID。
./ 运行程序,使之变成进程(修改说法)。
man fork
查看fork系统调用。
fork函数如果成功创建子进程,子进程的PID返回给父进程,0返回给子进程;如果创建子进程失败,-1返回给父进程,没有子进程被创建。
一个函数调用居然有两个返回值?其实,从调用fork函数开始的那一行,代码已经被拷贝两份了,具体更深刻的理解,还得等到进程地址空间那里。以下是证明:
如上,第二个打印函数,执行了两次,一个是由进程29364执行,一个是由进程29365执行。由此证明,从fork的调用开始,代码被拷贝了两份,分别由父子进程去执行。
fork之后,执行流会变成2个执行流。
fork之后,谁先运行由调度器决定。
fork之后,通常代码共享,一般使用if和else if进行执行分流。
由父进程的PID是子进程的PPID可知,创建子进程成功。
原理:
fork做了什么?
fork如何看待代码和数据?
进程在运行的时候,是具有独立性的。
父子进程,运行的时候,一样具有独立性。
父子进程共享代码和数据,是如何做到独立性的呢?
写实拷贝验证如下:当父进程修改x时,将会为x重新开辟一个空间,存入修改后的值,但是,为什么x的地址没有发生改变呢?其实,我们打印出来的地址是虚拟的地址,真实的地址是有改变的,一样在进程地址空间会提到。
fork如何理解两个返回值的问题?
当函数内部准备执行的return的时候,主体功能已经完成。
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在Linux内核里,进程有时候也叫做任务)。
阻塞:进程因为等待某种条件就绪,而导致的一种不推进的状态——进程卡住了——阻塞一定是在等待某种资源——为什么阻塞?进程要通过等待的方式,等具体资源被别人使用之后,再被自己使用。
阻塞:进程等待某种资源就绪的过程。
大量的进程,操作系统会对这些进程先描述为task_struct,再组织起来进行管理。对于键盘、网卡、显卡等各自外设也是先描述、再组织,只不过这里是描述为struct dev的设备结构体,然后再将这些结构体用一些数据结构进行管理起来。
阻塞的例子:
假如CPU正在调用程序下载一个文件。
突然,网断了,此时下载任务便不能再继续了,CPU就不会让该任务浪费CPU资源,等待网络资源恢复正常后,再进行下载,便发生了阻塞。
task_struct链接到所缺资源的struct dev里的队列,当网络恢复以后,该下载任务会被CPU重新调度。
struct dev
{
struct tast_struct* queue;//链接在这里
//dev的其他属性
};
写一个程序,其中包含cin,那么在运行的时候,程序就等待键盘输入,此时也是发生了阻塞。该进程的task_struct将被链接到磁盘struct dev的队列里面。
PCB是可以被维护在不同的队列里的。
阻塞:阻塞就是不被调度—— 一定是当前进程需要等待某种资源就绪 —— 一定是进程task_struct结构体需要在某种被OS管理的资源下排队。
当内存资源比较紧张的时候,操作系统就可能将正在阻塞的进程的代码和数据重新放到磁盘中,此时就可以称为挂起状态(严格称为阻塞挂起状态)。
当资源准备就绪的时候,可以被CPU调度时,操作系统会将代码和数据重新加载到内存中。
挂起在上面示意图中,没有画出。
task_struct是一个结构体,内部会包含各种属性,其中就有状态。
struct task_struct
{
int status;//如:0表示R状态
//其他属性
};
下面的状态在kernel源代码里定义:
/*
The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char * const task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"t (tracing stop)", /* 8 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};
进程是R状态,就一定是在CPU上运行?
答案是否定的。
CPU调度进程的时候,即在队列当中挑选去运行。
进程,是什么状态,一般也看这个进程在哪里排队。进程在CPU运行和在运行队列排队,都是R状态,剩下的在其他设备排队都是阻塞状态。
R状态并不直接代表进程在运行,而代表该进程在运行队列中排队。运行队列由操作系统维护,操作系统在内存里,运行队列也在内存里。操作系统管理task_struct,就是将它放在不同的队列里。
我们可以查看到,虽然程序一直在运行,但是查看进程的时候,却发现是S+状态,阻塞的一种。
为什么程序在死循环打印,不是R状态,而是S状态呢?
原因:该程序是在死循环打印,那么就要频繁的访问显示器的外设,那么出现等待显示器回应时,便出现了阻塞的状态,但是程序打印出了信息,证明该程序在运行呀?因为代码只有几行,一瞬间就运行完了,所以该程序大部分时间都在等待显示器的回应,由此查询的过程中,有可能出现的都是阻塞状态,多查询,可能出现R状态。
如果我们将打印函数注释掉呢?
这里我们把休眠函数和打印函数也注释掉,让程序一味的死循环。
此时,我们可以发现,一直是R状态。
因为代码中没有访问如何资源,只有while判断,就是纯计算,所以在进程调度的生命周期,只会用CPU资源,所以一定是R状态。
S休眠状态,可中断休眠。休眠的本质就是一种阻塞状态。
证明如下:
写一个程序,让程序等待键盘输入,运行起来,此时,查询进程状态。
我们可以发现是S状态,所以S状态本质就是一种阻塞状态。
D状态是一种休眠状态,不可中断休眠。
比如:CPU在调度一个进程,往磁盘输入100MB的数据,由于存储数据,需要一段时间,那么该进程进入阻塞状态,假如为S状态。
如果此时内存资源处于紧张的状态,操作系统便会想方设法的清理内存,观察到处于S状态的该进程,便将进程杀死得以释放资源。
此时,如果磁盘存储发生错误,返回信息给该进程,反而查询不到该进程,此时就乱套了,还丢失了数据。
为了防止该情况的出现,便出现了D休眠状态,该状态不可被中断,连操作系统也不行,有时连关机也不行,只能拔电源,但是电源一拔,责任便是自己的了。
给该进程发生19号信号,可以让该进程从S+状态,转换为T状态,即暂停。
继续向该进程发生18号信号,可以发现当前进程状态为S状态。此时,无法使用ctrl+c终止。
只能通过发生9号信号杀手该进程。
S+状态,证明在显卡执行,ctrl+c可以终止,发生9号信号可以终止。
S状态,后台运行,此时ctrl+c不可以终止,发生9号信号可以终止。
调试,打断点,此时发现进程处于S+状态。我们给gdb发生r指令。
可以发现,处于t状态。
X状态,死亡状态,瞬时状态,查询不到。
Z状态,僵尸状态。
我们为什么要创建进程?因为我们要让进程帮我们办事——1.我们关心结果 2.我们不关心结果。
在运行程序后,输入指令echo $? 可查询退出码。
如果一个程序退出了,立马X状态,立马退出,作为父进程,没有机会拿到退出结果。Linux设计当进程退出的时候,一般进程不会立马彻底退出,而是维持一个状态叫做Z状态,也叫做僵尸状态——方便后续父进程/OS读取该子进程退出时的退出结果。
观察可以得知,子进程退出的时候,为Z+状态,即僵尸状态,等待子进程的回收。
僵尸状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用wait()系统调用)没有读取到子进程退出的返回代码时就会产生僵死(尸)进程。
僵尸进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态
进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就一直处于Z状态。
维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB一直都要维护。
那一个父进程创建了很多子进程,就是不回收,就会造成内存资源的浪费。因为数据结构对象本身就要占用内存。
父进程退出,子进程会被OS自动领养(通过让1号进程成为新的父进程。由子进程的PPID变成1可以证明)——被领养的进程被称之为孤儿进程。
如果不领养会发生什么?
子进程在后续退出的时候,无人回收了。
观察上面的图片,可以发现父进程退出的时候,不是Z状态,而是X状态(死亡状态)呢?
原因:bash进程是已经回收了运行结果,所以回收了父进程。(bash进程是该父进程的父进程)。
权限代表的是能与不能的问题。
优先级代表的是已经能,但是谁先,谁后的问题。
为什么会有优先级?CPU资源有限。
在linux或者unix系统中,用ps –l命令则会类似输出以下几个内容:
我们很容易注意到其中的几个重要信息,有下:
用top命令更改已存在进程的nice:
echo $NAME //NAME:你的环境变量名称
如:echo $USER //查看当前用户
文件的拥有者、所属组的概念,对相应的用户进行权限限制,而如何得知现在是哪个用户呢?那么就是基于USER的环境变量来判定的。
如:我们可以利用USER的环境变量来确认当前用户是否有权限来执行我的代码。
#include
#include
#include
#include
#include
using namespace std;
#define User "zrb"
int main()
{
char* user = getenv("USER");
if (strcmp(user, User) == 0)
{
cout << "有权限执行当前代码" << endl;
//......
}
else
{
cout << "无权限执行代码,直接退出" << endl;
}
return 0;
}
echo $PATH //查看PATH
我们的命令本质就是一个可执行程序,但是为什么命令不需要带路径,自己执行,如ps -l,而我们写的可执行程序却要./myfile,./声明是当前路径呢?原因在于,PATH的环境变量,我们输入一个命令时,操作系统会自动根据PATH环境变量去找该命令的路径,如何,运行起来。
那么,我们也可以将我们当前目录加到PATH的环境变量里,下次运行该目录下的程序,也不用声明路径了。
在Linux中,把可执行程序,拷贝到系统默认的路径下,让我们可以直接访问的方式——相当于Linux下软件的安装。
每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串
main函数有三个参数,main(int argc , char* argv[ ] , char* envp[ ]),这三个参数平时由编译器传参,其中envp是一个表结构——环境变量表。
环境变量本质就是内存级别的一张表,这张由用户在登录系统的时候,进行给特定用户形成属于形成属于自己的环境变量表。环境变量中的每一个,都有自己的用途:有的是进行路径查找、有的是进行身份验证、有的是进行动态库查找、有的用来确认当前路径。每一个环境变量都有自己的特定的应用场景,每一个元素都是kv。那么环境变量对应的数据,都是从哪里来的呢?系统的相关配置文件中读取进来的。
环境变量通常具有全局属性,可以被子进程继承下去。
验证如下:
首先,我们给shell进程加上一个环境变量,再写一个程序,让它运行起来,便是shell进程的子进程,在该程序中获取我们写的环境变量,观察能不能获取成功。
本地变量,只在shell内部有效。
如果我们要让该本地变量加到环境变量表,让子进程可以接收的话。就需要用到export指令了。
main(int argc , char* argv[ ] , char* envp[ ]),envp在前面已经提到过,是一个表结构——环境变量表。
那么其他两个参数呢?
argv是一个指针数组,argc是argv数组的元素个数。
那么第二个参数是指向什么内容的指针数组呢。
我们一般运行自己的可执行程序,都是用./myfile的
如果加参数呢,./myfile -a ,此时argv指针数组存的就是指向-a内容的指针,argc存的就是argv的元素个数,即1。
如果加两个参数呢,如./myfile -a -l 那么argv指针数组存的就是分别指向-a和-l的指针,argc存的就是argv的元素个数,即2。
那么Linux程序指令,如何设置的就知道了吧。
如ls指令,ls -a -l
那么,如何验证上面的说法呢?我们可以通过一个程序。
#include
#include
#include
#include
#include
using namespace std;
int main(int argc, char* argv[])
{
for (int i = 0; i < argc; ++i)
{
printf("argv[%d]->%s\n",i,argv[i]);
}
return 0;
}
bash认为指令是一个字符串,制作一个表(argv),存储着指令(以空格为分隔符),给子进程使用。
接下来,我写一个使用命令行参数的例子。
#include
#include
#include
using namespace std;
void Usage(const char* name)
{
cout << "使用手册" << endl;
printf("\tUsage:%s -[a][b][c]\n",name);
exit(0);
}
int main(int argc,char* argv[])
{
if (argc != 2)
{
Usage(argv[0]);
}
if (strcmp(argv[1], "-a") == 0)
cout << "打印文件信息" << endl;
else if (strcmp(argv[1], "-b") == 0)
cout << "打印文件的详细信息" << endl;
else if (strcmp(argv[1], "-c") == 0)
cout << "打印隐藏文件" << endl;
else
cout << "其他功能,待开发" << endl;
return 0;
}
Linux底部设计都是C语言开发的,包括命令。C语言实现的命令,其中的选项就以字符串的形式通过传参给程序,对应的程序对选项做判断,让同样的一个软件带不同的选项就能表现出不同的现象和执行结果。
在C语言的学习过程中,我们肯定画过这样的图。
通过代码感受一下:
我们发现,输出的变量值和地址是一模一样的,很好理解呀,因为子进程按照父进程为模版,父子并没有对变量没有进行任何修改。可是将代码稍加改动:
我们发现,父子进程,输出地址是一致的,但是变量内容不一样!能得出如下结论:
进程地址空间,本质就是一个内核数据结构struct mm_struct[ ]。
地址空间是线性结构。
struct mm_strcut//4GB
{
long code_start;
long code_end;
long init_start;
long init_end;
//...
long brk_start;
long bra_end;
long stack_start;
long stack_end;
};
该结构体分别去指向物理空间,限定区域,如【1000,2000】,那么该区域的数据是虚拟地址或者线性地址。
如何从虚拟地址到物理地址转换。
页表的两边分别存放着虚拟地址和物理地址,借助MMU进行转换,而这项工作是由操作系统来执行的。页表就先这样浅浅的理解吧,真正的页表还有4kb这个数字,会让你惊叹设计者如此聪明。
解释上面的现象:
父子进程的数据不同,但地址为何相同?
在最开始,g_val没有被修改,指向同一个物理地址。
当子进程进行修改g_val时,发生写实拷贝,程序开辟一个地址,存着子进程的g_val修改之后的值,此时,子进程页表指向物理地址将被修改。
父子进程返回的都是虚拟地址,所以在父子进程中,g_val的地址相同,但在两个页表中,相同的虚拟地址指向不同的物理地址,它们的数据不相同,着就导致了地址相同的变量,却有不同数值的现象。
另外,在文章的上面,我已经提到过程序会被从磁盘先预加载到内存里,虚拟内存,是在物理内存上存储的,是一种内核数据结构,先有虚拟内存,才进行预加载。
如果没有地址空间,我们的OS是如何工作的?
如果没有地址空间,我们所写的进程都是直接往物理内存进程存储的,如果代码执行出现问题,发生了越界访问和越界写入,那么将对其他进程造成较大的影响。
设计进程地址空间,每一个进程看到的都是相同的地址空间,有堆区、有栈区、常量区、代码区,以统一的视角去看待自己的代码和数据。
拥有地址空间的好处:
向OS申请内存时,OS只要给你在虚拟地址空间上申请,再填充页表的虚拟地址的那一边,当你要真正访问该地址的时候,MMU硬件发现,页表填充的虚拟地址存在,即已经申请过,而页表没有填充物理地址,此时,MMU硬件就会触发缺页中断,即向CPU针脚发送信息,操作系统执行中断处理方法,即陷入操作系统内存(执行操作系统的代码),申请物理内存和填充页表,再返回来执行你的代码。
原因:
我们的程序在被编译的时候,没有加载到内存,我们的程序内部有没有地址?有。
不要以为虚拟地址只要的策略只会影响OS,还要让我们的编辑器遵守这样的规则。
源代码被编译的时候,就是按照虚拟地址空间的方式对代码和数据编好对应的编制?(ELF文件格式)
编译好以后,放在磁盘里,每一份代码和数据都有自己的地址,如变量就是在栈区范围的地址,代码就是在代码区范围内的地址。
此时,映射到物理内存里,便有自己的真实物理地址。
填充页表的物理地址。
这就是可执行程序加载的全过程。