wqpw_blog

= =

View on GitHub
13 June 2020

ucore_lab1_report

by wqpw

练习1

理解通过make生成执行文件的过程

操作系统镜像文件ucore.img是如何一步一步生成的

首先可以看到Makefile的第6行定义了V:=@,后面的许多命令通过$(V)xxx这样把@加在开头从而不显示,所以可以在执行make时重新定义V为空(make "V=")来把执行的命令显示出来。另外一种可以看到执行的命令的方法是make --just-print,这样会只打印命令而不会执行(除了表达式里的命令,因为要求值)。

gcc的一些选项

-c 编译但不链接 -o 指定输出
-g -ggdb -gstabs 产生调试信息;用于gdb;生成stabs格式的调试信息
-Wall 显示全部警告
-O2 优化,等级2
-m32 -march=i686 生成32位架构为i686的文件
-fno-builtin 除非用__builtin_前缀,否则不进行builtin函数的优化
-fno-PIC 不生成地址无关代码
-nostdinc 不搜索系统的标准头文件文件夹,只搜索-I指定的文件夹,只能用自己实现的标准库
-fno-stack-protector 不检查栈溢出
-Os 优化大小

ld的一些选项

-m elf_i386 模拟为i386上的连接器
-nostdlib 不用标准库,只搜索命令中-L指定的文件夹
-T tools/kernel.ld 指定链接器脚本,里面包含最后的可执行文件的格式和各个段的设置
-N 设置代码段数据段可读可写,不链接共享库
-e start 设置start为程序开始执行的位置,bootasm.S:13
-Ttext 0x7C00 定位text段到目标文件绝对地址0x7c00,结果见obj/bootblock.asm

objdump:-S 反编译为汇编 -t 打印符号表和入口
objcopy:-S 不复制源文件的符号和重定位及调试信息 -O binary 生成raw binary file,没有任何结构,只有代码和数据

ld -m elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o
objdump -S obj/bootblock.o > obj/bootblock.asm
objcopy -S -O binary obj/bootblock.o obj/bootblock.out
bin/sign obj/bootblock.out bin/bootblock

总的流程是首先编译内核和自己写的库函数并一起链接为bin/kernel。然后编译bootloader,用ld链接时指定代码段从0x7c00开始并用objcopy生成raw binary,然后用bin/sign程序将文件扩充为512字节且设置最后两个字节为(0x55AA)。其中还用objdump生成了kernel和bootloader的汇编代码和kernel的符号表文件。最后用dd来生成ucore.img

dd if=/dev/zero of=bin/ucore.img count=10000
#复制10000个0x00保存为bin/ucore.img
dd if=bin/bootblock of=bin/ucore.img conv=notrunc
#将bootloader复制到ucore.img,notrunc保持ucore.img大小不变
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
#把kernel复制到ucore.img,偏移为obs*seek, obs默认512字节

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

从tools/sign.c的代码可知是第一个扇区并且最后两个字节是0x55aa.

练习2

修改tools/gdbinit

set architecture i8086
target remote :1234

因为我用的是xubuntu,所以Makefile里的gnome-terminal要改成xfce4-terminal.

终端执行make debug-nox,可以看到gdb停在bios处(FFFFFFF0H). gdb的操作常用的就是p,x,n,s,ni,si,shell,c,b,d,r,r < xxx,file,define啥的,格式之类看看帮助行了,不再多说.

1

设置断点到初始化位置b *0x7c00,然后c继续执行,和objs/bootblock.asm比较.

2

可以看到前5条指令是一样的,当然后面也应该是一样的. 在0x7d11下断点,进入bootmain函数解析elf然后call eax执行kernel.

3

练习3

分析bootloader进入保护模式的过程. Intel 80386 Programmer’s Reference Manual(1986)

%cs=0 $pc=0x7c00,进入后

.code16                                             # Assemble for 16-bit mode
    cli                                             # 屏蔽cpu内部可屏蔽中断请求
    cld                                             # 进行字符串操作时si,di会递增
    xorw %ax, %ax                                   # ax=0
    movw %ax, %ds                                   # ds=ax
    movw %ax, %es                                   # es=ax
    movw %ax, %ss                                   # ss=ax

接下来是使能A20 Gate,这样才可以访问超过1MB的内存,否则会“回卷”. 据说是因为多年前某些目光短浅的程序员偏要利用这个特性来写程序,为了保证向下兼容才不得不加上A20 Gate. A20 Gate由8042键盘控制器来控制,因为当时8042键盘控制器刚好有个闲置的引脚……通过将键盘控制器上的A20线置于高电位,全部32条地址线可用,可以访问4G的内存空间。

