1. 程式人生 > >從開機加電到執行main函數之前的過程

從開機加電到執行main函數之前的過程

分享 鏈接 cmp 頁表 root 重要 pri 計算 頁面

技術分享1.啟動BIOS,準備實模式下中斷向量表和中斷服務程序

  • 在按下電源按鈕的瞬間,CPU硬件邏輯強制將CS:IP設置為0xFFFF:0x0000,指向內存地址的0xFFFF0位置,此位置屬於BIOS的地址範圍。關於硬件如何指向BIOS區,這是一個純硬件動作,在RAM實地址空間中,屬於BIOS地址空間部分為空,硬件只要見到CPU發出的地址屬於BIOS地址範圍,直接從硬件層次將訪問重定向到BIOS的ROM區中。這也就是為什麽RAM中存在空洞的原因。
  • BIOS程序在內存最開始的位置(0x00000)用1KB的內存空間(0x000000x003FF)構建中斷向量表,並在緊挨著它的位置用256個字節的內存空間構建BIOS數據區(0x004000x004FF),大約在56KB以後的位置(0x0E2CE)加載了8KB左右的與中斷向量表相對應的若幹中斷服務程序。

2.加載操作系統內核程序,並為保護模式做準備

  • 加載操作系統的過程分為三步

    • 由BIOS中斷int 0x19把第一扇區bootsect的內容加載到內存
    • 在bootsect的指揮下,把其後的四個扇區的內容加載至內存
    • 在bootsect的指揮下,把隨後的240個扇區內容加載至內存
  • 加載第一部分代碼---引導程序bootsect

    • int 0x19對應的中斷服務程序的入口地址為0x0E6F2,這個中斷服務程序的作用是將軟盤的第一個扇區的程序(512B)加載到內存的指定位置,該服務程序是BIOS事先設計好的,與Linux操作系統無關。該服務程序將軟驅0號磁頭對應盤面的0磁道1扇區的內容拷貝至內存0x07C00處。該扇區的作用就是Linux操作系統的引導程序bootsect,其作用就是擺脫BIOS的限制,陸續將軟盤中的操作系統程序載入到內存中。
  • 加載第二部分代碼---setup

    • bootsect的作用就是把第二批和第三批程序陸續加載到內存的適當位置。為了完成之一目標,bootsect首先要做的工作就是規劃內存。
SETUPLEN = 4				! nr of setup-sectors
BOOTSEG  = 0x07c0			! original address of boot-sector
INITSEG  = DEF_INITSEG		! we move boot here - out of the way (0x9000)
SETUPSEG = DEF_SETUPSEG		! setup starts here (0x9020)
SYSSEG   = DEF_SYSSEG		! system loaded at 0x10000 (65536).
ENDSEG   = SYSSEG + SYSSIZE	! where to stop loading
  • 該代碼的作用就是對後續操作所涉及的內存位置進行設置,包括將要加載的扇區數(SETUPLEN)和被加載到的位置(SETUPSEG)、啟動扇區被BIOS加載的位置(BOOTSEG)和將要移動到的新位置(INITSEG),內核被加載的位置(SYSSEG)、內核的末尾位置(ENDSEG)和根文件系統的系統設備號(ROOT_DEV)

  • 接著,bootsect啟動程序將它自身(512B內容)從內存0x07C00復制到內存0x90000(INITSEG)處

    技術分享

	mov	ax,#BOOTSEG
	mov	ds,ax
	mov	ax,#INITSEG
	mov	es,ax
	mov	cx,#256
	sub	si,si
	sub	di,di
	rep
	movw
  • 由於“兩頭約定”和“定位識別”的作用,所以bootsect在開始時“被迫”加載到0x07C00處。現在將其自身移至0x90000處,說明操作系統開始根據自己需要安排內存了

    技術分享

  • bootsect復制完成之後,內存位置0x7C000和0x9000位置有相同的代碼。這段代碼復制完成之後便需要將CS:IP的值設置到0x9000的位置,這一功能使用jmpi go, INITSEG代碼來實現,執行這段代碼之後,程序就轉到執行0x90000處來執行新位置的代碼了。Linus的設計思路是:跳轉到新位置之後在新位置接著執行後面的mov ax, cs,而不是死循環。jmpi go, INITSEGgo: mov ax, cs配合,巧妙地實現了“到新位置後接著原來的執行序繼續執行下去”的目的。

  • 由於bootsect復制到了新的地方,並且要在新的地方繼續執行。因為代碼的整體位置發生了變化,那麽代碼的的各個段也會發生變化,現在需要對DS、ES、SS和SP進行調整。

