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

Posted 车子 chezi

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了程序的加载和执行——《x86汇编语言:从实模式到保护模式》读书笔记23相关的知识,希望对你有一定的参考价值。

程序的加载和执行(三)——读书笔记23

接着上次的内容说。
关于过程load_relocate_program的讲解还没有完,还差创建栈段描述符和重定位符号表。

1.分配栈空间与创建栈段描述符

462         ;建立程序堆栈段描述符
463         mov ecx,[edi+0x0c]                 ;4KB的倍率 
464         mov ebx,0x000fffff
465         sub ebx,ecx                        ;得到段界限
466         mov eax,4096                        
467         mul dword [edi+0x0c]                         
468         mov ecx,eax                        ;准备为堆栈分配内存 
469         call sys_routine_seg_sel:allocate_memory
470         add eax,ecx                        ;得到堆栈的高端物理地址 
471         mov ecx,0x00c09600                 ;4KB粒度的堆栈段描述符
472         call sys_routine_seg_sel:make_seg_descriptor
473         call sys_routine_seg_sel:set_up_gdt_descriptor
474         mov [edi+0x08],cx

说代码之前,先上图,用户程序的头部示意图:

提醒一下,这时候DS:EDI依然指向用户程序的起始位置。
463行,取得用户设置的栈段的大小(以4KB为单位),就是下面公式中的N
464~465,计算出描述符中的段界限,计算公式是:

如果不明白为什么是这个公式,可以参考我的博文:
《如何构造栈段描述符》
466~469,调用过程allocate_memory申请栈空间;
470:准备参数EAX,因为描述符中的基地址等于栈空间的低端物理地址加上栈的大小。不懂的还请参考我上面提到的博文。
472~473,创建并安装栈段描述符。
474:将选择子回填到对应的位置(请参考上图)。

2.符号表的重定位

为了使用内核提供的例程,用户程序需要建立一个符号表。当用户程序被加载后,内核会根据这个符号表来回填每个例程的入口地址。这个过程就是符号地址的重定位。重定位过程中必不可少的环节是字符串的比较和匹配。
为了对用户程序的符号表进行匹配,内核也必须建立一张符号表,这张符号表包含了内核提供的所有例程。

329;===============================================================================
330     SECTION core_data vstart=0             ;系统核心的数据段
331;-------------------------------------------------------------------------------
332         pgdt             dw  0             ;用于设置和修改GDT 
333                          dd  0
334
335         ram_alloc        dd  0x00100000    ;下次分配内存时的起始地址
336
337         ;符号地址检索表
338         salt:
339         salt_1           db  '@PrintString'
340                     times 256-($-salt_1) db 0
341                          dd  put_string
342                          dw  sys_routine_seg_sel
343
344         salt_2           db  '@ReadDiskData'
345                     times 256-($-salt_2) db 0
346                          dd  read_hard_disk_0
347                          dw  sys_routine_seg_sel
348
349         salt_3           db  '@PrintDwordAsHexString'
350                     times 256-($-salt_3) db 0
351                          dd  put_hex_dword
352                          dw  sys_routine_seg_sel
353
354         salt_4           db  '@TerminateProgram'
355                     times 256-($-salt_4) db 0
356                          dd  return_point
357                          dw  core_code_seg_sel
358
359         salt_item_len   equ $-salt_4
360         salt_items      equ ($-salt)/salt_item_len

以上代码中第339~360,就是内核的符号表。
我们再看一下用户程序中定义的用户符号表(在文件c13.asm中)。

24;-------------------------------------------------------------------------------
25         ;符号地址检索表
26         salt_items       dd (header_end-salt)/256 ;#0x24
27         
28         salt:                                     ;#0x28
29         PrintString      db  '@PrintString'
30                     times 256-($-PrintString) db 0
31                     
32         TerminateProgram db  '@TerminateProgram'
33                     times 256-($-TerminateProgram) db 0
34                     
35         ReadDiskData     db  '@ReadDiskData'
36                     times 256-($-ReadDiskData) db 0

内核符号表的每个条目包括两部分:
1. 256字节的符号名,不足的部分用零填充;
2. 例程的入口(4字节的偏移地址+2字节的段选择子);

