1. 程式人生 > >清華大學OS操作系統實驗lab1練習知識點匯總

清華大學OS操作系統實驗lab1練習知識點匯總

attribute address ads import 經理 函數調用 sha core magic

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

有時間再做咯

清華大學OS操作系統實驗lab1練習知識點匯總