清华大学OS操作系统实验lab1练习知识点汇总

时间:2022-09-18 00:54:19

lab1知识点汇总

还是有很多问题,但是我觉得我需要在查看更多资料后回来再理解,学这个也学了一周了,看了大量的资料。。。还是它们自己的80386手册和lab的指导手册觉得最准确,现在我就把这部分知识做一个汇总,也为之后的lab打下坚实的基础。80386真的难啊,比mips复杂多了。。顿时觉得我们学的都是小菜。。

下面这些知识来源于:

  • 实验指导书和答案
  • 80386手册
  • mooc视频
  • 8086程序设计指导这本书
  • 网上的博客

lab1练习汇总

练习之所以被老师当做练习,一定有它重要的地方,所以我们先把练习有关的知识点汇总一下:

练习1

知识点包括:

  • ucore.img的生成过程
  • makefile 的相关语法(还是一点没懂)
  • gcc dd ld 等命令
  • 符合规范的硬盘主引导扇区

Makefile gcc dd ld 等相关知识以及ucore.img的生成过程

首先是makefile 相关知识,然而这个makefile是真的复杂。。比我们的复杂多了。。下面说一说这里的知识。

老师视频里说了,重点掌握ucore.img的形成过程,老师教的方法是利用 make V= 命令把过程信息打印出来,打印如下:
+ cc kern/init/init.c gcc -Ikern/init/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/init/init.c -o obj/kern/init/init.o + cc kern/libs/readline.c gcc -Ikern/libs/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/libs/readline.c -o obj/kern/libs/readline.o + cc kern/libs/stdio.c gcc -Ikern/libs/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/libs/stdio.c -o obj/kern/libs/stdio.o + cc kern/debug/kdebug.c gcc -Ikern/debug/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/debug/kdebug.c -o obj/kern/debug/kdebug.o + cc kern/debug/kmonitor.c gcc -Ikern/debug/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/debug/kmonitor.c -o obj/kern/debug/kmonitor.o + cc kern/debug/panic.c gcc -Ikern/debug/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/debug/panic.c -o obj/kern/debug/panic.o + cc kern/driver/clock.c gcc -Ikern/driver/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/driver/clock.c -o obj/kern/driver/clock.o + cc kern/driver/console.c gcc -Ikern/driver/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/driver/console.c -o obj/kern/driver/console.o + cc kern/driver/intr.c gcc -Ikern/driver/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/driver/intr.c -o obj/kern/driver/intr.o + cc kern/driver/picirq.c gcc -Ikern/driver/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/driver/picirq.c -o obj/kern/driver/picirq.o + cc kern/trap/trap.c gcc -Ikern/trap/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/trap/trap.c -o obj/kern/trap/trap.o + cc kern/trap/trapentry.S gcc -Ikern/trap/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/trap/trapentry.S -o obj/kern/trap/trapentry.o + cc kern/trap/vectors.S gcc -Ikern/trap/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/trap/vectors.S -o obj/kern/trap/vectors.o + cc kern/mm/pmm.c gcc -Ikern/mm/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/mm/pmm.c -o obj/kern/mm/pmm.o + cc libs/printfmt.c gcc -Ilibs/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -c libs/printfmt.c -o obj/libs/printfmt.o + cc libs/string.c gcc -Ilibs/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -c libs/string.c -o obj/libs/string.o + ld bin/kernel ld -m elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel obj/kern/init/init.o obj/kern/libs/readline.o obj/kern/libs/stdio.o obj/kern/debug/kdebug.o obj/kern/debug/kmonitor.o obj/kern/debug/panic.o obj/kern/driver/clock.o obj/kern/driver/console.o obj/kern/driver/intr.o obj/kern/driver/picirq.o obj/kern/trap/trap.o obj/kern/trap/trapentry.o obj/kern/trap/vectors.o obj/kern/mm/pmm.o obj/libs/printfmt.o obj/libs/string.o + cc boot/bootasm.S gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o + cc boot/bootmain.c gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootmain.c -o obj/boot/bootmain.o + 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: 488 bytes build 512 bytes boot sector: 'bin/bootblock' success! 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.0895198 s, 57.2 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.000186759 s, 2.7 MB/s dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc 146+1 records in 146+1 records out 74923 bytes (75 kB) copied, 0.00184633 s, 40.6 MB/s

  1. 这是Makefile里生成ucore.img的代码,可以看到生成ucore.img需要kernel和bootblock
    $(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

  2. 为了生成bootblock,首先需要生成bootasm.o、bootmain.o、sign
    1. 生成bootasm.o需要bootasm.S
      实际命令为
      gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o
      其中关键的参数为
      -ggdb 生成可供gdb使用的调试信息
      -m32 生成适用于32位环境的代码
      -gstabs 生成stabs格式的调试信息
      -nostdinc 不在标准系统文件夹寻找头文件,只在-I等参数指定的文件夹中搜索头文件
      -fno-stack-protector 不生成用于检测缓冲区溢出的代码
      -Os 为减小代码大小而进行优化
      -I 添加搜索头文件的路径,优先查找头文件的地方
    2. 生成bootmain.o需要bootmain.c
      实际命令为
      gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootmain.c -o obj/boot/bootmain.o
      新出现的关键参数有
      -fno-builtin 除非用__builtin_前缀,否则不进行builtin函数的优化

    3. 生成sign工具的makefile代码为
      $(call add_files_host,tools/sign.c,sign,sign)
      $(call create_target_host,sign,sign)

      实际命令为
      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

    4. 首先生成bootblock.o
      ld -m elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o
      其中关键的参数为
      -m 模拟为i386上的连接器
      -N 设置代码和数据段是可读可写的,对数据段不做page-align
      -e 指定入口
      -Ttext 制定代码段开始位置

    5. 拷贝二进制代码bootblock.o到bootblock.out
      objcopy -S -O binary obj/bootblock.o obj/bootblock.out
      其中关键的参数为
      -S 移除所有符号和重定位信息
      -O 指定输出格式

    6. 使用sign工具处理bootblock.out,生成bootblock
      bin/sign obj/bootblock.out bin/bootblock
  3. 生成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)

    为了生成kernel,首先需要 kernel.ld init.o readline.o stdio.o kdebug.o
    kmonitor.o panic.o clock.o console.o intr.o picirq.o trap.o
    trapentry.o vectors.o pmm.o printfmt.o string.o

    生成kernel的细节就不写了,就是.o文件的链接

  4. 生成一个有10000个块的文件,每个块默认512字节,用0填充
    dd if=/dev/zero of=bin/ucore.img count=10000

  5. 把bootblock中的内容写到第一个块
    dd if=bin/bootblock of=bin/ucore.img conv=notrunc

  6. 从第二个块开始写kernel中的内容
    dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
    dd的一些参数的含义:
    -if表示输入文件,如果不指定,那么会默认从stdin中读取输入
    -of表示输出文件,如果不指定,那么会stdout
    bs表示以字节为单位的块大小
    count表示被赋值的块数
    /dev/zero是一个字符设备,会不断返回0值字节\0
    conv = notrunc 不截短输出文件
    seek=blocks 从输出文件开头跳过blocks个块后再开始复制

