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

前序文章请看:
从裸机启动开始运行一个C++程序(十五)
从裸机启动开始运行一个C++程序(十四)
从裸机启动开始运行一个C++程序(十三)
从裸机启动开始运行一个C++程序(十二)
从裸机启动开始运行一个C++程序(十一)
从裸机启动开始运行一个C++程序(十)
从裸机启动开始运行一个C++程序(九)
从裸机启动开始运行一个C++程序(八)
从裸机启动开始运行一个C++程序(七)
从裸机启动开始运行一个C++程序(六)
从裸机启动开始运行一个C++程序(五)
从裸机启动开始运行一个C++程序(四)
从裸机启动开始运行一个C++程序(三)
从裸机启动开始运行一个C++程序(二)
从裸机启动开始运行一个C++程序(一)

终章

本系列文章已经接近尾声,最后的这一章还是照例来进行总结、归纳、答疑、填坑和讲故事。

回答序章的问题

在前言序章的时候,笔者曾经提过一些被问到的问题:

  1. 空指针到底能不能访问?(int *p = nullptr; *p = 5;)
  2. 给一个变量取地址,取到的是不是物理地址?(int a; std::cout << &a;)
  3. 操作一个常数地址是否合法?(*(int *)0xa0000 = 0x41;
  4. 全局变量、静态局部变量、字符串字面量等在内存中是如何布局的?
  5. C/C++程序如何编译为内核代码,运行在内核态程序上?
  6. gdb过程中,看到的寄存器是否是真实的?

现在到了解答的时候了,有了从裸机启动一直到运行C++程序的这个经历,想来再回头看这几个问题一定会有不一样的视角。

空指针能不能访问?

这个问题其实应该分成两个维度来回答。首先我们要搞明白「空指针」和「0值指针」的关系。

从C++语言设计哲学上来说,「空指针」是有独立语义的,就是没有指向任何地址的指针。而「0值指针」则是指向地址为0x0的指针。这二者是有本质区别的。

只不过在编译器实现的时候,考虑到0x0地址通常是已经被占用的地址,因此将它作为空地址的符号。如果不这样做的话,就必须要用额外的标识来表示空值。大家可以参考std::optional,比如说下面二者就是不等价的:

std::optional<int> a = std::nullopt; // 空值
std::optional<int> b = 0;			 // 0值

因此,按语言设计这个维度来说,空指针确实应当是不可访问的,但是0值可以。而实际上,由于编译器把0值作为了空值,因此,即便我们用空值来赋值指针,其实也被转换为了0值:

int *p = nullptr; // 空值语义,但实际上会转换为0值
int *p = reinterpret_cast<int *>(0);

但是,如果编译器开启了优化,那么对于空值则可能会有一些UB上的优化,而0值则一定会作为普通指针。关于这一点感兴趣的读者可以参考《C++中那些你不知道的未定义行为》

那么回到问题本身,语义上的空指针不可访问,但0值指针(包括被编译器实现为0值的指针)是可以访问的,0在这里其实是段偏移地址。而至于这个地址到底能不能正常访问取决于端配置,只要权限是匹配的,就可以访问(参考我们前面用显存段写输出的那个例子,显存段的0号偏移地址就是一个可操作的地址)。

给一个变量取地址,取到的是不是物理地址?

这个答案也很明确了,不一定是物理地址。我们取到的其实是段偏移地址。

对于没有分页的系统来说,段基址+段偏移地址就是物理地址。而对于有分页的系统来说,段基址+段偏移地址得到线性虚拟地址,再通过页表转换后才可以得到物理地址。

操作一个常数地址是否合法?

这个问题我们在前面例程中得到了非常充分的验证,直接操作一个常数地址是完全没有问题的,这个常数就是段偏移地址。

这件事之所以反直觉,主要是我们平时编写应用程序的时候,你对该程序将来会被OS分配到哪个段、哪个页、哪片地址是不清楚的,所以常数地址大概率是踩到了对当前程序无权限的部分,因而被OS拦截。

但如果是内核程序,并且在合法的段内操作合法的地址,那常数地址完全没有问题。

全局变量、静态局部变量、字符串字面量等在内存中是如何布局的?

这个问题我们在初次链接C程序的时候踩过坑,相信大家也印象深刻了。

全局变量、静态变量和字符串字面量其实都在程序的.Text段中,但会独立于指令单独存放。程序中会使用这个位置相对于ds的段偏移地址。当需要读取和操作的时候,也是通过ds加这个地址来找到对应内存位置的。

C/C++程序如何编译为内核代码,运行在内核态程序上?

这个问题我们也全流程体验了一遍,首先要把C/C++文件编译为elf格式的文件。同时,要把用于引导的汇编指令也编译成elf格式,然后和前面的elf进行链接,并且保证汇编头在最前面的位置,保证二进制装载后的首地址可控。

链接后的文件再用obj-copy提取出中间核心的部分,就可以作为内核态指令加载到内存中了。只要能够保证MBR初始化时能够把这部分指令加载进内存中,并做正确的跳转,就能正常执行这部分内核代码。

gdb过程中,看到的寄存器是否是真实的?

gdb调试是调试应用程序的手段,对于我们编写内核程序肯定是无能为力的。但我们体验过内核程序以后应该能够知道,应用程序运行和内核运行的唯一区别在内存,也就是内核需要先给应用程序来分配内存页,然后再去加载应用程序。

然而,执行应用程序的时候,各种内存偏移可能会跟实际物理情况不符,但寄存器是不受影响的,至少在AMD64体系中,不存在虚拟寄存器的说法。因此,应用程序运行中寄存器一定是真实的,但内存地址并不是实际的物理地址。

所以我们通过gdb或者lldb等调试工具来运行程序时,获取到的这些寄存器的值,至少在应用程序实际执行到这个位置时的情况一定是相符的。

额外的思考

高级语言和汇编语言的区别

在前面章节我们做64位改造的时候,就有一个非常经典且有趣的事情。就是我们要对进入IA-32e模式后的所有汇编指令进行改造,包括asm_func.nas,但C语言源码却是可以一个字符都不变。

C语言代码适配的方式是取改编译指令,比如说--m64这些。换句话说,高级语言本身是不假定它的运行硬件架构的,这个转换工作由编译器完成。同一份代码,你可以把它编译成任意架构(只要编译器支持)的机器指令。

而汇编则不同,汇编原本就是机器指令的另一种展示,所以它强依赖于运行环境,硬件不支持的指令你想都不用想,肯定没有汇编。就类似于mov es, 0x80这种语法就是错误的,因为没有对应的机器码。我们必须用mov ax, 0x80mov es, ax两条指令来完成。当然,如果切换了运行架构,那么所有的汇编指令也都要做对应的改造。

C语言适合写内核而C++不适合

这一个说法相信读者也能在很多场合听过,笔者也是,但事实上必须要自己亲自体验一遍才能深刻理解。

我们在链接C语言后,发现这件事情没什么太大压力,咱们把想要的功能用C语言写出来,然后丢给编译器就好了,内核就能正常执行。但是换成C++后,就会发现编译、链接阶段都会出现很多依赖问题,我们就不得不去研究ABI,实现一些额外的事情才能让代码正常构建。

换句话说,C语言可以脱离C的标准库、链接库等来独立使用,我们一个独立的C源码,不依赖任何额外由OS提供的功能,也可以轻松构建,所以用它来写内核就很容易。

但C++不行,C++默认你OS已经提供了很多功能的实现,我们前面体验了ABI确实导致的链接问题,其实还远不止这些,有的地方C++还对STL有依赖,比如说std::initializer_list,如果你不去单独实现的话,编译都无法通过。

所以,我们才看到很多大佬都理所应当使用C来编写内核,而不是C++。同时也正是因为C++的这种黏性太强,让一些大佬对C++嗤之以鼻,尽管它提供了很多方便的功能是C没有的,但还是由于门槛和成本太高,在内核介不受欢迎。(注意,只是说「不适合」,但绝对不是「不可以」。)

新时代里C语言确实已经开始有点力不从心了,但编写内核这样的需求确实一直存在,并且逐步扩大的。这种场景下也诞生了一些新的语言,更适合写内核,但同时提供了很多C不具备的功能,修复了很多C的缺陷。比如说Rust语言,同样值得大家来学习研究。

向下兼容

笔者在前面章节也提到过,计算机启动的过程像是计算机硬件发展的历史流程的缩影,类似于生物在母体发育的过程像是生物进化的历史流程的缩影。

对于AMD-64架构的设备来说,开机时就是一个8086,后面再通过一些配置,逐渐变成286、386,再到64位模式。出现这种情况的原因就是它始终保持了向下兼容,才能在不断迭代进化中生存下去。

如果大家用过一些比较有名的开源库的话,也会发现很多类似的情况,比如说某个API的名称,明显不合理(比如说用了一个跟实际意义完全不符的名称,或者参数类型明显不对)的情况。开发者早都发现了它,但通常并不会直接删除这个错误的API,而是额外添加一个正确的API,并保留原本的API,然后在内部做一个适配转发,同时会在API文档中标注deprecated以及应当使用的API名。

原因也很简单,就是为了兼容。尽管你用了一个错误的API,但可能很多项目已经依赖了它,并把它用在了正确的地方。那这个时候你的「修错」动作反而可能会造成更大的后果,因此选择保留。

但,这样持续迭代下去,会导致历史包袱越来越大,慢慢阻碍后面的发展,因此,我们还得在一定时间段去逐渐下线它。例如C++标准中,有一些旧的语法,虽然不会在决定下线它的时候马上就直接删除,但再过几个版本可能就会了。所以我们可能会看到一些「计划废弃」和「废弃」的语法或API,以及类似于「C++14中标记为计划废弃,不建议使用;C++20中正是废弃」之类的描述。

无独有偶,Intel公布的x86S就是在做这样的事情,毕竟这个年代了,8086确实考虑可以考虑退出历史舞台了。

心得体会

到了讲故事的环节啦!笔者想聊一聊写这篇文章的心路历程。

其实能从裸机运行开始就把控它,让它运行自己写的程序,这件事一直以来都是笔者的一个心愿,我相信也是很多计算机爱好者共同的心愿。因为我们接触电脑肯定不是从裸机开始接触的,我们印象里都是开机后等待OS加载,OS好了我们再打开要用的功能。包括学编程的我们,写出来的程序也是在特定OS上运行的。这就让人非常好奇,OS本身到底是怎么运行的,电脑开机以后都做了什么。

为了能搞清楚这个问题,我查过非常多的资料,阅读多很多相当厉害的书籍教程。笔者一开始研究这件事情,是因为有一本书,日本作者川合秀実(かわいひだみ)编写的《30日でできる!OS自作入門》,由浅入深讲解了如何从裸机启动,到运行一个简单的操作系统。按照这本书的方法,我们甚至可以写出一个带鼠标操作,可以执行多任务应用程序的简易操作系统。笔者当初托人从日本带回了日语原版的来研究,收获非常大。本篇文章中使用的字体文件,就是参考这本书中来编写的,在此万分感谢这本书提供的宝贵的素材和知识。

但这本教程有一个问题,就是他着重讲解的是,基于C语言,如何实现操作系统的基本功能,而对于汇编的部分,以及汇编和C联合构建的部分确实没有做讲解的。作者直接在这本书的附送光盘里,把所有构件用的工具都附带进去了,还很贴心地提供了bat文件,我们改完代码,直接双击就可以完成构建。同时,他使用了作者自己优化过的汇编工具——nask,但这玩意这本书独一份的,找不到迭代版本也找不到详细的文档资料。但也正是因为这个,勾起了笔者很大的胃口,因为中间这部分能力是缺失的,对我来说是黑盒,我不知道这些工具做了什么,也不知道这些工具是川合さん自己提供的,还是业界通用的工具。

另一方面,这本书由于年代久远,它讲解的只有32位环境,也就是基于IA-32架构的,同时,提供的构建工具也只支持Windows系统。

所以,笔者只能再去查阅其他资料,来解决以下这几个问题:

  1. 进入内核之前要做哪些配置,哪些是必须的,哪些是可选的?
  2. C程序怎么跟汇编程序链接,怎么从汇编指令跳转到C函数?
  3. 怎么进入64位模式,64位模式下C程序的编译跟32位的有哪些不同?

于是,笔者带着这些问题,继续去寻找资料。先是找到了一本由李忠、王小波、余洁编写的《x86汇编语言:从实模式到保护模式》,这是笔者发现的第二本宝藏教程。这本书中也是通过编写模拟内核的方式来讲解汇编语言的,并不是像大多数汇编教材那样,是编写用户态的应用程序。因而笔者从这本书中学到了很多裸机启动时的事情,包括8086实模式要做的事情、进入保护模式要做的GDT的配置、进入保护模式的方法、系统分页的方法等等。可以说,这本书已经非常好地讲解了如何在IA-32架构下启动和编写内核。而且他用了nasmbochs这些业界通用的工具,并且跨平台,我在Mac上也能很方便地学习和实验。

但这只解决我的第一个疑问,后面的两个怎么办呢?比如如何让C跟汇编去链接。《x86汇编语言》里没有使用C语言,而《OS自作入門》里是用自带工具来做链接的,内部黑盒。无奈,我只能继续寻找其他资料。于是,我找到了田宇的《一个64位操作系统的设计与实现》。这本书与《OS自作入門》类似,重点是在操作系统原理和实现的部分,但它却用了nasm编写汇编头,并且使用gccld等GNU工具作为构建工具,因此,笔者主要攻克的是这本书给出的makefile。不过虽然找到了编译参数,但书中并没有对这些参数的用法和意义做详细的介绍,只是给出了这个构建命令而已。

但这没关系,这已经离我的目标更进一步了,我着重去查相关参数的说明文档,这就包括了GNU的官方说明文档,以及很多散落在各个社区、论坛的一些零星的讨论。终于拼凑出了完整的,将汇编和C源码链接,生成二进制的方法。但是后续实验时又发现,链接后的文件指令顺序不符合预期,因为MBR跳转后并没有到Kernel的部分,而是直接跳到了C构建出的某个函数里。在经历了多次尝试之后终于发现,这个顺序跟链接时传入的参数顺序一致,所以要把Kernel头放在第一个参数。

搞定了链接问题,最后就是64位模式。虽然在《一个64位操作系统的设计与实现》中确实就是一个64位的操作系统了,但前期从实模式一直进入IA32-e模式的配置这部分介绍得又非常少,只能看到对应的代码但对这部分的知识确是缺失的,于是为了补齐这部分内容,笔者又找到了邓志著的《x86/x64体系探索与编程》,才看到了从实模式/保护模式进入IA-32e的方法以及需要的配置。但其实这个过程也并不顺利,就比如这本书中配置页表的时候,单纯用了4行代码就完成了,但并没有详细介绍4级页表结构的设计方法,以及如何控制页的大小(4KB或2MB或1GB),笔者也去网上搜索了大量资料。

因此,最后能梳理出从裸机启动到内核C++程序的完整过程,绝非单一某个资料或教程就可以领悟。这个过程中最大的几点体会就是:

  1. 领域内详细资料很多,但跨领域穿线的资料少之又少。前面介绍的几本书中,都对它本身主题的内容有非常详细和深入的介绍,但单看某一本这整件事情都是无法串起来的。
  2. 不要指望能搜到某一个资料是正好100%命中自己的需求的。要通过各种资料理解和变通,最后再组装成自己要的知识。
  3. 实践是检验真理的唯一标准。任何渠道获取到的内容都可能是不对的,或者说,你的理解和作者想表达的意思很有可能出现偏差,因此不能轻信任何一条结论,一定要通过自己动手实验去验证。

经常有人问笔者「你都是从哪学会这些知识的呀?」「你是看哪本书学的XXX知识呀?」「能不能给我推荐一些教程呀?」这里也是希望通过这个例子来告诉读者上面3点体会,获取知识的途径其实会经历很多坎坷的,一定不要做伸手党,指望有唾手可得的知识,而是要通过自己的努力不断获取。

最终,笔者前后用了差不多半年的时间,边学习边实验边输出,整理出这篇文章。这篇文章最主要的作用就是穿线,而不是对每一个领域做深入。因此,虽然最后我们运行了内核态程序,但其实离一个真正可用的OS内核还相差甚远。但这也正是笔者的目的,笔者希望尽可能用细线来穿,减少多余的东西。比如说IDP(中断配置表)就没有进行讲解和配置,因为即便没有这个环节,我们也能运行内核态64位程序,所以笔者希望大家可以不受这个东西的干扰。

当然,如果读者对中间某个环节感兴趣,也可以很方便地继续深入研究,因为沿着这个维度的资料是很丰富的,各个领域都有更加深入和更加详细的资料供大家学习。本文旨在填补「穿线引导」这个环节的空缺,读者可以将本文作为学习底层原理的一个引子,来增加知识广度和延展性,然后再向自己感兴趣的领域继续深入学习。

【完结】

  • 14
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

borehole打洞哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值