更详细的资料可以参考
https://wiki.osdev.org/%228042%22_PS/2_Controller#Data_Port
https://wiki.osdev.org/A20_Line

seta20.1:
    inb $0x64, %al              # 读64端口,也就是8042的status register
    testb $0x2, %al
    jnz seta20.1                # 如果输入缓冲有数据,再等等
    movb $0xd1, %al             # 0xd1 -> port 0x64
    outb %al, $0x64             # 发送0xd1命令到Controller,指示下一个写入0x60的字节写出到Output Port
seta20.2:
    inb $0x64, %al              # 同上
    testb $0x2, %al
    jnz seta20.2
    movb $0xdf, %al             # 0xdf -> port 0x60
    outb %al, $0x60             # 0xdf = 11011111, 使Output Port第二位为1打开A20 Gate

加载全局段描述符表

lgdt gdtdesc #从gdtdesc处读取6个字节(段上限16位,gdt起始地址32位)赋值给gdtr
# ......
# asm.h 里的两个宏,用来定义段描述符
#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)
/* Application segment type bits */
#define STA_X       0x8     // Executable segment
#define STA_E       0x4     // Expand down (non-executable segments)
#define STA_C       0x4     // Conforming code segment (executable only)
#define STA_W       0x2     // Writeable (non-executable segments)
#define STA_R       0x2     // Readable (executable segments)
#define STA_A       0x1     // Accessed
# ......
# bootasm.S:79及之后
.p2align 2                                          # 4字节对齐
gdt:
    SEG_NULLASM                                     # 第一个是空段,处理器不能使用
    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                                      # 段上限(有效字节数-1)sizeof(gdt) - 1
                                                    # 三个段,3*8-1=23=0x17
    .long gdt                                       # gdt起始地址

段描述符的各个位的解释

4

struct segdesc { //kern/mm/mmu.h:97
    unsigned sd_lim_15_0 : 16;        // low bits of segment limit
    unsigned sd_base_15_0 : 16;        // low bits of segment base address
    unsigned sd_base_23_16 : 8;        // middle bits of segment base address
    unsigned sd_type : 4;            // segment type (see STS_ constants)
    unsigned sd_s : 1;                // 0 = system, 1 = application
    unsigned sd_dpl : 2;            // descriptor Privilege Level
    unsigned sd_p : 1;                // present
    unsigned sd_lim_19_16 : 4;        // high bits of segment limit
    unsigned sd_avl : 1;            // unused (available for software use)
    unsigned sd_rsv1 : 1;            // reserved
    unsigned sd_db : 1;                // 0 = 16-bit segment, 1 = 32-bit segment
    unsigned sd_g : 1;                // granularity: limit scaled by 4K when set
    unsigned sd_base_31_24 : 8;        // high bits of segment base address
};

SEG_ASM(type,base,lim)分析:

type:4位,base:32位 ,lim:20位
.word (((lim) >> 12) & 0xffff) # lim的15-0位
.word ((base) & 0xffff) # base的15-0位
.byte (((base) >> 16) & 0xff) # base的23-16位
.byte (0x90 | (type)) # 1001(段存在位1,最高特权级00,1:代码or数据段),0000|type 段类型
.byte (0xC0 | (((lim) >> 28) & 0xf)) # 1100(段界限以4k为单位,32位段,不用,保留),lim的19-16位
.byte (((base) >> 24) & 0xff) # base的31-24位

计算一下SEG_NULLASM, SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff), SEG_ASM(STA_W, 0x0, 0xffffffff)

0000000000000000
1111111111111111, 0000000000000000, 00000000, 10011010, 11001111, 00000000
ffff0000009acf00
1111111111111111, 0000000000000000, 00000000, 10010010, 11001111, 00000000
ffff00000092cf00

5 实际比较

0x93是因为调试的时候已经加载这个段到ds了,所以cpu设置了STA_A,type变成0011. 这里设置的gdt的base和lim使得进入保护模式后虚拟地址等于物理地址,保证了内存映射不变.

加载完gdt后,通过修改cr0的PE(Protection Enable)位(bit0位)为1来进入保护模式.

    movl %cr0, %eax
    orl $CR0_PE_ON, %eax # orl 0x1,%eax
    movl %eax, %cr0