这样我们就可以大致了解了这个内核ucore.img是如何被一步一步加载出来的,真的详细

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

tools/sign.c
按照这个文件的描述,需要检查以下几点:

  • 输入的主引导扇区的记录必须是510字节以内(446+64)
  • 输出的主引导扇区的最后两个字节是55AA
    bootblock就是需要用到的主引导扇区

    练习2

知识点:

  • gdb的使用(一直有问题)

    通过单步调试熟悉BIOS的执行过程

要了解 makefile中的lab1-mon

熟悉gdb的调试命令

我现在就记着个next nexti step stepi

x /10i $pc

练习 3

根据代码分析bootloader的作用,同时重点是bootloader进入保护模式的过程

知识点:

  • bootloader的作用
  • 一些汇编指令:cli cld等的作用,常见寄存器等
  • A20 enable的过程
  • elf文件格式,文件头,如何加载,如何执行
  • 硬盘的读写

bootasm.S的内容

# Start the CPU: switch to 32-bit protected mode, jump into C.
# The BIOS loads this code from the first sector of the hard disk into
# memory at physical address 0x7c00 and starts executing in real mode
# with %cs=0 %ip=7c00.

.set PROT_MODE_CSEG,        0x8                     # kernel code segment selector
.set PROT_MODE_DSEG,        0x10                    # kernel data segment selector
.set CR0_PE_ON,             0x1                     # protected mode enable flag

# start address should be 0:7c00, in real mode, the beginning address of the running bootloader
.globl start
start:
.code16                                             # Assemble for 16-bit mode
    cli                                             # Disable interrupts
    cld                                             # String operations increment

    # Set up the important data segment registers (DS, ES, SS).
    # this xorw can set %ax to zero regardless of what the initial value in this %ax is
    # 使用 AT&T 样式的语法,所以其中的源和目的操作数和 Intel 文档中给出的顺序是相反的。
    xorw %ax, %ax                                   # Segment number zero
    movw %ax, %ds                                   # -> Data Segment
    movw %ax, %es                                   # -> Extra Segment
    movw %ax, %ss                                   # -> Stack Segment

    # Enable A20:
    #  For backwards compatibility with the earliest PCs, physical
    #  address line 20 is tied low, so that addresses higher than
    #  1MB wrap around to zero by default. This code undoes this.
seta20.1:
    # 64 -> status reg , bit 1 is set when input reg has data 
    inb $0x64, %al                                  # Wait for not busy(8042 input buffer empty).
    # testb $0x2 AND %al , affected ZF 
    testb $0x2, %al
    # jnz jump when ZF = 0,namely the result is not zero
    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
    // why don't read output buffer to get the original Output port?
    movb $0xdf, %al                                 # 0xdf -> port 0x60
    outb %al, $0x60                                 # 0xdf = 11011111, means set P2's A20 bit(the 1 bit) to 1

    # 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

    # Jump to next instruction, but in 32-bit code segment.
    # Switches processor into 32-bit mode.
    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                       # Our data segment selector
    movw %ax, %ds                                   # -> DS: Data Segment
    movw %ax, %es                                   # -> ES: Extra Segment
    movw %ax, %fs                                   # -> FS
    movw %ax, %gs                                   # -> GS
    movw %ax, %ss                                   # -> SS: Stack Segment

    # Set up the stack pointer and call into C. The stack region is from 0--start(0x7c00)
    movl $0x0, %ebp
    movl $start, %esp
    call bootmain

    # If bootmain returns (it shouldn't), loop.
spin:
    jmp spin

# Bootstrap GDT
.p2align 2       
# force 4 byte alignment
# code seg and data seg base is equal?
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

