练习1 理解通过make生成执行文件的过程
问题1:操作系统镜像文件ucore.img是如何一步一步生成的?
- 执行
make clean
再执行make "V="
,观察生成ucore.img的过程 - 提取核心过程如下:
# 构建bin/kernel
+ cc kern/init/init.c
+ cc kern/libs/readline.c
+ cc kern/libs/stdio.c
+ cc kern/debug/kdebug.c
+ cc kern/debug/kmonitor.c
+ cc kern/debug/panic.c
+ cc kern/driver/clock.c
+ cc kern/driver/console.c
+ cc kern/driver/intr.c
+ cc kern/driver/picirq.c
+ cc kern/trap/trap.c
+ cc kern/trap/trapentry.S
+ cc kern/trap/vectors.S
+ cc kern/mm/pmm.c
+ cc libs/printfmt.c
+ cc libs/string.c
+ ld bin/kernel
# 构建sign工具与bin/bootblock
+ cc boot/bootasm.S
+ cc boot/bootmain.c
+ cc tools/sign.c
gcc -Itools/ -g -Wall -O2 -c tools/sign.c -o obj/sign/tools/sign.o
gcc -g -Wall -O2 obj/sign/tools/sign.o -o bin/sign
+ ld bin/bootblock
ld -m elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o
obj/boot/bootmain.o -o obj/bootblock.o
'obj/bootblock.out' size: 472 bytes
build 512 bytes boot sector: 'bin/bootblock' success!
# 构建ucore.img
dd if=/dev/zero of=bin/ucore.img count=10000
10000+0 records in
10000+0 records out
5120000 bytes (5.1 MB) copied, 0.0456474 s, 112 MB/s
dd if=bin/bootblock of=bin/ucore.img conv=notrunc
1+0 records in
1+0 records out
512 bytes (512 B) copied, 0.00281044 s, 182 kB/s
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
138+1 records in
138+1 records out
70775 bytes (71 kB) copied, 0.000473867 s, 149 MB/s
由以上过程可知
- 编译16个内核文件,构建出内核
bin/kernel
- 生成
bin/bootblock
引导程序- 编译
bootasm.S,bootmain.c
,链接生成obj/bootblock.o
- 编译
sign.c
生成sign.o
工具 - 使用
sign.o
工具规范化bootblock.o
,生成bin/bootblock
引导扇区
- 编译
- 生成
ucore.img
虚拟磁盘-
dd
初始化ucore.img
为5120000 bytes
,内容为0的文件 -
dd
拷贝bin/bootblock
到ucore.img
第一个扇区 -
dd
拷贝bin/kernel
到ucore.img
第二个扇区往后的空间
-
问题2:一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
根据问题1可知通过sign.c
文件的操作使得bootblock.o
成为一个符合规范的引导扇区,因此查看sign.c
的内容
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <sys/stat.h>
int main(int argc, char *argv[]) {
struct stat st;
// 输入状态判断
if (argc != 3) {
fprintf(stderr, "Usage: <input filename> <output filename>\n");
return -1;
}
// 读取文件头
if (stat(argv[1], &st) != 0) {
fprintf(stderr, "Error opening file '%s': %s\n", argv[1], strerror(errno));
return -1;
}
// 问题1中输出的文件大小
printf("'%s' size: %lld bytes\n", argv[1], (long long)st.st_size);
// 文件大小超过510字节报错返回,因为最后2个字节要用作结束标志位
if (st.st_size > 510) {
fprintf(stderr, "%lld >> 510!!\n", (long long)st.st_size);
return -1;
}
// 多余位用0填充
char buf[512];
memset(buf, 0, sizeof(buf));
FILE *ifp = fopen(argv[1], "rb");
int size = fread(buf, 1, st.st_size, ifp);
// 文件实际大小需和文件头描述一致
if (size != st.st_size) {
fprintf(stderr, "read '%s' error, size is %d.\n", argv[1], size);
return -1;
}
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);
printf("build 512 bytes boot sector: '%s' success!\n", argv[2]);
return 0;
}
由以上代码可知,硬盘主引导扇区特征为:
- 大小为512字节,空余部分用0填充
- 文件内容不超过
510 bytes
- 最后
2 bytes
为0x55 0xAA
练习2 使用qemu执行并调试lab1中的软件
1 从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
修改tools/gdbinit
,在lab1
下执行make debug
set architecture i8086
target remote :1234
- 此时
CS
为0xF000
,PC
为0xFFF0
,内存地址为0xFFFF0
- 可知,
CPU
加电后第一条执行位于0xFFFF0
,并且第一条指令为长跳转指令 - 可知,BIOS实例存储在
cs:ip
为0xf000:0xe05b
的位置 - 使用
si
命令可对BIOS进行单步跟踪
2 在初始化位置0x7c00设置实地址断点,测试断点正常。
3 从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。
修改tools/gdbinit
,在lab1
下执行make debug
file obj/bootblock.o
set architecture i8086
target remote :1234
b *0x7c00
continue
- 调试发现
0x7C00
为主引导程序的入口地址,代码与bootasm.S
一致 - 使用ni可进行单步调试
4 自己找一个bootloader或内核中的代码位置,设置断点并进行测试。
修改tools/gdbinit
,在lab1
下执行make debug
file bin/kernel
set architecture i8086
target remote :1234
b kern_init
continue
- 在内核入口处增加断点,可以看到代码停在
kern_init
函数 - 使用ni可进行单步调试
练习3 分析bootloader进入保护模式的过程
为何开启A20,以及如何开启A20
在i8086
时代,CPU
的数据总线是16bit
,地址总线是20bit
,寄存器是16bit
,因此CPU只能访问1MB
以内的空间。因为数据总线和寄存器只有16bit
,如果需要获取20bit
的数据, 我们需要做一些额外的操作,比如移位。实际上,CPU
是通过对segment
(每个segment
大小恒定为64K
) 进行移位后和offset
一起组成了一个20bit
的地址,这个地址就是实模式下访问内存的地址:
address = segment << 4 | offset
理论上,20bit
的地址可以访问1MB
的内存空间(0x00000 - (2^20 - 1 = 0xFFFFF))
。但在实模式下, 这20bit
的地址理论上能访问从0x00000 - (0xFFFF0 + 0xFFFF = 0x10FFEF)
的内存空间。也就是说,理论上我们可以访问超过1MB的内存空间,但越过0xFFFFF
后,地址又会回到0x00000
。
上面这个特征在i8086
中是没有任何问题的(因为它最多只能访问1MB
的内存空间),但到了i80286/i80386
后,CPU
有了更宽的地址总线,数据总线和寄存器后,这就会出现一个问题: 在实模式下, 我们可以访问超过1MB
的空间,但我们只希望访问1MB以内的内存空间。为了解决这个问题, CPU
中添加了一个可控制A20
地址线的模块,通过这个模块,我们在实模式下将第20bit
的地址线限制为0
,这样CPU
就不能访问超过1MB
的空间了。进入保护模式后,我们再通过这个模块解除对A20
地址线的限制,这样我们就能访问超过1MB
的内存空间了。
默认情况下,A20
地址线是关闭的(20bit
以上的地址线限制为0
),因此在进入保护模式(需要访问超过1MB
的内存空间)前,我们需要开启A20
地址线(20bit
以上的地址线可为0
或者1
)。具体代码如下:
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表
- 可以看出这里所有
GDT表项
(除了空段)初始化为全段,此时段偏移量EIP
等于物理地址
...
#define SEG_NULLASM \
.word 0, 0; \
.byte 0, 0, 0, 0
#define SEG_ASM(type,base,lim) \
.word (((lim) >> 12) & 0xffff), ((base) & 0xffff); \
.byte (((base) >> 16) & 0xff), (0x90 | (type)), \
(0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)
...
lgdt gdtdesc
...
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
段选择子
在实模式下, 逻辑地址由段选择子和段选择子偏移量组成. 其中, 段选择子16bit, 段选择子偏移量是32bit. 下面是段选择子的示意图:
- 在段选择子中,其中的INDEX[15:3]是GDT的索引。
- TI[2:2]用于选择表格的类型,1是LDT,0是GDT。
- RPL[1:0]用于选择请求者的特权级,00最高,11最低。
GDT的访问
有了上面这些知识,我们可以来看看到底应该怎样通过GDT来获取需要访问的地址了。我们通过这个示意图来讲解:
- 根据CPU给的逻辑地址分离出段选择子。
- 利用段选择子查找到对应的段描述符。
- 将段描述符里的Base Address和EIP相加而得到线性地址。
如何使能和进入保护模式
开启A20,初始化gdt后,将控制寄存器CR0
的PE(bit0)
置为1
即可
movl %cr0, %eax
orl 0x1, %eax
movl %eax, %cr0
练习4 分析bootloader加载ELF格式的OS的过程
执行完bootasm.S
后,系统进入保护模式, 进行bootmain.c
开始加载OS
- 定义ELF头指针,指向
0x10000
- 读取
8
个扇区大小的ELF
头到内存地址0x10000
- 校验
ELF header
中的魔数,判断是否为0x464C457FU
- 读取
ELF header
中的程序段到内存中 - 跳转到操作系统入口
练习5 实现函数调用堆栈跟踪函数
完成kdebug.c中函数print_stackframe
要完成实验首先必须了解函数栈的构建过程
-
ebp
为基址指针寄存器 -
esp
为堆栈指针寄存器(指向栈顶) -
ebp
寄存器处于一个非常重要的地位,该寄存器中存储着栈中的一个地址(原ebp
入栈后的栈顶),从该地址为基准,向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取函数局部变量值,而该地址处又存储着上一层函数调用时的ebp
值
举一个实际的例子查看ebp
与esp
两个寄存器如何构建出完整的函数栈:
leave
等同于movl %ebp, %esp
,popl %ebp
两条指令
int g(int x) {
return x + 10;
}
int f(int x) {
return g(x);
}
int main(void) {
return f(20) + 8;
}
int i,j;
uint32_t ebp = read_ebp(), eip = read_eip();
for (i = 0; ebp != 0 && i < STACKFRAME_DEPTH; i ++) {
cprintf("ebp:0x%08x eip:0x%08x args:", ebp, eip);
// ebp向上移动4个字节为eip
uint32_t *args = (uint32_t *)ebp + 2;
// 再向上每4个字节都为输入的参数(这里只是假设4个参数,做实验)
for (j = 0; j < 4; j ++) {
cprintf("0x%08x ", args[j]);
}
cprintf("\n");
print_debuginfo(eip - 1);
// ebp指针指向的位置向上一个地址为上一个函数的eip
eip = ((uint32_t *)ebp)[1];
// ebp指针指向的位置存储的上一个ebp的地址
ebp = ((uint32_t *)ebp)[0];
}
练习6:完善中断初始化和处理
为什么有中断?
操作系统需要对计算机系统中的各种外设进行管理,这就需要CPU
和外设能够相互通信才行,CPU
速度远快于外设,若采用通常的轮询(polling)机制
,则太浪费CPU
资源了。所以需要操作系统和CPU
能够一起提供某种机制,让外设在需要操作系统处理外设相关事件的时候,能够“主动通知”操作系统,即打断操作系统和应用的正常执行,让操作系统完成外设的相关处理,然后在恢复操作系统和应用的正常执行。这种机制称为中断
。
中断的类型
- 由
CPU
外部设备引起的外部事件如I/O中断、时钟中断、控制台中断等是异步产生的(即产生的时刻不确定),与CPU
的执行无关,我们称之为异步中断
,也称外部中断
- 在
CPU
执行指令期间检测到不正常的或非法的条件(如除零错、地址访问越界)所引起的内部事件称作同步中断
,也称内部中断
- 在程序中使用请求系统服务的系统调用而引发的事件,称作
陷入中断
,也称软中断,系统调用
简称trap
中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
- 当
CPU
收到中断时,会查找对应的中断描述符表(IDT)
,确定对应的中断服务例程 -
IDT
是一个8字节的描述符数组,IDT可以位于内存的任意位置,CPU通过IDT寄存器(IDTR)
的内容来寻址IDT
的起始地址。指令LIDT
和SIDT
用来操作IDTR
。 -
DT
的一个表项如下,4个字节
分别存储offset
的高位地址、段选择子和offset
低位地址
-
中断处理过程如下图
请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
查看SETGATE
宏定义
- 由代码看出
SETGATE
本质是设置生成一个4字节
的中断描述表项 -
gate
为中断描述符表项对应的数据结构,定义在mmu.h
为struct gatedesc
-
istrap
标识是中断还是系统调用,唯一区别在于,中断会清空IF
标志,不允许被打断 -
sel
与off
分别为中断服务例程的代码段与偏移量,dpl
为访问权限
#define SETGATE(gate, istrap, sel, off, dpl) { \
(gate).gd_off_15_0 = (uint32_t)(off) & 0xffff; \
(gate).gd_ss = (sel); \
(gate).gd_args = 0; \
(gate).gd_rsv1 = 0; \
(gate).gd_type = (istrap) ? STS_TG32 : STS_IG32; \
(gate).gd_s = 0; \
(gate).gd_dpl = (dpl); \
(gate).gd_p = 1; \
(gate).gd_off_31_16 = (uint32_t)(off) >> 16; \
}
查看vector.S
定义的中断号定义
- 保护模式下有
256个中断号
,0~31
是保留的, 用于处理异常和NMI
(不可屏蔽中断);32~255
由用户定义, 可以是设备中断或系统调用. - 所有的中断服务例程,最终都是跳到
__alltraps
进行处理 - 注意这里的标号对应的地址为代码段偏移量
.text
.globl __alltraps
.globl vector0
vector0:
pushl $0
pushl $0
jmp __alltraps
...
.globl vector255
vector255:
pushl $0
pushl $255
jmp __alltraps
# vector table
.data
.globl __vectors
__vectors:
.long vector0
.long vector1
...
.long vector255
由以上可填充idt_init
,完成作业
extern uintptr_t __vectors[];
int i;
for(i = 0 ; i < 256 ; i++) {
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
}
lidt(&idt_pd);
请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
通过之前的分析查看__alltraps
所在的trappentry.S
文件
- 压栈各种需要传递给中断服务例程的信息,形成
trapFrame
,调用trap
函数 - 注意进入这个函数前,
vector.S
中已经压栈了1,2个参数
.text
.globl __alltraps
__alltraps:
# push registers to build a trap frame
# therefore make the stack look like a struct trapframe
pushl %ds
pushl %es
pushl %fs
pushl %gs
pushal
# load GD_KDATA into %ds and %es to set up data segments for kernel
movl $GD_KDATA, %eax
movw %ax, %ds
movw %ax, %es
# push %esp to pass a pointer to the trapframe as an argument to trap()
pushl %esp
# call trap(tf), where tf=%esp
call trap
# pop the pushed stack pointer
popl %esp
- 最终调用了
trap_dispatch
根据中断号将中断分发给不同的服务例程
+IRQ_OFFSET
为32
,与之前32~255
由用户定义, 为设备中断或系统调用的描述一致. - 填充时钟中断响应代码,完成实验
void
trap(struct trapframe *tf) {
// dispatch based on what type of trap occurred
trap_dispatch(tf);
}
static void
trap_dispatch(struct trapframe *tf) {
char c;
switch (tf->tf_trapno) {
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) {
print_ticks();
ticks = 0;
}
break;
case IRQ_OFFSET + IRQ_COM1:
c = cons_getc();
cprintf("serial [%03d] %c\n", c, c);
break;
case IRQ_OFFSET + IRQ_KBD:
c = cons_getc();
cprintf("kbd [%03d] %c\n", c, c);
break;
//LAB1 CHALLENGE 1 : YOUR CODE you should modify below codes.
case T_SWITCH_TOU:
case T_SWITCH_TOK:
panic("T_SWITCH_** ??\n");
break;
case IRQ_OFFSET + IRQ_IDE1:
case IRQ_OFFSET + IRQ_IDE2:
/* do nothing */
break;
default:
// in kernel, it must be a mistake
if ((tf->tf_cs & 3) == 0) {
print_trapframe(tf);
panic("unexpected trap in kernel.\n");
}
}
}