go:	mov	ax,cs		
	mov	dx,#0xfef4	! arbitrary value >>512 - disk parm size

	mov	ds,ax
	mov	es,ax
	push	ax

	mov	ss,ax		! put stack at 0x9ff00 - 12.
	mov	sp,dx

技術分享

  • 至此,bootsect的第一步操作:規劃內存並把自身從0x07C00的位置復制到0x90000的位置的動作已經完成了。接下來需要將Setup程序加載到內存中。

加載setup程序需要借助BIOS的int 0x13中斷。int 0x13中斷與int 0x19中斷的不同點:

  • int 0x19中斷向量所指向的啟動加載服務程序時BIOS執行的,int 0x13的中斷服務程序時linux系統自身的啟動代碼bootsect執行的
  • int 0x19的中斷服務程序只負責將軟盤的第一扇區的代碼加載到0x07C00位置,而int 0x13中斷服務程序可以根據設計者的意圖,把指定的扇區的代碼加載到內存的指定位置。執行的代碼如下。這段代碼首先設置各寄存器參數,再調用中斷服務程序進行數據傳輸,將軟盤從第2扇區開始的4個扇區加載至內存的SETUPSEG
load_setup:
	xor	dx, dx			! drive 0, head 0
	mov	cx,#0x0002		! sector 2, track 0
	mov	bx,#0x0200		! address = 512, in INITSEG
	mov	ax,#0x0200+SETUPLEN	! service 2, nr of sectors
	int	0x13			! read it
	jnc	ok_load_setup		! ok - continue

	push	ax			! dump error code
	call	print_nl
	mov	bp, sp
	call	print_hex
	pop	ax	
	
	xor	dl, dl			! reset FDC
	xor	ah, ah
	int	0x13
	j	load_setup

  • 加載第三部分代碼---system模塊

    • 代碼加載方式與第二部分代碼加載方式類似,同樣調用 int 0x13中斷。bootsect借助BIOS中斷int 0x13,將240個扇區的system模塊加載進內存。加載工作主要由read_it子程序完成的,這個子程序將軟盤的第6扇區的約240個扇區的system模塊加載至內存的SYSSEG(0x10000)處往後的120KB空間中。 技術分享
  • 三部分代碼加載完之後,bootsect還需要確定下根設備號,經過一系列檢測,得知軟盤為根設備,所有就把根設備好保存在root_dev中,這個根設備號作為機器系統數據之一,它將在根文件系統加載中發揮關鍵作用。這一步完成之後,bootsect的所有工作都做完了,接著執行jmpi 0, SETUPSEG跳轉至0x90200處,這地方存放的是setup程序,這意味著由bootsect程序繼續執行。

技術分享

  • setup程序做的第一件事就是利用BIOS提供的中斷服務程序從設備上提取內核運行所需要的系統參數,如:硬盤大小、內存大小、光標位置等參數。BIOS提取的機器系統數據占用的內存空間為0x90000~0x901FD,共510個字節,即原來的bootsect只有2字節未被覆蓋。當bootsect使用完之後,其所在的內存區域馬上被覆蓋掉,可見操作系統對內存是嚴格按需使用的。

