歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
您现在的位置: Linux教程網 >> UnixLinux >  >> Linux編程 >> Linux編程

Kernel 2.4.0 之 head.S 為何用兩次 jmp 刷新 EIP 寄存器

在arch\i386\kernel\head.S文件中,自line 100開始有這麼幾行:

    movl %cr0,%eax
    orl $0x80000000,%eax
    movl %eax,%cr0      /* ..and set paging (PG) bit */
    jmp 1f          /* flush the prefetch-queue */
1:
    movl $1f,%eax
    jmp *%eax       /* make sure eip is relocated */
1:
    /* Set up the stack pointer */
    lss stack_start,%esp

我看了很久都不明白第一次跳轉到底是為了什麼,情景分析那本書上說這是為了刷新指令預取隊列,我把Intel手冊翻了個遍也沒找到關於預取隊列的詳細信息,維基百科上介紹的也不夠詳細。

在我終於弄明白之後,寫一寫我的分析過程,這份文章寫寫改改,用時一下午加一晚上才完工,一邊寫一邊發現了很多自己得過且過的問題,寫文章的過程也是查資料的過程,還是比較累的,如果寫的有錯誤,歡迎指出。下面分析的過程也是思考的過程。

首先從setup.S看起,在arch\i386\boot\setup.S中line 113處有這麼幾行代碼:

code32_start:               # here loaders can put a different
                # start address for 32-bit code.
#ifndef __BIG_KERNEL__
    .long   0x1000      #   0x1000 = default for zImage
#else
    .long   0x100000    # 0x100000 = default for big kernel
#endif

因為我們編譯的是bzImage,所以code32_start標號處的數值為0x100000,占用四字節。

再看line 532處的幾行代碼:

# we get the code32 start address and modify the below 'jmpi'
# (loader may have changed it)
movl    %cs:code32_start, %eax
movl    %eax, %cs:code32

在執行這些代碼時CPU還處於實模式,所以CS裡面是段基址,不是selector!第一句是把code32_start處的一個雙字(四字節)裝入eax,這個雙字的值就是0x100000;然後第二句把eax即0x100000賦值到code32標號所指的內存位置裡。那麼這個位置在哪呢?請繼續看下面line 719的代碼:

# NOTE: For high loaded big kernels we need a
#   jmpi    0x100000,__KERNEL_CS
#
#   but we yet haven't reloaded the CS register, so the default size 
#   of the target offset still is 16 bit.
#       However, using an operant prefix (0x66), the CPU will properly
#   take our 48 bit far pointer. (INTeL 80386 Programmer's Reference
#   Manual, Mixing 16-bit and 32-bit code, page 16-6)

    .byte 0x66, 0xea            # prefix + jmpi-opcode
code32: .long   0x1000              # will be set to 0x100000
                    # for big kernels
    .word   __KERNEL_CS #這個數字是0x10

0x100000這個數字最終被寫到了code32這個標號處,覆蓋了原來的0x1000。那麼當執行到line 719時會發生什麼呢?

0xea這個數字其實是jmpi指令的機器碼,而0x66則告訴處理器jmpi要按照保護模式的方式來取操作數,即先取出一個4字節的雙字操作數置入EIP,然後繼續取出一個2字節的字操作數置入CS。如果不加0x66前綴那麼jmpi指令只會取2字節的操作數置入EIP,顯然這是不對的。至於為什麼這個前綴是0x66,這個問題要去問Intel了。注意當代碼執行到此,code32處的值早已經被覆蓋成了這個樣子:

code32: .long 0x100000
        .word __KERNEL_CS   #這個數字是0x10

所以jmpi指令會先後取出0x100000和0x10分別置入EIP和CS。如果將這幾行用偽代碼來表示,既然0xea是jmpi的機器碼,0x66是前綴,我們姑且創造一條新的匯編指令pjmpi,那麼上面幾行表示出來就是這樣的:

pjmpi 0x100000,0x10

