ucore bootloader详解

第一节:概要

ucore的bootloader用汇编语言和c语言混合编写。本文将详细解析bootsam.S和bootmain.C中的代码,以及代码牵扯到的知识点,如可执行文件的格式、段机制、A20gate。汇编语言部分在bootasm.S中,C语言部分在bootmain.C中。
本文的阅读对象是做过清华大学ucore操作系统实验的人,至少是了解的,因为本文不介绍具体的操作,没有操作演示。本文的写作目的是希望可以帮助你快速理解bootloader的每一行代码的含义,而不是帮助你把bootloader做出来。

第二节:概念介绍

理解这些概念有助于帮助你理解代码在做什么,你可以选择性地阅读本节。也可以先阅读第3第4节,遇到疑问了再回头阅读本节。

bootloader

bootloader用于加载操作系统。计算机系统启动后,首先是BIOS初始化计算机,然后BIOS将控制器交给bootloader,bootloader负责进一步设置计算机系统的工作模式,然后从磁盘中加载操作系统到内存,每个操作系统都要给自己写bootloader,以指导计算机系统如何把自己从磁盘中请出来。BIOS是硬件厂商写的,操作系统开发者可以不深入关注。
编译bootloader的过程如下:首先用gcc编译汇编代码部分,再用gcc编译c语言代码部分,分别形成对应的.o文件。然后用ld链接两个.o文件,形成bootblock.o文件。这个文件是elf格式的。下一步用objcopy把elf格式的bootblock.o文件变成raw binary格式的bootblock.out(大小为492字节)。最后用自己写的小工具sign.c,把492字节的bootblock.out变成512字节,且最后两个字节为0x55,0xAA的文件bootblock,他就是一个完整的bootloader。在制作虚拟磁盘的时候,这个bootblock就作为磁盘的第0扇区。

elf格式和raw binary格式

这是可执行文件的两种格式。
我们见到的大部分linux应用程序都是elf格式。elf格式的可执行文件除了代码和数据外,还有elf头,调试信息等内容。elf格式的应用程序需要elf解析器,根据elf头的指引把文件放到指定地址空间中去,然后解析器把PC设置为应用程序的第一行代码的地址,应用程序才开始运行。这个步骤对一般用户是无感知的。当我们输入./a.out命令的时候,操作系统从磁盘读a.out的内容,并自动使用elf解析器把内容放到正确的内容位置中。
raw binary格式的可执行文件只有代码和数据,没有任何引导操作系统如何放置文件内容的信息(比如elf头)。因此它可以很小。运行raw binary的程序,用户需要手动把应用程序从磁盘搬运到某个内存,然后,设置PC为第一行代码的地址。用C语言描述就像这样

#include<stdio.h>
int main()
{
	FILE *fp = fopen("bootblock", "rb");
	char* a;
    fread(a, 1, 10, fp);
    ((void (*)(void))a)();
}

最后一行的意思就是用一个函数指针指向a指向的内存空间,然后通过这个指针运行函数。也就是说,指针a指向的内存空间及其以后的若干字节已经是一段程序了,可以执行。
为什么要raw binary这样的格式?因为在操作系统没有启动的时候,是没有elf解析器的。硬件能做的工作只有:把磁盘的第一扇区读到内存的0x7c00处,然后把PC设置为0x7c00。所以bootloader的格式只能是raw binary格式。
elf格式与raw binary格式的转化命令

objcopy -S -O binary bootblock.o bootblock.out

raw binary格式反汇编命令为

objdump -b binary bootblock.out -D -mi386

如果要看raw binary里面的原生内容,用

vi -b bootblock.out
:%!xxd

效果如下
在这里插入图片描述
看看bootmain.o(elf格式)反汇编的内容,会发现raw binary就是elf的代码段和数据段。
在这里插入图片描述

段机制

略,偷个懒,这个很容易百度地到的。

A20gate

略,继续偷懒

第三节:bootasm.S

#include <asm.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

# 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).
    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(8042 input buffer empty).
    testb $0x2, %al
    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

    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
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

代码详解:片段1

.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

这三行相当于宏定义,PROT_MODE_CSEG并没有内存对应,后面的代码写PROT_MODE_CSEG有编译器直接替换成相应数值。
PROT_MODE_CSEG,PROT_MODE_DSEG是段选择子。
后面代码会把:
cs寄存器会被设置成PROT_MODE_CSEG(0000 0000 0000 1000)。
ds,ss等寄存器会被设置成PROT_MODE_DSEG(0000 0000 0001 0000)。