3.由16位模式切換到32位模式,為main函數的調用做準備

  • 操作系統要將計算機由16位的實模式轉換到32位的保護模式,在這期間需要做大量的重建工作,並且繼續工作到操作系統的main函數執行過程中。操作系統需要做的工作包括:

    • 打開32位尋址空間
    • 打開保護模式
    • 建立保護模式下的中斷響應機制等與保護模式配套的相關工作
    • 建立內存的分頁機制
    • 做好調用main函數的相關準備
  • 關中斷,並將system移動到內存地址的起始位置0x00000

    • 下面要執行的代碼將為操作系統進入保護模式做準備,此處即將進行實模式下中斷向量表和保護模式下中斷描述符表(IDT)的交接工作。在這段代碼執行之前首先要關中斷,試想,如果沒有cli,又恰好發生了中斷,如用戶不小心碰了下鍵盤,中斷就要切換進來,就不得不面對實模式的中斷機制已經廢除,但保護模式下的中斷機制尚未建立完成的尷尬局面,結果必然是系統崩潰。cli和sti保證了這個過程中中斷描述符表能夠完整的創建,以避免不可預料的中斷進入,從而造成中斷描述符表創建不完整或新老中斷機制 混用的情況。
  • setup程序做了一個影響深遠的工作:將位於0x10000的內核程序拷貝至內存起始地址為0x00000處。代碼如下:

do_move:
	mov	es,ax		! destination segment
	add	ax,#0x1000
	cmp	ax,#0x9000
	jz	end_move
	mov	ds,ax		! source segment
	sub	di,di
	sub	si,si
	mov 	cx,#0x8000
	rep
	movsw
	jmp	do_move

這樣做能取得一箭三雕的效果:

  • 廢除BIOS的中斷向量表,等價於廢除了BIOS提供的實模式下的中斷服務程序

    • 收回使用壽命剛剛結束的程序所占用的內存空間
    • 讓內核代碼占據內存物理地址最開始的、最天然的、最有利的位置
  • 我們廢除了16位中斷機制,但操作系統是不能沒有中斷的,對外設的使用、系統調用、進程調度都離不開中斷,因此,接下來操作系統需要建立新的32位的中斷機制

  • 設置中斷描述符表和全局描述符表
    • 幾個基本概念
      • GDT(全局描述符表):它是系統中唯一存放段寄存器內容(段描述符)的數組,配合程序進行保護模式下的段尋址。它在操作系統的進程切換中具有重要意義,可理解為所有進程的總目錄表,其中存放著每一個任務(task)局部描述符表(LDT)地址和任務狀態段(TSS)地址,用於完成進程中各段的尋址、現場保護與現場恢復
      • GDTR(GDT基地址寄存器):GDT可以存放在內存的任意位置,當程序通過段寄存器引用一個段描述符時,需要取得GDT的入口,GDTR所標識的即為此入口。在操作系統對GDT的初始化完成後,可以用LGDT指令將GDT基地址加載至GDTR中
      • IDT(中斷描述符表):保存保護模式下所有中斷服務程序的入口地址,類似於實模式下的中斷向量表
      • IDTR(IDT基地址寄存器):保存IDT的起始地址
    • 劃分一塊內存區域,並向這塊內存區域中寫入數據,即填寫GDT和IDT的表項。由於此時內核尚未真正運行起來,還沒有進程,所以現在常見的GDT表的第一項為空,第二項為內核代碼段描述符,第三項為內核數據段描述符,其余項皆為空。IDT表雖然已經設置,實為一張空表,原因是目前已經關中斷,無需調用中斷服務程序。實現代碼如下。這段代碼運行完成之後,所得到的結果如下圖所示:
gdt:
	.word	0,0,0,0		! dummy

	.word	0x07FF		! 8Mb - limit=2047 (2048*4096=8Mb)
	.word	0x0000		! base address=0
	.word	0x9A00		! code read/exec
	.word	0x00C0		! granularity=4096, 386

	.word	0x07FF		! 8Mb - limit=2047 (2048*4096=8Mb)
	.word	0x0000		! base address=0
	.word	0x9200		! data read/write
	.word	0x00C0		! granularity=4096, 386

