程序的加载和执行(一)
本文及之后的几篇博文是原书第13章的学习笔记。
本章主要是学习一个例子,对应的代码分为3个文件:
;代码清单13-1
;文件名:c13_mbr.asm
;文件说明:硬盘主引导扇区代码
;代码清单13-2
;文件名:c13_core.asm
;文件说明:保护模式微型核心程序
;代码清单13-3
;文件名:c13.asm
;文件说明:用户程序
因为代码比较长,完整的我就不贴了。有需要朋友的可以到http://download.csdn.net/detail/u013490896/9388139下载。
本章的例子清楚地说明了4个步骤:
1. 主引导程序开始执行
2. 主引导程序加载内核(其实这个内核太简陋了,只是为了说明原理,我们就这样叫吧),并转交控制权给内核
3. 内核加载用户程序,执行用户程序
4. 用户程序通过调用内核例程返回到内核
内核的结构
我把代码清单13-2
的源文件精简了一下,以清晰表示内核的结构。
;代码清单13-2
;文件名:c13_core.asm
;文件说明:内核结构
;以下常量定义部分。内核的大部分内容都应当固定
core_code_seg_sel equ 0x38 ;内核代码段选择子
core_data_seg_sel equ 0x30 ;内核数据段选择子
sys_routine_seg_sel equ 0x28 ;系统公共例程代码段的选择子
video_ram_seg_sel equ 0x20 ;视频显示缓冲区的段选择子
core_stack_seg_sel equ 0x18 ;内核堆栈段选择子
mem_0_4_gb_seg_sel equ 0x08 ;整个0-4GB内存的段的选择子
;-------------------------------------------------------------------------------
;以下是系统核心的头部,用于加载核心程序
core_length dd core_end ;核心程序总长度#00
sys_routine_seg dd section.sys_routine.start
;系统公用例程段位置#04
core_data_seg dd section.core_data.start
;核心数据段位置#08
core_code_seg dd section.core_code.start
;核心代码段位置#0c
core_entry dd start ;核心代码段入口点#10
dw core_code_seg_sel
;===============================================================================
[bits 32]
SECTION sys_routine vstart=0 ;系统公共例程代码段
......
SECTION core_data vstart=0 ;系统核心的数据段
......
SECTION core_code vstart=0 ;内核代码段
......
start:
......
;===============================================================================
core_end:
首先,用EQU声明了一些常量,需要注意的是:EQU声明的常量不占用空间。
其次,是内核的头部;
最后,是公共例程代码段、内核数据段、内核代码段。
内核头部示意图如下:
注意,内核代码段的入口共6个字节,前4个字节是段内偏移地址(它来自标号start,以后会被传送到EIP),后2个字节是内核代码段的选择子(=0x38)。
当引导程序加载完内核,内核加载完用户程序之后,内存布局示意图如下(只是示意图,没有严格按照比例绘制)
内核的加载
1 ;代码清单13-1
2 ;文件名:c13_mbr.asm
3 ;文件说明:硬盘主引导扇区代码
4 ;创建日期:2011-10-28 22:35
5
6 core_base_address equ 0x00040000 ;常数,内核加载的起始内存地址
7 core_start_sector equ 0x00000001 ;常数,内核的起始逻辑扇区号
8
9 mov ax,cs
10 mov ss,ax
11 mov sp,0x7c00
12
13 ;计算GDT所在的逻辑段地址
14 mov eax,[cs:pgdt+0x7c00+0x02] ;GDT的32位物理地址
15 xor edx,edx
16 mov ebx,16
17 div ebx ;分解成16位逻辑地址
18
19 mov ds,eax ;令DS指向该段以进行操作
20 mov ebx,edx ;段内起始偏移地址
第6、7行,作者定义了2个常量,分别是内核加载的起始物理内存地址(也不一定非要这个值,只要合理规划就行)和内核的起始逻辑扇区号(在写入镜像文件的时候,要和这个扇区号对应)。
9~11行,设置实模式的栈和栈指针。
14~17,像之前的程序一样,把GDT的物理地址分解为逻辑地址(段地址:偏移地址),于是 DS:EBX就指向了GDT的起始位置。
22 ;跳过0#号描述符的槽位
23 ;创建1#描述符,这是一个数据段,对应0~4GB的线性地址空间
24 mov dword [ebx+0x08],0x0000ffff ;基地址为0,段界限为0xFFFFF
25 mov dword [ebx+0x0c],0x00cf9200 ;粒度为4KB,存储器段描述符
26
27 ;创建保护模式下初始代码段描述符
28 mov dword [ebx+0x10],0x7c0001ff ;基地址为0x00007c00,界限0x1FF
29 mov dword [ebx+0x14],0x00409800 ;粒度为1个字节,代码段描述符
30
31 ;建立保护模式下的堆栈段描述符 ;基地址为0x00007C00,界限0xFFFFE
32 mov dword [ebx+0x18],0x7c00fffe ;粒度为4KB
33 mov dword [ebx+0x1c],0x00cf9600
34
35 ;建立保护模式下的显示缓冲区描述符
36 mov dword [ebx+0x20],0x80007fff ;基地址为0x000B8000,界限0x07FFF
37 mov dword [ebx+0x24],0x0040920b ;粒度为字节
38
39 ;初始化描述符表寄存器GDTR
40 mov word [cs: pgdt+0x7c00],39 ;描述符表的界限
41
42 lgdt [cs: pgdt+0x7c00]
43
44 in al,0x92 ;南桥芯片内的端口
45 or al,0000_0010B
46 out 0x92,al ;打开A20
47
48 cli ;中断机制尚未工作
49
50 mov eax,cr0
51 or eax,1
52 mov cr0,eax ;设置PE位
53
54 ;以下进入保护模式... ...
55 jmp dword 0x0010:flush ;16位的描述符选择子:32位偏移
56 ;清流水线并串行化处理器
24~37行,建立描述符,下图是GDT示意图。
在进入保护模式之后,首先设置DS和堆栈段,然后会加载内核的第一个扇区,因为第一个扇区包含了头部数据。
57 [bits 32]
58 flush:
59 mov eax,0x0008 ;加载数据段(0..4GB)选择子
60 mov ds,eax
61
62 mov eax,0x0018 ;加载堆栈段选择子
63 mov ss,eax
64 xor esp,esp ;堆栈指针 <- 0
于是DS指向了0~4GB的数据段;
66 ;以下加载系统核心程序
67 mov edi,core_base_address
68
69 mov eax,core_start_sector
70 mov ebx,edi ;起始地址
71 call read_hard_disk_0 ;以下读取程序的起始部分(一个扇区)
138 read_hard_disk_0: ;从硬盘读取一个逻辑扇区
139 ;EAX=逻辑扇区号
140 ;DS:EBX=目标缓冲区地址
141 ;返回:EBX=EBX+512
关于read_hard_disk_0
这个过程代码我就不贴了,这个过程和原书第八章的代码类似。具体讲解可以参考我的博文:硬盘和显卡的访问与控制(二)——《x86汇编语言:从实模式到保护模式》读书笔记02
http://blog.csdn.net/longintchar/article/details/49454459
与第八章的那个读硬盘的过程相比,这个过程仅有几处不同:
1.用EAX传入28位的逻辑扇区号。
2.DS:EBX指向目标缓冲区的地址。
3.每次返回时,EBX会自增512.
因为DS指向0-4GB的数据段,所以67~71把内核的第一个扇区加载到了物理地址core_start_sector (=0x40000)处。如下图所示:
73 ;以下判断整个程序有多大
74 mov eax,[edi] ;核心程序尺寸
75 xor edx,edx
76 mov ecx,512 ;512字节每扇区
77 div ecx
78
79 or edx,edx
80 jnz @1 ;未除尽,因此结果比实际扇区数少1
81 dec eax ;已经读了一个扇区,扇区总数减1
82 @1:
83 or eax,eax ;考虑实际长度≤512个字节的情况
84 jz setup ;EAX=0 ?
85
86 ;读取剩余的扇区
87 mov ecx,eax ;32位模式下的LOOP使用ECX
88 mov eax,core_start_sector
89 inc eax ;从下一个逻辑扇区接着读
90 @2:
91 call read_hard_disk_0
92 inc eax
93 loop @2 ;循环读,直到读完整个内核
94
上面这段代码首先判断程序的尺寸(保存在EAX中),然后做除法 EDX:EAX/512=EAX…EDX,根据商和余数读取剩余的扇区。计算原理与第八章的“代码清单8-1”中的代码类似。流程图可以参考我刚才提到的那篇博文。
需要特别提醒的是:83~84行的判断是必要的,不然的话,当剩余扇区数(EAX)为0时,循环将会执行(0xFFFF_FFFF+1)次,哦,这真是一个重大的BUG。
加载完内核后,我们要根据头部信息向GDT追加描述符。
95 setup:
96 mov esi,[0x7c00+pgdt+0x02] ;不可以在代码段内寻址pgdt,但可以
97 ;通过4GB的段来访问
98 ;建立公用例程段描述符
99 mov eax,[edi+0x04] ;公用例程代码段起始汇编地址
100 mov ebx,[edi+0x08] ;核心数据段汇编地址
101 sub ebx,eax
102 dec ebx ;公用例程段界限
103 add eax,edi ;公用例程段基地址
104 mov ecx,0x00409800 ;字节粒度的代码段描述符
105 call make_gdt_descriptor
106 mov [esi+0x28],eax
107 mov [esi+0x2c],edx
108
109 ;建立核心数据段描述符
110 mov eax,[edi+0x08] ;核心数据段起始汇编地址
111 mov ebx,[edi+0x0c] ;核心代码段汇编地址
112 sub ebx,eax
113 dec ebx ;核心数据段界限
114 add eax,edi ;核心数据段基地址
115 mov ecx,0x00409200 ;字节粒度的数据段描述符
116 call make_gdt_descriptor
117 mov [esi+0x30],eax
118 mov [esi+0x34],edx
119
120 ;建立核心代码段描述符
121 mov eax,[edi+0x0c] ;核心代码段起始汇编地址
122 mov ebx,[edi+0x00] ;程序总长度
123 sub ebx,eax
124 dec ebx ;核心代码段界限
125 add eax,edi ;核心代码段基地址
126 mov ecx,0x00409800 ;字节粒度的代码段描述符
127 call make_gdt_descriptor
128 mov [esi+0x38],eax
129 mov [esi+0x3c],edx
130
131 mov word [0x7c00+pgdt],63 ;描述符表的界限
132
133 lgdt [0x7c00+pgdt]
此时,整个的GDT示意图如下:
第98~107,是添加公共例程段描述符的具体代码。
98 ;建立公用例程段描述符
99 mov eax,[edi+0x04] ;公用例程代码段起始汇编地址
100 mov ebx,[edi+0x08] ;核心数据段汇编地址
101 sub ebx,eax
102 dec ebx ;公用例程段界限
103 add eax,edi ;公用例程段基地址
104 mov ecx,0x00409800 ;字节粒度的代码段描述符
105 call make_gdt_descriptor
106 mov [esi+0x28],eax
107 mov [esi+0x2c],edx
第105行,调用了过程 call make_gdt_descriptor
195 make_gdt_descriptor: ;构造描述符
196 ;输入:EAX=线性基地址
197 ; EBX=段界限
198 ; ECX=属性(各属性位都在原始
199 ; 位置,其它没用到的位置清0)
200 ;返回:EDX:EAX=完整的描述符
201 mov edx,eax
202 shl eax,16
203 or ax,bx ;描述符前32位(EAX)构造完毕
204
205 and edx,0xffff0000 ;清除基地址中无关的位
206 rol edx,8
207 bswap edx ;装配基址的31~24和23~16 (80486+)
208
209 xor bx,bx
210 or edx,ebx ;装配段界限的高4位
211
212 or edx,ecx ;装配属性
213
214 ret
根据注释,这个过程的输入和返回都已经很清楚了,这个过程的功能是通过“段基地址(EAX),段限长(EBX),属性值(ECX)”这三个参数来构造一个描述符(EDX:EAX)。下面就具体讲解这个过程。
我们先复习一下段描述符的通用格式(图片选自赵炯的《Linux内核完全注释》)。
首先构造描述符的低32位(图片中下面的那个东东)。
201 mov edx,eax
202 shl eax,16
203 or ax,bx ;描述符前32位(EAX)构造完毕
201行,先备份一个EAX到EDX中,留在后面用。
202行,EAX左移16位,于是基地址的0-15位就位;
203行,段限长的0-15位就位;
于是描述符的低32位构造完毕。
接下来,构造描述符的高32位(图片中上面那个东东)。这个构造起来有点麻烦。
我们先学习一个指令——字节交换指令:bswap
在标准的32位处理器上,这个指令只允许32位的寄存器操作数,其格式为
bswap r32
处理器执行该指令时,按如下过程操作(DEST是指令中的操作数,TEMP是处理器内的一个临时寄存器)
是不是有些晕呢?没有关系,我绘制了一张图,这张图的特色是“渐变色”,很清楚地说明了字节是如何交换的。
看清楚了吧。
OK,我们继续。
205 and edx,0xffff0000 ;清除基地址中无关的位
206 rol edx,8
207 bswap edx ;装配基址的31~24和23~16 (80486+)
208
209 xor bx,bx
210 or edx,ebx ;装配段界限的高4位
211
212 or edx,ecx ;装配属性
以上代码是具体的构造过程,引用原书图13-6。
205~207三行执行完后,段基地址已经就位。
209行,清除段界限的15-0位,只保留19-16位。这里假设EBX寄存器的高12位全为0,其实安全的做法是把209行修改为
and ebx,0x000F_0000
210行,装配段界限到EDX寄存器。
212行,装配属性值到EDX寄存器。
至此,在EDX:EAX中得到了完整的64位的段描述符。
好了,现在再回到98~107行。代码再贴一次。
98 ;建立公用例程段描述符
99 mov eax,[edi+0x04] ;公用例程代码段起始汇编地址
100 mov ebx,[edi+0x08] ;核心数据段汇编地址
101 sub ebx,eax
102 dec ebx ;公用例程段界限
103 add eax,edi ;公用例程段基地址
104 mov ecx,0x00409800 ;字节粒度的代码段描述符
105 call make_gdt_descriptor
106 mov [esi+0x28],eax
107 mov [esi+0x2c],edx
此时,DS:EDI仍然指向内核的起始位置。根据内核头部的构造,我们从头部取出公共例程代码段的起始汇编地址到EAX,再取出内核数据段的起始汇编地址到EBX,后者减去前者,就是公共例程段的长度,再减去一,就是段界限,EBX这个参数就准备好了。
然后准备参数EAX(段基址):因为公共例程段的起始汇编地址(已经传送到EAX了)是相对于内核的起始位置的,加载内核后,内核的起始位置在线性地址0x40000(就是EDI的值)处,所以,公共例程段的起始地址是(EAX+EDI),这就是103行的用意。
104行,填写属性值,注意要把无关的位都清零。
105行调用过程。
106~107利用返回值安装描述符。
其他描述符的构造和安装过程类似,这里从略。
跳转到内核入口点
最后一步,跳到内核的入口点开始执行内核程序。
135 jmp far [edi+0x10]
这是一个16位直接绝对远转移,在ds:[edi+0x10]处,是6字节的内核入口点。低32位是偏移地址,高16位是内核代码段的选择子。关于jmp指令,可以参考我的博文:
8086处理器的无条件转移指令——《x86汇编语言:从实模式到保护模式》读书笔记13
http://blog.csdn.net/longintchar/article/details/50529164
总结
啰嗦了这么多,不知道你是不是觉得什么都没有记住呢…
我们来总结一下引导程序的引导步骤吧。
1. 创建GDT
2. 令DS指向0-4GB数据段;初始化SS和ESP;
3. 调用read_hard_disk_0
读取内核的第一个扇区到0x40000;
4. 判断内核的长度,根据长度再读取若干个扇区
5. ESI指向GDT的起始,调用过程make_gdt_descriptor
向GDT追加关于内核的段描述符
6. 跳转到内核入口点
关于内核的执行,下篇博文我们再讨论。敬请期待……