《Linux内核完全剖析》这本书在第4章给出了一个简单多任务内核示例程序,作者称之为Linux 0.00系统。
源码的下载地址和实验方法可以参考我的博文
http://blog.youkuaiyun.com/longintchar/article/details/78757065
本文想分析一下启动代码boot.s
.
boot.s
,采用as86
语言编写,是引导启动程序,其作用是把内核代码加载到内存0x10000
处,之后设置好临时GDT表等信息,再把处理器设置成保护模式,最后跳转到内核代码处运行。
我打算边贴代码边分析。如有纰缪,还请各位看客拍砖指教。
BOOTSEG = 0x07c0
SYSSEG = 0x1000 ! system loaded at 0x10000 (65536).
SYSLEN = 17 ! sectors occupied.
以上三行都是在定义符号常量,也就是说BOOTSEG 就是0x07c0. 属于伪指令,并不会分配内存。
entry start
start:
jmpi go,#BOOTSEG
go: mov ax,cs
mov ds,ax
mov ss,ax
mov sp,#0x400
标识符entry是保留关键字,用于迫使链接器ld86在生成的可执行文件中包括其后指定的标号。通常在链接多个目标文件生成一个可执行文件时应该在其中一个汇编程序中用关键词entry指定一个入口标号,以便调试。但是这里可以省略entry start
,因为我们不希望在生成的纯二进制文件中包括任何符号信息。
jmpi go,#BOOTSEG
,是一个段间跳转语句,跳转到0x07c0:go
处。当BIOS把主引导扇区(也就是boot.s
生成的二进制镜像)加载到物理内存0x7c00处并跳转到该处时,所有段寄存器(包括CS)的默认值均为0,即此时CS:IP=0x0000:0x7c00
。因此这里使用段间跳转语句就是为了给CS
赋值0x07c0
。该语句执行后CS:IP = 0x07C0:go
。
load_system:
mov dx,#0x0000
mov cx,#0x0002
mov ax,#SYSSEG
mov es,ax
xor bx,bx
mov ax,#0x200+SYSLEN
int 0x13
jnc ok_load
die: jmp die
INT 13H,AH=02H 读扇区
此功能从磁盘上把一个或更多的扇区内容读进内存。因为这是一个低级功能,在一个操作中读取的全部扇区必须在同一条磁道上。
入口参数 | |
---|---|
AH | =02H ,指明调用读扇区功能。 |
AL | 要读的扇区数目,不允许使用读磁道末端以外的数值,也不允许使该寄存器为0。 |
DL | 需要进行读操作的驱动器号,0表示软盘,80H表示硬盘。 |
DH | 所读磁盘的磁头号。 |
CH | 磁道号的低8位数(磁道号共10位)。 |
CL | 低5位放入所读起始扇区号,位7-6表示磁道号的高2位。 |
ES:BX | 读出数据的缓冲区地址。 |
返回参数 | |
CF | =1,则操作失败;=0,操作成功。 |
AH | 错误返回码。 |
AL | 实际读到的扇区数。 |
所以,对照代码,可以得出从软盘的0磁头,0磁道,从第2个扇区起,连续读17个扇区到0x1000:0x0000
(即0x10000
)处。最后两行表示判断CF
标志,如果标志置位说明出错,则陷入死循环。否则跳转到ok_load
处。
ok_load:
cli ! no interrupts allowed !
mov ax, #SYSSEG
mov ds, ax !ds=0x1000
xor ax, ax
mov es, ax !es=0
mov cx, #0x2000 !书上是0x1000
sub si,si !si=0
sub di,di !di=0
rep
movw !每次移动一个字
上面的代码表示把 DS:SI
(0x1000:0x0)处的内容移动到ES:DI
(0x0:0x0); CX 中是重复的次数(按0x1000算,就是4K),每次移动一个字(2B),所以一共移动了8KB(内核的长度不超过8KB)的代码。
lidt idt_48 ! load idt with 0,0
lgdt gdt_48 ! load gdt with whatever appropriate
! absolute address 0x00000, in 32-bit protected mode.
mov ax,#0x0001 ! protected mode (PE) bit
lmsw ax ! This is it!
jmpi 0,8 ! jmp offset 0 of segment 8 (cs)
gdt: .word 0,0,0,0 ! dummy
.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0x00000
.word 0x9A00 ! code read/exec
.word 0x00C0 ! granularity=4096, 386
.word 0x07FF ! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0x00000
.word 0x9200 ! data read/write
.word 0x00C0 ! granularity=4096, 386
idt_48: .word 0 ! idt limit=0
.word 0,0 ! idt base=0L
gdt_48: .word 0x7ff ! gdt limit=2048, 256 GDT entries
.word 0x7c00+gdt,0 ! gdt base = 07xxx
lidt是加载IDTR
(中断描述符表寄存器),其后面要跟一个内存地址。在16位模式下,该地址是16位的;在32位模式下,该地址是32位的。该指令在实模式和保护模式下都可以用。地址指向一个6字节的内存区域,前16位是IDT的界限值,后32位是IDT的线性地址。
lgdt是加载GDTR
(全局描述符表寄存器),用法与lidt类似。gdt_48处定义了6个字节,前两个字节表示表的界限,0x7FF+1=2048(十进制),2048/8 = 256,也就是最多可以容纳256个段描述符;后四个字节是GDT的线性基地址,0x7c00+gdt
,因为主引导扇区被BIOS加载到了0x7c00处,所以要加个偏移。
9到19行定义了3个段描述符,第0个不用;
关于数据段描述符和代码段描述符,可以参考我的博文:
http://blog.youkuaiyun.com/longintchar/article/details/50489889
第1个描述符定义了一个代码段,其基地址为0,界限值是0x7FF(10进制2047),粒度4KB,DPL=0,非一致性,可读可执行。因为粒度是4KB,所以段长度是(2047+1)*4KB=8MB。
第2个描述符定义了一个数据段,其基地址为0,界限值是0x7FF(10进制2047),粒度4KB,DPL=0,向上扩展,可读可写。同上,段长度是8MB。
再来解释5~7行。
lmsw是加载机器状态字指令,后接16位寄存器或者内存地址。其功能是用源操作数的低4位加载CR0,也就是说仅会影响CR0的低4位——PE, MP, EM, TS。
第6行执行完成后,保护模式就开启了。
即使在实模式下,段寄存器的高速缓存寄存器也被用于访问内存。当处理器进入保护模式后,高速缓存寄存器的内容依然残留,但是这些内容在保护模式下是无效的。因此,比较安全的做法是尽快刷新段选择器,包括描述符高速缓存寄存器。
另外,在进入保护模式之前,很多指令已经进入了流水线。因为处理器工作在实模式下,所以它们都是按照16位操作数和地址长度进行译码的,即使是那些用bits32编译的指令,为了防止执行结果不正确,所以必须清空流水线。还用,那些通过乱序执行得到的中间结果也是无效的,所以必须清理掉,让处理器串化执行。
为了达到上述目的,我们可以采用段间跳转指令。jmpi 0,8
执行后,处理器一般会清空流水线并且串化执行;另一方面,会重新加载CS,并刷新描述符高速缓存寄存器的内容。
jmpi 0,8
,这里的0是偏移地址,8是段选择子。段选择子的结构如下图:
TI=0表示描述符在GDT中,TI=1表示描述符在LDT中。描述符索引则表示第几个描述符(从0开始)。
8即二进制的1000,也就是说是GDT表的第1个描述符,即基地址为0的代码段。基地址0+偏移地址0=0,所以jmpi 0,8
表示跳转到物理地址0处,这正是内核代码的起始位置,此后内核开始执行了。
源码的最后两行是
.org 510
.word 0xAA55
伪指令.org 510
表示在它之后的指令从地址510开始存放。遇到.org
,编译器会把其后的指令代码放到org
伪指令指定的偏移地址。如org
指定的地址和之前的指令地址有空洞,则用0填充。
.word 0xAA55
是有效引导扇区的标志,第510字节必须是0x55,第511字节必须是0xAA.
【参考资料】
《Linux内核完全剖析》