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

时间:2022-03-21 01:25:48

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

1.显示处理器品牌信息

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=指向用户程序头部的选择子

2.用户程序的加载和重定位

2.1.用户程序的头部

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

用户程序头部如下:
程序的加载和执行(二)——《x86汇编语言:从实模式到保护模式》读书笔记22
上图中,凡是需要改写的地方,我都用黄色标注了。改写的工作,是内核在加载程序的过程中完成的。

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

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

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         ;内核用的缓冲区

2.3.判断用户程序的大小

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中。

2.4.申请内存

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处。

2.5.加载用户程序到内存

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读取用户程序到内存。

2.6.为用户程序建立段描述符

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中。
这时候,用户程序在内存中的位置如下图所示:
程序的加载和执行(二)——《x86汇编语言:从实模式到保护模式》读书笔记22
434~438,调用过程make_seg_descriptor建立头部段描述符。
过程make_seg_descriptor的输入和返回说明如下:

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

为了方便阅读代码,再次粘贴头部的图片(右侧是偏移地址):

程序的加载和执行(二)——《x86汇编语言:从实模式到保护模式》读书笔记22
过程make_seg_descriptor的代码就不多说了,和主引导程序中的过程make_gdt_descriptor基本相同。具体讲解可以参见我的博文:

程序的加载和执行(一)——《x86汇编语言:从实模式到保护模式》读书笔记21
第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是带零扩展的传送指令。指令格式如下:
程序的加载和执行(二)——《x86汇编语言:从实模式到保护模式》读书笔记22
举个例子,比如

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

【未完待续】