MIT-JOS系列2:bool loader過程

MIT-JOS系列2:bool loader過程

強烈建議qemu從MIT官方下載編譯安裝!不建議使用apt-get install qemu安裝! 兩個版本有一些不同,apt-get版本的缺一些指令,而且在MIT爲qemu做了一些補丁工作,運行時會有一些差別

強烈建議不要使用高版本gcc進行編譯!!! 比如我一開始使用的gcc-7.3,首先第一個實驗運行就會出錯,這時候可以修改kern/kernel.ld

-	PROVIDE(edata = .);
-
 	.bss : {
+		PROVIDE(edata = .);
 		*(.bss)
+		PROVIDE(end = .);
+		BYTE(0)
 	}
 
-	PROVIDE(end = .);

修改完後前幾個實驗都能順利進行,但做到lab4的時候,gcc-7.3完全編譯不過去,gcc-4.8就可以。。。爲了避免麻煩,建議一開始就用稍低版本的gcc,5.x應該也是可以的

此外,實驗需要的依賴庫:

sudo dpkg --add-architecture i386  (64位系統需要)
sudo apt-get update
sudo apt-get install build-essential
sudo apt-get install gcc-multilib

多版本gcc共存的用戶記得注意gcc-multilib有沒有正確裝到要用的gcc下

物理內存分佈

PC開機後的默認物理內存分佈如圖:

1554340026196

早期PC是基於16位的8086處理器,因此只支持1MB物理內存,地址從0x00000000~0x000FFFFF

  • 物理內存前640K被標記爲Low Memory,這一區域是早期PC唯一可以訪問的RAM

  • 0x000A0000~0x000FFFFF的384K區域被硬件保留用於特殊用途:

    這一部分中最重要的是保存在0x000F0000~0x000FFFFF 處佔據 64KB 的基本輸入輸出系統(BIOS)。BIOS作用:

    • 對系統進行初始化(如激活顯卡、檢查內存的總量)
    • 將操作系統從一個合適的位置裝載到內存(從軟盤、硬盤、CD-ROM 或者是網絡)
    • 將控制權交給操作系統

在80286和80386之後物理內存訪問限制得到突破,但爲了保證和之前存在的軟件相兼容,PC架構還是保留了之前的物理內存低 1MB 空間的佈局方式,因此最新的 PC 會保留物理內存從 0x000A0000 ~0x00100000 的區域

32位機的可用RAM:

  • 低640K的Low Memory
  • 1M以上部分的擴展內存

32 位物理地址空間的最高部分往往被 BIOS 保留供 32 位的 PCI外設所使用

支持多餘4GB的物理內存時:BIOS 需要保留 32 位物理地址空間的最高部分,這是爲了將這個區域留給 32 位外設去匹配內存


ROM BIOS:從物理內存f000:fff0跳轉到f000:e05b開始執行

在PC啓動時,先會在實模式下運行BIOS。IBM PC執行始於地址0x000FFFF0,它是存放ROM BIOS區域的高地址部分,保證BIOS在剛啓動時得到控制權

  • IP=0xFFF0, CS=0xF000,此時爲實模式,因此物理地址=IP*16+CS
  • 執行的第一條指令是jmp e05b,BIOS 在內存中的上限是 0x00100000,於是在 0x000FFFF0 處執行第一條指令的話必然要跳轉這樣纔會有更多的 BIOS 指令可以執行
  • BIOS將boot loader裝載到物理內存

在JOS系統的lab1中也可以看到,系統加電後從0x000FFFF0開始執行,執行的第一條指令爲jmp e05b,它跳轉到BIOS的第一條指令處開始執行BIOS,然後由BIOS把bootloader從磁盤裝載到內存

Boot Loader:始於物理內存0000:7c00

BIOS 在完成它的一系列初始化後便把控制權交給 Boot Loader 程序

Boot Loader程序放在硬盤的第一個扇區

