Linux-內核-學習筆記(12):內核啓動過程分析

Linux-內核-學習筆記(12):內核啓動過程分析

在uboot啓動後,會將各種參數通過三個寄存器的方式傳遞給內核函數,並在執行啓動內核後自動結束。內核啓動過程會解析參數並初始化各種設備,最終進入到了一種能夠實現進程間調度的多進程狀態,這些進程裏面只要有哪個需要被運行,調度系統就會終止cpu_idle死循環進程(空閒進程)轉而去執行有意義的幹活的進程,從而實現內核的運轉。

一、鏈接腳本vmlinux.lds.S

在這裏插入圖片描述
kernel的鏈接腳本並不是直接提供的,而是提供了一個彙編文件vmlinux.lds.S,然後在編譯的時候再去編譯這個彙編文件得到真正的鏈接腳本vmlinux.lds

爲什麼linux kernel不直接提供vmlinux.lds而要提供一個vmlinux.lds.S然後在編譯時纔去動態生成vmlinux.lds呢?
.lds文件中只能寫死,不能用條件編譯。但是我們在kernel中鏈接腳本確實有條件編譯的需求(但是lds格式又不支持),於是乎kernel工作者找了個投機取巧的方法,就是把vmlinux.lds寫成一個彙編格式,然後彙編器處理的時候順便條件編譯給處理了,得到一個不需要條件編譯的vmlinux.lds。

從vmlinux.lds.S中 ENTRY(stext) 可以知道入口符號是stext,在SI中搜索這個符號,發現arch/arm/kernel/目錄下的head.S和head-nommu.S中都有。
head.S是啓用了MMU情況下的kernel啓動文件,相當於uboot中的start.S。head-nommu.S是未使用mmu情況下的kernel啓動文件。

二、內核啓動文件head.S(彙編階段)

內核運行的物理地址與虛擬地址(29-30)

在這裏插入圖片描述
KERNEL_RAM_VADDR(VADDR就是virtual address),這個宏定義了內核運行時的虛擬地址。值爲0xC0008000
KERNEL_RAM_PADDR(PADDR就是physical address),這個宏定義內核運行時的物理地址。值爲0x30008000
總結:內核運行的物理地址是0x30008000,對應的虛擬地址是0xC0008000。

內核運行硬件條件備註(59-76)

內核啓動不是無條件的,而是有一定的先決條件,這個條件由啓動內核的bootloader(我們這裏就是uboot)來構建保證。
在這裏插入圖片描述
(1)內核的起始部分代碼是被解壓代碼調用的。回憶之前講zImage的時候,uboot啓動內核後實際調用運行的是zImage前面的那段未經壓縮的解壓代碼,解壓代碼運行時先將zImage後段的內核解壓開,然後再去調用運行真正的內核入口。並在開始時MMU和D-cache是關閉的,I-cache任意,並且寄存器r0,r1,r2傳的參數與uboot階段時最後的theKernel函數傳參對應。所以uboot中最後theKernel (0, machid, bd->bi_boot_params);執行內核時,運行時實際把0放入r0中,machid放入到了r1中,bd->bi_boot_params放入到了r2中。ARM的這種處理技巧剛好滿足了kernel啓動的條件和要求。
(2)kernel啓動時MMU是關閉的,因此硬件上需要的是物理地址。但是內核是一個整體(zImage)只能被連接到一個地址(不能分散加載),這個連接地址肯定是虛擬地址。因此內核運行時前段head.S中尚未開啓MMU之前的這段代碼必須是位置無關碼,而且其中涉及到操作硬件寄存器等時必須使用物理地址。
(3)通過linux/arch/arm/tools/mach-types目錄中查找對應的機器碼。
(4)不要添加沒有用的代碼在這裏,這裏的代碼只是用來boot loader的。

內核啓動入口(78)

在這裏插入圖片描述
內核的真正入口就是ENTRY(stext)處。前面的__HEAD定義了後面的代碼屬於段名爲.head.text的段。

__lookup_processor_type(82)

