zoukankan      html  css  js  c++  java
  • MIT-JOS系列2:bool loader过程

    系统的启动过程(一):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代表硬盘的第几个扇区。

  • 相关阅读:
    JS表格测试
    2018电脑选购配置
    一句话技巧总结
    我的码风
    友情链接
    写代码时需要注意的一些东西
    他是 ISIJ 第四名,也是在线知名题库的洛谷“网红”
    从并查集的按秩合并看一类构造性问题
    高一上期末考游记
    P3233 [HNOI2014]世界树
  • 原文地址:https://www.cnblogs.com/sssaltyfish/p/10656780.html
Copyright © 2011-2022 走看看