上面的这段代码,是执行bootmain之前bootloader所做的工作。我们一步步分析这段代码,并在此过程中把相关的知识点总结一下:

  1. 第一步:屏蔽中断,设置串地址增长方向,把ds,es,ss寄存器置0
    1. cli:中断允许标志IF为1时CPU能响应外部的可屏蔽中断请求,但是对非屏蔽不起作用 ,设置用STI 清除是CLI,这里清除,表示不响应可屏蔽中断
    2. CLD:DF方向标志 为1 串按减 为0 串按加 STD置 CLD清DF
    3. 设置ds,es,ss寄存器为0.这里用了一个技巧,不管ax寄存器初始化为什么内容,通过xor ax,ax我都可以让ax置0
  2. 第二步:开启A20
    这部分知识,在实验指导书上有详细说明,这里对这部分内容进行详细阐述:
    • 首先,先不要管A20是什么,我们先了解一下8086结构的历史。之前的内存空间是比较小的,然后一开始的8086的地址线是20位,所以按照2的20次方是1M,所以按理来说,“按理”是指这20位二进制数的值就作为byte的地址的话,可以的寻址范围就是0-1M的范围,然而当时的寄存器是16位,所以不得不采用另外一种寻址方式:一个16位寄存器表示基地址 * 16 + 另一个16位寄存器表示的偏移地址,这样计算,最大的寻址空间是 0xffff0(0xffff左移了四位) + 0xffff,最后结果是0x10ffef,大约是1088KB,那么这就比1024KB还要大,举个例子,当你的地址通过上面的计算方式得到0x100001这个地址,那么因为只有20根地址线,所以这个地址的最高位1根本无法表示出来,会发生“回卷”,也就是会获取地址为0x00001处的值。但是当时这个问题对于使用没有影响
    • 但是后来,随着内存空间的不断增大,地址线也逐渐增加到32位,为了保持向下兼容(至今未理解这个向下兼容是具体表示什么意思),他们采取的做法是在第20根地址线(A20)上做了一个开关,当A20被使能时,它是一个正常的地址线,当他被disable时,它永远为0,所以这就引入了在一开始A20使能的问题,在保护模式下,要访问高端内存,一定要打开这个开关,否则第21个bit总是为0,那只能访问奇数M的空间了
    • 在了解A20如何打开之前,先对这个体系的地址空间做一个了解:在一开始只有1M内存时,这个部分内存是被分为低端的640KB的常规内存,和高端的384KB的内存,这部分内存一开始是被设计用来作为ROM和系统设备的地址区域。(好像是IBM当时认为内存不会到现在这么大,才把高地址的384KB作这样用)这个设计为之后内存容量的增大带来了麻烦。因为这384KB是ROM和系统设备的地址空间,那么内存会被这部分分开,0-640KB ,1M-最大内存,不连续了,为了解决这个问题,采用了这样的办法:系统加电后,先让ROM有效(即这部分地址空间是给ROM的),此时取出ROM的内容,然后再让RAM有效,把这部分内容保存到RAM的这部分地址空间中,这就是所谓的ROM shadowing
    • 接下来讲A20的相关操作。之前说A20是第21根地址线的值,实际上,是由一个8042键盘控制器来控制的A20 Gate(据说是找不到其他可以控制的地方了),而8042芯片内部有三个端口,其中一个是Output Port,而A20Gate就是Output Port端口的bit 1,所以要控制A20使能,其实就是通过读写端口数据,使得这个bit的值为1
    • 还是需要介绍这个芯片的读写方式:
      • 首先,这个芯片有两个外部端口,0x60h和0x64h,就相当于读写操作的地址了。
      • 读Output Port,需要向64h发送0d0h命令,然后从60h读取Output port的内容
      • 写Output Port,需要先向64h发送0d1h命令,然后向60h写入Output Port的内容
      • 同时我们还需要检查当前缓冲区是否有数据,如果有正在处理的数据,那么肯定需要等待数据处理完才可以,所以还需要知道我们可以通过读取0x64h的数据,获取这个芯片的状态,如果这个状态为0x2(这是规定),说明还有数据没有处理完
      • 实际上还需要知道更多的命令。包括关闭键盘输入等,但是在ucore的实现中,并没有这么麻烦
    • 所以我们可以看这部分代码,很容易就理解,这部分代码就是按照下面的顺序打开A20的:
      • inb $0x64, %al 就是读取当前状态到 al寄存器,然后testb $0x2, %al,就是检查它当前状态是否标志位0x2被设置了,配合下面这个跳转,当这个标志位为0,或者说是当前输入缓冲区没有数据了,就不跳转继续执行了
      • movb $0xd1, %al outb %al, $0x64,按照之前说的,先向64h发送0xd1命令, 表示要写
      • 其实不太清楚,这里为什么还要等待8042的input buffer没有数据了,中断已经关闭了呀,应该没有什么会影响到input buffer了吧
      • movb $0xdf, %al outb %al, $0x60,把0xdf写入0x60,这样A20就打开了
  3. 初始化GDT表,使用lgdt gdtdesc 即可,gdtdesc的定义了解一下,定义了空描述符,数据段和代码段
  4. 进入保护模式,就是让cr0寄存器的PE为1
    movl %cr0, %eax
    orl $CR0_PE_ON, %eax
    movl %eax, %cr0
  5. 通过长跳转,更新CS寄存器的基地址
    ljmp $PROT_MODE_CSEG, $protcseg
    其中protcseg是一个label
    这里还要注意PROT_MODE_CSEG和PROT_MODE_DSEG,这两者分别定义为0x8和0x10,表示代码段和数据段的选择子,注意段选择子的结构,前13位是index,正好这里分别对应1和2(和之前全局描述符表的顺序一致),然后后三位是0,表示全局的,而且dpl为0

  6. 设置段寄存器,并建立堆栈
    注意这里建立堆栈,ebp寄存器按理来说是栈帧的,但是这里并不需要把它设置为0x7c00,因为这里0x7c00是栈的最高地址,它上面没有有效内容,而之后因为调用,ebp会被设置为被调用的那个函数的栈的起始地址,这里就不用管它了。

而且很重要的一点,这一点在下面的打印栈帧的练习中也用到了,就是用ebp是否为0来判断是否已经到达最初始的函数。
movw $PROT_MODE_DSEG, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %fs
movw %ax, %gs
movw %ax, %ss
movl $0x0, %ebp
movl $start, %esp

  1. 最后进入bootmain方法

这就是bootasm.S中的代码的内容,它完成了bootloader的大部分功能,包括打开A20,初始化GDT,进入保护模式,更新段寄存器的值,建立堆栈

bootmain的内容

接下来bootmain完成bootloader剩余的工作,就是把内核从硬盘加载到内存中来,并把控制权交给内核,不过在此之前我们还需要了解一些基础知识。

硬盘的读写

这部分内容在指导书上的“bootloader的启动过程”中的“硬盘访问概述”中详细说明了,这里再仔细捋一遍

关于硬盘的读写,在我们的OS实验中也涉及到了,印象中就是在特定地址发命令,然后在特定地址读,或者在特定地址写,重复这个过程即可。

