【自制操作系统06】终于开始用 C 语言了,第一行内核代码!

一、整理下到目前为止的流程图

写到这,终于才把一些苦力活都干完了,也终于到了我们的内核代码部分,也终于开始第一次用 c 语言写代码了!为了这个阶段性的胜利,以及更好地进入内核部分,下图贴一张到目前为止的流程图。(其中黄色部分是今天准备做的事情)

二、先上代码

loader.asm

...
;加载kernel
mov eax,0x9        ;kernel.bin所在的扇区号 0x9
mov ebx,0x70000    ;写入的内存地址 0x70000
mov ecx,200        ;读入的扇区数
call rd_disk_m_32
...

;进入内核
call kernel_init

mov byte [gs:0x280],'i'
mov byte [gs:0x282],'n'
mov byte [gs:0x284],'i'
mov byte [gs:0x286],'t'
mov byte [gs:0x28a],'k'
mov byte [gs:0x28c],'e'
mov byte [gs:0x28e],'r'
mov byte [gs:0x290],'n'
mov byte [gs:0x292],'e'
mov byte [gs:0x294],'l'

mov esp,0xc009f000
jmp 0xc0001500

; 将kernel.bin中的segment拷贝到编译的地址
kernel_init:
	xor eax,eax
	xor ebx,ebx	;记录程序头表地址(内核地址+程序头表偏移地址)
	xor ecx,ecx	;记录程序头中的数量
	xor edx,edx	;记录程序头表中每个条目的字节大小
	
	mov dx,[0x70000+42]	;偏移文件42字节处是e_phentsize
	mov ebx,[0x70000+28]	;偏移文件28字节处是e_phoff
	add ebx,0x70000
	mov cx,[0x70000+44]	;偏移文件44字节处是e_phnum
	
.each_segment:
	cmp byte [ebx+0],0	;p_type=0,说明此头未使用
	je .PTNULL
	
	push dword [ebx+16]	;p_filesz压入栈(mem_cpy第三个参数)
	mov eax,[ebx+4]
	add eax,0x70000
	push eax		;p_offset+内核地址=段地址(mem_cpy第二个参数)
	push dword [ebx+8]	;p_vaddr(mem_cpy第一个参数)
	call mem_cpy
	add esp,12
.PTNULL:
	add ebx,edx	;ebx指向下一个程序头
	loop .each_segment
	ret
	
;主子拷贝函数(dst,src,size)
mem_cpy:
	cld
	push ebp
	mov ebp,esp
	push 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:

    mov esi, eax
    mov di, cx

    mov dx, 0x1f2
    mov al, cl
    out dx, al

    mov eax, esi
    ; 保存LBA地址
    mov dx, 0x1f3
    out dx, al

    mov cl, 8
    shr eax, cl
    mov dx, 0x1f4
    out dx, al

    shr eax, cl
    mov dx, 0x1f5
    out dx, al

    shr eax, cl
    and al, 0x0f
    or al, 0xe0
    mov dx, 0x1f6
    out dx, al

    mov dx, 0x1f7
    mov al, 0x20
    out dx, al

.not_ready:
    nop
    in al, dx
    and al, 0x88
    cmp al, 0x08
    jnz .not_ready

    mov ax, di
    mov dx, 256
    mul dx
    mov cx, ax
    mov dx, 0x1f0

.go_on_read:
    in ax, dx
    mov [ds:ebx], ax
    add ebx, 2
    loop .go_on_read
    ret

main.c

#include "print.h"
int main(void){
	put_str("put_str finish\n");
	while(1);
	return 0;
}

print.h

#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"
void put_char(uint8_t char_asci);
void put_str(char* message);
#endif

print.asm

TI_GDT equ 0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003<<3)+TI_GDT+RPL0

[bits 32]
section .text

global put_str
put_str:
	push ebx
	push ecx
	xor ecx,ecx
	mov ebx,[esp+12]
.goon:
	mov cl,[ebx]
	cmp cl,0
	jz .str_over
	push ecx
	call put_char
	add esp,4
	inc ebx
	jmp .goon
.str_over:
	pop ecx
	pop ebx
	ret