原因:硬盤默認分割成一個個大小爲512字節的扇區,扇區爲硬盤最小的讀寫單位,每次對硬盤的讀寫操作只能夠對一個或者多個扇區進行並且操作地址必須是 512 字節對齊的

  • 若操作系統從磁盤啓動,則磁盤第一個扇區爲"啓動扇區",因爲Boot Loader可執行程序放在這個扇區
  • BIOS找到啓動磁盤後,將 512 字節的啓動扇區的內容裝載到物理內存的 0x7c00 到 0x7dff 的位置
  • 執行跳轉指令將CS設置爲0x0000,IP設置爲0x7c00,控制權交給Boot Loader程序
  • Boot Loader大小不能超過512字節,只能佔據磁盤的第一個扇區
  • BIOS能從CD-ROM裝載更大的Boot Loader,以上爲從硬盤啓動,不考慮這個問題

MIT-JOS的Lab1中編譯得到Boot和Kernel兩個可執行文件,其中Boot爲Boot Loader程序,kernel是將被Boot Loader裝入內存的內核程序

Boot Loader源程序由以下兩個部分的程序組成

  • 名爲 boot.S 的 AT&T 彙編程序:將處理器從實模式轉換到 32 位的保護模式(因爲只有在保護模式才能訪問高於1M的物理內存)
  • 名爲 main.c 的 C 程序:將內核的可執行代碼從硬盤鏡像中讀入到內存中(具體的方式是運用 x86 專門的 I/O 指令)

boot.S:轉換到保護模式

boot.S將處理器從實模式轉換到 32 位的保護模式並調用main.c的bootmain

代碼過程

#include <inc/mmu.h>

# 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

.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).
  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:
  inb     $0x64,%al               # Wait for not busy
  testb   $0x2,%al
  jnz     seta20.1

  movb    $0xd1,%al               # 0xd1 -> port 0x64
  outb    %al,$0x64

seta20.2:
  inb     $0x64,%al               # Wait for not busy
  testb   $0x2,%al
  jnz     seta20.2

  movb    $0xdf,%al               # 0xdf -> port 0x60
  outb    %al,$0x60

  # Switch from real to protected mode, using a bootstrap GDT
  # and segment translation that makes virtual addresses 
  # identical to their 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.
  movl    $start, %esp
  call bootmain

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

# Bootstrap GDT
.p2align 2                                # force 4 byte alignment
gdt:
  SEG_NULL				# null seg
  SEG(STA_X|STA_R, 0x0, 0xffffffff)	# code seg
  SEG(STA_W, 0x0, 0xffffffff)	        # data seg

gdtdesc:
  .word   0x17                            # sizeof(gdt) - 1
  .long   gdt                             # address gdt

boot loader執行啓動時,是在16位的實模式下運行的

1、首先在入口 start 中,進行一系列初始化:

  • 設置16位模式
  • 關中斷(cli)
  • 設置變址寄存器SI或DI的地址指針自動增加,字串處理由前往後(cld)
  • 清零各重要數據段寄存器(ds, es, ss)

2、然後,打開A20地址線(seta20.1,seta20.2):

  • 在默認情況下,第20根地址線一直爲0,這樣做的目的是向下兼容早期PC。由於早期的 PC 僅僅只在實模式下進行尋址,這樣所能理論上可以尋到的最大地址應該是 0xFFFF0+0xFFFF,這看上去超過了 1MB 的地址空間,然而因爲早期的 PC 只有 20 根地址線(0~19),於是相當於最高位的進位時被忽略了,地址最終還是在 1MB 以內。所以當 PC 有了 32 根地址線並且能夠在保護模式下尋址 4G 的地址空間後,爲了向下兼容,在默認情況下將第 20 根地址線一直置 0,這樣就可以讓僅在實模式下運行的程序不會出現最高位的進位,相當於還是隻有 20 根地址線起作用

3、將系統從實模式切換到保護模式

  lgdt    gdtdesc
  movl    %cr0, %eax
  orl     $CR0_PE_ON, %eax
  movl    %eax, %cr0
  ljmp    $PROT_MODE_CSEG, $protcseg # 跳轉到下一條指令同時切換到 32 位的模式
  • 用 lgdt gdtdesc 將GDT表的首地址加載到 GDTR 寄存器

  • 將 CR0 最低位置1(PE位,打開保護模式)

  • 跳轉到下一條指令 protcseg 處

    此時因爲CR0最低位置1,已進入32位保護模式,PROT_MODE_CSEG 爲代碼段基址選擇子,從 GDTR+0x08H 處得出代碼段基地址爲0x0,偏移地址 $protcseg,得到下一條指令的虛擬地址,此時剛好bootloader的加載地址和鏈接地址一樣,因此 $protcseg 相當於該指令在內存中的物理地址

    關於鏈接地址和加載地址,將在下一節詳細談到

