MIT6.828——Lab1 partA
Lab1
基本部分
在實驗給出的文件中,已經詳說明了早期PC的記憶體佈局,並且運行了bootloader
。詳細地解釋了,上電後BIOS所做的工作,因此這部分不再贅述。需要注意的是bootloader
的職能:
- 進入保護模式
- 從磁碟載入kernel到記憶體
boot/boot.S
實驗文件中要求好好理解boot/boot.S
和boot/main.c
。先看彙編檔案
# Start the CPU: switch to 32-bit protected mode, jump into C. # The BIOS loads this code from the first sector of the hard disk into # memory at physical address 0x7c00 and starts executing in real mode # with %cs=0 %ip=7c00. .set PROT_MODE_CSEG, 0x8 # kernel code segment selector .set PROT_MODE_DSEG, 0x10 # kernel data segment selector .set CR0_PE_ON, 0x1 # protected mode enable flag .globl start start: .code16 # Assemble for 16-bit mode cli # Disable interrupts cld # String operations increment
可以看到第一條被載入到0x7c00
處的程式碼應該是cli
關中斷指令。之後進行了部分暫存器的初始化
# Set up the important data segment registers (DS, ES, SS). xorw %ax,%ax # Segment number zero movw %ax,%ds # -> Data Segment movw %ax,%es # -> Extra Segment movw %ax,%ss # -> Stack Segment
然後是開啟A20
地址線。早期8086處理器,只有20根地址線,因此編碼時地址最高到0xfffff
之後便會歸零。然而在80286時期,地址線已經增長到24根,為了早期程式的相容性,A20
(第21根地址線)是預設關閉的。現在為了進入保護模式我們需要手動開啟這根線。開啟的方法是利用8042晶片的IO埠。
# Enable A20: # For backwards compatibility with the earliest PCs, physical # address line 20 is tied low, so that addresses higher than # 1MB wrap around to zero by default. This code undoes this. seta20.1: inb $0x64,%al # Wait for not busy testb $0x2,%al jnz seta20.1 movb $0xd1,%al # 0xd1 -> port 0x64 outb %al,$0x64 seta20.2: inb $0x64,%al # Wait for not busy testb $0x2,%al jnz seta20.2 movb $0xdf,%al # 0xdf -> port 0x60 outb %al,$0x60
首先了解一下8042的狀態暫存器
Bit7: PARITY-EVEN(P_E): 從鍵盤獲得的資料奇偶校驗錯誤
Bit6: RCV-TMOUT(R_T): 接收超時,置1
Bit5: TRANS_TMOUT(T_T): 傳送超時,置1
Bit4: KYBD_INH(K_I): 為1,鍵盤沒有被禁止。為0,鍵盤被禁止。
Bit3: CMD_DATA(C_D): 為1,輸入緩衝器中的內容為命令,為0,輸入緩衝器中的內容為資料。
Bit2: SYS_FLAG(S_F): 系統標誌,加電啟動置0,自檢通過後置1
Bit1: INPUT_BUF_FULL(I_B_F): 輸入緩衝器滿置1,i8042 取走後置0
BitO: OUT_BUF_FULL(O_B_F): 輸出緩衝器滿置1,CPU讀取後置0
然後便是埠地址,8042只使用兩個埠
0x64 :命令埠
0x60 :資料埠
然後便是一些相關的命令,這裡只選取和程式碼相關的:
驅動對鍵盤控制器傳送命令是通過寫埠64h實現:
D1h
準備寫Output埠。隨後通過60h埠寫入的位元組,會被放置在Output Port中。
關於向8042傳送命令前的準備工作:
向i8042發命令的方法,首先,讀取狀態暫存器,判斷bit1,狀態暫存器bit1為0,說明輸入緩衝器為空,可以寫入。保證狀態暫存器bit1為0,然後對64h埠進行寫操作,寫入命令。
那麼這段程式碼就很明顯了,至於0xdf
解釋如下
0xdd :disenable A20
0xdf :enable A20
至此A20
便開啟了。
下面是關於載入GDT
的程式碼
# Switch from real to protected mode, using a bootstrap GDT
# and segment translation that makes virtual addresses
# identical to their physical addresses, so that the
# effective memory map does not change during the switch.
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
#......
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
gdt:
SEG_NULL # null seg
SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg
SEG(STA_W, 0x0, 0xffffffff) # data seg
gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt
指令lgdt
裝載了gdt descriptor
,其中的0x17
是全域性描述符表的大小,一共裝載了3個段,每個段的大小是8位元組,因此24位元組。關於gdt
可以看下圖:
而對於這三個段的定義,需要結合#include <inc/mmu.h>
/*
* Macros to build GDT entries in assembly.
*/
#define SEG_NULL \
.word 0, 0; \
.byte 0, 0, 0, 0
#define SEG(type,base,lim) \
.word (((lim) >> 12) & 0xffff), ((base) & 0xffff); \
.byte (((base) >> 16) & 0xff), (0x90 | (type)), \
(0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)
//......
#define STA_X 0x8 // Executable segment
#define STA_E 0x4 // Expand down (non-executable segments)
#define STA_C 0x4 // Conforming code segment (executable only)
#define STA_W 0x2 // Writeable (non-executable segments)
#define STA_R 0x2 // Readable (executable segments)
#define STA_A 0x1 // Accessed
為了更好地理解,先看一下Intel
手冊中,關於保護模式下記憶體管理地描述(chapter3)。幾個值得注意的點,一個是關於GDTR
這就是ldgt
裝載地暫存器,前文的程式碼含義便很清楚了。
下圖是關於段的type
因此:
#define STA_X 0x8 // Executable segment
#define STA_W 0x2 // Writeable (non-executable segments)
#define STA_R 0x2 // Readable (executable segments)
所以(STA_X|STA_R)便是type(1010),即可執行可讀。
下圖是關於segment descriptor
有幾個域是我們感興趣的:
Segment limit field
Specifies the size of the segment. The processor puts together the two segment limit fields to form a 20-bit value.
Base address fields
Defines the location of byte 0 of the segment within the 4-GByte linear address space. The processor puts together the three base address fields to form a single 32-bit value. Segment base addresses should be aligned to 16-byte boundaries. Although 16-byte alignment is not required,this alignment allows programs to maximize performance by aligning code and data on 16-byte boundaries.
Type field
Indicates the segment or gate type and specifies the kinds of access that can be made to the segment and the direction of growth.
S (descriptor type) flag
Specifies whether the segment descriptor is for a system segment (S flag is clear) or a code or data segment (S flag is set).
DPL (descriptor privilege level) field
Specifies the privilege level of the segment. The privilege level can range from 0 to 3, with 0 being the most privileged level.
P (segment-present) flag
Indicates whether the segment is present in memory (set) or not present (clear).
D/B (default operation size/default stack pointer size and/or upper bound) flag
Performs different functions depending on whether the segment descriptor is an executable code segment, an expand-down data segment, or a stack segment. (This flag should always be set to 1 for 32-bit code and data segments and to 0 for 16-bit code and data segments.)
L (64-bit code segment) flag
In IA-32e mode, bit 21 of the second doubleword of the segment descriptor indicates whether a code segment contains native 64-bit code. A value of 1 indicates instructions in this code segment are executed in 64-bit mode. A value of 0 indicates the instructions in this code segment are executed in compatibility mode.
G (granularity) flag
Determines the scaling of the segment limit field. When the granularity flag is clear, the segment limit is interpreted in byte units; when flag is set, the segment limit is interpreted in 4-KByte units. (This flag does not affect the granularity of the base address; it is always byte granular.) When the granularity flag is set, the twelve least significant bits of an offset are not tested when checking the offset against the segment limit. For example, when the granularity flag is set, a limit of 0 results in valid offsets from 0 to 4095.
這之後我們再去看程式碼,便很清晰了,以
SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg
為例子,先解讀C語言的這段巨集
#define SEG(type,base,lim) \
.word (((lim) >> 12) & 0xffff), ((base) & 0xffff); \
.byte (((base) >> 16) & 0xff), (0x90 | (type)), \
(0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)
.word (((lim) >> 12) & 0xffff), ((base) & 0xffff)
獲得了界限的bit12-27
,共計16位。在(((lim) >> 28) & 0xf)
中獲得了最高4位。共計20位。因為要對映到完整的4G空間,也就是說,段限最大0xffffffff
。因為後面置位了段描述符的G位,因此以4KB為單元就擴充套件到了最大段限制4G。
(0x90 | (type)),(0xC0 | (((lim) >> 28) & 0xf))
這裡只需解釋:
0x90
P=1(在記憶體中) DPL=00(特權級0) S=1(程式碼段或者資料段)
0xC0
G=1(4KB單位解釋段限) D/B=1(32位程式碼應總是1) L=0 AVL=0
至此關於GDT
的這段故事完結。
之後
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
先看關於CR0
CR0.PE
Protection Enable (bit 0 of CR0) — Enables protected mode when set; enables real-address mode when clear. This flag does not enable paging directly. It only enables segment-level protection. To enable paging, both the PE and PG flags must be set.
所以PE
允許了保護模式的開啟,並且開啟了分段保護機制(沒開啟分頁PG),這也就是為什麼要在此之前建立GDT
。至此,已經進入了32位的保護模式,因此定址方式也已經發生變化。
先了解段選擇子
保護模式是通過“段選擇符+段內偏移”定址最終的線性地址或實體地址的。
TI位,選擇GDT
或者是LDT
因此
# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg
段選擇子就是$PROT_MODE_CSEG
也就是0x8
,即0000000000001 0 00
所以是GDT的第二項,基址為0x00000000
,因此與之前的CS一致。
# Set up the stack pointer and call into C.
movl $start, %esp
call bootmain
把棧頂設在了start
處,也就是0x7c00
,之後呼叫了C語言函式。
參考材料
[關於8042詳細的解讀]https://blog.csdn.net/wyyy2088511/article/details/108847079
[關於A20
地址線]https://docs.huihoo.com/gnu_linux/own_os/booting-a20_4.htm
[關於0xdf
]https://stackoverflow.com/questions/15768683/the-a20-line-with-jos
[關於A20
與程式設計]https://www.win.tue.nl/~aeb/linux/kbd/A20.html
[GDT wiki
]https://en.wikipedia.org/wiki/Global_Descriptor_Table
[lgdt
]https://www.jianshu.com/p/2cb94c4c0cd0
[ucore
]https://zhuanlan.zhihu.com/p/67259776
[CHAPTER 3 protected-mode]https://pdos.csail.mit.edu/6.828/2018/readings/ia32/IA32-3A.pdf
[ucore boot]https://www.cnblogs.com/maruixin/p/3175894.html
boot/main.c
先看一個比較簡單的函式
void waitdisk(void)
{
// wait for disk reaady
while ((inb(0x1F7) & 0xC0) != 0x40)
/* do nothing */;
}
埠1F7
在被讀的時候是作為狀態暫存器使用,其中bit_7=0表示控制器空閒,bit_6=1表示驅動器就緒。因此,waitdisk在控制器空閒和驅動器就緒同時成立時才會結束等待。即在0100 0000
時退出等待。
下面是一個讀取扇區的函式
void readsect(void *dst, uint32_t offset)
{
// wait for disk to be ready
waitdisk();
outb(0x1F2, 1); // count = 1
outb(0x1F3, offset);
outb(0x1F4, offset >> 8);
outb(0x1F5, offset >> 16);
outb(0x1F6, (offset >> 24) | 0xE0);
outb(0x1F7, 0x20); // cmd 0x20 - read sectors
// wait for disk to be ready
waitdisk();
// read a sector
insl(0x1F0, dst, SECTSIZE/4);
}
首先
outb(port,data): 向port寫入1位元組資料data
insl(port,addr,cnt) : 從port讀cnt個dword到addr中去
關於埠的含義,可以參考UCORE的實驗教材
因此,這個函式從磁碟上讀了SECTSIZE/4
dword的資料,也就是512位元組,剛好一個扇區。
下面一個函式,進行kernel的載入,一個輔助的函式是從磁碟讀到記憶體中特定的地址上。
// Read 'count' bytes at 'offset' from kernel into physical address 'pa'.
// Might copy more than asked
void
readseg(uint32_t pa, uint32_t count, uint32_t offset)
{
uint32_t end_pa;
end_pa = pa + count;
// round down to sector boundary
pa &= ~(SECTSIZE - 1);
// translate from bytes to sectors, and kernel starts at sector 1
offset = (offset / SECTSIZE) + 1;
// If this is too slow, we could read lots of sectors at a time.
// We'd write more to memory than asked, but it doesn't matter --
// we load in increasing order.
while (pa < end_pa) {
// Since we haven't enabled paging yet and we're using
// an identity segment mapping (see boot.S), we can
// use physical addresses directly. This won't be the
// case once JOS enables the MMU.
readsect((uint8_t*) pa, offset);
pa += SECTSIZE;
offset++;
}
}
在解釋bootmain
之前需要了解一下ELF
檔案格式
ELF檔案(Executable Linkable Format)是一種檔案儲存格式。Linux下的目標檔案和可執行檔案都按照該格式進行儲存。程式碼編譯後的指令放在程式碼段,全域性變數和區域性靜態變數放到資料段。檔案以一個“檔案頭”開始,記錄了整個檔案的屬性資訊。
以下是inc/elf.h
的部分
#define ELF_MAGIC 0x464C457FU /* "\x7FELF" in little endian */
struct Elf {
uint32_t e_magic; // must equal ELF_MAGIC
uint8_t e_elf[12];
uint16_t e_type;
uint16_t e_machine;
uint32_t e_version;
uint32_t e_entry; //程式入口的虛地址
uint32_t e_phoff; //program header表的位置偏移
uint32_t e_shoff;
uint32_t e_flags;
uint16_t e_ehsize;
uint16_t e_phentsize;
uint16_t e_phnum; //program header表中的入口數目
uint16_t e_shentsize;
uint16_t e_shnum;
uint16_t e_shstrndx;
};
//一個ELF檔案中分為好幾個段,程式段、資料段等
struct Proghdr {
uint32_t p_type; // 段型別
uint32_t p_offset; // 段相對檔案頭的偏移值
uint32_t p_va; // 段的第一個位元組將被放到記憶體中的虛擬地址
uint32_t p_pa;
uint32_t p_filesz;
uint32_t p_memsz; // 段在記憶體映像中佔用的位元組數
uint32_t p_flags;
uint32_t p_align;
};
下面是bootmain
的實現,關鍵部分給與註釋
void
bootmain(void)
{
//兩個program header指標
struct Proghdr *ph, *eph;
//磁碟第一個頁讀到記憶體位置0x10000的位置
//頁大小512*8=4kB 偏移量為0
// read 1st page off disk
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
// is this a valid ELF?
if (ELFHDR->e_magic != ELF_MAGIC)
goto bad;
// 程式頭表的頭指標,為ELF檔案的起始地址加上程式頭表的偏移量
// load each program segment (ignores ph flags)
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
// 程式頭表的尾指標,為ELF檔案的頭指標加上程式頭表的段數
eph = ph + ELFHDR->e_phnum;
for (; ph < eph; ph++)
//迴圈讀取ELF程式頭表中的每個段(程式碼段,資料段)到記憶體中
// p_pa is the load address of this segment (as well
// as the physical address)
// pa實體地址 memsz佔用的位元組 offset偏移
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
//至此核心就載入完成了,馬上移交控制權
// call the entry point from the ELF header
// note: does not return!
// 跳轉到核心程式的入口,CPU 控制權交給kernel,不再返回
// 核心程式碼會被載入到指定位置,都會以一個ELF格式開頭,以此可以得到入口地址
((void (*)(void)) (ELFHDR->e_entry))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
while (1)
/* do nothing */;
}
至此,bootloader的程式程式碼已經沒有祕密了,是一個很底層的過程,但對於瞭解計算機啟動的初始階段很有幫助。
下一篇文章,包括kernel部分與lab1的習題。
參考文章
[ports]https://bochs.sourceforge.io/techspec/PORTS.LST
[ucore ports]https://chyyuu.gitbooks.io/ucore_os_docs/content/lab1/lab1_3_2_3_dist_accessing.html
[insl]https://stackoverflow.com/questions/38410829/why-cant-find-the-insl-instruction-in-x86-document
[ucore elf]https://chyyuu.gitbooks.io/ucore_os_docs/content/lab1/lab1_3_2_4_elf.html