第一节:概要
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 插入必要的代码先将缓存到寄存器的变量值写回内存,如果后面又访问这些变量,需要重新访问内存。
————————————————
版权声明:本文为优快云博主「梦悦foundation」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.youkuaiyun.com/sgy1993/article/details/89225075/
insl
insl是一个函数,它也是用汇编实现的。
功能为:从port IO口,连续读取cnt个双字(32位,也就是4个字节)(也就是cnt*4个字节)。送到内存addr处。
想要理解那几行汇编代码,推荐阅读
优快云博主「梦悦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");
}