xv6系统调用的流程?
-
拿trace函数举例,首先在用户态执行系统调用trace
-
函数使用CPU提供的ecall指令,跳转到内核态
-
到达内核态统一系统调用处理函数 syscall(),所有系统调用都会跳到这里来处理
-
syscall() 根据跳板传进来的系统调用编号,查询 syscalls[] 表,找到对应的内核函数并调用
-
到达 sys_trace() 函数,执行具体内核操作
添加的系统调用sysinfo是如何实现的?
sysinfo的功能是返回空闲内存和已经创建进程的数量,具体实现上,由于xv6使用链表管理空闲页面,通过遍历链表即可计算空闲内存的大小,遍历所有进程,其中状态为未使用的进程数即为已经创建进程的数量
你是如何加速系统调用getpid的?
pid存在进程控制块中,进程控制块在内核里,用户需要通过系统调用才可以读取内核。这里可以创建一个共享页面,记录虚拟地址USYSCALL到pid所在页面物理地址的映射,这样用户态可以通过访问共享页面获得pid,无需进行系统调用,因此加速了getpid的过程
xv6的页表机制是什么样的?你做了什么样的修改?
xv6采用了三级页表,每一级有512个页表项。实验中对三级页表进行了打印,并为页表项增添标志位,以实现系统调用sys_pgaccess(获知哪个页面被访问过)
alarm是如何实现的?
alarm是报警函数,进程使用 cpu 时,每隔一个特定的时间发送信号sigalarm通知内核,进程陷入,随后,内核保存各种寄存器和状态,并执行中断处理函数,执行完后内核恢复保存的状态并返回,从中断处继续执行,alarm函数限制进程使用cpu的时间
介绍一下函数调用栈
栈帧是因函数运行而临时开辟的空间,每调用一次函数,便会在栈上创建一个独立栈帧,其中存放函数中的必要信息,如局部变量、函数传参、返回值等。当函数运行完毕栈帧将会销毁。
栈从高地址向低地址增长,sp指向栈顶(当前栈帧的位置,会移动),fp指向栈底,创建新的栈帧时对指针做减法
栈帧大小不同,但return address、指向前一个栈帧的指针在固定位置
栈帧中从高到低第一个 8 字节 fp-8
是 return address,也就是当前调用层应该返回到的地址。
栈帧中从高到低第二个 8 字节 fp-16
是 previous address,指向上一层栈帧的 fp 开始地址。
剩下的为保存的寄存器、局部变量等。一个栈帧的大小不固定,但是至少 16 字节。
在 xv6 中,使用一个页(4KB)来存储栈,如果 fp 已经到达栈页的上界,则说明已经到达栈底。
backtrace是如何实现的?
backtrace的作用是打印栈帧,首先获取当前的fp,指向当前栈帧的起始地址(fp 虽然是帧开始地址,但是地址比 sp 高),随后打印fp-8位置处的地址,即为栈帧的返回地址,再将指针移动到fp-16,指向上一个栈帧,直到遍历完成。
xv6如何分配内存?
-
首先malloc维护了一个内存池,当申请的内存超过内存池可以提供的内存
-
进程可以通过kalloc动态申请内存,xv6有一个空闲页面组成的链表,分配页面的时候会将空闲页面移出,随后可以通过kfree释放内存,内存释放后需要将空闲页加入链表
写时复制的原理、如何实现、优缺点
-
原理:推迟分配物理内存,直到真正使用的时候,再分配物理内存
-
实现:首先,父进程页表项设置为只读,父进程的物理内存映射给子进程(页表项仍为只读),同时标记为COW页;子进程想要向物理页写数据时,CPU引发页错误;随后,分配新物理页,把原始页中的数据复制到新分配的页中,修改子进程页表项,指向新分配的内存,把新页面的页表项标记为可写PTE_W;需要释放页面时,先确保物理页没有进程使用(引用计数为0),随后释放(借用一个数组实现,索引代表页,值代表引用个数)
-
优点:需要的时候再复制页面,减少不必要的资源分配和延时
-
缺点:如果写页面频繁,会出现大量的页错误
介绍一下上下文切换?
-
上下文指的是通用寄存器的值和栈的状态,上下文切换发生在进程调度的时候,会保存当前进程的相关信息,将另一进程的相关信息存入到相关寄存器中
-
对于线程,如果多个线程属于同一进程,虚拟内存共享,只需要切换一些私有数据,虚拟内存保持不动
xv6如何实现用户线程?
-
每个用户进程只有一个用户线程,每个进程一张页表,用户线程不会共享内存
-
每个用户线程对应一个内核线程,所有内核线程使用同一张内核页表,内核线程会共享内存
xv6如何进行线程切换?
-
由于处理器数量有限,xv6需要在多个进程之间切换共享CPU资源
-
每个用户进程只有一个用户线程,每个用户线程对应一个内核线程,每个CPU有一个调度器线程(内核中)
-
xv6通过swtch函数进行用户线程的切换,该函数被调用时会保存上下文到内核栈中,再把控制权转交给调度器线程,调度器线程选择下一个要运行的进程,并通过swtch恢复所选进程的上下文,继续执行
-
同时,在调用swtch函数之前,必须加锁,以防上下文切换过程中发生状态变化
介绍一下xv6中有哪些锁
-
xv6实现了自旋锁和休眠锁
-
自旋锁是一种独占锁,加锁失败后线程会忙等待(可能会消耗大量CPU资源),使用整型变量表示,0表示未被占用,线程想要获取锁,会把该值设置为1,这是一个原子操作,等线程完成对共享资源的访问,则会将变量设置为0,表示锁已经释放
-
休眠锁:进程对资源加锁失败后会进入休眠状态,释放CPU资源,等待条件满足时才被唤醒。在xv6中睡眠锁通过自旋锁+循环+sleep实现
自旋锁和休眠锁有哪些应用场景?
-
自旋锁适合持有锁时间比较短的场景,因为在持有锁的过程中线程会持续占用CPU
-
睡眠锁适合那些持有锁时间比较长的场景
加锁的优缺点?
-
锁的作用保证了临界区内操作的序列化,但也会使得编程复杂、带来死锁问题,同时,锁的粒度应当尽可能的小,大锁安全但是会降低并行性
-
xv6中的kalloc就用了一把大锁,如果多个CPU想要申请内存,运行会变得很慢,而文件系统使用的是细粒度的锁,每个文件都有一把锁
xv6如何实现线程安全的哈希表
-
起初,我为哈希表的put和get操作整体加锁,这样保证每个时刻只有一个线程在操作哈希表,但是发现性能不佳,实际上等同于将哈希表的操作变回单线程
-
后面,我对锁进行了优化。只需要确保两个线程不会同时操作同一个 bucket 即可,并不需要确保不会同时操作整个哈希表,所以可以将加锁的粒度,从整个哈希表一个锁降低到每个 bucket 一个锁
你是怎么降低lock condition的?
-
xv6所有缓存用一条链表连接,并为空闲页面链表添加了一把大锁,这样做性能不高
-
我的应对方式是为每个空闲页面分别设置一个锁,从而降低了每个锁的锁征用
加锁的原则
-
优先加大锁,实在不行大锁化小锁,减小锁粒度
-
只在有线程安全要求的代码处加锁
-
锁分离,如读写锁
-
对于不可能被共享的对象,消除对这些对象的加锁操作
xv6的文件系统是怎么实现的?
-
xv6文件系统采用混合索引的方式,每个文件所占用的前 12 个盘块的盘块号是直接记录在 inode 中的(每个盘块 1024 字节),
-
大于 12 个盘块的部分,会分配一个额外的一级索引表(一盘块大小,1024Byte,包含256个盘块号),用于存储这部分数据
-
实验中我为文件系统加上了二级索引页,第14号盘块指向一级索引页,一级索引页指向二级索引页,再指向数据页
-
所有文件的inode都会被存储在磁盘上。系统和进程需要使用某个inode时,这个inode会被加载到inode缓存里。
软链接与硬链接的区别?
-
软链接也是一种文件,保存另一个文件的路径名,在打开文件的时候根据保存的路径名再去查找实际文件,硬链接则指向一个存在文件的inode
-
xv6中我将inode中第一个直接块存储文件路径,需要打开文件时,递归跟随符号链中存储的路径
mmap函数是如何实现的?
-
函数的作用是将文件直接映射到内存当中,之后对文件的读写就可以直接通过对内存进行读写来进行,而对文件的同步则由操作系统来负责完成
-
使用mmap可以避免对文件大量 read 和 write 操作带来的内核缓冲区和用户缓冲区之间的频繁的数据拷贝
-
mmap系统调用使用懒加载(延迟加载),因为需要读取的文件内容大小很可能要比可使用的物理内存要大
-
文件不再需要的时候需要调用munmap解除映射
-
具体实现上,首先要在用户的地址空间内找到一片空闲的区域,为了防止和其他进程的地址空间产生冲突,将其映射到trapframe的下面,向下生长
-
在进程结构体里添加数组,数组元素个数为想要同时支持的文件个数
-
mmap调用时,找到一个没被使用的用于映射文件的虚拟地址空间vma,增加文件的引用计数,并把空间插入到进程vma链表中
什么是懒分配?
- 分配内存时,只增大进程的内存空间字段值,但并不实际进行内存分配;当该内存段需要使用时,会发现找不到内存页,抛出 page fault 中断,这时再进行物理内存的分配,然后重新执行指令
遇到什么困难?你是怎么解决的?
-
加速系统调用的那个实验实在是看不懂,我就去看他的文档、手册,理解原理,然后对照提示和源码梳理流程
-
有时候文档写的很晦涩,我就去参考Linux中的设计,比如cow和mmap
-
优化锁的时候加大锁性能不佳,我就尝试把大锁转换成小锁,最终调试通过
有什么收获?
- 通过这个lab,我更深入的了解了操作系统进程切换、内存管理、系统调用、锁机制,锻炼了分析问题、解决问题的能力,养成阅读源码和文档的习惯,并能够调试诊断错误
其它相关问题
-
为什么要有虚拟内存?
-
内存隔离:使得各个进程拥有独立的地址空间,互不干扰
-
更大的地址空间:组合物理内存和外存,使得用户拥有比实际上大得多的地址空间
-
文件共享:可以将文件映射到进程的虚拟地址空间,使得多个进程共享一个文件的虚拟内存副本
-
优化内存管理:可以将不常用的数据页移动到硬盘上
-
-
为什么虚拟内存之和可以超过物理内存?
-
基于局部性原理,内存有重复访问的倾向性,不经常访问的页面,可以将其换出内存
-
操作系统基于缺页故障,实现了fork写时复制等延迟分配的机制
-
-
内存管理有哪些模块?
-
内存的分配与回收:管理内存池,为进程分配内存块,回收不再使用的内存块
-
虚拟内存管理(扩充内存空间):地址转换、页表机制、页面置换、共享内存
-
内存保护:确保进程不能越界访问其他进程的内存,一般设置上下限地址寄存器、限长寄存器
-
-
页表有什么作用?
- 页表存的是虚拟地址到物理地址的映射,作用是实现地址转换,系统中的每个进程都有一张页表
-
页表项中还有哪些位?
-
有效位:将地址空间中没有使用的页面标记为无效,就不需要为其分配物理帧,从而节省内存
-
存在位:标识页面是否在内存中,还是说已经被换出
-
脏位:页面是否被修改过
-
访问位:页面是否被访问过
-
-
三级页表相比于一级页表的优劣?
-
优势:
-
对于一级页表,页表项过多的话,整张页表会过大,且需要为其分配完整的物理内存空间,这是一种内存上的浪费,引入多级页表后,线性的页表转换成树状结构,页面可以稀疏存储;
-
每个层级的页表分配给不同进程,可以更好的实现内存隔离
-
缺点:
-
访问多级页表,速度会变慢
-
如果页面没有命中,需要加载多次才能获得正确的地址转换信息
-
地址映射比较复杂
-
-
TLB快表的作用和原理?
-
TLB是一种高速缓存,作用是加速虚拟地址到物理地址的转换,减少访存的次数
-
基于局部性原理,记录最近访问过的映射关系
-
-
局部性原理?
-
时间局部性:近期访问过的页面不久之后可能会再次访问
-
空间局部性:访问过的页面,它附近的页面也会被访问
-
-
地址转换的流程?
-
CPU发出一个虚拟地址访问请求,虚拟地址被发送到内存管理单元MMU
-
MMU查询TLB,看是否访问过,如果TLB命中,直接进行地址转换,TLB没有命中,则查询页表,页表中存在该页面,则进行地址转换,访问物理内存,页表中不存在该页面,发生缺页中断,从外存中调页
-
-
发生中断的场景有哪些?
-
程序执行系统调用
-
程序出现缺页中断、运算除0之类的错误
-
硬件触发中断
-
-
缺页中断物理内存不足会怎样?
- 操作系统会根据页面置换算法,如:最近最久未使用、先入先出等方式选择一页换出,再换入一页
-
什么是线程池?
- 在服务器启动之初就创建的一组子线程,所有线程运行相同的代码,当新任务到来,从线程池中选择某个子线程为之服务,线程池中线程的数量应当和CPU数量差不多
-
堆和栈的区别?
-
堆空间大、栈空间小
-
栈空间操作系统自动分配释放,存储静态变量、函数参数等,使用较安全;栈空间由程序员手动申请释放,容易发生内存泄漏
-
栈的生命周期更短,不适合长期存储数
-
-
进程、线程、协程的区别
-
进程是资源分配的最小单位,线程是任务调度的基本单位
-
协程可以理解为用户态线程,内核视角无法察觉协程存在
-
-
线程有哪些实现方式?
-
用户线程:线程的TCB存在用户态,线程的管理对操作系统透明,多个用户级线程对应一个内核级线程
-
优点:线程切换比较快,不需要内核态和用户态的切换
-
缺点:一个用户线程阻塞了,整个进程都会阻塞;时间片分配给进程,多线程执行时,每个线程得到的时间片较少
-
-
内核线程:由内核管理线程,一个用户线程对应一个内核线程
-
优点:某个线程被阻塞,不会影响其它线程的运行
-
缺点:线程的创建、终止、切换都是通过系统调用,开销比较大
-
-
轻量级线程
-
-
如何实现多线程?
-
并行,多个CPU,每个CPU运行一个线程
-
并发:分时复用CPU,一个CPU在多个线程间来回切换
-
-
什么是死锁?
- ≥2个线程并行执行,由于争夺资源相互等待,没有外力的作用下,线程会一直相互等待,没办法继续运行
-
死锁的四个条件?
-
互斥:多个线程不能同时使用同一个资源
-
请求保持:线程在等待资源时并不会释放已经持有的资源
-
不可剥夺:线程已经持有了资源,在自己使用完之前不能被其他线程获取
-
循环等待:两个线程获取资源的顺序形成了环路链
-
-
怎样避免死锁问题?
-
避免一个线程同时获取多个锁
-
使用锁超时,线程等待锁超过一段时间后放弃
-
顺序资源分配
-
银行家算法(一种死锁避免的算法)
-