【完整代码已经归档到 https://github.com/linzhanglong/mini_bootloader 】
现在我们完成我们第一个主要的功能:引导Linux内核。
首先我们要准备一个Linux内核文件 bzImag。如何引导Linux内核呢?需要做几件事情:
1. 设置GDT,设置访问内存权限;
2. 开启A20地址线(原因:http://blog.csdn.net/ruyanhai/article/details/7181842,是为了兼容历史CPU产生的坑)
3. 进入保护模式
4.把内核加载到内存去;
5.设置启动参数,然后调到内核代码执行。
首先我们先设置CPU进入保护模式,需要做两件事情,第一件事情就是设置GDT。我们看GDT的格式:
GDT用来做什么呢?第一,就是设置我们代码的权限;第二就是设置代码处于CPU哪个级别。其中Type字段决定了类型(数据段还是代码段)以及权限,而DPL决定了属于哪个CPU级别(ring0,还是ring3)。而Base address和Liminit决定了我们这条GDT描述符限制的地址范围。具体各个字段的意思可以参考:https://www.kancloud.cn/digest/protectedmode/121466
说明:S字段我们这里都是设置为1,因为我们处理的是代码段和数据段【统称存储段】。为0表示系统描述度(门描述符,tss描述符等,和我们这里没有关系)
我们现在是引导内核,所以我们这里简单设置两条GDT描述符,一条是ring0的代码段访问权限,一条是ring0的数据段访问权限。范围都是base address = 0,Limit = 0xffffff, G=1(决定Limit的单位),也就是Base_address到Base_address+4G范围。
怎么设置GDT描述符呢?初始化号GDT描述符表,然后把表的大小和内存地址赋值给lgdt指令:
最后一点说明:就是GDT描述符表的第一条描述符必须是空。所以我们现在可以先实现我们的GDT描述符加载函数了,代码如下(enter_protect.asm文件):
;***************初始化GDT表****************************
;第一条是空的描述符
[bits 16]
gdt_start:
dd 0x0
dd 0x0
;定义内核代码段访问权限
;Base_address-Base_address+4G代码段:权限只读,CPU处于ring0级别
gdt_system_cold:
dw 0xffff ;Limit
dw 0x0 ;Base adderess[15:0]
db 0x0 ;Base adderess[23:16]
db 10011010b ;P:1->描述符在内存中;DPL:00->ring0,S:1->存储段[代码数据段] 1010->代码段,权限是:执行,可读
db 11001111b ;G:1->Limit单位是4k,D/B:1->使用32位地址,未使用:00,Limit:1111
db 0x0 ;Base address[31:24]:0
;定义内核数据段访问权限
;Base_address-Base_address+4G数据段:权限读写,CPU处于ring0级别
gdt_system_data:
dw 0xffff ;Limit
dw 0x0 ;Base adderess[15:0]
db 0x0 ;Base adderess[23:16]
db 10010010b ;P:1->描述符在内存中;DPL:00->ring0,S:1->存储段[代码数据段] 010->数据段,权限是:读写
db 11001111b ;Base address[31:24]:0, G:1->Limit单位是4k,D/B:1->使用32位地址,未使用:00,Limit:1111
db 0x0 ;Base address[31:24]:0
gdt_end:
dgt_descriptors:
dw gdt_end - gdt_start -1
dd gdt_start
;进入保护模式之后,我们需要设置代码段将使用gdt_system_cold描述符,数据段将使用gdt_system_data描述符
GDT_SYSTEMCOLD_OFFSET equ gdt_system_cold - gdt_start ;在描述符表的偏移位置
GDT_SYSTEMDATA_OFFSET equ gdt_system_data - gdt_start ;在描述符表的偏移位置
;向外提供的函数,初始化GDT描述符表
init_gdt:
pusha
lgdt [dgt_descriptors]
popa
ret
现在开始做第二件事,就是启用A20地址,启用的原因也是x86历史的一个坑,具体原因看(https://zh.wikipedia.org/wiki/A20%E6%80%BB%E7%BA%BF),这里不重点关注。我们直接看怎么启动A20,代码如下(enter_protect.asm文件):
;******************启用A20*************
;摘自 http://mrhopehub.github.io/2014/12/26/enabling-the-A20-Gate.html
EnableA20_KB:
push ax ;Saves AX
mov al, 0xdd ;Look at the command list
out 0x64, al ;Command Register
pop ax ;Restore's AX
ret
现在开始进入保护模式,代码如下(enter_protect.asm文件):
;*****************设置保护模式****************************
enter_protect:
cli
call init_gdt
call EnableA20_KB
mov eax, cr0
or eax, 0x1
mov cr0, eax
jmp GDT_SYSTEMCOLD_OFFSET:init_protect
;开始进入保护模式,都是32位地址
;下面的函数都是32位的函数
[bits 32]
init_protect:
;现在设置我们的数据段使用GDT_SYSTEMDATA_OFFSET描述符
mov ax, GDT_SYSTEMDATA_OFFSET
mov ds, ax
mov ss, ax
mov es, ax
mov fs, ax
mov gs, ax
;设置堆栈s
mov ebp, 0x90000
mov esp, ebp
mov ebx, MSG_ENTER_PROTECT_OK
call print_string_protect
mov ebx, 0x123456
call print_hex_pm
jmp $
ret
MSG_ENTER_PROTECT_OK db 'Enter protect mode ok', 0
进入保护模式之后,BIOS的中断服务我们就使用不了了。因为BIOS是实模式,我们现在在保护模式下,中断的处理都不一样了。所以我们这里要实现两个函数,第一个函数就是打印字符串函数。这个比较容易实现了,通过操作显卡内存就可以打印了(BIOS在初始化硬件时候已经设置好了VGA现存地址,我们网改地址写入数据时候,显卡硬件就会对应显示数值),代码如下(enter_protect.asm文件):
;***************保护模式下的打印,不能通过BIOS,但是可以通过显卡映射的内存来操作*****
[bits 32]
VIDEO_MEMERY_START equ 0xb8000
;@ brief 保护模式下的打印字符串
;@ param ebx 字符串的地址
print_string_protect:
pusha
;先读取当前光标位置
;光标位置高8位
xor eax, eax
mov dx, 3D4H
mov al, 0xE
out dx, al ;设置索引:读取高8位
mov dx, 3D5H
in al, dx ;从VGA寄存器读取数据
mov ah, al
;光标位置低8位
mov al, 0xF
mov dx, 3D4H
out dx, al ;设置索引:读取低8位
mov dx, 3D5H
in al, dx ;从VGA寄存器读取数据
;设置光标对应的显卡内存位置,两个字节对应一个字符
mov edx, VIDEO_MEMERY_START
add edx, eax
add edx, eax
;保存光标的位置
mov ecx, eax
print_next_protect:
mov al, [ebx] ;显示的字符
;判断是否为结束符
cmp al ,0
je print_ok_protect
mov ah, 0x0f ;显示的字符颜色和背景设置
mov [edx], ax
add ebx, 1
add edx, 2
inc ecx ;更新光标位置
jmp print_next_protect
print_ok_protect:
;设置光标显示到下一行,每一行有80个字符
;下一行的位置 = (当前的位置 + 79) / 80 * 80
;ecx 保存要设置的坐标位置
add ecx, 79
mov eax, ecx
mov ebx, 80
xor edx,edx
div ebx
mul ebx
mov ecx, eax
mov dx, 3D4H
mov al, 0xE
out dx, al ;设置索引:读取高8位
mov dx, 3D5H
mov al, ch
out dx, al ;从VGA寄存器写入数据
;光标位置低8位
mov al, 0xF
mov dx, 3D4H
out dx, al ;设置索引:读取低8位
mov dx, 3D5H
mov al, cl
out dx, al ;从VGA寄存器读取数据
jmp $
popa
ret
第二个问题就是在保护模式下打印寄存器的数值,怎么实现呢?就是直接把寄存器的数值转为字符串,让后调用上面的打印字符串的函数就可以了。
例如0x1234先转为"0x1234"然后调用打印字符串就可以实现。这个函数用于我们后面调试使用,很有帮助。(说明:前面实模式下的print_hex也可以使用这个方式重新实现),现在我们直接看保护模式下,打印寄存器的函数实现:
;保护模式下打印ebx寄存器的数值
;这里把数值转为字符串,然后调用print_string_protect即可
[bits 32]
init_print_hex_pm:
pusha
mov ah, 0x0f
mov al, '0'
mov [edx], ax
add edx, 1
mov al, 'x'
mov [edx], ax
popa
add edx, 1
ret
;@ brief 直接把bl转为字符对应的ASCII码,例如3->'3',10->'a'
convert_bx_hex2str_pm:
;如果数字大于15,异常。打印?号
cmp bl, 15
jg _ERROR_pm
;如果数字大于10,那么转为A-F
cmp bl, 10
jge _LETTER_pm
;如果数字是0-10,就只加上'0',就可以把0-10转为对应数字的ascii码
add bl, '0'
jmp _EXIT_pm
_LETTER_pm:
;先把数字减去10,然后加上A,就可以把10-15的数字转为A-F
sub bl, 10
add bl, 'A'
jmp _EXIT_pm
_ERROR_pm:
mov bl, '?'
_EXIT_pm:
ret
;@ brief 把一个数字转为多位十六进制输出
;@ param bx保存要打印的数字
print_hex_pm:
pusha
;首先通过掩码和移位的方式,把数字以十六进制的方式从最后一位开始一个个压入堆栈
;然后输出时候才从堆栈里一个个取出来,这样就可以实现顺序打印
xor cx, cx
_NEED_PUSH_pm:
;取掩码获取数字以十六进制形式的最后一位,例如数字0x1234取出4,其中4保存到al,0x123保存到bx。cl计数
mov eax, ebx
and eax, 0x0f
shr ebx, 4
;入栈并且计数
push ax
inc cl
;如果bx数值为0,表示我们已经全部处理完
cmp ebx, 0
jne _NEED_PUSH_pm
mov eax,CONVERT_HEX2str
add eax, 2
;开始出栈,并且显示
_NEED_POP_pm:
pop bx
call convert_bx_hex2str_pm
mov [eax], bx
inc eax
dec cl
cmp cl, 0
jne _NEED_POP_pm
;开始打印
mov ebx, CONVERT_HEX2str
call print_string_protect
popa
jmp $
ret
;8个字节,还有一个结束符
CONVERT_HEX2str: db '0','x',0,0,0,0,0,0,0,0,0
效果图: