手写简易操作系统(七)--加载操作系统内核

前情提要

上一节中,我们开启了内存分页,这一节中,我们将加载内核,内核是用C语言写的,C语言编译完了是一段ELF可加载程序,所以我们需要学会解析ELF格式文件,并将内核加载到内存

一、ELF格式

程序中最重要的部分就是段(segment)和节(section),它们是真正的程序体,是真真切切的程序资源,所以下面的说明咱们以它们为例。程序中有很多段,如代码段和数据段等,同样也有很多节,段是由节来组成的,多个节经过链接之后就被合并成一个段了。

ELF格式的作用体现在两方面,一是链接阶段,另一方面是运行阶段,故它们在文件中组织布局咱们也从这两方面展示。

image-20240313192716756

这部分比较权威的资料可以看 /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数组功能为

image-20240313193422025

e_type表示elf目标文件类型

elf目标文件类型取值意义
ET_NONE0位置目标文件类型
ET_REL1可重复定位文件
ET_EXEC2可执行文件
ET_DYN3动态共享目标文件
ET_CORE4core文件,即程序崩溃时其内存映像的转储格式
ET_LOPROC0xff00特定处理器文件的扩展下边界
ET_HIPROC0xffff特定处理器文件的扩展上边界

e_machine表明elf文件在何种硬件平台上才能运行

elf体系结构类型取值意义
EM_NONE0未指定
EM_M321AT&T WE 32100
EM_SPARC2SPARC
EM_3863Intel 80386
EM_68K4Motorola 68000
EM_88K5Motorola 88000
EM_8607Intel 80860
EM_MIPS8MIPS 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 用来指明程序中该段的类型,其值为

image-20240313194726947

p_offset 用来指明本段在文件内的起始偏移字节。

p_vaddr 用来指明本段在内存中的起始虚拟地址。

p_paddr 仅用于与物理地址相关的系统中,System V忽略用户程序中所有的物理地址,此项暂时保留。

p_filesz 用来指明本段在文件中的大小。

p_memsz 用来指明本段在内存中的大小。

p_flags 用来指明与本段相关的标志,此标志取值范围见下表

image-20240313194951012

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;
}

加了一点内联汇编,为了能够在屏幕上输出字符。

可以看一下仿真结果

image-20240313212441097

结束语

今天我们终于进入到内核的编写了,非常的艰辛,前期的准备工作异常的多,希望大家没有厌倦,我已经将这个代码上传到了github,地址为https://github.com/lyajpunov/os.git
有一些程序,因为会零零散散的,所以我建议直接看github上的代码。想要看哪一节的直接 git log 看历史记录。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

LyaJpunov

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值