1. 程式人生 > >程式的載入和執行(三)——《x86組合語言:從真實模式到保護模式》讀書筆記23

程式的載入和執行(三)——《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】