4、進入32位保護模式後

  .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
  
  movl    $start, %esp
  call bootmain
  • 讀入數據段基址選擇子
  • 用數據段選擇子初始化 ds, es, fs, gs, ss 各個數據段基址寄存器
  • 初始化堆棧指針 esp 爲 $start(0x7c00)
  • 調用 main.c 中的 bootmain

GDT表

GDT 表存放爲四字節對齊。其中SEG_NULL和SEG(type, base, lim)爲宏,定義分別如下:

// Null segment
#define SEG_NULL	(struct Segdesc){ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
// Segment that is loadable but faults when used
#define SEG_FAULT	(struct Segdesc){ 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0 }
// Normal segment
#define SEG(type, base, lim, dpl) (struct Segdesc)			\
{ ((lim) >> 12) & 0xffff, (base) & 0xffff, ((base) >> 16) & 0xff,	\
    type, 1, dpl, 1, (unsigned) (lim) >> 28, 0, 0, 1, 1,		\
    (unsigned) (base) >> 24 }

段描述符結構定義如下:

// Segment Descriptors
struct Segdesc {
	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
};

可以對應下圖重新梳理一遍段描述符的結構

1554260331081

鏈接地址和加載地址

關於上一節中提到的

此時剛好bootloader的加載地址和鏈接地址一樣

在這一節中加以詳細解釋

首先區分鏈接地址和加載地址:

  • 鏈接地址:程序自己假設在內存中存放的位置。編譯器在編譯時認定程序將被放在從起始處的鏈接地址開始的連續空間中,protcseg 這樣的地址標識符會根據它程序起始鏈接地址和它在代碼中的相對位置,被編譯成那段代碼開始處的鏈接地址
  • 加載地址:可執行程序在物理內存中真正存放的位置

舉例說明,在JOS實驗中,boot loader的鏈接地址由 boot/Makefrag 文件的第 28 行規定:

$(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o $@.out $^

此時對於標籤 protcseg,它的鏈接地址爲0x7c32,指令 ljmp $PROT_MODE_CSEG, $protcseg 將被譯成 ljmp $0x8, $0x7c32

BIOS規定將Boot Loader放置在0x7c00處,因此此時boot loader 的物理地址也爲0x7c00,protcseg 的物理地址爲0x7c32與鏈接地址相同,跳轉指令可以正確執行;

但是若將 boot/Makefrag 中boot loader的鏈接地址修改爲 0x7C10,此時對於標籤 protcseg,它的鏈接地址將對應於程序的起始鏈接地址相對改變爲0x7c42,指令 ljmp $PROT_MODE_CSEG, $protcseg 將被譯成 ljmp $0x8, $0x7c42。但是BIOS將 boot loader 放置在0x7c00的行爲沒有改變,程序依然從0x7c00起始,protcseg 的物理地址應該爲0x7c32,此時鏈接地址和加載地址出現了不同,程序執行無法成功。


main.c:裝入內核

main.c將內核的可執行代碼從硬盤鏡像中讀入到內存中。在JOS實驗中這個可執行代碼實際上是一個ELF文件,所以要了解內核如何裝入內存,先了解一下ELF文件

ELF文件

ELF 文件可以分爲這樣幾個部分:

  • ELF 文件頭
  • 程序頭表(program header table)
  • 節頭表(section header table)
  • 文件內容
    • .text 節
    • .rodata 節
    • .stab節
    • .stabstr 節
    • .data 節
    • .bss 節
    • .comment 節

如果我們把 ELF 文件看做是一個連續順序存放的數據塊,則其結構如下圖:

1554357746292

從ELF文件需要讀到內存的內容集中在文件的中間

  • .text 節:可執行指令的部分
  • .rodata 節:只讀全局變量的部分
  • .stab節:符號表部分,這一部分的功能是程序報錯時可以提供錯誤信息
  • .stabstr 節:符號表字符串部分
  • .data 節:可讀可寫的全局變量部分
  • .bss 節:未初始化的全局變量部分。這一部分不會在磁盤有存儲空間,因爲這些變量並沒有被初始化,因此全部默認爲 0,於是在將這節裝入到內存的時候程序需要爲其分配相應大小的初始值爲 0 的內存空間
  • .comment 節:註釋部分,這一部分不會被加載到內存

ELF文件頭和程序頭表

以下列出ELF文件頭數據結構中比較重要的幾個成員:

struct Elf {
	// ...
    
    // 入口地址爲虛擬地址,也就是鏈接地址
    uint32_t e_entry; // Entry point 程序入口點
    
    // 可以用來找到所有的程序頭表項
    uint32_t e_phoff; // 程序頭表偏移量
    uint16_t e_phnum; // 程序頭部個數
    
    // 可以用來找到所有的節頭表項
    uint32_t e_shoff; // 節頭表偏移量
    uint16_t e_shnum; // 節頭部個數
};

程序頭表項將文件內容分成好幾個段,每個表項代表一個段,一個段可能同時包括好幾個節,程序頭表項的數據結構如下(此處列出比較重要的幾個):

struct Proghdr {
    uint32_t p_offset; // 段位置相對於文件開始處的偏移量
    uint32_t p_va; // 段在內存中地址(虛擬地址)
    uint32_t p_pa; // 段的物理地址
    uint32_t p_filesz; // 段在文件中的長度
    uint32_t p_memsz; // 段在內存中的長度
};

如何找到文件的第 i 段:

  1. 從ELF文件頭找到程序表頭的位置 e_phoff
  2. 從程序表頭找到第i個表項 e_phoff + i*表項字節數
  3. 從第i個表項讀出第i段位置相對於文件開始處的偏移量p_offset
  4. 使用p_offset訪問文件的第i段

節頭表

節頭表的功能是讓程序能夠找到特定的某一節,尋找方式與找到文件的某一段類似。在試驗中,可以用 objdump -h 可執行文件 查看ELF文件每個節的信息

1554361743840

注意:.bss節與.comment節偏移一致,說明.bss節在硬盤中不佔空間,僅記載它的長度,裝入內存時才填0

內核裝入過程

分析main.c的代碼

#define SECTSIZE	512  // 定義扇區大小爲512
#define ELFHDR		((struct Elf *) 0x10000) // elf文件裝入的位置:
                                             // 內存的0x10000處

void readsect(void*, uint32_t);  // 讀取磁盤上的一個扇區
void readseg(uint32_t, uint32_t, uint32_t);  // 讀取elf文件中的一個段

bootmain函數實現如下:

void bootmain(void)
{
    struct Proghdr *ph, *eph;
    readseg((uint32_t) ELFHDR, SECTSIZE*8, 0); // 將文件的前 4KB(elf文件頭+程序頭表) 讀入內存
    if (ELFHDR->e_magic != ELF_MAGIC) // 判斷該文件是否爲 ELF 文件
    	goto bad;
    ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff); // 將指針指向程序頭表的首地址
    eph = ph + ELFHDR->e_phnum; // 明確文件段的個數
    for (; ph < eph; ph++)
    	readseg(ph->p_pa, ph->p_memsz, ph->p_offset); // 用 readseg 函數依次將文件的每一段讀入內存中相應的位置
    ((void (*)(void)) (ELFHDR->e_entry))(); // 在將內核加載到內存中後轉移到內核入口地址處執行,並且不會再返回
bad:
    outw(0x8A00, 0x8A00);
    outw(0x8A00, 0x8E00);
    while (1)
}

從elf文件頭和程序頭表中找到每一段的方法與上文所述的相同,然後利用readseg函數將內核文件的每一段依次讀入內存,最後轉到內核入口地址執行

利用objdump -f 可執行文件 可以查看elf文件的入口地址:

1554365431238

kernel程序被加載到0x100000物理地址處,相應入口地址爲0x10000c

MIT-JOS官方的實驗指導有這樣一段話:

Operating system kernels often like to be linked and run at very high virtual address, such as 0xf0100000, in order to leave the lower part of the processor’s virtual address space for user programs to use. The reason for this arrangement will become clearer in the next lab.