因为进入(受)保护(的虚拟内存地址)模式后对机器语言指令的解释会改变还有流水线的原因,需要jmp到下个一指令.
ljmp $PROT_MODE_CSEG, $protcseg 使cs=0x8(0000000000001000后3位为TI,RPL)也就是指向gdt+1的段, eip=$protcseg

    ljmp $PROT_MODE_CSEG, $protcseg

.code32                                             # Assemble for 32-bit mode
protcseg:
    # Set up the protected-mode data segment registers
    movw $PROT_MODE_DSEG, %ax                       # 为0x10(0000000000010000) gdt+2的段
    movw %ax, %ds                                   # 初始化了段寄存器
    movw %ax, %es
    movw %ax, %fs
    movw %ax, %gs
    movw %ax, %ss

最后设置ebp,esp建立堆栈并且进入c语言写的bootmain函数

    movl $0x0, %ebp # 栈基地址为0x0
    movl $start, %esp # 栈顶地址为$start, 0x7c00. 所以栈的范围是0x0-0x7c00
    call bootmain

练习4

分析bootloader加载ELF格式的OS的过程.

读磁盘

bootmain函数里首先读取了磁盘开头的1页(8个扇区,4096字节)到内存的0x10000处:readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

具体看一下怎样操作磁盘:

#define SECTSIZE  512
#define ELFHDR    ((struct elfhdr *)0x10000)
static void
readseg(uintptr_t va, uint32_t count, uint32_t offset) {
    // va是要写入(内存的)地址, count是要读的字节数, offset是我们要读的数据在磁盘上从0开始的偏移
    // 末尾地址就是va+要读的字节数
    uintptr_t end_va = va + count;
    // 对齐边界,保证原来的va开始的数据就是offset开始的数据
    va -= offset % SECTSIZE;
    // 用偏移算要读的数据开始的扇区号, kernel在1号扇区(第二个扇区),总是跳过主引导扇区
    uint32_t secno = (offset / SECTSIZE) + 1;
    // 一个一个扇区的读取,最后真正的"end_va"可能会超出end_va一点,不过大概没啥影响
    for (; va < end_va; va += SECTSIZE, secno ++) {
        readsect((void *)va, secno);
    }
}

可以参考ATAPI, PCI IDE Controller

/* 读取secno号扇区到内存dst处 */
static void
readsect(void *dst, uint32_t secno) {
    waitdisk();
    outb(0x1F2, 1);                         // 要读写的扇区数,1
    outb(0x1F3, secno & 0xFF);              // LBA(logical block address)7-0位
    outb(0x1F4, (secno >> 8) & 0xFF);       // 15-8位
    outb(0x1F5, (secno >> 16) & 0xFF);      // 23-16位
    outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0); //第7位必须为1;第6位为1=LBA模式;第5位必须为1;第4位为0主盘;LBA27-24位
    outb(0x1F7, 0x20);                      // cmd 0x20 - PIO Read, read sectors
    waitdisk();
    // 读扇区
    insl(0x1F0, dst, SECTSIZE / 4); //除以4,因为以dw为单位
}
/* 等磁盘准备好 */
static void
waitdisk(void) { //状态和命令寄存器返回0x40表示准备就绪
    while ((inb(0x1F7) & 0xC0) != 0x40);
}
// libs/x86.h
static inline uint8_t
inb(uint16_t port) { //读端口的数据(1字节)并返回
    uint8_t data;
    asm volatile ("inb %1, %0" : "=a" (data) : "d" (port));
    return data;
}
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");
}
static inline void
outb(uint16_t port, uint8_t data) { //向端口写1字节
    asm volatile ("outb %0, %1" :: "a" (data), "d" (port));
}

把tools/gdbinit改一改,单步跟一下:

file obj/bootblock.o
target remote :1234
b bootmain.c:68
c

可以看到最后insl(0x1F0, dst, SECTSIZE / 4);编译成了:

mov    edi,esi # edi就是此时readseg的for循环里va的值
mov    ecx,0x80 # 512/4
mov    edx,0x1f0 # 要从0x1f0读数据
cld              #字符串操作时自动递增di
repnz ins DWORD PTR es:[edi],dx # 从dx端口读取ecx个dword到es:[edi]

这样就可以读取一个扇区.

加载os

上面磁盘读完后,ELF格式的os的数据从内存中0x10000开始. 所以0x10000开始就是ELF文件头(下面的e_magic),数据结构如下:

struct elfhdr {
    uint32_t e_magic;     // must equal 0x464C457FU ELF魔数
    uint8_t e_elf[12];
    uint16_t e_type;      // 1=relocatable, 2=executable, 3=shared object, 4=core image
    uint16_t e_machine;   // 3=x86, 4=68K, etc.
    uint32_t e_version;   // file version, always 1
    uint32_t e_entry;     // entry point if executable
    uint32_t e_phoff;     // file position of program header or 0
    uint32_t e_shoff;     // file position of section header or 0
    uint32_t e_flags;     // architecture-specific flags, usually 0
    uint16_t e_ehsize;    // size of this elf header
    uint16_t e_phentsize; // size of an entry in program header
    uint16_t e_phnum;     // number of entries in program header or 0
    uint16_t e_shentsize; // size of an entry in section header
    uint16_t e_shnum;     // number of entries in section header or 0
    uint16_t e_shstrndx;  // section number that contains section name strings
};

bootmain.c:92做了一个是否为合法ELF的判断:

6

x/4b 0x10000可以看到确实相等.

struct proghdr {
    uint32_t p_type;   // loadable code or data, dynamic linking info,etc.
    uint32_t p_offset; // file offset of segment
    uint32_t p_va;     // virtual address to map segment
    uint32_t p_pa;     // physical address, not used
    uint32_t p_filesz; // size of segment in file
    uint32_t p_memsz;  // size of segment in memory (bigger if contains bss)
    uint32_t p_flags;  // read/write/execute bits
    uint32_t p_align;  // required alignment, invariably hardware page size
};

readelf -l bin/kernel查看程序头,描述了如何把section映射到segment

7

struct proghdr { //elf.h 程序头结构
    uint32_t p_type;   // loadable code or data, dynamic linking info,etc.
    uint32_t p_offset; // file offset of segment
    uint32_t p_va;     // virtual address to map segment
    uint32_t p_pa;     // physical address, not used
    uint32_t p_filesz; // size of segment in file
    uint32_t p_memsz;  // size of segment in memory (bigger if contains bss)
    uint32_t p_flags;  // read/write/execute bits
    uint32_t p_align;  // required alignment, invariably hardware page size
};
    struct proghdr *ph, *eph;
    // 第一个程序头的地址,根据readelf为0x34=52
    ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
    // eph = 0x34 + 3*32 = 0x94;
    eph = ph + ELFHDR->e_phnum;
    // 读取每个segment到p_va对应的地址,ph++并不是ph+=1
    for (; ph < eph; ph ++) { //注意readseg自动跳过了0号扇区,偏移0就是kernel的开始
        readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
    }
    // ELF文件0x1000位置后面的0xd1ec比特被载入内存0x00100000
    // ELF文件0xf000位置后面的0x1d20比特被载入内存0x0010e000

8 来自《C语言参考手册》

关于ELF格式等更多内容,可以参考《程序员的自我修养:链接、装载与库》第3,6章有关内容.

这样整个程序就已经加载到了内存中,可以执行了:

    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

程序入口地址就是ELFHDR->e_entry的值,利用函数指针的方式执行.

练习5

实现函数调用堆栈跟踪函数(kdebug.c中的print_stackframe)

首先复习下:
C语言函数调用栈(一)
C语言函数调用栈(二)

docs和代码里的提示也说的很清楚了. ss:ebp指向的堆栈位置储存着caller的ebp,以此为线索可以得到所有使用堆栈的函数ebp。ss:ebp+4指向caller调用时的eip,ss:ebp+8等是(可能的)参数。

void
print_stackframe(void) {
    uint32_t ebp = read_ebp(), eip = read_eip();
    while (ebp != 0) {
        cprintf("ebp:0x%08x eip:0x%08x ", ebp, eip);
        cprintf("args:0x%08x 0x%08x 0x%08x 0x%08x\n", *((uint32_t*)(ebp+8)), *((uint32_t*)(ebp+12)), *((uint32_t*)(ebp+16)), *((uint32_t*)(ebp+20)));
        print_debuginfo(eip-1);
        eip = *((uint32_t*)(ebp+4));
        ebp = *((uint32_t*)ebp);
    }
}

执行make qemu的结果:

9

最后一行是ebp:0x00007bf8 eip:0x00007d72 args:0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8.
通过调试可以知道0x7bf8是bootmain(第一个使用堆栈的函数)的ebp,0x7d72是bootmain+97,因为栈的范围是0-0x7c00(返回地址和前一个ebp入栈再mov ebp,esp后ebp为0x7bf8)所以后面的args就是把0x7c00到0x7c0c当成了“前一个函数调用bootmain时的四个参数”输出,而实际上是bootloader的前几条指令.