readsect

这里也是bootloader的硬盘访问都是通过CPU访问硬盘的IO地址寄存器来完成,大致读一个扇区(512字节)的流程和之前的设置A20的流程类似:

        static void
        readsect(void *dst, uint32_t secno)
        从secno扇区读取一个扇区到dst
  • 等待硬盘准备好
    static void
    waitdisk(void) {
    while ((inb(0x1F7) & 0xC0) != 0x40)
    /* do nothing */;
    }
    其中0x1f7地址是状态和命令寄存器的地址,具体的状态细节这里不深究了,总之waitdisk函数就是一直通过获取0x1f7处的地址的状态值判断是否为不忙碌状态
  • 发出命令

      outb(0x1F2, 1);                         // 设置读取扇区的数目为1
      outb(0x1F3, secno & 0xFF);
      outb(0x1F4, (secno >> 8) & 0xFF);
      outb(0x1F5, (secno >> 16) & 0xFF);
      outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
          // 上面四条指令联合制定了扇区号
          // 在这4个字节联合构成的32位参数中
          //   29-31位强制设为1
          //   28位(=0)表示访问"Disk 0"
          //   0-27位是28位的偏移量
      outb(0x1F7, 0x20);                      // 0x20命令,读取扇区
    其中0x1f2是规定要读写的扇区数
    0x1f3 1f4 1f5以及1f6的0到3位,这些位组合起来表示LBA28位参数(secno),0x1f6的第4位,也就是这四个字节的32位的第28位设置为0(|0xE0),0表示主盘
    然后向0x1f7发送命令0x20,表示读取扇区
  • 然后又是waitdisk
  • 最后是读取到dst位置
    insl(0x1F0, dst, SECTSIZE / 4); // 读取到dst位置,
    注意insl命令,这个命令定义在x86.h中,其实就是从0x1f0读取SECTSIZE/4个双字到dst位置的汇编实现,注意这里是以双字为单位,即4个字节,所以才除以4,而且注意这里很多命令最后的“l”都对应了实际命令中的“d”

ELF文件格式

这个在之前《程序员的自我修养》中看过,然而现在全忘光了。。

这里只需要知道ELF是Linux系统下一种常用目标文件格式,有三种类型

  • 可执行文件,用于提供程序的进程映像,加载程序的执行
  • 可重定位文件
  • 共享目标文件

ELFheader在文件开始处描述了整个文件的组织,elf文件头在elf.h中有定义,我们关注它的

  • magic,这个是用于检验是否是一个合法的elf文件的
  • entry 程序入口的虚拟地址
  • phoff 表示程序头的地址偏移
  • phnum 表示程序头表的元素数量

可执行文件的程序头部是一个program header结构的数组,每个结构描述了一个段或者系统准备程序执行所必须的其他信息,下面我们看bootmain如何利用这些信息加载内核镜像

bootmain函数
readseg
/* *
 * readseg - read @count bytes at @offset from kernel into virtual address @va,
 * might copy more than asked.
 * */
static void
readseg(uintptr_t va, uint32_t count, uint32_t offset) {
    uintptr_t end_va = va + count;

    // round down to sector boundary
    va -= offset % SECTSIZE;

    // translate from bytes to sectors; kernel starts at sector 1
    uint32_t secno = (offset / SECTSIZE) + 1;

    // If this is too slow, we could read lots of sectors at a time.
    // We'd write more to memory than asked, but it doesn't matter --
    // we load in increasing order.
    for (; va < end_va; va += SECTSIZE, secno ++) {
        readsect((void *)va, secno);
    }
}

就是把硬盘上的kernel,读取到内存中

最后就是bootmain
    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);
    }
  • 注意到每个program header都规定了va,即段的第一个字节被放到内存中的虚拟地址,memsz是段在内存映像中所占的字节数,offset是段相对文件头的偏移值,用于指示段在哪个扇区,最后利用entry,进入内核执行

至此,我们终于看完了bootloader的整个执行过程!这个过程理解了还是很清晰的。

bootloader的作用

我们现在其实可以自己根据代码总结一下我们的bootloader都干了什么

  1. 关闭中断,
  2. A20 使能
  3. 全局描述符表初始化
  4. 保护模式启动
  5. 设置段寄存器(长跳转更新CS,根据设置好的段选择子更新其他段寄存器)
  6. 设置堆栈,esp 0x700 ebp 0
  7. 进入bootmain后读取内核映像到内存,检查是否合法,并启动操作系统,控制权交给它

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

知识点:

  • 函数调用时堆栈的变化

必须先理解函数调用栈

栈这块我觉得很难理解,倒不是因为函数调用,而是后面的中断处理那里的栈处理,至今还不太明白。

所以这里仅仅先总结一下一般的函数调用,不涉及特权级切换,调用栈会发生什么。
在MIPS体系结构中,关于这个部分其实当时已经理解的很多了,而且编译中也涉及到了这方面内容,而在80386体系结构中,函数调用时的栈也是一样的,大致的顺序如下:

  • 参数3
  • 参数2
  • 参数1
  • 返回地址
  • 上一层ebp(上一层的栈帧,这个就是之前编译里的ebp。。)
  • 局部变量

而此时的ebp指向哪里呢?此时的ebp指向上一层的ebp所在的地址,在执行pushl ebp之后,又会执行movl esp,ebp,把当前的esp给ebp作为被调用者的函数调用栈的栈帧(这里我习惯用栈帧来理解)

所以ebp寄存器很重要。

  • ss[ebp+8]指向第一个参数
  • ss[ebp+4]指向返回地址
  • ss[ebp]指向上一层ebp
  • ss[ebp-4]指向局部变量

通过ebp寄存器的值,我们可以快速的得到调用者的ebp值,继而得到调用者的调用者的ebp值,这样可以建立一个调用链。这个很重要,我们在java里的exception.print(什么来着,忘了)这个就是依赖于ebp指针实现的,或者在遇到一个bad argument时,我们可以通过这个调用链来回溯检查