在這裏插入圖片描述
我們從cp15協處理器的c0寄存器中讀取出硬件的CPU ID號,然後調用這個函數來進行合法性檢驗。如果合法則繼續啓動,如果不合法則停止啓動,轉向__error_p啓動失敗。
該函數檢驗cpu id的合法性方法是:內核會維護一個本內核支持的CPU ID號碼的數組,然後該函數所做的就是將從硬件中讀取的cpu id號碼和數組中存儲的各個id號碼依次對比,如果沒有一個相等則不合法,如果有一個相等的則合法。
內核啓動時設計這個校驗,也是爲了內核啓動的安全性着想。

__lookup_machine_type(85)

該函數的設計理念和思路和上面校驗cpu id的函數一樣的。不同之處是本函數校驗的是機器碼

__vet_atags(88)

該函數的設計理念和思路和上面2個一樣,不同之處是用來校驗uboot給內核的傳參ATAGS格式是否正確。這裏說的傳參指的是uboot通過tag給內核傳的參數(主要是板子的內存分佈memtag、uboot的bootargs)
內核認爲如果uboot給我的傳參格式不正確,那麼我就不啓動。
uboot給內核傳參的部分如果不對,是會導致內核不啓動的。譬如uboot的bootargs設置不正確內核可能就會不啓動。

__create_page_tables(89)

該函數是用來建立頁表的。linux內核本身被連接在虛擬地址處,因此kernel希望儘快建立頁表並且啓動MMU進入虛擬地址工作狀態。但是kernel本身工作起來後頁表體系是非常複雜的,建立起來也不是那麼容易的。kernel想了一個好辦法 :兩步建立頁表
第一步,kernel先建立了一個段式頁表(和uboot中之前建立的頁表一樣,頁表以1MB爲單位來區分的),這裏的函數就是建立段式頁表的。段式頁表本身比較好建立(段式頁表1MB一個映射,4GB空間需要4096個頁表項,每個頁表項4字節,因此一共需要16KB內存來做頁表),壞處是比較粗不能精細管理內存;第二步,再去建立一個細頁表(4kb爲單位的細頁表),然後啓用新的細頁表廢除第一步建立的段式映射頁表

內核啓動的早期建立段式頁表,並在內核啓動前期使用;內核啓動後期就會再次建立細頁表並啓用。等內核工作起來之後就只有細頁表了。

__switch_data(98)

在這裏插入圖片描述
建立了段式頁表後進入了 __switch_data部分,這東西是個函數指針數組。分析得知下一步要執行 __mmap_switched函數。並且在該函數複製數據段、清除bss段(目的是構建C語言運行環境),保存起來cpu id號、機器碼、tag傳參的首地址。在__mamap_switched函數中通過b start_kernel跳轉到C語言運行階段。

總結:在head.S彙編代碼階段主要就是校驗啓動合法性(CPU ID號、機器碼、ATAGS格式)、建立段式映射的頁表並開啓MMU以方便使用內存、跳入C階段。因爲大部分任務已經在uboot中進行了,所以這裏沒有做太多的工作。

三、內核啓動文件main.c

在這裏插入圖片描述

一些初始化代碼

(1)smp_setup_processor_id。smp就是對稱多處理器(其實就是我們說的多核心CPU),所以是建立smp。
(2)lockdep_init。鎖定依賴,是一個內核調試模塊,處理內核自旋鎖死鎖問題相關的。
(3)debug_objects_early_init。對obj_hash,obj_static_pool這兩個全局變量進行初始化設置。這兩個全局變量在進行調試的時候會使用到。
(4)boot_init_stack_canary。用來防止棧溢出。
(5)cgroup_init_early。control group,內核提供的一種來處理進程組的技術。
(6)local_irq_disable。屏蔽當前CPU上的所有中斷。
(7)early_boot_irqs_off。通過該標記可以讓我們知道是否在early bootup code。
(8)early_init_irq_lock_class。設置所有IRQ描述符的鎖是統一的鎖還是各有各的小鎖。
(9)lock_kernel。獲得大內核鎖,該鎖可以用來鎖定整個內核。
(10)tick_init。初始化tick控制功能,註冊clockevents的框架。
(11)boot_cpu_init。設置第一個CPU核爲活躍CPU核。若系統爲單CPU核系統,則設置僅有的CPU爲活躍CPU核。
(12)page_address_init。函數初始化高端內存頁表池的鏈表。