为什么要把这些寄存器设置为这两个数?因为后面要开启保护模式,保护模式启用段机制,段机制的寻址方式需要用到cs和ds,ss等寄存器。cs寄存器和ss寄存器存的是段选择子。段选择子的结构如下:
在这里插入图片描述

  • 索引(Index):指向段表的某一项
  • 表指示位(Table Indicator,TI):选择应该访问哪一个描述符表。0代表应该访问全局描述符表(GDT),1代表应该访问局部描述符表(LDT)。
  • 请求特权级(Requested Privilege Level,RPL):用来提供保护机制
    段选择子的高13位是段表的引索。

可以看到,PROT_MODE_CSEG的高13为0000 0000 0000 1,也就是代码未来会存储在第1段。
PROT_MODE_DSEG的高13位为0000 0000 0001 0。也就是数据未来会储存在第2段。

只开启段机制下,CPU是如何寻址的?
对于指令,CPU首先读取cs寄存器的值,发现代码在第1段,就去段表(段表也是bootloader构建的,后面会讲)中找第一个表项。段表的表项叫段描述符,描述这一段的基地址,总共有多大,以及一些标志信息。CPU通过 读取表项拿到代码段的起始地址(基地址),用基地址+eip存的地址就是代码的物理地址。所以我们看的指令地址不一定就是指令的物理地址,但如果代码段的基地址是0,看到的地址就是实际的物理地址。在ucore中,段的基地址确实是0。

对于数据,比如CPU收到这样一条指令:addl (0x2000),$eax CPU首先读取ds寄存器的值,发现数据在第2段,就去段表中找第二个表项,读取这个表项中记录的段基址,段基址+0x200才是真正要访问的数据所在的地址,而不是0x2000。
代码详解:片段2
这一片段比较简单,看我写的注释就行。

    cli                                             # 使得CPU不能被中断
    cld                                             # 使得df标志位变成0,从而影响si,di寄存器的行为,具体请百度,关键字为cld ,df寄存器,si,di寄存器。

    # Set up the important data segment registers (DS, ES, SS).
    xorw %ax, %ax                                   # 让ax寄存器=0.
    movw %ax, %ds                                   # -> 让ds寄存器=ax=0
    movw %ax, %es                                   # -> 让es寄存器=ax=0
    movw %ax, %ss                                   # -> 让ss寄存器=ax=0

代码详解:片段3——使能A20gate
要解释清楚为什么要使能A20gate不容易。推荐大家直接百度A20gate,我这里就不作解释了。只解释如何使能A20gate。
A20gate位于8042芯片中。8042芯片结构如下:
在这里插入图片描述
数据总线是CPU与8042芯片交互的通道,输入端口p1是外部设备影响8042芯片的接口,不由CPU控制。p2是8042控制外部设备和CPU的端口(8042能控制 CPU,比如A20gate,但能控制的东西不多)。
CPU可以通过执行inb $0x60读取8042芯片数据寄存器的值。可以通过执行outb $0x60向8042写数据。可以通过执行inb $0x64读取8042芯片的当前状态,可以通过执行outb $0x64向8042芯片发出指令。
与8042芯片交互的协议如下:
读取状态寄存器的值,直到它不为0x2。(0x2表示8042芯片正忙)
向0x64端口发送命令。
如果命令不携带数据,则交互完成,如果命令携带数据,则继续:
读取状态寄存器的值,直到它不为0x2
向0x60端口发送数据。

使能A20gate是让p21为1。这可以通过向8042芯片发送0xd1指令做到。0xd1指令是一个携带数据指令,当8042收到这个指令的数据后,会把p2替换成收到的数据。A20gate位于第1位,所以发送的数据第一个位要为1。

seta20.1:
    inb $0x64, %al                                  # 读8042芯片的状态寄存器的值 
    testb $0x2, %al									# 如果为2,说明8042芯片正忙,
    jnz seta20.1									# 循环执行前两条指令,直到8042芯片不忙

    movb $0xd1, %al                                 # 让al寄存器=0xd1
    outb %al, $0x64                                 # 向8042芯片发出0xd1指令,如果8042收到0xd1指令,它会在0x60端口等待一个字节的数据,接受到数据后会把这个字节搬运到控制CPU的寄存器中(CPU通过指令控制8042芯片,8042芯片也可以通过内部端口连接到CPU的使能引脚控制CPU的部分行为。

