ucore lab1 操作系统启动过程 学习笔记
开头赞美THU给我们提供了这么棒的资源.难是真的难,好也是真的好.这种广查资料,反复推敲,反复思考从通电后第一条代码搞起来理顺一个操作系统源码的感觉是真的爽.
1. 操作系统镜像文件ucore.img是如何一步一步生成的?
这makefile文件逻辑简略着看都能明白,仔细了瞧却处处有疑问,有的地方还用到了二重展开.对于初学者来讲,细读这东西太痛苦了,还是简略着读吧.
# create kernel target
kernel = $(call totarget,kernel)
$(kernel): tools/kernel.ld
$(kernel): $(KOBJS)
@echo + ld $@
$(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS)
@$(OBJDUMP) -S $@ > $(call asmfile,kernel)
@$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,kernel)
$(call create_target,kernel)
kernel赋值为"bin/kernel"
执行toos/kernel.ld链接脚本
编译kernel\下的所有.s和.c文件
# create bootblock
bootfiles = $(call listf_cc,boot)
$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))
编译boot\下的所有C文件
bootblock = $(call totarget,bootblock)
$(bootblock): $(call toobj,$(bootfiles)) | $(call totarget,sign)
@echo + ld $@
$(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 $^ -o $(call toobj,bootblock)
@$(OBJDUMP) -S $(call objfile,bootblock) > $(call asmfile,bootblock)
@$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)
@$(call totarget,sign) $(call outfile,bootblock) $(bootblock)
$(call create_target,bootblock)
编译boot\下所有文件,并链接bootblock文件
# create 'sign' tools
$(call add_files_host,tools/sign.c,sign,sign)
$(call create_target_host,sign,sign)
编译sign.c文件并调用之
# create ucore.img
UCOREIMG := $(call totarget,ucore.img)
$(UCOREIMG): $(kernel) $(bootblock)
$(V)dd if=/dev/zero of=$@ count=10000
$(V)dd if=$(bootblock) of=$@ conv=notrunc
$(V)dd if=$(kernel) of=$@ seek=1 conv=notrunc
$(call create_target,ucore.img)
定义变量UCOREIMG,用到了totarget.在本文件中搜不到totarget,但搜索include可得
include tools/function.mk
于是在function.mk中可找到totarget的定义
totarget = $(addprefix $(BINDIR)$(SLASH),$(1))
BINDIR在别处定义为"bin",slash定义为斜线"/",$(1)指代输入参数
所以totarget作用为给输入参数添加前缀"bin/"
回归前文,UCOREIMG则等于"bin/ucore.img"
继续往下看,UCOREIMG依赖项为kernel和bootblock,先不管它们
V定义为at符号"@"
dd为linux命令covert an copy,不过cc已经被用过了,所以改叫dd,用法可以参考【一天一个shell命令】文本操作系列-dd
$@为makefile特殊符号,表示目标文件
这几行命令作用是当kernel和bootblock更新时,把UCOREIMG先写入obt*count的空白数据.其中obt为默认值512,count为10000,生成UCOREIMG大小为5120000字节.然后把bootblock覆写到UCOREIMG上,作为bootloader引导代码.再把kernel覆写到相对于UCOREIMG开头512字节的位置.这里体现了磁盘开头的512字节是属于bootloader的.
最后作一下流程总结
生成kernel
生成bootblock
生成合法的主引导扇区
生成ucore.img
2. 一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
阅读sign.c可知,大小小于512字节且以0X55AA结尾
3. bootasm.S分析
CPU加电,初始化CS=0XF000,IP=0XFFF0
执行CS:IP处长跳指令,跳转到BIOS起点0XFE05B
BIOS启动,硬件自检与初始化,读取主引导扇区代码到0X7C00处
初始化各种控制寄存器
激活A20地址线控制位,切换保护模式:
等待8042输入缓冲为空
发送写指令
等待8042输入缓冲为空
发送写入的内容
# Switch from real to protected mode, using a bootstrap GDT
# and segment translation that makes virtual addresses
# identical to physical addresses, so that the
# effective memory map does not change during the switch.
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
GDT信息送入GTDR(高32位:基址 低16位:段界限),然后让CR0寄存保护模式使能位
这里引用一下GDT生成的代码:
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
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
gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt
此时生成的GDT为
{
0: 空段
1: 代码段:base=0x0,limit=ffffffff,即4G内存处,属性可执行可读
2: 数据段:base=0x0,limit=ffffffff,即4G内存处,属性可写
}
搞明白GDT啥样后再回来往下看
# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg
注意由于此时已经开启段机制,不能再按\(段基址<<4+偏移\)的方式计算ljmp的位置.现在的PROTMODECSEG为段选择子,protcseg为段偏移
现分析PROTMODECSEG为何取0x8
把PORTMODECSEG按二进制写出
000....00001000 (16bit)
拆分得RPL=0,TI=0,index=1,即对应GDT中的1号段描述符,基址为0,界限为4G
根据段机制下的公式 :
段描述符.基址 + 段偏移 = 线性地址 = 物理地址
因此ljmp最终指向的位置即是protcseg的值
同样我们可以分析出PROT_MODE_DSEG对应GDT的2号位置
接下来几行将各个段寄存器赋值为PROT_MODE_DSEG,将ESP置0,将ESP赋值为$start,即0x7c00,第一条指令处.
因为栈是从高内存往低内存增长,且bootloader代码都在0x7c00后,所以0x7c00前面的空间就暂时留给栈了
最后调用bootmain函数
4. bootmain.c分析:
先从磁盘开始处读取了1页(8个扇区,每个512byte)的数据到内存64K处,再校验头部标识符是否合法.
接着从磁盘中读取每个程序段,并放到虚拟内存对应位置.
最后执行ELF入口程序,将控制权交给kernel
5. 实现函数调用堆栈跟踪函数
考察的是对EBP寄存器的运用.几乎所有本地编译器都会在每个函数体之前插入类似如下的汇编指令:
pushl %ebp
movl %esp,%ebp
这时候EBP的位置就很重要了.EBP总是指向上次压栈的EBP,EBP+4处为上次调用的返回地址,即CALLER的EIP值,EPB+8处为参数1,EPB-4处为局部变量.这些特性就使得可以不断回溯EBP的值来或取函数的调用栈.
| 栈底方向 | 高位地址
| ... |
| 参数3 |
| 参数2 |
| 参数1 |
| 返回地址 |
| 上一层[ebp]| <-------- [ebp]
| 局部变量1 |
| 局部变量2 |
| |低位地址
这题虽然噱头看着挺大,但等着找到填空的地方一看提示都把流程写明了.最大的坑就在于处理指针时括号的使用
int a;
(uint32 *)a+1==(uint32 *)(a+sizeof(int))==(uint32 *)(a+4)
7. 扩展练习 Challenge 1
增加一用户态函数(可执行一特定系统调用:获得时钟计数值),当内核初始完毕后,可从内核态返回到用户态的函数,而用户态的函数又通过系统调用得到内核态的服务
这一部分是真的难,看完LAB2实践课里的讲解再回来理了半天才完全看懂答案的思路.
先捋一遍中断的处理流程:
汇编指令int触发中断,CPU把错误码和中断编号压栈,然后去vector.s里找对应中断例程的入口地址,再调用trapentry.S的__alltraps,在这里各类段寄存器,双字节寄存器,ESP被按顺序压栈,再调用trap.c里的trap函数,进而调用trap_dispatch函数进行中断处理.中断处理完后再把整个过程到着来一遍恢复原状.
再来说说内核态到用户态的转换:会从内核栈切换到用户栈,改变段寄存器特权级,并添加ESP和SS
内核栈: 用户栈:(段寄存器的特权级改变)
|栈顶地址 | | |
|各种寄存器|<-栈顶 |各种寄存器|<-栈顶
|..... | |..... |
|err | |err |
|eip | |eip |
|cs | |cs |
|eflags | |eflags |
| | |esp(新加 |(指向内核栈EFLAGS前面的地址)
| | |ss(新加 |
用户态到内核态的转换:产生中断时自动切换到内核栈,即在内核栈内进行操作,改变段寄存器的特权级,去除ESP和SS
内核栈: 内核栈
|栈顶地址 | |栈顶地址 |
|各种寄存器|<-栈顶 |各种寄存器|<-栈顶
|..... | |..... |
|err | |err |
|eip | |eip |
|cs | |cs |
|eflags | |eflags |
|esp(用户程序栈顶) | |
|ss | | |
我最终的实现方法与答案略有不同,这里讲一下
lab1_switch_to_user(void) {
asm volatile ("int %0"::"i"(T_SWITCH_TOU));
//触发转换到用户态的中断
}
lab1_switch_to_kernel(void) {
asm volatile ("int %0"::"i"(T_SWITCH_TOK));
//触发转换到内核态的中断
}
在trap.c中定义一个trapframe全局变量atrapframe做临时栈使用,并在trap_dispatch中完成内核态到用户态的转换
case T_SWITCH_TOU:
if(tf->tf_cs!=USER_CS){ //当前已经为用户态时跳过
atrapfream=*tf; //把中断帧的值赋给临时栈
atrapfream.tf_cs=USER_CS;//更改代码段
atrapfream.tf_ds=atrapfream.tf_es=atrapfream.tf_ss=USER_DS;//更改数据段
atrapfream.tf_esp=(uint32_t)tf+sizeof(struct trapframe)-8;//更改ESP
atrapfream.tf_eflags|=FL_IOPL_MASK;//更改EFLAGS,不然在转换时会发生IO权限异常
*((uint32_t*)tf-1)=&atrapfream;//因为从内核栈切换到用户栈,所以修改栈顶地址
}
break;
case T_SWITCH_TOK:
if(tf->tf_cs!=KERNEL_CS){ //当前已经为内核态时跳过
atrapfream=*tf; //把中断帧的值赋给临时栈
atrapfream.tf_cs=KERNEL_CS; //更改代码段
atrapfream.tf_ds=atrapfream.tf_es=KERNEL_DS; //更改数据段,这次没改SS
atrapfream.tf_eflags&=~FL_IOPL_MASK; //更改ESP
int offset=tf->tf_esp-(sizeof(struct trapframe)-8); //修改后少了ESP和SS,故需要偏移
__memmove(offset,&atrapfream,sizeof(struct trapframe)-8); //把修改好的栈移到目标位置
*((uint32_t*)tf-1)=offset; //重设栈顶地址
}
break;
注意此时我们要在用户态下调用T_SWITCH_TOK部分,所以要在创建IDT里把对应的访问权限设置为USER
SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
8. 扩展练习 Challenge 2
用键盘实现用户模式内核模式切换。具体目标是:“键盘输入3时切换到用户模式,键盘输入0
时切换到内核模式”。
这就简单了,直接在时间中断处理里加个if-else再把刚写的内写糊上去就行
case IRQ_OFFSET + IRQ_KBD:
c = cons_getc();
cprintf("kbd [%03d] %c\n", c, c);
if(c=='0'){
if(tf->tf_cs!=KERNEL_CS){
cprintf("+++ switch to kernel mode +++\n");
atrapfream=*tf;
atrapfream.tf_cs=KERNEL_CS;
atrapfream.tf_ds=atrapfream.tf_es=KERNEL_DS;
atrapfream.tf_eflags&=~FL_IOPL_MASK;
int offset=tf->tf_esp-(sizeof(struct trapframe)-8);
__memmove(offset,&atrapfream,sizeof(struct trapframe)-8);
*((uint32_t*)tf-1)=offset;
}
}
else if(c=='3'){
if(tf->tf_cs!=USER_CS){
cprintf("+++ switch to user mode +++\n");
atrapfream=*tf;
atrapfream.tf_cs=USER_CS;
atrapfream.tf_ds=atrapfream.tf_es=atrapfream.tf_ss=USER_DS;
atrapfream.tf_esp=(uint32_t)tf+sizeof(struct trapframe)-8;
atrapfream.tf_eflags|=FL_IOPL_MASK;
*((uint32_t*)tf-1)=&atrapfream;
}
}
break;