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

2023-10-19 16:21

本文主要是介绍从裸机启动开始运行一个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, -8mov	ebp, esp.cfi_def_cfa_register ebpsub	esp, 8mov	dword ptr [ebp - 4], 5mov	eax, dword ptr [ebp - 4]mov	dword ptr [ebp - 8], eaxadd	esp, 8pop	ebpret.cfi_endproc## -- End function
.subsections_via_symbols

我知道这一堆东西有点乱,因为是gas,所以出现了很多gas专用的伪指令语法,不过没关系,咱们将这些去掉,只看有用的指令部分:

_Entry: push ebpmov	ebp, espsub	esp, 8mov	dword ptr [ebp - 4], 5mov	eax, dword ptr [ebp - 4]mov	dword ptr [ebp - 8], eax

这样清晰很多,虽然中间出现了dword ptr这种gas语法,但相信大家应该能看得懂,我们也可以手动把他改写成nasm汇编:

_Entry: push ebpmov	ebp, espsub	esp, 8mov	dword [ebp - 4], 5mov	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, axmov eax, 0x1000mov esp, eax    mov ebp, eax    extern Entry ; 声明外部含有一个Entry的标签,链接时会检测call Entryhlt

代码不长,但需要解释的地方还挺多的,我们一个一个来。

首先,要注意因为现在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 sysbochs -qf bochsrca.img:rm -f a.imgbximage -q -func=create -hd=4096M $@sys: a.img mbr.bin kernel_final.bindd if=mbr.bin of=a.img conv=notruncdd if=kernel_final.bin of=a.img bs=512 seek=1 conv=notruncmbr.bin: mbr.nasnasm mbr.nas -o mbr.binkernel.o: kernel.nasnasm kernel.nas -f elf -o kernel.oentry.o: entry.cx86_64-elf-gcc -c -m32 -march=i386 entry.c -o entry.okernel_final.out: kernel.o entry.ox86_64-elf-ld -m elf_i386 kernel.o entry.o -o kernel_final.outkernel_final.bin: kernel_final.outx86_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()前后的情况:
成功进入Entry

大功告成!咱们已经成功从裸机启动开始,执行到一个C程序了!当然这仅仅是开始,我们还有很多细节要掌握的,比如如何在C语言中打印数据呢?后面章节会继续讨论的。

小结

本篇我们已经成功将C语言文件链接至Kernel,并运行成功了。后续我们会继续实现一些基本功能,还会讨论更多C语言的处理方式(例如全局变量、静态变量、指针等是如何处理的)。

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

这篇关于从裸机启动开始运行一个C++程序(十)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/240973

相关文章

【C++ Primer Plus习题】13.4

大家好,这里是国中之林! ❥前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站。有兴趣的可以点点进去看看← 问题: 解答: main.cpp #include <iostream>#include "port.h"int main() {Port p1;Port p2("Abc", "Bcc", 30);std::cout <<

MySQL数据库宕机,启动不起来,教你一招搞定!

作者介绍:老苏,10余年DBA工作运维经验,擅长Oracle、MySQL、PG、Mongodb数据库运维(如安装迁移,性能优化、故障应急处理等)公众号:老苏畅谈运维欢迎关注本人公众号,更多精彩与您分享。 MySQL数据库宕机,数据页损坏问题,启动不起来,该如何排查和解决,本文将为你说明具体的排查过程。 查看MySQL error日志 查看 MySQL error日志,排查哪个表(表空间

C++包装器

包装器 在 C++ 中,“包装器”通常指的是一种设计模式或编程技巧,用于封装其他代码或对象,使其更易于使用、管理或扩展。包装器的概念在编程中非常普遍,可以用于函数、类、库等多个方面。下面是几个常见的 “包装器” 类型: 1. 函数包装器 函数包装器用于封装一个或多个函数,使其接口更统一或更便于调用。例如,std::function 是一个通用的函数包装器,它可以存储任意可调用对象(函数、函数

C++11第三弹:lambda表达式 | 新的类功能 | 模板的可变参数

🌈个人主页: 南桥几晴秋 🌈C++专栏: 南桥谈C++ 🌈C语言专栏: C语言学习系列 🌈Linux学习专栏: 南桥谈Linux 🌈数据结构学习专栏: 数据结构杂谈 🌈数据库学习专栏: 南桥谈MySQL 🌈Qt学习专栏: 南桥谈Qt 🌈菜鸡代码练习: 练习随想记录 🌈git学习: 南桥谈Git 🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈�

JAVA智听未来一站式有声阅读平台听书系统小程序源码

智听未来,一站式有声阅读平台听书系统 🌟&nbsp;开篇:遇见未来,从“智听”开始 在这个快节奏的时代,你是否渴望在忙碌的间隙,找到一片属于自己的宁静角落?是否梦想着能随时随地,沉浸在知识的海洋,或是故事的奇幻世界里?今天,就让我带你一起探索“智听未来”——这一站式有声阅读平台听书系统,它正悄悄改变着我们的阅读方式,让未来触手可及! 📚&nbsp;第一站:海量资源,应有尽有 走进“智听

【C++】_list常用方法解析及模拟实现

相信自己的力量,只要对自己始终保持信心,尽自己最大努力去完成任何事,就算事情最终结果是失败了,努力了也不留遗憾。💓💓💓 目录   ✨说在前面 🍋知识点一:什么是list? •🌰1.list的定义 •🌰2.list的基本特性 •🌰3.常用接口介绍 🍋知识点二:list常用接口 •🌰1.默认成员函数 🔥构造函数(⭐) 🔥析构函数 •🌰2.list对象

springboot3打包成war包,用tomcat8启动

1、在pom中,将打包类型改为war <packaging>war</packaging> 2、pom中排除SpringBoot内置的Tomcat容器并添加Tomcat依赖,用于编译和测试,         *依赖时一定设置 scope 为 provided (相当于 tomcat 依赖只在本地运行和测试的时候有效,         打包的时候会排除这个依赖)<scope>provided

06 C++Lambda表达式

lambda表达式的定义 没有显式模版形参的lambda表达式 [捕获] 前属性 (形参列表) 说明符 异常 后属性 尾随类型 约束 {函数体} 有显式模版形参的lambda表达式 [捕获] <模版形参> 模版约束 前属性 (形参列表) 说明符 异常 后属性 尾随类型 约束 {函数体} 含义 捕获:包含零个或者多个捕获符的逗号分隔列表 模板形参:用于泛型lambda提供个模板形参的名

内核启动时减少log的方式

内核引导选项 内核引导选项大体上可以分为两类:一类与设备无关、另一类与设备有关。与设备有关的引导选项多如牛毛,需要你自己阅读内核中的相应驱动程序源码以获取其能够接受的引导选项。比如,如果你想知道可以向 AHA1542 SCSI 驱动程序传递哪些引导选项,那么就查看 drivers/scsi/aha1542.c 文件,一般在前面 100 行注释里就可以找到所接受的引导选项说明。大多数选项是通过"_

6.1.数据结构-c/c++堆详解下篇(堆排序,TopK问题)

上篇:6.1.数据结构-c/c++模拟实现堆上篇(向下,上调整算法,建堆,增删数据)-CSDN博客 本章重点 1.使用堆来完成堆排序 2.使用堆解决TopK问题 目录 一.堆排序 1.1 思路 1.2 代码 1.3 简单测试 二.TopK问题 2.1 思路(求最小): 2.2 C语言代码(手写堆) 2.3 C++代码(使用优先级队列 priority_queue)