idt_48:
	.word	0			! idt limit=0
	.word	0,0			! idt base=0L

gdt_48:
	.word	0x800		! gdt limit=2048, 256 GDT entries
	.word	512+gdt,0x9	! gdt base = 0X9xxxx

技術分享

  • 打開A20,實現32位尋址。打開A20,意味著CPU可以進行32位尋址,最大尋址空間為4GB,如下圖所示。Linux 0.11最大只能支持16MB的物理內存,但是其線性地址空間已經是4GB(為什麽只能支持16MB?) 技術分享

  • 為了建立保護模式下的中斷機制,setup程序需要對8259A中斷控制器進行重新編程。CPU工作方式由實模式轉變為保護模式,一個重要的特征就是要根據GDT表來決定後續將執行哪裏的程序。 技術分享 技術分享

  • 註意這段代碼jmpi 0,8 ! jmp offset 0 of segment 8 (cs) 這句中的“0”是段內偏移,“8”是保護模式下的段選擇符,用於選擇描述符表和描述符表項以及所要求的特權級。這裏的8的解讀方式很有意思,如果把8當做十進制的8來看待,這行程序的意思就很難理解了。必須把8看成二進制的1000,再把前後代碼聯合起來當做一個整體來看,便可形成下圖,才能明白這行代碼的真實意圖。註意,這個是以位為單位的數據使用方式,4bit的每一位都有明確的意義,這是底層代碼的一個特色。這裏的1000的最後兩位00表示內核特權級,與之相對應的用戶特權級是11,第三位的0表示GDT表,如果是1,則表示LDT。1000的1表示所選的表(此時就是GDT表)的1項(GDT表項號排序為0項、1項、2項,也就是第2項)來確定代碼段的段基址和段限長等信息,從上圖可以看到,代碼是從段基址0x00000000、偏移為0處開始執行的,也就是head程序的開始位置,這意味著將執行head程序。到此為止,setup就執行完畢了,它為系統能夠在保護模式下運行做了一系列的準備工作,但這些還不夠,後續準備工作將由head程序來完成 技術分享

  • head.s開始執行

    • 在執行main函數之前,先要執行三個有匯編代碼生成的程序,即bootsect、setup和head之後,才執行由main函數開始的由C語言編寫的操作系統內核程序。前面講述過,第一步:加載bootsect到0x07C00,然後復制到0x90000;第二步:加載setup到0x90200。需要註意的是,這兩段程序是分別加載和分別執行的,head程序與它們的加載方式有所不同。
    • head程序的加載過程如下:先將head.s匯編成目標代碼,將用C語言編寫的內核程序編譯成 目標代碼,然後兩者一起鏈接成system模塊。也就是說,在system模塊裏面,既有內核程序,又有head程序,兩者是緊挨著的。要點是:head程序在前面,內核程序在後面,所以head程序名字叫head,head程序在內存中占有25KB+184B的空間,這個數字很重要,望留心
    • head程序所做的工作:用程序自身的代碼和程序自身所在的內存空間創建了內核分頁機制,即在0x00000的位置創建了頁目錄表、頁表、緩沖區、GDT、IDT,並將head程序已經執行過的代碼所占用的內存空間覆蓋,這意味著head程序自己將自己廢棄,main函數即將執行。 技術分享
    • 將各寄存器(CS、DS、ES、FS、GS)的用法從實模式轉變到保護模式。在實模式下,CS本身就是代碼段基址,而在保護模式下,CS本身並不是代碼段基址,而是代碼段選擇符。以下代碼完成各寄存器模式的轉換工作。代碼中movl $0x10, %eax的解析與前面語句jmpi 0,8的解析方式相同。將0x10也看成二進制00010000. 技術分享
_pg_dir:
startup_32:
	movl $0x10,%eax
	mov %ax,%ds
	mov %ax,%es
	mov %ax,%fs
	mov %ax,%gs

