练习1:理解通过make生成执行文件的过程。(要求在报告中写出对下述问题的回答)

实验过程

静态分析代码。

实验的目录结构如下:

.

├── boot

├── kern

│   ├── debug

│   ├── driver

│   ├── init

│   ├── libs

│   ├── mm

│   └── trap

├── libs

└── tools

其中./boot里面是bootloader的相关代码;

./kern里面是操作系统的相关代码;

./toos/sign.c描述了怎样把bootloader变成一个规范的主引导扇区。

问题解答

问题一

操作系统镜像文件ucore.img是如何一步一步生成的?(需要比较详细地解释Makefile中每一条相关命令和命令参数的含义,以及说明命令导致的结果)

输入 make V=@echo 命令,make工具便把目录下的文件进行了编译。通过设置V=@echo 参数,把编译过程打印了下来。大致如下:

  1. 先使用gcc命令,把./kern目录下的代码都编译成obj/kern/*/*.o文件;
  2. ld命令通过/tools/kern.ls文件配置,把obj/kern/*/*.o文件连接成bin/kern
  3. gcc命令,把boot目录下的文件编译成obj/boot/*.o文件;
  4. gcctools/sign.c编译成obj/sign/tools/sign.o
  5. ldobj/boot/*.o连接成obj/bootblock.o
  6. 使用第4步生成的obj/sign/tools/sign.o,将obj/bootblock.o文件规范化为,符合规范的硬盘住引导扇区的文件bin/bootblock
  7. dd命令创建了一个bin/ucore.img文件;
  8. dd命令把bin/bootblock写入bin/ucore.img文件;
  9. dd命令创bin/kernel写入bin/ucore.img文件。

命令及参数解释:

gcc: Linux下的C语言编译器。

ld:把一定量的目标文件跟档案文件连接起来,并重定位它们的数据,连接符号引用。一般,在编译一个程序时,最后一步就是运行'ld'。

用法:

ld [option] [objs...]

参数:

-o:指定输出文件名;
-e:指定程序的入口符号。
-m: 指定连接器
-N: 指定 可读写 的 正文 和 数据 节(section). 如果 输出格式 支持 Unix 风格的 幻数(magic number), 则 输出文件 标记为 OMAGIC.当 使用 `-N' 选项 时, linker 不做数据段 的 页对齐(page-align).
-e: 设置程序开端
-T: 等同于 -c 告诉 ld 从指定文件中读取连接命令.

dd:用指定大小的块拷贝一个文件,并在拷贝的同时进行指定的转换。

参数注释:

  1. if=文件名:输入文件名,缺省为标准输入。即指定源文件。< if=input file >
  2. of=文件名:输出文件名,缺省为标准输出。即指定目的文件。< of=output file >
  3. ibs=bytes:一次读入bytes个字节,即指定一个块大小为bytes个字节。
  4. obs=bytes:一次输出bytes个字节,即指定一个块大小为bytes个字节。
  5. bs=bytes:同时设置读入/输出的块大小为bytes个字节。
  6. cbs=bytes:一次转换bytes个字节,即指定转换缓冲区大小。
  7. skip=blocks:从输入文件开头跳过blocks个块后再开始复制。
  8. seek=blocks:从输出文件开头跳过blocks个块后再开始复制。
  9. 注意:通常只用当输出文件是磁盘或磁带时才有效,即备份到磁盘或磁带时才有效。
  10. count=blocks:仅拷贝blocks个块,块大小等于ibs指定的字节数。
  11. conv=conversion:用指定的参数转换文件。
    • ascii:转换ebcdic为ascii
    • ebcdic:转换ascii为ebcdic
    • ibm:转换ascii为alternate ebcdic
    • block:把每一行转换为长度为cbs,不足部分用空格填充
    • unblock:使每一行的长度都为cbs,不足部分用空格填充
    • lcase:把大写字符转换为小写字符
    • ucase:把小写字符转换为大写字符
    • swab:交换输入的每对字节
    • noerror:出错时不停止
    • notrunc:不截短输出文件
    • sync:将每个输入块填充到ibs个字节,不足部分用空(NUL)字符补齐。

/dev/zero: 是一个输入设备,你可你用它来初始化文件。该设备无穷尽地提供0(是ASCII 0 就是NULL),

问题二

一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?

问题一种提到,bootloader.o文件经过sign.o的操作后,变成符合规范的引导文件。所以,我们先来看看tools/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;
}
// 输出文件名和文件大小
printf("'%s' size: %lld bytes\n", argv[1], (long long)st.st_size);
// 如果文件长度大于510,则报错退出
if (st.st_size > 510) {
fprintf(stderr, "%lld >> 510!!\n", (long long)st.st_size);
return -1;
}
// 申请一个512长度的buf数组,并初始化为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数组的最后两位置为 0x55, 0xAA
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字节的buf数组里,然后给最后两字节赋值为了0x55和0xAA。

所以,我们可以猜测主引导扇区的规则如下:

  1. 大小为512字节
  2. 多余的空间填0
  3. 最后16位为0x55AA

网上搜了下资料,说

结束标志(占2个字节)其值为AA55,存储时低位在前,高位在后,即看上去是55AA(十六进制)。

练习2 使用qemu执行并调试lab1中的软件。

问题一

从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。

(2020-03-18 修改)

gdb 调试 BIOS 的方法可以看这里

因为运行在云主机上,没有 GUI。所以直接跑 make debug 会报找不到 gnome-terminal 的错。打印了下 debug 的执行过程如下:

qemu-system-i386 -S -s -parallel stdio -hda bin/ucore.img -serial null &
sleep 2
gnome-terminal -e gdb -q -tui -x tools/gdbinit

大概是这么个意思:

  1. qemu 加载镜像并停在最开始,然后放到后台运行。其中 -S 参数是把 cpu 停在最开始,-s 参数是在 tcp::1234gdb 连接,和 -gdb tcp::1234 作用一样。
  2. 等两秒,应该是在等 qemu 完全启动好。
  3. 开一个新的 gnome-terminal 窗口,并在里面执行命令 gdb -q -tui -x tools/gdbinit。因为我没有 gnome-terminal 所以就在一个新 shell 直接跑了里面的命令。

此时 tools/gdbinit 的内容为:

set architecture i8086
target remote :1234

gdb 窗口中输入 i r 命令可以查看寄存器的状态:

eax            0x0      0
ecx 0x0 0
edx 0x663 1635
ebx 0x0 0
esp 0x0 0x0
ebp 0x0 0x0
esi 0x0 0
edi 0x0 0
eip 0xfff0 0xfff0
eflags 0x2 [ IOPL=0 ]
cs 0xf000 61440
ss 0x0 0
ds 0x0 0
es 0x0 0
fs 0x0 0
gs 0x0 0
fs_base 0x0 0
gs_base 0x0 0
...

此时 cs=0xf000, eip=0xfff0,所以 cpu 下一条指令在cs:eip = 0xffff0,输入 x /2i 0xffff0 查看接下来执行的代码。

(gdb) x /2i 0xffff0
0xffff0: ljmp $0x3630,$0xf000e05b
0xffff7: das

qemu 命令中加上 -d in_asm -D bin/q.log 参数,可以把执行的汇编指令保存到日志文件 q.log 里。完整命令如下:

qemu -S -s -parallel stdio -hda bin/ucore.img -serial null \
-d in_asm -D bin/q.log

修改 tools/gdbinit,在 0x7c00 处设置断点,并 continue 然后依次关掉 qemugdb 直接在日志文件中查看从 0xffff00x7c00 直接运行的代码。

此时 tools/q.log 的内容为:

set architecture i8086
target remote :1234
break *0x7c00
continue

日志文件内容为:

----------------
IN:
0xfffffff0: ea 5b e0 00 f0 ljmpw $0xf000:$0xe05b ----------------
IN:
0x000fe05b: 2e 66 83 3e b8 60 00 cmpl $0, %cs:0x60b8
0x000fe062: 0f 85 b9 f0 jne 0xd11f ---------------- ... (省略20710行) ----------------
IN:
0x000edefa: c6 05 ee bd 0e 00 01 movb $1, 0xebdee
0x000edf01: 58 popl %eax
0x000edf02: 5b popl %ebx
0x000edf03: c3 retl ----------------
IN:
0x000ef79c: b9 ad 80 0f 00 movl $0xf80ad, %ecx
0x000ef7a1: 31 d2 xorl %edx, %edx
0x000ef7a3: 8d 44 24 0e leal 0xe(%esp), %eax
0x000ef7a7: e8 06 e4 ff ff calll 0xedbb2 ----------------
IN:
0x00007c00: fa cli

(更新结束)

问题二

在初始化位置0x7c00设置实地址断点,测试断点正常。

在gdb中执行以下命令

b *0x7c00
continue

发现程序执行到0x7c00处确实停下来了,说明断点正常。

问题三

从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。

执行make debug命令,启动qemu和gdb开始debug。

然后在gdb中输入b *0x7c00,在内存0x7c00处设置断点。

continue让程序继续执行,程序会在前面设置的0x7c00的断点处停下来。

输入x /10i $pc查看接下来的10条指令,得到如下输出:

 => 0x7c00:      cli
0x7c01: cld
0x7c02: xor %eax,%eax
0x7c04: mov %eax,%ds
0x7c06: mov %eax,%es
0x7c08: mov %eax,%ss
0x7c0a: in $0x64,%al
0x7c0c: test $0x2,%al
0x7c0e: jne 0x7c0a
0x7c10: mov $0xd1,%al

可以发现,这和boot/bootasm.S文件中的内容一致。通过单步跟踪,发现执行指令确实是bootasm.S中的指令,大致过程如下:

  1. 禁用中断 (cli)
  2. 复位操作方向标志(cld)
  3. 初始化ds, es, ss寄存器为0
  4. 激活A20地址线
  5. 加载全局描述符表 (gdt)
  6. 打开cr0 ( 开启保护模式)
  7. 切换到32位模式
  8. 设置ds, es, fs, gs, ss为0x10
  9. 设置栈顶指针、栈底指针
  10. 调用bootmain

上面最后一步跳转到bootmain中执行,接下来我们来看下bootmain中的执行过程:

  1. 从硬盘起始处读取4k内容到内存0x10000处
  2. 加载各程序段
  3. 调用ELFHDR->e_entry的入口函数

可以看出上面最后调用调用ELFHDR->e_entry的入口函数,即切换到kernel处了。

bootblock.asmbootasm.Sbootmain.c都内容都整合到一起了。

并且bootblock.asm中每行代码下面都带有地址信息,和用gdb单步调试的时候基本一致。

问题四

自己找一个bootloader或内核中的代码位置,设置断点并进行测试。

break kern_init

练习3 分析bootloader进入保护模式的过程。

分析过程详见练习2问题一,进入保护模式的过程如下:

  1. 激活A20地址线
  2. 加载全局描述符表 (gdt)
  3. 打开cr0 ( 开启保护模式)

为何开启A20,以及如何开启A20

为何开启A20:若不开启A20,cpu在访问地址空间时第20位始终会是0,这时只能访问奇数段不能访问偶数段;开启A20后,cpu可访问连续地址空间。

如何开启A20:

  1. 等待8042 Input buffer为空;
  2. 发送Write 8042 Output Port (P2)命令到8042 Input buffer;
  3. 等待8042 Input buffer为空;
  4. 将8042 Output Port(P2)得到字节的第2位置1,然后写入8042 Input buffer;

如何初始化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

如何使能和进入保护模式

将cr0寄存器置1

练习4 分析bootloader加载ELF格式的OS的过程

问题一

bootloader如何读取硬盘扇区的?

读硬盘扇区的代码如下:

// bootmain.c
/* readsect - read a single sector at @secno into @dst */
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);
}