這樣就很清晰了,0x100000置入EIP,0x10置入CS。 到此為止,CS裡面的數值0x10就是selector,對應的描述符中指明該代碼段的基地址為0,又因為EIP=0x100000,所以經過分段機制後可得線性地址為0x100000,數值上沒變。此時尚未開啟分頁機制,該線性地址當作物理地址,它被送上地址總線准備從此處取指令。那麼0x100000這個地址處能取出什麼指令呢?

物理地址0x100000這個數值其實是1MB處。那裡就是內核的主代碼,也就是head.S的入口點startup_32。於是CPU會取出head.S中的第一條指令開始執行,往後就是繼續執行head.S剩余的部分了。

看到這裡必須明確:CS中是_KERNELCS代碼段selector,EIP中的虛擬地址值雖然需要經過分段機制才能當作物理地址,但是段基址為0,對數值沒影響,物理地址和虛擬地址數值上相等。每次取指令後EIP自動增加一個數,這個數就是剛才取的指令的長度,靠這種方式EIP從虛擬地址0x100000開始遞增,逐次取指令執行指令。

進入head.S後,從startup32入口開始的執行過程如下:先將數據段選擇子KERNELDS置入ds等寄存器,然後設置好頁表的內容,而頁目錄表的內容是直接寫到head.S文件中的,這樣頁目錄表和頁表都��備了,再然後就是將頁目錄表的物理地址置入cr3寄存器,再將cr0的PG標志位置1,從此分頁機制開啟了!


緊接著就是刷新指令預取隊列的代碼了,自line 103開始就是這幾行令人費解的代碼了:

    jmp 1f          /* flush the prefetch-queue */
1:
    movl $1f,%eax
    jmp *%eax       /* make sure eip is relocated */
1:
    /* Set up the stack pointer */
    lss stack_start,%esp

這裡為何要跳轉兩次?情景分析裡說的理由太過牽強,書中解釋也是令人費解。其實進行兩次jmp純粹是多余的,僅靠其中一次跳轉就能完成任務。而經過我的實驗與研究,我發現這兩次跳轉完全可以全部刪掉,根本不影響系統的啟動。

為什麼這麼說呢? 在講解原因之前,必須先說點Intel處理器的規定,因為待會兒要看匯編語言和機器語言的代碼才能徹底弄明白一切。

jmp跳轉分為遠跳轉(far)和近跳轉(near and short),遠跳轉是指覆寫CS的跳轉,近跳轉是指不重寫CS的跳轉。

近跳轉又分兩種:

  1. 絕對跳轉(absolute)和相對跳轉(relative),絕對跳轉在匯編裡的寫法是 jmp register/memory-location ,即跳轉的目的地址存儲在寄存器內或內存位置內,CPU直接把這個目的地址覆寫到EIP中,EIP=absolute_address;
  2. 相對跳轉的寫法是 jmp label ,匯編語言中一般寫作跳到某個標號label,在機器語言層面上這個標號被匯編成一個叫做relative offset的立即數。即jmp後面的數字是一個相對偏移量,CPU將這個偏移量加到EIP上去產生目的地址,EIP=EIP+offset。注意當CPU正在執行jmp指令時,EIP指向jmp的後一條指令,所以這個相對偏移就是jmp後一條指令的地址到目的地址之間的差值,(跳轉的目的地址)-(jmp後一條指令的地址)= offset。

