程序的加载和执行(三)——读书笔记23
接着上次的内容说。
关于过程load_relocate_program
的讲解还没有完,还差创建栈段描述符和重定位符号表。
分配栈空间与创建栈段描述符
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,计算出描述符中的段界限,计算公式是:
如果不明白为什么是这个公式,可以参考我的博文:《如何构造栈段描述符》
http://blog.csdn.net/longintchar/article/details/50967180
466~469,调用过程allocate_memory
申请栈空间;
470:准备参数EAX
,因为描述符中的基地址等于栈空间的低端物理地址加上栈的大小。不懂的还请参考我上面提到的博文。
472~473,创建并安装栈段描述符。
474:将选择子回填到对应的位置(请参考上图)。
符号表的重定位
为了使用内核提供的例程,用户程序需要建立一个符号表。当用户程序被加载后,内核会根据这个符号表来回填每个例程的入口地址。这个过程就是符号地址的重定位。重定位过程中必不可少的环节是字符串的比较和匹配。
为了对用户程序的符号表进行匹配,内核也必须建立一张符号表,这张符号表包含了内核提供的所有例程。
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个字节被重新填写,填写的是对应例程的入口。
上面的过程可以用一张图来说明:
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,就是在匹配之后,没有跳出到外循环,而是和内核符号表的下一个条目再次比较了。后文会仔细分析这个问题。
外循环的代码
先来看看外循环:
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:因为内循环也要用到ECX
和EDI
,所以进入内循环前先把它们压栈保存;
513:EDI
加上256,于是指向上图中U-SALT表格的下一个条目;
对于外循环ES:EDI
指向的这个条目,在内循环中要把它和内核符号表的所有条目进行比较(最坏的情况)。
内循环的代码
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
的值,使其指向内核符号表的下一个条目。
对比的核心代码
我们再看一下对比的核心代码:
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:ESI
和ES: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】