学习笔记
环境配置
实验指导书里有环境配置的相关说明,但实际操作后会发现更新包的过程非常缓慢,因为用的是系统默认源,建议大家先换成国内源,如阿里源、中科大源等,网上有很多用命令换源的操作指导文章,不做赘述,如果有不会使用命令行换源的,我的另一篇文章展示了如何通过图形界面换源,仅供参考。(https://blog.csdn.net/weixin_45484297/article/details/103005370)
练习一
理解通过make生成执行文件的过程。(要求在报告中写出对下述问题的回答)
1.操作系统镜像文件ucore.img是如何一步一步生成的?(需要比较详细地解释Makefile中每一条相关命令和命令参数的含义,以及说明命令导致的结果)
2.一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
生成kernal文件
键入make "V="查看执行过程;
链接生成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)
生成bootblock
生成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)
@$(OBJDUMP) -t $(call objfile,bootblock) | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,bootblock)
@$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)
@$(call totarget,sign) $(call outfile,bootblock) $(bootblock)
$(call create_target,bootblock)
生成sign工具
$(call add_files_host,tools/sign.c,sign,sign)
$(call create_target_host,sign,sign)
生成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)
简单总结执行过程
- 编译libs和kern目录下所有的.c和.S文件,生成.o文件,并链接得到bin/kernel文件;
- 编译boot目录下所有的.c和.S文件,生成.o文件,并链接得到bin/bootblock.out文件;
- 编译tools/sign.c文件,得到bin/sign文件;
- 利用bin/sign工具将bin/bootblock.out文件转化为512字节的bin/bootblock文件,并将bin/bootblock的最后两个字节设置为0x55AA;
- 为bin/ucore.img分配内存空间,并将bin/bootblock复制到bin/ucore.img的第一个block,紧接着将bin/kernel复制到bin/ucore.img第二个block开始的位置。
主引导扇区的特征
这里截取部分源码
fclose(ifp);
buf[510] = 0x55;
buf[511] = 0xAA;
FILE *ofp = fopen(argv[2], "wb+");
size = fwrite(buf, 1, 512, ofp);
if (size != 512) {
fprintf(stderr, "write '%s' error, size is %d.\n", argv[2], size);
return -1;
}
fclose(ofp);
可以看出其特征为:
1.大小为512字节
2. 最后两个字节为0x55AA
练习二
使用qemu执行并调试lab1中的软件。(要求在报告中简要写出练习过程)
进行如下的小练习:
1.从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
2.在初始化位置0x7c00设置实地址断点,测试断点正常。
3.从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。
4.自己找一个bootloader或内核中的代码位置,设置断点并进行测试。
问题一
修改lab1/tools/gdbinit 内容为:
set architecture i8086
target remote :1234
用cd命令切换到lab1,执行make debug,而后会弹出一个qemu框和一个gdb调试框,在dgb调试界面执行si进行单步跟踪,执行如下命令查看BIOS代码:
x /2i $pc
单步执行一次后位置变为0xe05b,如下:
问题二
修改 gdbinit文件内容为:
set architecture i8086
target remote :1234
b *0x7c00
c
x/2i $pc
按照问题一的步骤执行,得到初始位置为0x7c00:
问题三
改写makefile文件内容为:
然后再执行make debug,在bin文件里会生成一个q.log文件,打开文件找到对应地址如下:
将其与bootasm.S和bootblock.asm进行比较,可以发现,反汇编的代码与bootblock.asm基本相同,而与bootasm.S有所差别:
1.反汇编的代码中的指令不带指示长度的后缀,而bootasm.S的指令则有。比如,反汇编 的代码是xor %eax, %eax,而bootasm.S的代码为xorw %ax, %ax;
2.反汇编的代码中的通用寄存器是32位(带有e前缀),而bootasm.S的代码中的通用寄存器是16位(不带e前缀)。
问题四
修改gdbinit文件,在0x7c12处设置断点:
set architecture i8086
target remote :1234
break *0x7c4a
执行make debug如下:
练习三
分析bootloader进入保护模式的过程。(要求在报告中写出分析)
BIOS将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执行bootloader。请分析bootloader是如何完成从实模式进入保护模式的。
开启A20门
A20的初始值是0,所以其地址线控制是被屏蔽的,访问超过1MB的地址时,就会从0循环计数,即不可访问,而在保护模式下 A20 地址线控制是要打开的,所以需要通过将键盘控制器上的A20线置于高电位,使得全部32条地址线可用。
打开A20 Gate的具体步骤大致如下:
- 等待8042 Input buffer为空
- 发送Write 8042 Output Port (P2) 命令到8042 Input buffer
- 等待8042 Input buffer为空
- 将8042 Output Port(P2) 对应字节的第2位置1,然后写入8042 Input buffer
打开A20 Gate的功能是在boot/bootasm.S中实现的,代码分为seta20.1和seta20.2两部分,其中seta20.1是往端口0x64写数据0xd1,告诉CPU我要往8042芯片的P2端口写数据;seta20.2是往端口0x60写数据0xdf,从而将8042芯片的P2端口设置为1. 两段代码都需要先读0x64端口的第2位,确保输入缓冲区为空后再进行后续写操作。
seta20.1:
inb $0x64, %al # Wait for not busy(8042 input buffer empty).
testb $0x2, %al
jnz seta20.1
movb $0xd1, %al # 0xd1 -> port 0x64
outb %al, $0x64 # 0xd1 means: write data to 8042's P2 port
seta20.2:
inb $0x64, %al # Wait for not busy(8042 input buffer empty).
testb $0x2, %al
jnz seta20.2
movb $0xdf, %al # 0xdf -> port 0x60
outb %al, $0x60 # 0xdf = 11011111, means set P2's A20 bit(the 1 bit) to 1
初始化GDT表
boot/bootasm.S中的lgdt gdtdesc负责载入GDT表;
gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt
如何使能和进入保护模式
将cr0寄存器的PE位(cr0寄存器的最低位)设置为1;
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
练习四
分析bootloader加载ELF格式的OS的过程。(要求在报告中写出分析)
通过分析源代码和通过qemu来运行并调试bootloader&OS
bootloader如何读取硬盘扇区的?
bootloader是如何加载ELF格式的OS?
问题一
阅读材料中给出了大致过程:
- 等待磁盘准备好
- 发出读取扇区的命令
- 等待磁盘准备好
- 把磁盘扇区数据读到指定内存
实际操作中,所有的IO操作是通过CPU访问硬盘的IO地址寄存器完成。硬盘共有8个IO地址寄存器,其中第1个存储数据,第8个存储状态和命令,第3个存储要读写的扇区数,第4~7个存储要读写的起始扇区的编号(共28位)。
代码分析
bootloader读取扇区的功能是在boot/bootmain.c的readsect函数中实现的:
static void readsect(void *dst, uint32_t secno) {
// wait for disk to be ready
waitdisk();
outb(0x1F2, 1); // count = 1
outb(0x1F3, secno & 0xFF);
outb(0x1F4, (secno >> 8) & 0xFF);
outb(0x1F5, (secno >> 16) & 0xFF);
outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
outb(0x1F7, 0x20); // cmd 0x20 - read sectors
// wait for disk to be ready
waitdisk();
// read a sector
insl(0x1F0, dst, SECTSIZE / 4);
}
分析代码可以得到读取硬盘扇区的步骤:
- 等待硬盘空闲。waitdisk的函数实现只有一行:while ((inb(0x1F7) & 0xC0) != 0x40),意思是不断查询读0x1F7寄存器的最高两位,直到最高位为0、次高位为1(这个状态应该意味着磁盘空闲)才返回
- 硬盘空闲后,发出读取扇区的命令。对应的命令字为0x20,放在0x1F7寄存器中;读取的扇区数为1,放在0x1F2寄存器中;读取的扇区起始编号共28位,分成4部分依次放在0x1F3~0x1F6寄存器中
- 发出命令后,再次等待硬盘空闲
- 硬盘再次空闲后,开始从0x1F0寄存器中读数据。注意insl的作用是"That function will read cnt dwords from the input port specified by port into the supplied output array addr.",是以dword即4字节为单位的,因此这里SECTIZE需要除以4
问题二
读取完磁盘之后,开始加载ELF格式的文件,直接插入代码注释:
void bootmain(void) {
// 读取ELF的头部
readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
// 判断是否是合法的ELF文件
if (ELFHDR->e_magic != ELF_MAGIC) {
goto bad;
}
struct proghdr *ph, *eph;
// ELF头部有描述ELF文件应加载到内存什么位置的描述表,这里读取出来将之存入ph
ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
// 按照描述表将ELF文件中数据载入内存
for (; ph < eph; ph ++) {
readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
}
// ELF文件0x1000位置后面的0xd1ec比特被载入内存0x00100000
// ELF文件0xf000位置后面的0x1d20比特被载入内存0x0010e000
// 根据ELF头部储存的入口信息,找到内核的入口
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
while (1);
}
总结一下就是:
- 从硬盘读了8个扇区数据到内存0x10000处,并把这里强制转换成elfhdr使用
- 校验e_magic字段
- 根据偏移量分别把程序段的数据读取到内存中
代码调试
- 输入make debug启动gdb,并在bootmain函数入口处即0x7d0d设置断点,输入c跳到该入口;
- 单步执行几次,运行到call readseg处,由于该函数会反复读取硬盘,为节省时间,可在下一条语句设置断点,避免进入到readseg函数内部反复执行循环语句;(或者直接输入n即可,不用这么麻烦)
- 执行完readseg后,可以通过x/xw 0x10000查询ELF Header的e_magic的值,查询结果如下,确实与0x464c457f相等,所以校验成功。注意,我们的硬件是小端字节序(这从asm文件的汇编语句和二进制代码的对比中不难发现),因此0x464c45实际上对应字符串"elf",最低位的0x7f字符对应DEL;
(gdb) x/xw 0x10000
0x10000: 0x464c457f
- 继续单步执行,由0x7d2f mov 0x1001c,%eax可知ELF Header的e_phoff字段将加载到eax寄存器,0x1001c相对0x10000的偏移为0x1c,即相差28个字节,这与ELF Header的定义相吻合。执行完0x7d2f处的指令后,可以看到eax的值变为0x34,说明program Header表在文件中的偏移为0x34,则它在内存中的位置为0x10000 + 0x34 = 0x10034.查询0x10034往后8个字节的内容如下所示:
(gdb) x/8xw 0x10034
0x10034: 0x00000001 0x00001000 0x00100000 0x00100000
0x10044: 0x0000dac4 0x0000dac4 0x00000005 0x00001000
可以使用readelf -l bin/kernel来查询kernel文件各个Segment的基本信息,以作对比:
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x001000 0x00100000 0x00100000 0x0dac4 0x0dac4 R E 0x1000
LOAD 0x00f000 0x0010e000 0x0010e000 0x00aac 0x01dc0 RW 0x1000
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x10
- 继续单步执行,由0x7d34 movzwl 0x1002c,%esi可知ELF Header的e_phnum字段将加载到esi寄存器,执行完x07d34处的指令后,可以看到esi的值变为3,这说明一共有3个segment;
- 后面是通过磁盘I/O完成三个Segment的加载,不再赘述。
练习五
实现函数调用堆栈跟踪函数 (需要编程)
完成kdebug.c中函数print_stackframe的实现,可以通过函数print_stackframe来跟踪函数调用堆栈中记录的返回地址,并解释最后一行各个数值的含义。
打开kern/debug/kdebug.c,找到print_stackframe函数:
void
print_stackframe(void) {
/* LAB1 YOUR CODE : STEP 1 */
/* (1) call read_ebp() to get the value of ebp. the type is (uint32_t);
* (2) call read_eip() to get the value of eip. the type is (uint32_t);
* (3) from 0 .. STACKFRAME_DEPTH
* (3.1) printf value of ebp, eip
* (3.2) (uint32_t)calling arguments [0..4] = the contents in address (uint32_t)ebp +2 [0..4]
* (3.3) cprintf("\n");
* (3.4) call print_debuginfo(eip-1) to print the C calling function name and line number, etc.
* (3.5) popup a calling stackframe
* NOTICE: the calling funciton's return addr eip = ss:[ebp+4]
* the calling funciton's ebp = ss:[ebp]
*/
}
注释已经给得十分详细,但在码代码之前需要我们注意注释中的两个函数,以便于我们理解整个调用流程。
read_eip() 函数定义在kdebug.c中:
static __noinline uint32_t
read_eip(void) {
uint32_t eip;
asm volatile("movl 4(%%ebp), %0" : "=r" (eip));
return eip;
}
read_ebp() 函数定义在libs/x86.h中:
static inline uint32_t
read_ebp(void) {
uint32_t ebp;
asm volatile ("movl %%ebp, %0" : "=r" (ebp));
return ebp;
}
实现函数如下:
- 定义两个局部变量ebp、eip分别存放ebp、eip寄存器的值,调用read_ebp()以获得ebp的值,调用read_eip()来获取eip的值,这里将ebp定义为指针,是为了方便取ebp寄存器的值;
- 由于变量eip存放的是下一条指令的地址,因此将变量eip的值减去1;
- 以后变量eip的值从ebp寄存器指向栈中的位置再往上一个单位中获取;
- ebp寄存器指向栈中的位置存放的是调用者的ebp寄存器的值,可以不断回溯,直到ebp寄存器的值变为0。
void print_stackframe(void) {
uint32_t *ebp = (uint32_t *)read_ebp();
uint32_t eip = read_eip();
while (ebp)
{
cprintf("ebp:0x%08x eip:0x%08x args:", (uint32_t)ebp, eip);
cprintf("0x%08x 0x%08x 0x%08x 0x%08x\n", ebp[2], ebp[3], ebp[4], ebp[5]);
print_debuginfo(eip - 1);
eip = ebp[1];
ebp = (uint32_t *)*ebp;
}
/* LAB1 YOUR CODE : STEP 1 */
/* (1) call read_ebp() to get the value of ebp. the type is (uint32_t);
* (2) call read_eip() to get the value of eip. the type is (uint32_t);
* (3) from 0 .. STACKFRAME_DEPTH
* (3.1) printf value of ebp, eip
* (3.2) (uint32_t)calling arguments [0..4] = the contents in address (uint32_t)ebp +2 [0..4]
* (3.3) cprintf("\n");
* (3.4) call print_debuginfo(eip-1) to print the C calling function name and line number, etc.
* (3.5) popup a calling stackframe
* NOTICE: the calling funciton's return addr eip = ss:[ebp+4]
* the calling funciton's ebp = ss:[ebp]
*/
}
编写完后执行make qemu,打印得到如下结果,与实验指导书结果类似:
最后一行的解释:
- ebp:0x0007bf8 此时ebp的值是kern_init函数的栈顶地址,从obj/bootblock.asm文件中知道整个栈的栈顶地址为0x00007c00,ebp指向的栈位置存放调用者的ebp寄存器的值,ebp+4指向的栈位置存放返回地址的值,这意味着kern_init函数的调用者(也就是bootmain函数)没有传递任何输入参数给它!因为单是存放旧的ebp、返回地址已经占用8字节了。
- eip:0x00007d6e eip的值是kern_init函数的返回地址,也就是bootmain函数调用kern_init对应的指令的下一条指令的地址。
- args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8 一般来说,args存放的4个dword是对应4个输入参数的值。但这里比较特殊,由于bootmain函数调用kern_init并没传递任何输入参数,并且栈顶的位置恰好在boot loader第一条指令存放的地址的上面,而args恰好是kern_int的ebp寄存器指向的栈顶往上第2~5个单元,因此args存放的就是boot loader指令的前16个字节!可以对比obj/bootblock.asm文件来验证(验证时要注意系统是小端字节序)。
00007c00 <start>:
7c00: fa cli
7c01: fc cld
7c02: 31 c0 xor %eax,%eax
7c04: 8e d8 mov %eax,%ds
7c06: 8e c0 mov %eax,%es
7c08: 8e d0 mov %eax,%ss
7c0a: e4 64 in $0x64,%al
7c0c: a8 02 test $0x2,%al
7c0e: 75 fa jne 7c0a <seta20.1>
练习六
完善中断初始化和处理 (需要编程)
1.中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
2.请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
3.请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
问题一
中断描述符表一个表项占8个字节,其中2-3字节是段选择子,0-1字节和6-7字节拼成偏移量,即第16~ 32位是段选择子,第0~ 15、48~63位构成的偏移地址,通过段选择子去GDT中找到对应的基地址,然后基地址加上偏移量就是中断处理程序的地址。
问题二
查看原函数注释:
void idt_init(void) {
/* LAB1 YOUR CODE : STEP 2 */
/* (1) Where are the entry addrs of each Interrupt Service Routine (ISR)?
* All ISR's entry addrs are stored in __vectors. where is uintptr_t __vectors[] ?
* __vectors[] is in kern/trap/vector.S which is produced by tools/vector.c
* (try "make" command in lab1, then you will find vector.S in kern/trap DIR)
* You can use "extern uintptr_t __vectors[];" to define this extern variable which will be used later.
* (2) Now you should setup the entries of ISR in Interrupt Description Table (IDT).
* Can you see idt[256] in this file? Yes, it's IDT! you can use SETGATE macro to setup each item of IDT
* (3) After setup the contents of IDT, you will let CPU know where is the IDT by using 'lidt' instruction.
* You don't know the meaning of this instruction? just google it! and check the libs/x86.h to know more.
* Notice: the argument of lidt is idt_pd. try to find it!
*/
}
SETGATE在mmu.h中有定义:
#define SETGATE(gate, istrap, sel, off, dpl)
函数的代码实现:
void
idt_init(void) {
/* LAB1 YOUR CODE : STEP 2 */
/* (1) Where are the entry addrs of each Interrupt Service Routine (ISR)?
* All ISR's entry addrs are stored in __vectors. where is uintptr_t __vectors[] ?
* __vectors[] is in kern/trap/vector.S which is produced by tools/vector.c
* (try "make" command in lab1, then you will find vector.S in kern/trap DIR)
* You can use "extern uintptr_t __vectors[];" to define this extern variable which will be used later.
* (2) Now you should setup the entries of ISR in Interrupt Description Table (IDT).
* Can you see idt[256] in this file? Yes, it's IDT! you can use SETGATE macro to setup each item of IDT
* (3) After setup the contents of IDT, you will let CPU know where is the IDT by using 'lidt' instruction.
* You don't know the meaning of this instruction? just google it! and check the libs/x86.h to know more.
* Notice: the argument of lidt is idt_pd. try to find it!
*/
extern uintptr_t __vectors[];
int i;
for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) {
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
}
// set for switch from user to kernel
SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
// load the IDT
lidt(&idt_pd);
}
问题三
代码实现如下:
case IRQ_OFFSET + IRQ_TIMER:
/* LAB1 YOUR CODE : STEP 3 */
/* handle the timer interrupt */
/* (1) After a timer interrupt, you should record this event using a global variable (increase it), such as ticks in kern/driver/clock.c
* (2) Every TICK_NUM cycle, you can print some info using a funciton, such as print_ticks().
* (3) Too Simple? Yes, I think so!
*/
ticks ++;
if (ticks % TICK_NUM == 0) {
print_ticks();
}
执行make qemu,得到如下结果:
参考资料(https://www.cnblogs.com/wuhualong/p/ucore_lab1_report.html)
建议:大家在做这个实验时如遇到困难可查看指导书提供的answer,会有更深的理解。