今天,我带来冯诺依曼体系结构、操作系统、Linux进程概念、环境变量、进程地址空间。
冯诺依曼体系结构
- 输入单元:包括键盘, 鼠标,扫描仪, 写板等
- 中央处理器(CPU):含有运算器和控制器等
- 输出单元:显示器,打印机等
关于冯诺依曼体系结构,需要注意以下几点
- 这里的存储器指的是内存
- 不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备)
- 外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。
- 一句话,所有设备都只能直接和内存打交道。
输入、输出设备称之为外围设备了,外设一般会比较慢,以磁盘为例,相对于内存,磁盘是比较慢的。
因为有了内存的存在,我们可以对数据进行预加载,CPU以后在进行数据计算的时候,根本不需要访问外设了,两只手直接向内存要就可以了。
理解:可执行程序是不是一个文件?(磁盘里)
是,那么为什么我们的程序,必须先加载到内存里?冯诺依曼体系结构决定的。
结论1:在数据层面,一般CPU不和外设直接沟通,而是直接和内存打交道。
结论2:外设只会和内存打交道——数据层面。
操作系统(Operator System)
概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
- 内核(进程管理,内存管理,文件管理,驱动管理)
- 其他程序(例如函数库,shell程序等等)
设计OS的目的
- 与硬件交互,管理所有的软硬件资源
- 为用户程序(应用程序)提供一个良好的执行环境
定位
在整个计算机软硬件架构中,操作系统的定位是:一款纯正的“搞管理”的软件。
总结
计算机管理硬件
- 描述起来,用struct结构体
- 组织起来,用链表或其他高效的数据结构
系统调用和库函数概念
- 在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。
- 系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。
操作系统:一款进行软硬件资源管理的软件。
- 管理:管理者和被管理者,其实是不需要直接沟通的。
- 管理和被管理者没有自己沟通,是怎么做到管理的?
管理的本质:对被管理对象的数据做管理。- 管理者是如何拿到被管理者的数据呢?
由决策被执行人去收集。如:校长是管理者,收集被管理者(学生)的信息由辅导员来做。
管理的本质:先描述,再组织。
操作系统为什么要对软硬件资源进行管理呢?操作系统对下通过管理好硬件资源(手段),对上给用户提供良好(安全、稳定、高效、功能丰富等)执行环境。
操作系统给我们提供非常良好的服务,并不代表着操作系统相信我们,反之,操作系统不相信任何人,所以,我们没有办法随心所欲的去访问操作系统内部,只能通过操作系统提供的系统调用接口去访问。
进程
基本概念
课本概念:程序的一个执行实例,正在执行的程序等
内核观点:担当分配系统资源(CPU时间,内存)的实体。
描述进程-PCB
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
课本上称之为PCB(process control block),Linux操作系统下的PCB是: task_struct
task_struct-PCB的一种
在Linux中描述进程的结构体叫做task_struct。
task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
task_ struct内容分类
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息
组织进程
可以在内核源代码里找到它。所有运行在系统里的进程都以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。
./ 运行程序,使之变成进程(修改说法)。
通过系统调用创建进程-fork初识
man fork
查看fork系统调用。
fork函数如果成功创建子进程,子进程的PID返回给父进程,0返回给子进程;如果创建子进程失败,-1返回给父进程,没有子进程被创建。
一个函数调用居然有两个返回值?其实,从调用fork函数开始的那一行,代码已经被拷贝两份了,具体更深刻的理解,还得等到进程地址空间那里。以下是证明:
如上,第二个打印函数,执行了两次,一个是由进程29364执行,一个是由进程29365执行。由此证明,从fork的调用开始,代码被拷贝了两份,分别由父子进程去执行。
fork之后,执行流会变成2个执行流。
fork之后,谁先运行由调度器决定。
fork之后,通常代码共享,一般使用if和else if进行执行分流。
由父进程的PID是子进程的PPID可知,创建子进程成功。
原理:
fork做了什么?
fork如何看待代码和数据?
进程在运行的时候,是具有独立性的。
父子进程,运行的时候,一样具有独立性。
父子进程共享代码和数据,是如何做到独立性的呢?
- 代码:代码是只读的。
- 数据:当有一个执行流尝试修改数据的时候,OS会自动给我们的进程促发写实拷贝。
写实拷贝验证如下:当父进程修改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调度时,操作系统会将代码和数据重新加载到内存中。
挂起在上面示意图中,没有画出。
PCB当中细分的描述进程的状态
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运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
- S睡眠状态(sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。
- D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
- T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
- t追踪停止状态(tracing stop):“跟踪”指的是进程暂停下来,等待跟踪它的进程对它进行操作,比如在gdb中对被跟踪的进程添加一个断点。
- X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
- Z僵尸状态(zombie):在一个进程执行完毕时,需要有另一个进程来回收该进程执行完毕后的属性数据然后,如果这个回收数据的进程没有来得及回收进程的数据,这个执行完毕的进程就处于僵尸状态。
R状态和S状态
进程是R状态,就一定是在CPU上运行?
答案是否定的。
CPU调度进程的时候,即在队列当中挑选去运行。
进程,是什么状态,一般也看这个进程在哪里排队。进程在CPU运行和在运行队列排队,都是R状态,剩下的在其他设备排队都是阻塞状态。
R状态并不直接代表进程在运行,而代表该进程在运行队列中排队。运行队列由操作系统维护,操作系统在内存里,运行队列也在内存里。操作系统管理task_struct,就是将它放在不同的队列里。
我运行下面的代码,并且通过指令查看进程。
我们可以查看到,虽然程序一直在运行,但是查看进程的时候,却发现是S+状态,阻塞的一种。
为什么程序在死循环打印,不是R状态,而是S状态呢?
原因:该程序是在死循环打印,那么就要频繁的访问显示器的外设,那么出现等待显示器回应时,便出现了阻塞的状态,但是程序打印出了信息,证明该程序在运行呀?因为代码只有几行,一瞬间就运行完了,所以该程序大部分时间都在等待显示器的回应,由此查询的过程中,有可能出现的都是阻塞状态,多查询,可能出现R状态。
如果我们将打印函数注释掉呢?
这里我们把休眠函数和打印函数也注释掉,让程序一味的死循环。
此时,我们可以发现,一直是R状态。
因为代码中没有访问如何资源,只有while判断,就是纯计算,所以在进程调度的生命周期,只会用CPU资源,所以一定是R状态。
S休眠状态,可中断休眠。休眠的本质就是一种阻塞状态。
证明如下:
写一个程序,让程序等待键盘输入,运行起来,此时,查询进程状态。
我们可以发现是S状态,所以S状态本质就是一种阻塞状态。
D状态
D状态是一种休眠状态,不可中断休眠。
比如:CPU在调度一个进程,往磁盘输入100MB的数据,由于存储数据,需要一段时间,那么该进程进入阻塞状态,假如为S状态。
如果此时内存资源处于紧张的状态,操作系统便会想方设法的清理内存,观察到处于S状态的该进程,便将进程杀死得以释放资源。
此时,如果磁盘存储发生错误,返回信息给该进程,反而查询不到该进程,此时就乱套了,还丢失了数据。
为了防止该情况的出现,便出现了D休眠状态,该状态不可被中断,连操作系统也不行,有时连关机也不行,只能拔电源,但是电源一拔,责任便是自己的了。
T状态
T状态,为暂停状态。
给该进程发生19号信号,可以让该进程从S+状态,转换为T状态,即暂停。
继续向该进程发生18号信号,可以发现当前进程状态为S状态。此时,无法使用ctrl+c终止。
只能通过发生9号信号杀手该进程。
S+状态,证明在显卡执行,ctrl+c可以终止,发生9号信号可以终止。
S状态,后台运行,此时ctrl+c不可以终止,发生9号信号可以终止。
t状态
t状态表示,暂停状态。
调试,打断点,此时发现进程处于S+状态。我们给gdb发生r指令。
可以发现,处于t状态。
X状态
X状态,死亡状态,瞬时状态,查询不到。
Z状态
Z状态,僵尸状态。
我们为什么要创建进程?因为我们要让进程帮我们办事——1.我们关心结果 2.我们不关心结果。
在运行程序后,输入指令echo $? 可查询退出码。
如果一个程序退出了,立马X状态,立马退出,作为父进程,没有机会拿到退出结果。Linux设计当进程退出的时候,一般进程不会立马彻底退出,而是维持一个状态叫做Z状态,也叫做僵尸状态——方便后续父进程/OS读取该子进程退出时的退出结果。
写一个程序,创建子进程,让子进程先退出,观察它们的状态。
观察可以得知,子进程退出的时候,为Z+状态,即僵尸状态,等待子进程的回收。
Z(zombie)-僵尸进程
僵尸状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用wait()系统调用)没有读取到子进程退出的返回代码时就会产生僵死(尸)进程。
僵尸进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态
僵尸进程危害
进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就一直处于Z状态。
维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB一直都要维护。
那一个父进程创建了很多子进程,就是不回收,就会造成内存资源的浪费。因为数据结构对象本身就要占用内存。
孤儿进程
- 父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢?
- 父进程先退出,子进程就称之为“孤儿进程”
- 孤儿进程被1号init进程领养,当然要由init进程回收。
父进程退出,子进程会被OS自动领养(通过让1号进程成为新的父进程。由子进程的PPID变成1可以证明)——被领养的进程被称之为孤儿进程。
如果不领养会发生什么?
子进程在后续退出的时候,无人回收了。
观察上面的图片,可以发现父进程退出的时候,不是Z状态,而是X状态(死亡状态)呢?
原因:bash进程是已经回收了运行结果,所以回收了父进程。(bash进程是该父进程的父进程)。
进程优先级
基本概念
- cpu资源分配的先后顺序,就是指进程的优先权(priority)。
- 优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
- 还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。
优先级 vs 权限
权限代表的是能与不能的问题。
优先级代表的是已经能,但是谁先,谁后的问题。
为什么会有优先级?CPU资源有限。
查看系统进程
在linux或者unix系统中,用ps –l命令则会类似输出以下几个内容:
我们很容易注意到其中的几个重要信息,有下:
- UID : 代表执行者的身份
- PID : 代表这个进程的代号
- PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
- PRI :代表这个进程可被执行的优先级,其值越小越早被执行
- NI :代表这个进程的nice值
PRI and NI
- PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高
- 那NI呢?就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值
- PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice
- 这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行
- 所以,调整进程优先级,在Linux下,就是调整进程nice值
- nice其取值范围是-20至19,一共40个级别。
PRI vs NI
- 需要强调一点的是,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影响到进程的优先级变化。
- 可以理解nice值是进程优先级的修正数据
查看进程优先级的命令
用top命令更改已存在进程的nice:
- top
- 进入top后按“r”–>输入进程PID–>输入nice值
其他概念
- 竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级。
- 独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰。
- 并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行。
- 并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
环境变量
基本概念
- 环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。
- 如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
- 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性
常见环境变量
- PATH : 指定命令的搜索路径
- HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
- SHELL : 当前Shell,它的值通常是/bin/bash。
查看环境变量方法
echo $NAME //NAME:你的环境变量名称
如:echo $USER //查看当前用户
文件的拥有者、所属组的概念,对相应的用户进行权限限制,而如何得知现在是哪个用户呢?那么就是基于USER的环境变量来判定的。
如:我们可以利用USER的环境变量来确认当前用户是否有权限来执行我的代码。
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<string.h>
#include<stdlib.h>
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下软件的安装。
和环境变量相关的命令
- echo: 显示某个环境变量值
- export: 设置一个新的环境变量,设置的环境变量会在关闭终端后即刻消失
- env: 显示所有环境变量
- unset: 清除环境变量
- set: 显示本地定义的shell变量和环境变量
环境变量的组织方式
每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串
main函数有三个参数,main(int argc , char* argv[ ] , char* envp[ ]),这三个参数平时由编译器传参,其中envp是一个表结构——环境变量表。
环境变量本质就是内存级别的一张表,这张由用户在登录系统的时候,进行给特定用户形成属于形成属于自己的环境变量表。环境变量中的每一个,都有自己的用途:有的是进行路径查找、有的是进行身份验证、有的是进行动态库查找、有的用来确认当前路径。每一个环境变量都有自己的特定的应用场景,每一个元素都是kv。那么环境变量对应的数据,都是从哪里来的呢?系统的相关配置文件中读取进来的。
通过代码如何获取环境变量
-
命令行的第三个参数
-
通过第三方environ获取
libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时 要用extern声明。
通过系统调用获取或设置环境变量
常用getenv和putenv函数来访问特定的环境变量。
环境变量通常是具有全局属性的
环境变量通常具有全局属性,可以被子进程继承下去。
验证如下:
首先,我们给shell进程加上一个环境变量,再写一个程序,让它运行起来,便是shell进程的子进程,在该程序中获取我们写的环境变量,观察能不能获取成功。
证明子进程拿到了父进程的环境变量。
本地变量
本地变量,只在shell内部有效。
如:上面我所设置的count就是一个本地变量。
如果我们要让该本地变量加到环境变量表,让子进程可以接收的话。就需要用到export指令了。
main函数的三个参数
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<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<string.h>
#include<stdlib.h>
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<stdio.h>
#include<string.h>
#include<iostream>
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语言的学习过程中,我们肯定画过这样的图。
通过代码感受一下:
我们发现,输出的变量值和地址是一模一样的,很好理解呀,因为子进程按照父进程为模版,父子并没有对变量没有进行任何修改。可是将代码稍加改动:
我们发现,父子进程,输出地址是一致的,但是变量内容不一样!能得出如下结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
- 但地址值是一样的,说明,该地址绝对不是物理地址!
- 在Linux地址下,这种地址叫做 虚拟地址或者线性地址
- 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理
- OS必须负责将 虚拟地址 转化成 物理地址 。
进程地址空间,本质就是一个内核数据结构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的地址相同,但在两个页表中,相同的虚拟地址指向不同的物理地址,它们的数据不相同,着就导致了地址相同的变量,却有不同数值的现象。
另外,在文章的上面,我已经提到过程序会被从磁盘先预加载到内存里,虚拟内存,是在物理内存上存储的,是一种内核数据结构,先有虚拟内存,才进行预加载。
扩展1 为什么要有进程地址空间
如果没有地址空间,我们的OS是如何工作的?
如果没有地址空间,我们所写的进程都是直接往物理内存进程存储的,如果代码执行出现问题,发生了越界访问和越界写入,那么将对其他进程造成较大的影响。
设计进程地址空间,每一个进程看到的都是相同的地址空间,有堆区、有栈区、常量区、代码区,以统一的视角去看待自己的代码和数据。
拥有地址空间的好处:
- 防止物理内存被随意的访问,保护物理地址和其他进程。
- 将进程管理和内存管理进程解耦合。
- 可以让进程以统一的视角,看待自己的代码和数据。
扩展2 malloc的本质
向OS申请内存时,OS只要给你在虚拟地址空间上申请,再填充页表的虚拟地址的那一边,当你要真正访问该地址的时候,MMU硬件发现,页表填充的虚拟地址存在,即已经申请过,而页表没有填充物理地址,此时,MMU硬件就会触发缺页中断,即向CPU针脚发送信息,操作系统执行中断处理方法,即陷入操作系统内存(执行操作系统的代码),申请物理内存和填充页表,再返回来执行你的代码。
原因:
- OS一般不允许任何的浪费和不高效。
- 申请内存不一定马上使用。
- 在你申请成功之后和你使用之前,就有一段小小的时间窗口——这个空间没有被正常使用,但别人也使用不了(闲置状态)。
扩展3 重新理解地址空间
我们的程序在被编译的时候,没有加载到内存,我们的程序内部有没有地址?有。
不要以为虚拟地址只要的策略只会影响OS,还要让我们的编辑器遵守这样的规则。
源代码被编译的时候,就是按照虚拟地址空间的方式对代码和数据编好对应的编制?(ELF文件格式)
编译好以后,放在磁盘里,每一份代码和数据都有自己的地址,如变量就是在栈区范围的地址,代码就是在代码区范围内的地址。
填充在页表虚拟地址处,就是在磁盘存的时候的地址。
此时,映射到物理内存里,便有自己的真实物理地址。
填充页表的物理地址。
这就是可执行程序加载的全过程。