
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到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<= 53 并且r1<= 7。首先我们使用前面看到的比较命令去将r0的值与 53 做比较。下一个指令cmpls仅在前一个比较指令结果是小于或与 53 相同时才会去运行。如果是这种情况,它将寄存器r1的值与 7 进行比较,其它的部分都和前面的是一样的。如果最后的比较结果是寄存器值大于那个数字,最后我们将返回到运行函数的代码处。这正是我们所希望的效果。如果
r0中的值大于 53,那么cmpls命令将不会去运行,但是movhi会运行。如果r0中的值 <= 53,那么cmpls命令会运行,它会将r1中的值与 7 进行比较,如果r1> 7,movhi会运行,函数结束,否则movhi不会运行,这样我们就确定r0<= 53 并且r1<= 7。
ls(低于或相同)与le(小于或等于)有一些细微的差别,以及后缀hi(高于)和gt(大于)也一样有一些细微差别,我们在后面将会讲到。将这些命令复制到上面的代码的下面位置。
push {lr} mov r2,r0 bl GetGpioAddress
push {reg1,reg2,...}复制列出的寄存器reg1、reg2、… 到栈顶。该命令仅能用于通用寄存器和lr寄存器。
bl lbl设置lr为下一个指令的地址并切换到标签lbl。这三个命令用于调用我们第一个方法。
push {lr}命令复制lr中的值到栈顶,这样我们在后面可以获取到它。当我们调用GetGpioAddress时必须要这样做,我们将需要使用lr去保存我们函数要返回的地址。如果我们对
GetGpioAddress函数一无所知,我们必须假设它改变了r0、r1、r2和r3的值 ,并移动我们的值到r4和r5中,以在函数完成之后保持它们的值一样。幸运的是,我们知道GetGpioAddress做了什么,并且我们也知道它仅改变了r0为 GPIO 地址,它并没有影响r1、r2或r3的值。因此,我们仅去将 GPIO 针脚号从r0中移出,这样它就不会被覆盖掉,但我们知道,可以将它安全地移到r2中,因为GetGpioAddress并不去改变r2。最后我们使用
bl指令去运行GetGpioAddress。通常,运行一个函数,我们使用一个术语叫“调用”,从现在开始我们将一直使用这个术语。正如我们前面讨论过的,bl调用一个函数是通过更新lr为下一个指令的地址并切换到该函数完成的。当一个函数结束时,我们称为“返回”。当一个
GetGpioAddress调用返回时,我们已经知道了r0中包含了 GPIO 的地址,r1中包含了函数编号,而r2中包含了 GPIO 针脚号。我前面说过,GPIO 函数每 10 个保存在一个块中,因此首先我们需要去判断我们的针脚在哪个块中。这似乎听起来像是要使用一个除法,但是除法做起来非常慢,因此对于这些比较小的数来说,不停地做减法要比除法更好。
将下面的代码复制到上面的代码中最下面的位置。
functionLoop$: cmp r2,#9 subhi r2,#10 addhi r0,#4 bhi functionLoop$
add reg,#val将数字val加到寄存器reg的内容上。这个简单的循环代码将针脚号(
r2)与 9 进行比较。如果它大于 9,它将从针脚号上减去 10,并且将 GPIO 控制器地址加上 4,然后再次运行检查。这样做的效果就是,现在,
r2中将包含一个 0 到 9 之间的数字,它是针脚号除以 10 的余数。r0将包含这个针脚的函数所设置的 GPIO 控制器的地址。它就如同是 “GPIO 控制器地址 + 4 × (GPIO 针脚号 ÷ 10)”。最后,将下面的代码复制到上面的代码中最下面的位置。
add r2, r2,lsl #1 lsl r1,r2 str r1,[r0] pop {pc}移位参数
reg,lsl #val表示将寄存器reg中二进制表示的数逻辑左移val位之后的结果作为与前面运算的操作数。
lsl reg,amt将寄存器reg中的二进制数逻辑左移amt中的位数。
str reg,[dst]与str reg,[dst,#0]相同。
pop {reg1,reg2,...}从栈顶复制值到寄存器列表reg1、reg2、… 仅有通用寄存器与pc可以这样弹出值。这个代码完成了这个方法。第一行其实是乘以 3 的变体。乘法在汇编中是一个大而慢的指令,因为电路需要很长时间才能给出答案。有时使用一些能够很快给出答案的指令会让它变得更快。在本案例中,我们知道
r2× 3 与r2× 2 +r2是相同的。一个寄存器乘以 2 是非常容易的,因为它可以通过将二进制表示的数左移一位来很方便地实现。ARMv6 汇编语言其中一个非常有用的特性就是,在使用它之前可以先移动参数所表示的位数。在本案例中,我将
r2加上r2中二进制表示的数左移一位的结果。在汇编代码中,你可以经常使用这个技巧去更快更容易地计算出答案,但如果你觉得这个技巧使用起来不方便,你也可以写成类似mov r3,r2;add r2,r3;add r2,r3这样的代码。现在,我们可以将一个函数的值左移
r2中所表示的位数。大多数对数量的指令(比如add和sub)都有一个可以使用寄存器而不是数字的变体。我们执行这个移位是因为我们想去设置表示针脚号的位,并且每个针脚有三个位。然后,我们将函数计算后的值保存到 GPIO 控制器的地址上。我们在循环中已经算出了那个地址,因此我们不需要像 OK01 和 OK02 中那样在一个偏移量上保存它。
最后,我们从这个方法调用中返回。由于我们将
lr推送到了栈上,因此我们pop pc,它将复制lr中的值并将它推送到pc中。这个操作类似于mov pc,lr,因此函数调用将返回到运行它的那一行上。敏锐的人可能会注意到,这个函数其实并不能正确工作。虽然它将 GPIO 针脚函数设置为所要求的值,但它会导致在同一个块中的所有的 10 个针脚的函数都归 0!在一个大量使用 GPIO 针脚的系统中,这将是一个很恼人的问题。我将这个问题留给有兴趣去修复这个函数的人,以确保只设置相关的 3 个位而不去覆写其它位,其它的所有位都保持不变。关于这个问题的解决方案可以在本课程的下载页面上找到。你可能会发现非常有用的几个函数是
and,它是计算两个寄存器的布尔与函数,mvns是计算布尔非函数,而orr是计算布尔或函数。4、另一个函数
现在,我们已经有了能够管理 GPIO 针脚函数的函数。我们还需要写一个能够打开或关闭 GPIO 针脚的函数。我们不需要写一个打开的函数和一个关闭的函数,只需要一个函数就可以做这两件事情。
我们将写一个名为
SetGpio的函数,它将 GPIO 针脚号作为第一个输入放入r0中,而将值作为第二个输入放入r1中。如果该值为0,我们将关闭针脚,而如果为非零则打开针脚。将下列的代码复制粘贴到
gpio.s文件的结尾部分。.globl SetGpio SetGpio: pinNum .req r0 pinVal .req r1
alias .req reg设置寄存器reg的别名为alias。我们再次需要
.globl命令,标记它为其它文件可访问的全局函数。这次我们将使用寄存器别名。寄存器别名允许我们为寄存器使用名字而不仅是r0或r1。到目前为止,寄存器别名还不是很重要,但随着我们后面写的方法越来越大,它将被证明非常有用,现在开始我们将尝试使用别名。当在指令中使用到pinNum .req r0时,它的意思是pinNum表示r0。将下面的代码复制粘贴到上述的代码下面位置。
cmp pinNum,#53 movhi pc,lr push {lr} mov r2,pinNum .unreq pinNum pinNum .req r2 bl GetGpioAddress gpioAddr .req r0
.unreq alias删除别名alias。就像在函数
SetGpio中所做的第一件事情是检查给定的针脚号是否有效一样。我们需要同样的方式去将pinNum(r0)与 53 进行比较,如果它大于 53 将立即返回。一旦我们想要再次调用GetGpioAddress,我们就需要将lr推送到栈上来保护它,将pinNum移动到r2中。然后我们使用.unreq语句来删除我们给r0定义的别名。因为针脚号现在保存在寄存器r2中,我们希望别名能够反映这个变化,因此我们从r0移走别名,重新定义到r2。你应该每次在别名使用结束后,立即删除它,这样当它不再存在时,你就不会在后面的代码中因它而产生错误。然后,我们调用了
GetGpioAddress,并且我们创建了一个指向r0的别名以反映此变化。将下面的代码复制粘贴到上述代码的后面位置。
pinBank .req r3 lsr pinBank,pinNum,#5a lsl pinBank,#2 add gpioAddr,pinBank .unreq pinBank
lsr dst,src,#val将src中二进制表示的数右移val位,并将结果保存到dst。对于打开和关闭 GPIO 针脚,每个针脚在 GPIO 控制器上有两个 4 字节组。第一个 4 字节组每个位控制前 32 个针脚,而第二个 4 字节组控制剩下的 22 个针脚。为了判断我们要设置的针脚在哪个 4 字节组中,我们需要将针脚号除以 32。幸运的是,这很容易,因为它等价于将二进制表示的针脚号右移 5 位。因此,在本案例中,我们将
r3命名为pinBank,然后计算pinNum÷ 32。因为它是一个 4 字节组,我们需要将它与 4 相乘的结果。它与二进制表示的数左移 2 位相同,这就是下一行的命令。你可能想知道我们能否只将它右移 3 位呢,这样我们就不用先右移再左移。但是这样做是不行的,因为当我们做 ÷ 32 时答案有些位可能被舍弃,而如果我们做 ÷ 8 时却不会这样。现在,
gpioAddr的结果有可能是 20200000 16(如果针脚号介于 0 到 31 之间),也有可能是 20200004 16(如果针脚号介于 32 到 53 之间)。这意味着如果加上 28 10,我们将得到打开针脚的地址,而如果加上 40 10 ,我们将得到关闭针脚的地址。由于我们用完了pinBank,所以在它之后立即使用.unreq去删除它。将下面的代码复制粘贴到上述代码的下面位置。
and pinNum,#31 setBit .req r3 mov setBit,#1 lsl setBit,pinNum .unreq pinNum
and reg,#val计算寄存器reg中的数与val的布尔与。该函数的下一个部分是产生一个正确的位集合的数。至于 GPIO 控制器去打开或关闭针脚,我们在针脚号除以 32 的余数里设置了位的数。例如,设置 16 号针脚,我们需要第 16 位设置数字为 1 。设置 45 号针脚,我们需要设置第 13 位数字为 1,因为 45 ÷ 32 = 1 余数 13。
这个
and命令计算我们需要的余数。它是这样计算的,在两个输入中所有的二进制位都是 1 时,这个and运算的结果就是 1,否则就是 0。这是一个很基础的二进制操作,and操作非常快。我们给定的输入是 “pinNum and 31 10 = 11111 2”。这意味着答案的后 5 位中只有 1,因此它肯定是在 0 到 31 之间。尤其是在pinNum的后 5 位的位置是 1 的地方它只有 1。这就如同被 32 整除的余数部分。就像 31 = 32 - 1 并不是巧合。
代码的其余部分使用这个值去左移 1 位。这就有了创建我们所需要的二进制数的效果。
将下面的代码复制粘贴到上述代码的下面位置。
teq pinVal,#0 .unreq pinVal streq setBit,[gpioAddr,#40] strne setBit,[gpioAddr,#28] .unreq setBit .unreq gpioAddr pop {pc}
teq reg,#val检查寄存器reg中的数字与val是否相等。这个代码结束了该方法。如前面所说,当
pinVal为 0 时,我们关闭它,否则就打开它。teq(等于测试)是另一个比较操作,它仅能够测试是否相等。它类似于cmp,但它并不能算出哪个数大。如果你只是希望测试数字是否相同,你可以使用teq。如果
pinVal是 0,我们将setBit保存在 GPIO 地址偏移 40 的位置,我们已经知道,这样会关闭那个针脚。否则将它保存在 GPIO 地址偏移 28 的位置,它将打开那个针脚。最后,我们通过弹出pc返回,这将设置它为我们推送链接寄存器时保存的值。5、一个新的开始
在完成上述工作后,我们终于有了我们的 GPIO 函数。现在,我们需要去修改
main.s去使用它们。因为main.s现在已经有点大了,也更复杂了。将它分成两节将是一个很好的设计。到目前为止,我们一直使用的.init应该尽可能的让它保持小。我们可以更改代码来很容易地反映出这一点。将下列的代码插入到
main.s文件中_start:的后面:b main .section .text main: mov sp,#0x8000在这里重要的改变是引入了
.text节。我设计了makefile和链接器脚本,它将.text节(它是默认节)中的代码放在地址为 8000 16 的.init节之后。这是默认加载地址,并且它给我们提供了一些空间去保存栈。由于栈存在于内存中,它也有一个地址。栈向下增长内存,因此每个新值都低于前一个地址,所以,这使得栈顶是最低的一个地址。
图中的 “ATAGs” 节的位置保存了有关树莓派的信息,比如它有多少内存,默认屏幕分辨率是多少。
用下面的代码替换掉所有设置 GPIO 函数针脚的代码:
pinNum .req r0 pinFunc .req r1 mov pinNum,#16 mov pinFunc,#1 bl SetGpioFunction .unreq pinNum .unreq pinFunc这个代码将使用针脚号 16 和函数编号 1 去调用
SetGpioFunction。它的效果就是启用了 OK LED 灯的输出。用下面的代码去替换打开 OK LED 灯的代码:
pinNum .req r0 pinVal .req r1 mov pinNum,#16 mov pinVal,#0 bl SetGpio .unreq pinNum .unreq pinVal这个代码使用
SetGpio去关闭 GPIO 第 16 号针脚,因此将打开 OK LED。如果我们(将第 4 行)替换成mov pinVal,#1它将关闭 LED 灯。用以上的代码去替换掉你关闭 LED 灯的旧代码。6、继续向目标前进
但愿你能够顺利地在你的树莓派上测试我们所做的这一切。到目前为止,我们已经写了一大段代码,因此不可避免会出现错误。如果有错误,可以去查看我们的排错页面。
如果你的代码已经正常工作,恭喜你。虽然我们的操作系统除了做 课程 2:OK02 中的事情,还做不了别的任何事情,但我们已经学会了函数和格式有关的知识,并且我们现在可以更好更快地编写新特性了。现在,我们在操作系统上修改 GPIO 寄存器将变得非常简单,而它就是用于控制硬件的!
在 课程 4:OK04 中,我们将处理我们的
wait函数,目前,它的时间控制还不精确,这样我们就可以更好地控制我们的 LED 灯了,进而最终控制所有的 GPIO 针脚。



