计算机实验室之树莓派:课程 3 OK03

OK03 课程基于 OK02 课程来构建,它教你在汇编中如何使用函数让代码可复用和可读性更好。假设你已经有了 课程 2:OK02 的操作系统,我们将以它为基础。

1、可复用的代码

到目前为止,我们所写的代码都是以我们希望发生的事为顺序来输入的。对于非常小的程序来说,这种做法很好,但是如果我们以这种方式去写一个完整的系统,所写的代码可读性将非常差。我们应该去使用函数。

一个函数是一段可复用的代码片断,可以用于去计算某些答案,或执行某些动作。你也可以称它们为过程procedure例程routine子例程subroutine。虽然它们都是不同的,但人们几乎都没有正确地使用这个术语。

你应该在数学上遇到了函数的概念。例如,余弦函数应用于一个给定的数时,会得到介于 -1 到 1 之间的另一个数,这个数就是角的余弦。一般我们写成 cos(x) 来表示应用到一个值 x 上的余弦函数。

在代码中,函数可以有多个输入(也可以没有输入),然后函数给出多个输出(也可以没有输出),并可能导致副作用。例如一个函数可以在一个文件系统上创建一个文件,第一个输入是它的名字,第二个输入是文件的长度。

Function as black boxes

函数可以认为是一个“黑匣子”。我们给它输入,然后它给我们输出,而我们不需要知道它是如何工作的。

在像 C 或 C++ 这样的高级代码中,函数是语言的组成部分。在汇编代码中,函数只是我们的创意。

理想情况下,我们希望能够在我们的寄存器中设置一些输入值,然后分支切换到某个地址,然后预期在某个时刻分支返回到我们代码,并通过代码来设置输出值到寄存器。这就是我们所设想的汇编代码中的函数。困难之处在于我们用什么样的方式去设置寄存器。如果我们只是使用平时所接触到的某种方法去设置寄存器,每个程序员可能使用不同的方法,这样你将会发现你很难理解其他程序员所写的代码。另外,编译器也不能像使用汇编代码那样轻松地工作,因为它们压根不知道如何去使用函数。为避免这种困惑,为每个汇编语言设计了一个称为应用程序二进制接口Application Binary Interface(ABI)的标准,由它来规范函数如何去运行。如果每个人都使用相同的方法去写函数,这样每个人都可以去使用其他人写的函数。在这里,我将教你们这个标准,而从现在开始,我所写的函数将全部遵循这个标准。

该标准规定,寄存器 r0r1r2r3 将被依次用于函数的输入。如果函数没有输入,那么它不会在意值是什么。如果只需要一个输入,那么它应该总是在寄存器 r0 中,如果它需要两个输入,那么第一个输入在寄存器 r0 中,而第二个输入在寄存器 r1 中,依此类推。输出值也总是在寄存器 r0 中。如果函数没有输出,那么 r0 中是什么值就不重要了。

另外,该标准要求当一个函数运行之后,寄存器 r4r12 的值必须与函数启动时的值相同。这意味着当你调用一个函数时,你可以确保寄存器 r4r12 中的值没有发生变化,但是不能确保寄存器 r0r3 中的值也没有发生变化。

当一个函数运行完成后,它将返回到启动它的代码分支处。这意味着它必须知道启动它的代码的地址。为此,需要一个称为 lr(链接寄存器)的专用寄存器,它总是在保存调用这个函数的指令后面指令的地址。

表 1.1 ARM ABI 寄存器用法

寄存器 简介 保留 规则
r0 参数和结果 r0r1 用于给函数传递前两个参数,以及函数返回的结果。如果函数返回值不使用它,那么在函数运行之后,它们可以携带任何值。
r1 参数和结果  
r2 参数 r2r3 用去给函数传递后两个参数。在函数运行之后,它们可以携带任何值。
r3 参数  
r4 通用寄存器 r4r12 用于保存函数运行过程中的值,它们的值在函数调用之后必须与调用之前相同。
r5 通用寄存器  
r6 通用寄存器  
r7 通用寄存器  
r8 通用寄存器  
r9 通用寄存器  
r10 通用寄存器  
r11 通用寄存器  
r12 通用寄存器  
lr 返回地址 当函数运行完成后,lr 中保存了分支的返回地址,但在函数运行完成之后,它将保存相同的地址。
sp 栈指针 sp 是栈指针,在下面有详细描述。它的值在函数运行完成后,必须是相同的。

通常,函数需要使用很多的寄存器,而不仅是 r0r3。但是,由于 r4r12 必须在函数完成之后值必须保持相同,因此它们需要被保存到某个地方。我们将它们保存到称为栈的地方。

Stack diagram

一个stack就是我们在计算中用来保存值的一个很形象的方法。就像是摞起来的一堆盘子,你可以从上到下来移除它们,而添加它们时,你只能从下到上来添加。

在函数运行时,使用栈来保存寄存器值是个非常好的创意。例如,如果我有一个函数需要去使用寄存器 r4r5,它将在一个栈上存放这些寄存器的值。最后用这种方式,它可以再次将它拿回来。更高明的是,如果为了运行完我的函数,需要去运行另一个函数,并且那个函数需要保存一些寄存器,在那个函数运行时,它将把寄存器保存在栈顶上,然后在结束后再将它们拿走。而这并不会影响我保存在寄存器 r4r5 中的值,因为它们是在栈顶上添加的,拿走时也是从栈顶上取出的。

用来表示使用特定的方法将值放到栈上的专用术语,我们称之为那个方法的“栈帧stack frame”。不是每种方法都使用一个栈帧,有些是不需要存储值的。

