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

本博文对应原书13.3-13.4节的内容。

显示处理器品牌信息

531   start:
532         mov ecx,core_data_seg_sel           ;使ds指向核心数据段 
533         mov ds,ecx
534
535         mov ebx,message_1
536         call sys_routine_seg_sel:put_string
537                                         
538         ;显示处理器品牌信息 
539         mov eax,0x80000002
540         cpuid
541         mov [cpu_brand + 0x00],eax
542         mov [cpu_brand + 0x04],ebx
543         mov [cpu_brand + 0x08],ecx
544         mov [cpu_brand + 0x0c],edx
545      
546         mov eax,0x80000003
547         cpuid
548         mov [cpu_brand + 0x10],eax
549         mov [cpu_brand + 0x14],ebx
550         mov [cpu_brand + 0x18],ecx
551         mov [cpu_brand + 0x1c],edx
552
553         mov eax,0x80000004
554         cpuid
555         mov [cpu_brand + 0x20],eax
556         mov [cpu_brand + 0x24],ebx
557         mov [cpu_brand + 0x28],ecx
558         mov [cpu_brand + 0x2c],edx
559
560         mov ebx,cpu_brnd0
561         call sys_routine_seg_sel:put_string
562         mov ebx,cpu_brand
563         call sys_routine_seg_sel:put_string
564         mov ebx,cpu_brnd1
565         call sys_routine_seg_sel:put_string

从主引导程序转移到内核之后,处理器会从532行处开始执行,因为这里是内核头部登记的入口。
532~533行,初始化DS,令其指向内核数据段。
535~536,调用公共例程段内的过程put_string来显示字符串

362         message_1        db  '  If you seen this message,that means we '
363                          db  'are now in protect mode,and the system '
364                          db  'core is loaded,and the video display '
365                          db  'routine works perfectly.',0x0d,0x0a,0

这个显示字符串的例程,原理和书上第八章的代码基本相同,不同的地方是:
1. 这里的代码是32位模式的,字符串的地址由DS:EBX传入
2. 过程返回用的指令是retf,这意味着必须以远调用的方式调用它

还有一点要说明:

106         mov eax,video_ram_seg_sel
107         mov ds,eax
108         mov es,eax
109         cld
110         mov esi,0xa0                       ;小心!32位模式下movsb/w/d 
111         mov edi,0x00                       ;使用的是esi/edi/ecx 
112         mov ecx,1920
113         rep movsd

以上代码是滚屏功能的一部分,第113行用到了rep movsd指令。但是这里有一个小小的问题,作者的本意是把1~24行的字符拷贝到0~23行,又因为一次传送4个字节,所以传送的次数=24*80*2/4=960;
所以第112行应该改为:

112         mov ecx,960

第539~565用于显示处理器品牌信息。
cpuid指令是从80486处理器的后期版本开始引入的。原则上,在使用该指令前,要先检测标志寄存器EFLAGS的ID标志位,如果为0则不支持该指令,反之则支持。本代码省略了这个检测。

    mov eax,0
    cpuid

这两行用于探测处理器能够支持的最大的基本功能号,返回值在EAX中,我的实验环境返回3;

    mov eax,0x8000_0000
    cpuid

这两行用于探测处理器能够支持的最大的扩展功能号,返回值在EAX中,我的实验环境返回0x8000_0004;

567         mov ebx,message_5
568         call sys_routine_seg_sel:put_string
569         mov esi,50                          ;用户程序位于逻辑50扇区 
570         call load_relocate_program

567~568行,显示字符串

367         message_5        db  ' Loading user program...',0

569~570调用过程load_relocate_program,用于加载并且重定位用户程序。

387 load_relocate_program: ;加载并重定位用户程序 388 ;输入:ESI=起始逻辑扇区号 389 ;返回:AX=指向用户程序头部的选择子 

用户程序的加载和重定位

用户程序的头部

其实,用户程序头部的构造,属于链接器的工作。可是对于我们这个如此的简陋的系统,那就由用户自己来构造吧。

用户程序头部如下:

上图中,凡是需要改写的地方,我都用黄色标注了。改写的工作,是内核在加载程序的过程中完成的。