seta20.2:
    inb $0x64, %al                                  # 读8042芯片的状态寄存器的值 empty).
    testb $0x2, %al                                 # 如果为2,说明8042芯片正忙,
    jnz seta20.2                                    #  循环执行前两条指令,直到8042芯片不忙

    movb $0xdf, %al                                 # 让al=0xdf
    outb %al, $0x60                                 # 0xdf = 11011111,0xdf送到8042芯片的数据寄存器,8042收到后,会把1101 1111搬运到控制CPU的寄存器中。而这里,A20 gate位为1.

代码片段4——建立段表

.p2align 2                                          # 编译的时候,让编译器把下面的数据4字节对齐(不懂的可以百度内存数据对齐)
gdt:
    SEG_NULLASM                                     # 第0个表项为空表项 
    SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)           # 第二个表项为可读可执行段,起始地址从0开始,大小为4G的段,也就是我们说的代码段的描述符
    SEG_ASM(STA_W, 0x0, 0xffffffff)                 # 第三个表项为可写段,起始地址从0开始,大小为4G的段,也就是我们说的数据段的描述符。
# 可以看到,代码段和数据段其实是同一段。因为ucore在实际中是用分页机制。建立段表是因为要开启保护模式,而开启保护模式要求建立段表,保护模式强制开启CPU的分段机制。即使我们不用分段机制,这些流程也是要走完的。

SEG_NULLASM,SEG_ASM,STA_X等都是宏定义,位于asm.h中。在介绍这些宏定义之前,先看下段描述符的样子
在这里插入图片描述
段描述符是一个64位8字节的数据结构,其实就包含3部分的信息:段基址(32位),段大小(20位),段类型(8位)。但他们的分布很反人类。比如段基址的前8位在最高位,后24位从16位开始到40位。是短节分布的。
为了书写方便,ucore的开发者写了一个宏SEG_ASM。这个宏接受3个参数,段类型,段基址,段大小,宏自动把这3个参数调整成上图的格式,生成一个64位的段描述符。

#define SEG_ASM(type,base,lim)                                  \
    .word (((lim) >> 12) & 0xffff), ((base) & 0xffff);          \
    .byte (((base) >> 16) & 0xff), (0x90 | (type)),             \
        (0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)

SEG_NULLASM是一个全0的段表项

#define SEG_NULLASM                                             \
    .word 0, 0;                                                 \
    .byte 0, 0, 0, 0

建立段表描述符(注意多了个表字)

gdtdesc:
    .word 0x17                                      # sizeof(gdt) - 1
    .long gdt                                       # address gdt
gdt:
    SEG_NULLASM                                     # 第0个表项为空表项 
    SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)           # 第二个表项为可读可执行段,起始地址从0开始,大小为4G的段,也就是我们说的代码段的描述符
    SEG_ASM(STA_W, 0x0, 0xffffffff)                 # 第三个表项为可写段,起始地址从0开始,大小

段表 描述符由两个内容,一个是段表大小,这里是24字节。一个是段表的起始地址。.long就是开辟一个4字节的内存空间 ,空间中存放的是段表的起始地址。
代码片段5——申明段表并开启保护模式,在保护模式下调用bootmain

lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
ljmp $PROT_MODE_CSEG, $protcseg

lgdt指令将使得CPU把段表的地址以及大小记录在内部寄存器中,这条指令过后,CPU就知道段表在哪里了。
下面3行代码是设置cr0寄存器,当cr0寄存器的第1位为1时,CPU正式进入保护模式。
ljmp指令会设置cs寄存器为PROT_MODE_CSEG(0x8),eip寄存器为protcseg代码段的第一行代码的地址
protcseg

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 $0x0, %ebp
movl $start, %esp
call bootmain

这个代码段就是设置ds,es等数据段寄存器为$PROT_MODE_DSEG。然后设置ebp和esp。这是和栈相关的寄存器。call bootmian就是调用bootmain函数,这个函数是c语言写的。

第四节:bootmain.c