技術分享

  • 接下來需要對中斷描述符表進行設置,中斷描述符的結構如下:

    技術分享

call setup_idt

setup_idt:
	lea ignore_int,%edx
	movl $0x00080000,%eax
	movw %dx,%ax		/* selector = 0x0008 = cs */
	movw $0x8E00,%dx	/* interrupt gate - dpl=0, present */

	lea _idt,%edi
	mov $256,%ecx
rp_sidt:
	movl %eax,(%edi)
	movl %edx,4(%edi)
	addl $8,%edi
	dec %ecx
	jne rp_sidt
	lidt idt_descr
	ret

這是重建保護模式下中斷服務體系的開始,程序先讓所有中斷描述符默認指向ignor_int這個位置(將來main函數裏面還要讓中斷描述符對應具體的中斷服務程序),之後還需要對中斷描述符表寄存器的值進行設置,具體操作狀態如下: 技術分享

  • 接下來head程序要廢除已有的GDT,並在內核中的新位置重建全局描述表,其中第二項和第三項分別為內核代碼段描述符和內核數據段描述符,其段限長均被設置為16MB,並設置全局描述符表寄存器的值 技術分享
call setup_gdt

setup_gdt:
	lgdt gdt_descr
	ret

_gdt:	.quad 0x0000000000000000	/* NULL descriptor */
	.quad 0x00c09a0000000fff	/* 16Mb */
	.quad 0x00c0920000000fff	/* 16Mb */
	.quad 0x0000000000000000	/* TEMPORARY - don‘t use */
	.fill 252,8,0			/* space for LDT‘s and TSS‘s etc */

  • 為什麽要廢除原來的GDT而重新設計一套新的GDT呢?

    原來GDT所在的位置是設計代碼時在setup.s裏面設置的,將來這個setup模塊所在的內存位置會在設計緩沖區時被覆蓋。如果不改變位置,GDT內容將來肯定會被緩沖區覆蓋掉,從而影響系統的運行。這樣一來,將來整個內存中唯一安全的地方就是現在head.s所在的位置了。 那麽有沒有可能在執行setup程序時直接把GDT的內容拷貝到head.s所在的位置呢?肯定不能,如果先復制GDT的內容,後移動system模塊,它就會被後者覆蓋掉;如果先移動system模塊,後復制GDT內容,它又會把head.s對應的程序覆蓋掉,而這時head.s還沒有執行呢,所以,無論如何,都要重新建立GDT。

  • 全局描述符表GDT的位置和內容發生了變化,段限長由原來的8MB擴展到現在的16MB,同時還需要再一次調整各寄存器,使其適應新的段限

技術分享

  • 接下來head程序需要重新檢查A20地址線是否打開,因為A20地址線是否打開時保護模式和實模式的根本區別之一。另外還要檢測是否有數學協處理器的存在,如果有,則將其設置為保護模式的工作狀態
  • head程序將為調用main函數做最後的準備。將L6標號和main函數的入口地址壓棧,棧頂為main函數地址,目的是使head程序執行完之後通過ret指令就可以執行main函數 技術分享
	pushl $L6		# return address for main, if it decides to.
	pushl $_main
	jmp setup_paging
  • 壓棧完成之後,head程序將跳轉至setup_paging處去執行,開始創建分頁機制
setup_paging:
	movl $1024*5,%ecx		/* 5 pages - pg_dir+4 page tables */
	xorl %eax,%eax
	xorl %edi,%edi			/* pg_dir is at 0x000 */
	cld;rep;stosl
	movl $pg0+7,_pg_dir		/* set present bit/user r/w */
	movl $pg1+7,_pg_dir+4		/*  --------- " " --------- */
	movl $pg2+7,_pg_dir+8		/*  --------- " " --------- */
	movl $pg3+7,_pg_dir+12		/*  --------- " " --------- */
	movl $pg3+4092,%edi
	movl $0xfff007,%eax		/*  16Mb - 4096 + 7 (r/w user,p) */
	std