关于每个字段的详细解释,书上P231页已经说明得很清楚了,这里不再赘述。只是有几点需要强调:
1. 偏移0x08处的双字是保留字段,内核不要求用户程序提供栈空间,而是由内核分配,以减轻用户编写程序的负担。当内核分配了栈空间后,会把栈段的选择子回填到这里。当用户程序开始执行时,可以从这里获得栈选择子,然后初始化SS和ESP;
2. 偏移0x10处的双字,应该填写用户要求的栈大小(以4KB为单位)。例如填写1就表示希望分配4KB的栈空间。
3. 偏移量0x14处的双字,是用户程序代码段的起始汇编地址。当内核完成对用户程序的加载和重定位后,会把代码段的选择子回填到这里(仅占用低字)。这样一来,它和0x10处的双字组成了一个6字节的入口点(图中绿色部分),内核就从这个入口点转移到用户程序。
4. 内核会提供一些例程供用户程序调用。在偏移0x28处,用户程序需要列出所有要用到的符号名。每个符号名的长度是256字节(不足部分用0填充)。在用户程序加载后,内核会分析这个表格,将每个符号名替换成对应的内存地址,这就是过程的重定位。为了方便起见,本文把这个表格简称为“符号表”。

读取用户程序的第一个扇区

399         mov eax,core_data_seg_sel
400         mov ds,eax                         ;切换DS到内核数据段
401       
402         mov eax,esi                        ;读取程序头部数据 
403         mov ebx,core_buf                        
404         call sys_routine_seg_sel:read_hard_disk_0

这段代码读取用户程序的第一个扇区到内核的缓冲区。
内核缓冲区在数据段定义

376         core_buf   times 2048 db 0         ;内核用的缓冲区

判断用户程序的大小

