前情提要
上一节中,我们开启了内存分页,这一节中,我们将加载内核,内核是用C语言写的,C语言编译完了是一段ELF可加载程序,所以我们需要学会解析ELF格式文件,并将内核加载到内存
一、ELF格式
程序中最重要的部分就是段(segment)和节(section),它们是真正的程序体,是真真切切的程序资源,所以下面的说明咱们以它们为例。程序中有很多段,如代码段和数据段等,同样也有很多节,段是由节来组成的,多个节经过链接之后就被合并成一个段了。
ELF格式的作用体现在两方面,一是链接阶段,另一方面是运行阶段,故它们在文件中组织布局咱们也从这两方面展示。
这部分比较权威的资料可以看 /usr/include/elf.h
中这个文件
首先我们看ELF Header
1.1、ELF header
这个头是我从 /usr/include/elf.h
中节选的
typedef uint16_t Elf32_Half;
typedef uint32_t Elf32_Word;
typedef uint32_t Elf32_Addr;
typedef uint32_t Elf32_Off;
typedef struct
{
unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
Elf32_Half e_type; /* Object file type */
Elf32_Half e_machine; /* Architecture */
Elf32_Word e_version; /* Object file version */
Elf32_Addr e_entry; /* Entry point virtual address */
Elf32_Off e_phoff; /* Program header table file offset */
Elf32_Off e_shoff; /* Section header table file offset */
Elf32_Word e_flags; /* Processor-specific flags */
Elf32_Half e_ehsize; /* ELF header size in bytes */
Elf32_Half e_phentsize; /* Program header table entry size */
Elf32_Half e_phnum; /* Program header table entry count */
Elf32_Half e_shentsize; /* Section header table entry size */
Elf32_Half e_shnum; /* Section header table entry count */
Elf32_Half e_shstrndx; /* Section header string table index */
} Elf32_Ehdr;
其中 e_ident数组功能为
e_type表示elf目标文件类型
elf目标文件类型 | 取值 | 意义 |
---|---|---|
ET_NONE | 0 | 位置目标文件类型 |
ET_REL | 1 | 可重复定位文件 |
ET_EXEC | 2 | 可执行文件 |
ET_DYN | 3 | 动态共享目标文件 |
ET_CORE | 4 | core文件,即程序崩溃时其内存映像的转储格式 |
ET_LOPROC | 0xff00 | 特定处理器文件的扩展下边界 |
ET_HIPROC | 0xffff | 特定处理器文件的扩展上边界 |
e_machine表明elf文件在何种硬件平台上才能运行
elf体系结构类型 | 取值 | 意义 |
---|---|---|
EM_NONE | 0 | 未指定 |
EM_M32 | 1 | AT&T WE 32100 |
EM_SPARC | 2 | SPARC |
EM_386 | 3 | Intel 80386 |
EM_68K | 4 | Motorola 68000 |
EM_88K | 5 | Motorola 88000 |
EM_860 | 7 | Intel 80860 |
EM_MIPS | 8 | MIPS RS3000 |
e_version 用来表示版本信息。
e_entry 用来指明操作系统运行该程序时,将控制权转交到的虚拟地址。
e_phoff 用来指明程序头表(program header table)在文件内的字节偏移量。如果没有程序头表,该值为0。
e_shoff 用来指明节头表(section header table)在文件内的字节偏移量。若没有节头表,该值为0。
e_flags 用来指明与处理器相关的标志
e_ehsize 用来指明elf header的字节大小。
e_phentsize 用来指明程序头表(program header table)中每个条目(entry)的字节大小,即每个用来描述段信息的数据结构的字节大小,该结构是后面要介绍的struct Elf32_Phdr。
e_phnum 用来指明程序头表中条目的数量。实际上就是段的个数。
e_shentsize 用来指明节头表(section header table)中每个条目(entry)的字节大小,即每个用来描述节信息的数据结构的字节大小。
e_shnum 用来指明节头表中条目的数量。实际上就是节的个数。
e_shstrndx 用来指明string name table在节头表中的索引index。
1.2、ELF Phdr
接下来再给大家介绍下程序头表中的条目的数据结构,这是用来描述各个段的信息用的,其结构名为struct Elf32_Phdr。struct Elf32_Phdr结构的功能类似GDT中段描述符的作用,段描述符用来描述物理内存中的一个内存段,而struct Elf32_Phdr是用来描述位于磁盘上的程序中的一个段,它被加载到内存后才属于GDT中段描述符所指向的内存段的子集。
typedef struct
{
Elf32_Word p_type; /* Segment type */
Elf32_Off p_offset; /* Segment file offset */
Elf32_Addr p_vaddr; /* Segment virtual address */
Elf32_Addr p_paddr; /* Segment physical address */
Elf32_Word p_filesz; /* Segment size in file */
Elf32_Word p_memsz; /* Segment size in memory */
Elf32_Word p_flags; /* Segment flags */
Elf32_Word p_align; /* Segment alignment */
} Elf32_Phdr;
p_type 用来指明程序中该段的类型,其值为
p_offset 用来指明本段在文件内的起始偏移字节。
p_vaddr 用来指明本段在内存中的起始虚拟地址。
p_paddr 仅用于与物理地址相关的系统中,System V忽略用户程序中所有的物理地址,此项暂时保留。
p_filesz 用来指明本段在文件中的大小。
p_memsz 用来指明本段在内存中的大小。
p_flags 用来指明与本段相关的标志,此标志取值范围见下表
p_align 用来指明本段在文件和内存中的对齐方式。如果值为0或1,则表示不对齐。否则p_align应该是2的幂次数。
二、编写内核
哈哈哈,下面的程序就是一个内核
// os/src/kernel/main.c
int main(void) {
while (1) ;
return 0;
}
这里只是将其当做进入C程序的跳板,首先编译
# 编译main.c
# -m32 编译为32位程序
gcc -m32 -c -o devel/main.o src/kernel/main.c
链接
# 链接
# -melf_i386 链接为elf_i386类型
# -Ttext 0xc0001500 指定入口地址
# -e main 指定入口函数
ld -melf_i386 -Ttext 0xc0001500 -e main devel/main.o -o bin/kernel.bin
我们查看一下编译好的kernel.bin
yj@ubuntu:~/os$ readelf -e bin/kernel.bin
ELF 头:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
类别: ELF32
数据: 2 补码,小端序 (little endian)
Version: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
类型: EXEC (可执行文件)
系统架构: Intel 80386
版本: 0x1
入口点地址: 0xc0001500
程序头起点: 52 (bytes into file)
Start of section headers: 8636 (bytes into file)
标志: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 7
Size of section headers: 40 (bytes)
Number of section headers: 9
Section header string table index: 8
节头:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .note.gnu.propert NOTE 08048114 000114 00001c 00 A 0 0 4
[ 2] .text PROGBITS c0001500 000500 000017 00 AX 0 0 1
[ 3] .eh_frame PROGBITS c0002000 001000 000048 00 A 0 0 4
[ 4] .got.plt PROGBITS c0004000 002000 00000c 04 WA 0 0 4
[ 5] .comment PROGBITS 00000000 00200c 00002b 01 MS 0 0 1
[ 6] .symtab SYMTAB 00000000 002038 0000e0 10 7 9 4
[ 7] .strtab STRTAB 00000000 002118 000051 00 0 0 1
[ 8] .shstrtab STRTAB 00000000 002169 000050 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
p (processor specific)
程序头:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x08048000 0x08048000 0x00130 0x00130 R 0x1000
LOAD 0x000500 0xc0001500 0xc0001500 0x00017 0x00017 R E 0x1000
LOAD 0x001000 0xc0002000 0xc0002000 0x00048 0x00048 R 0x1000
LOAD 0x002000 0xc0004000 0xc0004000 0x0000c 0x0000c RW 0x1000
NOTE 0x000114 0x08048114 0x08048114 0x0001c 0x0001c R 0x4
GNU_PROPERTY 0x000114 0x08048114 0x08048114 0x0001c 0x0001c R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10
Section to Segment mapping:
段节...
00 .note.gnu.property
01 .text
02 .eh_frame
03 .got.plt
04 .note.gnu.property
05 .note.gnu.property
06
三、将内核载入内存
上面的ELF文件格式其实已经解释了一个程序是由什么构成的,下面我们就要解释如何加载一段程序了
1、将硬盘中的程序加载进内存,这一步我们加载到了KERNEL_BIN_BASE_ADDR这个地址
2、分析程序的ELF文件头,找到e_phnum有几个segment需要加载,找到e_phoff第一个segment在文件中的偏移量
3、根据e_phoff找到第一个program header,根据里面的内容将相应的程序加载到对应的虚拟内存中
4、由于program header是连续的,所以,上一个segment加载完了就可以找下一个program header,再加载一段segment
loader.s
需要加三个函数
; 将kernel.bin的segment拷贝到编译地址
kernel_init:
xor eax, eax
xor ebx, ebx ;ebx记录程序头表地址
xor ecx, ecx ;cx记录程序头表中的program header数量
xor edx, edx ;dx 记录program header尺寸,即e_phentsize
mov dx, [KERNEL_BIN_BASE_ADDR + 42] ; 偏移文件42字节处的属性是e_phentsize,表示program header大小
mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ; 偏移文件开始部分28字节的地方是e_phoff,表示第1个program header在文件中的偏移量
; 其实该值是0x34,不过还是谨慎一点,这里来读取实际值
add ebx, KERNEL_BIN_BASE_ADDR
mov cx, [KERNEL_BIN_BASE_ADDR + 44] ; 偏移文件开始部分44字节的地方是e_phnum,表示有几个program header
.each_segment:
cmp byte [ebx + 0], PT_NULL ; 若p_type等于 PT_NULL,说明此program header未使用。
je .PTNULL
push dword [ebx + 16] ; program header中偏移16字节的地方是p_filesz,压入函数memcpy的第三个参数:size
mov eax, [ebx + 4] ; 距程序头偏移量为4字节的位置是p_offset
add eax, KERNEL_BIN_BASE_ADDR ; 加上kernel.bin被加载到的物理地址,eax为该段的物理地址
push eax ; 压入函数memcpy的第二个参数:源地址
push dword [ebx + 8] ; 压入函数memcpy的第一个参数:目的地址,偏移程序头8字节的位置是p_vaddr,这就是目的地址
call mem_cpy ; 调用mem_cpy完成段复制
add esp,12 ; 清理栈中压入的三个参数
.PTNULL:
add ebx, edx ; edx为program header大小,即e_phentsize,在此ebx指向下一个program header
loop .each_segment
ret
; 诸字节拷贝 mem_cpy(dst,src,size)
mem_cpy:
cld
push ebp
mov ebp, esp
push ecx ; rep指令用到了ecx,但ecx对于外层段的循环还有用,故先入栈备份
mov edi, [ebp + 8] ; dst
mov esi, [ebp + 12] ; src
mov ecx, [ebp + 16] ; size
rep movsb ; 逐字节拷贝
;恢复环境
pop ecx
pop ebp
ret
; 读取硬盘
rd_disk_m_32:
; eax=LBA扇区号
; ebx=将数据写入的内存地址
; ecx=读入的扇区数
mov esi,eax ; 备份eax
mov di,cx ; 备份扇区数到di
;第1步:设置要读取的扇区数
mov dx,0x1f2
mov al,cl
out dx,al ;读取的扇区数
mov eax,esi ;恢复ax
;第2步:将LBA地址存入0x1f3 ~ 0x1f6
;LBA地址7~0位写入端口0x1f3
mov dx,0x1f3
out dx,al
;LBA地址15~8位写入端口0x1f4
mov cl,8
shr eax,cl
mov dx,0x1f4
out dx,al
;LBA地址23~16位写入端口0x1f5
shr eax,cl
mov dx,0x1f5
out dx,al
shr eax,cl
and al,0x0f ; lba第24~27位
or al,0xe0 ; 设置7~4位为1110,表示lba模式
mov dx,0x1f6
out dx,al
;第3步:向0x1f7端口写入读命令,0x20
mov dx,0x1f7
mov al,0x20
out dx,al
;第4步:检测硬盘状态
.not_ready: ; 测试0x1f7端口(status寄存器)的的BSY位
nop
in al,dx
and al,0x88 ; 第4位为1表示硬盘控制器已准备好数据传输,第7位为1表示硬盘忙
cmp al,0x08
jnz .not_ready ; 若未准备好,继续等。
;第5步:从0x1f0端口读数据
mov ax, di ; 以下从硬盘端口读数据用insw指令更快捷,不过尽可能多的演示命令使用,
; 在此先用这种方法,在后面内容会用到insw和outsw等
mov dx, 256 ; di为要读取的扇区数,一个扇区有512字节,每次读入一个字,共需di*512/2次,所以di*256
mul dx
mov cx, ax
mov dx, 0x1f0
.go_on_read:
in ax,dx
mov [ebx], ax
add ebx, 2
; 由于在实模式下偏移地址为16位,所以用bx只会访问到0~FFFFh的偏移。
; loader的栈指针为0x900,bx为指向的数据输出缓冲区,且为16位,
; 超过0xffff后,bx部分会从0开始,所以当要读取的扇区数过大,待写入的地址超过bx的范围时,
; 从硬盘上读出的数据会把0x0000~0xffff的覆盖,
; 造成栈被破坏,所以ret返回时,返回地址被破坏了,已经不是之前正确的地址,
; 故程序出会错,不知道会跑到哪里去。
; 所以改为ebx代替bx指向缓冲区,这样生成的机器码前面会有0x66和0x67来反转。
; 0X66用于反转默认的操作数大小! 0X67用于反转默认的寻址方式.
; cpu处于16位模式时,会理所当然的认为操作数和寻址都是16位,处于32位模式时,
; 也会认为要执行的指令是32位.
; 32位模式下用32字节的操作数)时,编译器会在指令前帮我们加上0x66或0x67,
; 临时改变当前cpu模式到另外的模式下.
; 假设当前运行在16位模式,遇到0X66时,操作数大小变为32位.
; 假设当前运行在32位模式,遇到0X66时,操作数大小变为16位.
; 假设当前运行在16位模式,遇到0X67时,寻址方式变为32位寻址
; 假设当前运行在32位模式,遇到0X67时,寻址方式变为16位寻址.
loop .go_on_read
ret
详细的代码可以看github https://github.com/lyajpunov/os.git
为了看一下是否进入到了内核,我们可以修改一下内核
// os/src/kernel/main.c
int main(void) {
__asm__ __volatile__ ("movb $'M', %%gs:480" : : : "memory");
__asm__ __volatile__ ("movb $'A', %%gs:482" : : : "memory");
__asm__ __volatile__ ("movb $'I', %%gs:484" : : : "memory");
__asm__ __volatile__ ("movb $'N', %%gs:486" : : : "memory");
while (1) ;
return 0;
}
加了一点内联汇编,为了能够在屏幕上输出字符。
可以看一下仿真结果
结束语
今天我们终于进入到内核的编写了,非常的艰辛,前期的准备工作异常的多,希望大家没有厌倦,我已经将这个代码上传到了github,地址为https://github.com/lyajpunov/os.git。
有一些程序,因为会零零散散的,所以我建议直接看github上的代码。想要看哪一节的直接 git log
看历史记录。