计算机实验室之树莓派:课程 6 屏幕01

2019-02-16 14:52


欢迎来到屏幕系列课程。在本系列中,你将学习在树莓派中如何使用汇编代码控制屏幕,从显示随机数据开始,接着学习显示一个固定的图像和显示文本,然后格式化数字为文本。假设你已经完成了 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
RGBA32 16,777,216 带 256 级透明度 每个像素使用 32 位去保存,前八位表示红色通道,第二个八位表示绿色通道,第三个八位表示蓝色通道。只有一个图像绘制在另一个图像的上方时才考虑使用透明通道,值为 0 时表示下面图像的颜色,值为 255 时表示上面这个图像的颜色,介于这两个值之间的所有值表示这两个图像颜色的混合。

不过这里的一些图像只用了很少的颜色,因为它们使用了一个叫空间抖动的技术。这允许它们以很少的颜色仍然能表示出非常好的图像。许多早期的操作系统就使用了这种技术。

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

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

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

3、编写邮差程序

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

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

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

表 3.1 邮箱地址

地址 大小 / 字节 名字 描述 读 / 写
2000B880 4 Read 接收邮件 R
2000B890 4 Poll 不检索接收 R
2000B894 4 Sender 发送者信息 R
2000B898 4 Status 信息 R
2000B89C 4 Configuration 设置 RW
2000B8A0 4 Write 发送邮件 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中国 荣誉推出