global put_char
put_char:
	pushad
	;保证gs中为正确到视频段选择子
	mov ax,SELECTOR_VIDEO
	mov gs,ax
	
	;获取当前光标位置
	;获得高8位
	mov dx,0x03d4	;索引寄存器
	mov al,0x0e
	out dx,al
	mov dx,0x03d5
	in al,dx
	mov ah,al
	
	;获得低8位
	mov dx,0x03d4
	mov al,0x0f
	out dx,al
	mov dx,0x03d5
	in al,dx
	
	;将光标存入bx
	mov bx,ax
	
	mov ecx,[esp+36]
	cmp cl,0xd
	jz .is_carriage_return
	cmp cl,0xa
	jz .is_line_feed
	
	cmp cl,0x8
	jz .is_backspace
	jmp .put_other
	
.is_backspace:
	dec bx
	shl bx,1
	mov byte [gs:bx],0x20
	inc bx
	mov byte [gs:bx],0x07
	shr bx,1
	jmp .set_cursor
	
.put_other:
	shl bx,1
	mov [gs:bx],cl
	inc bx
	mov byte [gs:bx],0x07
	shr bx,1
	inc bx
	cmp bx,2000
	jl .set_cursor
	
.is_line_feed:
.is_carriage_return:
;cr(\r),只要把光标移到首行就行了
	xor dx,dx
	mov ax,bx
	mov si,80
	div si
	sub bx,dx
	
.is_carriage_return_end:
	add bx,80
	cmp bx,2000
.is_line_feed_end:
	jl .set_cursor
	
.roll_screen:
	cld
	mov ecx,960
	mov esi,0xc00b80a0	;第1行行首
	mov edi,0xc00b8000	;第0行行首
	rep movsd
	
	;最后一行填充为空白
	mov ebx,3840
	mov ecx,80
.cls:
	mov word [gs:ebx],0x0720
	add ebx,2
	loop .cls
	mov bx,1920	;最后一行行首
	
.set_cursor:
;将光标设为bx值
	;设置高8位
	mov dx,0x03d4
	mov al,0x0e
	out dx,al
	mov dx,0x03d5
	mov al,bh
	out dx,al
	
	;再设置低8位
	mov dx,0x03d4
	mov al,0x0f
	out dx,al
	mov dx,0x03d5
	mov al,bl
	out dx,al
.put_char_done:
	popad
	ret

Makefile

mbr.bin: mbr.asm
	nasm -I include/ -o out/mbr.bin mbr.asm -l out/mbr.lst
	
loader.bin: loader.asm
	nasm -I include/ -o out/loader.bin loader.asm -l out/loader.lst
	
kernel.bin: kernel/main.c
	nasm -f elf -o out/print.o lib/kernel/print.asm
	gcc -I lib/kernel/ -c -o out/main.o kernel/main.c
	ld -Ttext 0xc0001500 -e main -o out/kernel.bin out/main.o out/print.o
	
os.raw: mbr.bin loader.bin kernel.bin
	../bochs/bin/bximage -hd -mode="flat" -size=60 -q target/os.raw
	dd if=out/mbr.bin of=target/os.raw bs=512 count=1
	dd if=out/loader.bin of=target/os.raw bs=512 count=4 seek=2
	dd if=out/kernel.bin of=target/os.raw bs=512 count=200 seek=9
	
brun:
	make install
	make only-bochs-run

only-bochs-run:
	../bochs/bin/bochs -f ../bochs/bochsrc.disk -q
	
install:
	make clean
	make -r os.raw

三、鸟瞰代码

;加载kernel
mov eax,0x9        ;kernel.bin所在的扇区号 0x9
mov ebx,0x70000    ;写入的内存地址 0x70000
mov ecx,200        ;读入的扇区数
call rd_disk_m_32
;进入内核
call kernel_init
mov esp,0xc009f000
jmp 0xc0001500

我将关键部分提取出来,有助于你鸟瞰本讲的全部代码要做的事。本段代码实际上就做了这么几个事:

  1. 将硬盘第 9 扇区开始后的 200 个扇区的内容(包括 kernel.bin),复制到内存 0x70000 开始的地方
  2. call kernel_init 调用了一下这个方法,这个方法干嘛之后再说,也是重点
  3. 栈指针赋值为 0xc009f000,并跳转到 0xc0001500 开始执行

有一点有些不符合我们的直觉,既然 kernel.bin 被写入内存第 0x70000 位置了,按照我们之前一跳二跳三跳的写法,应该直接跳转到 0x70000,可为什么是 0xc0001500 呢?

