1. 冯诺依曼体系结构
我们常见的计算机,如笔记本。我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系。
截至目前,我们所认识的计算机,都是由⼀个个的硬件组件组成
输⼊单元:包括键盘,鼠标,扫描仪,写板,网卡,磁盘(外部存储)等
中央处理器(CPU):含有运算器和控制器等
存储器:内存
输出单元:显示器,打印机,喇叭,网卡,磁盘(外部存储)等
关于冯诺依曼,必须强调⼏点:
- 这⾥的存储器指的是内存
- 不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输⼊或输出设备)(数据层⾯)
- 外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。
- ⼀句话,所有设备都只能直接和内存打交道。
对冯诺依曼的理解,不能停留在概念上,要深入到对软件数据流理解上,请解释,从你登录 上qq开始和某位朋友聊天开始,数据的流动过程。从你打开窗口,开始给他发消息,到他的到消息之后的数据流动过程。
2. 操作系统(Operator System)
2-1概念
任何计算机系统都包含⼀个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括
内核(进程管理,内存管理,⽂件管理,驱动管理)
其他程序(例如函数库,shell程序等等)
2-2设计OS的目的
- 对下,与硬件交互,管理所有的软硬件资源
- 对上,为用户程序(应用程序)提供⼀个良好的执行环境
2-3核心功能
在整个计算机软硬件架构中,操作系统的定位是:一款纯正的“搞管理”的软件
2-4如何理解"管理"
描述被管理对象 组织被管理对象
总结
计算机管理硬件
1. 描述起来,⽤struct结构体
2. 组织起来,用链表或其他⾼效的数据结构
2-5 系统调用和库函数概念
在开发⻆度,操作系统对外会表现为⼀个整体,但是会暴露自己的部分接⼝,供上层开发使用, 这部分由操作系统提供的接⼝,叫做系统调用。
系统调用是应用程序向操作系统内核请求服务的机制。
系统调用由用户态程序发起,但实际执行过程是在内核态完成的。当系统调用被触发时,CPU会从用户态切换到内核态,执行内核代码。
就像银行开启的一个个窗口。既保证内部安全,有确保顾客的服务。
系统调⽤在使⽤上,功能⽐较基础,对用户的要求相对也⽐较⾼,所以,有⼼的开发者可以对部 分系统调⽤进⾏适度封装,从⽽形成库,有了库,就很有利于更上层⽤⼾或者开发者进⾏⼆次开 发。
就比如:
- printf:标准C库函数,用于格式化输出,通常基于系统调用(如
write)实现,但不是系统调用本身。- read:这是一个系统调用,在Unix-like系统中用于从文件描述符读取数据。
承上启下
那在还没有学习进程之前,就问⼤家,操作系统是怎么管理进⾏进程管理的呢?很简单,先把进程描述起来,再把进程组织起来
3. 进程
3-1基本概念与基本操作
课本概念:程序的⼀个执⾏实例,正在执⾏的程序等
内核观点:担当分配系统资源(CPU时间,内存)的实体。
当前:进程=内核数据结构(task_struct)+⾃⼰的程序代码和数据
3-1-2描述进程-PCB
基本概念
进程信息被放在⼀个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
课本上称之为PCB(processcontrolblock), Linux 操作系统下的 PCB 是: task_struct
task_struct-PCB的⼀种
在 Linux 中描述进程的结构体叫做 task_struct 。
task_struct 是 Linux 内核的⼀种数据结构类型,它会被装载到RAM(内存)⾥并且包含着进 程的信息。
3-2-3task_struct
内容分类
标⽰符:描述本进程的唯⼀标⽰符,⽤来区别其他进程。
状态:任务状态,退出代码,退出信号等。
优先级:相对于其他进程的优先级。
程序计数器:程序中即将被执⾏的下⼀条指令的地址。
内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
上下⽂数据:进程执⾏时处理器的寄存器中的数据[休学例⼦,要加图CPU,寄存器]。
I∕O状态信息:包括显⽰的I/O请求,分配给进程的I∕O设备和被进程使⽤的⽂件列表。
记账信息:可能包括处理器时间总和,使⽤的时钟数总和,时间限制,记账号等。
其他信息
组织进程
可以在内核源代码⾥找到它。所有运⾏在系统⾥的进程都以 task_struct 双链表的形式存在内核 ⾥。
C语言中,任何变量的地址数字,是开辟众多字节中,地址数据是最小的那个。
3-1-4查看进程
1.进程的信息可以通过/proc系统⽂件夹查看
如:要获取PID为1的进程信息,你需要查看 /proc/1 这个⽂件夹。
2. ⼤多数进程信息同样可以使⽤top和ps这些⽤⼾级⼯具来获取
#include <stdio.h> #include <sys/types.h> #include <unistd.h> int main() { while(1){ sleep(1); } return 0; }3-1-5通过系统调⽤获取进程标⽰符
进程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; }3-1-6通过系统调用创建进程-fork初识
运行 man fork 认识fork
fork有两个返回值
父子进程代码共享,数据各自开辟空间,私有⼀份(采用写时拷贝)
#include <stdio.h> #include <sys/types.h> #include <unistd.h> int main() { int ret = fork(); printf("hello proc : %d!, ret: %d\n", getpid(), ret); sleep(1); return 0; }• fork之后通常要用 if 进行分流
#include <stdio.h> #include <sys/types.h> #include <unistd.h> int main() { int ret = fork(); if(ret < 0){ perror("fork"); return 1; } else if(ret == 0){ //child printf("I am child : %d!, ret: %d\n", getpid(), ret); }else{ //father printf("I am father : %d!, ret: %d\n", getpid(), ret); } sleep(1); return 0; }fork为什么会有两个返回值?
fork之后 ,代码和数据,一般都是父子共享的。父子各自执行return
两个返回值各种给父子如何返回?
给子进程返回的是0,给父进程返回的是子进程的pid(标识指定的一个子进程,未来控制特定的子进程)
至于:一个变量怎么能让 if 和 else if 同时成立这个问题,需要在后面才能解释清楚。
引用的内存块不一样。
3-2进程状态
操作系统教材中的状态的说明。
运行 阻塞 挂起。
一个cpu一个调度队列
凡事都在这个队列中的进程,状态都是运行状态,等待或正在CPU上执行。
等待队列并不是一个全局唯一的队列。内核中任何一个需要让进程等待的“资源”或“事件”都会创建并维护自己的等待队列头。
例如,一个键盘设备有一个等待队列,用于存放所有等待键盘输入的进程。
一个网络套接字有一个等待队列,用于存放等待数据包到达的进程。
一个信号量有一个等待队列,用于存放所有等待获取该锁的进程。
阻塞与运行的本质:是看你的task_struct是谁提供的队列中。
T:kill -18 -19
3-2-1Linux内核源代码怎么说
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状 态(在Linux内核里,进程有时候也叫做任务)。下面的状态在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信号让进程继续运行。
X死亡状态(dead):这个状态只是⼀个返回状态,你不会在任务列表里看到这个状态。
3-2-2进程状态查看
ps aux / ps axj 命令a:显示⼀个终端所有的进程,包括其他用户的进程。
x:显示没有控制终端的进程,例如后台运行的守护进程。
j:显示进程归属的进程组ID、会话ID、父进程ID,以及与作业控制相关的信息
u:以用户为中心的格式显示进程信息,提供进程的详细信息,如用户、CPU和内存使用情况等
3-2-3Z(zombie)-僵尸进程
僵死状态(Zombies)是⼀个比较特殊的状态。当进程退出并且父进程(使用wait()系统调用,后面讲)没有读取到子进程退出的返回代码时就会产生僵死(尸)进程
僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态
来⼀个创建维持30秒的僵死进程例子:
#include <stdio.h> #include <stdlib.h> int main() { pid_t id = fork(); if(id < 0){ perror("fork"); return 1; } else if(id > 0){ //parent printf("parent[%d] is sleeping...\n", getpid()); sleep(30); }else{ printf("child[%d] is begin Z...\n", getpid()); sleep(5); exit(EXIT_SUCCESS); } return 0; }编译并在另⼀个终端下启动监控
开始测试
看到结果
- 进程退出了,退出信息是:main函数的返回值,或收到的信号值
- 退出的信息保存在:进程自己的task_struct结构体中
- 检测Z状态进程,回收z状态进程,本质是在做什么:检查获取task_struct内部的数据,来判断
- 集体怎么回收谁来回收:父进程->系统调用(os)
- 僵尸状态:指得是只保留进程的task_struct,未来让父进程 os帮我们获得到子称的退出数据。
3-2-4僵尸进程危害
进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我 办的怎么样了。可父进程如果⼀直不读取,那子进程就一直处于Z状态
维护退出状态本⾝就是要⽤数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中, 换句话说,Z状态⼀直不退出,PCB⼀直都要维护
那⼀个父进程创建了很多子进程,就是不回收,就会造成内存资源的浪费,因为数据结构对象本身就要占用内存,想想C中定义⼀个结构体变量(对象),是要在内存的某个位置进行开辟空间
最后就会导致:内存泄漏?
3-2-5孤儿进程
父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢?
父进程先退出,子进程就称之为“孤儿进程”
孤儿进程被1号init/systemd进程领养,当然要有init/systemd进程回收喽。孤儿进程必须被领养
#include <stdio.h> #include <unistd.h> #include <stdlib.h> int main() { pid_t id = fork(); if(id < 0){ perror("fork"); return 1; } else if(id == 0){//child printf("I am child, pid : %d\n", getpid()); sleep(10); } else{//parent printf("I am parent, pid: %d\n", getpid()); sleep(3); exit(0); } return 0; }3-3进程优先级
3-3-1基本概念
因为资源不足,需要分配资源,设置优先级:决定进程,获得某种资源的先后顺序。
- cpu资源分配的先后顺序,就是指进程的优先权(priority)。
- 优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
- 还可以把进程运行到指定的CPU上,这样⼀来,把不重要的进程安排到某个CPU,可以大改善系统整体性能。
3-3-2查看系统进程
在linux或者unix系统中,用ps ‒l命令则会类似输出以下几个内容
我们很容易注意到其中的几个重要信息,有下:
UID:代表执行者的身份
PID:代表这个进程的代号
PPID:代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
PRI:代表这个进程可被执行的优先级,其值越小越早被执行
NI:代表这个进程的nice值
3-3-3PRI and NI
PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高
那NI呢?就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值
PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice
这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行
所以,调整进程优先级,在Linux下,就是调整进程nice值
nice其取值范围是-20至19,一共40个级别。
3-3-4PRI vs NI
需要强调一点的是,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影 响到进程的优先级变化
可以理解nice值是进程优先级的修正数据
分时系统->给进程分配时间片,相对公平,公正的调度策略,较为均衡的让不同的进程都能在一段时间内,都能得到CPU的资源。
实时系统->
3-3-5查看进程优先级的命令
⽤top命令更改已存在进程的nice:
top
进入top后按“r”‒>输入进程PID‒>输入nice值
注意:
其他调整优先级的命令:nice,renice
系统函数:
#include <sys/time.h> #include <sys/resource.h> int getpriority(int which, int who); int setpriority(int which, int who, int prio);3-3-6补充概念-竞争、独立、并行、并发
竞争性:系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为 了高效完成任务,更合理竞争相关资源,便具有了优先级
独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰
并行:多个进程在多个CPU下分别,同时进行运行,这称之为并行
并发:多个进程在⼀个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称 之为并发
3.4进程切换
CPU上下文切换:其实际含义是任务切换,或者CPU寄存器切换。当多任务内核决定运行另外的任务时,它保存正在运行任务的当前状态,也就是CPU寄存器中的全部内容。这些内容被保存在任务自己的堆栈中,入栈工作完成后就把下一个将要运行的任务的当前状况从该任务的栈中重新装⼊CPU寄存器, 并开始下⼀个任务的运行,这一过程就是context switch。
寄存器是共享的,但是寄存器里的数据,本质是进程私有的,叫做进程上下文。
参考⼀下Linux内核0.11代码
注意:
时间片:当代计算机都是分时操作系统,没有进程都有它合适的时间片(其实就是一个计数器)。时间片到达,进程就被操作系统从CPU中剥离下来。
3-4 Linux2.6内核进程O(1)调度队列
上图是Linux2.6内核中进程队列的数据结构,之间关系也已经给大家画出来,方便大家理解
优先级数字,本质就是数组下标。
根据优先级进程的时候,本质就是一个hash的过程
一旦确定是那个队列,剩下的就是FIFO.
3-4-1 ⼀个CPU拥有⼀个runqueue
如果有多个CPU就要考虑进程个数的负载均衡问题
3-4-2优先级
普通优先级:100〜139(我们都是普通的优先级,想想nice值的取值范围,可与之对应!)
实时优先级:0〜99(不关心)
3-4-3活动队列
时间片还没有结束的所有进程都按照优先级放在该队列
nr_active:总共有多少个运行状态的进程
queue[140]:⼀个元素就是⼀个进程队列,相同优先级的进程按照FIFO规则进行排队调度,所以, 数组下标就是优先级!
从该结构中,选择⼀个最合适的进程,过程是怎么的呢?
- 从0下表开始遍历queue[140]
- 找到第⼀个非空队列,该队列必定为优先级最高的队列
- 拿到选中队列的第⼀个进程,开始运行,调度完成!
- 遍历queue[140]时间复杂度是常数!但还是太低效了!
bitmap[5]:一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用 5*32个比特位表示队列是否为空,这样,便可以大提高查找效率!
3-4-4过期队列
过期队列和活动队列结构⼀模一样
过期队列上放置的进程,都是时间片耗尽的进程
当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算
3-4-5 active指针和expired指针
active指针永远指向活动队列
expired指针永远指向过期队列
可是活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期时⼀直 都存在的。
没关系,在合适的时候,只要能够交换active指针和expired指针的内容,就相当于有具有了⼀批 新的活动进程!
3-4-6总结
在系统当中查找⼀个最合适调度的进程的时间复杂度是⼀个常数,不随着进程增多⽽导致时间成 本增加,我们称之为进程调度O(1)算法
struct rq { spinlock_t lock; /* * nr_running and cpu_load should be in the same cacheline because * remote CPUs use both these fields when doing load calculation. */ unsigned long nr_running; unsigned long raw_weighted_load; #ifdef CONFIG_SMP unsigned long cpu_load[3]; #endif unsigned long long nr_switches; /* * This is part of a global counter where only the total sum * over all CPUs matters. A task can increase this counter on * one CPU and if it got migrated afterwards it may decrease * it on another CPU. Always updated under the runqueue lock: */ unsigned long nr_uninterruptible; unsigned long expired_timestamp; unsigned long long timestamp_last_tick; struct task_struct *curr, *idle; struct mm_struct *prev_mm; struct prio_array *active, *expired, arrays[2]; int best_expired_prio; atomic_t nr_iowait; #ifdef CONFIG_SMP struct sched_domain *sd; /* For active balancing */ int active_balance; int push_cpu; struct task_struct *migration_thread; struct list_head migration_queue; #endif #ifdef CONFIG_SCHEDSTATS /* latency stats */ struct sched_info rq_sched_info; /* sys_sched_yield() stats */ unsigned long yld_exp_empty; unsigned long yld_act_empty; unsigned long yld_both_empty; unsigned long yld_cnt; /* schedule() stats */ unsigned long sched_switch; unsigned long sched_cnt; unsigned long sched_goidle; /* try_to_wake_up() stats */ unsigned long ttwu_cnt; unsigned long ttwu_local; #endif struct lock_class_key rq_lock_key; }; /* * These are the runqueue data structures: */ struct prio_array { unsigned int nr_active; DECLARE_BITMAP(bitmap, MAX_PRIO+1); /* include 1 bit for delimiter */ struct list_head queue[MAX_PRIO]; };4. 命令行参数和环境变量
4-1基本概念
环境变量(environment variables)⼀般是指在操作系统中⽤来指定操作系统运行环境的⼀些参数
如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪 里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性
命令行参数的本质应用,是为了实现一个命令,可以根据不同的选项,实现不同的子功能,也是Llnux中所有命令选项功能的实现方式。
4-2常见环境变量
PATH:指定命令的搜索路径
HOME:指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
SHELL:当前Shell,它的值通常是/bin/bash。
4-3查看环境变量⽅法
echo $NAME //NAME:你的环境变量名称
测试PATH
1. 创建hello.c文件
#include <stdio.h> int main() { printf("hello world!\n"); return 0; }2. 对比 ./hello 执行和之间 hello 执行
3. 为什么有些指令可以直接执行,不需要带路径,而我们的二进制程序需要带路径才能执行?
4. 将我们的程序所在路径加入环境变量PATH当中, export PATH=$PATH:hello程序所在路径
5. 对比测试
6. 还有什么方法可以不用带路径,直接就可以运行呢?
测试HOME
1. 用root和普通用户,分别执行 echo $HOME ,对比差异
2. 执行 cd ~; pwd ,对应 ~ 和HOME 的关系
4-4和环境变量相关的命令
- echo:显⽰某个环境变量值
- export:设置⼀个新的环境变量
- env:显⽰所有环境变量
- unset:清除环境变量
- set:显示本地定义的shell变量和环境变量
4-5 环境变量的组织⽅式
每个程序都会收到⼀张环境表,环境表是⼀个字符指针数组,每个指针指向⼀个以’\0’结尾的环境 字符串
4-6通过代码如何获取环境变量
命令⾏第三个参数
#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; }libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头⽂件中,所以在使⽤时要⽤ extern声明。
4-7通过系统调用获取或设置环境变量
putenv ,后⾯讲解
getenv ,本次讲解
#include <stdio.h> #include <stdlib.h> int main() { printf("%s\n", getenv("PATH")); return 0; }常⽤getenv和putenv函数来访问特定的环境变量。
4-8环境变量通常是具有全局属性的
环境变量通常具有全局属性,可以被子进程继承下去
#include <stdio.h> #include <stdlib.h> int main() { char *env = getenv("MYENV"); if(env){ printf("%s\n", env); } return 0; }直接查看,发现没有结果,说明该环境变量根本不存在
导出环境变量
export MYENV="hello world"
再次运行程序,发现结果有了!说明:环境变量是可以被子进程继承下去的!想想为什么?
4-9实验
如果只进行 MYENV=“helloworld” ,不调用export导出,在用我们的程序查看,会有什么结果?为什么?
普通变量
如果时间允许:做⼀下~/.bash_profile&&~/.bashrc修改文件级环境变量
5. 程序地址空间
5-1研究平台
kernel2.6.32
32位平台
5-2程序地址空间回顾
我们在讲C语⾔的时候,⽼师给⼤家画过这样的空间布局图
可是我们对他并不理解!可以先对其进⾏各区域分布验证:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> int g_unval; int g_val = 100; int main(int argc, char *argv[], char *env[]) { const char *str = "helloworld"; printf("code addr: %p\n", main); printf("init global addr: %p\n", &g_val); printf("uninit global addr: %p\n", &g_unval); static int test = 10; char *heap_mem = (char*)malloc(10); char *heap_mem1 = (char*)malloc(10); char *heap_mem2 = (char*)malloc(10); char *heap_mem3 = (char*)malloc(10); printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1) printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1) printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1) printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1) printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1) printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1) printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1) printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1) printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1) printf("read only string addr: %p\n", str); for(int i = 0 ;i < argc; i++) { printf("argv[%d]: %p\n", i, argv[i]); } for(int i = 0; env[i]; i++) { printf("env[%d]: %p\n", i, env[i]); } return 0; }$ ./a.out code addr: 0x40055d init global addr: 0x601034 uninit global addr: 0x601040 heap addr: 0x1791010 heap addr: 0x1791030 heap addr: 0x1791050 heap addr: 0x1791070 test static addr: 0x601038 stack addr: 0x7ffd0f9a4368 stack addr: 0x7ffd0f9a4360 stack addr: 0x7ffd0f9a4358 stack addr: 0x7ffd0f9a4350 read only string addr: 0x400800 argv[0]: 0x7ffd0f9a4811 env[0]: 0x7ffd0f9a4819 env[1]: 0x7ffd0f9a482e env[2]: 0x7ffd0f9a4845 env[3]: 0x7ffd0f9a4850 env[4]: 0x7ffd0f9a4860 env[5]: 0x7ffd0f9a486e env[6]: 0x7ffd0f9a4892 env[7]: 0x7ffd0f9a48a5 env[8]: 0x7ffd0f9a48ae env[9]: 0x7ffd0f9a48f1 env[10]: 0x7ffd0f9a4e8d env[11]: 0x7ffd0f9a4ea6 env[12]: 0x7ffd0f9a4f00 env[13]: 0x7ffd0f9a4f13 env[14]: 0x7ffd0f9a4f24 env[15]: 0x7ffd0f9a4f3b env[16]: 0x7ffd0f9a4f43 env[17]: 0x7ffd0f9a4f52 env[18]: 0x7ffd0f9a4f5e env[19]: 0x7ffd0f9a4f93 env[20]: 0x7ffd0f9a4fb6 env[21]: 0x7ffd0f9a4fd5 env[22]: 0x7ffd0f9a4fdf5-3虚拟地址
来段代码感受⼀下
#include <stdio.h> #include <unistd.h> #include <stdlib.h> int g_val = 0; int main() { pid_t id = fork(); if(id < 0){ perror("fork"); return 0; } else if(id == 0){ //child printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val); }else{ //parent printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val); } sleep(1); return 0; }输出
//与环境相关,观察现象即可 parent[2995]: 0 : 0x80497d8 child[2996]: 0 : 0x80497d8我们发现,输出出来的变量值和地址是⼀模⼀样的,很好理解呀,因为⼦进程按照⽗进程为模版,⽗ ⼦并没有对变量进⾏进⾏任何修改。可是将代码稍加改动:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> int g_val = 0; int main() { pid_t id = fork(); if(id < 0){ perror("fork"); return 0; } else if(id == 0){ //child,⼦进程肯定先跑完,也就是⼦进程先修改,完成之后,⽗进程 再读取 g_val=100; printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val); }else{ //parent sleep(3); printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val); } sleep(1); return 0; }输出结果:
//与环境相关,观察现象即可 child[3046]: 100 : 0x80497e8 parent[3045]: 0 : 0x80497e8我们发现,父子进程,输出地址是一致的,但是变量内容不⼀样!能得出如下结论:
- 变量内容不⼀样,所以⽗⼦进程输出的变量绝对不是同⼀个变量
- 但地址值是⼀样的,说明,该地址绝对不是物理地址!
- 在Linux地址下,这种地址叫做 虚拟地址
- 我们在⽤C/C++语⾔所看到的地址,全部都是虚拟地址!物理地址,⽤⼾⼀概看不到,由OS统⼀ 管理
OS必须负责将 虚拟地址 转化成 物理地址 。
5-4进程地址空间
所以之前说‘程序的地址空间’是不准确的,准确的应该说成 进程地址空间 ,那该如何理解呢?看 图:
分页&虚拟地址空间
虚拟地址空间和页表,每一个进程各自有一套
fork之后,父子共享代码和数据。为什么?因为子进程会拷贝父进程的页表,类似发生了浅拷贝
os规定:父子中,任何一个进程,尝试对共享的变量进行修改时,不能之间修改,而要发生写时拷贝。-----深拷贝
在CPU和物理内存之间进行地址转换时,MMU将地址从虚拟(逻辑)地址空间映射到物理地址空间
- TCB 线程控制块
- MMU内存管理单元,一种负责处理中央处理器(CPU)的内存访问请求,功能包括虚拟地址到物理地址的转换(即虚拟内存管理)、内存保护、中央处理器高速缓存的控制
- CACHE 高速缓存
- DMA 直接内存存取
5-5虚拟内存管理 - 第一讲
描述linux下进程的地址空间的所有的信息的结构体是 mm_struct (内存描述符)。每个进程只有⼀ 个mm_struct结构,在每个进程的 task_struct 结构中,有⼀个指向该进程的mm_struct结构体指 针。
struct task_struct { /*...*/ struct mm_struct *mm; //对于普通的⽤⼾进程来说该字段指向他 的虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL。 struct mm_struct *active_mm; // 该字段是内核线程使⽤的。当 该进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因 为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。 /*...*/ }可以说, mm_struct 结构是对整个⽤⼾空间的描述。每⼀个进程都会有⾃⼰独⽴的 mm_struct , 这样每⼀个进程都会有⾃⼰独⽴的地址空间才能互不⼲扰。先来看看由 task_struct 到 mm_struct ,进程的地址空间的分布情况:
定位 mm_struct ⽂件所在位置和 task_struct 所在路径是⼀样的,不过他们所在⽂件是不⼀样 的, mm_struct 所在的⽂件是 mm_types.h
struct mm_struct { /*...*/ struct vm_area_struct *mmap; /* 指向虚拟区间(VMA)链表 */ struct rb_root mm_rb; /* red_black树 */ unsigned long task_size; /*具有该结构体的进程的虚拟地址空间的⼤⼩*/ /*...*/ // 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。 unsigned long start_code, end_code, start_data, end_data; unsigned long start_brk, brk, start_stack; unsigned long arg_start, arg_end, env_start, env_end; /*...*/ }那既然每⼀个进程都会有⾃⼰独⽴的 mm_struct ,操作系统肯定是要将这么多进程的 mm_struct 组织起来的!虚拟空间的组织⽅式有两种:
1. 当虚拟区较少时采取单链表,由mmap指针指向这个链表;
2. 当虚拟区间多时采取红⿊树进⾏管理,由mm_rb指向这棵树。
linux内核使⽤ vm_area_struct 结构来表⽰⼀个独⽴的虚拟内存区域(VMA),由于每个不同质的虚 拟内存区域功能和内部机制都不同,因此⼀个进程使⽤多个vm_area_struct结构来分别表⽰不同类型 的虚拟内存区域。上⾯提到的两种组织⽅式使⽤的就是vm_area_struct结构来连接各个VMA,⽅便进程快速访问。
struct vm_area_struct { unsigned long vm_start; //虚存区起始 unsigned long vm_end; //虚存区结束 struct vm_area_struct *vm_next, *vm_prev; //前后指针 struct rb_node vm_rb; //红⿊树中的位置 unsigned long rb_subtree_gap; struct mm_struct *vm_mm; //所属的 mm_struct pgprot_t vm_page_prot; unsigned long vm_flags; //标志位 struct { struct rb_node rb; unsigned long rb_subtree_last; } shared; struct list_head anon_vma_chain; struct anon_vma *anon_vma; const struct vm_operations_struct *vm_ops; //vma对应的实际操作 unsigned long vm_pgoff; //⽂件映射偏移量 struct file * vm_file; //映射的⽂件 void * vm_private_data; //私有数据 atomic_long_t swap_readahead_info; #ifndef CONFIG_MMU struct vm_region *vm_region; /* NOMMU mapping region */ #endif #ifdef CONFIG_NUMA struct mempolicy *vm_policy; /* NUMA policy for the VMA */ #endif struct vm_userfaultfd_ctx vm_userfaultfd_ctx; } __randomize_layout;5-6为什么要有虚拟地址空间
这个问题其实可以转化为:如果程序直接可以操作物理内存会造成什么问题?在早期的计算机中,要运⾏⼀个程序,会把这些程序全都装⼊内存,程序都是直接运⾏在内存上的, 也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运⾏多个程序时,必须保证 这些程序⽤到的内存总量要⼩于计算机实际物理内存的⼤⼩。 那当程序同时运⾏多个程序时,操作系统是如何为这些程序分配内存的呢?例如某台计算机总的内存 ⼤⼩是128M,现在同时运⾏两个程序A和B,A需占⽤内存10M,B需占⽤内存110。计算机在给程序分 配内存时会采取这样的⽅法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分 出110M分配给程序B。
这种分配⽅法可以保证程序A和程序B都能运⾏,但是这种简单的内存分配策略问题很多。
安全⻛险
每个进程都可以访问任意的内存空间,这也就意味着任意⼀个进程都能够去读写系统相关内 存区域,如果是⼀个⽊⻢病毒,那么他就能随意的修改内存空间,让设备直接瘫痪。
• 地址不确定
◦ 众所周知,编译完成后的程序是存放在硬盘上的,当运⾏的时候,需要将程序搬到内存当中 去运⾏,如果直接使⽤物理地址的话,我们⽆法确定内存现在使⽤到哪⾥了,也就是说拷⻉ 的实际内存地址每⼀次运⾏都是不确定的,⽐如:第⼀次执⾏a.out时候,内存当中⼀个进程 都没有运⾏,所以搬移到内存地址是0x00000000,但是第⼆次的时候,内存已经有10个进程 在运⾏了,那执⾏a.out的时候,内存地址就不⼀定了
• 效率低下
◦ 如果直接使⽤物理内存的话,⼀个进程就是作为⼀个整体(内存块)操作的,如果出现物理 内存不够⽤的时候,我们⼀般的办法是将不常⽤的进程拷⻉到磁盘的交换分区中,好腾出内 存,但是如果是物理地址的话,就需要将整个进程⼀起拷⾛,这样,在内存和磁盘之间拷⻉ 时间太⻓,效率较低。
存在这么多问题,有了虚拟地址空间和分⻚机制就能解决了吗?当然!
地址空间和⻚表是OS创建并维护的!是不是也就意味着,凡是想使⽤地址空间和⻚表进⾏映射, 也⼀定要在OS的监管之下来进⾏访问!!也顺便 ,包括各个 进程以及内核的相关有效数据! 保护了物理内存中的所有的合法数据
因为有地址空间的存在和页表的映射的存在,我们的物理内存中可以对未来的数据进⾏任意位置 的加载!物理内存的分配和进程的管理就可以做到没有关系, 。 进程管理模块和内存管理模块就完成了解耦合
因为有地址空间的存在,所以我们在C、C++语⾔上new, malloc空间的时候,其实是在地址 空间上申请的,物理内存可以甚⾄⼀个字节都不给你。⽽当你真正进⾏对物理地址空间访问 的时候,才执⾏内存的相关管理算法,帮你申请内存,构建⻚表映射关系(延迟分配),这 是由操作系统⾃动完成,⽤⼾包括进程完全0感知!!
因为⻚表的映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的 虚拟地址和物理地址进⾏映射,在进程视⻆所有的内存分布都可以是有序的。