具体堆栈跟踪函数的实现

首先要注意ucore的实现中堆栈的建立是之前说的bootloader的bootasm.S中的把esp设置为0x7c00,ebp设置为0,然后就使用call bootmain来调用bootmain函数。

在执行call指令过程中,这个指令会执行:把返回地址push,然后把这一层的ebp push,所以此时esp指向的是0x7bf8(就是因为前面是一个ebp以及一个返回地址),这个也在之后的堆栈打印函数的执行结果中可以体现。然后ebp被赋予当前的esp,即0x7bf8,这也是最后一个合法的ebp。

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();

    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];
    }
}

在这个程序中要注意的是:

  • read_ebp读取的当前ebp寄存器的值
  • 而read_eip读取的不是当前eip寄存器的值,而是调用者的返回地址即ss:[ebp+4]处的值
  • 参数一定要打印四个吗?
  • 注意print_debuginfo这个函数的实现
    这里先不对这个函数进行深究,以后有时间了好好讲一讲,主要是这个函数可以通过eip,打印出这条指令的函数信息,这个主要是通过“符号表”实现的,可见和编译息息相关啊
  • STACKFRAME_DEPTH,限制了调用深度

总之只要理解了之前的函数调用时,调用栈的变化,push的顺序,做出上面这段程序没有问题

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

知识点:

  • 中断向量表,中断向量表项
  • 中断的初始化,中断向量表的初始化,中断产生,进入中断处理,中断处理结束这整个过程,尤其是堆栈的变化

中断和异常

中断我感觉还有很多地方没有理解,这里先把我理解的部分好好总结一下,但是由于这部分内容实在是太多了,所以这里专挑练习问道的地方做总结

中断引入

在中断之前,还有一种CPU和外设打交道的方式:轮询。但是这个方式太浪费CPU资源了,所以需要一种机制可以不让CPU主动询问,而是被动等待,等有需要的时候再处理中断事件

中断分类

有三种

  • CPU外部设备引起的中断,例如IO中断,叫做异步中断,外部中断,也称中断,与CPU的执行无关
  • CPU执行指令期间检测到不正常的或非法的条件所引起的内部事件,叫做同步中断,内部中断,简称异常
  • 程序中使用系统服务的系统调用而引发的事件,叫做陷入中断,也称软中断系统调用简称trap

中断描述符表IDT

  • 起始地址:IDTR
    • 也是包含base address和limit两项
    • LIDT
    • SIDT
    • 在使用IDTR来索引相应的中断描述符时,IDTR中的limit也和一般的描述符一样,用于检查是否越界
  • 中断描述符表把每个中断或异常编号和一个指向中断服务例程的描述符联系起来
  • 保护模式下最多有256个中断向量
  • 中段描述符,可以有三种
    • Task-gate descriptor
    • Interrupt-gate descriptor
    • Trap-gate descriptor
    • 对于中断门和陷阱门,它们的结构大致一样,都包含了相应代码段所对应的selector和相应的offset,还有一些描述符所需的标志位

中断处理中硬件完成的工作

这个部分是重点,理解了这个部分,中断就没问题了。
CPU在收到中断事件后,打断当前任务的执行,根据某种机制跳到中断服务例程去执行的过程:

  1. 之前一直觉得中断是一个神秘的过程,这里解释了:CPU在执行完当前程序的每一条指令后,都会去确认在执行刚才的指令过程中中断控制器(如:8259A)是否发送中断请求过来,如果有那么CPU就会在相应的时钟脉冲到来时从总线上读取中断请求对应的中断向量;
  2. 根据这个中断向量号,利用IDTR寄存器,经过检查后,得到该向量号所对应的中断描述符
  3. 中断描述符中保存着offset,还保存着中断例程的所在段的段选择子,根据这个段选择子,从GDT中取得相应的代码段描述符,在代码段描述符中保存了中断服务例程的基地址,根据这里得到的基地址和中段描述符中的offset,我们可以得到中断服务例程的起始地址
  4. 接下来进行特权级转换的判断,CPU会根据当前的CPL和中断服务例程的段描述符DPL信息,确认是否发生了特权级的转换。具体的判断逻辑:
    1. 首先明确参与判断的三个特权级表示:①当前代码段寄存器的段选择子中存储的CPL,表示当前代码的特权级,②中断描述符的中断门描述符或者陷阱门描述符中存储的中断例程代码段的选择子中的DPL,表示目标代码段的DPL,然后也是中断门描述符或者陷阱门描述符的标志位中的DPL,表示门中的DPL
    2. 要求:CPL>=目标代码段的DPL(作为结果的CPL一定要等于目标代码段的DPL) 对于软件产生的中断(用户态程序中的指令触发,比如INT n),要求:CPL <= gateDPL
    3. 如果这些检查失败,那么会产生一个一般的保护异常
    4. 是否发生特权级的转换,我觉得就是看CPL是否被改变,或者说是目标代码段的DPL是否与CPL相等
    5. 当发生CPL的改变,一个堆栈切换操作就会完成!按照指导书,这个切换操作是这样的:这时CPU会从当前程序的TSS信息里取得改程序的内核栈地址,即包括内核态的ss和esp的值,并立即将系统当前使用的栈切换成新的内核栈,这个栈就是即将运行的中断服务程序所要使用的栈,紧接着就要把当前用户态程序使用的ss和esp先压入内核栈中(所以根据试验指导书,如果发生特权级转换,那么相比于没发生特权级转换,栈(不管是新栈还是旧栈)会先多压入一个ss和一个esp,剩下的和没发生特权级转换一样)
  5. 刚刚检查完是否进行了特权级的转换,接下来,CPU就需要开始保存当前被打断的程序的现场,不管现在是内核栈还是用户栈,都会压入:eflags cs eip errorcode
  6. 这些现场保护工作做完后,CPU就利用之前的中断服务例程里记录的offset和根据段选择子得到的段描述符里记录的base address,设置好当前的cs和eip寄存器,开始执行中断服务例程