下面直接解答这个问题,

kernel.bin 是用 c 语言 写好之后编译出来的产物,不像之前我们都是直接汇编语言 .asm 编译成 .bin。c 语言在 linux 的 gcc 工具编译后的二进制文件,是一个格式为 ELF 的文件,并不完全是从头到尾都是可执行的机器指令。

这个格式里肯定有某个地方指出,指令代码在什么位置(相对文件开始的偏移量),并且要求加载这种格式文件的程序(kernel_init),将指令代码放在内存中的什么位置(0xc0001500)。

如果是这样的话,整个流程就说通了,kernel_init 只是将 kernel.bin 这个 ELF 格式的文件里的关键信息提取出来,最重要的就是加载到内存中的什么位置这个信息,然后执行相应的处理操作。

那接下来,我们就该详细看看,ELF 格式究竟是什么?

四、详解 ELF 格式

ELF:1999 年,被 86open 项目选为 x86 架构上的类 Unix 操作系统的二进制文件标准格式,用来取代 COFF,也是 Linux 的主要可执行文件格式

为什么要有这种格式呢?其实没有这种格式也是完全可以的,但我们用户写的应用程序,是独立与操作系统之外的。换句话说,就是需要操作系统这个 主应用程序,去调用那些用户写出来的 应用程序。如果没有一种特定的格式当然也可以,那就让操作系统约定俗成一个内存地址来存放用户的应用程序,这样应用程序也不能将自己的程序分成一段一段的。所以有个格式,至少是只有好处没有坏处。

刚刚只提到了可执行文件,生成可执行文件之前还要经历一个重定位文件的过程,链接之后才是可执行文件。重定位文件可执行文件都可以用 ELF 格式来表示,该格式有一个统一的,下面分成好多个和好多个,多个节通过链接变成一个段,具体格式如下图。

ELF 格式鸟瞰

ELF 格式具体定义

先定义下数据类型方便后续描述

数据类型 字节大小
Elf32_Half 无符号整数(2)
Elf32_Word 无符号整数(4)
Elf32_Addr 程序运行地址(4)
Elf32_Off 文件偏移量(4)

ELF 头

数据类型 名称 字节 含义 例子
unsigned char e_ident[16] 16 0-3魔数 4类型 5大小端 6版本 7-15保留零
Elf32_Half e_type 2 文件类型:0未知 1可重定位 2可执行 3动态共享目标 4core 0x0002
Elf32_Half e_machine 2 处理器结构:0未知 3Intel80386 8MIPSRS3000 0x0003
Elf32_Word e_version 4 版本 0x00000001
Elf32_Addr e_entry 4 用来指明操作系统运行该程序时,将控制权转交到的虚拟地址 0xc0001500
Elf32_Off e_phoff 4 程序头表(program header table)在文件内的字节偏移量。没有为0 0x00000034
Elf32_Off e_shoff 4 节头表(section header table)在文件内的字节偏移量。没有为0 0x0000055c
Elf32_Word e_flags 4 与处理器相关标志 0x00000000
Elf32_Half e_enhsize 2 elf header的字节大小 0x0034
Elf32_Half e_phentsize 2 程序头表(program header table)中每个条目(entry)的字节大小 0x0020
Elf32_Half e_phnum 2 程序头表中条目的数量。实际上就是段的个数 0x0002
Elf32_Half e_shentsize 2 节头表(section header table)中每个条目(entry)的字节大小 0x0028
Elf32_Half e_shnum 2 程序头表中条目的数量。实际上就是节的个数 0x0006
Elf32_Half e_shstmdx 2 用来指明string name table在节头表中的索引index 0x0003

程序头表

数据类型 名称 字节 含义 例子
Elf32_Word p_type 4 段的类型:1可加载的程序段 2动态连接信息 3动态加载器名称 0x00000001
Elf32_Off p_offset 4 本段在文件内的起始偏移字节 0x00000000
Elf32_Addr p_vaddr 4 本段在内存中的起始虚拟地址 0xc0001000
Elf32_Addr p_paddr 4 物理地址相关,保留,未设定 0xc0001000
Elf32_Word p_filesz 4 本段在文件中的大小 0x0000060b
Elf32_Word p_memsz 4 本段在内存中的大小 0x0000060b
Elf32_Word p_flags 4 标志 1可执行 2可写 4可读 0x00000005
Elf32_Word p_align 4 对其方式 0不对齐 2的幂次对齐 0x00001000

