您现在的位置是:首页 >学无止境 >从裸机启动开始运行一个C++程序(二)网站首页学无止境

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

borehole打洞哥 2024-06-17 10:43:20
简介从裸机启动开始运行一个C++程序(二)

先序文章请看:
从裸机启动开始运行一个C++程序(一)

运行在8086上的第一个程序

既然硬件环境已经就绪了,那接下来,就要想办法让它运行我们的程序了。不过在此之前,我们必须要了解一下8086的主要架构,以及执行程序的方式。

为什么要了解8086

话说,我们不是要研究AMD64架构嘛,干嘛要扯这几十年前的这款胡子都老白了的这款CPU爷爷呢?其实我们在前面介绍AMD64历史的时候就提到过,IA-32也好,AMD64也好,它本质上并不是完全新的架构,而是保持着向下兼容的。

一方面来说,IA-32和AMD64都是从8086模式开始启动的,在开机的那一瞬间,你的电脑其实就是8086,然后再通过一些配置,切换到286模式、386模式、AMD64模式等等的。因此,要想在AMD64架构的裸机开始加载程序,8086的工作方式我们是避不开的。

另一方面来说,从IA-32和AMD64架构中来看,其实它还是有很浓重的8086风格,主干框架并没有大的变动,因此,了解了8086以后,自然而然也就了解了AMD64的其中一部分了。

因此,我们有必要在那些额外扩展的环节之前,先来了解一下8086。

8086体系架构

我们要了解8086体系的计算机中的几大硬件,它们是:

  1. CPU
  2. 内存
  3. BIOS
  4. 硬盘
  5. 显卡
  6. I/O设备

CPU是核心,我们放后面来讲,先讲讲内存、硬盘(外存)和显卡。

内存和外存

「内存」这个词感觉在近年来,已经被移动设备行业的术语给“污染”了。因为我们常说的「手机内存」其实指的并不是计算机领域术语中的「内存」。

内存,全称「内部存储器」,英文名称是「Internal Memory」,又被称为「主存」。之所以叫「内」,这也是有历史原因的。因为早年,内存并不是一个独立的硬件,而是直接将内存颗粒焊死在主板上的。

所以,以这个核心的元器件作为边界,在「里面」的存储器就叫内存,然后在这个体系外部的就叫做了「外存」。

还有一个原因在于,内存是可以直接和CPU交互的,而外存则不可以,它必须通过I/O接口,将数据先通过内存,然后才可以被CPU处理。

内存一般使用的是电路方式存储,比如说由晶体管组成的双稳态电路,通过电路的电压来表示比特位的信号。这种存储方式的优点就是读写速度会很快(毕竟是电路实现),而缺点就是,依赖持续的电力。换句话说,如果断电了,数据就会丢失,重新上电以后,里面的数据是什么是不一定的(随缘,非常的薛定谔),得重新写入以后才会可用。

所以,移动设备行业里所谓的「手机内存」,指的显然不是这个意义上的内存。这其实也是划界的问题,因为手机内存中的「内」是相对于SD卡而言的,手机里自带的存储就叫了个「内存」。但计算机专业领域中的「内存」则是体系结构的内部。(后来也是因为手机内存这个称呼已经有了,再想提及手机里真正意义上的「内存」的时候,又不得不加定语,叫了个「运行内存」。所以用计算机专业领域的概念来说,「手机内存」其实是「外存」,「手机运行内存」才是「内存」)。

我们再来说说外存。外存自然就是前面说的那一套之外的存储设备咯,像是早期的软盘。你想想啊,机器里其实只有一个软驱的,要用的时候,把软盘插到软驱里,再来读取数据。所以,这个「软盘」不就是「计算机外部」的存储设备吗?这样解释可能更容易被接受。

当然,像是硬盘、光盘、U盘等等这些,也都属于外存,虽然硬盘一般是放在机箱里面的,不会频繁插拔,但不影响它在体系结构中的角色。

外存一般用非电路方式存储,像是软盘、机械硬盘采用的就是磁性存储,通过磁头去感应某一个位置磁粉的N极或S极来识别比特位。而光盘则是采用光返性质存储,驱动器来识别某一位置的反光性来识别比特位。再像是U盘(闪存盘)、固态硬盘这些则是用浮栅层来存储,通过栅格中的电子数来识别这一位置的比特位数据。

既然是非电路方式,那么它就不怕掉电,数据将会更长久地保存。不过相对地,它的读写速度就会慢很多。

