用汇编和 C 编写引导加载程序——第一部分






4.88/5 (137投票s)
如何用您自己编写的 C 和汇编代码引导软盘映像
引言
我认为本文是对用 C 语言和汇编语言编写引导加载程序的介绍,我不想在编写引导加载程序方面深入探讨 C 语言和汇编语言编写的代码的性能比较。在本文中,我将只尝试简要介绍如何通过编写自己的代码并将其注入到设备(引导加载程序)的引导扇区来引导软盘映像。在此过程中,我将把文章分为几个部分。在一个文章中解释计算机、可引导设备以及如何编写代码感觉很困难,所以我尽力解释了学习计算机和引导最常见的方面。我试图将每个阶段的含义和重要性概括化,以便于理解和记忆。如果您需要更详细的解释,可以在互联网上浏览许多文章。
本文的范围是什么?
我将把本文的范围限制在如何编写程序代码,以及如何将其复制到软盘映像的引导扇区,然后如何使用 Linux 上的 bochs 等 x86 仿真器测试软盘是否能够用您的程序代码引导。
本文未解释的内容
我没有解释为什么引导加载程序不能用其他语言编写,以及与其他语言相比,用一种语言编写引导加载程序的缺点。由于这是一篇关于如何编写引导代码的入门学习文章,我不想过多地探讨速度、编写更小的代码等更高级的主题。
文章的组织方式
就我而言,我希望在文章中介绍一些基础知识,然后是代码。以下是按照我向您简要介绍如何编写引导加载程序的顺序对内容的分解。
- 可引导设备介绍。
- 开发环境介绍。
- 微处理器介绍。
- 在汇编器中编写代码。
- 在编译器中编写代码。
- 一个显示矩形的小项目。
注意:
- 如果您有任何语言的编程经验,本文将对您有很大帮助。尽管本文看起来是入门级的,但在引导时用汇编和 C 语言编写程序可能是一项艰巨的任务。如果您是计算机编程新手,我建议您先阅读一些介绍编程和计算机基础知识的教程,然后再回到本文。
- 在本文中,我将以问答的形式向您介绍与计算机相关的各种术语。坦白说,我将以向自己介绍这篇文章的方式来撰写。因此,其中包含了许多问答式的对话,以确保我能理解它在日常生活中的重要性和目的。例如:“计算机是什么意思?”或“我为什么需要它们?我比它们聪明多了。”
那么,我们开始吧……:)
可启动设备介绍
当一台典型的计算机开机时会发生什么?
通常,当电脑开机时,电源按钮会向电源发出信号,使其向电脑和 CPU、显示器、键盘、鼠标等其他组件发送适当的电压。CPU 初始化基本输入输出系统只读存储芯片以加载可执行程序。一旦 BIOS 芯片初始化,它会将一个名为 BIOS 的特殊程序传递给 CPU 执行,其功能如下。
- BIOS 是嵌入在 BIOS 芯片中的特殊程序。
- BIOS 程序执行后,会执行以下任务。
- 运行开机自检。
- 检查可用的时钟和各种总线。
- 检查 CMOS RAM 中的系统时钟和硬件信息
- 验证系统设置、预配置的硬件设置等。
- 测试已连接的硬件,从内存、磁盘驱动器、光驱、硬件驱动器等设备开始。
- 根据 BIOS 可启动设备信息中预配置的信息,它根据设置中可用的信息搜索启动驱动器,并开始对其进行初始化以进一步进行。
注意:所有 x86 兼容的 CPU 在引导过程中都以实模式运行。
什么是可启动设备?
如果设备包含引导扇区或引导块,并且 BIOS 通过首先将引导扇区加载到内存 (RAM) 中执行,然后继续进行,则该设备是可引导设备。
什么是扇区?
扇区是可引导磁盘上特定大小的划分。通常一个扇区的大小为 512 字节。我将在后面的章节中向您详细解释计算机内存的测量方式以及与此相关的各种术语。
什么是引导扇区?
引导扇区或引导块是可引导设备上的一个区域,其中包含机器代码,该机器代码由计算机系统的内置固件在其初始化期间加载到 RAM 中。在软盘上,它的大小为 512 字节。您将在后面的章节中了解更多关于字节的信息。
可启动设备如何工作?
每当可引导设备初始化时,BIOS 都会搜索并加载第一个扇区(称为引导扇区或引导块)到 RAM 中并开始执行。引导扇区中的代码是您可以编辑的第一个程序,用于定义计算机其余时间的功能。我的意思是您可以编写自己的代码并将其复制到引导扇区,使计算机按照您的要求工作。您打算写入设备引导扇区的程序代码也称为引导加载程序。
什么是引导加载程序?
在计算中,引导加载程序是一个特殊程序,每当计算机在开机或重置时初始化可引导设备时都会执行它。它是一个可执行的机器代码,对 CPU 或微处理器的硬件架构非常特定。
微处理器有多少种类型?
我主要列出以下几种。
- 16 位
- 32 位
- 64 位
通常,位数越多,程序可访问的内存空间越大,在临时存储等方面获得的性能也越高。目前市场上主要有两家微处理器制造商,它们是 Intel 和 AMD。在本文的其余部分中,我将只提及基于 Intel 系列 (x86) 的微处理器。
基于 Intel 的微处理器和基于 AMD 的微处理器有什么区别?
每家公司在硬件和用于交互的指令集方面都有自己独特的设计微处理器的方式。
开发环境介绍。
什么是实模式?
在前面的“计算机启动时会发生什么”一节中,我提到所有 x86 CPU 在从设备引导时都以实模式启动。在为任何设备编写引导代码时,这一点非常重要。实模式只支持 16 位指令。因此,您写入设备引导记录或引导扇区以加载的代码应该只编译成 16 位兼容代码。在实模式下,指令一次最多只能处理 16 位,例如:一个 16 位 CPU 会有一个特定的指令,可以在一个 CPU 周期内将两个 16 位数字相加,如果一个进程需要将两个 32 位数字相加,那么它将需要更多的周期,利用 16 位加法。
什么是指令集?
一种异构实体集合,它们对微处理器的架构(就设计而言)非常特定,用户可以使用它们与微处理器交互。我指的是一个实体集合,它包括原生数据类型、指令、寄存器、寻址模式、内存架构、中断和异常处理以及外部 I/O。通常,一组指令会为一系列微处理器通用。8086 微处理器是 8086、80286、80386、80486、奔腾、奔腾 I、II、III……系列中的一个,也称为 X86 系列。在本文中,我将把指令集称为 x86 系列微处理器。
如何将自己的代码写入设备的引导扇区?
为了成功完成这项任务,我们需要了解以下内容。
- 操作系统 (GNU Linux)
- 汇编器 (GNU Assembler)
- 指令集 (x86 系列)
- 在 GNU 汇编器上为 x86 微处理器编写 x86 指令。
- 编译器 (C 编程语言 - 可选)
- 链接器 (GNU 链接器 ld)
- 一个 x86 模拟器,例如用于我们测试目的的 bochs。
什么是操作系统?
我将以一种非常简单的方式解释这一点。这是一个由成千上万的专业人士编写的庞大程序集合,包括应用程序和实用程序,旨在帮助全球的个人和人民。从技术角度来看,一般来说,操作系统主要是为了提供各种应用程序,以帮助人们更好地完成日常生活活动。例如,连接互联网、聊天、浏览网页、创建文件、保存文件、数据、处理数据等等。我仍然不明白。我的意思是,您可能想和朋友聊天,您可能想在线观看新闻,您可能想将一些个人信息写入文件,您可能想观看一些电影,您可能想计算一些数学方程,您可能想玩游戏,您可能想编写程序等等……所有这些任务都可以通过操作系统实现。操作系统的职责是提供足够的工具来帮助和服务您。您可能还需要多任务处理一些活动,而操作系统的工作就是管理硬件,为您提供最佳体验。
另外,请注意,所有现代操作系统都在保护模式下运行。
操作系统有哪些不同类型?
- Windows
- Linux
- MAC
以及更多……
什么是保护模式?
与实模式不同,保护模式支持 32 位指令。现在不用过多担心,因为我们不太关心操作系统如何工作等。
什么是汇编器?
汇编器将用户给出的指令转换为机器代码。
编译器也做同样的事情...不是吗?
从更高级别来看,是的……但实际上,嵌入在编译器内部的汇编器完成了这项活动。
那么,为什么编译器不能直接生成机器代码呢?
编译器的主要工作是将其将用户编写的指令转换为一组称为汇编语言指令的中间指令。然后汇编器将这些指令转换为相应的机器代码。
为什么我需要一个操作系统来为引导扇区编写代码?
现在,我不想进行非常详细的解释,但让我从本文的范围来解释。嗯!我之前提到过,为了编写微处理器能够理解的指令,我们需要编译器,而这个编译器作为操作系统中的一个实用程序而开发。我告诉过您,操作系统的设计目的是通过提供各种实用程序来帮助人们,而编译器也是其中一个实用程序。
我可以使用哪个操作系统?
我已经在 Ubuntu 操作系统上编写了程序,以便从软盘设备启动,所以我推荐本文使用 Ubuntu。
我应该使用哪个编译器?
我使用 GNU GCC 编译器编写了程序,我将介绍如何使用它编译代码。如何测试手写代码到设备的引导扇区?我将向您介绍一个 x86 仿真器,它可以在不让我们每次编辑设备的引导扇区时都重启计算机的情况下,为我们提供极大的帮助。
微处理器简介
要学习微处理器编程,首先我们需要学习如何使用寄存器。
什么是寄存器?
寄存器就像微处理器中的实用程序,用于临时存储数据并根据我们的要求对其进行操作。假设用户想要将 3 加到 2,用户会要求计算机将数字 3 存储在一个寄存器中,将数字 2 存储在另一个寄存器中,然后将这两个寄存器中的内容相加,结果由 CPU 放置在另一个寄存器中,这就是我们希望看到的结果。寄存器有四种类型,列举如下。
- 通用寄存器
- 段寄存器
- 堆栈寄存器
- 索引寄存器
让我向您简要介绍每种类型。
通用寄存器:用于在程序生命周期中存储程序所需的临时数据。每个寄存器宽 16 位或长 2 字节。
- AX - 累加器寄存器
- BX - 基地址寄存器
- CX - 计数寄存器
- DX - 数据寄存器
段寄存器:为了向微处理器表示内存地址,我们需要了解两个术语。
- 段:通常是内存块的起始位置。
- 偏移量:它是内存块在其上的索引。
示例:假设一个字节的值为“X”,它位于一个内存块中,该内存块的起始地址为 0x7c00,并且该字节位于从起始位置开始的第 10 个位置。在这种情况下,我们将段表示为 0x7c00,将偏移量表示为 10。
绝对地址是 0x7c00 + 10。
我想列出以下四类。
- CS - 代码段
- SS - 堆栈段
- DS - 数据段
- ES - 扩展段
但是这些寄存器总是有限制的。您不能直接将地址分配给这些寄存器。我们可以做的是,将地址复制到通用寄存器中,然后将地址从该寄存器复制到段寄存器中。示例:为了解决定位字节“X”的问题,我们这样做
movw $0x07c0, %ax
movw %ax , %ds
movw (0x0A) , %ax
在我们的案例中,发生的情况是
- 在 AX 中设置 0x07c0 * 16
- 设置 DS = AX = 0x7c00
- 设置 AX = 0x7c00 + 0x0a
我将介绍在编写程序时需要了解的各种寻址模式。
堆栈寄存器:
- BP - 基址指针
- SP - 堆栈指针
索引寄存器:
- SI - 源索引寄存器。
- DI - 目的索引寄存器。
- AX:CPU 用于算术运算。
- BX:它可以保存过程或变量的地址(SI、DI 和 BP 也可以)。还可以执行算术和数据移动。
- CX:它充当重复或循环指令的计数器。
- DX:它在乘法中保存乘积的高 16 位(也处理除法运算)。
- CS:它保存程序中所有可执行指令的基址。
- SS:它保存堆栈的基址。
- DS:它保存变量的默认基址。
- ES:它保存内存变量的附加基址。
- BP:它包含一个从 SS 寄存器假定的偏移量。通常由子程序使用,以定位调用程序在堆栈上传递的变量。
- SP:包含堆栈顶部的偏移量。
- SI:用于字符串移动指令。源字符串由 SI 寄存器指向。
- DI:充当字符串移动指令的目的地。
什么是位?
在计算机中,位是存储数据的最小单位。位以二进制形式存储数据。要么是 1(开)要么是 0(关)。
更多关于寄存器:
寄存器进一步从左到右按位划分如下
- AX:AX 的前 8 位标识为 AL,后 8 位标识为 AH
- BX:BX 的前 8 位标识为 BL,后 8 位标识为 BH
- CX:CX 的前 8 位标识为 CL,后 8 位标识为 CH
- DX:DX 的前 8 位标识为 DL,后 8 位标识为 DH
如何访问 BIOS 函数?
BIOS 提供了一组函数,让我们能够引起 CPU 的注意。可以通过中断来访问 BIOS 功能。
什么是中断?
为了中断程序的正常流程并处理需要及时响应的事件,我们使用中断。计算机的硬件提供了一种称为中断的机制来处理事件。例如,当鼠标移动时,鼠标硬件会中断当前程序以处理鼠标移动(移动鼠标光标等)。中断会导致控制权传递给中断处理程序。中断处理程序是处理中断的例程。每种类型的中断都分配了一个整数。在物理内存的开头,驻留着一个中断向量表,其中包含中断处理程序的段地址。中断号本质上是该表的一个索引。我们也可以将中断称为 BIOS 提供的服务。
我们将在程序中使用哪个中断服务?
BIOS 中断 0x10。
在汇编器中编写代码
GNU 汇编器中有哪些可用的数据类型?
一组用于表示单位以构成各种数据类型的位。
什么是数据类型?
数据类型用于标识数据的特性。各种数据类型如下。
- byte
- 字
- int
- ascii
- asciz
字节:它长八位。一个字节被认为是计算机上最小的单位,数据可以通过编程存储在其中。
字:它是一个长 16 位的数据单位。
什么是 int?
int 是一种数据类型,表示 32 位长的数据。四个字节或两个字构成一个 int。
什么是 ascii?
一种数据类型,用于表示一组没有空终止符的字节。
什么是 asciz?
一种数据类型,用于表示一组以空字符结尾的字节。
如何通过汇编器生成实模式代码?
当 CPU 以实模式(16 位)启动时,我们在从设备引导时所能做的就是利用 BIOS 提供的内置函数继续。我的意思是我们可以利用 BIOS 的功能来编写我们自己的引导加载程序代码,然后将其转储到设备的引导扇区,然后引导它。让我们看看如何在汇编器中编写一小段代码,通过 GNU 汇编器生成 16 位 CPU 代码。
Example: test.S
.code16 #generate 16-bit code
.text #executable code location
.globl _start;
_start: #code entry point
. = _start + 510 #mov to 510th byte from 0 pos
.byte 0x55 #append boot signature
.byte 0xaa #append boot signature
让我解释一下上面代码中的每个语句。
- .code16: 这是一个指令,或给汇编器的命令,用于生成 16 位代码而不是 32 位代码。为什么这个提示是必要的?请记住,您将使用操作系统来利用汇编器和编译器编写引导加载程序代码。但是,我也提到过操作系统在 32 位保护模式下工作。因此,当您在保护模式操作系统上使用汇编器时,它默认配置为生成 32 位代码而不是 16 位代码,这不符合目的,因为我们需要 16 位代码。为了避免汇编器和编译器生成 32 位代码,我们使用此指令。
- .text: .text 段包含构成您程序的实际机器指令。
- .globl _start: .global <symbol> 使符号对链接器可见。如果您在部分程序中定义符号,则其值可供与其链接的其他部分程序使用。否则,符号会从链接到同一程序的另一个文件中的同名符号获取其属性。
- _start: 主代码的入口点,_start 是链接器的默认入口点。
- . = _start + 510: 从开始遍历到第 510 个字节
- .byte 0x55: 它是引导签名的一部分,被识别为第一个字节(第 511 个字节)。
- .byte 0xaa: 它是引导签名的一部分,被识别为最后一个字节(第 512 个字节)。
如何编译汇编程序?
将代码保存为 test.S 文件。在命令行中键入以下内容
- as test.S -o test.o
- ld –Ttext 0x7c00 --oformat=binary test.o –o test.bin
上面的命令对我们来说意味着什么?
- as test.S –o test.o:此命令将给定的汇编代码转换为相应的目标代码,该目标代码是汇编器在转换为机器代码之前生成的中间代码。
- --oformat=binary 开关告诉链接器您希望输出文件是一个纯二进制映像(没有启动代码,没有重定位,...)。
- –Ttext 0x7c00 告诉链接器您希望将“text”(代码段)地址加载到 0x7c00,因此它会计算绝对寻址的正确地址。
什么是引导签名?
还记得我之前简要介绍过 BIOS 程序加载的引导记录或引导扇区吗?BIOS 如何识别设备是否包含引导扇区?为了回答这个问题,我可以告诉您,引导扇区长 512 字节,在第 510 字节处期望一个符号 0x55,在第 511 字节处期望另一个符号 0xaa。因此,它验证引导扇区的最后两个字节是否为 0x55 和 0xaa,如果是,则将其识别为引导扇区并继续执行引导扇区代码,否则会抛出设备不可引导的错误。使用十六进制编辑器可以更清晰地查看二进制文件的内容,以下是您使用 hexedit 工具查看文件时的快照,供您参考。
如何将可执行代码复制到可引导设备然后进行测试?
要创建 1.4MB 大小的软盘映像,请在命令行中输入以下内容。
- dd if=/dev/zero of=floppy.img bs=512 count=2880
要将代码复制到软盘映像文件的引导扇区,请在命令行中输入以下内容。
- dd if=test.bin of=floppy.img
要在命令行中测试程序,请键入以下内容。
- bochs
如果 bochs 未安装,您可以键入以下命令
- sudo apt-get install bochs-x
Sample bochsrc.txt file
megs: 32
#romimage: file=/usr/local/bochs/1.4.1/BIOS-bochs-latest, address=0xf0000
#vgaromimage: /usr/local/bochs/1.4.1/VGABIOS-elpin-2.40
floppya: 1_44=floppy.img, status=inserted
boot: a
log: bochsout.txt
mouse: enabled=0
您应该会看到如下所示的典型 bochs 仿真窗口。
观察:
现在,如果您在十六进制编辑器中查看 test.bin 文件,您会看到引导签名位于第 510 个字节的末尾,以下是供您参考的屏幕截图。
什么都没有发生,因为我们没有在代码中写入任何内容来显示在屏幕上。所以您只看到一条消息“从软盘引导”。让我们看更多一些在汇编器上编写汇编代码的例子。
Example: test2.S
.code16 #generate 16-bit code
.text #executable code location
.globl _start;
_start: #code entry point
movb $'X' , %al #character to print
movb $0x0e, %ah #bios service code to print
int $0x10 #interrupt the cpu now
. = _start + 510 #mov to 510th byte from 0 pos
.byte 0x55 #append boot signature
.byte 0xaa #append boot signature
输入上述内容后,保存为 test2.S,然后按照之前指示的方法进行编译,更改源文件名。当您编译并成功将此代码复制到引导扇区并运行 bochs 时,您应该会看到以下屏幕。在命令行中键入 bochs 查看结果,您应该会在屏幕上看到字母“X”,如以下屏幕截图所示。
恭喜!!!
观察
如果用十六进制编辑器查看,您会发现字符“X”位于起始地址的第二个位置。
现在让我们做一些不同的事情,比如在屏幕上打印文本。
Example: test3.S
.code16 #generate 16-bit code
.text #executable code location
.globl _start;
_start: #code entry point
#print letter 'H' onto the screen
movb $'H' , %al
movb $0x0e, %ah
int $0x10
#print letter 'e' onto the screen
movb $'e' , %al
movb $0x0e, %ah
int $0x10
#print letter 'l' onto the screen
movb $'l' , %al
movb $0x0e, %ah
int $0x10
#print letter 'l' onto the screen
movb $'l' , %al
movb $0x0e, %ah
int $0x10
#print letter 'o' onto the screen
movb $'o' , %al
movb $0x0e, %ah
int $0x10
#print letter ',' onto the screen
movb $',' , %al
movb $0x0e, %ah
int $0x10
#print space onto the screen
movb $' ' , %al
movb $0x0e, %ah
int $0x10
#print letter 'W' onto the screen
movb $'W' , %al
movb $0x0e, %ah
int $0x10
#print letter 'o' onto the screen
movb $'o' , %al
movb $0x0e, %ah
int $0x10
#print letter 'r' onto the screen
movb $'r' , %al
movb $0x0e, %ah
int $0x10
#print letter 'l' onto the screen
movb $'l' , %al
movb $0x0e, %ah
int $0x10
#print letter 'd' onto the screen
movb $'d' , %al
movb $0x0e, %ah
int $0x10
. = _start + 510 #mov to 510th byte from 0 pos
.byte 0x55 #append boot signature
.byte 0xaa #append boot signature
将其保存为 test3.S。当您编译并成功将此代码复制到引导扇区并运行 bochs 时,您应该会看到以下屏幕。
观察
好的……现在我们做一些比以前的程序更不同的事情。
Let us write an assembly program to print the letters “Hello, World” onto the screen.
我们还将尝试通过定义函数和宏来打印字符串。
Example: test4.S
#generate 16-bit code
.code16
#hint the assembler that here is the executable code located
.text
.globl _start;
#boot code entry
_start:
jmp _boot #jump to boot code
welcome: .asciz "Hello, World\n\r" #here we define the string
.macro mWriteString str #macro which calls a function to print a string
leaw \str, %si
call .writeStringIn
.endm
#function to print the string
.writeStringIn:
lodsb
orb %al, %al
jz .writeStringOut
movb $0x0e, %ah
int $0x10
jmp .writeStringIn
.writeStringOut:
ret
_boot:
mWriteString welcome
#move to 510th byte from the start and append boot signature
. = _start + 510
.byte 0x55
.byte 0xaa
将其保存为 test4.S。当您编译并成功将此代码复制到引导扇区并运行 bochs 时,您应该会看到以下屏幕。
嗯!!!如果您理解了我所做的,并且能够编写类似的程序,那么再次恭喜您!
观察
什么是函数?
函数是一段具有名称且具有可重用属性的代码块。
什么是宏?
宏是一段已命名代码片段。每当使用该名称时,它就会被宏的内容替换。
宏和函数在语法方面有什么区别?
要调用函数,我们使用以下语法。
- push <参数>
- call <函数名>
要调用宏,我们使用以下语法
- 宏名称 <参数>
但是宏的调用和使用语法比函数简单得多。所以我更喜欢编写宏并使用它,而不是在主代码中调用函数。您可以参考更多在线资料,了解如何在 GNU 汇编器上编写汇编代码。
在 C 编译器中编写代码
什么是 C?
在计算领域,C 是一种通用编程语言,最初由 Dennis Ritchie 于 1969 年至 1973 年间在 AT&T 贝尔实验室开发。
为什么要使用 C 语言?尽管 C 是一种依赖机器的语言,但用 C 语言编写的程序通常小巧且执行速度快。该语言包含通常只在汇编语言或机器语言中才有的低级特性。C 是一种结构化编程语言。
为什么我需要用 C 语言编写代码?
嗯,如果你想编写更小的程序并希望它们真正快速,那就去吧。
用 C 语言编写代码需要什么?
我们将使用 GNU C 编译器 gcc 来编写 C 代码。
如何在 GCC 编译器中用 C 语言编写程序?
让我们写一个程序看看它是怎样的。
Example: test.c
__asm__(".code16\n");
__asm__("jmpl $0x0000, $main\n");
void main() {
}
File: test.ld
ENTRY(main);
SECTIONS
{
. = 0x7C00;
.text : AT(0x7C00)
{
*(.text);
}
.sig : AT(0x7DFE)
{
SHORT(0xaa55);
}
}
如何编译 C 程序?在命令行中输入以下内容
- gcc -c -g -Os -march=i686 -ffreestanding -Wall -Werror test.c -o test.o
- ld -static -Ttest.ld -nostdlib --nmagic -o test.elf test.o
- objcopy -O binary test.elf test.bin
上面的命令对我们来说意味着什么?
此命令将给定的 C 代码转换为相应的目标代码,该目标代码是编译器在转换为机器代码之前生成的中间代码。
- gcc -c -g -Os -march=i686 -ffreestanding -Wall -Werror test.c -o test.o
每个标志是什么意思?
- -c: 用于编译给定源代码而不进行链接。
- -g: 生成供 GDB 调试器使用的调试信息。
- -Os: 代码大小优化
- -march: 为特定的 CPU 架构生成代码(在我们的例子中是 i686)
- -ffreestanding: 独立环境是指标准库可能不存在,并且程序启动不一定在“main”函数处。
- -Wall: 启用所有编译器的警告消息。应始终使用此选项,以生成更好的代码。
- -Werror: 将警告视为错误
- test.c: 输入源文件名
- -o: 生成目标代码
- test.o: 输出目标代码文件名。
通过上述所有编译器标志组合,我们尝试生成目标代码,这有助于我们识别错误、警告,并为 CPU 类型生成更高效的代码。如果您不指定 march=i686,它会为您的机器类型生成代码,否则为了移植性,最好始终指定您要针对的 CPU 类型。
- ld -static -Ttest.ld -nostdlib --nmagic test.elf -o test.o
这是从命令行调用链接器的命令,我在下面解释了我们正在尝试用链接器做什么。
每个标志是什么意思?
- -static: 不链接共享库。
- -Ttest.ld: 此功能允许链接器遵循链接器脚本中的命令。
- -nostdlib: 此功能允许链接器在不链接任何标准 C 库启动函数的情况下生成代码。
- --nmagic: 此功能允许链接器在没有 _start_SECTION 和 _stop_SECTION 代码的情况下生成代码。
- test.elf: 输入文件名(平台相关的文件格式,用于存储可执行文件,Windows:PE,Linux:ELF)
- -o: 生成目标代码
- test.o: 输出目标代码文件名。
什么是链接器?
这是编译的最后阶段。ld(链接器)将一个或多个目标文件或库作为输入,并将它们组合以生成单个(通常是可执行的)文件。在此过程中,它解析对外部符号的引用,为过程/函数和变量分配最终地址,并修改代码和数据以反映新地址(此过程称为重定位)。
还要记住,我们的代码中没有标准库和所有花哨的函数可供使用。
- objcopy -O binary test.elf test.bin
此命令用于生成平台无关的代码。请注意,Linux 存储可执行文件的方式与 Windows 不同。它们都有自己的文件存储方式,但我们目前只是开发一个不依赖任何操作系统的小代码来引导。因此,我们不依赖它们中的任何一个,因为我们在引导时不需要操作系统来运行我们的代码。
为什么在 C 程序中使用汇编语句?
在实模式下,可以通过软件中断轻松访问 BIOS 功能,使用汇编语言指令。这导致了在我们的 C 代码中使用内联汇编。
如何将可执行代码复制到可引导设备然后进行测试?
要创建 1.4MB 大小的软盘映像,请在命令行中输入以下内容。
- dd if=/dev/zero of=floppy.img bs=512 count=2880
要将代码复制到软盘映像文件的引导扇区,请在命令行中输入以下内容。
- dd if=test.bin of=floppy.img
要在命令行中测试程序,请键入以下内容。
- bochs
您应该会看到如下所示的典型 bochs 仿真窗口。
观察:什么都没有发生,因为我们没有在代码中写入任何内容来显示在屏幕上。所以您只看到一条消息“从软盘引导”。恭喜!!!
- 我们使用
__asm__
关键字将汇编语言语句嵌入到 C 程序中。这个关键字提示编译器识别它是由用户给出的汇编指令。 - 我们还使用
__volatile__
来提示汇编器不要修改我们的代码,并保持原样。
这种将汇编代码嵌入到 C 代码中的方式称为内联汇编。
让我们再看一些在编译器上编写代码的例子。
让我们编写一个汇编程序,在屏幕上打印字母“X”。
示例:test2.c
__asm__(".code16\n");
__asm__("jmpl $0x0000, $main\n");
void main() {
__asm__ __volatile__ ("movb $'X' , %al\n");
__asm__ __volatile__ ("movb $0x0e, %ah\n");
__asm__ __volatile__ ("int $0x10\n");
}
输入上述内容后,保存为 test2.c,然后按照之前指示的方法进行编译,更改源文件名。当您编译并成功将此代码复制到引导扇区并运行 bochs 时,您应该会看到以下屏幕。在命令行中键入 bochs 查看结果,您应该会在屏幕上看到字母“X”,如以下屏幕截图所示。
现在,让我们编写一个 C 程序,在屏幕上打印字母“Hello, World”。
我们还将尝试通过定义函数和宏来打印字符串。
示例:test3.c
/*generate 16-bit code*/
__asm__(".code16\n");
/*jump boot code entry*/
__asm__("jmpl $0x0000, $main\n");
void main() {
/*print letter 'H' onto the screen*/
__asm__ __volatile__("movb $'H' , %al\n");
__asm__ __volatile__("movb $0x0e, %ah\n");
__asm__ __volatile__("int $0x10\n");
/*print letter 'e' onto the screen*/
__asm__ __volatile__("movb $'e' , %al\n");
__asm__ __volatile__("movb $0x0e, %ah\n");
__asm__ __volatile__("int $0x10\n");
/*print letter 'l' onto the screen*/
__asm__ __volatile__("movb $'l' , %al\n");
__asm__ __volatile__("movb $0x0e, %ah\n");
__asm__ __volatile__("int $0x10\n");
/*print letter 'l' onto the screen*/
__asm__ __volatile__("movb $'l' , %al\n");
__asm__ __volatile__("movb $0x0e, %ah\n");
__asm__ __volatile__("int $0x10\n");
/*print letter 'o' onto the screen*/
__asm__ __volatile__("movb $'o' , %al\n");
__asm__ __volatile__("movb $0x0e, %ah\n");
__asm__ __volatile__("int $0x10\n");
/*print letter ',' onto the screen*/
__asm__ __volatile__("movb $',' , %al\n");
__asm__ __volatile__("movb $0x0e, %ah\n");
__asm__ __volatile__("int $0x10\n");
/*print letter ' ' onto the screen*/
__asm__ __volatile__("movb $' ' , %al\n");
__asm__ __volatile__("movb $0x0e, %ah\n");
__asm__ __volatile__("int $0x10\n");
/*print letter 'W' onto the screen*/
__asm__ __volatile__("movb $'W' , %al\n");
__asm__ __volatile__("movb $0x0e, %ah\n");
__asm__ __volatile__("int $0x10\n");
/*print letter 'o' onto the screen*/
__asm__ __volatile__("movb $'o' , %al\n");
__asm__ __volatile__("movb $0x0e, %ah\n");
__asm__ __volatile__("int $0x10\n");
/*print letter 'r' onto the screen*/
__asm__ __volatile__("movb $'r' , %al\n");
__asm__ __volatile__("movb $0x0e, %ah\n");
__asm__ __volatile__("int $0x10\n");
/*print letter 'l' onto the screen*/
__asm__ __volatile__("movb $'l' , %al\n");
__asm__ __volatile__("movb $0x0e, %ah\n");
__asm__ __volatile__("int $0x10\n");
/*print letter 'd' onto the screen*/
__asm__ __volatile__("movb $'d' , %al\n");
__asm__ __volatile__("movb $0x0e, %ah\n");
__asm__ __volatile__("int $0x10\n");
}
现在将上述代码保存为 test3.c,然后按照提供的编译说明更改输入源文件名,并按照说明将编译后的代码复制到软盘的引导扇区。现在观察结果。如果一切正常,您应该会看到以下屏幕输出。
让我们编写一个 C 程序,在屏幕上打印字母“Hello, World”。
我们还将尝试定义函数,通过它来打印字符串。
示例:test4.c
/*generate 16-bit code*/
__asm__(".code16\n");
/*jump boot code entry*/
__asm__("jmpl $0x0000, $main\n");
/* user defined function to print series of characters terminated by null character */
void printString(const char* pStr) {
while(*pStr) {
__asm__ __volatile__ (
"int $0x10" : : "a"(0x0e00 | *pStr), "b"(0x0007)
);
++pStr;
}
}
void main() {
/* calling the printString function passing string as an argument */
printString("Hello, World");
}
现在将上述代码保存为 test3.c,然后按照提供的编译说明更改输入源文件名,并按照说明将编译后的代码复制到软盘的引导扇区。现在观察结果。如果一切正常,您应该会看到以下屏幕输出。
我想提醒您一点。我们所做的只是通过学习的方式将之前编写的汇编程序转换为 C 程序。现在您应该能够熟练地用汇编和 C 编写程序,并且清楚地知道如何编译和测试它们。
现在我们将继续编写循环,并使其在函数中工作,并查看更多 BIOS 服务。
一个显示矩形的小项目
现在让我们转向一些更大的东西……比如显示图形。
示例:test5.c
/* generate 16 bit code */
__asm__(".code16\n");
/* jump to main function or program code */
__asm__("jmpl $0x0000, $main\n");
#define MAX_COLS 320 /* maximum columns of the screen */
#define MAX_ROWS 200 /* maximum rows of the screen */
/* function to print string onto the screen */
/* input ah = 0x0e */
/* input al = <character to print> */
/* interrupt: 0x10 */
/* we use interrupt 0x10 with function code 0x0e to print */
/* a byte in al onto the screen */
/* this function takes string as an argument and then */
/* prints character by character until it founds null */
/* character */
void printString(const char* pStr) {
while(*pStr) {
__asm__ __volatile__ (
"int $0x10" : : "a"(0x0e00 | *pStr), "b"(0x0007)
);
++pStr;
}
}
/* function to get a keystroke from the keyboard */
/* input ah = 0x00 */
/* input al = 0x00 */
/* interrupt: 0x10 */
/* we use this function to hit a key to continue by the */
/* user */
void getch() {
__asm__ __volatile__ (
"xorw %ax, %ax\n"
"int $0x16\n"
);
}
/* function to print a colored pixel onto the screen */
/* at a given column and at a given row */
/* input ah = 0x0c */
/* input al = desired color */
/* input cx = desired column */
/* input dx = desired row */
/* interrupt: 0x10 */
void drawPixel(unsigned char color, int col, int row) {
__asm__ __volatile__ (
"int $0x10" : : "a"(0x0c00 | color), "c"(col), "d"(row)
);
}
/* function to clear the screen and set the video mode to */
/* 320x200 pixel format */
/* function to clear the screen as below */
/* input ah = 0x00 */
/* input al = 0x03 */
/* interrupt = 0x10 */
/* function to set the video mode as below */
/* input ah = 0x00 */
/* input al = 0x13 */
/* interrupt = 0x10 */
void initEnvironment() {
/* clear screen */
__asm__ __volatile__ (
"int $0x10" : : "a"(0x03)
);
__asm__ __volatile__ (
"int $0x10" : : "a"(0x0013)
);
}
/* function to print rectangles in descending order of */
/* their sizes */
/* I follow the below sequence */
/* (left, top) to (left, bottom) */
/* (left, bottom) to (right, bottom) */
/* (right, bottom) to (right, top) */
/* (right, top) to (left, top) */
void initGraphics() {
int i = 0, j = 0;
int m = 0;
int cnt1 = 0, cnt2 =0;
unsigned char color = 10;
for(;;) {
if(m < (MAX_ROWS - m)) {
++cnt1;
}
if(m < (MAX_COLS - m - 3)) {
++cnt2;
}
if(cnt1 != cnt2) {
cnt1 = 0;
cnt2 = 0;
m = 0;
if(++color > 255) color= 0;
}
/* (left, top) to (left, bottom) */
j = 0;
for(i = m; i < MAX_ROWS - m; ++i) {
drawPixel(color, j+m, i);
}
/* (left, bottom) to (right, bottom) */
for(j = m; j < MAX_COLS - m; ++j) {
drawPixel(color, j, i);
}
/* (right, bottom) to (right, top) */
for(i = MAX_ROWS - m - 1 ; i >= m; --i) {
drawPixel(color, MAX_COLS - m - 1, i);
}
/* (right, top) to (left, top) */
for(j = MAX_COLS - m - 1; j >= m; --j) {
drawPixel(color, j, m);
}
m += 6;
if(++color > 255) color = 0;
}
}
/* function is boot code and it calls the below functions */
/* print a message to the screen to make the user hit the */
/* key to proceed further and then once the user hits then */
/* it displays rectangles in the descending order */
void main() {
printString("Now in bootloader...hit a key to continue\n\r");
getch();
initEnvironment();
initGraphics();
}
现在将上述代码保存为 test5.c,然后按照提供的编译说明更改输入源文件名,并按照说明将编译后的代码复制到软盘的引导扇区。
现在观察结果。如果一切正常,您应该会看到以下屏幕输出。
现在按下一个键,看看接下来会发生什么。
观察
如果您仔细查看可执行文件的内容,您会发现我们几乎用尽了空间。由于引导扇区只有 512 字节,我们只能在程序中嵌入少数函数,例如初始化环境和打印彩色矩形,但不能再多了,因为它需要超过 512 字节的空间。下面是供您参考的快照。
本文到此为止。玩得开心,编写更多程序来探索实模式,你会发现使用 BIOS 中断在实模式下编程真的很有趣。在下一篇文章中,我将尝试解释用于访问数据的寻址模式、软盘的读取及其架构,以及为什么引导加载程序大多用汇编而不是 C 编写,以及用 C 编写引导加载程序在代码生成方面的限制 :)