打印內核版本信息(572)

在這裏插入圖片描述
printk函數是內核中用來從console打印信息的,類似於應用層編程中的printf。內核編程時不能使用標準庫函數,因此不能使用printf,其實printk就是內核自己實現的一個printf。printk函數的用法和printf幾乎一樣,不同之處在於可以在參數最前面用一個宏來定義消息輸出的級別
在這裏插入圖片描述
爲什麼要有這種級別? 主要原因是linux內核太大了,代碼量太多,裏面的printk打印信息太多了。如果所有的printk都能打印出來而不加任何限制,則最終內核啓動後得到海量的輸出信息。爲了解決打印信息過多,無效信息會淹沒有效信息這個問題,linux內核的解決方案是給每一個printk添加一個打印級別。級別定義0-7(注意編程的時候要用相應的宏定義,不要直接用數字)分別代表8種輸出的重要性級別,0表示最重要,7表示最不重要。我們在printk的時候自己根據自己的消息的重要性去設置打印級別。
linux的控制檯監測消息的地方也有一個消息過濾顯示機制,控制檯實際只會顯示級別比我的控制檯定義的級別高的消息。譬如說控制檯的消息顯示級別設置爲4,那麼只有printk中消息級別爲0-3(也可能是0-4)的纔可以顯示看見,其餘的被過濾掉了。

setup_arch(573)

這個函數是用來確定我們當前內核的機器(arch、machine)。我們的linux內核會支持一種CPU的運行,CPU+開發板就確定了一個硬件平臺(架構),這個架構就決定了內核能夠在哪種硬件上跑。之前說過的機器碼就是給這個硬件平臺一個固定的編碼,以表徵這個平臺。CPU:S5PV210,開發板:X210
當前內核支持的機器碼以及硬件平臺相關的一些定義都在這個函數中處理。

(1)setup_processor函數
在這裏插入圖片描述
setup_processor函數用來查找CPU信息,可以結合串口打印的信息來分析。它的查找方式是通過read_cpuid)id函數,對硬件進行查找,從而確定CPI的ID。

(2)setup_machine函數
在這裏插入圖片描述
setup_machine函數的傳參是機器碼編號,通過在linux中搜索(grep "mathine_arch_type " * -nR),從而確定machine_arch_type符號在include/generated/mach-types.h的32039-32050行定義了。經過分析後確定這個傳參值就是2456。通過這個2456機器碼反查從而找到對應這個機器碼的machine_desc描述符,並且返回這個描述符的指針。
其實真正幹活的函數是lookup_machine_type,它通過調用了__lookup_machine_type這個函數來工作。該函數的工作原理:內核在建立的時候就把各種CPU架構的信息組織成一個一個的machine_desc結構體實例,然後都給一個段屬性.arch.info.init,鏈接的時候會保證這些描述符會被連接在一起。__lookup_machine_type就去那個那些描述符所在處依次挨個遍歷各個描述符,比對看機器碼哪個相同。

(3)cmdline處理
這裏說的cmdline就是指的uboot給kernel傳參時傳遞的命令行啓動參數,也就是uboot的bootargs
在這裏插入圖片描述
default_command_line: 是一個全局變量字符數組,這個字符數組可以用來存東西。
CONFIG_CMDLINE: 在.config文件中定義的(可以在make menuconfig中去更改設置),這個表示內核的一個默認的命令行參數。
內核對cmdline的處理思路是: 內核中自己維護了一個默認的cmdline(就是.config中配置的這一個),然後uboot還可以通過tag給kernel再傳遞一個cmdline。如果uboot給內核傳cmdline成功則內核會優先使用uboot傳遞的這一個;如果uboot沒有給內核傳cmdline或者傳參失敗,則內核會使用自己默認的這個cmdline。