406         ;以下判断整个程序有多大
407         mov eax,[core_buf]                 ;程序尺寸
408         mov ebx,eax
409         and ebx,0xfffffe00                 ;使之512字节对齐(能被512整除的数, 
410         add ebx,512                        ;低9位都为0 
411         test eax,0x000001ff                ;程序的大小正好是512的倍数吗? 
412         cmovnz eax,ebx                     ;不是。使用凑整的结果 

第406行用于获取用户程序的长度,传送到EAX;
408~412的目的是把用户程序的长度向上对齐到512字节。也许长度正好是512B的倍数,也许不是。于是我们两手准备:如果不是,那么408~410用于向上对齐到512B,保存在EBX中;如果是,也就是说411行执行后,标志位Z=1。
第412行,cmovnz属于条件传送指令,条件满足就传送,条件不满足就什么也不做。
结合我们的代码,当411行执行完后,如果Z=1,说明长度本身就是512B的整数倍,那么不需要传送,EAX就是我们要的结果;当Z不等于1时,需要对齐,于是把EBX(对齐的结果)传送给EAX;
不管哪种情况,最终的长度在EAX中。

申请内存

414         mov ecx,eax                        ;实际需要申请的内存数量
415         call sys_routine_seg_sel:allocate_memory

这两行用于内存的申请,调用了过程allocate_memory;

232  allocate_memory:                            ;分配内存
233                                            ;输入:ECX=希望分配的字节数
234                                            ;输出:ECX=起始线性地址 
235         push ds
236         push eax
237         push ebx
238      
239         mov eax,core_data_seg_sel
240         mov ds,eax
241      
242         mov eax,[ram_alloc]
243         add eax,ecx                        ;下一次分配时的起始地址
244      
245         ;这里应当有检测可用内存数量的指令
246          
247         mov ecx,[ram_alloc]                ;返回分配的起始地址
248
249         mov ebx,eax
250         and ebx,0xfffffffc
251         add ebx,4                          ;强制对齐 
252         test eax,0x00000003                ;下次分配的起始地址最好是4字节对齐
253         cmovnz eax,ebx                     ;如果没有对齐,则强制对齐 
254         mov [ram_alloc],eax                ;下次从该地址分配内存
255                                            ;cmovcc指令可以避免控制转移 
256         pop ebx
257         pop eax
258         pop ds
259
260         retf
335         ram_alloc        dd  0x00100000    ;下次分配内存时的起始地址

我们的内存分配策略非常简单,在标号ram_alloc处初始化了一个双字:0x0010_0000,这就是可用于分配的初始内存地址。每次请求分配内存时,过程allocate_memory仅简单地返回该地址,同时,将这个值加上本次需要分配的长度,把结果写回标号ram_alloc处,作为下次分配的起始地址。
247行,返回分配的起始地址。
242~243行,算出下一次分配的起始地址,值在EAX中;
249~253,把EAX的值向上4字节对齐。因为32位的计算机系统为了提高访问速度,建议内存地址最好4字节对齐。可是话又说回来,因为我们申请的大小是512字节对齐的,所以自然是4的倍数。
254行,把对齐后的结果写回标号ram_alloc处。

加载用户程序到内存

416         mov ebx,ecx                        ;ebx -> 申请到的内存首地址
417         push ebx                           ;保存该首地址 
418         xor edx,edx
419         mov ecx,512
420         div ecx
421         mov ecx,eax                        ;总扇区数 
422      
423         mov eax,mem_0_4_gb_seg_sel         ;切换DS到0-4GB的段
424         mov ds,eax
425
426         mov eax,esi                        ;起始扇区号 
427  .b1:
428         call sys_routine_seg_sel:read_hard_disk_0
429         inc eax
430         loop .b1                           ;循环读,直到读完整个用户程序

416行,把申请到的内存首地址传送到EBX中,因为后面要把用户程序加载到DS:EBX处(因为DS指向0-4GB的段,所以就是加载到物理地址EBX处);
417行,把EBX的值压栈,这个值是用户加载的起始地址。因为在读扇区的时候会修改EBX的值,所以要压栈保存。
418~421:程序的总大小在EAX中(已经对齐到512字节了),EDX:EAX/512=EAX(余数为0);用总字节数除以512,得到总扇区数,传送到ECX中;
426~430,循环调用过程read_hard_disk_0读取用户程序到内存。

为用户程序建立段描述符

432         ;建立程序头部段描述符
433         pop edi                            ;恢复程序装载的首地址 
434         mov eax,edi                        ;程序头部起始线性地址
435         mov ebx,[edi+0x04]                 ;段长度
436         dec ebx                            ;段界限 
437         mov ecx,0x00409200                 ;字节粒度的数据段描述符
438         call sys_routine_seg_sel:make_seg_descriptor
439         call sys_routine_seg_sel:set_up_gdt_descriptor
440         mov [edi+0x04],cx                   

433行,把之前保存的用户程序加载的起始地址弹出到EDI中。
这时候,用户程序在内存中的位置如下图所示:

434~438,调用过程make_seg_descriptor建立头部段描述符。
过程make_seg_descriptor的输入和返回说明如下:

308 make_seg_descriptor: ;构造存储器和系统的段描述符 309 ;输入: EAX=线性基地址 310 ; EBX=段界限 311 ; ECX=属性。各属性位都在原始 312 ; 位置,无关的位清零 313 ;返回:EDX:EAX=描述符

为了方便阅读代码,再次粘贴头部的图片(右侧是偏移地址):
程序的加载和执行(二)——《x86汇编语言:从实模式到保护模式》读书笔记22_第1张图片
过程make_seg_descriptor的代码就不多说了,和主引导程序中的过程make_gdt_descriptor基本相同。具体讲解可以参见我的博文:
程序的加载和执行(一)——《x86汇编语言:从实模式到保护模式》读书笔记21
http://blog.csdn.net/longintchar/article/details/50938137
第439行,调用过程set_up_gdt_descriptor向GDT中追加描述符。
这个过程的代码如下:

263  set_up_gdt_descriptor:                    ;在GDT内安装一个新的描述符
264                                            ;输入:EDX:EAX=描述符 
265                                            ;输出:CX=描述符的选择子
266         push eax
267         push ebx
268         push edx
269      
270         push ds
271         push es
272      
273         mov ebx,core_data_seg_sel          ;切换到核心数据段
274         mov ds,ebx
275
276         sgdt [pgdt]                        ;以便开始处理GDT
277
278         mov ebx,mem_0_4_gb_seg_sel
279         mov es,ebx
280
281         movzx ebx,word [pgdt]              ;GDT界限 
282         inc bx                             ;GDT总字节数,也是下一个描述符偏移 
283         add ebx,[pgdt+2]                   ;下一个描述符的线性地址 
284      
285         mov [es:ebx],eax
286         mov [es:ebx+4],edx
287      
288         add word [pgdt],8                  ;增加一个描述符的大小 
289      
290         lgdt [pgdt]                        ;对GDT的更改生效 
291       
292         mov ax,[pgdt]                      ;得到GDT界限值
293         xor dx,dx
294         mov bx,8
295         div bx                             ;除以8,去掉余数
296         mov cx,ax                          
297         shl cx,3                           ;将索引号移到正确位置 
298
299         pop es
300         pop ds
301
302         pop edx
303         pop ebx
304         pop eax
305      
306         retf 

下面对这个过程进行讲解。
273~274,令DS指向核心数据段,为使用sgdt指令做准备;
276行,用到了指令sgdt,格式是:

    sgdt m

其中m是一个6字节内存区域的首地址。指令执行后,在m处就会存有GDT的边界值(低2个字节)和基地址(高4个字节)。
代码中的pgdt在内核的数据段中定义:

332         pgdt             dw  0             ;用于设置和修改GDT 
333                          dd  0

278~279行,令ES指向0-4GB数据段。
第281~283行,计算描述符的安装地址。计算原理是这样的:首先得到GDT的界限值,把它加一,就是GDT的总字节数,也是要安装的描述符的偏移量。这个偏移量加上GDT的基地址,就是安装地址。
movzx是带零扩展的传送指令。指令格式如下:

举个例子,比如

    movzx cx,al

假设al=0x12,那么指令执行后,cx=0x0012;
其实就是把源操作数复制给目的寄存器,目的寄存器的高位用0填充。
因为第283行的加法指令,需要把GDT的基地址(4个字节)和偏移量相加,所以我们需要把偏移量变成4字节。这就是281行要用movzx的原因。
281行:EBX中得到GDT的边界。
282行:得到GDT的大小(也就是要安装的描述符的偏移量);也许你很奇怪,为什么不是

    inc ebx

关于这一点,书上说得很明确。一般情况下,这两条指令都是可以的。但是如果是刚启动计算机,这时GDT还是空的,GDTR寄存器中的基地址为0x0000_0000,界限值为0xFFFF。假设在此时要调用这个过程安装一个描述符,如果使用inc bx,那么0xFFFF+1=0x0000(进位丢弃),于是EBX的值就是0,这就是第一个描述符在表内的偏移量,合情合理。
如果使用inc ebx,那么0xFFFF+1=0x0001_0000,于是EBX的值是0x0001_0000,这个值作为第一个描述符的偏移量显然不对。
285~286,填写描述符。
288~290,将GDT的界限加上8,然后用lgdt指令重新加载GDTR,使新描述符生效。
292~295,求出新描述符的索引号。推导过程如下:
设索引号为idx(idx=0,1,2,…),

    界限值=(idx+1)*8-1=idx*8+7

于是得出

 界限值/8 = idx ...7

所以界限值除以8,丢弃余数,商就是索引。
297,索引值左移3位,得到段选择子(TI=0,RPL=0)
我们返回调用者的代码,第440行,将返回的头部段选择子回填到头部偏移0x04处(仅覆盖低字)。
继续看代码。

442         ;建立程序代码段描述符
443         mov eax,edi
444         add eax,[edi+0x14]                 ;代码起始线性地址
445         mov ebx,[edi+0x18]                 ;段长度
446         dec ebx                            ;段界限
447         mov ecx,0x00409800                 ;字节粒度的代码段描述符
448         call sys_routine_seg_sel:make_seg_descriptor
449         call sys_routine_seg_sel:set_up_gdt_descriptor
450         mov [edi+0x14],cx
451
452         ;建立程序数据段描述符
453         mov eax,edi
454         add eax,[edi+0x1c]                 ;数据段起始线性地址
455         mov ebx,[edi+0x20]                 ;段长度
456         dec ebx                            ;段界限
457         mov ecx,0x00409200                 ;字节粒度的数据段描述符
458         call sys_routine_seg_sel:make_seg_descriptor
459         call sys_routine_seg_sel:set_up_gdt_descriptor
460         mov [edi+0x1c],cx

433~460,创建代码段描述符和数据段描述符,并回填到相应的位置。具体过程同上,此处略。

关于过程load_relocate_program的讲解还没有完,还差创建栈段描述符和重定位符号表。由于时间和篇幅的关系,这篇博文就写到这里。剩下的内容会在下篇博文中讲述。

【未完待续】

你可能感兴趣的:(汇编语言,从实模式到保护模式)