10

参考答案,本质上是一样的,没注意有STACKFRAME_DEPTH这个宏

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

练习6

完善中断初始化和处理

中断描述符表一个表项占8字节,中断处理代码的入口由段选择符和偏移组成,段选择符为16-31位,偏移为0-15和48-63位.

struct gatedesc {
    unsigned gd_off_15_0 : 16;        // low 16 bits of offset in segment
    unsigned gd_ss : 16;            // segment selector
    unsigned gd_args : 5;            // # args, 0 for interrupt/trap gates
    unsigned gd_rsv1 : 3;            // reserved(should be zero I guess)
    unsigned gd_type : 4;            // type(STS_{TG,IG32,TG32})
    unsigned gd_s : 1;                // must be 0 (system)
    unsigned gd_dpl : 2;            // descriptor(meaning new) privilege level
    unsigned gd_p : 1;                // Present
    unsigned gd_off_31_16 : 16;        // high bits of offset in segment
};

完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。

void
idt_init(void) {
    extern uintptr_t __vectors[];
    for (int i = 0; i < 256; i++) {
        SETGATE(idt[i], 0, KERNEL_CS, __vectors[i], DPL_KERNEL);
    }
    //用户态转内核态的中断,要DPL_USER
    SETGATE(idt[T_SWITCH_TOK], 1, KERNEL_CS, __vectors[T_SWITCH_TOK], DPL_USER);
    lidt(&idt_pd);
}

完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。

static void
trap_dispatch(struct trapframe *tf) {
    char c;
    switch (tf->tf_trapno) {
    case IRQ_OFFSET + IRQ_TIMER:
        ticks++;
        if (ticks % TICK_NUM == 0) //参考kern/driver/clock.c
            print_ticks();
        break;
        //后略

最终效果:

11

扩展challenge 1

好像有点麻烦,研究下答案吧. 添加了两部分代码. 设置了0x79中断用于切换到用户态,0x80中断用于切换到内核态. 要注意init.c里pmm_init重新设置了gdt,添加了用户态的代码段和数据段. 当目的代码段的DPL和CPL不同时也就是特权级发生变化时,我们需要切换到对应的栈并保存一些必要的信息,返回的时候都要恢复.

12

13 来源

//kern/trap/trap.h
#define T_SWITCH_TOU 120
#define T_SWITCH_TOK 121
//kern/init/init.c
static void
lab1_switch_to_user(void) {
    asm volatile (
        "sub $0x8, %%esp \n"  //从中断返回时,会多pop两位,并用这两位的值更新ss,sp,损坏堆栈
        "int %0 \n"           //所以先把栈压两位
        "movl %%ebp, %%esp"   //并且返回后修复esp
        :
        : "i"(T_SWITCH_TOU)
    );
}
static void
lab1_switch_to_kernel(void) {
    asm volatile (
        "int %0 \n"
        "movl %%ebp, %%esp \n" //从中断返回时esp仍在TSS指示的堆栈中,要在返回后修复esp
        :
        : "i"(T_SWITCH_TOK)
    );
}
//kern/trap/trap.c:trap_dispatch
struct trapframe switchk2u, *switchu2k;
/* ...... */
case T_SWITCH_TOU:
    if (tf->tf_cs != USER_CS) {
        switchk2u = *tf;
        switchk2u.tf_cs = USER_CS;
        switchk2u.tf_ds = switchk2u.tf_es = switchk2u.tf_ss = USER_DS;
        switchk2u.tf_esp = (uint32_t)tf + sizeof(struct trapframe) - 8;
        // 将调用io所需权限降低
        // if CPL > IOPL, then cpu will generate a general protection.
        switchk2u.tf_eflags |= FL_IOPL_MASK;
        // set temporary stack
        // then iret will jump to the right stack
        *((uint32_t *)tf - 1) = (uint32_t)&switchk2u;
    }
    break;
case T_SWITCH_TOK:
    if (tf->tf_cs != KERNEL_CS) {
        tf->tf_cs = KERNEL_CS;
        tf->tf_ds = tf->tf_es = KERNEL_DS;
        tf->tf_eflags &= ~FL_IOPL_MASK;
        switchu2k = (struct trapframe *)(tf->tf_esp - (sizeof(struct trapframe) - 8));
        memmove(switchu2k, tf, sizeof(struct trapframe) - 8);
        *((uint32_t *)tf - 1) = (uint32_t)switchu2k;
    }
    break;
/* ...... */
tags: blog