用户符号表的每个条目只有一个部分:
256字节的符号名,不足的部分用零填充。

当内核对用户符号表完成重定位后,用户符号表的内容发生了改变:每个条目的前6个字节被重新填写,填写的是对应例程的入口。
上面的过程可以用一张图来说明:

2.1.CMPS指令

在讲述代码之前,我们先学习字符串比较指令cmps。该指令有3种形式,分别用于字节、字和双字的比较。

    cmpsb   ;字节比较
    cmpsw   ;字比较
    cmpsd   ;双字比较

在16位模式下,源字符串的首地址由DS:SI指定,目的字符串的首地址由ES:DI指定;
在32位模式下,源字符串的首地址由DS:ESI指定,目的字符串的首地址由ES:EDI指定;
在处理器内部,cmps指令的操作是把两个操作数相减,然后根据结果设置相应的标志位。这还没有完,还要根据DF的值调整(E)SI(E)DI的值。下图是从《Intel Architecture Software Developer’s Manual Volume 2:Instruction Set Reference》弄过来的,用伪代码描述了操作过程。

REP/REPE/REPZ/REPNE/REPNZ指令

单纯的cmps指令只比较一次,如果要连续比较,需要加指令前缀rep;连续比较的次数由CX(16位模式下)或者ECX(32位模式下)控制。除了rep前缀,还有repe(repz),表示相等则重复;repne(repnz)表示不相等则重复。用这些前缀结合cmps比较时,操作过程如下:

由此可见,repe(repz)用于搜索第一个不相等的字节、字或者双字,repne(repnz)用来搜索第一个相等的字节、字或者双字。

好了,有了以上铺垫,我们可以进入代码的学习了。

476         ;重定位SALT
477         mov eax,[edi+0x04]
478         mov es,eax                         ;es -> 用户程序头部 
479         mov eax,core_data_seg_sel
480         mov ds,eax
481      
482         cld
483
484         mov ecx,[es:0x24]                  ;用户程序的SALT条目数
485         mov edi,0x28                       ;用户程序内的SALT位于头部内0x28处

477~478:把之前安装好的头部段选择子赋值给ES;(注意,DS依然指向0-4GB内存段,EDI中的值是程序加载的物理地址,所以[edi+0x04]就可以寻址到头部段的选择子。)
479~480:DS指向核心数据段;
482:令DF标志位=0,采用正向比较;
484:如下图所示,把用户的符号表的条目数传入ECX;
485:令ES:EDI指向第一个符号。
用户程序的头部

为了说明代码思路,还是引用书上的一张图吧:

思路是两层循环,分为外循环和内循环。外循环的作用是从用户符号表依次取出符号1,符号2,…符号N;内循环的作用是遍历内核符号表的每一个条目,同外循环取出的那个条目进行对比。如果匹配,则复制偏移地址和段选择子,之后跳出到外循环。
请注意红色的字。配书代码有一个小小的BUG,就是在匹配之后,没有跳出到外循环,而是和内核符号表的下一个条目再次比较了。后文会仔细分析这个问题。

2.2.外循环的代码

先来看看外循环:

486  .b2: 
487         push ecx       ;初始值为用户程序的符号数目,每次外循环都减一
488         push edi



512  .b5:   pop edi        ;.b5这个标号是我自己加的,后面会讲到
513         add edi,256    ;指向用户符号表的下一个条目
514         pop ecx
515         loop .b2

487~488:因为内循环也要用到ECXEDI,所以进入内循环前先把它们压栈保存;
513:EDI加上256,于是指向上图中U-SALT表格的下一个条目;

对于外循环ES:EDI指向的这个条目,在内循环中要把它和内核符号表的所有条目进行比较(最坏的情况)。

2.3.内循环的代码

490         mov ecx,salt_items      ;内核符号总数目
491         mov esi,salt            ;指向内核的第一个符号
492  .b3:
493         push edi
494         push esi
495         push ecx

            ;这里放置实际进行对比的代码

506         pop ecx
507         pop esi
508         add esi,salt_item_len   ;指向内核符号表的下一个条目
509         pop edi                            
510         loop .b3