outb()可以看出这里是用LBA模式的PIO(Program IO)方式来访问硬盘的。从磁盘IO地址和对应功能表可以看出,该函数一次只读取一个扇区。

IO地址 功能
0x1f0 读数据,当0x1f7不为忙状态时,可以读。
0x1f2 要读写的扇区数,每次读写前,你需要表明你要读写几个扇区。最小是1个扇区
0x1f3 如果是LBA模式,就是LBA参数的0-7位
0x1f4 如果是LBA模式,就是LBA参数的8-15位
0x1f5 如果是LBA模式,就是LBA参数的16-23位
0x1f6 第0~3位:如果是LBA模式就是24-27位 第4位:为0主盘;为1从盘
0x1f7 状态和命令寄存器。操作时先给命令,再读取,如果不是忙状态就从0x1f0端口读数据

其中insl的实现如下:

// x86.h
static inline void
insl(uint32_t port, void *addr, int cnt) {
asm volatile (
"cld;"
"repne; insl;"
: "=D" (addr), "=c" (cnt)
: "d" (port), "0" (addr), "1" (cnt)
: "memory", "cc");
}

问题二

bootloader是如何加载ELF格式的OS?

  1. 从硬盘读了8个扇区数据到内存0x10000处,并把这里强制转换成elfhdr使用;
  2. 校验e_magic字段;
  3. 根据偏移量分别把程序段的数据读取到内存中。