Many machines don’t have any physical memory at address 0xf0100000, so we can’t count on being able to store the kernel there. Instead, we will use the processor’s memory management hardware to map virtual address 0xf0100000 (the link address at which the kernel code expects to run) to physical address 0x00100000 (where the boot loader loaded the kernel into physical memory). This way, although the kernel’s virtual address is high enough to leave plenty of address space for user processes, it will be loaded in physical memory at the 1MB point in the PC’s RAM, just above the BIOS ROM. This approach requires that the PC have at least a few megabytes of physical memory (so that physical address 0x00100000 works), but this is likely to be true of any PC built after about 1990.

這裏有一點不明白:這條指令和 e_entry 顯示的應該是虛擬地址,按這裏的說法入口地址應該是0xf0100000,其他實驗和博客出現的也是這個虛擬地址,並且在代碼中有 ELFHDR->e_entry & 0xFFFFFF 的地址轉換,但我的實驗中它們都是物理地址(或者說是低地址),因此也不需要地址轉換,爲什麼?

結合gdb打印內存進一步理解裝入的數據:

首先ELF頭被裝入到物理內存0x10000處,因此使用指令 x/10x 0x10000 打印ELF頭的部分數據:

(gdb) x/10x 0x10000
0x10000:	0x464c457f	0x00010101	0x00000000	0x00000000
0x10010:	0x00030002	0x00000001	0x0010000c	0x00000034
0x10020:	0x00013ccc	0x00000000

根據ELF的數據結構,得到內核入口e_entry的值爲0x0010000c,它是進入內核可執行文件後第一行執行的代碼的物理地址;程序頭表偏移量e_phoff的值爲0x00000034,表明程序頭表在0x10000+0x34=0x10034的物理地址處。繼續打印程序頭表的數據:

(gdb) x/10x 0x10034
0x10034:	0x00000001	0x00001000	0xf0100000	0x00100000
0x10044:	0x0000712c	0x0000712c	0x00000005	0x00001000
0x10054:	0x00000001	0x00009000

結合程序頭表的數據結構Proghdr, p_pa的值應該爲0x00100000,因此內核被裝入到物理地址0x00100000處

另一個問題

參考其他博客時看到有這麼個問題:

這裏有個問題不是很明白, boot.asm 中,call 的最後一條程序的地址爲此爲啥會跳到knernal呢?

7d71:   ff 15 18 00 01 00       call   *0x10018

:0x10018是elf載入後elf->e_entry所在的物理地址,該內存單元的值爲0x10000c,即爲內核入口地址

void
readseg(uint32_t pa, uint32_t count, uint32_t offset)
{
    // pa: p_pa, 段在內存中地址(物理地址)
    // count: p_filesz, 段在文件中的長度
    // offset: p_offset, 段位置相對於文件開始處的偏移量
    
	uint32_t end_pa;

    // 該段在內存中的末地址
	end_pa = pa + count;

	// 由於硬盤中的每一扇區加載到內存的時候都需要 512 字節對齊,於是在這裏把起始加載地址向下對齊到 512 字節的倍數的地址處
	pa &= ~(SECTSIZE - 1);

	// 將在硬盤中的偏移由字節數轉換成扇區數,由於內核可執行程序是從磁盤的第二個扇區開始存儲的,所以需要加 1 (第一個扇區是boot loader)
	offset = (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.
	while (pa < end_pa) {
		// Since we haven't enabled paging yet and we're using
		// an identity segment mapping (see boot.S), we can
		// use physical addresses directly.  This won't be the
		// case once JOS enables the MMU.
		readsect((uint8_t*) pa, offset);
		pa += SECTSIZE;
		offset++;
	}
}

void
readsect(void *dst, uint32_t offset)
{
	// wait for disk to be ready
	waitdisk();

	outb(0x1F2, 1);		// count = 1
	outb(0x1F3, offset);
	outb(0x1F4, offset >> 8);
	outb(0x1F5, offset >> 16);
	outb(0x1F6, (offset >> 24) | 0xE0);
	outb(0x1F7, 0x20);	// cmd 0x20 - read sectors

	// wait for disk to be ready
	waitdisk();

	// read a sector
	insl(0x1F0, dst, SECTSIZE/4);
}

readsect函數從硬盤讀取一個扇區的數據到地址(物理地址)爲dst的內存中,offset代表硬盤的第幾個扇區。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章