內核爲什麼要設計這樣一個cmdline傳參機制?
因爲可以通過傳參數就可以改變內核運行的狀態,不用重新編譯燒錄,方便了很多。

setup_command_line(575)

在這裏插入圖片描述
該函數也是在處理和命令行參數cmdline有關的任務。

parse_early_param&parse_args(584-587)

在這裏插入圖片描述
該函數解析cmdline傳參和其他傳參。
這裏的解析意思是把cmdline的細節設置信息給解析出來。譬如cmdline:console=ttySAC2,115200 root=/dev/mmcblk0p2 rw init=/linuxrc rootfstype=ext3,則解析出的內容就是就是一個字符串數組,數組中依次存放了一個設置項目信息,這些內容最終會影響內核的啓動。
console=ttySAC2,115200 一個(串口2,波特率是115200)
root=/dev/mmcblk0p2 rw 一個
init=/linuxrc 一個
rootfstype=ext3 一個

這裏只是進行了解析,並沒有去處理。也就是說只是把長字符串解析成了短字符串,最多和內核裏控制這個相應功能的變量掛鉤了,但是並沒有去執行。執行的代碼在各自模塊初始化的代碼部分。

還是一些初始化代碼

(1)trap_init。設置異常向量表。
(2)mm_init。內存管理模塊初始化。
(3)sched_init。內核調度系統初始化。
(4)early_irq_init&init_IRQ。中斷初始化。
(5)console_init.控制檯初始化。
……
start_kernel函數中調用了很多的xx_init函數,全都是內核工作需要的模塊的初始化函數。這些初始化之後內核就具有了一個基本的可以工作的條件了。

rest_init函數之前,內核工作所需的初始化條件已經基本結束了,剩下的一些工作就比較重要了,放在了該函數中。至此也就意味着start_kernel函數基本結束了。
下面對start_kernel函數做的工作進行簡單的總結: start_kernel函數做的主要工作有打印了一些信息、內核工作需要的模塊的初始化被依次調用(譬如內存管理、調度系統、異常處理···)、我們需要重點了解的就是setup_arch中做的2件事情:機器碼架構的查找並且執行架構相關的硬件的初始化、uboot給內核的傳參cmdline。

rest_init(710)

在這裏插入圖片描述
(1)rest_init中調用kernel_thread函數啓動了2個內核線程,分別是:kernel_init和kthreadd
(2)調用schedule函數開啓了內核的調度系統,從此linux系統開始轉起來了。
(3)rest_init最終調用cpu_idle函數結束了整個內核的啓動。也就是說linux內核最終結束了一個函數cpu_idle。這個函數裏面肯定是死循環。
之前已經啓動了內核調度系統,調度系統會負責考評系統中所有的進程,這些進程裏面只有有哪個需要被運行,調度系統就會終止cpu_idle死循環進程(空閒進程)轉而去執行有意義的幹活的進程。這樣操作系統就轉起來了。最終穩定的狀態就是有事幹去執行相應的進程,沒事幹時候執行空閒進程

補充知識1:什麼是內核線程?
進程和線程。簡單來理解,一個運行的程序就是一個進程。所以進程就是任務、進程就是一個獨立的程序。獨立的意思就是這個程序和別的程序是分開的,這個程序可以被內核單獨調用執行或者暫停(但是這個時間非常短)。
在linux系統中,線程和進程非常相似,幾乎可以看成是一樣的。實際上我們當前講課用到的進程和線程的概念就是一樣的。進程/線程就是一個獨立的程序。應用層運行一個程序就構成一個用戶進程/線程那麼內核中運行一個函數(函數其實就是一個程序)就構成了一個內核進程/線程
所以我們kernel_thead函數運行一個函數,其實就是把這個函數變成了一個內核線程去運行起來,然後他可以被內核調度系統去調度(可以暫停它也可以恢復它)。說白了就是去調度器註冊了一下,以後人家調度的時候會考慮你。