显卡

显卡,全称「显示适配器」,英文是「Graphics Adapter」。顾名思义,就是用来把信号变成画面,呈现在显示器上的硬件。

在早期,显卡的作用仅仅是用来做信号转换,在内存当中会分配一片专属区域,供显卡来使用。显卡就是不断地读取这片内存区域的数据,然后把它按照一定的协议方式,转换成显示器上的图像。当需要变换显示的东西的时候,CPU就会改写这片内存空间,这样在下一帧的时候,显卡就会按照对应的要求,变换显示的图像。

在这套体系当中,图形的处理完全是由CPU来承担的,而用于显示输出的数据,也是由内存的一部分来承担的,我们把这片用于显示画面的内存区域叫做「显存」。

然而后来,随着人们对图形质量的要求越来越高,因此就想到专门搞一个用来处理图像数据的处理器,也就是GPU,GPU也需要自己的主存,也叫做「独立显存」。

稍微多扯几句,现在我们再说「显卡」,默认都是包含了GPU的显卡,而不再是单纯的显示适配器了。随着现代显卡的性能不断发展,在一些对图形性能要求不是那么高的设备上,就考虑不使用独立显卡,而是将显卡(包括GPU)继承在其他部件上,这种显卡也被称为「集成显卡」。将GPU集成在主板上的叫做「板载显卡」,将GPU集成在CPU中的叫做「核心显卡」。不过板载显卡已经被淘汰了,目前如果你的电脑中没有独显的话,那一定是核显。注意,这种情况只是GPU集成在了「CPU这个芯片」当中,但早已不是早期那种,没有GPU的情况了。

BIOS

前面我们介绍了内存和外存的特性,不知道读者有没有这样一个疑问:既然CPU只能操作内存,而内存又是断电后数据就消掉了,外存虽然可以长久保存,但是刚开机的时候,CPU又执行不到这里来。那么,开机后CPU到底要执行哪里的指令呢?

这确实是个很严重的问题,所以说,计算机需要一个「固化」下来的启动程序,做一些硬件自检的功能,然后把一份指令从外存读到内存中,再开始执行。承担这个任务的就是BIOS,全称Basic Input/Output System,中文译作「基本输入输出系统」。一般会用一种类似于FPGA的这种ROM,随着新机器的发型,直接固化在主板上了,当然后来也出了一些可升级固件的BIOS。

硬件的问题解决了,还有另一个问题,照理说,BIOS也不属于内存,那CPU要怎么执行到BIOS中的指令呢?Intel解决这个问题的方法叫做「统一编址」,简单来说,就是把一部分内存地址,映射给内存之外的部件,比如说BIOS。对于CPU来说,它会「认为」自己是在通过内存数据线来操作内存,但其实中间的一部分链接到了BIOS中。

因此,当计算机启动的时候,它会先执行BIOS中的指令,BIOS里会把一份代码从外存加载到内存中,然后再来执行它。由于这份代码是程序员完全可控的,因此接下来的事情就由这份代码来完成了。我们把BIOS加载的第一段程序叫做「MBR(Master Boot Record)」。

另外多啰嗦几句,前面介绍的BIOS也是计算机专业领域当中「BIOS」的概念,而现代我们常说的「BIOS」,里面有丰富图形界面,多种功能(甚至可以超频的那种),其实已经不是传统的BIOS了,而是UEFI(Unified Extensible Firmware Interface)。只不过因为它承担着与BIOS类似的作用,所以大家仍然习惯称之为「BIOS」,这一点希望读者悉知。笔者在后续描述中的「BIOS」特指计算机专业领域术语的BIOS,而对于UEFI则会单独称为「UEFI」。

CPU

终于讲到了核心的部件——CPU。CPU,全称「Central Processing Unit」,中文译为「中央处理单元」或「中央处理器」,但这个中文名用得不多,一般还是直接叫它CPU。

【注:为了简化问题,帮助读者快速上手,下面的CPU框架结构是简化版的,想知道完整、规范地8086CPU内部结构的读者可以在网上自行搜索。】

CPU有三个重要的部分:运算器(CU, Calculation Unit)、执行器(EU, Execution Unit)和寄存器(Register)。其他类似于缓存(Cache)之类的东西先不讲,因为我们暂时感知不到。

运算器,简单来说就是CPU的原子功能,比如说能做加减法运算之类的。它能做哪些运算取决于它的指令集。

执行器,由它来负责,当前要使用运算器的哪个功能,执行什么样的指令。