void
bootmain(void) {
    
    // bootmain开头处有两个宏定义:
    // #define SECTSIZE        512
    // #define ELFHDR          ((struct elfhdr *)0x10000)      // scratch space
    
    // 从磁盘连续读取512*8个字节到物理内存0x10000处。
    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
	// ELFHDR是elfhdr结构体的指针,指向0x10000,意思就是0x10000存放的是一个elf头。
    // 通过elf头中的magic字段判断这是不是一个合法的elf格式。
    if (ELFHDR->e_magic != ELF_MAGIC) {
        goto bad;
    }

    struct proghdr *ph, *eph;

    //解析elf格式,首先把ELFHDR转化成一个无符号整形,加上elf头中的e_phoff,就得到了段表的首地址。然后用proghdr*指针指向这个首地址,意为从这个地址开始,就是段表。Ph是段表第一项的地址。eph是段表最后一项的下一项的地址。
    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);
    }

    // call the entry point from the ELF header
    // note: does not return
    // 从elf头中读出这个可执行程序的入口地址,用一个函数指针指向它,然后通过这个函数指针调用函数。从这以后,操作系统正式接管计算机。
    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

readseg(uintptr_t va, uint32_t count, uint32_t offset)
以磁盘第一扇区(磁盘扇区引索从0开始,第一扇区的第一个字节引索为512)最开始的字节+offset为起始字节,连续读取count个字节到内存va处(由于此时没有开启页机制,段机制开启了但由没有任何作用,所以虚拟地址=物理地址)。
看看这个函数的实现,有一个危险的地方需要注意。

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; //SECTSIZE=512,这里把va向前移动,说明这个函数是危险的,如果offset不是512的整数倍,这个函数会修改va前面的字节。

    // 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);   //循环地、按扇区地从磁盘中读数据,可能会读超过需求的字节数,因为不能读半个扇区。而count不一定是512字节的整数倍
    }
}

readsect(void *dst, uint32_t secno)
读取磁盘第secno个扇区到内存dst处。

static void
readsect(void *dst, uint32_t secno) {
    // wait for disk to be ready
    waitdisk();
	//下面是CPU与磁盘交互的协议。CPU要读磁盘,需要通过outb指令向磁盘IO口发送一系列命令。具体的过程不展开,
	//outb是通过内联汇编实现的。内联汇编 可以让程序员在c语言中直接写汇编语句。与计算机硬件打交道的部分只能通过汇编完成。
    outb(0x1F2, 1);                         
    outb(0x1F3, secno & 0xFF);
    outb(0x1F4, (secno >> 8) & 0xFF);
    outb(0x1F5, (secno >> 16) & 0xFF);
    outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
    outb(0x1F7, 0x20);                      // cmd 0x20 - read sectors

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

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

outb
向IO端口为port的IO设备发送一个1字节的数据。用内联汇编来实现。

static inline void
outb(uint16_t port, uint8_t data) {
    asm volatile ("outb %0, %1" :: "a" (data), "d" (port) : "memory");
}

这里不解释内联汇编,网上有很多关于这个的介绍。
当你了解完内联汇编,你可以会好奇memory的作用是什么。它的作用就是告诉编译器这条汇编指令会改变内存。为什么要让编译器知道这个呢?因为编译器会做一些激进的优化,如果明确告诉编译器一些细节,编译器的优化会保守些(过于激进的优化能提高性能,但可以使得程序的运行结果不是程序员想要的结果)具体的,如果告诉编译器这条指令可能导致内存变化,就是让编译器:

(1)不要将该段内嵌汇编指令与前面的指令重新排序;也就是在执行内嵌汇编代码之前,它前面的指令都执行完毕
(2)不要将变量缓存到寄存器,因为这段代码可能会用到内存变量,而这些内存变量会以不可预知的方式发生改变,因此 GCC 插入必要的代码先将缓存到寄存器的变量值写回内存,如果后面又访问这些变量,需要重新访问内存。
————————————————
版权声明:本文为CSDN博主「梦悦foundation」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/sgy1993/article/details/89225075/

insl
insl是一个函数,它也是用汇编实现的。
功能为:从port IO口,连续读取cnt个双字(32位,也就是4个字节)(也就是cnt*4个字节)。送到内存addr处。
想要理解那几行汇编代码,推荐阅读
CSDN博主「梦悦foundation」的原创文章:gnu嵌入汇编,内嵌汇编详细的介绍

static inline void
insl(uint32_t port, void *addr, int cnt) {
    asm volatile (
        "cld;"
        "repne; insl;"
        : "=D" (addr), "=c" (cnt)
        : "d" (port), "0" (addr), "1" (cnt)
        : "memory", "cc");
}
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值