在機器語言層面上:

  1. 絕對跳轉的機器碼是ff,後面的操作數代表目的地址存放的位置,比如e0代表eax寄存器,那麼ffe0就表示將eax中的目的地址數值取出來,直接覆寫至EIP寄存器,下一次取指令就從目的地址取了。
  2. 相對跳轉的機器碼是eb,後面的操作數是相對偏移,在匯編器進行匯編操作時會自動進行運算:(跳轉的目的地址)-(jmp後一條指令的地址)= offset,將這個offset放在eb後面作為操作數,CPU執行jmp跳轉時EIP恰好指向jmp的後一條指令處,CPU將offset操作數加到EIP上恰好得到跳轉的目的地址,然後EIP中就是目的地址了,下一次取指令就從目的地址取了。
  3. 在內核匯編完成的鏈接階段,arch\i386\Vmlinux.lds文件第9行 . = 0xC0000000 + 0x100000; 說明在ld鏈接時給最終的vmlinux文件裡面所有的符號地址都加上0xC0000000 + 0x100000,也就是都加上0xC0100000。這個操作對相對跳轉沒有任何影響,因為相對跳轉在機器碼層面的操作數是相對偏移,不管目的地址和jmp後一條指令的地址被鏈接器改成了多少,這倆地址的差是不變的,也就是說相對偏移不會被鏈接器所影響,它永遠是個差。轉而看絕對跳轉就不一樣了,絕對跳轉的目的地址存儲在寄存器或內存裡,那在 jmp *%eax 之前必然要 mov label,%eax ,這個label不是相對偏移了,它切切實實是某條指令的絕對地址,因為這裡並不是jmp相對跳轉指令! 那它既然是一個絕對地址,鏈接器就會給它統一加上一個值0xC0100000,這必然會影響jmp指令,如果原來label代表地址0x42,即jmp是往0x42跳的話,那現在label變成了0xC0100042,jmp就是往0xC0100042跳轉。記住,只有jmp相對跳轉指令後面的數字才是相對偏移,鏈接器無法將之修改,其他指令中的標號全部是絕對地址,是可以被鏈接器修改的!

下面終於要開始看這兩條jmp指令的作用了。源匯編代碼如下:

    movl %cr0,%eax
    orl $0x80000000,%eax
    movl %eax,%cr0      /* ..and set paging (PG) bit */
    jmp 1f          /* flush the prefetch-queue */
1:
    movl $1f,%eax
    jmp *%eax       /* make sure eip is relocated */
1:
    /* Set up the stack pointer */
    lss stack_start,%esp

我們再來看看內核的反匯編代碼。在頂層Makefile裡講 CFLAGS_KERNEL = 改為 CFLAGS_KERNEL = -g 給內核加入調試信息,然後 objdump -d vmlinux | less 反編譯內核鏡像vmlinux的結果如下:

虛擬地址:   物理地址:
c010002e:   10002e  0f 20 c0                mov    %cr0,%eax
c0100031:   100031  0d 00 00 00 80          or     $0x80000000,%eax
c0100036:   100036  0f 22 c0                mov    %eax,%cr0
c0100039:   100039  eb 00                   jmp    c010003b <_text+0x3b>
c010003b:   10003b  b8 42 00 10 c0          mov    $0xc0100042,%eax
c0100040:   100040  ff e0                   jmp    *%eax
c0100042:   100042  0f b2 25 e4 01 10 c0    lss    0xc01001e4,%esp

可以看到在內核編譯鏈接完成後所有符號的地址都變成了0xC0100000之後的數,數值上講都大於3GB,畢竟內核空間的范圍是虛擬地址空間的3G-4G。我為了方便,把物理地址也標上了。

CPU內部正在執行當前指令的同時,EIP指向的是下一條指令。回憶上文所講內容,開始進入head.S時EIP=0x100000。下面按照CPU取指令->執行指令的過程來分步講解。

  • 當CPU取指令並執行 movl %eax,%cr0 時,EIP指向 jmp 1f ,即EIP=0x100039。注意當movl指令執行完後,分頁機制開啟了,下一次取指令時EIP不能只進行分段變換,還要進行分頁變換。對照著頁目錄和頁表,EIP=0x100039這個虛擬地址經過分段+分頁變換後的結果是物理地址0x100039,這是下一次取指令的地方。

