在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的跳轉。
近跳轉又分兩種:
jmp register/memory-location
,即跳轉的目的地址存儲在寄存器內或內存位置內,CPU直接把這個目的地址覆寫到EIP中,EIP=absolute_address;jmp label
,匯編語言中一般寫作跳到某個標號label,在機器語言層面上這個標號被匯編成一個叫做relative offset的立即數。即jmp後面的數字是一個相對偏移量,CPU將這個偏移量加到EIP上去產生目的地址,EIP=EIP+offset。注意當CPU正在執行jmp指令時,EIP指向jmp的後一條指令,所以這個相對偏移就是jmp後一條指令的地址到目的地址之間的差值,(跳轉的目的地址)-(jmp後一條指令的地址)= offset。在機器語言層面上:
. = 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取指令->執行指令的過程來分步講解。
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都刪了然後重新編譯內核,系統啟動完全正常。