寄存器,则是CPU内部用来存放数据的地方,对于软件层面来说,我们主要操作的就是寄存器,因为其他部件都是按照自己的规则去执行的,我们只需要控制寄存器,就可以完成我们希望CPU执行的指令。

照理说,这个时候我应该介绍一下8086的14个寄存器的,但是笔者觉得,前面的铺垫有点太多了,读者可能已经迫不及待想写点程序运行运行了,所以,这些内容,等用到的时候再说吧~

让机器执行起来

啰里八嗦了那么多,总算是可以开始运行程序了!现在就请打开bochs,我们用debug模式来裸机运行一下,看看会发生什么。

对于Windows系统来说,直接运行bochsdbg.exe就可以了,暂时还不用加载配置文件,对于macOS来说,需要指定一下显示的配置。我们找一个工作路径(以后项目的代码都可以放到这个里面),例如~/code,再里面创建一个文件名为bochsrc,这是虚拟机的配置文件,然后编辑里面的内容如下:

display_library: sdl2

主要是因为,bochs的显示输出,默认用的并不是sdl2,这在macOS上是显示不出来的,所以我们需要指定到这个库。

如果你的机器上还没有安装,那么可以用brew install sdl2来安装。

保存完毕以后,在工作路径上通过这个配置文件来运行虚拟机:

bochs -qf bochsrc

即可启动虚拟机,命令行会保持在调试状态:
命令行
这时候我们可以输入c,回车,表示继续执行,不出意外的话,会弹出虚拟机的显示窗口:

虚拟机窗口

可以看到,BIOS中的指令已经运行完毕了,但是由于它没有搜索到外存,所以最终停在了这里。

很好!接下来,我们只需要把指令给它加载到外存里就OK了吧!你可以想象,现在我们把程序写好了,放到一张软盘中,然后把软盘插到软驱里,再重启电脑,这样的话,BIOS就应当能检测到软盘中的内容,并自动加载到内存里了。

不过对于虚拟机来说,上面这套动作得靠配置文件来完成。打开我们刚才的bochsrc(如果你用Windows,之前没有建立的话,现在就该建立了!),加入以下内容(注意,macOS的话不可以删除sdl2的配置项哈!):

boot: floppy # 设置软盘启动
floppy_bootsig_check: disabled=0 # 打开自检
floppya: type=1_44, 1_44="a.img", status=inserted, write_protected=0 # 使用1.44MB的3.5英寸软盘,取镜像为a.img,开机默认已插入软驱,不开启写保护

这样再开机的时候,就可以读取软盘镜像了。那么接下来,我们只需要把要执行的指令,写成这个名为a.img的软盘镜像里就大功告成了。

那怎么创建软盘镜像呢?需要用到二进制编辑器。二进制编辑器很多,macOS上推荐使用Hex Fiend,可以直接在App Store中下载到:
HexFiend

对于Windows来说,可以使用ultra edit,请读者自行安装,如果你实在找不到合适的也无妨,因为我们不可能一直用编辑二进制的方式来写程序,下一章开始我们就改用其他方式了,可以看一下笔者的操作,领悟精神即可。

为了能看到执行效果,我们就把一个数写到一个寄存器里,然后通过bochs的调试指令来看看寄存器里的值,如果生效了,那么就证明我们的MBR已经加载并执行成功了。比如说,我们给ax寄存器中放一个数值6。关于ax寄存器是什么后面章节会讲,反正当前只要知道它是一个8086中的寄存器就好了。

那么,把6写入ax寄存器的命令是什么?这个可以通过查Intel手册知道,应当是:

B8 06 00

B8是指令码,表示给ax寄存器中存入数据。后面的06 00是操作数,因为ax是一个十六位寄存器,所以给它应该要放一个16位的操作数。那为什么是06 00而不是00 06呢?这是因为,8086体系使用小端序,也就是低字节放数的低位。但是在书写数据的时候,我们又习惯从低到高来写,所以就变成了06 00,看上去可能有点不适应,但是还是需要大家适应一下~

那是不是这样就OK了?并不是!虽然BIOS会自动加载数据,但是,BIOS有一个约定,它会检测这段数据的最后两个字节是否是55 AA,是才会认为这是一段合法的MBR,才会加载。至于为啥是这俩魔数……emmm……估计没人晓得~