補充知識2:進程0、進程1、進程2
截至目前爲止,我們一共涉及到3個內核進程/線程。
操作系統是用一個數字來表示/記錄一個進程/線程的,這個數字就被稱爲這個進程的進程號。這個號碼是從0開始分配的。因此這裏涉及到的三個進程分別是linux系統的進程0、進程1、進程2
在linux下我們可以通過ps指令查看當前linux系統中運行的進程情況,ps -aux可以查看當前系統運行的所有進程。
在這裏插入圖片描述
從圖中可以看出進程號是從1開始的。爲什麼不從0開始,因爲進程0不是一個用戶進程,而屬於內核進程。我們這裏查看的只能是用戶進程。
那麼進程0,進程1,進程2都是做什麼的?

  • 進程0:進程0其實就是剛纔講過的idle進程,叫空閒進程,也就是死循環。
  • 進程1:kernel_init函數就是進程1,這個進程被稱爲init進程
  • 進程2:kthreadd函數就是進程2,這個進程是linux內核的守護進程。這個進程是用來保證linux內核自己本身能正常工作的。

總結:進程0就是指當前如果其他進程不需要cpu參與時,那麼cpu就會在進程0中進行循環等待進程調度系統給它安排任務,同時做一些統計CPU利用率等閒雜事情;進程2用來保證linux內核正常工作。通過觀察可以發現,進程0屬於內核進程,工作在內核態,而進程2屬於用戶進程,工作在用戶態,那麼兩個狀態之間的切換需要由進程1來完成

rest_init->kernel_init(進程1)

init進程完成了從內核態向用戶態的轉變,所以又可以叫進程1爲一個進程兩種狀態。
init進程剛開始運行的時候是內核態,它屬於一個內核線程,然後他自己運行了一個用戶態下面的程序後把自己強行轉成了用戶態。因爲init進程自身完成了從內核態到用戶態的過度,因此後續的其他進程都可以工作在用戶態下面了。

(1)在內核態下做的事情:
重點就做了一件事情,就是掛載根文件系統並試圖找到用戶態下的那個init程序。init進程要把自己轉成用戶態就必須運行一個用戶態的應用程序(這個應用程序名字一般也叫init),要運行這個應用程序就必須得找到這個應用程序,要找到它就必須得掛載根文件系統,因爲所有的應用程序都在文件系統中。
內核源代碼中的所有函數都是內核態下面的,執行任何一個都不能脫離內核態。應用程序必須不屬於內核源代碼,這樣才能保證自己是用戶態。也就是說我們這裏執行的這個init程序和內核不在一起,他是另外提供的。提供這個init程序的那個人就是根文件系統

(2)在用戶態下做的事情:
在這裏插入圖片描述
init進程大部分有意義的工作都是在用戶態下進行的。它在用戶態下構建了用戶交互界面。啓動了login進程(登錄上去以後就死掉了)、命令行進程shell進程。正式由於命令行和shell進程的啓動,從而通過./xx的方式來啓動其他進程,因此可以說其他所有的用戶進程都直接或者間接派生自init進程

(3)如何從內核態跳躍到用戶態?
init進程在內核態下面時,通過一個函數kernel_execve來執行一個用戶空間編譯連接的應用程序就跳躍到用戶態了。注意這個跳躍過程中進程號是沒有改變的,所以一直是進程1。這個跳躍過程是單向的,也就是說一旦執行了init程序轉到了用戶態下整個操作系統就算真正的運轉起來了,以後只能在用戶態下工作了,用戶態下想要進入內核態只有走API這一條路了。

(4)進程的創建規則
在這裏插入圖片描述
linux系統中每個進程都有自己的一個文件描述符表,表中存儲的是本進程打開的文件。
linux系統中有一個設計理念:一切屆是文件。所以設備也是以文件的方式來訪問的。我們要訪問一個設備,就要去打開這個設備對應的文件描述符。譬如/dev/fb0這個設備文件就代表LCD顯示器設備,/dev/buzzer代表蜂鳴器設備,/dev/console代表控制檯設備。
這裏我們打開了 /dev/console 文件,並且 sys_dup複製了2次文件描述符,一共得到了3個文件描述符。這三個文件描述符分別是0、1、2.這三個文件描述符就是所謂的:標準輸入、標準輸出、標準錯誤
父進程創建一個子進程,子進程默認擁有所有父進程的文件描述。進程1打開了三個標準輸出輸出錯誤文件,因此後續的進程1衍生出來的所有的進程默認都具有這3個三件描述符。