1:	stosl			/* fill pages backwards - more efficient :-) */
	subl $0x1000,%eax
	jge 1b
	xorl %eax,%eax		/* pg_dir is at 0x0000 */
	movl %eax,%cr3		/* cr3 - page directory start */
	movl %cr0,%eax
	orl $0x80000000,%eax
	movl %eax,%cr0		/* set paging (PG) bit */
	ret			/* this also flushes prefetch-queue */

首先會將頁目錄表和4個頁表放在物理內存的起始位置。從內存起始位置開始的5頁空間內容全部清零(每頁4KB),為初始化頁目錄和頁表做準備。註意,這個動作啟用了一個頁目錄和4個頁表覆蓋了head程序自身所在內存空間的作用。head程序將也目錄表和4個頁表所占物理內存空間清零後,設置頁目錄表的前4項,使之分別指向4個頁表。設置完頁目錄表後,linux 0.11在保護模式下支持的最大尋址地址為0xFFFFFF(16MB),此處將第4張頁表(由pg3指向的位置)的最後一個頁表項(pg3+4092指向的位置)指向尋址範圍的最後一個頁面,即0xFFF000開始的4KB字節大小的內存空間。然後開始從高地址向低地址方向填寫全部4個頁表,依次指向內存從高地址向低地址方向的各個頁面。填寫過程如下列各圖所示。 註意這4個頁表都是內核專屬頁表,將來每個用戶進程都有他們專屬的頁表,兩者在尋址範圍方面的區別將在後文介紹。 技術分享

將頁目錄表和4個頁表放在物理內存的起始位置,這個動作意義重大,是操作系統能夠掌控全局、掌控進程在內存中安全運行的基石之一

技術分享 技術分享 技術分享 技術分享

  • head程序已將頁表設置完畢了,但分頁機制的建立還沒有完成。需要設置頁目錄基址寄存器CR3,使之指向頁目錄表,再將CR0寄存器設置的最高位(31位)置位1,如下圖所示 技術分享

  • 所有設置完成之後的內存布局為如下,可以看出,只有184字節的剩余代碼,由此可見在設計head程序和system模塊時,其計算是非常精確的,對head.s的代碼量的控制非常到位 技術分享

  • head程序執行的最後一步:ret 跳入main函數程序中執行。這個函數調用方法與普通函數調用方法有很大差別。

先看看普通函數的調用和返回方法。普通函數都是使用CALL指令來實現。 CALL指令會將EIP的值自動壓棧,保護返回現場,然後執行被調函數的程序。等到執行被調用函數的ret指令時,自動出棧給EIP並恢復現場,繼續執行CALL的下一條指令。這是通常的函數調用方法。但對操作系統的main函數來說,這個方法就有些怪異了。main函數式操作系統的,如果用CALL調用操作系統的main函數,那麽ret時返回給誰呢?難道還有一個更底層的系統程序接收操作系統的返回麽?操作系統已經是最底層的系統了,所以邏輯上不成立。那麽如何調用了操作系統的main函數,又不需要返回呢?操作系統的設計者采用了下圖的下半部分所示的方法。 這個方法的妙處在於用ret是實現的調用操作系統的main函數,既然是ret調用,當然就不需要再用ret了。不過CALL做的壓棧和跳轉動作誰來完成呢?操作系統的設計者做了一個仿CALL的動作,手工編寫壓棧和跳轉代碼,模仿了CALL的全部動作,實現了調用setup_paging函數。註意,壓棧的EIP值並不是調用setup_paging函數的下一條指令的地址,而是操作系統的main函數的執行入口地址_main。這樣,當setup_paging函數執行到ret時,從棧中將操作系統的main函數的執行入口地址_main自動出棧給EIP,EIP指向main函數的入口地址,實現了用返回指令“調用”main函數。

技術分享

  • 至此,Linux操作系統內核啟動的一個重要階段已經完成了,接下來就要進入main函數對應的代碼了。

從開機加電到執行main函數之前的過程