由于BIOS只会加载512字节(也就是对于软盘来说的第一个扇区),又对后两个字节有标志检测,所以,MBR应当是不多不少正好512字节,并且要在软盘的第一个扇区,这样才能正确被加载。所以,我们补全到512字节,并且把后两个字节设置为55 AA,如下图:

MBR

保存成a.img,就可以使用了!

保存a.img

然后我们再执行bochs -qf bochsrc,(Windows可以先打开bochsdbg.exe,然后选择Load按钮加载bochsrc),注意,现在还不能无脑按c,因为我们的MBR里只有一条指令,黑着往下执行的话会观察不到。所以,我们需要打一个断点,让bochs执行到这个位置的时候停一下。

那么另一个问题来了,断点应该打在哪?这取决于,BIOS会把MBR加载到内存的哪一个位置。这里的约定是0x7c00的位置(同样,至于为什么是这个地址估计也没人知道了~总之是作为一种约定),那么我们就要在0x7c00的位置打断点,所以执行下面的调试指令:

pb 0x7c00

然后再按c,这样执行到这一位置的时候就会停下来:

打断点

停下来的时候,调试页面会显示这样的情况:

调试

注意最下面一行,中括号里的就是当先执行指令的内存地址,也就是0x7c00,证明这个断点位置是对的,在继续执行之前,我们先来看一下当前ax寄存器的情况,输入r指令,回车可以看到通用寄存器的状态:

寄存器状态

这里需要解释一下,由于bochs是AMD64架构的模拟器,所以这里的寄存器都是按64位显示的,它们的扩展情况将会在后续章节来介绍,目前我们只需要知道,要看ax寄存器的值,其实就是看rax的最后16位(也就是最后4位十六进制位),如上图红框里的,就是ax的值,现在是aa55

然后,我们往下执行一条指令就好了,s命令是单条执行,只会向下执行一句指令。所以我们输入s,回车,再输入r来打印一下寄存器的情况:

执行一步

OK,ax寄存器真的被改写成0006了,说明我们的指令已经成功运行了!

改用汇编语言

不知道会不会有读者跟笔者一样,第一次在裸机上运行一句指令以后会无比兴奋,仿佛打开了新世界的大门,恨不得现在就着手写一片江山上去!但是先别急!因为这种用二进制机器码直接编程的难度也忒大了。我得去记住所有的指令码和指令格式,万一错一个数字那就整个都不对了,况且它可读性也很差呀!谁能一眼看出来B80600是什么鬼?

当然了,要是退回到8086的年代,可能程序员真的是这么干的,但是现在,我们有了更方便的工具,这种仿古式的编程方法,稍微体验一下就OK啦。回到上面的指令,既然B80600是「给ax寄存器写入0006这个数」的含义,那么,能否有一个翻译器,把我的这种表意,转换成机器指令呢?

当然有!这就是汇编器,它可以把汇编语言转换成机器码。比如说:

mov ax, 0x06

表明给ax寄存器中传入0x06这个十六进制数,然后交由汇编器将其转换为B80600。这样的语言就叫做汇编语言,汇编语言看起来是比机器码要友好得多了吧?

不过成熟的汇编器除了做指令翻译以外,可能还会有一些更方便的功能,类似于编译器的预处理,做一些静态的数值转换之类的工作,但是不同的汇编器支持的汇编语言也会略有不同,业界比较常用的有两个:nasm和gas。

gas也就是GNU的asmmbly(汇编语言),之所以比较常用,是因为gcc只能将C代码编译成gas格式,后续本篇的示例中,也会使用gcc编译器,编译后的就是gas格式。

nasm是一个比较被普遍认可的汇编器,全称Netwide Assembler。它的优点在于语法简洁易用。在本篇的示例中,对于需要直接手动开发的汇编语言部分,将会使用nasm。

接下来就来介绍如何安装nasm。

安装nasm汇编器

首先,登录nasm官网,点击当前最新的稳定版本(读者看到的时候有可能已经是高于截图的版本了,不过没关系,选择最新的稳定版即可)。
nasm官网

接下来,根据自己所使用的OS选择对应的文件夹,如果你用macOS,就选macosx,如果你用Windows,就选win64。注意,这里只区分操作系统,不区分你的实际硬件架构,即便你使用苹果自研芯片的Mac,或者搭载骁龙芯片的Windows,这里的软件也同样适用。
下载对应OS版本的nasm

接下来Windows和macOS的步骤会有不同,笔者分别来介绍。

在Windows上安装nasm

