分类 树莓派 下的文章

屏幕02 课程在屏幕01 的基础上构建,它教你如何绘制线和一个生成伪随机数的小特性。假设你已经有了 课程 6:屏幕01 的操作系统代码,我们将以它为基础来构建。

1、点

现在,我们的屏幕已经正常工作了,现在开始去创建一个更实用的图像,是水到渠成的事。如果我们能够绘制出更实用的图形那就更好了。如果我们能够在屏幕上的两点之间绘制一条线,那我们就能够组合这些线绘制出更复杂的图形了。

我们将尝试用汇编代码去实现它,但在开始时,我们确实需要使用一些其它的函数去辅助。我们需要一个这样的函数,我将调用 SetPixel 去修改指定像素的颜色,而在寄存器 r0r1 中提供输入。如果我们写出的代码可以在任意内存中而不仅仅是屏幕上绘制图形,这将在以后非常有用,因此,我们首先需要一些控制真实绘制位置的方法。我认为实现上述目标的最好方法是,能够有一个内存片段用于保存将要绘制的图形。我应该最终得到的是一个存储地址,它通常指向到自上次的帧缓存结构上。我们将一直在我们的代码中使用这个绘制方法。这样,如果我们想在我们的操作系统的另一部分绘制一个不同的图像,我们就可以生成一个不同结构的地址值,而使用的是完全相同的代码。为简单起见,我们将使用另一个数据片段去控制我们绘制的颜色。

为了绘制出更复杂的图形,一些方法使用一个着色函数而不是一个颜色去绘制。每个点都能够调用着色函数来确定在那里用什么颜色去绘制。

复制下列代码到一个名为 drawing.s 的新文件中。

.section .data
.align 1
foreColour:
.hword 0xFFFF

.align 2
graphicsAddress:
.int 0

.section .text
.globl SetForeColour
SetForeColour:
cmp r0,#0x10000
movhs pc,lr
ldr r1,=foreColour
strh r0,[r1]
mov pc,lr

.globl SetGraphicsAddress
SetGraphicsAddress:
ldr r1,=graphicsAddress
str r0,[r1]
mov pc,lr

这段代码就是我上面所说的一对函数以及它们的数据。我们将在 main.s 中使用它们,在绘制图像之前去控制在何处绘制什么内容。

我们的下一个任务是去实现一个 SetPixel 方法。它需要带两个参数,像素的 x 和 y 轴,并且它应该要使用 graphicsAddressforeColour,我们只定义精确控制在哪里绘制什么图像即可。如果你认为你能立即实现这些,那么去动手实现吧,如果不能,按照我们提供的步骤,按示例去实现它。