(5)掛載根文件系統
在這裏插入圖片描述
uboot傳參中的root=/dev/mmcblk0p2 rw 這一句就是告訴內核根文件系統在哪裏。
uboot傳參中的
rootfstype=ext3
這一句就是告訴內核rootfs的類型。

如果內核掛載根文件系統成功,則會打印出:VFS: Mounted root (ext3 filesystem) on device 179:2.
如果掛載根文件系統失敗,則會打印:No filesystem could mount root, tried: yaffs2
如果內核啓動時掛載rootfs失敗,則後面肯定沒法執行了,肯定會死。內核中設置了啓動失敗休息5s自動重啓的機制,因此這裏會自動重啓,所以有時候大家會看到反覆重啓的情況。
掛載失敗可能的原因:(1)最常見的錯誤就是uboot的bootargs設置不對。(2)rootfs燒錄失敗(fastboot燒錄不容易出錯,以前是手工燒錄很容易出錯)。(3)rootfs本身製作失敗的。(尤其是自己做的rootfs,或者別人給的第一次用)

掛載文件系統成功後:
上面一旦掛載rootfs成功,則進入rootfs中尋找應用程序的init程序,這個程序就是用戶空間的進程1.找到後用init_post->run_init_process->kernel_execute去執行他。

接下來就要通過進程1來跳轉到用戶態下的init程序,那麼如何確定init程序是誰?
在這裏插入圖片描述
先從uboot傳參cmdline中看有沒有指定,如果有指定先執行cmdline中指定的程序。cmdline中的init=/linuxrc這個就是指定rootfs中哪個程序是init程序。這裏的指定方式就表示我們rootfs的根目錄下面有個名字叫linuxrc的程序,這個程序就是init程序

四、cmdline常用參數

格式就是由很多個項目用空格隔開依次排列,每個項目中都是項目名=項目值。整個cmdline會被內核啓動時解析,解析成一個一個的項目名=項目值的字符串。這些字符串又會被再次解析從而影響啓動過程。

1、一些常用的項目名

(1)root=
這個是用來指定根文件系統在哪裏的。一般格式是root=/dev/xxx(一般如果是nandflash上則/dev/mtdblock2,如果是inand/sd的話則/dev/mmcblk0p2)第0個SD卡的第二個分區
(3)如果是nfs的rootfs,則root=/dev/nfs。

(2)rootfstype=
(1)根文件系統的文件系統類型,一般是jffs2、yaffs2、ext3、ubi

(3)console=
控制檯信息聲明,譬如console=/dev/ttySAC0,115200表示控制檯使用串口0,波特率是115200。
正常情況下,內核啓動的時候會根據console=這個項目來初始化硬件,並且重定位console到具體的一個串口上,所以這裏的傳參會影響後續是否能從串口終端上接收到內核的信息。

(4)mem=
mem=用來告訴內核當前系統的內存有多少。

(5)init=
init=用來指定進程1的程序pathname,一般都是init=/linuxrc(符號鏈接)

2、常見cmdline介紹

(1)console=ttySAC2,115200 root=/dev/mmcblk0p2 rw init=/linuxrc rootfstype=ext3
第一種這種方式對應rootfs在SD/iNand/Nand/Nor等物理存儲器上。這種對應產品正式出貨工作時的情況。
(2)root=/dev/nfs nfsroot=192.168.1.141:/root/s3c2440/build_rootfs/aston_rootfs ip=192.168.1.10:192.168.1.141:192.168.1.1:255.255.255.0::eth0:off init=/linuxrc console=ttySAC0,115200
第二種這種方式對應rootfs在nfs上,這種對應我們實驗室開發產品做調試的時候。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章