其实不用想得多复杂,就是一个格式而已,程序中需要哪个数据,就根据偏移量把它取出来用就可以了,实际上我们的程序就是这么做的。

来看一下 kernel.bin 的具体内容

7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
02 00 03 00 01 00 00 00 [00 15 00 c0] [34 00 00 00]
64 06 00 00 00 00 00 00 34 00 [20 00] [02 00] 28 00
06 00 03 00 01 00 00 00 [00 00 00 00] [00 10 00 c0]
00 10 00 c0 [0b 06 00 00] 0b 06 00 00 05 00 00 00
00 10 00 00 51 e5 74 64 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 07 00 00 00
04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
...

按照上述的 ELF 格式表一一对应看,便能知道全部信息,其中我们本次代码中用到的,都用加粗了。我们拿 ELF 文件查看器工具看一下(不是必须的)

代码中的 kernel_init 就是将 ELF 格式文件中的 程序头表地址程序头中的数量程序头表中每个条目的字节大小加载到的内存地址 取出,然后执行相应的拷贝操作。

kernel_init:
	xor eax,eax
	xor ebx,ebx	;记录程序头表地址(内核地址+程序头表偏移地址)
	xor ecx,ecx	;记录程序头中的数量
	xor edx,edx	;记录程序头表中每个条目的字节大小
	
	mov dx,[0x70000+42]	;偏移文件42字节处是e_phentsize
	mov ebx,[0x70000+28]	;偏移文件28字节处是e_phoff
	add ebx,0x70000
	mov cx,[0x70000+44]	;偏移文件44字节处是e_phnum
	
.each_segment:
	cmp byte [ebx+0],0	;p_type=0,说明此头未使用
	je .PTNULL
	
	push dword [ebx+16]	;p_filesz压入栈(mem_cpy第三个参数)
	mov eax,[ebx+4]
	add eax,0x70000
	push eax		;p_offset+内核地址=段地址(mem_cpy第二个参数)
	push dword [ebx+8]	;p_vaddr(mem_cpy第一个参数)
	call mem_cpy
	add esp,12
.PTNULL:
	add ebx,edx	;ebx指向下一个程序头
	loop .each_segment
	ret

五、c 语言和汇编语言相互调用

本章讲述了 ELF 格式的可执行文件,还讲述了如何加载一个 ELF 可执行文件,并跳转到相应的地址去执行。

本章还隐含讲述了汇编语言如何调用 c 语言(约定好跳转地址,以及传参方式),以及 C 语言如何调用汇编语言。

c 语言调用汇编

print.asm

global put_str
put_str:
    ...
    ret

main.c

#include "print.h"
int main(void){
	put_str();
	return 0;
}

print.h

#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
void put_str();
#endif

写在最后:开源项目和课程规划

如果你对自制一个操作系统感兴趣,不妨跟随这个系列课程看下去,甚至加入我们(下方有公众号和小助手微信),一起来开发。

参考书籍

《操作系统真相还原》这本书真的赞!强烈推荐

项目开源

项目开源地址:https://gitee.com/sunym1993/flashos

当你看到该文章时,代码可能已经比文章中的又多写了一些部分了。你可以通过提交记录历史来查看历史的代码,我会慢慢梳理提交历史以及项目说明文档,争取给每一课都准备一个可执行的代码。当然文章中的代码也是全的,采用复制粘贴的方式也是完全可以的。

如果你有兴趣加入这个自制操作系统的大军,也可以在留言区留下您的联系方式,或者在 gitee 私信我您的联系方式。

课程规划

本课程打算出系列课程,我写到哪觉得可以写成一篇文章了就写出来分享给大家,最终会完成一个功能全面的操作系统,我觉得这是最好的学习操作系统的方式了。所以中间遇到的各种坎也会写进去,如果你能持续跟进,跟着我一块写,必然会有很好的收货。即使没有,交个朋友也是好的哈哈。

目前的系列包括

posted @ 2020-02-02 14:30  闪客sun  阅读(4948)  评论(6编辑  收藏  举报