构建一个通用方法,比如 SetPixel,我们将在它之上构建另一个方法是一个很好的想法。但我们必须要确保这个方法很快,因为我们要经常使用它。
  1. 加载 graphicsAddress
  2. 检查像素的 x 和 y 轴是否小于宽度和高度。
  3. 计算要写入的像素地址(提示:frameBufferAddress +(x + y * 宽度)* 像素大小
  4. 加载 foreColour
  5. 保存到地址。

上述步骤实现如下:

1、加载 graphicsAddress

.globl DrawPixel
DrawPixel:
px .req r0
py .req r1
addr .req r2
ldr addr,=graphicsAddress
ldr addr,[addr]

2、记住,宽度和高度被各自保存在帧缓冲偏移量的 0 和 4 处。如有必要可以参考 frameBuffer.s

height .req r3
ldr height,[addr,#4]
sub height,#1
cmp py,height
movhi pc,lr
.unreq height

width .req r3
ldr width,[addr,#0]
sub width,#1
cmp px,width
movhi pc,lr

3、确实,这段代码是专用于高色值帧缓存的,因为我使用一个逻辑左移操作去计算地址。你可能希望去编写一个不需要专用的高色值帧缓冲的函数版本,记得去更新 SetForeColour 的代码。它实现起来可能更复杂一些。

ldr addr,[addr,#32]
add width,#1
mla px,py,width,px
.unreq width
.unreq py
add addr, px,lsl #1
.unreq px
mla dst,reg1,reg2,reg3 将寄存器 reg1reg2 中的值相乘,然后将结果与寄存器 reg3 中的值相加,并将结果的低 32 位保存到 dst 中。

4、这是专用于高色值的。

fore .req r3
ldr fore,=foreColour
ldrh fore,[fore]

5、这是专用于高色值的。

strh fore,[addr]
.unreq fore
.unreq addr
mov pc,lr

2、线

问题是,线的绘制并不是你所想像的那么简单。到目前为止,你必须认识到,编写一个操作系统时,几乎所有的事情都必须我们自己去做,绘制线条也不例外。我建议你们花点时间想想如何在任意两点之间绘制一条线。

我估计大多数的策略可能是去计算线的梯度,并沿着它来绘制。这看上去似乎很完美,但它事实上是个很糟糕的主意。主要问题是它涉及到除法,我们知道在汇编中,做除法很不容易,并且还要始终记录小数,这也很困难。事实上,在这里,有一个叫布鲁塞姆的算法,它非常适合汇编代码,因为它只使用加法、减法和位移运算。

在我们日常编程中,我们对像除法这样的运算通常懒得去优化。但是操作系统不同,它必须高效,因此我们要始终专注于如何让事情做的尽可能更好。

我们从定义一个简单的直线绘制算法开始,代码如下:

/* 我们希望从 (x0,y0) 到 (x1,y1) 去绘制一条线,只使用一个函数 setPixel(x,y),它的功能是在给定的 (x,y) 上绘制一个点。 */

if x1 > x0 then

set deltax to x1 - x0
set stepx to +1

otherwise

set deltax to x0 - x1
set stepx to -1

end if

if y1 > y0 then

set deltay to y1 - y0
set stepy to +1

otherwise

set deltay to y0 - y1
set stepy to -1

end if

if deltax > deltay then

set error to 0
until x0 = x1 + stepx

setPixel(x0, y0)
set error to error + deltax ÷ deltay
if error ≥ 0.5 then

set y0 to y0 + stepy
set error to error - 1

end if
set x0 to x0 + stepx

repeat

otherwise

end if

这个算法用来表示你可能想像到的那些东西。变量 error 用来记录你离实线的距离。沿着 x 轴每走一步,这个 error 的值都会增加,而沿着 y 轴每走一步,这个 error 值就会减 1 个单位。error 是用于测量距离 y 轴的距离。

虽然这个算法是有效的,但它存在一个重要的问题,很明显,我们使用了小数去保存 error,并且也使用了除法。所以,一个立即要做的优化将是去改变 error 的单位。这里并不需要用特定的单位去保存它,只要我们每次使用它时都按相同数量去伸缩即可。所以,我们可以重写这个算法,通过在所有涉及 error 的等式上都简单地乘以 deltay,从面让它简化。下面只展示主要的循环:

set error to 0 × deltay
until x0 = x1 + stepx

setPixel(x0, y0)
set error to error + deltax ÷ deltay × deltay
if error ≥ 0.5 × deltay then

set y0 to y0 + stepy
set error to error - 1 × deltay

end if
set x0 to x0 + stepx

repeat

它将简化为:

cset error to 0
until x0 = x1 + stepx

setPixel(x0, y0)
set error to error + deltax
if error × 2 ≥ deltay then

set y0 to y0 + stepy
set error to error - deltay

end if
set x0 to x0 + stepx

repeat

突然,我们有了一个更好的算法。现在,我们看一下如何完全去除所需要的除法运算。最好保留唯一的被 2 相乘的乘法运算,我们知道它可以通过左移 1 位来实现!现在,这是非常接近布鲁塞姆算法的,但还可以进一步优化它。现在,我们有一个 if 语句,它将导致产生两个代码块,其中一个用于 x 差异较大的线,另一个用于 y 差异较大的线。对于这两种类型的线,如果审查代码能够将它们转换成一个单语句,还是很值得去做的。

困难之处在于,在第一种情况下,error 是与 y 一起变化,而第二种情况下 error 是与 x 一起变化。解决方案是在一个变量中同时记录它们,使用负的 error 去表示 x 中的一个 error,而用正的 error 表示它是 y 中的。

set error to deltax - deltay
until x0 = x1 + stepx or y0 = y1 + stepy

setPixel(x0, y0)
if error × 2 > -deltay then

set x0 to x0 + stepx
set error to error - deltay

end if
if error × 2 < deltax then

set y0 to y0 + stepy
set error to error + deltax

end if

repeat

你可能需要一些时间来搞明白它。在每一步中,我们都认为它正确地在 x 和 y 中移动。我们通过检查来做到这一点,如果我们在 x 或 y 轴上移动,error 的数量会变低,那么我们就继续这样移动。

布鲁塞姆算法是在 1962 年由 Jack Elton Bresenham 开发,当时他 24 岁,正在攻读博士学位。

用于画线的布鲁塞姆算法可以通过以下的伪代码来描述。以下伪代码是文本,它只是看起来有点像是计算机指令而已,但它却能让程序员实实在在地理解算法,而不是为机器可读。

/* 我们希望从 (x0,y0) 到 (x1,y1) 去绘制一条线,只使用一个函数 setPixel(x,y),它的功能是在给定的 (x,y) 上绘制一个点。 */

if x1 > x0 then
    set deltax to x1 - x0
    set stepx to +1
otherwise
    set deltax to x0 - x1
    set stepx to -1
end if

set error to deltax - deltay
until x0 = x1 + stepx or y0 = y1 + stepy
    setPixel(x0, y0)
    if error × 2 ≥ -deltay then
        set x0 to x0 + stepx
        set error to error - deltay
    end if
    if error × 2 ≤ deltax then
        set y0 to y0 + stepy
        set error to error + deltax
    end if
repeat

与我们目前所使用的编号列表不同,这个算法的表示方式更常用。看看你能否自己实现它。我在下面提供了我的实现作为参考。

.globl DrawLine
DrawLine:
push {r4,r5,r6,r7,r8,r9,r10,r11,r12,lr}
x0 .req r9
x1 .req r10
y0 .req r11
y1 .req r12

mov x0,r0
mov x1,r2
mov y0,r1
mov y1,r3

dx .req r4
dyn .req r5  /* 注意,我们只使用 -deltay,因此为了速度,我保存它的负值。(因此命名为 dyn)*/
sx .req r6
sy .req r7
err .req r8

cmp x0,x1
subgt dx,x0,x1
movgt sx,#-1
suble dx,x1,x0
movle sx,#1

cmp y0,y1
subgt dyn,y1,y0
movgt sy,#-1
suble dyn,y0,y1
movle sy,#1

add err,dx,dyn
add x1,sx
add y1,sy

pixelLoop$:

    teq x0,x1
    teqne y0,y1
    popeq {r4,r5,r6,r7,r8,r9,r10,r11,r12,pc}
    
    mov r0,x0
    mov r1,y0
    bl DrawPixel
    
    cmp dyn, err,lsl #1
    addle err,dyn
    addle x0,sx
    
    cmp dx, err,lsl #1
    addge err,dx
    addge y0,sy
    
    b pixelLoop$

.unreq x0
.unreq x1
.unreq y0
.unreq y1
.unreq dx
.unreq dyn
.unreq sx
.unreq sy
.unreq err

3、随机性

到目前,我们可以绘制线条了。虽然我们可以使用它来绘制图片及诸如此类的东西(你可以随意去做!),我想应该借此机会引入计算机中随机性的概念。我将这样去做,选择一对随机的坐标,然后从上一对坐标用渐变色绘制一条线到那个点。我这样做纯粹是认为它看起来很漂亮。

那么,总结一下,我们如何才能产生随机数呢?不幸的是,我们并没有产生随机数的一些设备(这种设备很罕见)。因此只能利用我们目前所学过的操作,需要我们以某种方式来发明“随机数”。你很快就会意识到这是不可能的。各种操作总是给出定义好的结果,用相同的寄存器运行相同的指令序列总是给出相同的答案。而我们要做的是推导出一个伪随机序列。这意味着数字在外人看来是随机的,但实际上它是完全确定的。因此,我们需要一个生成随机数的公式。其中有人可能会想到很垃圾的数学运算,比如:4x 2! / 64,而事实上它产生的是一个低质量的随机数。在这个示例中,如果 x 是 0,那么答案将是 0。看起来很愚蠢,我们需要非常谨慎地选择一个能够产生高质量随机数的方程式。

硬件随机数生成器很少用在安全中,因为可预测的随机数序列可能影响某些加密的安全。

我将要教给你的方法叫“二次同余发生器”。这是一个非常好的选择,因为它能够在 5 个指令中实现,并且能够产生一个从 0 到 232-1 之间的看似很随机的数字序列。

不幸的是,对为什么使用如此少的指令能够产生如此长的序列的原因的研究,已经远超出了本课程的教学范围。但我还是鼓励有兴趣的人去研究它。它的全部核心所在就是下面的二次方程,其中 xn 是产生的第 n 个随机数。

这类讨论经常寻求一个问题,那就是我们所谓的随机数到底是什么?通常从统计学的角度来说的随机性是:一组没有明显模式或属性能够概括它的数的序列。

这个方程受到以下的限制:

  1. a 是偶数
  2. b = a + 1 mod 4
  3. c 是奇数

如果你之前没有见到过 mod 运算,我来解释一下,它的意思是被它后面的数相除之后的余数。比如 b = a + 1 mod 4 的意思是 ba + 1 除以 4 的余数,因此,如果 a 是 12,那么 b 将是 1,因为 a + 1 是 13,而 13 除以 4 的结果是 3 余 1。

复制下列代码到名为 random.s 的文件中。

.globl Random
Random:
xnm .req r0
a .req r1

mov a,#0xef00
mul a,xnm
mul a,xnm
add a,xnm
.unreq xnm
add r0,a,#73

.unreq a
mov pc,lr

这是随机函数的一个实现,使用一个在寄存器 r0 中最后生成的值作为输入,而接下来的数字则是输出。在我的案例中,我使用 a = EF00 16,b = 1, c = 73。这个选择是随意的,但是需要满足上述的限制。你可以使用任何数字代替它们,只要符合上述的规则就行。

4、Pi-casso

OK,现在我们有了所有我们需要的函数,我们来试用一下它们。获取帧缓冲信息的地址之后,按如下的要求修改 main

  1. 使用包含了帧缓冲信息地址的寄存器 r0 调用 SetGraphicsAddress
  2. 设置四个寄存器为 0。一个将是最后的随机数,一个将是颜色,一个将是最后的 x 坐标,而最后一个将是最后的 y 坐标。
  3. 调用 random 去产生下一个 x 坐标,使用最后一个随机数作为输入。
  4. 调用 random 再次去生成下一个 y 坐标,使用你生成的 x 坐标作为输入。
  5. 更新最后的随机数为 y 坐标。
  6. 使用 colour 值调用 SetForeColour,接着增加 colour 值。如果它大于 FFFF~16~,确保它返回为 0。
  7. 我们生成的 x 和 y 坐标将介于 0 到 FFFFFFFF 16。通过将它们逻辑右移 22 位,将它们转换为介于 0 到 1023 10 之间的数。
  8. 检查 y 坐标是否在屏幕上。验证 y 坐标是否介于 0 到 767 10 之间。如果不在这个区间,返回到第 3 步。
  9. 从最后的 x 坐标和 y 坐标到当前的 x 坐标和 y 坐标之间绘制一条线。
  10. 更新最后的 x 和 y 坐标去为当前的坐标。
  11. 返回到第 3 步。

一如既往,你可以在下载页面上找到这个解决方案。

在你完成之后,在树莓派上做测试。你应该会看到一系列颜色递增的随机线条以非常快的速度出现在屏幕上。它一直持续下去。如果你的代码不能正常工作,请查看我们的排错页面。

如果一切顺利,恭喜你!我们现在已经学习了有意义的图形和随机数。我鼓励你去使用它绘制线条,因为它能够用于渲染你想要的任何东西,你可以去探索更复杂的图案了。它们中的大多数都可以由线条生成,但这需要更好的策略?如果你愿意写一个画线程序,尝试使用 SetPixel 函数。如果不是去设置像素值而是一点点地增加它,会发生什么情况?你可以用它产生什么样的图案?在下一节课 课程 8:屏幕 03 中,我们将学习绘制文本的宝贵技能。


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

作者:Alex Chadwick 选题:lujun9972 译者:qhwdw 校对:wxy

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

欢迎来到屏幕系列课程。在本系列中,你将学习在树莓派中如何使用汇编代码控制屏幕,从显示随机数据开始,接着学习显示一个固定的图像和显示文本,然后格式化数字为文本。假设你已经完成了 OK 系列课程的学习,所以在本系列中出现的有些知识将不再重复。

第一节的屏幕课程教你一些关于图形的基础理论,然后用这些理论在屏幕或电视上显示一个图案。

1、入门

预期你已经完成了 OK 系列的课程,以及那个系列课程中在 gpio.ssystemTimer.s 文件中调用的函数。如果你没有完成这些,或你喜欢完美的实现,可以去下载 OK05.s 解决方案。在这里也要使用 main.s 文件中从开始到包含 mov sp,#0x8000 的这一行之前的代码。请删除这一行以后的部分。

2、计算机图形

正如你所认识到的,从根本上来说,计算机是非常愚蠢的。它们只能执行有限数量的指令,仅仅能做一些数学,但是它们也能以某种方式来做很多很多的事情。而在这些事情中,我们目前想知道的是,计算机是如何将一个图像显示到屏幕上的。我们如何将这个问题转换成二进制?答案相当简单;我们为每个颜色设计一些编码方法,然后我们为在屏幕上的每个像素保存一个编码。一个像素就是你的屏幕上的一个非常小的点。如果你离屏幕足够近,你或许能够辨别出你的屏幕上的单个像素,能够看到每个图像都是由这些像素组成的。

将颜色表示为数字有几种方法。在这里我们专注于 RGB 方法,但 HSL 也是很常用的另一种方法。

随着计算机时代的进步,人们希望显示越来越复杂的图形,于是发明了图形卡的概念。图形卡是你的计算机上用来在屏幕上专门绘制图像的第二个处理器。它的任务就是将像素值信息转换成显示在屏幕上的亮度级别。在现代计算机中,图形卡已经能够做更多更复杂的事情了,比如绘制三维图形。但是在本系列教程中,我们只专注于图形卡的基本使用;从内存中取得像素然后把它显示到屏幕上。

不管使用哪种方法,现在马上出现的一个问题就是我们使用的颜色编码。这里有几种选择,每个产生不同的输出质量。为了完整起见,我在这里只是简单概述它们。

名字唯一颜色数量描述示例
单色2每个像素使用 1 位去保存,其中 1 表示白色,0 表示黑色。Monochrome image of a bird
灰度256每个像素使用 1 个字节去保存,使用 255 表示白色,0 表示黑色,介于这两个值之间的所有值表示这两个颜色的一个线性组合。Geryscale image of a bird
8 色8每个像素使用 3 位去保存,第一位表示红色通道,第二位表示绿色通道,第三位表示蓝色通道。8 colour image of a bird
低色值256每个像素使用 8 位去保存,前三位表示红色通道的强度,接下来的三位表示绿色通道的强度,最后两位表示蓝色通道的强度。Low colour image of a bird
高色值65,536每个像素使用 16 位去保存,前五位表示红色通道的强度,接下来的六位表示绿色通道的强度,最后的五位表示蓝色通道的强度。High colour image of a bird
真彩色16,777,216每个像素使用 24 位去保存,前八位表示红色通道,第二个八位表示绿色通道,最后八位表示蓝色通道。True colour image of a bird
RGBA3216,777,216 带 256 级透明度每个像素使用 32 位去保存,前八位表示红色通道,第二个八位表示绿色通道,第三个八位表示蓝色通道。只有一个图像绘制在另一个图像的上方时才考虑使用透明通道,值为 0 时表示下面图像的颜色,值为 255 时表示上面这个图像的颜色,介于这两个值之间的所有值表示这两个图像颜色的混合。
不过这里的一些图像只用了很少的颜色,因为它们使用了一个叫空间抖动的技术。这允许它们以很少的颜色仍然能表示出非常好的图像。许多早期的操作系统就使用了这种技术。

在本教程中,我们将从使用高色值开始。这样你就可以看到图像的构成,它的形成过程清楚,图像质量好,又不像真彩色那样占用太多的空间。也就是说,显示一个比较小的 800x600 像素的图像,它只需要小于 1 MiB 的空间。它另外的好处是它的大小是 2 次幂的倍数,相比真彩色这将极大地降低了获取信息的复杂度。

树莓派和它的图形处理器有一种特殊而奇怪的关系。在树莓派上,首先运行的事实上是图形处理器,它负责启动主处理器。这是很不常见的。最终它不会有太大的差别,但在许多交互中,它经常给人感觉主处理器是次要的,而图形处理器才是主要的。在树莓派上这两者之间依靠一个叫 “邮箱” 的东西来通讯。它们中的每一个都可以为对方投放邮件,这个邮件将在未来的某个时刻被对方收集并处理。我们将使用这个邮箱去向图形处理器请求一个地址。这个地址将是一个我们在屏幕上写入像素颜色信息的位置,我们称为帧缓冲,图形卡将定期检查这个位置,然后更新屏幕上相应的像素。

保存 帧缓冲 frame buffer 给计算机带来了很大的内存负担。基于这种原因,早期计算机经常作弊,比如,保存一屏幕文本,在每次单独刷新时,它只绘制刷新了的字母。

3、编写邮差程序

接下来我们做的第一件事情就是编写一个“邮差”程序。它有两个方法:MailboxRead,从寄存器 r0 中的邮箱通道读取一个消息。而 MailboxWrite,将寄存器 r0 中的头 28 位的值写到寄存器 r1 中的邮箱通道。树莓派有 7 个与图形处理器进行通讯的邮箱通道。但仅第一个对我们有用,因为它用于协调帧缓冲。

消息传递是组件间通讯时使用的常见方法。一些操作系统在程序之间使用虚拟消息进行通讯。

下列的表和示意图描述了邮箱的操作。

表 3.1 邮箱地址

地址大小 / 字节名字描述读 / 写
2000B8804Read接收邮件R
2000B8904Poll不检索接收R
2000B8944Sender发送者信息R
2000B8984Status信息R
2000B89C4Configuration设置RW
2000B8A04Write发送邮件W

为了给指定的邮箱发送一个消息:

  1. 发送者等待,直到 Status 字段的头一位为 0。
  2. 发送者写入到 Write,低 4 位是要发送到的邮箱,高 28 位是要写入的消息。

为了读取一个消息:

  1. 接收者等待,直到 Status 字段的第 30 位为 0。
  2. 接收者读取消息。
  3. 接收者确认消息来自正确的邮箱,否则再次重试。

如果你觉得有信心,你现在已经有足够的信息去写出我们所需的两个方法。如果没有信心,请继续往下看。

与以前一样,我建议你实现的第一个方法是获取邮箱区域的地址。

.globl GetMailboxBase
GetMailboxBase:
ldr r0,=0x2000B880
mov pc,lr

发送程序相对简单一些,因此我们将首先去实现它。随着你的方法越来越复杂,你需要提前去规划它们。规划它们的一个好的方式是写出一个简单步骤列表,详细地列出你需要做的事情,像下面一样。

  1. 我们的输入将要写什么(r0),以及写到什么邮箱(r1)。我们必须验证邮箱的真实性,以及它的低 4 位的值是否为 0。不要忘了验证输入。
  2. 使用 GetMailboxBase 去检索地址。
  3. 读取 Status 字段。
  4. 检查头一位是否为 0。如果不是,回到第 3 步。
  5. 将写入的值和邮箱通道组合到一起。
  6. 写入到 Write

我们来按顺序写出它们中的每一步。

1、这将实现我们验证 r0r1 的目的。tst 是通过计算两个操作数的逻辑与来比较两个操作数的函数,然后将结果与 0 进行比较。在本案例中,它将检查在寄存器 r0 中的输入的低 4 位是否为全 0。

.globl MailboxWrite
MailboxWrite:
tst r0,#0b1111
movne pc,lr
cmp r1,#15
movhi pc,lr
tst reg,#val 计算寄存器 reg#val 的逻辑与,然后将计算结果与 0 进行比较。

2、这段代码确保我们不会覆盖我们的值,或链接寄存器,然后调用 GetMailboxBase

channel .req r1
value .req r2
mov value,r0
push {lr}
bl GetMailboxBase
mailbox .req r0

3、这段代码加载当前状态。

wait1$:
status .req r3
ldr status,[mailbox,#0x18]

4、这段代码检查状态字段的头一位是否为 0,如果不为 0,循环回到第 3 步。

tst status,#0x80000000
.unreq status
bne wait1$

5、这段代码将通道和值组合到一起。

add value,channel
.unreq channel

6、这段代码保存结果到写入字段。

str value,[mailbox,#0x20]
.unreq value
.unreq mailbox
pop {pc}

MailboxRead 的代码和它非常类似。

  1. 我们的输入将从哪个邮箱读取(r0)。我们必须要验证邮箱的真实性。不要忘了验证输入。
  2. 使用 GetMailboxBase 去检索地址。
  3. 读取 Status 字段。
  4. 检查第 30 位是否为 0。如果不为 0,返回到第 3 步。
  5. 读取 Read 字段。
  6. 检查邮箱是否是我们所要的,如果不是返回到第 3 步。
  7. 返回结果。

我们来按顺序写出它们中的每一步。

1、这一段代码来验证 r0 中的值。

.globl MailboxRead
MailboxRead:
cmp r0,#15
movhi pc,lr

2、这段代码确保我们不会覆盖掉我们的值,或链接寄存器,然后调用 GetMailboxBase

channel .req r1
mov channel,r0
push {lr}
bl GetMailboxBase
mailbox .req r0

3、这段代码加载当前状态。

rightmail$:
wait2$:
status .req r2
ldr status,[mailbox,#0x18]

4、这段代码检查状态字段第 30 位是否为 0,如果不为 0,返回到第 3 步。

tst status,#0x40000000
.unreq status
bne wait2$

5、这段代码从邮箱中读取下一条消息。

mail .req r2
ldr mail,[mailbox,#0]

6、这段代码检查我们正在读取的邮箱通道是否为提供给我们的通道。如果不是,返回到第 3 步。

inchan .req r3
and inchan,mail,#0b1111
teq inchan,channel
.unreq inchan
bne rightmail$
.unreq mailbox
.unreq channel

7、这段代码将答案(邮件的前 28 位)移动到寄存器 r0 中。

and r0,mail,#0xfffffff0
.unreq mail
pop {pc}

4、我心爱的图形处理器

通过我们新的邮差程序,我们现在已经能够向图形卡上发送消息了。我们应该发送些什么呢?这对我来说可能是个很难找到答案的问题,因为它不是任何线上手册能够找到答案的问题。尽管如此,通过查找有关树莓派的 GNU/Linux,我们能够找出我们需要发送的内容。

消息很简单。我们描述我们想要的帧缓冲区,而图形卡要么接受我们的请求,给我们返回一个 0,然后用我们写的一个小的调查问卷来填充屏幕;要么发送一个非 0 值,我们知道那表示很遗憾(出错了)。不幸的是,我并不知道它返回的其它数字是什么,也不知道它意味着什么,但我们知道仅当它返回一个 0,才表示一切顺利。幸运的是,对于合理的输入,它总是返回一个 0,因此我们不用过于担心。

由于在树莓派的内存是在图形处理器和主处理器之间共享的,我们能够只发送可以找到我们信息的位置即可。这就是 DMA,许多复杂的设备使用这种技术去加速访问时间。

为简单起见,我们将提前设计好我们的请求,并将它保存到 framebuffer.s 文件的 .data 节中,它的代码如下:

.section .data
.align 4
.globl FrameBufferInfo
FrameBufferInfo:
.int 1024 /* #0 物理宽度 */
.int 768 /* #4 物理高度 */
.int 1024 /* #8 虚拟宽度 */
.int 768 /* #12 虚拟高度 */
.int 0 /* #16 GPU - 间距 */
.int 16 /* #20 位深 */
.int 0 /* #24 X */
.int 0 /* #28 Y */
.int 0 /* #32 GPU - 指针 */
.int 0 /* #36 GPU - 大小 */

这就是我们发送到图形处理器的消息格式。第一对两个关键字描述了物理宽度和高度。第二对关键字描述了虚拟宽度和高度。帧缓冲的宽度和高度就是虚拟的宽度和高度,而 GPU 按需要伸缩帧缓冲去填充物理屏幕。如果 GPU 接受我们的请求,接下来的关键字将是 GPU 去填充的参数。它们是帧缓冲每行的字节数,在本案例中它是 2 × 1024 = 2048。下一个关键字是每个像素分配的位数。使用了一个 16 作为值意味着图形处理器使用了我们上面所描述的高色值模式。值为 24 是真彩色,而值为 32 则是 RGBA32。接下来的两个关键字是 x 和 y 偏移量,它表示当将帧缓冲复制到屏幕时,从屏幕左上角跳过的像素数目。最后两个关键字是由图形处理器填写的,第一个表示指向帧缓冲的实际指针,第二个是用字节数表示的帧缓冲大小。

在这里我非常谨慎地使用了一个 .align 4 指令。正如前面所讨论的,这样确保了下一行地址的低 4 位是 0。所以,我们可以确保将被放到那个地址上的帧缓冲(FrameBufferInfo)是可以发送到图形处理器上的,因为我们的邮箱仅发送低 4 位全为 0 的值。

当设备使用 DMA 时,对齐约束变得非常重要。GPU 预期该消息都是 16 字节对齐的。

到目前为止,我们已经有了待发送的消息,我们可以写代码去发送它了。通讯将按如下的步骤进行:

  1. 写入 FrameBufferInfo + 0x40000000 的地址到邮箱 1。
  2. 从邮箱 1 上读取结果。如果它是非 0 值,意味着我们没有请求一个正确的帧缓冲。
  3. 复制我们的图像到指针,这时图像将出现在屏幕上!

我在步骤 1 中说了一些以前没有提到的事情。我们在发送之前,在帧缓冲地址上加了 0x40000000。这其实是一个给 GPU 的特殊信号,它告诉 GPU 应该如何写到结构上。如果我们只是发送地址,GPU 将写到它的回复上,这样不能保证我们可以通过刷新缓存看到它。缓存是处理器使用的值在它们被发送到存储之前保存在内存中的片段。通过加上 0x40000000,我们告诉 GPU 不要将写入到它的缓存中,这样将确保我们能够看到变化。

因为在那里发生很多事情,因此最好将它实现为一个函数,而不是将它以代码的方式写入到 main.s 中。我们将要写一个函数 InitialiseFrameBuffer,由它来完成所有协调和返回指向到上面提到的帧缓冲数据的指针。为方便起见,我们还将帧缓冲的宽度、高度、位深作为这个方法的输入,这样就很容易地修改 main.s 而不必知道协调的细节了。

再一次,来写下我们要做的详细步骤。如果你有信心,可以略过这一步直接尝试去写函数。

  1. 验证我们的输入。
  2. 写输入到帧缓冲。
  3. 发送 frame buffer + 0x40000000 的地址到邮箱。
  4. 从邮箱中接收回复。
  5. 如果回复是非 0 值,方法失败。我们应该返回 0 去表示失败。
  6. 返回指向帧缓冲信息的指针。

现在,我们开始写更多的方法。以下是上面其中一个实现。

1、这段代码检查宽度和高度是小于或等于 4096,位深小于或等于 32。这里再次使用了条件运行的技巧。相信自己这是可行的。

.section .text
.globl InitialiseFrameBuffer
InitialiseFrameBuffer:
width .req r0
height .req r1
bitDepth .req r2
cmp width,#4096
cmpls height,#4096
cmpls bitDepth,#32
result .req r0
movhi result,#0
movhi pc,lr

2、这段代码写入到我们上面定义的帧缓冲结构中。我也趁机将链接寄存器推入到栈上。

fbInfoAddr .req r3
push {lr}
ldr fbInfoAddr,=FrameBufferInfo
str width,[fbInfoAddr,#0]
str height,[fbInfoAddr,#4]
str width,[fbInfoAddr,#8]
str height,[fbInfoAddr,#12]
str bitDepth,[fbInfoAddr,#20]
.unreq width
.unreq height
.unreq bitDepth

3、MailboxWrite 方法的输入是写入到寄存器 r0 中的值,并将通道写入到寄存器 r1 中。

mov r0,fbInfoAddr
add r0,#0x40000000
mov r1,#1
bl MailboxWrite

4、MailboxRead 方法的输入是写入到寄存器 r0 中的通道,而输出是值读数。

mov r0,#1
bl MailboxRead

5、这段代码检查 MailboxRead 方法的结果是否为 0,如果不为 0,则返回 0。

teq result,#0
movne result,#0
popne {pc}

6、这是代码结束,并返回帧缓冲信息地址。

mov result,fbInfoAddr
pop {pc}
.unreq result
.unreq fbInfoAddr

5、在一帧中一行之内的一个像素

到目前为止,我们已经创建了与图形处理器通讯的方法。现在它已经能够给我们返回一个指向到帧缓冲的指针去绘制图形了。我们现在来绘制一个图形。

第一示例中,我们将在屏幕上绘制连续的颜色。它看起来并不漂亮,但至少能说明它在工作。我们如何才能在帧缓冲中设置每个像素为一个连续的数字,并且要持续不断地这样做。

将下列代码复制到 main.s 文件中,并放置在 mov sp,#0x8000 行之后。

mov r0,#1024
mov r1,#768
mov r2,#16
bl InitialiseFrameBuffer

这段代码使用了我们的 InitialiseFrameBuffer 方法,简单地创建了一个宽 1024、高 768、位深为 16 的帧缓冲区。在这里,如果你愿意可以尝试使用不同的值,只要整个代码中都一样就可以。如果图形处理器没有给我们创建好一个帧缓冲区,这个方法将返回 0,我们最好检查一下返回值,如果出现返回值为 0 的情况,我们打开 OK LED 灯。

teq r0,#0
bne noError$

mov r0,#16
mov r1,#1
bl SetGpioFunction
mov r0,#16
mov r1,#0
bl SetGpio

error$:
b error$

noError$:
fbInfoAddr .req r4
mov fbInfoAddr,r0

现在,我们已经有了帧缓冲信息的地址,我们需要取得帧缓冲信息的指针,并开始绘制屏幕。我们使用两个循环来做实现,一个走行,一个走列。事实上,树莓派中的大多数应用程序中,图片都是以从左到右然后从上到下的顺序来保存的,因此我们也按这个顺序来写循环。

render$:

    fbAddr .req r3
    ldr fbAddr,[fbInfoAddr,#32]
    
    colour .req r0
    y .req r1
    mov y,#768
    drawRow$:
    
        x .req r2
        mov x,#1024
        drawPixel$:
        
            strh colour,[fbAddr]
            add fbAddr,#2
            sub x,#1
            teq x,#0
            bne drawPixel$
        
        sub y,#1
        add colour,#1
        teq y,#0
        bne drawRow$
    
    b render$

.unreq fbAddr
.unreq fbInfoAddr
strh reg,[dest] 将寄存器中的低位半个字保存到给定的 dest 地址上。

这是一个很长的代码块,它嵌套了三层循环。为了帮你理清头绪,我们将循环进行缩进处理,这就有点类似于高级编程语言,而汇编器会忽略掉这些用于缩进的 tab 字符。我们看到,在这里它从帧缓冲信息结构中加载了帧缓冲的地址,然后基于每行来循环,接着是每行上的每个像素。在每个像素上,我们使用一个 strh(保存半个字)命令去保存当前颜色,然后增加地址继续写入。每行绘制完成后,我们增加绘制的颜色号。在整个屏幕绘制完成后,我们跳转到开始位置。

6、看到曙光

现在,你已经准备好在树莓派上测试这些代码了。你应该会看到一个渐变图案。注意:在第一个消息被发送到邮箱之前,树莓派在它的四个角上一直显示一个渐变图案。如果它不能正常工作,请查看我们的排错页面。

如果一切正常,恭喜你!你现在可以控制屏幕了!你可以随意修改这些代码去绘制你想到的任意图案。你还可以做更精彩的渐变图案,可以直接计算每个像素值,因为每个像素包含了一个 Y 坐标和 X 坐标。在下一个 课程 7:Screen 02 中,我们将学习一个更常用的绘制任务:行。


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

作者:Alex Chadwick 选题:lujun9972 译者:qhwdw 校对:wxy

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

OK05 课程构建于课程 OK04 的基础,使用它来闪烁摩尔斯电码的 SOS 序列(...---...)。这里假设你已经有了 课程 4:OK04 操作系统的代码作为基础。

1、数据

到目前为止,我们与操作系统有关的所有内容提供的都是指令。然而有时候,指令只是完成了一半的工作。我们的操作系统可能还需要数据。

一些早期的操作系统确实只允许特定文件中的特定类型的数据,但是这通常被认为限制太多了。现代方法确实可以使程序变得复杂的多。

通常,数据就是些很重要的值。你可能接受过培训,认为数据就是某种类型的,比如,文本文件包含文本,图像文件包含图片,等等。说实话,这只是你的想法而已。计算机上的全部数据都是二进制数字,重要的是我们选择用什么来解释这些数据。在这个例子中,我们会用一个闪灯序列作为数据保存下来。

main.s 结束处复制下面的代码:

.section .data %定义 .data 段
.align 2 %对齐
pattern: %定义整形变量
.int 0b11111111101010100010001000101010

.align num 确保下一行代码的地址是 2 num 的整数倍。

.int val 输出数值 val

要区分数据和代码,我们将数据都放在 .data 区域。我已经将该区域包含在操作系统的内存布局图。我选择将数据放到代码后面。将我们的指令和数据分开保存的原因是,如果最后我们在自己的操作系统上实现一些安全措施,我们就需要知道代码的那些部分是可以执行的,而那些部分是不行的。

我在这里使用了两个新命令 .align.int.align 保证接下来的数据是按照 2 的乘方对齐的。在这个里,我使用 .align 2 ,意味着数据最终存放的地址是 2 2=4 的整数倍。这个操作是很重要的,因为我们用来读取内存的指令 ldr 要求内存地址是 4 的倍数。

命令 .int 直接复制它后面的常量到输出。这意味着 11111111101010100010001000101010 2 将会被存放到输出,所以该标签模式实际是将这段数据标识为模式。

关于数据的一个挑战是寻找一个高效和有用的展示形式。这种保存一个开、关的时间单元的序列的方式,运行起来很容易,但是将很难编辑,因为摩尔斯电码的 -. 样式丢失了。

如我提到的,数据可以代表你想要的所有东西。在这里我编码了摩尔斯电码的 SOS 序列,对于不熟悉的人,就是 ...---...。我使用 0 表示一个时间单元的 LED 灭灯,而 1 表示一个时间单元的 LED 亮。这样,我们可以像这样编写一些代码在数据中显示一个序列,然后要显示不同序列,我们所有需要做的就是修改这段数据。下面是一个非常简单的例子,操作系统必须一直执行这段程序,解释和展示数据。

复制下面几行到 main.s 中的标记 loop$ 之前。

ptrn .req r4 %重命名 r4 为 ptrn
ldr ptrn,=pattern %加载 pattern 的地址到 ptrn
ldr ptrn,[ptrn] %加载地址 ptrn 所在内存的值
seq .req r5 %重命名 r5 为 seq
mov seq,#0 %seq 赋值为 0

这段代码加载 pattrern 到寄存器 r4,并加载 0 到寄存器 r5r5 将是我们的序列位置,所以我们可以追踪我们已经展示了多少个 pattern

如果 pattern 的当前位置是 1 且仅有一个 1,下面的代码将非零值放入 r1

mov r1,#1 %加载1到 r1
lsl r1,seq %对r1 的值逻辑左移 seq 次
and r1,ptrn %按位与

这段代码对你调用 SetGpio 很有用,它必须有一个非零值来关掉 LED,而一个 0 值会打开 LED。

现在修改 main.s 中你的全部代码,这样代码中每次循环会根据当前的序列数设置 LED,等待 250000 毫秒(或者其他合适的延时),然后增加序列数。当这个序列数到达 32 就需要返回 0。看看你是否能实现这个功能,作为额外的挑战,也可以试着只使用一条指令。

2、当你玩得开心时,时间过得很快

你现在准备好在树莓派上实验。应该闪烁一串包含 3 个短脉冲,3 个长脉冲,然后 3 个短脉冲的序列。在一次延时之后,这种模式应该重复。如果这不工作,请查看我们的问题页。

一旦它工作,祝贺你已经抵达 OK 系列教程的结束点。

在这个系列我们学习了汇编代码,GPIO 控制器和系统定时器。我们已经学习了函数和 ABI,以及几个基础的操作系统原理,已经关于数据的知识。

你现在已经可以准备学习下面几个更高级的课程的某一个。

  • Screen 系列是接下来的,会教你如何通过汇编代码使用屏幕。
  • Input 系列教授你如何使用键盘和鼠标。

到现在,你已经有了足够的信息来制作操作系统,用其它方法和 GPIO 交互。如果你有任何机器人工具,你可能会想尝试编写一个通过 GPIO 管脚控制的机器人操作系统。


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

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

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

OK04 课程在 OK03 的基础上进行构建,它教你如何使用定时器让 OK 或 ACT LED 灯按精确的时间间隔来闪烁。假设你已经有了 课程 3:OK03 的操作系统,我们将以它为基础来构建。

1、一个新设备

定时器是树莓派保持时间的唯一方法。大多数计算机都有一个电池供电的时钟,这样当计算机关机后仍然能保持时间。

到目前为止,我们仅看了树莓派硬件的一小部分,即 GPIO 控制器。我只是简单地告诉你做什么,然后它会发生什么事情。现在,我们继续看定时器,并继续带你去了解它的工作原理。

和 GPIO 控制器一样,定时器也有地址。在本案例中,定时器的基地址在 20003000 16。阅读手册我们可以找到下面的表:

表 1.1 GPIO 控制器寄存器

地址大小 / 字节名字描述读或写
200030004Control / Status用于控制和清除定时器通道比较器匹配的寄存器RW
200030048Counter按 1 MHz 的频率递增的计数器R
2000300C4Compare 00 号比较器寄存器RW
200030104Compare 11 号比较器寄存器RW
200030144Compare 22 号比较器寄存器RW
200030184Compare 33 号比较器寄存器RW

Flowchart of the system timer's operation

这个表只告诉我们一部分内容,在手册中描述了更多的字段。手册上解释说,定时器本质上是按每微秒将计数器递增 1 的方式来运行。每次它是这样做的,它将计数器的低 32 位(4 字节)与 4 个比较器寄存器进行比较,如果匹配它们中的任何一个,它更新 Control/Status 以反映出其中有一个是匹配的。

关于 bit 字节 byte 位字段 bit field 、以及数据大小的更多内容如下:

一个位是一个单个的二进制数的名称。你可能还记得,一个单个的二进制数既可能是一个 1,也可能是一个 0。

一个字节是一个 8 位集合的名称。由于每个位可能是 1 或 0 这两个值的其中之一,因此,一个字节有 2 8 = 256 个不同的可能值。我们一般解释一个字节为一个介于 0 到 255(含)之间的二进制数。

Diagram of GPIO function select controller register 0.

一个位字段是解释二进制的另一种方式。二进制可以解释为许多不同的东西,而不仅仅是一个数字。一个位字段可以将二进制看做为一系列的 1(开) 或 0(关)的开关。对于每个小开关,我们都有一个意义,我们可以使用它们去控制一些东西。我们已经遇到了 GPIO 控制器使用的位字段,使用它设置一个针脚的开或关。位为 1 时 GPIO 针脚将准确地打开或关闭。有时我们需要更多的选项,而不仅仅是开或关,因此我们将几个开关组合到一起,比如 GPIO 控制器的函数设置(如上图),每 3 位为一组控制一个 GPIO 针脚的函数。

我们的目标是实现一个函数,这个函数能够以一个时间数量为输入来调用它,这个输入的时间数量将作为等待的时间,然后返回。想一想如何去做,想想我们都拥有什么。

我认为这将有两个选择:

  1. 从计数器中读取一个值,然后保持分支返回到相同的代码,直到计数器的等待时间数量大于它。
  2. 从计数器中读取一个值,加上要等待的时间数量,将它保存到比较器寄存器,然后保持分支返回到相同的代码处,直到 Control / Status 寄存器更新。

这两种策略都工作的很好,但在本教程中,我们将只实现第一个。原因是比较器寄存器更容易出错,因为在增加等待时间并保存它到比较器的寄存器期间,计数器可能已经增加了,并因此可能会不匹配。如果请求的是 1 微秒(或更糟糕的情况是 0 微秒)的等待,这样可能导致非常长的意外延迟。

像这样存在被称为“并发问题”的问题,并且几乎无法解决。

2、实现

我将把这个创建完美的等待方法的挑战基本留给你。我建议你将所有与定时器相关的代码都放在一个名为 systemTimer.s 的文件中(理由很明显)。关于这个方法的复杂部分是,计数器是一个 8 字节值,而每个寄存器仅能保存 4 字节。所以,计数器值将分到 2 个寄存器中。

大型的操作系统通常使用等待函数来抓住机会在后台执行任务。

下列的代码块是一个示例。

ldrd r0,r1,[r2,#4]
ldrd regLow,regHigh,[src,#val]src 中的数加上 val 之和的地址加载 8 字节到寄存器 regLowregHigh 中。

上面的代码中你可以发现一个很有用的指令是 ldrd。它加载 8 字节的内存到两个寄存器中。在本案例中,这 8 字节内存从寄存器 r2 中的地址 + 4 开始,将被复制进寄存器 r0r1。这种安排的稍微复杂之处在于 r1 实际上只持有了高位 4 字节。换句话说就是,如果如果计数器的值是 999,999,999,999 10 = 1110100011010100101001010000111111111111 2 ,那么寄存器 r1 中只有 11101000 2,而寄存器 r0 中则是 11010100101001010000111111111111 2

实现它的更明智的方式应该是,去计算当前计数器值与来自方法启动后的那一个值的差,然后将它与要求的等待时间数量进行比较。除非恰好你希望的等待时间是占用 8 字节的,否则上面示例中寄存器 r1 中的值将会丢弃,而计数器仅需要使用低位 4 字节。

当等待开始时,你应该总是确保使用大于比较,而不是使用等于比较,因为如果你尝试去等待一个时间,而这个时间正好等于方法开始的时间与结束的时间之差,那么你就错过这个值而永远等待下去。

如果你不明白如何编写等待函数的代码,可以参考下面的指南。

借鉴 GPIO 控制器的创意,第一个函数我们应该去写如何取得系统定时器的地址。示例如下:

.globl GetSystemTimerBase
GetSystemTimerBase:
ldr r0,=0x20003000
mov pc,lr

另一个被证明非常有用的函数是返回在寄存器 r0r1 中的当前计数器值:

.globl GetTimeStamp
GetTimeStamp:
push {lr}
bl GetSystemTimerBase
ldrd r0,r1,[r0,#4]
pop {pc}

这个函数简单地使用了 GetSystemTimerBase 函数,并像我们前面学过的那样,使用 ldrd 去加载当前计数器值。

现在,我们可以去写我们的等待方法的代码了。首先,在该方法启动后,我们需要知道计数器值,我们可以使用 GetTimeStamp 来取得。

delay .req r2
mov delay,r0
push {lr}
bl GetTimeStamp
start .req r3
mov start,r0

这个代码复制了我们的方法的输入,将延迟时间的数量放到寄存器 r2 中,然后调用 GetTimeStamp,这个函数将会返回寄存器 r0r1 中的当前计数器值。接着复制计数器值的低位 4 字节到寄存器 r3 中。

接下来,我们需要计算当前计数器值与读入的值的差,然后持续这样做,直到它们的差至少是 delay 的大小为止。

loop$:

bl GetTimeStamp
elapsed .req r1
sub elapsed,r0,start
cmp elapsed,delay
.unreq elapsed
bls loop$

这个代码将一直等待,一直到等待到传递给它的时间数量为止。它从计数器中读取数值,减去最初从计数器中读取的值,然后与要求的延迟时间进行比较。如果过去的时间数量小于要求的延迟,它切换回 loop$

.unreq delay
.unreq start
pop {pc}

代码完成后,函数返回。

3、另一个闪灯程序

你一旦明白了等待函数的工作原理,修改 main.s 去使用它。修改各处 r0 的等待设置值为某个很大的数量(记住它的单位是微秒),然后在树莓派上测试。如果函数不能正常工作,请查看我们的排错页面。

如果正常工作,恭喜你学会控制另一个设备了,会使用它,则时间由你控制。在下一节课程中,我们将完成 OK 系列课程的最后一节 课程 5:OK05,我们将使用我们已经学习过的知识让 LED 按我们的模式进行闪烁。


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

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

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

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 的结果有可能是 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 并不是巧合。

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 节(它是默认节)中的代码放在地址为 8000 16.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中国 荣誉推出

OK02 课程构建于 OK01 课程的基础上,通过不停地打开和关闭 OK 或 ACT LED 指示灯来实现闪烁。假设你已经有了 课程 1:OK01 操作系统的代码,它将是这一节课的基础。

1、等待

等待是操作系统开发中非常有用的部分。操作系统经常发现自己无事可做,以及必须要延迟。在这个例子中,我们希望通过等待,让 LED 灯打开、关闭的闪烁可以看到。如果你只是打开和关闭它,你将看到这个视觉效果,因为计算机每秒种可以打开和关闭它好几千次(LCTT 译注:视觉暂留效应会使你难以发觉它的闪烁)。在后面的课程中,我们将看到精确的等待,但是现在,我们只要简单地去消耗时间就足够了。

mov r2,#0x3F0000
wait1$:
sub r2,#1
cmp r2,#0
bne wait1$

sub reg,#val 从寄存器 reg 中的值上减去数字 val

cmp reg,#val 将寄存器中的值与数字 val 进行比较。

如果最后的比较结果是不相等,那么执行后缀了 neb 命令。

上面是一个很常见的产生延迟的代码片段,由于每个树莓派基本上是相同的,所以产生的延迟大致也是相同的。它的工作原理是,使用一个 mov 命令将值 3F0000 16 推入到寄存器 r2 中,然后将这个值减 1,直到这个值减到 0 为止。在这里使用了三个新命令 subcmpbne

sub 是减法命令,它只是简单地从第一个参数中的值减去第二个参数中的值。

cmp 是个很有趣的命令。它将第一个参数与第二个参数进行比较,然后将比较结果记录到一个称为当前处理器状态寄存器的专用寄存器中。你其实不用担心它,它记住的只是两个数谁大或谁小,或是相等而已。 1

bne 其实是一个伪装的分支命令。在 ARM 汇编语言家族中,任何指令都可以有条件地运行。这意味着如果上一个比较结果是某个确定的结果,那个指令才会运行。这是个非常有意思的技巧,我们在后面将大量使用到它,但在本案例中,我们在 b 命令后面的 ne 后缀意思是 “只有在上一个比较的结果是值不相等,才去运行该分支”。ne 后缀可以使用在任何命令上,其它几个(总共 16 个)条件也是如此,比如 eq 表示等于,而 lt 表示小于。

2、组合到一起

上一节讲我提到过,通过将 GPIO 地址偏移量设置为 28(即:str r1,[r0,#28])而不是 40 即可实现 LED 的关闭。因此,你需要去修改课程 OK01 的代码,在打开 LED 后,运行等待代码,然后再关闭 LED,再次运行等待代码,并包含一个回到开始位置的分支。注意,不需要重新启用 GPIO 的 16 号针脚的输出功能,这个操作只需要做一次就可以了。如果你想更高效,我建议你复用 r1 寄存器的值。所有课程都一样,你可以在 下载页面 找到所有的解决方案。需要注意的是,必须保证你的所有标签都是唯一的。当你写了 wait1$: 你其它行上的标签就不能再使用 wait1$ 了。

在我的树莓派上,它大约是每秒闪两次。通过改变我们所设置的 r2 寄存器中的值,可以很轻松地修改它。但是,不幸的是,我不能够精确地预测它的运行速度。如果你的树莓派未按预期正常工作,请查看我们的故障排除页面,如果它正常工作,恭喜你。

在这个课程中,我们学习了另外两个汇编命令:subcmp,同时学习了 ARM 中如何实现有条件运行。

在下一个课程,课程 3:OK03 中我们将学习如何编写代码,以及建立一些代码复用的标准,并且如果需要的话,可能会使用 C 或 C++ 来写代码。


  1. 如果你点了这个链接,说明你一定想知道它的具体内容。CPSR 是一个由许多独立的比特位组成的 32 比特寄存器。它有一个位用于表示正数、零和负数。当一个 cmp 指令运行后,它从第一个参数上减去第二个参数,然后用这个位记下它的结果是正数、零还是负数。如果是零意味着它们相等(a-b=0 暗示着 a=b)如果为正数意味着 a 大于 b(a-b>0 暗示着 a>b),如果为负数意味着小于。还有其它比较指令,但 cmp 指令最直观。

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

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

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