当用户程序调用一个system call,硬件提高特权等级(privilege level),并运行内核中的一段预先安排的代码。
用户空间和内核空间的接口:system calls系统调用,
Look and behave like function calls, but they aren’t
。
内核为每个进程维护一个PID标识符
exec
system call会用调用的可执行文件(存储在文件系统中)覆盖进程的内存,但是会保留file table。exec
不会返回,会从elf header中的entry开始执行。
每个进程都有用来存放fd的私有空间file table,fd从0开始。每个fd都有对应的offset,每次调用read
或write
都会从上一次地方开始。新分配的fd从最小的数字开始。
pipe有2个接口:read和write,当write end和所有引用write end的fd关闭时,pipe才会关闭,这时候read读完缓冲区所有内容后下一次read返回0。pipe在内核中实现,有缓冲区。
mknod
创建特殊文件(称为device设备),需要传入2个数字用于唯一标识一个kernel device。当打开一个device file,内核会把read
和write
替换为kernel device implementation。
当一个文件的link
为0且没有fd应用它时,释放文件的inode和磁盘空间。
os需要满足三个需求:multiplexing,isolation and interaction(pipe)。
对物理资源的抽象:进程、文件等。通过系统调用(ecall)从用户空间陷入内核空间(即从user mode转入supervisor mode)。
riscv的三种模式:machine mode,supervisor(kernel) mode and user mode。supervisor mode下可以执行privileged instructions
,比如使能/失能中断、读/写satp寄存器。
运行在内核空间(supervisor mode)的software/code称为kernel。
宏内核(monolithic kernel)和微内核(micro kernel)。微内核中,在用户空间像进程一样运行的os服务(如file server)成为servers。
heap堆在需要时通过malloc分配空间。在虚拟地址空间最顶处分配了一页trampoline(包含进出内核的代码)和一页trapframe(save/restore用户进程的状态)。
硬件使用39位寻址va,但是xv6只使用了38位,避免对设置了高位的va进行符号拓展。所以最大地址
MAXVA
(kernel/riscv.h:363)= 2^38-1 = 0x3fffffffff。
内核为每个进程维护一个结构体proc
(kernel/proc.h:86),保存进程状态,如p->state和p->pagetable。
每个进程有两个栈:user stack和kernel stack,分别在用户空间和内核空间下使用。ecall
指令进入内核空间,sret
指令返回用户空间。
xv6中,一个进程包含一个线程和一个地址空间。
security:os必须假设进程的user-level代码会尽最大的可能去破坏内核或其他进程。在内核中可能存在bugs的地方设计safeguards,如:assertions、type checking和stack guard pages。
powers on:初始化,运行存储在read-only memory中的boot loader。boot loader会把xv6 kernel下载到内存中(0x80000000)。然后xv6以machine mode运行_entry
(kernel/entry.S:7)。
entry.S:初始化栈使xv6可以运行C代码,然后跳转进入start()
(kernel/start.c:21)。
start.c:进行一些只能在machine mode运行的初始化并转到supervisor mode。比如:失能内存地址映射(往satp寄存器写0)、编程时钟芯片来初始化定时器中断。通过mret
指令返回supervisor mode,这将导致pc寄存器指向main()
(kernel/main.c:11)。
main.c:初始化一些设备和子系统后,运行userinit()
(kernel/proc.c:226)即创建第一个进程等待scheduler调度,里面嵌入了initcode.S
(user/initcode.S:3)的RISC-V汇编代码,作用是将exec
需要的参数加载到寄存器a0
(=“/init\0”)和a1
(=argv)中并把系统调用编号加载到寄存器a7
中。最后调用ecall
重新进入内核执行系统调用exec
运行/init
(user/init.c:15)。
kernel在
syscall
(kernel/syscall.c:133)中使用寄存器a7
中的数字执行对应系统调用。
系统调用表(kernel/syscall.c:108)把SYS_EXEC
映射为sys_exec
。
在proc.c
中的uchar initcode[]
就是initcode.S
的十六进制代码。
satp寄存器
CPU中的satp寄存器保存root page table的地址,页表存在内存中的某个位置。MMU根据satp查找页表,MMU不负责建立页表,而是os负责。如果需要的物理地址(PM)不在页表中,MMU引发page-fault exception,由os负责从磁盘中寻找需要的page并移动到物理内存中。
在Sv39 RISC-V中,只使用了39bits用于地址映射(共512GB),高位25bits用于未来拓展。在39bit中,前27bit用于三级页表,后12bit为物理内存offset,用于索引一页4096(2^12)bytes
中的偏移。每级页表有512项,索引下一级页表时,offset全为0。
为了避免从物理内存移动PTEs的代价,RSIC-V CPU使用TLB(Translation Look-aside Buffer)用于缓存PTEs。
如果只使用一级页表,需要2^27表项,需要占用很大的空间。
如果一个进程用不到全部页表,多余的页表项(第2、3级页表)不会分配空间。
物理内存指的是DRAM中的存储单元,ma的每个字节都有一个地址,称为物理地址。os初始化开启地址映射后,指令使用的地址是虚拟地址。
xv6为每个进程维护一张pagetable,同时为内核维护一张kernel pagetable(直接映射)。
有2个地址没有直接映射:
如果kstack采用直接映射方式,guard page对应的物理地址将很难使用。
在main()
(kernel/main.c)中初始化地址映射。
kinit():擦除从end到PHYSTOP的物理内存,并在每个free page开头写入struct run,指向kmem.freelist,即指向下一页free page。
kvminit():创建PTE和proc stack,核心函数为mappages()
(kernel/vm.c:138)和walk()
(kernel/vm.c:81)。walk()
用于寻找或创建pte。创建了NPROC
(最多进程数)个proc stack(大小为一页)。
PA2PTE:先右移12位清除offset,再左移10位为Flags预留位置。
PTE2PA:先右移10位清除Flags,再左移12位为offset预留位置。
copyout()和copyin()也在kernel/vm.c中,因为它们需要翻译va到pa。
kvminithart():kernal pagetable写入satp
寄存器并enable paging。
当改变页表时,需要invalid目前缓存的TLB entries,指令sfence.vma
会flush目前CPU的TLB。xv6在kvminithart()中加载satp寄存器和在trampoline code(切换用户页表before返回用户空间)中执行sfence.vma
。
每个free page除了struct run
之外不存储任何其他内容。freelist被一个spin lock
保护,list和lock放在一个结构体中(kmem
)。
kinit()中初始化allocator,即初始化从end of kernel
到PHYSTOP
的物理内存到freelist。一个PTE只能对应以4096字节对齐的一段物理内存,所以初始化页表时传入的va和pa都是以4096字节对齐。
sbrk
来缩小或增长进程内存,通过函数growproc
(kernel/proc.c:253)调用uvmalloc
和uvmdealloc
。uvmalloc
(kernel/vm.c:221)调用kalloc
分配物理内存和mappages
添加PTEs到用户页表。uvmdealloc
(kernel/vm.c:166)调用uvmunmap
(调用walk
查找PTEs)和kfree
释放物理内存。exec
(kernel/exec.c:13)系统调用使用文件系统里的一个elf
文件(kernel/elf.h)来初始化一个地址空间的用户部分。
path
获取文件inode,每个文件都由唯一的inode标识。elf
文件。uvmalloc
(kernel/exec.c:52)为每个elf段分配内存并建立页表映射,调用loadseg
(kernel/exec.c:10)加载每个段到内存中(使用walkaddr
查找要分配的pa)。
filesz可能小于memsz。
copyout
把argv参数入栈,最后放入一个空指针。前三个参数分别为:假pc,argc指针和argv指针。guard page除了检测栈溢出,还可以检测参数过长。proc
结构体相关成员,释放旧进程的页表。exit -1
返回旧进程。只有到最后一步才释放旧进程的页表,否则exit出错。elf文件中的地址或指令可能指向内核,所以需要做一系列的检查,防止破坏内核和用户空间的隔离。
xv6内核缺少malloc
类似的allocator/动态内存分配器。
在MMU之前有对VM的cache,之后有对PM的cache。切换页表之后会flush TLB。多核CPU每核都有satp和TLB。
物理内存地址layout由硬件决定,所以开机boot结束后,跳转到0x8000 0000
也是由硬件决定(该地址需要人为写到boot中用于跳转)。
卸载IO devices映射位置的指令,实际上是写到对应设备芯片或controller里。
物理地址里可能有一部分(高地址空间)unused,取决于板子上的DRAM大小和xv6限制(128MB)。
calling convention
RISC-V调用约定:函数调用时,使用a0-a7
和fa0-fa7
来传递参数。当函数参数超过8个时,需要使用内存(栈)传参。
stack frame
调用函数时(由汇编,即编译器)创建一个栈帧,非叶子函数的汇编代码结构:减sp
创建栈帧,主体代码,加sp
恢复栈帧。栈帧由高地址向下(栈顶向栈底)增长,返回地址在栈顶,超过8个的函数参数会通过栈帧传递。sp
指向栈底,fp
指向栈顶,返回地址后保存上一个栈帧的fp
。这2个寄存器帮助函数正确返回。
当发生system call
,exception
,interrupt
时(统称为trap
陷阱)CPU暂停当前指令,并转到解决上述事件的特殊代码中。内核可以只通过a single code path
来解决所有的代码路径,然后判断三种情况执行对应代码(handler
)。第一条handler
指令通常是汇编,称为vector
。
Traps from user space
trampoline
(kernel/trampoline.S:16)被映射到用户页表和内核页表的相同位置(va最高处),当发生陷阱时,内核设置相关寄存器,然后进程以supervisor mode
运行trampoline
(此时仍然是用户页表)。并执行uservec
(kernel/trampoline.S:16),usertrap
(kernel/trap.c:37),usertrapret
(kernel/trap.c:90),userret
(kernel/trampoline.S:88)。
内核设置相关寄存器:stvec
指向uservec()
;sepc
保存进程的pc;scause
保存表示trap原因的数字;sscratch
指向p->trapframe
;sstatus
中的SIE bit
控制设备终端使能,SPP bit
表示trap来自user mode还是supervisor mode同时控制sret
返回什么mode。
uservec():保存32个通用寄存器到p->trapframe
。从p->trapframe
加载内核相关寄存器,此时切换到内核页表。跳转到usertrap()
并且不会返回。
usertrap():在stvec
寄存器存入kernelvec()
(kernelvec.S)的地址(以便interrupt
和exception
使用)。保存sepc
的值到p->trapframe->epc
,因为后面可能要调用yield()
来切换进程(可能改变sepc
)。读取scause
寄存器判断trap类型:
system call
则p->trapframe->epc
+4指向ecall
的下一条指令,然后调用syscall()
。interrupt
则执行devintr()
,定时器中断还会调用yield()
。exception
直接kill掉进程,exit(-1)。然后调用usertrapret()
返回用户空间。
usertrapret():设置相关寄存器为未来的trap from user space做准备。stvec
指向uservec()
,更新p->trapframe
内核相关寄存器,更新sstatus
和sepc
。最后调用userret
,传入2个参数:TRAPFRAME(在用户页表中的va)到a0
和satp(即用户页表指针)到a1
。
userret():切换用户页表,加载用户寄存器,sscratch
保存TRAPFRAME,sret
切换到user mode并根据pc运行进程中下一条指令。
Trap from kernel space
stvec
指向kernelvec
(kernel/kernelvec.S:10),此时在内核空间中,所以satp
指向内核页表,栈指针指向内核栈。
kerneltrap()
。devintr
(kernel/trap.c:177)检查是否为device interrupts
如果是则执行对应代码。如果不是,则为exception
(发生在内核中的通常为致命错误),调用panic
并停止执行。yield
切换另一个线程。返回kernelvec()
sret
。从用户空间进入内核空间时,在usertrap()中有一段时间是内核正在执行但是stvec
仍然指向uservec
,这时候失能设备中断很重要。所以引发trap时,xv6会首先失能device interrupt。
Page-fault exceptions
很多内核使用page fault来实现写时复制(copy-on-write,COW)fork。xv6中的fork是直接复制父进程到子进程的初始化物理内存,更高效的方法是通过页表权限和page fault来共享父进程的物理内存。
发生以下情况时CPU发起一个page-fault exception:访问页表中没有映射的va、
PTE_V
标志位被清除的va、相关权限位被清除的va。RISC-V将page-fault分为三类:
load page faults:load指令的va翻译失败。
store page faults:store指令的va翻译失败。
instruction page faults:pc寄存器中的地址翻译失败。
子进程写某一页时引发page-fault异常。在trap handler中分配新一页(或连续几页)物理内存并将访问的那页复制进去,同时允许读写。然后重新执行之前的指令。COW需要一个记录来决定什么时候可以释放物理内存,因为每一页物理内存可能被多个页表引用(如果进程引发store page fault并且此时对应的pa只被当前进程的页表引用,则不需要复制)。
lazy allocation
当一个进程使用sbrk
请求内存时,内核关注请求的空间大小,但不分配新的物理内存也不创建新的PTEs。当在新的va上引发page fault时才分配新的一页和添加地址映射。
demand paging
现代内核中执行exec
时为避免加载大的可执行文件而影响响应时间,不直接加载内容,而是创建页表并标记PTEs为invalid。当引发page fault时从硬盘/磁盘中读取内容并建立地址映射。
paging to disk
在RAM中只存储一部分user pages,剩下的存储在硬盘/磁盘上的paging area(对应PTEs标记为invalid)。当引发page fault,从硬盘读取到RAM中,并修改PTEs指向RAM。
automatically extending stacks and memor-mapped files
如果将内核映射到每个用户进程的页表里,可以:不需要特殊的trampoline、从用户空间陷入内核时减少开销、系统调用可以直接利用用户内存(比如允许内核直接解引用用户指针)。但是xv6没有这么做,为了防止用户指针的错误使用和避免考虑用户和内核va的重叠问题。
商业os都会实现以上功能,并且尽可能用尽所有物理内存。但是xv6用尽内存时会返回错误或直接杀死当前进程,而不是驱逐其他进程的物理内存来交换需要的内容。
initcode.S
将exec
系统调用的参数加载到寄存器a0
(“/init\0”)和a1
(=argv),将系统调用编号加载到寄存器a7
(=7,sys_exec
)。ecall
指令陷入内核并执行uservec
、usertrap
、syscall
。syscall
使用a7
的编号执行对应sys_*
。
当sys_exec
返回时,syscall
会保存返回值到p->trapframe->a0
,这是exec()
的返回值。RISC-V中C语言函数返回值放在寄存器a0
,系统调用返回负数表示error,返回0或正数表示success。
Lab 2: system calls
kernel trap code保存用户寄存器到当前进程的trapframe,所以切换到内核空间后仍然可以找到它们。argint
,argaddr
,argfd
都调用了argraw
从p->trapframe
(在allocproc()中分配新进程时,指向新进程的TRAPFRAME的pa)中找到第n个系统调用参数。
有些系统调用的参数包含指针,带来2个问题:
fetchaddr
和fetchstr
(kernel/syscall.c:12)函数从用户空间复制数据到内核,分别调用了copyin
和copyinstr
(kernel/vm.c:372)。难度:Easy(小于1h),Moderate(1-2h),Hard(>2h,不需要很多代码,但是需要一些技巧)。
gdb调试:在一个终端开启gdb servermake qemu-gdb
,在另一个终端开启gdb clientgdb or riscv64-linux-gnu-gdb
(ubuntu使用gdb-multiarch
),会根据xv6-riscv/.gdbinit自动配置)。
如果出现类似下面的警告:
warning: File "
/xv6-riscv/.gdbinit" auto-loading has been declined by your 'auto-load safe-path' set to "$debugdir:$datadir/auto-load". To enable execution of this file add add-auto-load-safe-path /xv6-riscv/.gdbinit line to your configuration file "~/.gdbinit".
按照提示做,在~/.gdbinit文件里添加add-auto-load-safe-path
。/xv6-riscv/.gdbinit
查看kernel.asm
,同时编译器编译内核后,会给每个user program生成对应的.asm。
内核崩溃时,报错信息里有pc寄存器的值,然后查看kernel.asm
在哪个函数出错,或者运行addr2line -e kernel/kernel pc-value
。
ctrl-a c
进入qemu的monitor,可以查看虚拟机的信息。info mem
查看页表(使用cpu
指令选择info mem
查看哪个核,make qemu CPUS=1
模拟单核)。
gdb中输入layout split上下分层显示。
xv6中的系统调用以sys_
开头,源码在kernel/sysproc.c
。kernel/syscall.h
定义了系统调用编号,kernel/syscall.c:108
转为宏定义SYS_*
ctrl + a x
退出qemu,xv6没有ps命令,但是可以输入ctrl + p
,内核会输出每个进程的信息。并发模型:
任务:使用pipes编写素数筛的并发版本。使用pipe和fork建立pipeline,父进程投喂2-35进入pipeline。对于每个素数,安排一个进程通过pipe从left neighbor进程读取,然后通过另一个pipe写入right neighbor进程。
提示:
xv6资源有限,不能一次创建11个pipe,所以在循环里关闭不需要的管道再创建新的管道
int p[11][2];
int data_p,data_n;
pipe(p[0]); pipe(p[1]);
for( int i=2; i<36; i++ )// feed
write(p[0][1], &i, 4);
for( int i=0; i<11; i++ ){
if( fork() == 0 ){ // parent
close unuse pipe
print prime and sieve
close used pipe
}
else{ //parent
close unuse pipe
create new pipe for next child
}
}
usys.S
对应的汇编,把SYS_*
系统调用编号加载到寄存器a7
然后执行ecall
。ecall
指令里会调用syscall
(kernel/syscall.c:135),根据系统调用编号执行对应sys_*
函数(调用fork
等syscall时实际在执行sys_*
)。在sys_*
里执行对应功能或调用对应*
函数。struct proc
中添加额外内容。