当中断处理工作完成后,需要通过iret指令恢复被打断的程序的执行,具体的执行过程如下:

  1. 首先弹出eip,cs eflags
  2. 然后如果存在特权级转换(内核态到用户态?如何判断),那么还需要从内核栈中弹出用户态的ss和esp,此时栈也恢复为用户态的栈了
  3. 对于错误码,需要自己通过指令主动弹出,也就是说,iret指令在执行时自动的按照eip cs eflags弹出的,所以为了保证弹出正确,需要在iret指令执行之前自己写指令弹出errorcode

所以说上面的就是宏观的,中断处理和返回的过程,具体到我们的代码,如何实现呢?

具体的中断实现

外设的基本初始化设置

8259外设中断控制器

串口的初始化函数
static void
serial_init(void) {
    // Turn off the FIFO
    outb(COM1 + COM_FCR, 0);

    // Set speed; requires DLAB latch
    outb(COM1 + COM_LCR, COM_LCR_DLAB);
    outb(COM1 + COM_DLL, (uint8_t) (115200 / 9600));
    outb(COM1 + COM_DLM, 0);

    // 8 data bits, 1 stop bit, parity off; turn off DLAB latch
    outb(COM1 + COM_LCR, COM_LCR_WLEN8 & ~COM_LCR_DLAB);

    // No modem controls
    outb(COM1 + COM_MCR, 0);
    // Enable rcv interrupts,使串口1接手字符后产生中断
    outb(COM1 + COM_IER, COM_IER_RDI);

    // Clear any preexisting overrun indications and interrupts
    // Serial port doesn't exist if COM_LSR returns 0xFF
    serial_exists = (inb(COM1 + COM_LSR) != 0xFF);
    (void) inb(COM1+COM_IIR);
    (void) inb(COM1+COM_RX);

    if (serial_exists) {
        // IRQ_COM1 defined in trap.h,通过中断使能控制器使能串口1中断
        pic_enable(IRQ_COM1);
    }
}

这里细节,有时间再看,这不是重点

键盘初始化
static void
kbd_init(void) {
    // drain the kbd buffer
    kbd_intr();
    pic_enable(IRQ_KBD);
}
时钟中断初始化

时钟这个外设很特殊,作用不仅仅是计时,正是因为有了规律的时钟中断,才使得无论当前CPU运行在哪里,操作系统都可以在预先确定的时间点上获得CPU的控制权,而且也影响一个应用程序的切换

/* *
 * clock_init - initialize 8253 clock to interrupt 100 times per second,
 * and then enable IRQ_TIMER.
 * */
void
clock_init(void) {
    // set 8253 timer-chip
    // 100 times per second
    outb(TIMER_MODE, TIMER_SEL0 | TIMER_RATEGEN | TIMER_16BIT);
    outb(IO_TIMER1, TIMER_DIV(100) % 256);
    outb(IO_TIMER1, TIMER_DIV(100) / 256);

    // initialize time counter 'ticks' to zero
    ticks = 0;

    cprintf("++ setup timer interrupts\n");
    pic_enable(IRQ_TIMER);
}

中断的初始化设置

  • 中断向量:操作系统如果要正确处理各种不同的中断事件,就需要安排应该由哪个中断服务例程负责处理特定的中断事件,系统将所有的中断事件统一进行了编号,这个编号就是中断向量

中断的初始化可以从vector.S说起。

vector.S 规定了中断的入口地址

vector.S文件,打开一看是两部分,第一部分是代码段,定义了vector0到vector255这256个标号所对应的代码段的起始位置,每个标号后的代码无非是两种:

  • 压入0和中断向量
  • 不压入0,只压入中断向量(不知道怎么自动压一个0)

然后是jmp __alltraps

第二部分是数据段,定义了__vectors数组,保存了每个中断向量的入口地址
而这些入口地址,就是当中断发生时,中断描述符中所对应的那个offset,所以一旦中断发生,中断处理程序首先是会跳到vector[i]所对应的代码

idt_init 初始化中断向量表

vector.S规定了每个中断处理例程的代码偏移,然后idt_init通过这些偏移,设置好idt表,然后再通过lidt,把idt表的初始地址保存到idtr寄存器中,这样中断相关的数据结构初始化完毕了

/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */
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);
}

注意:

  • SETGATE是初始化gate descriptor的宏,联系中断描述符的结构,主要的部分有:offset selector dpl type
  • 第二个参数0表示是否是trap,这里还有些疑问,不明白为什么都设置为0?
  • GD_KTEXT 定义在memlayout.h中,是表示全局描述符表中的内核代码段选择子
  • offset就用__vector所规定的地址即可
  • DPL 这里还有些疑问

至此,中断的初始化设置就结束了,接下来分析之前说的那个中断的处理过程如何用代码来实现

中断处理的具体实现

具体实现步骤

按照之前说的,因为idt_init中把__vector的元素作为中断描述符的offset设置好了,所以说,一旦中断发生,那么CPU会从__vector[i]所对应的代码开始执行。

然而,我们还需要仔细思考中断发生(INI 或者是 外设中断发生)后,这整个过程究竟是怎么样的,之前已经把这个过程宏观的(也不宏观,但是也没有代码细节)讲了一遍。

  • 首先CPU发现了中断发生,获取了中断向量
  • 根据中断向量,查找idt表,获得offset和对应的代码段选择子
  • 根据选择子得到代码段描述符,获取代码段基地址,然后和offset一起得到了中断处理的入口地址
  • 然后注意,此时要进行特权级的检查,如果发生特权级的转换,那么此时会先保存当前用户态的ss和esp压入,然后从TSS中获得内核态堆栈的ss和esp并保存到当前寄存器,然后压入用户的ss和esp,再然后压入eflags cs eip,根据中断处理入口地址设置cs和eip,到这里,INT指令就执行完毕了,(下面是int指令在查找了手册后的一些操作说明)
  • 然后根据之前说的,此时会跳到vector.S的相应地址上开始执行代码,首先是压入errorno(不一定)和trapno(中断向量),然后根据vector.S的具体实现,此时会统一跳入一个函数__alltraps,这个函数在trapentry.S中有实现