由于Windows版本中提供了安装包,因此,比较方法的做法是下载这个installer,然后通过自带的安装程序安装到电脑中。当然,如果你对搭建环境比较熟的话,也可以直接下载下面的zip,解压缩后得到的直接是nasm程序本身。
选择安装器

如果你选择了安装器的版本,那么直接运行安装器,安装选项全部默认即可。
nasm安装器

不过这里要注意一下安装路径,默认情况是C:Program FilesNASM,Windows默认这个带空格的路径确实是一个饱含诟病的历史遗留问题,不过对于nasm来说影响不大,安装在默认路径下也是OK的,只不过我们要记住这个路径,保证能找到它。如果你没有用安装器,而是直接下载的zip然后解压缩的话,也请把整个文件夹放在一个合适的路径下,保证自己找得到。
安装路径

等安装完毕后,nasm就已经躺在刚才的安装路径下了。但是每次都指定绝对路径去运行着实麻烦了一些,也不方便我们进行项目的迁移,因此,我们还要把它配置到环境变量里。按Win+R组合键,弹出「运行」窗口,输入sysdm.cpl,回车,即可打开系统属性设置。
运行

在「系统属性」设置中,选择「高级」标签页,再点击下面的「环境变量」按钮。
系统属性

接着,在环境变量中找到用户变量里的Path,这个变量决定了,如果你不指定绝对路径,而是直接输入一个命令的时候,系统会去哪些路径中找程序。我们希望的效果是,当我们想运行nasm的时候,直接输「nasm」就好了,而不是每次都要输「C:Program FilesNASM asm」,因此,就要把这个路径也配置到环境变量中。

选择Path后点击「编辑」,或者直接双击Path也可以,就可以编辑环境变量了。
环境变量

在「编辑环境变量」的窗口中点击「新建」,然后把nams的安装路径写进去。注意,要写全路径,并且只需要写到NASM这层路径就好了,确保这个路径下有nasm.exe这个可执行程序。
编辑环境变量

环境变量设置好以后,我们就可以尝试运行一下nasm了。按Win+R打开「运行」,输入cmd,回车,即可调出控制台。
运行

在控制台中输入nasm -v,如果能够看到打印出的nasm版本号信息,就说明我们已经安装配置完毕了!
运行nasm

在macOS上安装nasm

由于macOS版本的nasm没有安装包,所以我们只能下载源程序的压缩包。
下载nasm

解压缩之后,就已经是可以执行的程序了,不过一般情况下浏览器默认会把文件下到「下载」这个路径中,这里自然不合适放一个经常要用到的程序,所以请手动把它挪到一个妥当的位置。

我这里选择的是用户根路径,也就是~/。文件夹它默认带版本号,你可以改个名字,也可以不管它,只要确保里面有nasm这个可执行程序就好了。我这里的路径是~/nasm-2.16.01

同样地,为了让我们使用时可以只输入nasm,而不是~/nasm-2.16.01/nasm,我们还需要把这个路径放入环境变量。

macOS最早默认使用的bash,后来换成了zsh,因为这个切换已经很久了,所以笔者介绍zsh的情况,如果你用的是其他版本的shell,就请自行解决环境变量的配置问题。

执行下面的命令,编辑zsh的配置文件:

vim ~/.zprofile

注意,即便你当前没有.zprofile这个文件也没关系,上面的命令执行会以新建文件的方式。

然后再编辑界面按「i」键,进入编辑模式,此时左下角会显示「INSERT」,表示在编辑模式。如果里面已经有一些配置了,无视就好,我们在文件最后加上:

PATH=$PATH:/Users/xxx/nasm-2.16.01

注意,由于我是放在~/里的,但这里要写全路径,所以你需要看一下全路径是什么,用波浪线有时可能会失效。

那一句的意思就是,在PATH这个变量后面,加上一个nasm的路径,所以这里要填写你的nasm所在路径。

由于.zprofile会在每次运行终端的时候自动执行,因此我们把命令写在这个文件里就不用每次手动配置了,但由于现在还没生效,所以你还需要执行一句:

source ~/.zprofile

或者干脆把终端关了,重新开一下,就生效了。

然后我们在控制台输入

nasm -v

如果能够看到版本信息,那么说明nasm已经安装配置成功。
nasm版本

小结

本篇介绍了8086的体系和计算机硬件的基本常识,然后尝试在bochs上运行了一条指令来修改ax寄存器的值,最后介绍了安装nasm的方法,下一篇开始,我们将会使用汇编语言继续开发我们的MBR。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。