先序文章请看:
从裸机启动开始运行一个C++程序(一)
既然硬件环境已经就绪了,那接下来,就要想办法让它运行我们的程序了。不过在此之前,我们必须要了解一下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体系的计算机中的几大硬件,它们是:
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的情况了。
前面我们介绍了内存和外存的特性,不知道读者有没有这样一个疑问:既然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,全称「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中下载到:
对于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
,如下图:
保存成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官网,点击当前最新的稳定版本(读者看到的时候有可能已经是高于截图的版本了,不过没关系,选择最新的稳定版即可)。
接下来,根据自己所使用的OS选择对应的文件夹,如果你用macOS,就选macosx
,如果你用Windows,就选win64
。注意,这里只区分操作系统,不区分你的实际硬件架构,即便你使用苹果自研芯片的Mac,或者搭载骁龙芯片的Windows,这里的软件也同样适用。
接下来Windows和macOS的步骤会有不同,笔者分别来介绍。
由于Windows版本中提供了安装包,因此,比较方法的做法是下载这个installer
,然后通过自带的安装程序安装到电脑中。当然,如果你对搭建环境比较熟的话,也可以直接下载下面的zip
,解压缩后得到的直接是nasm
程序本身。
如果你选择了安装器的版本,那么直接运行安装器,安装选项全部默认即可。
不过这里要注意一下安装路径,默认情况是C:\Program Files\NASM
,Windows默认这个带空格的路径确实是一个饱含诟病的历史遗留问题,不过对于nasm
来说影响不大,安装在默认路径下也是OK的,只不过我们要记住这个路径,保证能找到它。如果你没有用安装器,而是直接下载的zip
然后解压缩的话,也请把整个文件夹放在一个合适的路径下,保证自己找得到。
等安装完毕后,nasm
就已经躺在刚才的安装路径下了。但是每次都指定绝对路径去运行着实麻烦了一些,也不方便我们进行项目的迁移,因此,我们还要把它配置到环境变量里。按Win+R组合键,弹出「运行」窗口,输入sysdm.cpl
,回车,即可打开系统属性设置。
在「系统属性」设置中,选择「高级」标签页,再点击下面的「环境变量」按钮。
接着,在环境变量中找到用户变量里的Path
,这个变量决定了,如果你不指定绝对路径,而是直接输入一个命令的时候,系统会去哪些路径中找程序。我们希望的效果是,当我们想运行nasm
的时候,直接输「nasm
」就好了,而不是每次都要输「C:\Program Files\NASM\nasm
」,因此,就要把这个路径也配置到环境变量中。
选择Path
后点击「编辑」,或者直接双击Path
也可以,就可以编辑环境变量了。
在「编辑环境变量」的窗口中点击「新建」,然后把nams
的安装路径写进去。注意,要写全路径,并且只需要写到NASM
这层路径就好了,确保这个路径下有nasm.exe
这个可执行程序。
环境变量设置好以后,我们就可以尝试运行一下nasm
了。按Win+R打开「运行」,输入cmd
,回车,即可调出控制台。
在控制台中输入nasm -v
,如果能够看到打印出的nasm
版本号信息,就说明我们已经安装配置完毕了!
由于macOS版本的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
本篇介绍了8086的体系和计算机硬件的基本常识,然后尝试在bochs
上运行了一条指令来修改ax
寄存器的值,最后介绍了安装nasm
的方法,下一篇开始,我们将会使用汇编语言继续开发我们的MBR。
从裸机启动开始运行一个C++程序(三)