【自制操作系统06】终于开始用 C 语言了,第一行内核代码!
闪客sun 人气:0一、整理下到目前为止的流程图
写到这,终于才把一些苦力活都干完了,也终于到了我们的内核代码部分,也终于开始第一次用 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
我将关键部分提取出来,有助于你鸟瞰本讲的全部代码要做的事。本段代码实际上就做了这么几个事:
- 将硬盘第 9 扇区开始后的 200 个扇区的内容(包括 kernel.bin),复制到内存 0x70000 开始的地方
- call kernel_init 调用了一下这个方法,这个方法干嘛之后再说,也是重点
- 栈指针赋值为 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 私信我您的联系方式。
课程规划
本课程打算出系列课程,我写到哪觉得可以写成一篇文章了就写出来分享给大家,最终会完成一个功能全面的操作系统,我觉得这是最好的学习操作系统的方式了。所以中间遇到的各种坎也会写进去,如果你能持续跟进,跟着我一块写,必然会有很好的收货。即使没有,交个朋友也是好的哈哈。
目前的系列包括
- 【自制操作系统01】硬核讲解计算机的启动过程
- 【自制操作系统02】环境准备与启动区实现
- 【自制操作系统03】读取硬盘中的数据
- 【自制操作系统04】从实模式到保护模式
- 【自制操作系统05】开启内存分页机制。
加载全部内容