烤派宝典第三章之OK03
Nov 9, 2013Technology
#烤派宝典第三章之OK03 OK03 这一章基于OK02,将教会你在汇编语言中如何使用函数,函数的引入将使得汇编代码可用性大大提升,并提升代码的可读性。开始本章之前,我们假定你已经有了烤派宝典第二章之OK02的代码作为基础。
内容 |
---|
1 可重用的代码 |
2 第一个函数 |
3 一个大函数 |
4 另一个函数 |
5 新的开始 |
6 前瞻 |
##1 可重用的代码
迄今为止我们写操作系统时代码都是硬生生敲进去的。对于小程序而言无所谓,但倘若我们要把整个系统用这种方式写出来,代码将变得完全不可读。解决方案是我们将使用函数。
有关函数的解释:
函数是一小段用于计算出特定答案的、可重用的代码,或者它可以执行一个特定的动作。你可能听过它的不同叫法,例如procedures, routines, subroutines。 这些叫法没有本质上的差别,也没有特定的标准叫法。 你可能早就在数学课上接触过函数的概念了。比如余弦函数将根据给定的角度计算出一个介于-1和1之间的值。我们用标号cos(x)来表明这是参数为x的函数。在代码中,函数能拥有多个输入值(也可以无输入值),这可能会有点副作用。比如一个函数可能能完成多个功能:在文件系统上创建一个文件,读入标准输入,保存命名之。
在高级语言譬如C或C++中,函数是语言本身的概念。但是在汇编语言中,函数就是我们所拥有的想法。
理想情况下,我们设想给寄存器某些输入值,而后程序分支跳转到另一地址,执行完该地址的代码后将跳转回代码分支执行的地方,这时候寄存器中会得到输出值。这就是汇编语言中的函数执行流程。难点在于我们如何系统化组织和设置寄存器。如果我们使用我们所能想象到的方式来组织,那么每个程序员所撰写的代码都会变得难以理解。对于编译器而言,如何组织设置寄存器是无所谓的话题,因为它们压根就不知道如何使用函数。为了避免混淆,一种叫ABI(Application Binary Interface,二进制程序接口)的标准被引入,这是任何一种汇编语言中的约定俗成,规定了函数应该如何被运行。如果每个程序员都按照同样的方式来创建函数,那么所有的函数都可以被别人使用。在这里我会讲解这一标准,而且从现在开始我敲入的所有函数代码都将适应这一标准。
标准定义如下:寄存器r0,r1,r2,r3将被依次被用作函数的输入。如果函数无输入值,这些寄存器值就都可以被忽略。如果需要一个参数,则把参数放入r0中。如果需要两个参数,则第一个参数放入r0中,第二个参数放入r1中,依此类推。输出值将被放入r0中。如果函数无输出值,那就可以忽略r0里的值。
此外,标准还要求,函数运行完毕后,r4到r12必须保持函数运行前的取值。这意味着当你调用一个函数时,可以确认的是r4到r12的值不会发生变化,但是对r0到r3的值,我们就没法确保了。
函数通常被称之为“黑盒子”,我们输入值,然后得到输出值。但是我们不知道黑盒子内部是如何工作的。
函数执行完毕后需要回到函数被调用的起始点。这意味着我们必须知道代码是从何处被调用的。为方便起见,有一个特殊寄存器,名为lr(link register,链接寄存器),它总是用于记住指令调用该函数后的地址。
<th>概述</th>
<th>是否保留</th>
<th>规则</th>
</tr>
<td>参数和结果</td>
<td>No</td>
<td rowspan="2">r0 和 r1被用作传递第一个和第二个参数给函数,并存储在函数中返回的值。如果函数无返回值,则他们的值可为任意值。
</td>
</tr>
<tr class="highlightRow1">
<td>r1</td>
<td>参数和结果</td>
<td>No</td>
</tr>
<tr>
<td>r2</td>
<td>参数</td>
<td>No</td>
<td rowspan="2">r2 和r3被用于传递第3个参数起的两个参数给函数。当函数被运行了,它们可以被改变为其他任意值。
</td>
</tr>
<tr>
<td>r3</td>
<td>参数</td>
<td>No</td>
</tr>
<tr class="highlightRow1">
<td>r4</td>
<td>通用</td>
<td>Yes</td>
<td rowspan="9">r4 到 r12被用作工作值,他们的值在函数调用前后保持一致。
</td>
</tr>
<tr class="highlightRow1">
<td>r5</td>
<td>通用</td>
<td>Yes</td>
</tr>
<tr class="highlightRow1">
<td>r6</td>
<td>通用</td>
<td>Yes</td>
</tr>
<tr class="highlightRow1">
<td>r7</td>
<td>通用</td>
<td>Yes</td>
</tr>
<tr class="highlightRow1">
<td>r8</td>
<td>通用</td>
<td>Yes</td>
</tr>
<tr class="highlightRow1">
<td>r9</td>
<td>通用</td>
<td>Yes</td>
</tr>
<tr class="highlightRow1">
<td>r10</td>
<td>通用</td>
<td>Yes</td>
</tr>
<tr class="highlightRow1">
<td>r11</td>
<td>通用</td>
<td>Yes</td>
</tr>
<tr class="highlightRow1">
<td>r12</td>
<td>通用</td>
<td>Yes</td>
</tr>
<tr>
<td>lr</td>
<td>返回值</td>
<td>No</td>
<td>lr 中存储了函数完成时需要跳转回的地址,这个值在函数完成前保持不变.
</td>
</tr>
<tr class="highlightRow1">
<td>sp</td>
<td>栈指针</td>
<td>Yes</td>
<td>sp 是栈指针寄存器,描述如下。它的值需要保持到函数执行完后。
</td>
</tr>
通常函数运行时需要更多的寄存器,而不是仅仅局限于r0到r3,因为r4到r12的值需要在函数运行前后保持一直,在使用前我们就必须将它们保存到某个特定地方,这个特定的地方被称之为栈(Stack)。
栈是我们在计算机中用于存储数值的一个比喻。就像一叠盘子,你只能在这一堆盘子的顶部取走盘子,或者添加新的盘子到顶部。 在函数运行时候使用栈来存储寄存器数据是一个绝佳的主意。比如如果我有一个函数需要存储寄存器r4和r5的值,那我就可以直接将这些寄存器的值放到栈的顶部。调用完函数后再从栈中将其送回。更聪明的如果我在函数里又调用了另一个函数以便完成某些操作,我仍然可以将当前的寄存器值继续送到栈中,运行完再取回。反复的压栈和取栈操作并不会影响到r4和r5所存储位置的值,每次新的压栈都会被添加到栈的顶部,而后从同样的位置被移除。 我们用来在栈里存放数据的方法用术语来讲就叫“stack frame(堆栈桢)”。并不是每个函数都需要用到堆栈桢,有的函数压根儿就不需要保存数值。
由于栈如此有用,在ARMv6指令集中我们可以直接设置它。一个特殊的寄存器叫sp(stack pointer,栈指针)用于专门存放栈地址。当有新的条目被添加到栈中时,栈指针寄存器将更新其内容,以确保永远指向第一个条目的地址。push{r4,r5}这条指令将会把r4和r5放入到栈的顶部,pop{r4,r5}将把这两个条目从栈中取回(按正确的顺序取回)。
##2 第一个函数
现在我们已经知道函数是如何工作的了,让我们来写一个函数。作为第一个最基础的例子,我们要写一个没有输入的函数,输出则是GPIO地址。在上一章里,我们已经写入了这个值,但最好还是用函数来实现,因为在一个操作系统中我们需要频繁设置GPIO口的值,但我们不可能每一次都记得该GPIO口的物理地址。
拷贝下列代码到一个新文件中,命名为’gpio.s’。新文件应该在’source'文件夹中,和’main.s'同一级。我们将把所有与GPIO控制器相关的函数都放在这个文件中,以方便我们查找。
.globl GetGpioAddress
GetGpioAddress:
ldr r0,=0x20200000
mov pc,lr
这是一个很简单而完整的函数。.global GetGpioAddress命令是传递给汇编器的消息,这使得GetGpioAddress在所有的文件里都可见。也意味着在main.s文件里我们可以跳转到GetGpioAddress这个符号,尽管在当前文件中没有定义。
.globl lbl使得标号lbl可以在其他文件中被调用。
你应该已经认识ldr r0,=0x20200000命令了,这条命令将GPIO控制器地址加载入r0寄存器中。这里我们用函数的形式实现,需要把输出放入r0中,所以我们不再像以前一样可以自由使用任何寄存器了。
mov pc,lr将lr寄存器中存储的值拷贝到pc。如以前所说,lr中存储的地址永远是我们执行完函数后需要返回的地址。pc是一个特殊的寄存器,它含有下一条需要运行的指令的地址。正常的跳转命令将改变这个寄存器的值。通过把lr的值拷贝到pc,我们确保了函数执行完后依然可以返回当初调用该函数的下一行执行。
mov reg1,reg2将寄存器reg2中的值拷贝到寄存器reg1中。
一个合理的问题是:实际上我们是如何运行函数中的代码的?我们用一个特殊的跳转指令bl来完成这一点。它跳转到一个标签,就像是正常的分支指令,但是调用这条指令会更新lr的值,使得lr指向分支指令后的紧跟的代码行。更新完lr的值意味着当函数完成时,需要返回的行将是bl指令的下一行。这使得函数的调用和其他命令一样,它被调用,做完需要完成的事情后,继续执行下一行。这是思考函数工作方式时,一个非常有用的思考方式。使用函数时,我们把函数看作是‘黑盒子’,我们不需要考虑它们内部的运行逻辑和工作方式,我们只需要知道其所需要的输入和它们能给出的输出。
现在,别担心使用函数会带来什么坏处了,我们将在下一小节开始使用它们。
##3 一个大函数
接下来我们来实现一个大函数。我们要完成的第一个任务是使能GPIO16管脚的输出。用函数来实现它是一种优雅的方式。我们的代码将调用一个函数和给出一个管脚号,函数将设置该管脚的值。这样以来,我们将可以使用代码来控制任意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是函数代码所能取值的范围。我们可以假定输入都是正确的,但不加检查和限定的代码很危险,因为在实际的硬件上工作时,错误的输入值将导致可能灾难性的后果。因此,在这个例子中,我们希望能对输入的值做安全检查,使得它们都在正确的范围内。
我们需要检查r0<=53和r1<=7。首先,我们需要使用之前我们已经使用过的比较功能,对r0的值和53做比较。就下来,我们调用cmpls,这是一条通用的比较指令,只有在r0小于和等于53的时候才会被运行。如果这一步被执行了,我们接着比较r1和7的值,否则比对结果依然是上一次比对的结果。如果历经比较后,最后一次比对的值大于给定值(r0大于53或者r1大于7),我们将返回到调用函数的起始地址。
这正好是我们想要的结果,如果r0大于53,那么之后的cmpls命令就不会被执行。如果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 {lr}命令将lr的值拷贝入栈的顶部,以便我们在稍后取回它。这一步是必须的,因为我们需要调用GetGpioAddress函数,调用前,我们需要使用lr寄存器用于存储调用完毕后以便程序返回的地址。
push {reg1,reg2,…}将列表reg1,reg2….中的寄存器拷贝到栈的顶部。只有通用寄存器和lr才可以执行push指令。
如果我们不知道不了解GetGpioAddress的实现细节,那么我们必须假定调用函数会改变r0,r1,r2和r3,那么我们可以将这些值暂存入r4和r5中,因为这些寄存器的值在函数调用的前后不会发生变化。幸运的是,这里我们已经知道GetGpioAddress的实现细节了,我们知道它仅仅改变了r0寄存器的值,而不会影响到r1,r2和r3。 因此,我们只需要将r0的值移出即可,这样就不会在函数调用中被覆盖,我们可以把它暂存入r2中,因为GetGpioAddress函数并不会改动r2的值。
最后我们使用bl指令来运行GetGpioAddress。 通常我们使用名词'调用(call)‘来运行一个函数。之前我们说过,bl在调用函数前会把lr更新为下一条指令的地址后,才分支跳转到函数中去。
bl lbl将lr寄存器指向下一条指令地址,然后跳转执行到标号lbl所示的代码。
当函数调用完毕后,我们可以说函数'返回'了。当GetGpioAddress返回时,我们知道r0寄存器中包含了GPIO地址,r1中包含了函数代码r2包含了GPIO 管脚数。我之前提到过GPIO函数以10的block大小存储,所以首先我们需要确定我们所得到的是在哪一个块中。听起来我们需要完成一个除法,但是除法在处理器中执行起来很慢,这里我们最好使用重复减法。
在上面代码的基础上,拷贝入下列代码行:
functionLoop$:
cmp r2,#9
subhi r2,#10
addhi r0,#4
bhi functionLoop$
add reg,#val 将数字val加到reg寄存器所包含的值中去。
这个简单的循环代码比较了管脚号和9, 如果管脚号大于9, 那它将自动减少10,然后把地址在GPIO控制器的基础上加上4后,重新运行检查程序。
这样做的效果是,r2将包含0~9之间的数字,用于表示管脚除以10后的余数。 r0现在则包含了GPIO控制器中关于管脚的地址。效果等同于GPIO控制器基准地址 + 4 x (GPIO管脚号/10)。
最后,在上述代码的基础上,拷贝入下列代码行:
add r2, r2,lsl #1
lsl r1,r2
str r1,[r0]
pop {pc}
这几行代码完成了这个函数。第一行实际上是将数字乘以3. 乘法在汇编代码中执行起来很慢,因为电路需要很长时间才能运算出答案。有时候,使用某些指令能更快的得到结果。 在这个例子中,我知道r2x3的结果相当于r2x2+r2。 将r2乘以2的操作实际上就是将二进制表示的数字左移一位。
reg,lsl #val将二进制表示的reg中的值左移val个位。这个移位操作在使用它之前就会做完。
ARMv6汇编语言的一个很有用的特性是能够在使用一个数字前将其做偏移。在这个例子中,我将r2加上了r2二进制表示下左移一行以后的结果。在汇编代码中,我们可以使用这样的技巧以便使得更容易得到计算结果,如果你觉得这样实现起来不舒服,你仍然可以使用传统的方式来实现,比如: mov r3,r2; add r2,r3; add r2,r3.
lsl reg,amt 将二进制表示的reg中的值左移amt个位置。
现在我们将r1的值左移r2的位置。大多数诸如add和sub的指令有一个使用寄存器而不是使用数值的变体。我们运行这条shift指令是因为我们需要设置管脚值所对应的比特位, 每个管脚有3个比特位。
我们接下来将计算出的函数地址存储到GPIO控制器中,我们已经在循环中计算出了地址,所以我们不再需要像在OK01和OK02中使用便移了。
str reg,[dst]和str reg,[dest,#0]作用效果相同。
最后,我们从这个函数调用中返回。 因为我们以前曾把lr放入了栈中,如果我们将pc取出, 它将拷贝上一次我们放入栈中的lr值。这和mov pc,lr这条指令执行的效果是一样的, 执行完这一行后,函数调用就返回了。
pop {reg1,reg2,…} 将值从栈顶按顺序取出,仅通用寄存器和pc才可以被pop出。
细心的家伙可以会注意到这个函数实际上是不能正运行的。尽管它设置了正确的数值和GPIO管脚给函数, 但是它导致同一个块中的10个函数的值都变成了0!因为频繁使用了GPIO管脚设置函数,会给系统带来很重的负担。这里我把练习留给你,让你来修正这个错误,使得我们在设置某一个管脚时候,不会影响到别的管脚状态,所做的改动仅限于该管脚对应的3个bit位上。这个练习的解决方案可以在本章的下载页面中找到。可能对你有用的函数包括:计算布尔值的and,用于计算布尔的与,not,计算布尔值的或,orr用于计算布尔值的或。
##4 另一个函数
现在,我们已经有了一个可以用于设置GPIO管脚函数的函数。我们现在需要一个可以用于设置GPIO管脚导通和断开的函数。与其写两个函数来完成这两个功能,不如用一个函数来控制两种状态。
我们将创建一个叫SetGpio的函数,这个函数的第一个输入参数是r0,用于记录GPIO的管脚号,第二个参数用于记录该管脚的取值。如果取值为0,我们将管脚断开,如果是非0值,我们将这个管脚导通。
在文件’gpio.s'的末尾拷贝和粘贴进下列代码:
.globl SetGpio
SetGpio:
pinNum .req r0
pinVal .req r1
alias .req reg 设置了寄存器的别名为reg。
我们又一次使用了.globl命令用于将这个函数设为全局可访问(可以从其他文件中访问)。这次我们要设置一个寄存器的别名。寄存器别名允许我们使用一个更容易记忆的名字,而不是直接使用晦涩难懂的r0或是r1。现在看起来设置别名不重要,但是在写大的函数体的时候你会发现这一点很有用。现在开始你可以试着使用别名了。 pinNum .reg 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.
和SetGpioFunction类似,函数开始的第一件事情就是用来检查管脚是否是一个有效值。 我们将首先比较pinNum(r0)值和53, 如果管脚值大于53,函数将立刻返回。 既然我们需要调用GetGpioAddress函数,我们需要在栈中保存lr的值,并将pinNum(r0)的值保存到r2。接着我们使用.unreq声明来移除关于r0的别名。因为管脚取值现在被存储在r2中,我们需要别名映射到它,所以我们在移除r0的同时,新建了一个指向r2的别名。一旦我们不再使用变量别名,第一件事就是将它移除掉,这样在之后的代码中你就不会引用到一个不再使用的别名了。
接着我们调用GetGpioAddress,并创建一个指向r0的别名来表示它。
在上述代码的基础上,拷贝和粘贴下列代码:
pinBank .req r3
lsr pinBank,pinNum,#5
lsl pinBank,#2
add gpioAddr,pinBank
.unreq pinBank
lsr dst,src,#val 将二进制形式表示的数字src右移val个位置,并将结果保存在dst中。
GPIO控制器有两个4比特的序列用于控制管脚的导通与截至。第一个序列控制着前32个管脚的状态,第二个序列则控制着剩下的22个。为了确定我们需要哪个序列,我们需要将管脚除以32。除法在这里非常容易,就等同于将二进制格式表示的管脚值右移5个位。 因此,在这个例子中将r3命名为pinBank,并将pinNum/32的值存入其中。因为序列的大小是4个Byte,要得到地址,则需要将pinBank的值乘以4。乘以4这个操作等同于将二进制表示的值左移两个位,这就是lsl pinBank,#2这条指令做的事。你可能会奇怪,为什么我们非要先右移后左移,先右5后左2干嘛不直接右3?答案是如果直接右移3位将不会工作,因为在执行/32的时候,有些值会被截断。而单纯除以8则达不到这个效果。
现在就诶过是gpioAddr会被设置为,如果管脚是0-31时值为2020000016, 如果管脚是32-53时则是2020000416。 这意味着如果我们加上2810,我们将会把管脚导通,加上4010则会把管教截至。我们现在可以不用使用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为1,我们需要将第13位设置为1,原因是因为45/32的余数是13。
与运算指令and可用于计算余数。对两个数进行and运算的结果是只有当两个数的某个位都为1才为1,否则就为0. 这是一个很基本的二进制运算,速度非常快。这里我们给出的输入值是pinNum and 31(10进制的31) = 11111(二进制)。这意味着只有最后5个位置的值会被计入最终结果,因而所得的结果肯定在0~31之间。具体来说,只有为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( test equal,测试相等)是另一条比较指令,只能用于测试相等。它和cmp相似,但是不用用于比较哪个数字更大。如果你要做的只是比较两个数字是否相等,你可以直接使用 teq指令。
如果 pinVal的值为0,我们将调用setBit在GPIO基准地址40, 这能将管脚设为断开状态。否则我们将其放置于GPIO基准地址28的地址,这能将管教置为导通。最后,我们通过从栈中压出之前保存的值,从而返回。
##5 新的开始
最后,我们终于拥有了自己的GPIO函数。现在我们修改’main.s'以使用它。因为’main.s'现在变得越来越大,结构也越来越复杂,我们最好将它分成两个部分。‘init'部分我们已经使用过,保持它越小越好。以便我们将来修改的方便,修改完立马就可以看到结果。
将下列代码插入到main.s文件中_start: 之后:
b main
.section .text
main:
mov sp,#0x8000
我们这里的主要修改在于引入了.text部分。在我的makefile和链接脚本中,.text部分(即默认部分)中的代码会被放置在.init部分之后,位置在800016。这是默认的加载地址,给了我们用于存储栈的空间。因为栈存在于内存中,它需要有一个地址。栈在内存中从上向下增长, 这样每一个新插入的值都拥有最低的地址,这就是栈的"栈顶”, 越靠近栈顶,地址越低。
图中的 ‘ATAGs'部分存储了Raspberry Pi硬件相关的信息,譬如,板载内存大小,默认的屏幕分辨率,等等。
将上一章中用于设置GPIO管脚的代码换成下列代码:
pinNum .req r0
pinFunc .req r1
mov pinNum,#16
mov pinFunc,#1
bl SetGpioFunction
.unreq pinNum
.unreq pinFunc
以上代码调用了SetGpioFunction函数,参数为管脚号16,管脚函数名为1.这使得该管脚置为输出状态。
把上一章中用于点亮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。如果我们使用mov pinVal, #1,那么LED将熄灭。把你之前用于点亮LED的代码换成上述代码。
##6 前瞻
希望到现在,你可以在Raspberry Pi上测试完上面写完的代码。这一章我们写了很大一块代码,免不了我们会碰到各种乱七八糟的错误。如果你遇到了,请参考troubleshooting页面。
如果你使用本章代码成功点亮了你的OK LED,恭喜你!对比于烤派宝典2之OK02而言,我们所做的事情效果一样,但是我们接触到了许多新的概念,譬如函数和格式,我们还学会了如何写出更块的函数的特性。现在稍微修改修改我们代码,指向不同的GPIO寄存器,就能让我们的操作系统更为完善,通过控制不同的GPIO寄存器,我们可以自由控制任何想控制的硬件。
在下一章OK04中,我们将引入更为精确的定时函数,这样我们可以更好的控制LED,最终我们将玩转所有的GPIO口。