练习5 实现函数调用堆栈跟踪函数

我们需要在lab1中完成kdebug.c中函数print_stackframe的实现,可以通过函数print_stackframe来跟踪函数调用堆栈中记录的返回地址。

首先,可以通过read_ebp()read_eip()函数来获取当前ebp寄存器和eip 寄存器的信息。

因为程序在执行一个一个函数前,会依次把 参数、返回地址、当前epb入栈。如下图所示

+|  栈底方向    | 高位地址
| ... |
| ... |
| 参数3 |
| 参数2 |
| 参数1 |
| 返回地址 |
| 上一层[ebp] | <-------- [ebp]
| 局部变量 | 低位地址

所以,当我们拿到 ebp 时,就可以知道上层函数的所有信息,即ebp*ebp的内容。所以ebp+2开始就是上一层的(可能的)参数,ebp+1即是当前层的返回地址(可以当作上一层的eip)。

实现过程代码如下:

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 (unit32_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]
*/
uint32_t ebp = read_ebp(), eip = read_eip();
for (int i = 0; i < STACKFRAME_DEPTH && ebp != 0; i++) {
cprintf("ebp: 0x%08x eip: 0x%08x args:", ebp, eip);
for (int ij= 0; j < 4; j++) {
cprintf(" 0x%08x", ((uint32_t*)(ebp + 2))[j]);
}
cprintf("\n");
print_debuginfo(eip - 1);
eip = *((uint32_t*) ebp + 1);
ebp = *((uint32_t*) ebp);
}
}