# vectors.S sends all traps here.
.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

    # return falls through to trapret...
.globl __trapret
__trapret:
    # restore registers from stack
    popal

    # restore %ds, %es, %fs and %gs
    popl %gs
    popl %fs
    popl %es
    popl %ds

    # get rid of the trap number and error code
    addl $0x8, %esp
    iret

可以从上面的代码中看到,在跳入__alltraps后,接下来的工作是:

  • 压入 ds es fs gs,以及pushl,注意这个pushl操作,它的顺序与trapframe这个结构体的规定一致,注意接下来的操作
  • 接下来是把当前的ds和es设置为内核数据段选择子
  • 这一步很重要,把当前的esp指针入栈,作为参数,那么此时的esp处的值就是esp-4,而esp-4指向的是从edi开始之后的一系列寄存器的值,所以说这个入栈过程很重要,这里也很难理解
  • 接下来这一步是call trap,注意call指令会把当前的返回地址入栈,这个其实是之前函数调用那块的知识了,函数调用时:栈上的顺序是:参数 返回地址 上一层ebp...所以这里栈上有什么东西一定要清楚
  • 进入了trap函数后,注意此时的参数是一个trapframe类型的指针,这个指针的值就是之前push的那个ebp,它指向的是trapframe结构体。然后会继续执行trap_dispatch函数,在这里,会根据trapno,不同情况做不同处理

  • 处理之后,会继续回到__trapret这里继续执行,此时栈指针所指的就是那个参数!也就是之前的esp指针,恢复了它以后,按照之前压栈顺序陆续恢复这些寄存器,注意,恢复到ds寄存器之后,按照之前的思考,此时栈指针应该指向的是trapno,然而这里iret之前说过它不负责恢复trapno和errorno,所以此时我们需要手动把栈指针提高,跳过这两个,然后执行iret,按照之前的iret的描述,此时会陆续恢复eip cs eflags,还会根据是否特权级转换,恢复esp和ss,就和int的操作的逆过程一样

至此我们就把整个中断处理从初始化,到发生,执行,执行结束,这个过程弄清楚了!

扩展练习1中关于特权级转换的具体实现

知识点:

  • int iret在不同情况下的执行步骤
  • 特权级检查,什么叫做特权级发生了改变
  • 还是中断发生过程中堆栈的变化!

看了几天了,终于懂了这里的代码含义了,热泪盈眶!感受:指针得弄懂,堆栈也得彻底弄懂才能看懂

在从内核态,通过中断,切换为用户态时:

  • 首先要执行 sub 0x8,esp 这个语句,是因为
  • 然后执行int T_SWITCH_TOU表示发生这个中断,按照之前的叙述,此时的执行过程是:通过中断向量,查找中断向量表,查找入口地址,发现此时CPL并没有发生切换!所以并不把当前的ss和esp入栈,直接把eflags,cs,eip入栈,然后进入vector规定的地址后,继续把errorno和trapno入栈,然后进入__alltraps,把ds es gs ss 入栈,pushal,当前esp入栈,执行trap,执行trapdispatch,执行相应中断向量号case处的代码:
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;
        
            // set eflags, make sure ucore can use io under user mode.
            // 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;
  • 首先检查当前不是用户态,否则不需要切换。此时需要对堆栈进行一些操作,思考一下,现在是内核栈,我们原来从用户到内核态转换时,是通过TSS查到内核态的ss和esp的,但是这里似乎并没有从TSS查用户态的ss和esp?也就是说此时用户态可能还没有一个堆栈给他用(我自己的猜想),那么我们需要自己建立一个堆栈给他使用,这个就是这里的switchk2u变量所对应的地址。注意到这个变量是个定义在函数外的全局变量,所以它具体在哪存着。。其实我也不太清楚(debug或许可以搞懂),所以首先必须要有一个意识:用户态的堆栈和内核态的堆栈不在一个地方
  • 然后我们看这个具体代码实现,这里首先把tf所指的内容复制过来到switchk2u所对应的地址上,然后设置switchk2u这个变量的cs段,ds,es,ss等为用户数据段选择子,然后注意此时设置switchk2u的esp,这里我觉得这个赋值没有什么意义,果然,删除了这句话,最后结果还是正确。怎么解释呢?原来的trapframe结构的esp保存的是如果发生了权限切换,那么保存原来那个特权级的esp,便于之后恢复,(可以看之前用户态转换为内核态的步骤),但是现在问题是用户态原来就没哟堆栈,所以esp指针也没啥意义,而(uint32_t)tf + sizeof(struct trapframe) - 8表示的是tf结构体esp所在的位置(不是值!),这个位置赋给esp似乎没什么意义。真正给esp赋值的地方,是在中断结束返回后,手动把当前的ebp的值给esp,其实这里我还不理解
  • 之后设置eflags,因为用户态要实现IO,需要把eflags寄存器中的IOPL标志位设置为3,这样CPL<=IOPL是恒成立的,用户态也可以实现IO了
  • 最后这一句很关键,需要扎实的指针知识才能理解,现在我们的switchk2u是与内核栈不同的一个地址,我们要把它作为新的用户栈,并且还要保证在iret恢复寄存器时,要从switchl2u所规定的这个栈中恢复(因为我们已经在这个栈的地址空间上,把一些寄存器做了修改),那该如何实现iret恢复寄存器时,是从switchk2u这里恢复而不是从之前的tf这里恢复呢?这就需要看trapentry.S在call trap后第一句执行的语句:popl esp,也就是说,在执行完trap并返回后,会把当前栈指针所指的内容作为esp指针,原来如果不发生特权级转换,根据我们之前的描述,这个esp指针其实就是当前栈指针+4,直接pop而不设置esp就可以,而现在呢,这个popl esp就是我们修改用户栈指针的好时机!试想:如果我们把popl esp这个语句原来要弹出的内容(即tf的值),换成switchk2u的地址,那么我们不就可以把esp指针设置为switchk2u了吗?接下来我们根据esp恢复寄存器,不就会从switchk2u这块恢复了吗?理解了这一点,我们接下来的目标就是把原来的tf改成switchk2u,怎么改?注意tf的含义,它的值是edi开始的地址,tf的值-4就是一个存储tf 的地址(也就是tf作为局部变量的存储地址,也就是之前pushl esp,esp所存放的地址),所以代码中的((uint32_t *)tf - 1) 指向的就是存储tf这个值的那个地址,也是执行popl esp时的那个取值的栈指针,也是所以我们把这个地址上的值设置为switchk2u的地址,就可以了!
  • 综上,我们就实现了内核栈到用户栈的切换,接下来就是从trap返回,执行popl esp,把寄存器都pop出来,执行iret指令,注意,根据iret的实现,iret会取出栈中的cs寄存器,并得到它的DPL,然后与当前的代码段寄存器cpl进行比较,首先此时dpl一定要大于等于cpl,也就是说只能从内核态中断返回用户态,不能从用户态通过iret返回内核态,然后根据dpl和cpl是否相等,来判断是否发生特权级的改变,此时cpl一定是0,因为cs寄存器还没有回复,而dpl,也就是目标代码段,用户态的特权级是3,所以此时iret会判断发生了特权级的切换,然后它就会多pop 两次给esp和ss,而幸好我们之前设置switchk2u的内容时,把ss设置为了用户态的段寄存器,这个没问题,但是esp却是之前说的那个地址,这个地址真的不知道有什么意义,所以这里我们需要重新设置一下esp,怎么设置?代码中写的是,把当前的ebp给esp,此时的ebp是内核态的ebp吧,这里我也不懂为什么要拿这个来恢复esp?在经过调试后,发现我们只是暂时使用了switchk2u这个变量的那片地址空间,最后还是把esp设回到0x7b98,我原来还以为栈会使用新的地址呢
