程序的加载和执行(一)——《x86汇编语言:从实模式到保护模式》读书笔记21

程序的加载和执行(一)

本文及之后的几篇博文是原书第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~2423~16  (80486+)
208      
209         xor bx,bx
210         or edx,ebx                      ;装配段界限的高4211      
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. 跳转到内核入口点

关于内核的执行,下篇博文我们再讨论。敬请期待……

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值