执行 make qemu得到如下结果:

(THU.CST) os is loading ...

Special kernel symbols:
entry 0x00100000 (phys)
etext 0x0010325f (phys)
edata 0x0010ea16 (phys)
end 0x0010fd20 (phys)
Kernel executable memory footprint: 64KB
ebp: 0x00007b38 eip: 0x00100a27 args: 0x0d210000 0x00940010 0x00940001 0x7b680001
kern/debug/kdebug.c:305: print_stackframe+21
ebp: 0x00007b48 eip: 0x00100d21 args: 0x007f0000 0x00000010 0x00000000 0x00000000
kern/debug/kmonitor.c:125: mon_backtrace+10
ebp: 0x00007b68 eip: 0x0010007f args: 0x00a10000 0x00000010 0x7b900000 0x00000000
kern/init/init.c:48: grade_backtrace2+19
ebp: 0x00007b88 eip: 0x001000a1 args: 0x00be0000 0x00000010 0x00000000 0x7bb4ffff
kern/init/init.c:53: grade_backtrace1+27
ebp: 0x00007ba8 eip: 0x001000be args: 0x00df0000 0x00000010 0x00000000 0x00000010
kern/init/init.c:58: grade_backtrace0+19
ebp: 0x00007bc8 eip: 0x001000df args: 0x00500000 0x00000010 0x00000000 0x00000000
kern/init/init.c:63: grade_backtrace+26
ebp: 0x00007be8 eip: 0x00100050 args: 0x7d6e0000 0x00000000 0x00000000 0x00000000
kern/init/init.c:28: kern_init+79
ebp: 0x00007bf8 eip: 0x00007d6e args: 0x7c4f0000 0xfcfa0000 0xd88ec031 0xd08ec08e
<unknow>: -- 0x00007d6d --

其中,最深一层对应着第一个使用堆栈的函数,即boot/bootmain.c中的bootmain。在boot/bootasm.S中的第 68 行可以看到,bootloader 设置的堆栈从 0x7c00 开始,然后 call bootmain。所以 bootmainebp0x7bf8

练习6 完善中断初始化和处理

请完成编码工作和回答如下问题:

  1. 中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?

中断描述符表的一个表项占8字节。根据中断类型的不同,其中每个字节代表的意义也不同。

一个表项的结构如下:

可以看到,其中第16到31位为中断例程的段选择子,第0到15位 和 第48到63位分别为偏移量的地位和高位。这几个数据一起决定了中断处理代码的入口地址。

  1. 请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */
void
idt_init(void) {
// (1) 拿到外部变量 __vector
extern uintptr_t __vectors[];
// (2) 使用SETGATE宏,对中断描述符表中的每一个表项进行设置
for (int i = 0; i < 256; i++) {
uint16_t istrap = 0, off = 0, dpl = 3;
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
}
// set for switch from user to kernel
SETGATE(idt[T_SWITCH_TOU], 0, GD_KTEXT, __vectors[T_SWITCH_TOU], DPL_USER);
SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
// (3) 调用lidt函数,设置中断描述符表
lidt(&idt_pd);
}
  1. 请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。

在函数体头部声明一个静态变量用于计数

static int32_t tick_count = 0;

然后,在时间中断 IRQ_OFFSET + IRQ_TIMER的case中添加判断打印的条件:

tick_count++;
if (0 == (tick_count % TICK_NUM)) {
print_ticks();
}

ucore_lab1的更多相关文章

  1. Lab_1:练习4——分析bootloader加载ELF格式的OS的过程

    一.实验内容 通过阅读bootmain.c,了解bootloader如何加载ELF文件.通过分析源代码和通过qemu来运行并调试bootloader&OS, bootloader如何读取硬盘扇 ...

随机推荐

  1. MVC笔记(一)

    1 MVC介绍 MVC是一个编程思想. 是一种设计模式 思想: 将一个功能分解成3个部分, M: Model (模型) 处理数据相关的逻辑 V: View (视图) 显示页面 C: Controlle ...

  2. BigInteger类(高精度整型)

    位置:java.math.BigInteger 作用:提供高精度整型数据类型及相关操作 一.基本介绍 BigInteger为不可变的任意精度的整数(对象创建后无法改变,每次运算均会产生一个新的对象). ...

  3. spring boot不同环境读取不同配置

    具体做法: 不同环境的配置设置一个配置文件,例如:dev环境下的配置配置在application-dev.properties中:prod环境下的配置配置在application-prod.prope ...

  4. [Angular] ngx-formly (AKA angular-formly for Angular latest version)

    In our dynamic forms lessons we obviously didn’t account for all the various edge cases you might co ...

  5. 用PHP去实现静态化

    我们在PHP站点开发过程中为了站点的推广或者SEO的须要,须要对站点进行一定的静态化,这里设计到什么是静态页面,所谓的静态页面.并非页面中没有动画等元素,而是指网页的代码都在页面中,即不须要再去执行P ...

  6. Unity3D_c#脚本注意要点

    1. Inherit from MonoBehaviour 继承自MonoBehaviour All behaviour scripts must inherit from MonoBehaviour ...

  7. c 语言函数可变參数的处理

    /************************************************************************* > File Name: va_list.c ...

  8. netty可靠性

    Netty的可靠性 首先,我们要从Netty的主要用途来分析它的可靠性,Netty目前的主流用法有三种: 1) 构建RPC调用的基础通信组件,提供跨节点的远程服务调用能力: 2) NIO通信框架,用于 ...

  9. 基于nginx的最基础的TCP代理,经过测试可通!

    测试操作系统为win7,nginx版本为1.9.4. 在本机上编写java程序一个socket服务类SocketServer,监听端口为8889, (增加了一个SocketServerThread线程 ...

  10. Spark RDD概念学习系列之RDD的五大特征

    不多说,直接上干货! RDD的五大特征 分区--- partitions 依赖--- dependencies() 计算函数--- computer(p,context) 分区策略(Pair RDD) ...