从裸机启动开始运行一个C++程序(十)

前序文章请看:
从裸机启动开始运行一个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-asx86_64-elf-ldx86_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-gccmingw32-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工具集的前缀,asassambly的前两个字母,所以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 - 4ebp - 8分别对应了局部变量ab。把这玩意复制到我们的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.okernel.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语言的处理方式(例如全局变量、静态变量、指针等是如何处理的)。

本篇的示例工程项目会通过附件上传,请读者参考。

你可能感兴趣的:(开发语言,底层,x86)