OK03 课程基于 OK02 课程来构建,它教你在汇编中如何使用函数让代码可复用和可读性更好。假设你已经有了 课程 2:OK02 的操作系统,我们将以它为基础。
1、可复用的代码
到目前为止,我们所写的代码都是以我们希望发生的事为顺序来输入的。对于非常小的程序来说,这种做法很好,但是如果我们以这种方式去写一个完整的系统,所写的代码可读性将非常差。我们应该去使用函数。
一个函数是一段可复用的代码片断,可以用于去计算某些答案,或执行某些动作。你也可以称它们为 过程 procedure 、 例程 routine 或 子例程 subroutine 。虽然它们都是不同的,但人们几乎都没有正确地使用这个术语。
你应该在数学上遇到了函数的概念。例如,余弦函数应用于一个给定的数时,会得到介于 -1 到 1 之间的另一个数,这个数就是角的余弦。一般我们写成
cos(x)
来表示应用到一个值x
上的余弦函数。在代码中,函数可以有多个输入(也可以没有输入),然后函数给出多个输出(也可以没有输出),并可能导致副作用。例如一个函数可以在一个文件系统上创建一个文件,第一个输入是它的名字,第二个输入是文件的长度。
函数可以认为是一个“黑匣子”。我们给它输入,然后它给我们输出,而我们不需要知道它是如何工作的。
在像 C 或 C++ 这样的高级代码中,函数是语言的组成部分。在汇编代码中,函数只是我们的创意。
理想情况下,我们希望能够在我们的寄存器中设置一些输入值,然后分支切换到某个地址,然后预期在某个时刻分支返回到我们代码,并通过代码来设置输出值到寄存器。这就是我们所设想的汇编代码中的函数。困难之处在于我们用什么样的方式去设置寄存器。如果我们只是使用平时所接触到的某种方法去设置寄存器,每个程序员可能使用不同的方法,这样你将会发现你很难理解其他程序员所写的代码。另外,编译器也不能像使用汇编代码那样轻松地工作,因为它们压根不知道如何去使用函数。为避免这种困惑,为每个汇编语言设计了一个称为 应用程序二进制接口 Application Binary Interface (ABI)的标准,由它来规范函数如何去运行。如果每个人都使用相同的方法去写函数,这样每个人都可以去使用其他人写的函数。在这里,我将教你们这个标准,而从现在开始,我所写的函数将全部遵循这个标准。
该标准规定,寄存器 r0
、r1
、r2
和 r3
将被依次用于函数的输入。如果函数没有输入,那么它不会在意值是什么。如果只需要一个输入,那么它应该总是在寄存器 r0
中,如果它需要两个输入,那么第一个输入在寄存器 r0
中,而第二个输入在寄存器 r1
中,依此类推。输出值也总是在寄存器 r0
中。如果函数没有输出,那么 r0
中是什么值就不重要了。
另外,该标准要求当一个函数运行之后,寄存器 r4
到 r12
的值必须与函数启动时的值相同。这意味着当你调用一个函数时,你可以确保寄存器 r4
到 r12
中的值没有发生变化,但是不能确保寄存器 r0
到 r3
中的值也没有发生变化。
当一个函数运行完成后,它将返回到启动它的代码分支处。这意味着它必须知道启动它的代码的地址。为此,需要一个称为 lr
(链接寄存器)的专用寄存器,它总是在保存调用这个函数的指令后面指令的地址。
表 1.1 ARM ABI 寄存器用法
寄存器 | 简介 | 保留 | 规则 |
---|---|---|---|
r0 |
参数和结果 | 否 | r0 和 r1 用于给函数传递前两个参数,以及函数返回的结果。如果函数返回值不使用它,那么在函数运行之后,它们可以携带任何值。 |
r1 |
参数和结果 | 否 | |
r2 |
参数 | 否 | r2 和 r3 用去给函数传递后两个参数。在函数运行之后,它们可以携带任何值。 |
r3 |
参数 | 否 | |
r4 |
通用寄存器 | 是 | r4 到 r12 用于保存函数运行过程中的值,它们的值在函数调用之后必须与调用之前相同。 |
r5 |
通用寄存器 | 是 | |
r6 |
通用寄存器 | 是 | |
r7 |
通用寄存器 | 是 | |
r8 |
通用寄存器 | 是 | |
r9 |
通用寄存器 | 是 | |
r10 |
通用寄存器 | 是 | |
r11 |
通用寄存器 | 是 | |
r12 |
通用寄存器 | 是 | |
lr |
返回地址 | 否 | 当函数运行完成后,lr 中保存了分支的返回地址,但在函数运行完成之后,它将保存相同的地址。 |
sp |
栈指针 | 是 | sp 是栈指针,在下面有详细描述。它的值在函数运行完成后,必须是相同的。 |
通常,函数需要使用很多的寄存器,而不仅是 r0
到 r3
。但是,由于 r4
到 r12
必须在函数完成之后值必须保持相同,因此它们需要被保存到某个地方。我们将它们保存到称为栈的地方。
一个 栈 stack 就是我们在计算中用来保存值的一个很形象的方法。就像是摞起来的一堆盘子,你可以从上到下来移除它们,而添加它们时,你只能从下到上来添加。
在函数运行时,使用栈来保存寄存器值是个非常好的创意。例如,如果我有一个函数需要去使用寄存器
r4
和r5
,它将在一个栈上存放这些寄存器的值。最后用这种方式,它可以再次将它拿回来。更高明的是,如果为了运行完我的函数,需要去运行另一个函数,并且那个函数需要保存一些寄存器,在那个函数运行时,它将把寄存器保存在栈顶上,然后在结束后再将它们拿走。而这并不会影响我保存在寄存器r4
和r5
中的值,因为它们是在栈顶上添加的,拿走时也是从栈顶上取出的。用来表示使用特定的方法将值放到栈上的专用术语,我们称之为那个方法的“ 栈帧 stack frame ”。不是每种方法都使用一个栈帧,有些是不需要存储值的。
因为栈非常有用,它被直接实现在 ARMv6 的指令集中。一个名为 sp
(栈指针)的专用寄存器用来保存栈的地址。当需要有值添加到栈上时,sp
寄存器被更新,这样就总是保证它保存的是栈上第一个值的地址。push {r4,r5}
将推送 r4
和 r5
中的值到栈顶上,而 pop {r4,r5}
将(以正确的次序)取回它们。
2、我们的第一个函数
现在,关于函数的原理我们已经有了一些概念,我们尝试来写一个函数。由于是我们的第一个很基础的例子,我们写一个没有输入的函数,它将输出 GPIO 的地址。在上一节课程中,我们就是写到这个值上,但将它写成函数更好,因为我们在真实的操作系统中经常需要用到它,而我们不可能总是能够记住这个地址。
复制下列代码到一个名为 gpio.s
的新文件中。就像在 source
目录中使用的 main.s
一样。我们将把与 GPIO 控制器相关的所有函数放到一个文件中,这样更好查找。
.globl GetGpioAddress
GetGpioAddress:
ldr r0,=0x20200000
mov pc,lr
.globl lbl
使标签lbl
从其它文件中可访问。
mov reg1,reg2
复制reg2
中的值到reg1
中。
这就是一个很简单的完整的函数。.globl GetGpioAddress
命令是通知汇编器,让标签 GetGpioAddress
在所有文件中全局可访问。这意味着在我们的 main.s
文件中,我们可以使用分支指令到标签 GetGpioAddress
上,即便这个标签在那个文件中没有定义也没有问题。
你应该认得 ldr r0,=0x20200000
命令,它将 GPIO 控制器地址保存到 r0
中。由于这是一个函数,我们必须要让它输出到寄存器 r0
中,我们不能再像以前那样随意使用任意一个寄存器了。
mov pc,lr
将寄存器 lr
中的值复制到 pc
中。正如前面所提到的,寄存器 lr
总是保存着方法完成后我们要返回的代码的地址。pc
是一个专用寄存器,它总是包含下一个要运行的指令的地址。一个普通的分支命令只需要改变这个寄存器的值即可。通过将 lr
中的值复制到 pc
中,我们就可以将要运行的下一行命令改变成我们将要返回的那一行。
理所当然这里有一个问题,那就是我们如何去运行这个代码?我们将需要一个特殊的分支类型 bl
指令。它像一个普通的分支一样切换到一个标签,但它在切换之前先更新 lr
的值去包含一个在该分支之后的行的地址。这意味着当函数执行完成后,将返回到 bl
指令之后的那一行上。这就确保了函数能够像任何其它命令那样运行,它简单地运行,做任何需要做的事情,然后推进到下一行。这是理解函数最有用的方法。当我们使用它时,就将它们按“黑匣子”处理即可,不需要了解它是如何运行的,我们只了解它需要什么输入,以及它给我们什么输出即可。
到现在为止,我们已经明白了函数如何使用,下一节我们将使用它。
3、一个大的函数
现在,我们继续去实现一个更大的函数。我们的第一项任务是启用 GPIO 第 16 号针脚的输出。如果它是一个函数那就太好了。我们能够简单地指定一个针脚号和一个函数作为输入,然后函数将设置那个针脚的值。那样,我们就可以使用这个代码去控制任意的 GPIO 针脚,而不只是 LED 了。
将下列的命令复制到 gpio.s
文件中的 GetGpioAddress
函数中。
.globl SetGpioFunction
SetGpioFunction:
cmp r0,#53
cmpls r1,#7
movhi pc,lr
带后缀
ls
的命令只有在上一个比较命令的结果是第一个数字小于或与第二个数字相同的情况下才会被运行。它是无符号的。带后缀
hi
的命令只有上一个比较命令的结果是第一个数字大于第二个数字的情况下才会被运行。它是无符号的。
在写一个函数时,我们首先要考虑的事情就是输入,如果输入错了我们怎么办?在这个函数中,我们有一个输入是 GPIO 针脚号,而它必须是介于 0 到 53 之间的数字,因为只有 54 个针脚。每个针脚有 8 个函数,被编号为 0 到 7,因此函数编号也必须是 0 到 7 之间的数字。我们可以假设输入应该是正确的,但是当在硬件上使用时,这种做法是非常危险的,因为不正确的值将导致非常糟糕的副作用。所以,在这个案例中,我们希望确保输入值在正确的范围。
为了确保输入值在正确的范围,我们需要做一个检查,即 r0