ucore 源码剖析
lab1 源码剖析
从实模式到保护模式
-
初始化ds,es和ss等段寄存器为0
-
使能A20门,其中seta20.1写数据到0x64端口,表示要写数据给8042芯片的Output Port;seta20.2写数据到0x60端口,把Output Port的第2位置为1,从而使能A20门。
-
建立gdt,此处只设置了两个段描述符,分别对应代码段和数据段
gdt:
SEG_NULLASM # null seg
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg for bootloader and kernel
SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg for bootloader and kernel
-
使用
lgdt gdtdesc
将gdt的地址加载到GDTR寄存器中 -
设置cr0寄存器的最低位为1,从而使能保护模式
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
-
执行
ljmp $PROT_MODE_CSEG, $protcseg
从而进入保护模式,其中PROT_MODE_CSEG为8,即代码段的段选择子,执行ljmp时会将cs寄存器设置为PROT_MODE_CSEG=8. -
protcseg的开头先将除CS外的段寄存器设置为数据段的段选择子,然后调用bootmain来执行bootmain。
# Set up the protected-mode data segment registers
movw $PROT_MODE_DSEG, %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment
bootloader加载内核
- ucore内核文件共有几个段?
- 共有2个段,如下所示:
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x001000 0xc0100000 0xc0100000 0x15650 0x15650 R E 0x1000
LOAD 0x017000 0xc0116000 0xc0116000 0x05000 0x05f28 RW 0x1000
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x10
- bootloader将内核加载到内存中哪个位置?
- 根据代码语句
readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
以及内核文件的program headers信息,可知bootloader分别将代码段和数据段加载到物理地址为0x100000和0x116000的位置。
- 根据代码语句
中断初始化
-
调用pic_init函数,初始化8259A可编程中断控制器芯片,包括主片和从片,需要严格按照一定的顺序写入ICW1~ICW4这四个初始化命令字
-
调用idt_init函数,首先设置IDT表中256个门描述符的信息,包括每个中断向量的中断服务例程地址及DPL等,除了系统调用的DPL设置为3外,其他中断向量的DPL均设置为0.
for (pos = 0; pos < 256; pos++) {
SETGATE(idt[pos], 0, GD_KTEXT, __vectors[pos], DPL_KERNEL);
}
SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER);
- 然后通过
lidt(&idt_pd);
加载IDT的地址到IDTR寄存器中
中断处理流程
-
无论是外部中断、异常还是系统调用,都统一采用了中断机制进行处理。简言之,就是CPU检查到有中断发生后,根据中断号索引中断向量表,得到中断处理例程的地址,跳到中断处理例程中进行处理。
-
中断处理例程统一定义在vectors.S文件中,而且所有中断处理例程除了开头压入的中断号不一样,其他的处理流程完全一致:都是先压入中断号,然后跳到__alltraps函数;__alltraps函数创建一个trap frame后,再跳到trap函数,trap函数则直接调用trap_dispatch函数。trap_dispatch函数,顾名思义,就是根据中断号,分发到不同的函数进行处理。
vectors (kern/trap/vectors.S 中断向量表,存有所有中断向量的地址) ->
vector[k] (第k个中断的入口处理函数) ->
__alltraps (kern/trap/trapentry.S) ->
trap (kern/trap/trap.c) ->
trap_dispatch
__trapret
- trap frame的构造是由硬件和软件共同完成的。
- 当硬件检测到中断时,就会把一些现场信息压入栈中。这里要分两种情况讨论。如果是在内核态发生中断,则不需要切换特权级和栈,这时直接将eflags/cs/eip压入内核栈即可;如果是在用户态发生中断,则需要将特权级由3切换到0,用户栈切换到内核栈,这时需要将ss/esp/eflags/cs/eip压入内核栈。注意:硬件根据TSS段的内容找到内核栈的地址。
- 硬件将一些现场信息压入栈后,根据中断号找到对应的中断处理例程入口(定义在vectors.S文件中),入口处将err code(设置为0)和trap no压入栈中,然后跳到__alltraps
- __alltraps将段寄存器和通用寄存器压栈,从而构造出一个完整的trap frame.
- 整个构造过程,和trap frame的定义是完全吻合的:
struct trapframe {
struct pushregs tf_regs;
uint16_t tf_gs;
uint16_t tf_padding0;
uint16_t tf_fs;
uint16_t tf_padding1;
uint16_t tf_es;
uint16_t tf_padding2;
uint16_t tf_ds;
uint16_t tf_padding3;
uint32_t tf_trapno;
/* below here defined by x86 hardware */
uint32_t tf_err;
uintptr_t tf_eip;
uint16_t tf_cs;
uint16_t tf_padding4;
uint32_t tf_eflags;
/* below here only when crossing rings, such as from user to kernel */
uintptr_t tf_esp;
uint16_t tf_ss;
uint16_t tf_padding5;
} __attribute__((packed));
- 中断处理完成后,进入__trapret。此函数先把栈中保存的trap frame恢复到各寄存器中,然后跳过trap no和error code,执行iret,返回到中断发生时的下一条指令继续执行。
系统调用
-
系统调用初始化:在idt_init函数实现中,可以看到在执行加载中断描述符表lidt指令前,专门设置了一个特殊的中断描述符idt[T_SYSCALL],它的特权级设置为DPL_USER,中断向量处理地址在__vectors[T_SYSCALL]处。这样建立好这个中断描述符后,一旦用户进程执行“INTT_SYSCALL”后,由于此中断允许用户态进程产生(注意它的特权级设置为DPL_USER) ,所以CPU就会从用户态切换到内核态,保存相关寄存器,并跳转到__vectors[T_SYSCALL]处开始执行。
-
系统调用接口封装:在操作系统中初始化好系统调用相关的中断描述符、中断处理起始地址等后,还需在用户态的应用程序中初始化好相关工作,简化应用程序访问系统调用的复杂性。为此在用户态建立了一个中间层,即简化的libc实现,在user/libs/ulib.[ch]和user/libs/syscall.[ch]中完成了对访问系统调用的封装。用户态最终的访问系统调用函数是syscall。
-
下面以read函数执行过程为例,分析系统调用的处理流程。首先在用户态下执行:
safe_read(int fd, void *data, size_t len) ->
read(fd, data, len) ->
sys_read(fd, data, len) ->
syscall(SYS_read, fd, data, len) ->
"int %1;"
: "=a" (ret)
: "i" (T_SYSCALL),
"a" (num),
"d" (a[0]),
"c" (a[1]),
"b" (a[2]),
"D" (a[3]),
"S" (a[4])
: "cc", "memory");
-
执行int指令后,硬件检测到中断,保存ss/esp/eflags/cs/eip后,进入软件处理流程(详见上节):vectors.S -> __alltraps -> trap -> trap_dispatch,最终进入trap_dispatch函数。
-
在trap_dispatch函数中,检查到trapno为系统调用对应的中断号0x80,因此调用syscall函数(注意用户态和内核态下各自定义了同名的syscall函数,但两者实现不同)。syscall函数主要设置好输入参数,然后根据系统调用号(保存在tf的reg_eax字段中),索引函数数组syscalls,找到对应的函数来运行。
int num = tf->tf_regs.reg_eax;
if (num >= 0 && num < NUM_SYSCALLS) {
if (syscalls[num] != NULL) {
arg[0] = tf->tf_regs.reg_edx;
arg[1] = tf->tf_regs.reg_ecx;
arg[2] = tf->tf_regs.reg_ebx;
arg[3] = tf->tf_regs.reg_edi;
arg[4] = tf->tf_regs.reg_esi;
tf->tf_regs.reg_eax = syscalls[num](arg);
return ;
}
}
时钟中断
- 时钟中断初始化:初始化可编程定时器/计数器8253芯片,使其每10ms产生一次时钟中断。
outb(TIMER_MODE, TIMER_SEL0 | TIMER_RATEGEN | TIMER_16BIT);
outb(IO_TIMER1, TIMER_DIV(100) % 256);
outb(IO_TIMER1, TIMER_DIV(100) / 256);
- 时钟中断响应:在trap_dispatch函数中,当检查到trap no等于时钟中断对应的中断号32时,说明发生了时钟中断,此时将全局变量ticks计数加1,并判断其是否能被100整除,若能则说明满1秒了,打印提示信息,并将ticks清零。
键盘中断
-
在80386系统中,通常采用Intel 8042(或8742)单片机作为主机的键盘接口安置在主机上。8042内部含有1个8位的CPU、2个8位的并行端口、2KB ROM、128B RAM、1个输入缓冲寄存器、1个输出缓冲寄存器、1个状态缓冲寄存器、1个命令缓冲寄存器。其中,输出并行端口有一位是输出缓冲器满IRQ,用来向主机发中断请求。
-
键盘中断初始化,主要是使能键盘中断对应的掩码。
-
键盘中断响应:在trap_dispatch函数中,当检查到trap no等于键盘中断对应的中断号33时,说明发生了键盘中断,此时读取键盘输入的字符并打印。
lab 1 知识点
-
I/O端口: 参考io端口与io内存详解
- 定义:CPU与外设之间通过接口部件相连,每个接口部件中都包含一组寄存器,用于CPU与外设之间的数据传输,这些寄存器叫做I/O端口。
- 分类:根据存放内容,可将端口分为数据端口、状态端口和控制端口三类。
- 统一内存空间 vs 独立内存空间:前者是内存和I/O接口统一编址,后者是内存和I/O接口分别编址
- CPU写接口部件的数据端口或状态端口:首先把地址送到地址总线,将确定的控制信息送到控制总线,再把数据送到数据总线。
- CPU读接口部件的数据端口或状态端口:首先把地址送到地址总线,将确定的控制信息送到控制总线,然后等待接口部件将数据送到数据总线。
- 一个双向工作的接口芯片通常有4个端口:数据输入端口、数据输出端口、状态端口和控制端口。由于数据输入端口和状态端口是“只读”的,数据输出端口和控制端口是“只写”的,为节省内存空间,通常将数据输入端口和数据输出端口对应同一个端口地址,状态端口和控制端口对应同一个端口地址。
-
段寄存器:
- The segment selector can be specified either implicitly or explicitly. The most common method of specifying a segment selector is to load it in a segment register and then allow the processor to select the register implicitly, depending on the type of operation being performed. The processor automatically chooses a segment according to the rules given in Table 3-5.
- FS and GS are commonly used by OS kernels to access thread-specific memory. In windows, the GS register is used to manage thread-specific memory. The linux kernel uses GS to access cpu-specific memory.
-
push many register:
- pusha: Push AX, CX, DX, BX, original SP, BP, SI, and DI
- pushal: Push EAX, ECX, EDX, EBX, original ESP, EBP, ESI, and EDI
lab2 源码分析
探测可用的物理内存空间(boot/bootasm.S的probe_memory函数)
通过BIOS中断调用INT 15
来探测可用的物理内存空间,中断调用时需要设置eax等寄存器,返回值也保存在这些寄存器中。最终探测到的物理内存空间如下所示:
e820map:
memory: 0009fc00, [00000000, 0009fbff], type = 1.
memory: 00000400, [0009fc00, 0009ffff], type = 2.
memory: 00010000, [000f0000, 000fffff], type = 2.
memory: 07ee0000, [00100000, 07fdffff], type = 1.
memory: 00020000, [07fe0000, 07ffffff], type = 2.
memory: 00040000, [fffc0000, ffffffff], type = 2.
页目录表和页表的建立(kern/init/entry.S)
-
bootloader加载完内核OS后,进入到内核OS的入口kern_entry
-
创建一个页目录表boot_pgdir。一个页目录表刚好占用一个物理页,其中每个页目录项占用4字节,共有1K个页目录项。在kern/init/entry.S文件中只设置了两个有效页目录项,分别将0 ~ 0x00400000, 0xc0000000 ~ 0xc0400000映射到0 ~ 0x00400000.
-
创建一个页表boot_pt1。在kern/init/entry.S文件中创建了一个页表boot_pt1,并且初始化其中每一个页表项:将其物理地址设置为0~4M物理内存中对应的物理页的地址,并且标记对应物理页的属性为已存在(PTE_P)并且可写(PTE_W)。
-
kern_entry的开头将页目录表的地址加载到cr3寄存器,并设置cr0寄存器以使能保护模式。
-
设置好cr0和cr3寄存器后,将页目录表的第一项清零,以取消虚拟地址04M到物理地址04M的映射。(为什么要取消这个映射?)
物理内存初始化(pmm_init)
-
通过查看memmap结构体的内容,来确认最大可用物理内存地址maxpa。gdb调试时观察到maxpa = 0x07fe0000.
-
创建一个pages数组,其起始位置为内核文件的.bss段的后面(亦即end对应的地址),元素数目npage为0~maxpa这段内存空间对应的页数。并且将freemem设置为空闲内存的起始位置。gdb调试观察到npage=0x7fe0,freemem=0x001bc000.
-
将pages数组的每个元素的reserved标志位均设置为1,即将每一页初始化为预留给内核使用,后面会重新设置可用的物理页的reserved标志位为0.
-
从freemem对应的地址开始寻找所有可用的空闲内存块。由上文可知探测到的可用物理内存共2块,范围分别为0x00x0009fc00和0x001000000x07fe0000.由于第一个内存块在freemem前面,因此实际上只找到一块空闲物理内存,范围是0x00100000~0x07fe0000。在这块空闲物理内存的第一页对应的Page结构中设置页数等属性,并添加到空闲列表free_list中。
页表项的创建与删除
-
pmm_init中调用boot_map_segment函数,为0xc0000000~0xf8000000这段虚拟地址的每个虚拟页创建对应的页目录项和页表项,从而将0~0x38000000这段物理内存映射到0xc0000000~0xf8000000.由于entry.S只创建了一个页表,当一个虚拟页的起始地址找不到对应的页表时,需要分配一个物理页来建立对应的页表。这个是在get_pte函数中实现的。
-
get_pte函数根据虚拟地址返回对应的页表项。首先根据虚拟地址的最高10位索引页目录表,确认对应的页表是否存在。若存在,则根据虚拟地址的中间10位,返回该页表中对应页表项的地址;若不存在,则先分配一个物理页作为页表,再返回该页表项的地址。
-
page_remove_pte根据虚拟地址删除对应的页表项。如果对应的页表项指向的物理页的引用计数减至0,则释放对应物理页。然后将对应页表项清零以表示无效。
采用FFMA算法的物理内存管理器
分配内存 alloc_pages
-
首先判断待申请的页数n是否大于空闲页的总数nr_free,若是则返回NULL
-
遍历空闲列表free_list,若无法找到一个页数不小于n的空闲内存块,则返回NULL
-
若找到的空闲块的页数刚好等于n,则直接从free_list删除当前空闲块的page_link;若空闲的页数大于n,则在free_list中当前空闲块的Page_link后面先插入一个新的page_link,其对应的内存块的页数为当前空闲块的页数减去n,然后再删除当前空闲块的page_link。
注:这里先插入新page_link再删除当前page_link的原因是:为了保证空闲列表中的内存块按照地址从小到大排序,需要将当前空闲块分配n页剩余的空闲块仍插回到原位置,如果先删除当前空闲块,将无法直接得到插入位置,因此先插入再删除。
- 找到空闲块后,还要将其Property属性清零,并更新空闲页的数目nr_free(减少n页)
释放内存 free_pages
-
遍历待释放内存块的每一页,将其flags和property清零
-
遍历空闲列表,若找到与待释放内存块相邻的内存块,则将它们合并。注意一共有4种情况:仅左边有相邻空闲块、仅右边有相邻空闲块、左右均有相邻空闲块、左右均无相邻空闲块。
-
释放内存块后,还需要更新空闲页的数目nr_free(增加n页)
lab2 疑问
- load_esp0的作用是啥?esp0是啥?gdt_init中为啥要load_esp0?
- load_esp0的实现是
ts.ts_esp0 = esp0;
,可见是设置trap frame中的esp0字段。esp0是指内核态下(此时特权级为0)的esp寄存器的值。如果在用户态下发生中断,会将特权级由3切换到0,这时CPU需要知道特权级0下的内核栈在哪里,而trap frame提供了这个信息。因此需要提前初始化trap frame,并在gdt中设置好TSS段描述符,方便找到trap frame;为了加速寻找trap frame的过程,CPU还专门提供一个寄存器TR来保存TSS的段地址,因此在初始化时也要提前将TSS的段地址加载到TR寄存器中。
- load_esp0的实现是
lab3 源码分析
alloc_pages
疑问:为什么判断到n > 1或swap_init_ok == 0时退出while循环?
if (page != NULL || n > 1 || swap_init_ok == 0) break;
check_vma_struct
- mm_struct
// the control struct for a set of vma using the same PDT
struct mm_struct {
list_entry_t mmap_list; // linear list link which sorted by start addr of vma
struct vma_struct *mmap_cache; // current accessed vma, used for speed purpose
pde_t *pgdir; // the PDT of these vma
int map_count; // the count of these vma
void *sm_priv; // the private data for swap manager
};
- vma_struct
// the virtual continuous memory area(vma), [vm_start, vm_end),
// addr belong to a vma means vma.vm_start<= addr <vma.vm_end
struct vma_struct {
struct mm_struct *vm_mm; // the set of vma using the same PDT
uintptr_t vm_start; // start addr of vma
uintptr_t vm_end; // end addr of vma, not include the vm_end itself
uint32_t vm_flags; // flags of vma
list_entry_t list_link; // linear list link which sorted by start addr of vma
};
-
vma_struct是一个连续的虚拟内存块,mm_struct是一系列连续的内存块的集合,而且这些内存块使用的是同一个页目录表。通过往mm_struct的mmap_list链表添加或删除vma_struct的list_link来实现对vma的管理。
-
insert_vma_struct实现以下功能:在mm->mmap_list链表中找到vm_start不大于vma的vm_start的最后一个节点,把vma插到该节点后面,以保证mmap_list仍然按照地址从小到大排序。此外,在插入时还要检查vma之间地址不重叠。
check_pgfault
-
创建一个mm,将其页目录表地址设置为boot_pgdir的地址
-
创建一个vma,虚拟地址范围为0~4M,然后将其插入到mm的mmap_list中
-
访问虚拟地址为0x1000x163的内存空间,这时由于页目录表中不存在虚拟地址为04M的页目录项,换言之尚未建立虚拟地址为0~4M到物理地址的映射,因此会导致缺页异常。
缺页异常处理流程
-
CPU访问的虚拟地址尚未与物理地址建立映射,从而导致缺页异常。比如check_pgfault中访问虚拟地址0x100.
-
缺页异常对应的中断向量号为14,CPU根据中断向量表得到中断向量号14对应的中断处理例程。
-
其实所有中断处理例程都是先将中断号入栈,然后跳转到alltraps函数。alltraps函数只是将通用寄存器和段寄存器等入栈,然后调用trap函数。trap函数直接调用trap_dispatch函数。
-
trap_dispatch函数根据不同中断号trapno进行不同的处理。当判断到中断号为T_PGFLT(14)时,则调用pgfault_handler函数。
-
pgfault_handler先调用print_pgfault打印缺页异常信息,包括
- 缺页原因:no page found(找不到物理页面)、protection fault(比如特权级检查失败)
- 是在读内存还是写内存时出现缺页异常
- 是在内核态还是用户态下访问内存时出现缺页异常
-
pgfault_handler然后调用do_pgfault进行具体的缺页异常处理。注意cr2寄存器保存引起缺页异常的内存地址。
check_swap
-
备份内存环境变量,包括空闲内存块数目count、空闲页面数目total。
-
创建mm和vma,其中vma对应的虚拟地址范围为0x1000~0x6000,共5个虚拟页面。
-
为虚拟地址0x1000建立页表,其间需要申请一个物理页。
setup Page Table for vaddr 0X1000, so alloc a page setup Page Table vaddr 0~4MB OVER!
-
申请分配4个物理页,然后又将其释放。
-
check_content_set:分别访问a,b,c,d等4个虚拟页,每个虚拟页访问两个地址。访问每个虚拟页的第一个地址时,由于该虚拟页尚未与物理页建立映射,导致缺页异常。在缺页异常处理函数中,申请分配物理页,填写页表,建立虚拟页到物理页的映射后,重新访问一次虚拟页就能成功了。
set up init env for check_swap begin! page fault at 0x00001000: K/W [no page found]. page fault at 0x00002000: K/W [no page found]. page fault at 0x00003000: K/W [no page found]. page fault at 0x00004000: K/W [no page found]. set up init env for check_swap over!
-
检查页表项是否正确建立
-
fifo_check_swap:检查页面置换是否成功。依次访问页面c,a,d,b,e等5个虚拟页,当访问到虚拟页e时,同样由于该虚拟页尚未与物理页建立映射,导致缺页异常。在缺页异常处理函数,申请分配物理页。但由于物理页只有4个,且已经分别与虚拟页a,b,c,d建立映射,无法为虚拟页e申请物理页,这时需要将一个物理页换出到磁盘中。
write Virt Page c in fifo_check_swap write Virt Page a in fifo_check_swap write Virt Page d in fifo_check_swap write Virt Page b in fifo_check_swap write Virt Page e in fifo_check_swap page fault at 0x00005000: K/W [no page found].
-
swap_out:首先选出要置换的页面,将其内容写回到磁盘(疑问:无论页面是否修改都进行写回,这样是否必要?)。根据FIFO算法,将最早进入物理内存的虚拟页a写到swap区域2.
swap_out: i 0, store page in vaddr 0x1000 to disk swap entry 2
-
swap_in:当再次访问虚拟页a时,发现物理页已用完且找不到a对应的物理页。因此首先根据FIFO算法,将当前最早进入物理内存的虚拟页b写到swap区域3(疑问:怎么确保swap区域3尚未被占用?),再从swap区域2将虚拟页a读入到物理内存。
页面置换流程
- alloc_pages函数中当申请物理页失败,而且申请页数为1(不明白为什么设置这个条件?)、swap区域初始化完成时,需要调用swap_out将部分物理页换出到磁盘。
lab4 源码分析
第一个内核线程idleproc的创建过程
-
idleproc线程其实直接沿用正在运行的内核程序。当然,要让当前的内核程序名正言顺地成为一个线程,需要做些注册之类的事情:分配一个线程控制块,并设置好其中的信息,包括标识信息、控制信息等。比如,
- 将pid设置为0,表明这是第一个线程
- name设置为“idle”
- state设置为RUNNABLE,因为当前正在运行的进程就是idleproc
- kstack设置为bootstack
- need_resched设置为1,表示需要调度,因为idleproc的任务就是不断检查是否有可以运行的进程,一旦发现有的话立即让CPU运行该进程。
-
为什么idleproc不需要设置进程现场保存信息(context和tf)?
-
为什么idleproc不需要添加到进程链表proc_list和哈希链表hash_list中?
第二个内核线程init_main的创建过程
-
第二个内核线程initproc是由idleproc线程在proc_init函数中调用kernel_thread函数来创建的,调用kernel_thread函数时,传入的前两个参数分别为init_main线程的处理函数地址init_main及其输入参数"Hello world!!"
-
同理,initproc的创建过程首先也是调用alloc_proc申请分配一个线程控制块,然后可以设置各种信息:
- state设置为UNINIT,因为initproc此时正在初始化过程中
- cr3设置为内核页目录表boot_pgdir,mm暂时不用设置,因为所有内核线程共用相同的虚拟地址空间,因此直接使用已经建立好的内核虚拟内存空间即可
- need_resched设置为0,表示不需要调度,为什么?(猜想:need_resched设置为1实际上意味着主动让出CPU,一般内核线程不会主动让出CPU,而是等到阻塞、分配的时间片用完或执行完毕后才让出CPU)
- parent设置为idleproc,表明initproc是由idleproc fork出来的
- 申请分配2个物理页作为内核栈空间,并将其地址赋给kstack
- 设置context和trap frame变量(有点复杂,下面再展开论述)
- 调用get_pid来分配一个pid,其值为1
- name设置为“init”
-
设置context和tf:这两个变量用于线程状态保存与恢复。注意:由于initproc仍然在内核态运行,因此tf的cs设置为内核代码段的段选择子,tf的ds/es/ss均设置为内核数据段的段选择子。
// kernel_thread:
tf.tf_cs = KERNEL_CS;
tf.tf_ds = tf.tf_es = tf.tf_ss = KERNEL_DS;
tf.tf_regs.reg_ebx = (uint32_t)fn;
tf.tf_regs.reg_edx = (uint32_t)arg;
tf.tf_eip = (uint32_t)kernel_thread_entry;
// copy_thread:
proc->tf->tf_regs.reg_eax = 0;
proc->tf->tf_esp = esp;
proc->tf->tf_eflags |= FL_IF;
proc->context.eip = (uintptr_t)forkret;
proc->context.esp = (uintptr_t)(proc->tf);
-
加入proc_list和hash_list:后面调度器是通过遍历proc_list来挑选下一个要运行的线程的,因此需要将initproc先插入到proc_list和hash_list中。注:hash_list用于find_proc函数中根据pid快速找到对应的进程控制块,因此这里也要将initproc加入hash_list。
-
完成初始化后,将proc->state设置为RUNNABLE,从而唤醒该进程。
第一次线程切换 idleproc -> initproc
-
当内核线程idleproc从kern_init的开头运行到最后一个函数cpu_idle时,发生第一次线程切换,由idleproc切换到initproc
-
首先看cpu_idle的实现,其实就是在死循环里不断查询当前线程是否需要调度,如果需要,则进入schedule函数进行调度,即选择其他线程来运行。
while (1) {
if (current->need_resched) {
schedule();
}
}
-
为什么schedule函数开头要设置
current->need_resched = 0;
?答:避免循环调用schedule函数。 -
schedule按照FIFO的顺序来选择下一个将要运行的线程。具体而言,如果当前正在运行的线程是idleproc,则从线程链表的开头开始搜索;否则从当前线程的下一个元素开始搜索,一旦找到一个state为RUNNABLE,立即退出搜索,并运行之。如果找不到,则继续运行idleproc。
-
来看看找不到RUNNABLE的线程时会发生什么:
- 假设ucore内核中只有一个线程idleproc,idleproc一路运行到cpu_idle。
- 由于idleproc的need_resched标志设置为1,因此进入schedule函数。
- schedule函数开头将idleproc的need_resched标志设置为0,然后搜索proc_list,没搜到目标,因此退出schedule。
- 返回到cpu_idle后,由于cpu_idle是个死循环,而且current->need_resched已经设置为0,因此ucore会一直在cpu_idle函数中执行死循环。
- 刚测试了一下,把wakeup initproc的语句注释后再执行,确实是死循环,不过每隔100 ticks会触发一次时钟中断,并在控制台上打印“100 ticks”
-
如果前面成功创建了initproc线程,schedule从proc_list中搜索第一个元素便能找到initproc,然后调用proc_run来运行新进程,具体分为3步:设置新进程的栈指针esp、加载新进程的页目录表地址到cr3、切换进程上下文。
load_esp0(next->kstack + KSTACKSIZE);
lcr3(next->cr3);
switch_to(&(prev->context), &(next->context));
-
切换上下文时,把eip和esp的值分别设置为initproc->context.eip和initproc->context.esp,然后跳转到context.eip指定的函数地址,也就是forkret
-
forkret把栈指针指向initproc->tf,然后通过pop指令将tf保存的内容依次赋给各寄存器,接着通过iret指令跳转到tf_eip指定的入口kernel_thread_entry
-
kernel_thread_entry先将输入变量压栈,然后通过call指令跳转到ebx指向的函数fn,也就是init_main。init_main函数主要是打印一些字符串信息。执行完init_main后,回到kernel_thread_entry函数,先将返回值压栈,然后调用do_exit函数。
kernel_thread_entry: # void kernel_thread(void)
pushl %edx # push arg
call *%ebx # call fn
pushl %eax # save the return value of fn(arg)
call do_exit # call do_exit to terminate current thread
- do_exit函数调用panic函数,panic函数打印调用栈信息,然后循环调用kmonitor函数,进入内核调试界面,不断等待用户输入命令并执行。
cprintf("kernel panic at %s:%d:\n ", file, line);
vcprintf(fmt, ap);
cprintf("\n");
cprintf("stack trackback:\n");
print_stackframe();
va_end(ap);
panic_dead:
intr_disable();
while (1) {
kmonitor(NULL);
}
- 因此,从idleproc线程切换到initproc线程后,最终会一直停留在到内核调试界面,不会再进行线程切换。
lab5 源码分析
第二次进程切换 initproc -> userproc
-
内核线程initproc在创建完成用户态进程userproc后,调用do_wait函数,do_wait函数在确认存在RUNNABLE的子进程后,调用schedule函数。
-
schedule函数通过调用proc_run来运行新线程,proc_run做了三件事情:
- 设置userproc的栈指针esp为userproc->kstack + 2 * 4096,即指向userproc申请到的2页栈空间的栈顶
- 加载userproc的页目录表。用户态的页目录表跟内核态的页目录表不同,因此要重新加载页目录表
- 切换进程上下文,然后跳转到userproc->context.eip指向的函数,即forkret
-
forkret函数直接调用forkrets函数,forkrets先把栈指针指向userproc->tf的地址,然后跳到__trapret
-
__trapret先将userproc->tf的内容pop给相应寄存器,然后通过iret指令,跳转到userproc->tf.tf_eip指向的函数,即kernel_thread_entry
-
kernel_thread_entry先将edx保存的输入参数(NULL)压栈,然后通过call指令,跳转到ebx指向的函数,即user_main
-
user_main先打印userproc的pid和name信息,然后调用kernel_execve
-
kernel_execve执行exec系统调用,CPU检测到系统调用后,会保存eflags/ss/eip等现场信息,然后根据中断号查找中断向量表,进入中断处理例程。这里要经过一系列的函数跳转,才真正进入到exec的系统处理函数do_execve中:vector128 -> __alltraps -> trap -> trap_dispatch -> syscall -> sys_exec -> do_execve
-
do_execve首先检查用户态虚拟内存空间是否合法,如果合法且目前只有当前进程占用,则释放虚拟内存空间,包括取消虚拟内存到物理内存的映射,释放vma,mm及页目录表占用的物理页等。
-
调用load_icode函数来加载应用程序
- 为用户进程创建新的mm结构
- 创建页目录表
- 校验ELF文件的魔鬼数字是否正确
- 创建虚拟内存空间,即往mm结构体添加vma结构
- 分配内存,并拷贝ELF文件的各个program section到新申请的内存上
- 为BSS section分配内存,并初始化为全0
- 分配用户栈内存空间
- 设置当前用户进程的mm结构、页目录表的地址及加载页目录表地址到cr3寄存器
- 设置当前用户进程的tf结构
-
load_icode返回到do_exevce,do_execve设置完当前用户进程的名字为“exit”后也返回了。这样一直原路返回到__alltraps函数时,接下来进入__trapret函数
-
__trapret函数先将栈上保存的tf的内容pop给相应的寄存器,然后跳转到userproc->tf.tf_eip指向的函数,也就是应用程序的入口(exit.c文件中的main函数)。注意,此处的设计十分巧妙:__alltraps函数先将各寄存器的值保存到userproc->tf中,接着将userproc->tf的地址压入栈后,然后调用trap函数;trap返回后再将current->tf的地址出栈,最后恢复current->tf的内容到各寄存器。这样看来中断处理前后各寄存器的值应该保存不变。但事实上,load_icode函数清空了原来的current->tf的内容,并重新设置为应用进程的相关状态。这样,当__trapret执行iret指令时,实际上跳转到应用程序的入口去了,而且特权级也由内核态跳转到用户态。接下来就开始执行用户程序(exit.c文件的main函数)啦。
-
执行完用户程序后,继续原路返回到kernel_thread_entry函数。接下来将保存在eax的返回值压栈,然后调用do_exit。
第三次进程切换 userproc -> initproc
-
上文说到userproc调用do_exit函数,接着往下分析。do_exit函数首先释放userproc占用的内存空间,包括取消虚拟地址到物理地址的映射,释放mm、vma、pgdir等占用的内存。然后唤醒内核线程initproc,接着调用schedule函数,这时由于userproc的state已经设置为ZOMBIE,因此只能选择initproc来运行。
-
切换进程上下文后,会回到initproc之前执行的do_wait函数。do_wait函数返回到repeat标签,重新找到子进程userproc,检查到其状态为ZOMBIE后,将其从hash_list和proc_list中删除,并释放userproc对应的内核栈空间和进程控制块空间,此时用户进程userproc彻底over了。最后do_wait返回0.
-
由于do_wait返回0,init_main再次执行schedule,由于proc_list中只有initproc,因此还是继续调用do_wait,这次由于找不到子进程,最终返回-E_BAD_PROC,从而退出init_main中的while循环。
-
init_main打印一些字符串信息,然后返回。
-
执行完init_main函数后,会返回到kernel_thread_entry,先将保存在eax的返回值压栈,然后调用do_exit。
-
do_exit判断到当前进程为initproc后,调用panic函数打印一些字符串,然后停留在内核调试界面,等待用户输入命令。
创建新进程时的内存拷贝过程(copy_mm)
-
调用mm_create分配一个mm结构体并初始化
-
调用setup_pgdir分配一个物理页,作为页目录表的内存空间,并拷贝内核页目录表的内容到新页目录表,从而建立内核虚拟地址空间。
-
调用dup_mmap,首先复制父进程的vma链表到子进程的mm->mmap_list中,然后调用copy_range将父进程使用到的虚拟内存空间的全部内容拷贝到子进程。
lab5 疑问
-
为什么用户进程需要加载ELF文件来执行,内核线程则不需要?
-
user_main执行的binary文件是什么?
- 执行
sudo make qemu
时,不会定义TEST,因此走的是else分支,KERNEL_EXECVE宏中又包含__KERNEL_EXECVE宏,exit在这个宏中被修饰为_binary_obj___user_exit_out_start,也就是说用户进程将要执行的程序的地址保存在_binary_obj___user_exit_out_start中。可是,我找遍整个目录下的所有文件,都找不到这个变量被定义的地方,why? - 经过分析Makefile内容、make生成结果以及ucore_os_docs的以下说明:“且ld命令会在kernel中会把__user_hello.out的位置和大小记录在全局变量_binary_obj___user_hello_out_start和_binary_obj___user_hello_out_size中”,终于明白了:首先Makefile中定义的命令会将user目录下的代码文件编译并链接到kernel中,并且ld链接器还会将这些文件的地址记录在相应的变量中,比如obj/__user_exit.out文件的地址记录在_binary_obj___user_exit_out_start中。这个变量名为啥这么奇怪?这应该是链接器ld的名字修饰机制决定的。
- 执行
#define KERNEL_EXECVE(x) ({ \
extern unsigned char _binary_obj___user_##x##_out_start[], \
_binary_obj___user_##x##_out_size[]; \
__KERNEL_EXECVE(#x, _binary_obj___user_##x##_out_start, \
_binary_obj___user_##x##_out_size); \
})
-
exit.c文件中的main函数为啥子进程反复七次调用yield?
-
proc.c文件中load_icode函数在拷贝ELF的section时为啥不一次性拷完,而是逐页拷贝?是因为本来就只设计了分配一页的接口吗?
-
kmalloc/kfree函数看不明白?spin_lock, slob是什么?
-
do_exit尚未看明白?cptr,optr和yptr的设置?
-
lock_mm和unlock_mm看不明白?
-
user目录下各个文件的功能是什么?
lab5 知识点
-
TSS: The processor switches to a new stack to execute the called procedure. Each privilege level has its own stack. The segment selector and stack pointer for the privilege level 3 stack are stored in the SS and ESP registers, respectively, and are automatically saved when a call to a more privileged level occurs. The segment selectors and stack pointers for the privilege level 2, 1, and 0 stacks are stored in a system segment called the task state segment (TSS).
- 为什么TSS中只有ESP和SS区分特权级,而其他寄存器不用区分?
- 为什么特权级切换时需要切换栈?为什么要区分用户栈和内核栈?
-
中断帧的指针,总是指向内核栈的某个位置:当进程从用户空间跳到内核空间时,中断帧记录了进程在被中断前的状态。当内核需要跳回用户空间时,需要调整中断帧以恢复让进程继续执行的各寄存器值。除此之外,uCore内核允许嵌套中断。因此为了保证嵌套中断发生时tf 总是能够指向当前的trapframe,uCore 在内核栈上维护了 tf 的链,可以参考trap.c::trap函数做进一步的了解。
lab6 源码分析
Stride Scheduling
- sched_init中将sched_clasee设置为stride_sched_class,表明使用Stride scheduling。stride_sched_class的结构体定义如下:
struct sched_class stride_sched_class = {
.name = "stride_scheduler",
.init = stride_init,
.enqueue = stride_enqueue,
.dequeue = stride_dequeue,
.pick_next = stride_pick_next,
.proc_tick = stride_proc_tick,
};
-
何时设置进程的优先级?
-
如何设置BIG_STRIDE?
lab6 疑问
-
为什么ucore进入kmonitor后就不进行时钟中断处理了?
- 答:initproc -> do_exit -> panic -> irq_disable,这里把IRQ中断屏蔽掉了。
-
没理解stride溢出问题的解决方案?使用无符号整数表示,但比较时又看作是有符号?
-
我的PC是如何运行多进程的?我的PC含有一个四核CPU,跑多进程时,会把进程分到四个核上面跑吗?
-
sudo make debug
为什么能加载并执行priority.c文件?是在哪里设置TEST=priority的?
lab7 源码分析
定时器
- 设计很巧妙:N个timer依次插入链表,插入时需要调整expires。这个调整很巧妙。但是,如果两个定时器的expires相同怎么办?
lab8 源码分析
文件系统初始化流程
- 文件系统的初始化分为三部分,分别是vfs、dev和sfs的初始化。
kern_init ->
fs_init ->
vfs_init
dev_init
sfs_init
- vfs的初始化
- 初始化信号量bootfs_sem,将其value设置为1
- 初始化vdev_list为空链表,初始化信号量vdev_list_sem,将其value设置为1
void vfs_init(void) {
sem_init(&bootfs_sem, 1);
vfs_devlist_init();
}
- dev的初始化:包括stdin、stdout和disk0等三部分的初始化
void dev_init(void) {
init_device(stdin);
init_device(stdout);
init_device(disk0);
}
- stdin的初始化
- 分配一个inode并初始化,将其函数表设置为dev_node_ops
- 初始化inode里面的devive,将其函数表分别设置为stdin相关的函数,包括stdin_open, stdin_close, stdin_io和stdin_ioctl等
- 分配一个vfs_dev_t,设置好其中的devname,devnode和fs等成员,然后添加到vdev_list
void dev_init_stdin(void) {
struct inode *node;
if ((node = dev_create_inode()) == NULL) {
panic("stdin: dev_create_node.\n");
}
stdin_device_init(vop_info(node, device));
int ret;
if ((ret = vfs_add_dev("stdin", node, 0)) != 0) {
panic("stdin: vfs_add_dev: %e.\n", ret);
}
}
-
stdout,disk0的初始化与stdin类似,只是在初始化inode里面的device时挂的函数表不同。
-
sfs的初始化:调用sfs_mount将sfs挂载到disk0。(疑问:disk0是什么玩意?)
sfs_init ->
sfs_mount ->
vfs_mount ->
sfs_do_mount
- sfs函数表的初始化
sfs_lookup ->
sfs_namefile ->
sfs_lookup_once ->
sfs_do_mount ->
sfs_get_root ->
sfs_load_inode ->
sfs_create_inode ->
vop_init(sfs_get_ops) ->
sfs_node_dirops
用户执行open的详细流程
关键问题在于:如何根据路径名找到文件在磁盘上的位置。
- 从用户调用open接口到触发系统调用
open (user/libs/file.c) -> // 用户接口
sys_open (user/libs/syscall.c) -> // 封装系统调用接口给用户
syscall(SYS_open, path, open_flags) -> // 系统调用统一实现接口,根据不同系统调用号从函数表中找到相应处理函数
sys_open (kern/syscall/syscall.c) -> // 发生open系统调用的实际处理接口
sysfile_open (kern/fs/sysfile.c) // VFS提供给系统调用的接口
- 找到文件所在目录对应的inode节点
sysfile_open (kern/fs/sysfile.c) ->
file_open (kern/fs/file.c) ->
vfs_open (kern/fs/vfs/vfsfile.c) -> // 根据文件名获取或生成一个inode
vfs_lookup (kern/fs/vfs/vfsloopup.c) ->
get_device -> // 根据文件名获取对应的inode
vop_lookup (kern/fs/vfs/inode.h) ->
sfs_lookup (kern/fs/sfs/sfs_inode.c)
vop_open (kern/fs/vfs/inode.h) ->
sfs_opendir (kern/fs/sfs/sfs_inode.c)
- 当前目录是何时与对应的inode绑定的?答:在dev_init时已经为disk0分配inode,并将该信息记录在vfs_dev_t结构体,再添加到vfs_list。然后在内核线程initproc执行init_main时,
init_main ->
vfs_set_bootfs("disk0:") -> // 设置当前目录对应的inode为disk0
vfs_chdir("disk0:") ->
vfs_lookup("disk0:") ->
get_device("disk0:") ->
vfs_get_root // 由于初始化时已将disk0的vfs_dev_t结构添加到vdev_list中,这里遍历链表即可找到对应的inode
vfs_set_curdir ->
set_cwd_nolock
- 根据目录的inode及文件名找到文件的inode
- inode->fs->sfs_fs:sfs_buffer提供数据缓冲区、dev提供block数目信息、hash_list提供inode链表信息
- inode->sfs_inode->sfs_disk_inode:含有block数目、block内容等信息
- 查找文件inode流程简析:首先根据inode->sfs_inode->sfs_disk_inode->blocks得知目录inode的block数目,然后遍历每个block,读取每个block的sfs_disk_entry信息,将sfs_disk_entry->name与文件名对比,若相同,则对应的sfs_disk_entry->ino即为文件的inode号。
- 根据ino读取inode内容流程:inode->fs->sfs_fs->hash_list记录有disk0所有inode的信息,首先用ino索引哈希表得到一个链表,再遍历该链表,找到inode号等于ino的节点。
sfs_lookup (kern/fs/sfs/sfs_inode.c) ->
sfs_lookup_once ->
sfs_dirent_search_nolock -> // 读取当前目录下的每一个file entry,搜索与文件名name匹配的entry
sfs_dirent_read_nolock -> // 根据当前目录的inode及slot找到相应entry并读取其内容
sfs_bmap_load_nolock ->
sfs_bmap_get_nolock
sfs_rbuf ->
sfs_rwblock_nolock ->
dop_io -> disk0_io ->
disk0_read_blks_nolock ->
ide_read_secs
sfs_load_inode ->
lookup_sfs_nolock
疑问:
- 如何根据路径找到文件所在的磁盘位置?
- disk0对应的inode的fs为空指针?
- 一个文件可能由多个block组成,如何读取每个block的内容?如何读取sfs_disk_entry?
- sfs_dirent_search_nolock中判断到entry->ino为0则continue,为什么?ino不能为0吗?
- 根据inode内容设置file的读写权限、当前读写位置、设置文件的status为OPENED,然后返回file->fd.
用户执行read的详细流程
- 从用户调用read到进入系统调用
read ->
sys_read ->
syscall(SYS_read) ->
sys_read ->
sysfile_read
- 根据fd索引fd_array找到对应的file,从file->inode得到对应的inode.
sysfile_read ->
file_read ->
fd2file
iobuf_init
vop_read -> sfs_read
iobuf_used
copy_to_user
- 根据文件的inode读取文件内容
sys_read ->
sfs_io ->
sfs_io_nolock
疑问:
- 如何根据inode读取数据?
用户执行write的详细流程(自上而下,谁访问谁)
- 通用文件系统访问接口
write (user/libs/file.c) ->
sys_write (user/libs/syscall.c) ->
syscall(SYS_write) (user/libs/syscall.c) ->
sys_write (kern/syscall/syscall.c) ->
sysfile_write (kern/fs/sysfile.c)
- 文件系统抽象层 - VFS
sysfile_write (kern/fs/sysfile.c) ->
file_write (kern/fs/file.c) ->
vop_write (kern/fs/vfs/inode.h) <=> __vop_op(node, write) ->
sys_write (kern/fs/sfs/sfs_inode.c)
- Simple FS文件系统
sfs_write (kern/fs/sfs/sfs_inode.c) ->
sfs_io (kern/fs/sfs/sfs_inode.c) -> sfs_io_nolock ->
sfs_wbuf (kern/fs/sfs/sfs_io.c) ->
sfd_rwblock_nolock (kern/fs/sfs/sfs_io.c) ->
dop_io (kern/fs/devs/dev.h)
- 文件系统I/O设备
dop_io (kern/fs/devs/dev.h) -> dev->d_io ->
disk0_io (kern/fs/devs/dev_disk0.c) ->
disk0_write_blks_nolock (kern/fs/devs/dev_disk0.c) ->
ide_write_secs (kern/driver/ide.c) ->
outsl (kern/driver/ide.c)
文件系统相关数据结构(自上而下,谁包含谁)
-
进程控制块的结构体proc_struct包含文件控制信息结构体files_struct
-
files_struct结构体定义如下
struct files_struct {
struct inode *pwd; // inode of present working directory
struct file *fd_array; // opened files array
int files_count; // the number of opened files
semaphore_t files_sem; // lock protect sem
};
- inode是文件的抽象表示。其结构体定义如下。
- device包含
- 两个变量d_blocks、d_blocksize,标记文件块数目和大小
- 四个函数指针,分别对应open,close和io操作
- sfs_inode含有inode序号、文件大小、文件块的数目及位置等信息
- inode_ops包含一堆vop层的函数指针
- fs指向一个抽象文件系统
- inode如何与文件绑定?代码中定义了sfs_disk_entry结构体,将inode号ino与文件名name绑定在一起。
- device包含
struct inode {
union {
struct device __device_info;
struct sfs_inode __sfs_inode_info;
} in_info;
enum {
inode_type_device_info = 0x1234,
inode_type_sfs_inode_info,
} in_type;
int ref_count;
int open_count;
struct fs *in_fs;
const struct inode_ops *in_ops;
};
struct sfs_disk_entry {
uint32_t ino; /* inode number */
char name[SFS_MAX_FNAME_LEN + 1]; /* file name */
};
- sfs_inode结构体定义如下,其中sfs_disk_inode的内容跟硬盘中保存的inode信息基本一致。
struct sfs_inode {
struct sfs_disk_inode *din; /* on-disk inode */
uint32_t ino; /* inode number */
bool dirty; /* true if inode modified */
int reclaim_count; /* kill inode if it hits zero */
semaphore_t sem; /* semaphore for din */
list_entry_t inode_link; /* entry for linked-list in sfs_fs */
list_entry_t hash_link; /* entry for hash linked-list in sfs_fs */
};
- sfs_disk_inode的定义如下。标记文件块的数目及位置。注意这里使用了二级索引。
struct sfs_disk_inode {
uint32_t size; /* size of the file (in bytes) */
uint16_t type; /* one of SYS_TYPE_* above */
uint16_t nlinks; /* # of hard links to this file */
uint32_t blocks; /* # of blocks */
uint32_t direct[SFS_NDIRECT]; /* direct blocks */
uint32_t indirect; /* indirect blocks */
};
- fs结构体定义如下
struct fs {
union {
struct sfs_fs __sfs_info;
} fs_info; // filesystem-specific data
enum {
fs_type_sfs_info,
} fs_type; // filesystem type
int (*fs_sync)(struct fs *fs); // Flush all dirty buffers to disk
struct inode *(*fs_get_root)(struct fs *fs); // Return root inode of filesystem.
int (*fs_unmount)(struct fs *fs); // Attempt unmount of filesystem.
void (*fs_cleanup)(struct fs *fs); // Cleanup of filesystem.???
};
- sfs_fs结构体定义如下
struct sfs_fs {
struct sfs_super super; /* on-disk superblock */
struct device *dev; /* device mounted on */
struct bitmap *freemap; /* blocks in use are mared 0 */
bool super_dirty; /* true if super/freemap modified */
void *sfs_buffer; /* buffer for non-block aligned io */
semaphore_t fs_sem; /* semaphore for fs */
semaphore_t io_sem; /* semaphore for io */
semaphore_t mutex_sem; /* semaphore for link/unlink and rename */
list_entry_t inode_list; /* inode linked-list */
list_entry_t *hash_list; /* inode hash linked-list */
};
- file结构体定义如下
struct file {
enum {
FD_NONE, FD_INIT, FD_OPENED, FD_CLOSED,
} status;
bool readable;
bool writable;
int fd;
off_t pos;
struct inode *node;
Simple FS分析(自下而上,从硬盘到内存,谁包含谁)
参考资料
- 《微型计算机技术及应用(戴梅萼)》