static void
lab1_switch_to_user(void) {
    //LAB1 CHALLENGE 1 : TODO
    asm volatile (
        "sub $0x8, %%esp \n"
        "int %0 \n"
        "movl %%ebp, %%esp"
        : 
        : "i"(T_SWITCH_TOU)
    );
}

上面说明了内核态通过切换到用户态时的过程,接下来解释,用户态通过中断到内核态的过程

  • 首先也是通过 int来触发中断
  • 然后这个中间的过程就先省略吧,直接进入trapdispatch来处理这个中断.
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;
  • 首先也检查当前段是否已经是内核段。先设置cs寄存器,然后设置ds,es寄存器(gs?),把eflags的IOPL标志位设置为0,然后注意switchu2k是一个trapframe类型的指针,经过debug,发现我原来忽略了一点:当从用户态到内核态时,因为发生了特权级的转换,所以原来的esp和ss被存起来了,而新的内核态的esp竟然是之前的switchk2u的地址附近,可能是之前在用到它的时候,把它存起来了,这里应该涉及到TSS的相关知识,应该找ts寄存器?忘了这个TSS相关寄存器是啥了。。总之我一直以为用户态的堆栈空间和内核的堆栈空间应该不一样的,但是目前来看esp差不多。
static void
lab1_switch_to_kernel(void) {
    //LAB1 CHALLENGE 1 :  TODO
    asm volatile (
        "int %0 \n"
        "movl %%ebp, %%esp \n"
        : 
        : "i"(T_SWITCH_TOK)
    );
}
trapframe的具体规定
struct trapframe {
    struct pushregs tf_regs;
    uint16_t tf_gs;
    uint16_t tf_padding0;
    uint16_t tf_fs;
    uint16_t tf_padding1;
    uint16_t tf_es;
    uint16_t tf_padding2;
    uint16_t tf_ds;
    uint16_t tf_padding3;
    uint32_t tf_trapno;
    /* below here defined by x86 hardware */
    uint32_t tf_err;
    uintptr_t tf_eip;
    uint16_t tf_cs;
    uint16_t tf_padding4;
    uint32_t tf_eflags;
    /* below here only when crossing rings, such as from user to kernel */
    uintptr_t tf_esp;
    uint16_t tf_ss;
    uint16_t tf_padding5;
} __attribute__((packed));

/* registers as pushed by pushal */
struct pushregs {
    uint32_t reg_edi;
    uint32_t reg_esi;
    uint32_t reg_ebp;
    uint32_t reg_oesp;            /* Useless */
    uint32_t reg_ebx;
    uint32_t reg_edx;
    uint32_t reg_ecx;
    uint32_t reg_eax;
};

注意到在pushregs中有一个oesp,是useless的,注意把它与发生特权级切换时用户态的esp区分开

一些指令的堆栈操作总结

对于int指令,它在特权级改变时,会对栈进行这些操作:

  • push long pointer to old stack(因为栈改变了)
  • push eflags
  • push long pointer to return location

特权级不变时,就在栈上:

  • push eflags
  • push long pointer to return location
    而call指令,相比int,少push一个eflags,也就是说call在长模式下只是push return address

  • 至此我觉得可以总结一下int call iret ret retf的区别
    • int 特权级改变:push ss esp,不改变那就不push这两个,之后push eflags cs eip
    • call 只是push cs eip
    • iret pop eip cs eflags ,特权级改变就pop esp ss,不改变就不pop
    • ret pop eip ,retf pop cs和eip
pushl的堆栈操作

pushal:

  1. Temp <- ESP
  2. push EAX
  3. PUSH ECX
  4. PUSH EDX
  5. PUSH EBX
  6. PUSH TEMP
  7. PUSH EBP
  8. PUSH ESI
  9. PUSH EDI

jmp只不过会影响CS寄存器,但不会对栈造成影响

扩展练习2

有时间再做咯