因为栈非常有用,它被直接实现在 ARMv6 的指令集中。一个名为 sp(栈指针)的专用寄存器用来保存栈的地址。当需要有值添加到栈上时,sp 寄存器被更新,这样就总是保证它保存的是栈上第一个值的地址。push {r4,r5} 将推送 r4r5 中的值到栈顶上,而 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,...} 复制列出的寄存器 reg1reg2、… 到栈顶。该命令仅能用于通用寄存器和 lr 寄存器。

bl lbl 设置 lr 为下一个指令的地址并切换到标签 lbl

这三个命令用于调用我们第一个方法。push {lr} 命令复制 lr 中的值到栈顶,这样我们在后面可以获取到它。当我们调用 GetGpioAddress 时必须要这样做,我们将需要使用 lr 去保存我们函数要返回的地址。

如果我们对 GetGpioAddress 函数一无所知,我们必须假设它改变了 r0r1r2r3 的值 ,并移动我们的值到 r4r5 中,以在函数完成之后保持它们的值一样。幸运的是,我们知道 GetGpioAddress 做了什么,并且我们也知道它仅改变了 r0 为 GPIO 地址,它并没有影响 r1r2r3 的值。因此,我们仅去将 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,...} 从栈顶复制值到寄存器列表 reg1reg2、… 仅有通用寄存器与 pc 可以这样弹出值。

这个代码完成了这个方法。第一行其实是乘以 3 的变体。乘法在汇编中是一个大而慢的指令,因为电路需要很长时间才能给出答案。有时使用一些能够很快给出答案的指令会让它变得更快。在本案例中,我们知道 r2 × 3 与 r2 × 2 + r2 是相同的。一个寄存器乘以 2 是非常容易的,因为它可以通过将二进制表示的数左移一位来很方便地实现。

ARMv6 汇编语言其中一个非常有用的特性就是,在使用它之前可以先移动参数所表示的位数。在本案例中,我将 r2 加上 r2 中二进制表示的数左移一位的结果。在汇编代码中,你可以经常使用这个技巧去更快更容易地计算出答案,但如果你觉得这个技巧使用起来不方便,你也可以写成类似 mov r3,r2add r2,r3add r2,r3 这样的代码。

现在,我们可以将一个函数的值左移 r2 中所表示的位数。大多数对数量的指令(比如 addsub)都有一个可以使用寄存器而不是数字的变体。我们执行这个移位是因为我们想去设置表示针脚号的位,并且每个针脚有三个位。

然后,我们将函数计算后的值保存到 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 命令,标记它为其它文件可访问的全局函数。这次我们将使用寄存器别名。寄存器别名允许我们为寄存器使用名字而不仅是 r0r1。到目前为止,寄存器别名还不是很重要,但随着我们后面写的方法越来越大,它将被证明非常有用,现在开始我们将尝试使用别名。当在指令中使用到 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 中所做的第一件事情是检查给定的针脚号是否有效一样。我们需要同样的方式去将 pinNumr0)与 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,#valsrc 中二进制表示的数右移 val 位,并将结果保存到 dst

对于打开和关闭 GPIO 针脚,每个针脚在 GPIO 控制器上有两个 4 字节组。第一个 4 字节组每个位控制前 32 个针脚,而第二个 4 字节组控制剩下的 22 个针脚。为了判断我们要设置的针脚在哪个 4 字节组中,我们需要将针脚号除以 32。幸运的是,这很容易,因为它等价于将二进制表示的针脚号右移 5 位。因此,在本案例中,我们将 r3 命名为 pinBank,然后计算 pinNum ÷ 32。因为它是一个 4 字节组,我们需要将它与 4 相乘的结果。它与二进制表示的数左移 2 位相同,这就是下一行的命令。你可能想知道我们能否只将它右移 3 位呢,这样我们就不用先右移再左移。但是这样做是不行的,因为当我们做 ÷ 32 时答案有些位可能被舍弃,而如果我们做 ÷ 8 时却不会这样。

现在,gpioAddr 的结果有可能是 2020000016(如果针脚号介于 0 到 31 之间),也有可能是 2020000416(如果针脚号介于 32 到 53 之间)。这意味着如果加上 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 号针脚,我们需要设置第 13 位数字为 1,因为 45 ÷ 32 = 1 余数 13。

这个 and 命令计算我们需要的余数。它是这样计算的,在两个输入中所有的二进制位都是 1 时,这个 and 运算的结果就是 1,否则就是 0。这是一个很基础的二进制操作,and 操作非常快。我们给定的输入是 “pinNum and 3110 = 111112”。这意味着答案的后 5 位中只有 1,因此它肯定是在 0 到 31 之间。尤其是在 pinNum 的后 5 位的位置是 1 的地方它只有 1。这就如同被 32 整除的余数部分。就像 31 = 32 – 1 并不是巧合。

binary division example

代码的其余部分使用这个值去左移 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 节(它是默认节)中的代码放在地址为 800016.init 节之后。这是默认加载地址,并且它给我们提供了一些空间去保存栈。由于栈存在于内存中,它也有一个地址。栈向下增长内存,因此每个新值都低于前一个地址,所以,这使得栈顶是最低的一个地址。

Layout diagram of operating system

图中的 “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 针脚。


via: https://www.cl.cam.ac.uk/projects/raspberrypi/tutorials/os/ok03.html

作者:Robert Mullins 选题:lujun9972 译者:qhwdw 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

计算机实验室之树莓派:课程 3 OK03

原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/255010.html

(0)
上一篇 2022年5月18日
下一篇 2022年5月18日

相关推荐

发表回复

登录后才能评论