前序文章请看:
从裸机启动开始运行一个C++程序(九)
从裸机启动开始运行一个C++程序(八)
从裸机启动开始运行一个C++程序(七)
从裸机启动开始运行一个C++程序(六)
从裸机启动开始运行一个C++程序(五)
从裸机启动开始运行一个C++程序(四)
从裸机启动开始运行一个C++程序(三)
从裸机启动开始运行一个C++程序(二)
从裸机启动开始运行一个C++程序(一)
开始使用C语言
从这一节开始,我们就要研究如何将我们的Kernel与C语言联动了。大家先回忆一下之前我们提到的,汇编语言也并不是计算机可以直接识别的代码,必须要经过汇编器来进行翻译,变成计算机可以直接识别的机器指令才能够执行。
同理,C语言相比汇编是更上一层的语言了,更加不可能被计算机直接识别和执行,它也需要先被转换成机器指令才行。我们把将C语言源代码转换成机器指令的过程称为「编译(compile)」。
但C语言跟汇编语言还不太一样,毕竟汇编指令和机器指令有着一一对应的关系,因此汇编器的工作相对简单许多。而编译器就不能是做简单的映射了,而是要理解高级语义,然后输出成能够实现相同功能的机器指令。有关编译原理的详细内容不作为本文的重点,因此也就不过多介绍了。
用于编译的工具我们称之为「编译器(compiler)」,而C语言的编译器就是「C Compiler」,简写为「cc」。所以接下来,我们需要安装cc,市面上有很多版本的cc,这里我们为了方便起见,就使用GNU工具集中的gcc。下面分别介绍在Windows和MacOS上安装gcc的方法:
安装gcc
需要说明的一点是,无论是Mac还是Windows PC,都存在AMD64架构的和ARM架构的。Intel和AMD芯片是AMD64环境,我们在上面默认安装的gcc就是AMD64版本的。但对于ARM架构的Apple Silicon或者骁龙芯片的环境来说,默认安装的gcc是aarch-64版本的。
但因为咱们的工程(包括bochs环境)都是模拟AMD64架构的,因此aarch-64版本的gcc是不能编译AMD64指令的(当然也不能编译IA-32指令)。因此,对于ARM环境,我们不能使用默认的gcc,而是要使用专门编译AMD64指令的gcc,这个工具称为x86_64-elf-gcc
。其中的x86_64-elf
前缀就是针对AMD64架构的交叉编译环境,保证其输出是AMD64指令集的。当然,除了gcc
本身,其他的GNU工具集都有交叉编译版本,例如x86_64-elf-as
、x86_64-elf-ld
、x86_64-elf-objcopy
等。
当然,即便你本身就是AMD64环境,也同样可以安装x86_64-elf
前缀的工具,不影响使用的。另外一点就是对于macOS来说,默认的cc是clang
,并且为了兼容,它把所有gcc
命令都进行了映射,也就是说,我们直接输入gcc
其实是使用了clang
,所以会比较麻烦,但如果使用x86_64-elf-gcc
则不会出现这个问题。因此为了统一起见,后面的教程都以交叉编译环境为例,保证读者在所有的环境下都可用。
在macOS上安装gcc
Mac上我们同样是使用HomeBrew来完成安装。我们这里将需要用到的两个工具集一次性安装:
brew install x86_64-elf-gcc x86_64-elf-gdb
安装完成后可以通过以下命令验证:
x86_64-elf-gcc -v
在Windows上安装gcc
在Windows上我们需要通过MinGW工具来安装。打开MinGW的安装器,分别找到mingw32-gcc
和mingw32-gdb
,然后安装即可,详情可以查看前面安装make
工具的方法。
除了使用图形化工具以外,还可以通过控制台指令来安装:
mingw-get install gcc gdb
需要注意,即便是ARM架构的Windows,通过MinGW安装的工具也是AMD-64架构的,所以大家不用担心。
安装完毕后可以通过mingw32-gcc -v
来判断是否安装成功。为了跟本文的命令相匹配,这里建议大家把所有mingw32-
前缀都换成x86_64-elf-
前缀。当然,不改也可以,后面工程中出现的指令(包括makefile
中的指令)大家记得更换为对应名称即可。
C源码编译后
我们先来写一个简单的C程序,注意,此时的代码我们是要加载到Kernel里的,这是内核态的部分,还并没有任何OS来支持,所以所有的C语言库都是没法用的,是需要我们自己来实现的。因此,就先不用吧,空着跑一下:
// entry.c
void Entry() {
int a = 5;
int b = a;
}
如何把这个C源码加到我们的Kernel里呢?这是个问题,因为直接编译的话会单独出一个文件来,但咱肯定是要打包到a.img
里,并且还要在begin
里去call
才能调用到这里的。
那怎么办?别急,我们一步一步来。想想,如果能把C代码变成汇编的话,我们直接把汇编指令粘贴到Kernel中,是不是也可以实现诉求?虽然有点蠢,但是先试试吧。
用以下指令可以把C代码编译成汇编指令:
x86_64-elf-gcc -S -masm=intel -m32 -march=i386 entry.c -o entry.gas
解释一下上面的指令,x86_64-elf-gcc
是C编译器,-S
表示将其编译为汇编指令(而不是机器指令),-masm=intel
表示使用Inte形式l汇编(如果不指定的话,则会默认编译成AT&T形式汇编)。-m32
表示要编译为32位指令集(默认会编译为64位)。-march=i386
表示要编译为386指令(也就是IA-32指令)。
我们输出的结果是gas
格式,注意这里gas
不是气体的意思哈,这个词要分开读,g
就是GNU工具集的前缀,as
是assambly
的前两个字母,所以gas
就是「GNU的汇编格式」。
由于编译器版本和环境默认配置的不同,得到的gas
文件可能也存在区别,大家不用太在意,核心内容是大差不差的:
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 13, 0 sdk_version 14, 0
.intel_syntax noprefix
.globl _Entry ## -- Begin function Entry
.p2align 4, 0x90
_Entry: ## @Entry
.cfi_startproc
## %bb.0:
push ebp
.cfi_def_cfa_offset 8
.cfi_offset ebp, -8
mov ebp, esp
.cfi_def_cfa_register ebp
sub esp, 8
mov dword ptr [ebp - 4], 5
mov eax, dword ptr [ebp - 4]
mov dword ptr [ebp - 8], eax
add esp, 8
pop ebp
ret
.cfi_endproc
## -- End function
.subsections_via_symbols
我知道这一堆东西有点乱,因为是gas
,所以出现了很多gas
专用的伪指令语法,不过没关系,咱们将这些去掉,只看有用的指令部分:
_Entry:
push ebp
mov ebp, esp
sub esp, 8
mov dword ptr [ebp - 4], 5
mov eax, dword ptr [ebp - 4]
mov dword ptr [ebp - 8], eax
这样清晰很多,虽然中间出现了dword ptr
这种gas
语法,但相信大家应该能看得懂,我们也可以手动把他改写成nasm
汇编:
_Entry:
push ebp
mov ebp, esp
sub esp, 8
mov dword [ebp - 4], 5
mov eax, dword [ebp - 4]
mov dword [ebp - 8], eax
可以看到,C语言函数编译后,遵从了我们前面介绍的栈帧和现场记录规则。其中的ebp - 4
和ebp - 8
分别对应了局部变量a
和b
。把这玩意复制到我们的Kernel中,再在begin
里进行call _Entry
就好了吧。
可是,我们不可能真的每次都手动这样去复制汇编代码吧?还是要找到真正的构建工程的方法才行。
链接
所谓的链接,就是把多个文件组合起来的过程。举例来说,我们在entry.c
中实现了Entry()
函数,但是希望在kernel.nas
中调用,那么,就需要把这两个文件进行链接,成为一个完整的二进制。
就以前面的工程项目为例,我们在entry.c
中实现了Entry()
函数,那么首先,我们需要把entry.c
转换成待链接文件,这种文件格式通常以.o
结尾。它是一种中间态文件,并不能像二进制那样直接执行,同时也不能像源代码那样可视化阅读。在.o
文件中除了有这个文件的过程指令(比如Entry
函数编译成的机器指令)以外,还会有很多额外的信息,比如说这个文件中含有哪些标签,需要使用额外的哪些标签之类的。之后我们收集所有的.o
之后,再通过链接成为最终的二进制。
把C代码转换成.o
文件的指令如下:
x86_64-elf-gcc -c -m32 -march=i386 entry.c -o entry.o
注意这里的-c
参数,表示把源文件编译为待链接的文件。编译结束后我们得到了entry.o
。
那接下来的问题就是,如何把kernel.nas
也转换成.o
文件呢?我们前面一直都是直接把nas
转换成二进制的,但现在由于要和entry.o
进行联动,我们就不得不多一步,先把kernal.nas
转换成kernel.o
,然后再去参与链接。
因此,我们需要对kernel.nas
做一些改造,让它变得可链接。改造后的代码如下:
[bits 32]
section .text ; 这里要配合.o文件的要求,指定为.text段
begin:
mov ax, 00011_00_0b
mov ss, ax
mov eax, 0x1000
mov esp, eax
mov ebp, eax
extern Entry ; 声明外部含有一个Entry的标签,链接时会检测
call Entry
hlt
代码不长,但需要解释的地方还挺多的,我们一个一个来。
首先,要注意因为现在kernel.nas
不是直接变成二进制了,而是会参与链接,因此,以前文件末尾的times 1024-($-begin) db 0
是一定要去掉的,否则跳转后的位置指令会变成0x0000
。
其次,我们在文件首增加的section .text
是用于指定当前这个代码属于哪个分段,分段这个概念在单文件下没有什么作用,但是如果用于链接,那就需要指定给对应的段。这里由于我们要跟C语言联动,所以要配合C链接时的规范,因此这里要指定为.text
段。至于这个名称大家不用过于纠结,只是因为C语言这么规定了,我们配合就好。
最后,extern Entry
则表示,外部存在一个名为Entry
的标签,稍后链接的时候才会确定它具体表示什么地址。有了这样的声明以后就可以call Entry
了。
说到这里相信读者也能够明白,以链接模式来处理文件时,这些标签的地址都是不能确定的,只能暂时作为一个标记,后续所有.o
文件齐全的时候,「链接」过程才能确定这些标签表示的具体地址,同时如果有不存在的标签也会在这个阶段检测出并报错。
处理好源文件,我们就可以将它编译成.o
文件了(注意这里的措辞,我用了「编译」而不是「汇编」,因为此时已经不是简单的汇编转机器码这么简单的工作了),命令如下:
nasm kernel.nas -f elf -o kernel.o
其中-f elf
参数表示以链接方式处理。
现在我们已经收集齐了entry.o
和kernel.o
,可以进行链接了。由于kernel
这个名称目前已经代指kernel.nas
文件直接处理出的东西了,所以我们将这个步骤的输出重新命名为kernel_final
。链接过程需要用到的工具叫做「链接器(linker)」,输出的结果通常以.out
为后缀。指令如下:
x86_64-elf-ld -m elf_i386 kernel.o entry.o -o kernel_final.out
其中x86_64-elf-ld
是链接器工具,-m elf_i386
指定按IA-32架构方式进行处理。链接之后我们得到了kernel_final.out
。
有一个非常重要的点!,由于我们的MBR到Kernel的步骤是通过直接的jmp
跳转指令来的,MBR并没有参与链接,因此,我们必须保证,begin
这个标签正好是MBR的跳转位置。换句话说,在kernel_final.out
中,begin
必须是第一个过程,至于其他的过程,由于在内部都是通过call
跳转的,因此顺序无所谓。那么,如何保证begin
一定是被放到第一个呢?这取决于我们传给链接器的参数顺序,只要保证kernel.o
是第一个入参即可。后续工程可能会加入更多的.o
,但是一定要记住,kernel.o
必须是第一个!
其实此时的kernel_final.out
已经是完整的机器指令了,但这个格式是用于OS调度的,它含有很多环境和配置信息方便OS来处理。但此时咱们并不是要把它当应用程序来处理,而是作为内核使用的,所以我们还差最后一步,就是把里面核心的指令部分提取出来,去掉冗余信息,成为一个纯粹的内核程序二进制。对于.out
文件来说,内部结构仍然是分为很多个模块的,而我们只需要其中指令的那一部分,所以这里使用一个对象拷贝工具,来把其中的指令模块提取出来,命令如下:
x86_64-elf-objcopy -I elf32-i386 -S -R ".eh_frame" -R ".comment" -O binary kernel_final.out kernel_final.bin
其中x86_64-elf-objcopy
是对象提取工具。-I elf32-i386
表示用IA-32架构方式处理。-S
表示只提取其中的指令部分。-R ".eh_frame" -R ".comment"
则是除去其中不必要的数据段。binary
表示以纯粹二进制形式输出。最终我们可以得到kernel_final.bin
,这就是完整的内核二进制了。
试试运行
因为这一部分的命令突然变多了,所以,笔者整理了一份makefile
供大家参考(暂时没有用太多makefile技巧,写的比较LOW,后续会重新整理的):
.PHONY: all
all: sys
.PHONY: run
run: bochsrc sys
bochs -qf bochsrc
a.img:
rm -f a.img
bximage -q -func=create -hd=4096M $@
sys: a.img mbr.bin kernel_final.bin
dd if=mbr.bin of=a.img conv=notrunc
dd if=kernel_final.bin of=a.img bs=512 seek=1 conv=notrunc
mbr.bin: mbr.nas
nasm mbr.nas -o mbr.bin
kernel.o: kernel.nas
nasm kernel.nas -f elf -o kernel.o
entry.o: entry.c
x86_64-elf-gcc -c -m32 -march=i386 entry.c -o entry.o
kernel_final.out: kernel.o entry.o
x86_64-elf-ld -m elf_i386 kernel.o entry.o -o kernel_final.out
kernel_final.bin: kernel_final.out
x86_64-elf-objcopy -I elf32-i386 -S -R ".eh_frame" -R ".comment" -O binary kernel_final.out kernel_final.bin
.PHONY: clean
clean:
-rm -f .DS_Store
-rm -f *.bin
-rm -f *.img
-rm -f *.o
-rm -f *.out
让我们利用这个makefile
来构建并运行一下,看看程序能否正常进入Entry()
函数中。我们在0x8000
处打断点,然后逐条指令运行,就可以观察到进入Entry()
前后的情况:
大功告成!咱们已经成功从裸机启动开始,执行到一个C程序了!当然这仅仅是开始,我们还有很多细节要掌握的,比如如何在C语言中打印数据呢?后面章节会继续讨论的。
小结
本篇我们已经成功将C语言文件链接至Kernel,并运行成功了。后续我们会继续实现一些基本功能,还会讨论更多C语言的处理方式(例如全局变量、静态变量、指针等是如何处理的)。
本篇的示例工程项目会通过附件上传,请读者参考。
从裸机启动开始运行一个C++程序(十一)