從上面可以看到,頁表和頁目錄的設置非常巧妙,0x100039這個虛擬地址不管是只經過分段變換還是經過分段+分頁變換,得到的物理地址是一樣的,並且從數值上講虛擬地址=線性地址=物理地址。你可以自己拿筆算算。

  • 接下來,CPU取指令並執行 jmp 1f ,同時EIP繼續自增指向 movl $1f,%eax ,即EIP=0x10003b。仔細看機器碼,jmp 1f 這條語句被匯編成了eb00,eb表示相對跳轉,相對偏移量為00。jmp相對跳轉指令將相對偏移量00加到EIP上得到跳轉的目的地址0x10003b,EIP數值上不變(EIP=0x10003b),所以這個jmp沒什麼作用。EIP經過分段+分頁後得到物理地址0x10003b,這是下一次取指令的地方。

  • 接下來,CPU取指令並執行 movl $1f,%eax ,EIP指向 jmp *%eax,EIP=0x100040。語句中1f是個地址標號,代表一個絕對地址,一開始匯編後它的值為0x42,鏈接後加上0xC0100000變成0xC0100042,把0xC0100042這個數置入eax寄存器。movl指令執行後eax=0xC0100042。EIP虛擬地址化成物理地址是0x100040,這是下一次取指令的地方。

  • 接下來,CPU取指令並執行 jmp *%eax,EIP指向 lss stack_start,%esp ,EIP=0x100042。jmp指令的機器碼是ff,代表絕對跳轉,將eax中目的地址的值直接覆寫到EIP。從此EIP=0xC0100042。

  • 接下來,CPU要去EIP處取指令,它把EIP=0xC0100042經過分段+分頁變換,根據頁表和頁目錄的設置,得到物理地址0x100042,取得指令 lss stack_start,%esp 開始執行,同時EIP自動增加指令長度的數值變為EIP=0xC0100049。頁表和頁目錄表都設置的非常巧妙,虛擬地址X將映射到物理地址X,虛擬地址3G+X也將映射到的物理地址X,這裡不展開講。

從此之後EIP將從0xC0100049開始逐漸遞增,經過分段+分頁映射到物理地址,虛擬地址和物理地址之間差了3G,內核的內存管理初見雛形。

回想上面第一個jmp,它的沒有任何作用,不產生任何影響,可以刪掉。 如果也把第二個jmp刪掉會如何呢?那就可以預見,EIP將會繼續保持從0x1000xx這樣的模式遞增,不會變成0xC01000xx這樣。因為頁目錄和頁表都設置的非常巧妙,0x1000xx和0xC01000xx會換算成同一個物理地址,所以這兩種虛擬地址等效,可以互相替代。即使一直按照0x1000xx的格式取指令也不會出現任何問題,因為這和用0xC01000xx取到的指令是完全一樣的,畢竟兩者都能換算成同樣的物理地址。所以這個地方不跳轉也是可以的,即第二個jmp也可以刪掉。 所以即使把兩個jmp全刪了,都不會產生影響。在後面的代碼中,自然會有別的代碼替它們完成將EIP置成0xC01000xx的任務。

內核在執行到head.S line 252時會執行下面的指令:

    ljmp $(__KERNEL_CS),$1f
1:  movl $(__KERNEL_DS),%eax

ljmp後面跟上兩個操作數,這是絕對跳轉的寫法,並且是遠跳轉,CS和EIP都將被覆寫。在匯編器匯編時,1f這個標號不是相對偏移,而是絕對地址,既然是絕對地址,那必然會被鏈接器修改,它原先是0x172,在鏈接時被改成了0xc0100172。再者,_KERNELCS=0x10,所以這個指令相當於 ljmp $0x10,$0xc0100172 。這條指令將0x10置入CS,將0xC0100172置入EIP,這樣EIP在這裡變成了0xC01000xx這種格式。即使之前兩次都不跳轉,EIP遲早會變成0xC01000xx這個樣子。又如果前面真的發生了跳轉,EIP在那時已經被置成0xC01000xx這個樣子,那麼到了此處EIP還是免不了被重新覆蓋一次,反正這個地方CS和EIP必須被重新賦一次值,不管以前EIP是什麼樣子。

注:我將兩個jmp都刪了然後重新編譯內核,系統啟動完全正常。

Copyright © Linux教程網 All Rights Reserved