第一部分:基本功能流程
CPU上電後會從IO空間的某地址取第一條指令。但此時:PLL沒有啓動,CPU工作頻率爲外部輸入晶振頻率,非常低;CPU工作模式、中斷設置等不確定;存儲空間的各個BANK(包括內存)都沒有驅動,內存不能使用。在這種情況下必須在第一條指令處做一些初始化工作,這段初始化程序與操作系統獨立分開,稱之爲bootloader。
實際上,很少有必要自己寫一個Bootloader,因爲U-Boot已經強大到能夠滿足各種需要。但是強大必然複雜,一個初學者想要分析U-Boot的源代碼,還是有些難度的。出於學習的目的,我寫了這個史上最簡單的啓動加載器,它只包含最基本的功能,卻囊括了一個嵌入式Bootloader應該有的核心和精華。我把這個啓動加載器命名爲S-Boot, 是Simple Bootloader的縮寫,亦可進一步簡稱爲SB。
使用的實驗環境爲OK2440開發板,板上處理器爲S3C2440A,有64M內存,Nand存儲器爲K9F1208,64M。網口芯片爲CS8900A。我們要實現的功能是:從串口下載Linux內核映像到RAM;從網口下載Linux內核映像到RAM;從RAM啓動內核掛載NFS根文件系統。
1. 第一階段的彙編代碼:start.S
一個嵌入式Bootloader最初始部分的代碼幾乎必須是用彙編語言寫成的,因爲開發板剛上電後沒有準備好C程序運行環境,比如堆棧指針SP沒有指到正確的位置。彙編代碼應該完成最原始的硬件設備初始化,並準備好C運行環境,這樣後面的功能就可以用C語言來寫了。
對我們的S-Boot來說,上電後的起始運行代碼是 start/start.S。
.text .global _start _start: b Reset @ 0x00: 發生復位異常時從地址零處開始運行 b HandleUndef @ 0x04: 未定義指令中止模式的向量地址 b HandleSWI @ 0x08: 管理模式的向量地址,通過SWI指令進入此模式 b HandlePrefetchAbort @ 0x0C: 指令預取終止導致的異常的向量地址 b HandleDataAbort @ 0x10: 數據訪問終止導致的異常的向量地址 b HandleNotUsed @ 0x14: 保留 b HandleIRQ @ 0x18: 中斷模式的向量地址 b HandleFIQ @ 0x1C: 快中斷模式的向量地址
|
這裏,彙編指示符.text表明以下內容屬於代碼段,.global _start指明_start是全局可訪問的符號(Give the symbol external linkage)。按照ARM920T的規定,從地址0x00到0x1C放置異常向量表,向量表每個條目佔四個字節,正好可以放置一條跳轉指令,跳轉到相應異常的服務程序中去。在S-Boot中沒有使用中斷,所以除Reset異常外,其它異常的服務程序都可簡單地寫個死循環。Reset異常是系統上電後自動觸發的,所以我們的代碼都寫在Reset的服務程序裏面。
實際上,異常向量表不一定非要位於地址0x00處,CP15協處理器中的c1寄存器的第13位用來控制異常向量表的起始地址。該位爲0時,異常向量表位於低地址0x00處;該位爲1時,異常向量表位於高地址 0xFFFF0000處。我們沒有必要改變這個位的值,使用默認的低地址就行了。
Reset: mrs r0,cpsr @set cpu to SVC32 mode bic r0,r0,#0x1F orr r0,r0,#0xD3 msr cpsr,r0 @cpsr=11x10011, IRQ/FIQ disabled
|
代碼最初始的任務是設置CPU工作在SVC32模式,關閉所有中斷,禁用看門狗。實際上,即使不設置工作模式,CPU在復位之後將自動工作在管理模式。在整個S-Boot運行期間,我們沒有使用中斷,也沒有改變CPU工作模式,它將一直工作在SVC32模式。
MMU、ICache、DCache的打開和關閉都是由CP15協處理器的c1寄存器控制的。實際上在復位之後這三者都是自動關閉的,所以省略了關閉它們的代碼。
S3C2440A的PSR寄存器(Program Status Reguster)中每個Bit位的含義如圖1所示。Bit4~Bit0爲模式位,用來設置CPU工作模式,現在只要知道 M[4:0] = 10011 表示SVC32模式就行了。Bit5爲狀態位,T=0表示工作在ARM狀態,T=1表示工作在Thumb狀態,默認爲0,不需要改變。Bit6爲快速中斷禁止位,F=1爲禁止快速中斷,F=0爲使能快速中斷。Bit7爲中斷禁止位,I=1爲禁止中斷,F=0爲使能中斷。其它Bit位暫時可以不必理會。
mrs 和msr是在PSR寄存器和其它寄存器間傳遞數據的指令。如:mrs r0,cpsr 把cpsr的值傳送到r0中, msr cpsr,r0 把r0的值傳送到cpsr中。bic是位清零(Bit Clear)指令,bic r0,r0,#0x1F 意思是把r0的Bit[4:0]位清零(由0x1F指示),然後把結果寫入r0中。 orr是按位求或指令,orr r0,r0,#0xD3 表示把r0的 Bit7,Bit6,Bit4,Bit1,Bit0 置爲1,其它位保持不變。
執行完上述操作後,cpsr中的 I=1, F=1, T保持不變(默認爲0),M[4:0]=10011,意思是禁止IRQ,禁止FIQ,工作在ARM狀態,工作在SVC32模式。
ldr r0, =0x53000000 mov r1, #0x0 str r1, [r0] @disable watch dog
|
禁用看門狗更簡單,因爲WTCON寄存器的地址爲0x53000000,直接向該寄存器寫0即可。
到目前爲止,CPU工作在外接晶振12MHz頻率之下。使用以下代碼設置PLL,提升工作頻率。
ldr r0, =0x4C000014 @CLKDIVN register
mov r1, #0x05 @FCLK:HCLK:PCLK = 1:4:8 str r1, [r0]
mrc p15,0,r0,c1,c0,0 @if HDIVN Not 0, must asynchronous bus mode orr r0,r0,#0xC0000000 @see S3C2440A manual P7-9 mcr p15,0,r0,c1,c0,0
ldr r0, =0x4C000004 @MPLLCON register ldr r1, =0x0005C011 @((92<<12)|(1<<4)|(1)) str r1, [r0] @FCLK is 400 MHz !
|
最後的結果是,FCLK=400MHz,HCLK=100MHz,PCLK=50MHz。
@ SDRAM Init mov r1, #0x48000000 @MEM_CTL_BASE adrl r2, mem_cfg_val add r3, r1, #52 1: ldr r4, [r2], #4 @ 讀取設置值,並讓r2加4 str r4, [r1], #4 @ 將此值寫入寄存器,並讓r1加4 cmp r1, r3 @ 判斷是否設置完所有13個寄存器 bne 1b @ 若沒有寫成,繼續
|
設置存儲控制器。
ldr sp, =0x32FFF000 @設置堆棧 bl nand_init @初始化NAND Flash @nand_read_ll函數需要3個參數: ldr r0, =0x33000000 @1. 目標地址=0x30000000,這是SDRAM的起始地址 mov r1, #0 @2. 源地址 =0,S-Boot代碼都存在NAND地址0開始處 mov r2, #102400 @3. 複製長度=102400(bytes) bl nand_read @調用C函數nand_read
ldr lr, =halt_loop @設置返回地址 ldr pc, =main @b指令和bl指令只能前後跳轉32M的範圍,故使用向pc賦值的方法進行跳轉
halt_loop: b halt_loop
|
這裏把所有的代碼從Nand拷貝到RAM中,然後跳轉到main函數去執行。此後程序便在RAM中運行了。但是到目前爲止,前面的程序都是在SteppingStone裏運行的。所謂SteppingStone,是指在S3C2440A的內部的4KB的RAM緩存,它總是映射到地址0x00處。硬件加電後會自動將Nand Flash中的前4KB的數據拷貝到Stepping Stone中,然後從地址0x00處開始運行。
如果代碼足夠小(小於4KB)的話,那隻在SteppingStone中運行,加載Linux內核到內存即可。但通常代碼肯定會大於4KB。所以Bootloader一般分爲兩部分,Stage1的代碼在SteppingStone中運行,它會把Stage2的代碼拷貝到RAM中,並跳轉到RAM中執行;Stage2的代碼在RAM中執行,它可以完成加載內核及其它任何複雜的功能。因爲Stage2的起始位置不好確定,爲了方便,我們把所有的代碼都拷貝到RAM中了。
C 函數nand_read有三個參數,第一個參數爲目的地起始地址,第二個參數爲源起始地址,第三個參數爲要複製的數據長度,以字節爲單位。根據ATPCS 函數調用規則,三個參數分別用寄存器r0,r1,r2來傳遞。我們在內存的0x33000000處存放Bootloader,複製長度根據編譯生成的S- Boot.bin映像文件大小,向上取512字節的整數倍。
這裏先來規劃一下內存空間的分配。RAM的地址範圍是從0x30000000到0x34000000共64MByte。把S-Boot和Kernel放在高地址處,S-Boot從 0x33000000開始,預留8MByte的空間,內核從0x33800000開始,可供使用的空間也是8MByte。因棧空間是向下生長的,我們在 S-Boot下面預留4096Byte的空閒區域,然後向下爲棧空間,故棧指針SP初始化爲 0x32FFF000。其實留不留空閒區域是無所謂的,這裏只是爲了把二者更明顯地區分開。我們只設置SVC模式下的SP,不使用CPU的其它工作模式,所以也沒必要設置其它模式下的棧指針。另外,程序中不使用動態內存分配,故而也不必分配堆空間。
2. nand讀操作
在編譯連接時,我們把上述 start.S 代碼放在生成的二進制映像文件的最開始位置,因而也被燒寫到 Nand Flash 的最起始位置,因而會被自動拷貝到 SteppingStone 裏運行。start.S 要完成的任務之一,是把S-Boot的所有代碼從Nand Flash拷貝到內存中,這裏需要對NAND的讀操作,因此對NAND的初始化和讀操作要在第一階段寫好。
以開發板上使用的K9F1208爲例,每個頁(page)爲512Byte數據和16Byte校驗,每個塊(Block)爲32個頁,即16KByte數據和512Byte校驗。
Nand Flash只用8根線與CPU的DATA0-7連接,位寬爲8位,不管是數據、地址或控制字都通過這8根線傳遞,如果讀寫數據的話每次只能傳輸一個字節數據。Nand Flash的操作通過NFCONF、NFCMD、NFADDR、NFDATA、NFSTAT和NFECC六個寄存器來完成。在S3C2440A數據手冊第218頁可以看到讀寫Nand Flash的操作時序:1. 通過NFCONF寄存器配置Nand Flash;2.寫Nand Flash命令到NFCMD寄存器;3.寫Nand Flash地址到 NFADDR寄存器;4. 在讀寫數據時,通過NFSTAT寄存器獲得Nand Flash的狀態信息。應該在讀操作前或寫操作後檢查R/nB信號(Ready/Busy信號)。
初始化NAND Flash:S3C2440的NFCONF寄存器用來設置時序參數TACLS、TWRPH0、TWRPH1,設置數據位寬;還有一些只讀位。TACLS、 TWRPH0、TWRPH1這三個參數控制的是Nand Flash信號線CLE/ALE與寫控制信號nWE的時序關係。
注意,寄存器值轉換成實際的時鐘週期值時,TACLS不需加1,而TWRPH0和TWRPH1需要加1。比如NFCONF寄存器中設置 TACLS=1,TWRPH0=3,TWRPH1=0,意思是時序圖中 TACLS=1個HCLK時鐘,TWRPH0=4個HCLK時鐘,TWRPH1=1個HCLK時鐘。
void nand_init(void) { //時間參數設爲:TACLS=0 TWRPH0=3 TWRPH1=0
NFCONF = 0x300; /* 使能NAND Flash控制器, 初始化ECC, 禁止片選 */ NFCONT = (1<<4)|(1<<1)|(1<<0); /* 復位NAND Flash */ NFCONT &= ~(1<<1); //發出片選信號 NFCMMD = 0xFF; //復位命令 s3c2440_wait_idle();//循環查詢NFSTAT位0,直到它等於1 NFCONT |= 0x2; //取消片選信號 }
|
讀操作:讀操作也是以頁(512Byte)爲單位進行的。在初始上電時,器件進入缺省的“讀方式1模式”。在這一模式下,頁讀操作通過將0x00寫入指令寄存器,接着寫入3個地址(1個列地址和2個行地址)來啓動。一旦頁讀指令被器件鎖存,下面的頁讀操作就不需要再重複寫入頁讀指令了。寫入頁讀指令和地址後,處理器可以通過對信號線R//B的分析來判斷頁讀操作是否完成。如果信號爲低電平,表示器件正忙;如果信號爲高電平,表示器件內部操作完成,要讀取的數據被送入了數據寄存器。外部控制器可以再以50ns爲週期的連續/RE脈衝信號的控制下,從IO口依次讀出數據。連續頁讀操作中,輸出的數據是從指定的列地址開始,直到該頁最後一個列地址的數據爲止。
for(i=start_addr; i < (start_addr + size);) {
NFCMMD = 0; //發出READ0命令 s3c2440_write_addr(i); //Write Address s3c2440_wait_idle(); //循環查詢NFSTAT位0,直到它等於1 for(j=0; j < NAND_SECTOR_SIZE; j++, i++) { *buf = (unsigned char)NFDATA; buf++; } }
|
缺點:沒有使用ECC校驗和糾錯;沒有使用壞塊檢查;
3. main 函數
串口初始化,以便能夠向用戶輸出一些信息;網口初始化,以便能夠從主機下載內核映像;輸出一些菜單,以便用戶選擇執行所需要的功能。比如,用戶可以選擇從串口或網口下載內核映像到RAM中某個地址,然後運行這個內核。關於下載內核映像的實現,在後文會詳細介紹。這裏只看當內核映像已經存在於RAM中時,怎樣才能把這個內核啓動起來。
4. 啓動參數的傳遞
啓動Linux內核之前需要設置好一些必要的啓動參數,這些參數以TAG列表的形式傳遞給內核。所謂TAG列表,就是多個TAG在內存空間中按順序排列。每個TAG,其實都是一個結構體,每個結構體中又包含了一個頭部結構體和一個內容結構體稱。頭部結構體指明瞭本TAG的類型、佔用空間大小;所謂TAG的類型,就是一個宏定義,用一個確定的整數來識別該標記。內容結構體包含了該TAG的具體內容。
下面以具體的例子做說明。
在atag.h中就有:
#define ATAG_CORE 0x54410001 #define ATAG_MEM 0x54410002 #define ATAG_CMDLINE 0x54410009 #define ATAG_NONE 0x00000000
|
這些都是TAG的類型,注意這些整數跟地址沒有關係,只是一個用來識別標記類型的符號而已。
每個Tag都用結構體表示,包含TagHeader 頭結構體以及隨後的參數值數據結構。如 ATAG_CORE:
struct Atag {
struct TagHeader stHdr;
struct TagCore stCore;
};
其中包含兩個結構體。第一個結構體TagHeader含兩個整型變量,用以表示本結構體的長度、標記類型;nSzie賦值爲頭部TagHeader和數據TagCore的大小之和,注意是以字(即4字節)爲單位;ulTag 就賦值爲先前定義的宏ATAG_CORE。第二個結構體就是實際的數據了。
struct TagHeader { UINT32 nSize; UINT32 ulTag; };
struct TagCore { UINT32 ulFlags; UINT32 nPageSize; UINT32 ulRootDev; };
|
由於每個Tag都由一個TagHeader加一個數據部分組成,因此通常的做法是使用Struct和Union相結合來定義:
struct Atag { struct TagHeader stHdr; union { struct TagCore stCore; struct TagMem32 stMem; struct TagVideoText stVideoText; struct TagRamDisk stRamDisk; struct TagInitrd stInitRd; struct TagSerialnr stSerialNr; struct TagRevision stRevision; struct TagVideolfb stVideoLfb; struct TagCmdline stCmdLine; }; };
|
其中涉及到的所有數據結構均可在 Linux 內核源碼的include/asm/setup.h 頭文件找到,我們把這些定義放在Bootloader的頭文件atag.h中。
啓動參數標記列表以標記 ATAG_CORE 開始,以標記 ATAG_NONE 結束。每個標記由標識被傳遞參數的 tag_header 結構以及隨後的參數值數據結構來組成。數據結構 tag 和 tag_header 定義在 Linux 內核源碼的include/asm/setup.h 頭文件中,在我們的S-Boot中對應的頭文件爲 atag.h。
在嵌入式 Linux 系統中,通常需要由 Boot Loader 設置的常見啓動參數有:ATAG_CORE、ATAG_MEM、ATAG_CMDLINE、ATAG_RAMDISK、ATAG_INITRD等。
向內核傳遞參數的方法,先在內存中某個起始地址開始,連續存放多個Tag, 組成Tag列表。列表中的每個Tag包括頭部TagHeader和數據結構體。按規定,第一個Tag必須是ATAG_CORE, 最末一個Tag必須是ATAG_NONE,而且中間必須包含至少一個ATAG_MEM。 注意的是末尾的ATAG_NONE只包括頭部,沒有數據內容。如圖所示。
在編程時先定義好起始地址,然後用一個指針,每設置完畢一個Tag的內容就向後移動相應的長度,然後設置下一個Tag內容,以保證各個Tag的連續存放。
下面具體說明幾個關鍵Tag的數據區域內容的設置。struct TagCore結構體已經在前面列出,它包含三個整型變量,ulFlags一般設爲零,nPageSize表示分頁內存管理中每一頁的大小,一般爲4096字節,ulRootDev是系統啓動的設備號,設爲零即可,因爲通常在後面的命令行參數Cmdline中覆蓋這個設置。Struct TagMem用來描述系統的物理內存地址空間,定義如下:
struct atag_mem { UINT32 nSize; /* size of the area */ UINT32 ulStart; /* physical start address */ };
|
其中nSzie表示內存的總大小,ulStart爲內存的起始物理地址,二者結合告訴內核系統可用的物理內存空間是哪些。Struct TagCmdline結構體的定義就更簡單了,只是一個字符數組,初始長度爲1,如下所示:
struct TagCmdline { char cCmdLine[1]; /* this is the minimum size */ };
|
實際上命令行參數不可能只有一個字節,我們通常使用strcpy函數把命令行參數拷貝到cCmdLine地址處,在結尾附加一個字符串結束符’/0’,然後用strlen函數獲得cCmdLine數組的實際長度(包括字符串結束符)。常見的命令行參數如:root=/dev/mtdblock2 init=/linuxrc console=ttySAC0,115200 mem=65536。我們知道的是,Bootloader以標記列表的形式向內核傳遞的參數,大概有10種不同類型的Tag,而命令行參數只是其中的一種。其它需要設置的Tag包括ATAG_RAMDISK、ATAG_INITRD等,此處不再詳細介紹。
在我們的S-Boot中設置了ATAG_CORE,ATAG_MEM,ATAG_CMDLINE,ATAG_NONE 四項。其中CmdLine 使用的是:
const char *CmdLine = "root=/dev/nfs nfsroot=192.168.1.249:/home/hongwang/mkrootfs/rootfs ip=192.168.1.252:192.168.1.249:192.168.1.1:255.255.255.0:hwlee.net:eth0:off console=ttySAC0,115200 init=/linuxrc mem=65536K console=tty1 fbcon=rotate:2";
這裏root=/dev/nfs表示使用NFS做根文件系統,注意並不真的存在/dev/nfs這個設備,它只是一個符號而已,告訴內核使用NFS而不是使用真正的設備做根文件系統。
nfsroot=[<server-ip>:]<root-dir>[,<nfs-options>]
nfsroot=192.168.1.249:/home/hongwang/mkrootfs/rootfs是NFS服務器地址及要掛載的目錄。
ip=<client-ip>:<server-ip>:<gw-ip>:<netmask>:<hostname>:<device>:<autoconf>
ip=192.168.1.252:192.168.1.249:192.168.1.1:255.255.255.0:hwlee.net:eth0:off
只說明一下autoconf,這一個選項指明開發板使用的自動配置IP地址的方法,有時開發板可以設置成通過DHCP或者BOOTP等協議從服務器獲取IP地址。off 或 none 表示不使用自動配置,使用指定的靜態IP地址信息。
console=ttySAC0,115200 串口控制檯
console=tty1 fbcon=rotate:2 液晶屏Framebuffer控制檯,如果內核支持,可以在LCD屏幕上顯示Linux內核啓動過程,起點結束後在LCD屏幕上進入Shell控制檯供用戶操作。fbcon=rotate:2表示控制檯旋轉180度,若爲1表示旋轉90度,3旋轉270度,0不旋轉。
4. boot kernel zImage
zImage 二進制文件包含兩部分內容,起始部分是解壓縮程序,後面是壓縮的內核。解壓縮程序是最先運行的,內核中文件是:arch/arm/boot /compressed/head.S,它負責把壓縮的內核解壓到0x30008000處。因此zImage可以下載到RAM任意位置處,由解壓縮程序負責搬移到正確的運行地址。
所以 Bootloader啓動Linux內核的方法就是直接跳轉到內核的第一條指令處,也就是跳轉到內存中存放內核映像的開始地址,內核映像具有自解壓功能,會把自己釋放到正確的運行地址。Tag列表怎樣傳給內核呢?使用的方法是把Tag列表的起始地址傳給內核。首先,定義一個指向函數的指針:
typedef void (*LINUX_KERNEL_ENTRY)(int, int, UINT32);
LINUX_KERNEL_ENTRY pfExecKernel;
這樣pfExecKernel就是一個函數指針,函數具有三個整型變量。然後,讓pfExecKernel指向內核映像的起始地址處,這裏使用強制類型轉換把地址轉換成函數指針類型:
pfExecKernel = (LINUX_KERNEL_ENTRY)pKernelStartAddr;
最後,以三個參數調用pfExecKernel函數:
pfExecKernel(0, MACH_ID, ATAG_BASE);
其中第一個參數默認爲零,可以不必理會。第二個參數是機器ID號,不同的CPU有不同的號碼與之對應,可以在內核源代碼的linux/arch/arm/tools/mach-types 文件中查到,S3C2440 對應的MACH_ID 爲362。第三個參數ATAG_BASE就是上文講到的Tag列表的首地址。
這個函數調用的作用其實就是設置 r0=0,r1=機器ID,r2=TAG首地址,然後跳到arch/arm/boot/compressed/head.S文件中的第一條指令處。
既然可以把TAG首地址傳遞給內核,那麼TAG LIST就可以放在RAM中的任何位置了,只要不與其它有用內容衝突即可。但是事實卻並不是想象的這樣。實驗發現,第三個參數傳遞進去的TAG首地址似乎沒有起到作用,因爲啓動時總是找不到正確的啓動參數。後來發現內核有個默認的TAG首地址0x30000100,它總是到0x30000100去尋找啓動參數,而不理會我們傳進來的第三個參數。所以,S-Boot中把TAG首地址就設置爲0x30000100。
5. 小結
綜上所述,包含最基本功能的S-Boot運行流程已經很清楚了。下圖對此作了一個總結。