490~491:每次从外循环进入内循环的时候,都要初始化内循环的对比次数(=内核符号总数目),并且重新让ESI指向内核符号表(C-SALT)的起始。这相当于内循环的初始化,可以想象成C语言中for语句

    for(ecx = salt_items,esi = salt;  ...;  ...)

493~495:因为在实际对比的时候,会改变ESI,EDI,ECX的值,所以要在实际对比之前把这些寄存器压栈保存。
506~509:恢复上述压栈的寄存器,并且增加ESI的值,使其指向内核符号表的下一个条目。

2.4.对比的核心代码

我们再看一下对比的核心代码:

497         mov ecx,64                         ;检索表中,每条目的比较次数 
498         repe cmpsd                         ;每次比较4字节 
499         jnz .b4                            ;ZF=0表示不匹配,则跳转
500         mov eax,[esi]                      ;若匹配,esi恰好指向其后的地址数据
501         mov [es:edi-256],eax               ;将字符串改写成偏移地址 
502         mov ax,[esi+4]
503         mov [es:edi-252],ax                ;以及段选择子 
504  .b4:
505      

每当执行到这里,DS:ESIES:EDI都分别指向内核符号表和用户符号表中的某个条目。
497:因为一个符号占用256字节,我们用的是cmpsd指令,所以最多需要比较256/4=64次,于是向ECX传入64;
498:如果相等就继续比较;停止条件是(ECX==0) || (ZF==0),也就是ECX为0或者发现了不相等就停止比较。
499:假如比较发现了不相等,于是ZF=0;假如字符串是相等的,那么会重复比较64次,最后ZF=1;所以ZF=0说明不匹配,反之匹配。
如果不匹配,就跳转到.b4标号处。其实就是跳到内循环的506行。

506:恢复ECX的值,这个值表示还剩多少次内循环(对于某个用户符号,还剩多少个内核符号要和它比较);
509:恢复EDI的值,也就是让EDI再次指向当前用户符号的起始。

500~501:如果匹配,那么这时候ESI刚好指向了内核某匹配上的符号(总共256字节)的末尾,后面就是4字节的偏移地址和2字节的段选择子。将偏移地址回填到某用户符号的开始处;
502~503:将段选择子回填到偏移地址的后面,于是这个段选择子就和前面的偏移地址组成了例程的入口。到时候用户程序就能利用这个入口,来个华丽的远调用或者远跳转。

这个代码说到这里就结束了吗?No,No.前文提到过,这里是有个小问题的。在500~503执行完后,应该怎么办?既然匹配成功了,该填的也填了,那么就应该让EDI指向下一个符号,让ESI指向内核符号表的起始,也就是说跳出内循环,进入下一轮外循环(跳到512行开始执行,相当于C语言中的break)。但是还牵扯到一个问题,在跳转到512行之前,我们应该使栈平衡。因为在493~495压入了三个寄存器,然后进行实际的比较,比较之后,也应该弹出这三个寄存器。
所以505行应该插入一段代码:

        pop ecx
        pop esi
        pop edi                            
        jmp .b5 ;跳转到512行

其实这几行代码中,寄存器ECX,ESI,EDI里面的值是不重要的。
因为在514行,ECX会获得合适的值;
在512~513行,EDI会获得合适的值;
在491行,ESI会获得合适的值;
所以上面的补丁可以修改为:

        add esp,12    ;使栈平衡                        
        jmp .b5       ;跳转到512行

这样就简洁多了。

可能有的读者不太相信,觉得配书源码不应该有问题,是不是我搞错了。这没有关系,我会在后面的博文中证明这确实是一个BUG。“实践出真知。”

好了,这篇博文就说到这里。下次我们讲用户程序的执行。

【end】

以上是关于程序的加载和执行——《x86汇编语言:从实模式到保护模式》读书笔记23的主要内容,如果未能解决你的问题,请参考以下文章

任务和特权级保护——《x86汇编语言:从实模式到保护模式》读书笔记32

《x86汇编语言:从实模式到保护模式》课后答案

《x86汇编语言:从实模式到保护模式》课后答案

《x86汇编语言:从实模式到保护模式》检测点和习题答案

《x86汇编语言:从实模式到保护模式》配书文件包下载

《X86汇编语言 从实模式到保护模式》bochs 配置教程(详细)