Alex Chadwick 发布的文章

课程输入 02 是以课程输入 01 为基础讲解的,通过一个简单的命令行实现用户的命令输入和计算机的处理和显示。本文假设你已经具备 课程11:输入01 的操作系统代码基础。

1、终端

几乎所有的操作系统都是以字符终端显示启动的。经典的黑底白字,通过键盘输入计算机要执行的命令,然后会提示你拼写错误,或者恰好得到你想要的执行结果。这种方法有两个主要优点:键盘和显示器可以提供简易、健壮的计算机交互机制,几乎所有的计算机系统都采用这个机制,这个也广泛被系统管理员应用。

早期的计算一般是在一栋楼里的一个巨型计算机系统,它有很多可以输命令的'终端'。计算机依次执行不同来源的命令。

让我们分析下真正想要哪些信息:

  1. 计算机打开后,显示欢迎信息
  2. 计算机启动后可以接受输入标志
  3. 用户从键盘输入带参数的命令
  4. 用户输入回车键或提交按钮
  5. 计算机解析命令后执行可用的命令
  6. 计算机显示命令的执行结果,过程信息
  7. 循环跳转到步骤 2

这样的终端被定义为标准的输入输出设备。用于(显示)输入的屏幕和打印输出内容的屏幕是同一个(LCTT 译注:最早期的输出打印真是“打印”到打印机/电传机的,而用于输入的终端只是键盘,除非做了回显,否则输出终端是不会显示输入的字符的)。也就是说终端是对字符显示的一个抽象。字符显示中,单个字符是最小的单元,而不是像素。屏幕被划分成固定数量不同颜色的字符。我们可以在现有的屏幕代码基础上,先存储字符和对应的颜色,然后再用方法 DrawCharacter 把其推送到屏幕上。一旦我们需要字符显示,就只需要在屏幕上画出一行字符串。

新建文件名为 terminal.s,如下:

.section .data
.align 4
terminalStart:
.int terminalBuffer
terminalStop:
.int terminalBuffer
terminalView:
.int terminalBuffer
terminalColour:
.byte 0xf
.align 8
terminalBuffer:
.rept 128*128
.byte 0x7f
.byte 0x0
.endr
terminalScreen:
.rept 1024/8 core.md Dict.md lctt2014.md lctt2016.md lctt2018.md LICENSE published README.md scripts sources translated 768/16
.byte 0x7f
.byte 0x0
.endr

这是文件终端的配置数据文件。我们有两个主要的存储变量:terminalBufferterminalScreenterminalBuffer 保存所有显示过的字符。它保存 128 行字符文本(1 行包含 128 个字符)。每个字符有一个 ASCII 字符和颜色单元组成,初始值为 0x7f(ASCII 的删除字符)和 0(前景色和背景色为黑)。terminalScreen 保存当前屏幕显示的字符。它保存 128x48 个字符,与 terminalBuffer 初始化值一样。你可能会觉得我仅需要 terminalScreen 就够了,为什么还要terminalBuffer,其实有两个好处:

  1. 我们可以很容易看到字符串的变化,只需画出有变化的字符。
  2. 我们可以回滚终端显示的历史字符,也就是缓冲的字符(有限制)

这种独特的技巧在低功耗系统里很常见。画屏是很耗时的操作,因此我们仅在不得已的时候才去执行这个操作。在这个系统里,我们可以任意改变 terminalBuffer,然后调用一个仅拷贝屏幕上字节变化的方法。也就是说我们不需要持续画出每个字符,这样可以节省一大段跨行文本的操作时间。

你总是需要尝试去设计一个高效的系统,如果在很少变化的情况下这个系统会运行的更快。

其他在 .data 段的值得含义如下:

  • terminalStart 写入到 terminalBuffer 的第一个字符
  • terminalStop 写入到 terminalBuffer 的最后一个字符
  • terminalView 表示当前屏幕的第一个字符,这样我们可以控制滚动屏幕
  • temrinalColour 即将被描画的字符颜色

terminalStart 需要保存起来的原因是 termainlBuffer 是一个环状缓冲区。意思是当缓冲区变满时,末尾地方会回滚覆盖开始位置,这样最后一个字符变成了第一个字符。因此我们需要将 terminalStart 往前推进,这样我们知道我们已经占满它了。如何实现缓冲区检测:如果索引越界到缓冲区的末尾,就将索引指向缓冲区的开始位置。环状缓冲区是一个比较常见的存储大量数据的高明方法,往往这些数据的最近部分比较重要。它允许无限制的写入,只保证最近一些特定数据有效。这个常常用于信号处理和数据压缩算法。这样的情况,可以允许我们存储 128 行终端记录,超过128行也不会有问题。如果不是这样,当超过第 128 行时,我们需要把 127 行分别向前拷贝一次,这样很浪费时间。

显示 Hellow world 插入到大小为5的循环缓冲区的示意图。

环状缓冲区是数据结构一个例子。这是一个组织数据的思路,有时我们通过软件实现这种思路。

之前已经提到过 terminalColour 几次了。你可以根据你的想法实现终端颜色,但这个文本终端有 16 个前景色和 16 个背景色(这里相当于有 16 2 = 256 种组合)。CGA终端的颜色定义如下:

表格 1.1 - CGA 颜色编码

序号颜色 (R, G, B)
0黑 (0, 0, 0)
1蓝 (0, 0, ⅔)
2绿 (0, ⅔, 0)
3青色 (0, ⅔, ⅔)
4红色 (⅔, 0, 0)
5品红 (⅔, 0, ⅔)
6棕色 (⅔, ⅓, 0)
7浅灰色 (⅔, ⅔, ⅔)
8灰色 (⅓, ⅓, ⅓)
9淡蓝色 (⅓, ⅓, 1)
10淡绿色 (⅓, 1, ⅓)
11淡青色 (⅓, 1, 1)
12淡红色 (1, ⅓, ⅓)
13浅品红 (1, ⅓, 1)
14黄色 (1, 1, ⅓)
15白色 (1, 1, 1)

我们将前景色保存到颜色的低字节,背景色保存到颜色高字节。除了棕色,其他这些颜色遵循一种模式如二进制的高位比特代表增加 ⅓ 到每个组件,其他比特代表增加 ⅔ 到各自组件。这样很容易进行 RGB 颜色转换。

棕色作为替代色(黑黄色)既不吸引人也没有什么用处。

我们需要一个方法从 TerminalColour 读取颜色编码的四个比特,然后用 16 比特等效参数调用 SetForeColour。尝试你自己实现。如果你感觉麻烦或者还没有完成屏幕系列课程,我们的实现如下:

.section .text
TerminalColour:
teq r0,#6
ldreq r0,=0x02B5
beq SetForeColour

tst r0,#0b1000
ldrne r1,=0x52AA
moveq r1,#0
tst r0,#0b0100
addne r1,#0x15
tst r0,#0b0010
addne r1,#0x540
tst r0,#0b0001
addne r1,#0xA800
mov r0,r1
b SetForeColour

2、文本显示

我们的终端第一个真正需要的方法是 TerminalDisplay,它用来把当前的数据从 terminalBuffer拷贝到 terminalScreen 和实际的屏幕。如上所述,这个方法必须是最小开销的操作,因为我们需要频繁调用它。它主要比较 terminalBufferterminalDisplay 的文本,然后只拷贝有差异的字节。请记住 terminalBuffer 是以环状缓冲区运行的,这种情况,就是从 terminalViewterminalStop,或者 128*48 个字符,要看哪个来的最快。如果我们遇到 terminalStop,我们将会假定在这之后的所有字符是 7f 16 (ASCII 删除字符),颜色为 0(黑色前景色和背景色)。

让我们看看必须要做的事情:

  1. 加载 terminalViewterminalStopterminalDisplay 的地址。
  2. 对于每一行:

    1. 对于每一列:

      1. 如果 terminalView 不等于 terminalStop,根据 terminalView 加载当前字符和颜色
      2. 否则加载 0x7f 和颜色 0
      3. terminalDisplay 加载当前的字符
      4. 如果字符和颜色相同,直接跳转到第 10 步
      5. 存储字符和颜色到 terminalDisplay
      6. r0 作为背景色参数调用 TerminalColour
      7. r0 = 0x7f(ASCII 删除字符,一个块)、 r1 = xr2 = y 调用 DrawCharacter
      8. r0 作为前景色参数调用 TerminalColour
      9. r0 = 字符r1 = xr2 = y 调用 DrawCharacter
      10. 对位置参数 terminalDisplay 累加 2
      11. 如果 terminalView 不等于 terminalStopterminalView 位置参数累加 2
      12. 如果 terminalView 位置已经是文件缓冲器的末尾,将它设置为缓冲区的开始位置
      13. x 坐标增加 8
    2. y 坐标增加 16

尝试去自己实现吧。如果你遇到问题,我们的方案下面给出来了:

1、我这里的变量有点乱。为了方便起见,我用 taddr 存储 textBuffer 的末尾位置。

.globl TerminalDisplay
TerminalDisplay:
push {r4,r5,r6,r7,r8,r9,r10,r11,lr}
x .req r4
y .req r5
char .req r6
col .req r7
screen .req r8
taddr .req r9
view .req r10
stop .req r11

ldr taddr,=terminalStart
ldr view,[taddr,#terminalView - terminalStart]
ldr stop,[taddr,#terminalStop - terminalStart]
add taddr,#terminalBuffer - terminalStart
add taddr,#128*128*2
mov screen,taddr

2、从 yLoop 开始运行。

mov y,#0
yLoop$:

2.1、

mov x,#0
xLoop$:

xLoop 开始运行。

2.1.1、为了方便起见,我把字符和颜色同时加载到 char 变量了

teq view,stop
ldrneh char,[view]

2.1.2、这行是对上面一行的补充说明:读取黑色的删除字符

moveq char,#0x7f

2.1.3、为了简便我把字符和颜色同时加载到 col 里。

ldrh col,[screen]

2.1.4、 现在我用 teq 指令检查是否有数据变化

teq col,char
beq xLoopContinue$

2.1.5、我可以容易的保存当前值

strh char,[screen]

2.1.6、我用比特偏移指令 lsrand 指令从切分 char 变量,将颜色放到 col 变量,字符放到 char 变量,然后再用比特偏移指令 lsr 获取背景色后调用 TerminalColour

lsr col,char,#8
and char,#0x7f
lsr r0,col,#4
bl TerminalColour

2.1.7、写入一个彩色的删除字符

mov r0,#0x7f
mov r1,x
mov r2,y
bl DrawCharacter

2.1.8、用 and 指令获取 col 变量的低半字节,然后调用 TerminalColour

and r0,col,#0xf
bl TerminalColour

2.1.9、写入我们需要的字符

mov r0,char
mov r1,x
mov r2,y
bl DrawCharacter

2.1.10、自增屏幕指针

xLoopContinue$:
add screen,#2

2.1.11、如果可能自增 view 指针

teq view,stop
addne view,#2

2.1.12、很容易检测 view 指针是否越界到缓冲区的末尾,因为缓冲区的地址保存在 taddr 变量里

teq view,taddr
subeq view,#128*128*2

2.1.13、 如果还有字符需要显示,我们就需要自增 x 变量然后到 xLoop 循环执行

add x,#8
teq x,#1024
bne xLoop$

2.2、 如果还有更多的字符显示我们就需要自增 y 变量,然后到 yLoop 循环执行

add y,#16
teq y,#768
bne yLoop$

3、不要忘记最后清除变量

pop {r4,r5,r6,r7,r8,r9,r10,r11,pc}
.unreq x
.unreq y
.unreq char
.unreq col
.unreq screen
.unreq taddr
.unreq view
.unreq stop

3、行打印

现在我有了自己 TerminalDisplay 方法,它可以自动显示 terminalBuffer 内容到 terminalScreen,因此理论上我们可以画出文本。但是实际上我们没有任何基于字符显示的例程。 首先快速容易上手的方法便是 TerminalClear, 它可以彻底清除终端。这个方法不用循环也很容易实现。可以尝试分析下面的方法应该不难:

.globl TerminalClear
TerminalClear:
ldr r0,=terminalStart
add r1,r0,#terminalBuffer-terminalStart
str r1,[r0]
str r1,[r0,#terminalStop-terminalStart]
str r1,[r0,#terminalView-terminalStart]
mov pc,lr

现在我们需要构造一个字符显示的基础方法:Print 函数。它将保存在 r0 的字符串和保存在 r1 的字符串长度简单的写到屏幕上。有一些特定字符需要特别的注意,这些特定的操作是确保 terminalView 是最新的。我们来分析一下需要做什么:

  1. 检查字符串的长度是否为 0,如果是就直接返回
  2. 加载 terminalStopterminalView
  3. 计算出 terminalStop 的 x 坐标
  4. 对每一个字符的操作:

    1. 检查字符是否为新起一行
    2. 如果是的话,自增 bufferStop 到行末,同时写入黑色删除字符
    3. 否则拷贝当前 terminalColour 的字符
    4. 检查是否在行末
    5. 如果是,检查从 terminalViewterminalStop 之间的字符数是否大于一屏
    6. 如果是,terminalView 自增一行
    7. 检查 terminalView 是否为缓冲区的末尾,如果是的话将其替换为缓冲区的起始位置
    8. 检查 terminalStop 是否为缓冲区的末尾,如果是的话将其替换为缓冲区的起始位置
    9. 检查 terminalStop 是否等于 terminalStart, 如果是的话 terminalStart 自增一行。
    10. 检查 terminalStart 是否为缓冲区的末尾,如果是的话将其替换为缓冲区的起始位置
  5. 存取 terminalStopterminalView

试一下自己去实现。我们的方案提供如下:

1、这个是 Print 函数开始快速检查字符串为0的代码

.globl Print
Print:
teq r1,#0
moveq pc,lr

2、这里我做了很多配置。 bufferStart 代表 terminalStartbufferStop 代表terminalStopview 代表 terminalViewtaddr 代表 terminalBuffer 的末尾地址。

push {r4,r5,r6,r7,r8,r9,r10,r11,lr}
bufferStart .req r4
taddr .req r5
x .req r6
string .req r7
length .req r8
char .req r9
bufferStop .req r10
view .req r11

mov string,r0
mov length,r1

ldr taddr,=terminalStart
ldr bufferStop,[taddr,#terminalStop-terminalStart]
ldr view,[taddr,#terminalView-terminalStart]
ldr bufferStart,[taddr]
add taddr,#terminalBuffer-terminalStart
add taddr,#128*128*2

3、和通常一样,巧妙的对齐技巧让许多事情更容易。由于需要对齐 terminalBuffer,每个字符的 x 坐标需要 8 位要除以 2。

and x,bufferStop,#0xfe
lsr x,#1

4.1、我们需要检查新行

charLoop$:
ldrb char,[string]
and char,#0x7f
teq char,#'\n'
bne charNormal$

4.2、循环执行值到行末写入 0x7f;黑色删除字符

mov r0,#0x7f
clearLine$:
strh r0,[bufferStop]
add bufferStop,#2
add x,#1
teq x,#128 blt clearLine$
    
b charLoopContinue$

4.3、存储字符串的当前字符和 terminalBuffer 末尾的 terminalColour 然后将它和 x 变量自增

charNormal$:
strb char,[bufferStop]
ldr r0,=terminalColour
ldrb r0,[r0]
strb r0,[bufferStop,#1]
add bufferStop,#2
add x,#1

4.4、检查 x 是否为行末;128

charLoopContinue$:
cmp x,#128
blt noScroll$

4.5、设置 x 为 0 然后检查我们是否已经显示超过 1 屏。请记住,我们是用的循环缓冲区,因此如果 bufferStopview 之前的差是负值,我们实际上是环绕了缓冲区。

mov x,#0
subs r0,bufferStop,view
addlt r0,#128*128*2
cmp r0,#128*(768/16)*2

4.6、增加一行字节到 view 的地址

addge view,#128*2

4.7、 如果 view 地址是缓冲区的末尾,我们就从它上面减去缓冲区的长度,让其指向开始位置。我会在开始的时候设置 taddr 为缓冲区的末尾地址。

teq view,taddr
subeq view,taddr,#128*128*2

4.8、如果 stop 的地址在缓冲区末尾,我们就从它上面减去缓冲区的长度,让其指向开始位置。我会在开始的时候设置 taddr 为缓冲区的末尾地址。

noScroll$:
teq bufferStop,taddr
subeq bufferStop,taddr,#128*128*2

4.9、检查 bufferStop 是否等于 bufferStart。 如果等于增加一行到 bufferStart

teq bufferStop,bufferStart
addeq bufferStart,#128*2

4.10、如果 start 的地址在缓冲区的末尾,我们就从它上面减去缓冲区的长度,让其指向开始位置。我会在开始的时候设置 taddr 为缓冲区的末尾地址。

teq bufferStart,taddr
subeq bufferStart,taddr,#128*128*2

循环执行知道字符串结束

subs length,#1
add string,#1
bgt charLoop$

5、保存变量然后返回

charLoopBreak$:
sub taddr,#128*128*2
sub taddr,#terminalBuffer-terminalStart
str bufferStop,[taddr,#terminalStop-terminalStart]
str view,[taddr,#terminalView-terminalStart]
str bufferStart,[taddr]

pop {r4,r5,r6,r7,r8,r9,r10,r11,pc}
.unreq bufferStart
.unreq taddr
.unreq x
.unreq string
.unreq length
.unreq char
.unreq bufferStop
.unreq view

这个方法允许我们打印任意字符到屏幕。然而我们用了颜色变量,但实际上没有设置它。一般终端用特性的组合字符去行修改颜色。如 ASCII 转义(1b 16)后面跟着一个 0 - f 的 16 进制的数,就可以设置前景色为 CGA 颜色号。如果你自己想尝试实现;在下载页面有一个我的详细的例子。

4、标志输入

现在我们有一个可以打印和显示文本的输出终端。这仅仅是说对了一半,我们需要输入。我们想实现一个方法:ReadLine,可以保存文件的一行文本,文本位置由 r0 给出,最大的长度由 r1 给出,返回 r0 里面的字符串长度。棘手的是用户输出字符的时候要回显功能,同时想要退格键的删除功能和命令回车执行功能。它们还需要一个闪烁的下划线代表计算机需要输入。这些完全合理的要求让构造这个方法更具有挑战性。有一个方法完成这些需求就是存储用户输入的文本和文件大小到内存的某个地方。然后当调用 ReadLine 的时候,移动 terminalStop 的地址到它开始的地方然后调用 Print。也就是说我们只需要确保在内存维护一个字符串,然后构造一个我们自己的打印函数。

按照惯例,许多编程语言中,任意程序可以访问 stdin 和 stdin,它们可以连接到终端的输入和输出流。在图形程序其实也可以进行同样操作,但实际几乎不用。

让我们看看 ReadLine 做了哪些事情:

  1. 如果字符串可保存的最大长度为 0,直接返回
  2. 检索 terminalStopterminalStop 的当前值
  3. 如果字符串的最大长度大约缓冲区的一半,就设置大小为缓冲区的一半
  4. 从最大长度里面减去 1 来确保输入的闪烁字符或结束符
  5. 向字符串写入一个下划线
  6. 写入一个 terminalViewterminalStop 的地址到内存
  7. 调用 Print 打印当前字符串
  8. 调用 TerminalDisplay
  9. 调用 KeyboardUpdate
  10. 调用 KeyboardGetChar
  11. 如果是一个新行直接跳转到第 16 步
  12. 如果是一个退格键,将字符串长度减 1(如果其大于 0)
  13. 如果是一个普通字符,将它写入字符串(字符串大小确保小于最大值)
  14. 如果字符串是以下划线结束,写入一个空格,否则写入下划线
  15. 跳转到第 6 步
  16. 字符串的末尾写入一个新行字符
  17. 调用 PrintTerminalDisplay
  18. 用结束符替换新行
  19. 返回字符串的长度

为了方便读者理解,然后然后自己去实现,我们的实现提供如下:

  1. 快速处理长度为 0 的情况
.globl ReadLine
ReadLine:
teq r1,#0
moveq r0,#0
moveq pc,lr

2、考虑到常见的场景,我们初期做了很多初始化动作。input 代表 terminalStop 的值,view 代表 terminalViewLength 默认为 0

string .req r4
maxLength .req r5
input .req r6
taddr .req r7
length .req r8
view .req r9

push {r4,r5,r6,r7,r8,r9,lr}

mov string,r0
mov maxLength,r1
ldr taddr,=terminalStart
ldr input,[taddr,#terminalStop-terminalStart]
ldr view,[taddr,#terminalView-terminalStart]
mov length,#0

3、我们必须检查异常大的读操作,我们不能处理超过 terminalBuffer 大小的输入(理论上可行,但是 terminalStart 移动越过存储的 terminalStop`,会有很多问题)。

cmp maxLength,#128*64
movhi maxLength,#128*64

4、由于用户需要一个闪烁的光标,我们需要一个备用字符在理想状况在这个字符串后面放一个结束符。

sub maxLength,#1

5、写入一个下划线让用户知道我们可以输入了。

mov r0,#'_'
strb r0,[string,length]

6、保存 terminalStopterminalView。这个对重置一个终端很重要,它会修改这些变量。严格讲也可以修改 terminalStart,但是不可逆。

readLoop$:
str input,[taddr,#terminalStop-terminalStart]
str view,[taddr,#terminalView-terminalStart]

7、写入当前的输入。由于下划线因此字符串长度加 1

mov r0,string
mov r1,length
add r1,#1
bl Print

8、拷贝下一个文本到屏幕

bl TerminalDisplay

9、获取最近一次键盘输入

bl KeyboardUpdate

10、检索键盘输入键值

bl KeyboardGetChar

11、如果我们有一个回车键,循环中断。如果有结束符和一个退格键也会同样跳出循环。

teq r0,#'\n'
beq readLoopBreak$
teq r0,#0
beq cursor$
teq r0,#'\b'
bne standard$

12、从 length 里面删除一个字符

delete$:
cmp length,#0
subgt length,#1
b cursor$

13、写回一个普通字符

standard$:
cmp length,maxLength
bge cursor$
strb r0,[string,length]
add length,#1

14、加载最近的一个字符,如果不是下划线则修改为下换线,如果是则修改为空格

cursor$:
ldrb r0,[string,length]
teq r0,#'_'
moveq r0,#' '
movne r0,#'_'
strb r0,[string,length]

15、循环执行值到用户输入按下

b readLoop$
readLoopBreak$:

16、在字符串的结尾处存入一个新行字符

mov r0,#'\n'
strb r0,[string,length]

17、重置 terminalViewterminalStop 然后调用 PrintTerminalDisplay 显示最终的输入

str input,[taddr,#terminalStop-terminalStart]
str view,[taddr,#terminalView-terminalStart]
mov r0,string
mov r1,length
add r1,#1
bl Print
bl TerminalDisplay

18、写入一个结束符

mov r0,#0
strb r0,[string,length]

19、返回长度

mov r0,length
pop {r4,r5,r6,r7,r8,r9,pc}
.unreq string
.unreq maxLength
.unreq input
.unreq taddr
.unreq length
.unreq view

5、终端:机器进化

现在我们理论用终端和用户可以交互了。最显而易见的事情就是拿去测试了!删除 main.sbl UsbInitialise 后面的代码后如下:

reset$:
  mov sp,#0x8000
  bl TerminalClear
  
  ldr r0,=welcome
  mov r1,#welcomeEnd-welcome
  bl Print

loop$:
  ldr r0,=prompt
  mov r1,#promptEnd-prompt
  bl Print
  
  ldr r0,=command
  mov r1,#commandEnd-command
  bl ReadLine
  
  teq r0,#0
  beq loopContinue$
  
  mov r4,r0
  
  ldr r5,=command
  ldr r6,=commandTable
  
  ldr r7,[r6,#0]
  ldr r9,[r6,#4]
  commandLoop$:
    ldr r8,[r6,#8]
    sub r1,r8,r7
    
    cmp r1,r4
    bgt commandLoopContinue$
    
    mov r0,#0
    commandName$:
      ldrb r2,[r5,r0]
      ldrb r3,[r7,r0]
      teq r2,r3
      bne commandLoopContinue$
      add r0,#1
      teq r0,r1
      bne commandName$
    
    ldrb r2,[r5,r0]
    teq r2,#0
    teqne r2,#' '
    bne commandLoopContinue$
    
    mov r0,r5
    mov r1,r4
    mov lr,pc
    mov pc,r9
    b loopContinue$
  
  commandLoopContinue$:
    add r6,#8
    mov r7,r8
    ldr r9,[r6,#4]
    teq r9,#0
    bne commandLoop$
  
  ldr r0,=commandUnknown
  mov r1,#commandUnknownEnd-commandUnknown
  ldr r2,=formatBuffer
  ldr r3,=command
  bl FormatString
  
  mov r1,r0
  ldr r0,=formatBuffer
  bl Print

loopContinue$:
  bl TerminalDisplay
  b loop$

echo:
  cmp r1,#5
  movle pc,lr

  add r0,#5
  sub r1,#5
  b Print

ok:
  teq r1,#5
  beq okOn$
  teq r1,#6
  beq okOff$
  mov pc,lr
  
  okOn$:
    ldrb r2,[r0,#3]
    teq r2,#'o'
    ldreqb r2,[r0,#4]
    teqeq r2,#'n'
    movne pc,lr
    mov r1,#0
    b okAct$
  
  okOff$:
    ldrb r2,[r0,#3]
    teq r2,#'o'
    ldreqb r2,[r0,#4]
    teqeq r2,#'f'
    ldreqb r2,[r0,#5]
    teqeq r2,#'f'
    movne pc,lr
    mov r1,#1
  
  okAct$:
  
    mov r0,#16
    b SetGpio

.section .data
.align 2
welcome: .ascii "Welcome to Alex's OS - Everyone's favourite OS"
welcomeEnd:
.align 2
prompt: .ascii "\n> "
promptEnd:
.align 2
command:
  .rept 128
    .byte 0
  .endr
commandEnd:
.byte 0
.align 2
commandUnknown: .ascii "Command `%s' was not recognised.\n"
commandUnknownEnd:
.align 2
formatBuffer:
  .rept 256
    .byte 0
  .endr
formatEnd:

.align 2
commandStringEcho: .ascii "echo"
commandStringReset: .ascii "reset"
commandStringOk: .ascii "ok"
commandStringCls: .ascii "cls"
commandStringEnd:

.align 2
commandTable:
.int commandStringEcho, echo
.int commandStringReset, reset$
.int commandStringOk, ok
.int commandStringCls, TerminalClear
.int commandStringEnd, 0

这块代码集成了一个简易的命令行操作系统。支持命令:echoresetokclsecho 拷贝任意文本到终端,reset 命令会在系统出现问题的是复位操作系统,ok 有两个功能:设置 OK 灯亮灭,最后 cls 调用 TerminalClear 清空终端。

试试树莓派的代码吧。如果遇到问题,请参照问题集锦页面吧。

如果运行正常,祝贺你完成了一个操作系统基本终端和输入系列的课程。很遗憾这个教程先讲到这里,但是我希望将来能制作更多教程。有问题请反馈至 [email protected]

你已经在建立了一个简易的终端操作系统。我们的代码在 commandTable 构造了一个可用的命令表格。每个表格的入口是一个整型数字,用来表示字符串的地址,和一个整型数字表格代码的执行入口。 最后一个入口是 为 0 的 commandStringEnd。尝试实现你自己的命令,可以参照已有的函数,建立一个新的。函数的参数 r0 是用户输入的命令地址,r1 是其长度。你可以用这个传递你输入值到你的命令。也许你有一个计算器程序,或许是一个绘图程序或国际象棋。不管你的什么点子,让它跑起来!


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

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

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

欢迎进入输入课程系列。在本系列,你将会学会如何使用键盘接收输入给树莓派。我们将会从揭示输入开始本课,然后转向更传统的文本提示符。

这是第一堂输入课,会教授一些关于驱动和链接的理论,同样也包含键盘的知识,最后以在屏幕上显示文本结束。

1、开始

希望你已经完成了 OK 系列课程,这会对你完成屏幕系列课程很有帮助。很多 OK 课程上的文件会被使用而不会做解释。如果你没有这些文件,或者希望使用一个正确的实现,可以从该堂课的下载页下载模板。如果你使用你自己的实现,请删除调用了 SetGraphicsAddress 之后全部的代码。

2、USB

如你所知,树莓派 B 型有两个 USB 接口,通常用来连接一个鼠标和一个键盘。这是一个非常好的设计决策,USB 是一个非常通用的接口,很多种设备都可以使用它。这就很容易为它设计新外设,很容易为它编写设备驱动,而且通过 USB 集线器可以非常容易扩展。还能更好吗?当然是不能,实际上对一个操作系统开发者来说,这就是我们的噩梦。USB 标准太大了。我是说真的,在你思考如何连接设备之前,它的文档将近 700 页。

USB 标准的设计目的是通过复杂的软件来简化硬件交互。

我和很多爱好操作系统的开发者谈过这些,而他们全部都说几句话:不要抱怨。“实现这个需要花费很久时间”,“你不可能写出关于 USB 的教程”,“收益太小了”。在很多方面,他们是对的,我不可能写出一个关于 USB 标准的教程,那得花费几周时间。我同样不能教授如何为全部所有的设备编写外设驱动,所以使用自己写的驱动是没什么用的。然而,即便不能做到最好,我仍然可以获取一个正常工作的 USB 驱动,拿一个键盘驱动,然后教授如何在操作系统中使用它们。我开始寻找可以运行在一个甚至不知道文件是什么的操作系统的自由驱动,但是我一个都找不到,它们都太高层了,所以我尝试写一个。大家说的都对,这耗费了我几周时间。然而我可以高兴的说我做的这些工作没有获取操作系统以外的帮助,并且可以和鼠标和键盘通信。这绝不是完整的、高效的,或者正确的,但是它能工作。驱动是以 C 编写的,而且有兴趣的可以在下载页找到全部源代码。

所以,这一个教程不会是 USB 标准的课程(一点也没有)。实际上我们将会看到如何使用其他人的代码。

3、链接

既然我们要引进外部代码到操作系统,我们就需要谈一谈 链接 linking 。链接是一种过程,可以在程序或者操作系统中链接函数。这意味着当一个程序生成之后,我们不必要编写每一个函数(几乎可以肯定,实际上并非如此)。链接就是我们做的用来把我们程序和别人代码中的函数连结在一起。这个实际上已经在我们的操作系统进行了,因为链接器把所有不同的文件链接在一起,每个都是分开编译的。

链接允许我们制作可重用的代码库,所有人都可以在他们的程序中使用。

有两种链接方式:静态和动态。静态链接就像我们在制作自己的操作系统时进行的。链接器找到全部函数的地址,然后在链接结束前,将这些地址都写入代码中。动态链接是在程序“完成”之后。当程序加载后,动态链接器检查程序,然后在操作系统的库找到所有不在程序里的函数。这就是我们的操作系统最终应该能够完成的一项工作,但是现在所有东西都将是静态链接的。

程序经常调用调用库,这些库会调用其它的库,直到最终调用了我们写的操作系统的库。

我编写的 USB 驱动程序适合静态编译。这意味着我给你的是每个文件的编译后的代码,然后链接器找到你的代码中的那些没有实现的函数,就将这些函数链接到我的代码。在本课的 下载页 是一个 makefile 和我的 USB 驱动,这是接下来需要的。下载并使用这个 makefile 替换你的代码中的 makefile, 同事将驱动放在和这个 makefile 相同的文件夹。

4、键盘

为了将输入传给我们的操作系统,我们需要在某种程度上理解键盘是如何实际工作的。键盘有两种按键:普通键和修饰键。普通按键是字母、数字、功能键,等等。它们构成了键盘上几乎全部按键。修饰键是多达 8 个的特殊键。它们是左 shift、右 shift、左 ctrl、右 ctrl、左 alt、右 alt、左 GUI 和右 GUI。键盘可以检测出所有的组合中那个修饰键被按下了,以及最多 6 个普通键。每次一个按钮变化了(例如,是按下了还是释放了),键盘就会报告给电脑。通常,键盘也会有 3 个 LED 灯,分别指示大写锁定,数字键锁定,和滚动锁定,这些都是由电脑控制的,而不是键盘自己。键盘也可能有更多的灯,比如电源、静音,等等。

对于标准 USB 键盘,有一个按键值的表,每个键盘按键都一个唯一的数字,每个可能的 LED 也类似。下面的表格列出了前 126 个值。

表 4.1 USB 键盘值

序号描述序号描述序号描述序号描述
4aA5bB6cC7dD
8eE9fF10gG11hH
12iI13jJ14kK15lL
16mM17nN18oO19pP
20qQ21rR22sS23tT
24uU25vV26wW27xX
28yY29zZ301!312@
323#334$345%356^
367&378*389(390)
40ReturnEnter41Escape42DeleteBackspace43Tab
44Spacebar45-_46=+47[{
48]}49\ 和 ``50#~51;:
52'"53`~54,<55.>
56/?57Caps Lock58F159F2
60F361F462F563F6
64F765F866F967F10
68F1169F1270Print Screen71Scroll Lock
72Pause73Insert74Home75Page Up
76Delete forward77End78Page Down79Right Arrow
80Left Arrow81Down Arrow82Up Arrow83Num Lock
84小键盘 /85小键盘 *86小键盘 -87小键盘 +
88小键盘 Enter89小键盘 1End90小键盘 2Down Arrow91小键盘 3Page Down
92小键盘 4Left Arrow93小键盘 594小键盘 6Right Arrow95小键盘 7Home
96小键盘 8Up Arrow97小键盘 9Page Up98小键盘 0Insert99小键盘 .Delete
100\ 和 ``101Application102Power103小键盘 =
104F13105F14106F15107F16
108F17109F18110F19111F20
112F21113F22114F23115F24
116Execute117Help118Menu119Select
120Stop121Again122Undo123Cut
124Copy125Paste126Find127Mute
128Volume Up129Volume Down

完全列表可以在HID 页表 1.12的 53 页,第 10 节找到。

5、车轮后的螺母

通常,当你使用其他人的代码,他们会提供一份自己代码的总结,描述代码都做了什么,粗略介绍了是如何工作的,以及什么情况下会出错。下面是一个使用我的 USB 驱动的相关步骤要求。

这些总结和代码的描述组成了一个 API - 应用程序产品接口。

表 5.1 CSUD 中和键盘相关的函数

函数参数返回值描述
UsbInitialiser0 是结果码这个方法是一个集多种功能于一身的方法,它加载 USB 驱动程序,枚举所有设备并尝试与它们通信。这种方法通常需要大约一秒钟的时间来执行,但是如果插入几个 USB 集线器,执行时间会明显更长。在此方法完成之后,键盘驱动程序中的方法就可用了,不管是否确实插入了键盘。返回代码如下解释。
UsbCheckForChange本质上提供与 UsbInitialise 相同的效果,但不提供相同的一次初始化。该方法递归地检查每个连接的集线器上的每个端口,如果已经添加了新设备,则添加它们。如果没有更改,这应该是非常快的,但是如果连接了多个设备的集线器,则可能需要几秒钟的时间。
KeyboardCountr0 是计数返回当前连接并检测到的键盘数量。UsbCheckForChange 可能会对此进行更新。默认情况下最多支持 4 个键盘。可以通过这个驱动程序访问多达这么多的键盘。
KeyboardGetAddressr0 是索引r0 是地址检索给定键盘的地址。所有其他函数都需要一个键盘地址,以便知道要访问哪个键盘。因此,要与键盘通信,首先要检查计数,然后检索地址,然后使用其他方法。注意,在调用 UsbCheckForChange 之后,此方法返回的键盘顺序可能会改变。
KeyboardPollr0 是地址r0 是结果码从键盘读取当前键状态。这是通过直接轮询设备来操作的,与最佳实践相反。这意味着,如果没有频繁地调用此方法,可能会错过一个按键。所有读取方法只返回上次轮询时的值。
KeyboardGetModifiersr0 是地址r0 是修饰键状态检索上次轮询时修饰键的状态。这是两边的 shift 键、alt 键和 GUI 键。这回作为一个位字段返回,这样,位 0 中的 1 表示左控件被保留,位 1 表示左 shift,位 2 表示左 alt ,位 3 表示左 GUI,位 4 到 7 表示前面几个键的右版本。如果有问题,r0 包含 0。
KeyboardGetKeyDownCountr0 是地址r0 是计数检索当前按下键盘的键数。这排除了修饰键。这通常不能超过 6。如果有错误,这个方法返回 0。
KeyboardGetKeyDownr0 是地址,r1 键号r0 是扫描码检索特定按下键的扫描码(见表 4.1)。通常,要计算出哪些键是按下的,可以调用 KeyboardGetKeyDownCount,然后多次调用 KeyboardGetKeyDown ,将 r1 的值递增,以确定哪些键是按下的。如果有问题,返回 0。可以(但不建议这样做)在不调用 KeyboardGetKeyDownCount 的情况下调用此方法将 0 解释为没有按下的键。注意,顺序或扫描代码可以随机更改(有些键盘按数字排序,有些键盘按时间排序,没有任何保证)。
KeyboardGetKeyIsDownr0 是地址,r1 扫描码r0 是状态除了 KeyboardGetKeyDown 之外,还可以检查按下的键中是否有特定的扫描码。如果没有,返回 0;如果有,返回一个非零值。当检测特定的扫描码(例如寻找 ctrl+c)时更快。出错时,返回 0。
KeyboardGetLedSupportr0 是地址r0 是 LED检查特定键盘支持哪些 LED。第 0 位代表数字锁定,第 1 位代表大写锁定,第 2 位代表滚动锁定,第 3 位代表合成,第 4 位代表假名,第 5 位代表电源,第 6 位代表 Shift ,第 7 位代表静音。根据 USB 标准,这些 LED 都不是自动更新的(例如,当检测到大写锁定扫描代码时,必须手动设置大写锁定 LED)。
KeyboardSetLedsr0 是地址, r1 是 LEDr0 是结果码试图打开/关闭键盘上指定的 LED 灯。查看下面的结果代码值。参见 KeyboardGetLedSupport 获取 LED 的值。

有几种方法返回“返回值”。这些都是 C 代码的老生常谈了,就是用数字代表函数调用发生了什么。通常情况, 0 总是代表操作成功。下面的是驱动用到的返回值。

返回值是一种处理错误的简单方法,但是通常更优雅的解决途径会出现于更高层次的代码。

表 5.2 - CSUD 返回值

代码描述
0方法成功完成。
-2参数:函数调用了无效参数。
-4设备:设备没有正确响应请求。
-5不匹配:驱动不适用于这个请求或者设备。
-6编译器:驱动没有正确编译,或者被破坏了。
-7内存:驱动用尽了内存。
-8超时:设备没有在预期的时间内响应请求。
-9断开连接:被请求的设备断开连接,或者不能使用。

驱动的通常用法如下:

  1. 调用 UsbInitialise
  2. 调用 UsbCheckForChange
  3. 调用 KeyboardCount
  4. 如果返回 0,重复步骤 2。
  5. 针对你支持的每个键盘:

    1. 调用 KeyboardGetAddress
    2. 调用 KeybordGetKeyDownCount
    3. 针对每个按下的按键:

      1. 检查它是否已经被按下了
      2. 保存按下的按键
    4. 针对每个保存的按键:

      1. 检查按键是否被释放了
      2. 如果释放了就删除
  6. 根据按下/释放的案件执行操作
  7. 重复步骤 2

最后,你可以对键盘做所有你想做的任何事了,而这些方法应该允许你访问键盘的全部功能。在接下来的两节课,我们将会着眼于完成文本终端的输入部分,类似于大部分的命令行电脑,以及命令的解释。为了做这些,我们将需要在更有用的形式下得到一个键盘输入。你可能注意到我的驱动是(故意的)没有太大帮助,因为它并没有方法来判断是否一个按键刚刚按下或释放了,它只有方法来判断当前那个按键是按下的。这就意味着我们需要自己编写这些方法。

6、可用更新

首先,让我们实现一个 KeyboardUpdate 方法,检查第一个键盘,并使用轮询方法来获取当前的输入,以及保存最后一个输入来对比。然后我们可以使用这个数据和其它方法来将扫描码转换成按键。这个方法应该按照下面的说明准确操作:

重复检查更新被称为“轮询”。这是针对驱动 IO 中断而言的,这种情况下设备在准备好后会发一个信号。
  1. 提取一个保存好的键盘地址(初始值为 0)。
  2. 如果不是 0 ,进入步骤 9.
  3. 调用 UsbCheckForChange 检测新键盘。
  4. 调用 KeyboardCount 检测有几个键盘在线。
  5. 如果返回 0,意味着没有键盘可以让我们操作,只能退出了。
  6. 调用 KeyboardGetAddress 参数是 0,获取第一个键盘的地址。
  7. 保存这个地址。
  8. 如果这个值是 0,那么退出,这里应该有些问题。
  9. 调用 KeyboardGetKeyDown 6 次,获取每次按键按下的值并保存。
  10. 调用 KeyboardPoll
  11. 如果返回值非 0,进入步骤 3。这里应该有些问题(比如键盘断开连接)。

要保存上面提到的值,我们将需要下面 .data 段的值。

.section .data
.align 2
KeyboardAddress:
.int 0
KeyboardOldDown:
.rept 6
.hword 0
.endr
.hword num 直接将半字的常数插入文件。
.rept num [commands] .endr 复制 `commands` 命令到输出 num 次。

试着自己实现这个方法。对此,我的实现如下:

1、我们加载键盘的地址。

.section .text
.globl KeyboardUpdate
KeyboardUpdate:
push {r4,r5,lr}

kbd .req r4
ldr r0,=KeyboardAddress
ldr kbd,[r0]

2、如果地址非 0,就说明我们有一个键盘。调用 UsbCheckForChanges 慢,所以如果一切正常,我们要避免调用这个函数。

teq kbd,#0
bne haveKeyboard$

3、如果我们一个键盘都没有,我们就必须检查新设备。

getKeyboard$:
bl UsbCheckForChange

4、如果有新键盘添加,我们就会看到这个。

bl KeyboardCount

5、如果没有键盘,我们就没有键盘地址。

teq r0,#0
ldreq r1,=KeyboardAddress
streq r0,[r1]
beq return$

6、让我们获取第一个键盘的地址。你可能想要支持更多键盘。

mov r0,#0
bl KeyboardGetAddress

7、保存键盘地址。

ldr r1,=KeyboardAddress
str r0,[r1]

8、如果我们没有键盘地址,这里就没有其它活要做了。

teq r0,#0
beq return$
mov kbd,r0

9、循环查询全部按键,在 KeyboardOldDown 保存下来。如果我们询问的太多了,返回 0 也是正确的。

saveKeys$:
  mov r0,kbd
  mov r1,r5
  bl KeyboardGetKeyDown
  
  ldr r1,=KeyboardOldDown
  add r1,r5,lsl #1
  strh r0,[r1]
  add r5,#1
  cmp r5,#6
  blt saveKeys$

10、现在我们得到了新的按键。

mov r0,kbd
bl KeyboardPoll

11、最后我们要检查 KeyboardOldDown 是否工作了。如果没工作,那么我们可能是断开连接了。

teq r0,#0
bne getKeyboard$

return$:
pop {r4,r5,pc}
.unreq kbd

有了我们新的 KeyboardUpdate 方法,检查输入变得简单,固定周期调用这个方法就行,而它甚至可以检查键盘是否断开连接,等等。这是一个有用的方法,因为我们实际的按键处理会根据条件不同而有所差别,所以能够用一个函数调以它的原始方式获取当前的输入是可行的。下一个方法我们希望它是 KeyboardGetChar,简单的返回下一个按下的按钮的 ASCII 字符,或者如果没有按键按下就返回 0。这可以扩展到支持如果它按下一个特定时间当做多次按下按键,也支持锁定键和修饰键。

如果我们有一个 KeyWasDown 方法可以使这个方法有用起来,如果给定的扫描代码不在 KeyboardOldDown 值中,它只返回 0,否则返回一个非零值。你可以自己尝试一下。与往常一样,可以在下载页面找到解决方案。

7、查找表

KeyboardGetChar 方法如果写得不好,可能会非常复杂。有 100 多种扫描码,每种代码都有不同的效果,这取决于 shift 键或其他修饰符的存在与否。并不是所有的键都可以转换成一个字符。对于一些字符,多个键可以生成相同的字符。在有如此多可能性的情况下,一个有用的技巧是查找表。查找表与物理意义上的查找表非常相似,它是一个值及其结果的表。对于一些有限的函数,推导出答案的最简单方法就是预先计算每个答案,然后通过检索返回正确的答案。在这种情况下,我们可以在内存中建立一个序列的值,序列中第 n 个值就是扫描代码 n 的 ASCII 字符代码。这意味着如果一个键被按下,我们的方法只需要检测到,然后从表中检索它的值。此外,我们可以为当按住 shift 键时的值单独创建一个表,这样按下 shift 键就可以简单地换个我们用的表。

在编程的许多领域,程序越大,速度越快。查找表很大,但是速度很快。有些问题可以通过查找表和普通函数的组合来解决。

.section .data 命令之后,复制下面的表:

.align 3
KeysNormal:
    .byte 0x0, 0x0, 0x0, 0x0, 'a', 'b', 'c', 'd'
    .byte 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l'
    .byte 'm', 'n', 'o', 'p', 'q', 'r', 's', 't'
    .byte 'u', 'v', 'w', 'x', 'y', 'z', '1', '2'
    .byte '3', '4', '5', '6', '7', '8', '9', '0'
    .byte '\n', 0x0, '\b', '\t', ' ', '-', '=', '['
    .byte ']', '\\\', '#', ';', '\'', '`', ',', '.'
    .byte '/', 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0
    .byte 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0
    .byte 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0
    .byte 0x0, 0x0, 0x0, 0x0, '/', '*', '-', '+'
    .byte '\n', '1', '2', '3', '4', '5', '6', '7'
    .byte '8', '9', '0', '.', '\\\', 0x0, 0x0, '='

.align 3
KeysShift:
    .byte 0x0, 0x0, 0x0, 0x0, 'A', 'B', 'C', 'D'
    .byte 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L'
    .byte 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T'
    .byte 'U', 'V', 'W', 'X', 'Y', 'Z', '!', '"'
    .byte '£', '$', '%', '^', '&', '*', '(', ')'
    .byte '\n', 0x0, '\b', '\t', ' ', '_', '+', '{'
    .byte '}', '|', '~', ':', '@', '¬', '<', '>'
    .byte '?', 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0
    .byte 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0
    .byte 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0
    .byte 0x0, 0x0, 0x0, 0x0, '/', '*', '-', '+'
    .byte '\n', '1', '2', '3', '4', '5', '6', '7'
    .byte '8', '9', '0', '.', '|', 0x0, 0x0, '='

这些表直接将前 104 个扫描码映射到 ASCII 字符作为一个字节表。我们还有一个单独的表来描述 shift 键对这些扫描码的影响。我使用 ASCII null 字符(0)表示所有没有直接映射的 ASCII 键(例如功能键)。退格映射到 ASCII 退格字符(8 表示 \b),enter 映射到 ASCII 新行字符(10 表示 \n), tab 映射到 ASCII 水平制表符(9 表示 \t)。

.byte num 直接插入字节常量 num 到文件。

.

大部分的汇编器和编译器识别转义序列;如 \t 这样的字符序列会插入该特殊字符。

KeyboardGetChar 方法需要做以下工作:

  1. 检查 KeyboardAddress 是否返回 0。如果是,则返回 0。
  2. 调用 KeyboardGetKeyDown 最多 6 次。每次:

    1. 如果按键是 0,跳出循环。
    2. 调用 KeyWasDown。 如果返回是,处理下一个按键。
    3. 如果扫描码超过 103,进入下一个按键。
    4. 调用 KeyboardGetModifiers
    5. 如果 shift 是被按着的,就加载 KeysShift 的地址,否则加载 KeysNormal 的地址。
    6. 从表中读出 ASCII 码值。
    7. 如果是 0,进行下一个按键,否则返回 ASCII 码值并退出。
  3. 返回 0。

试着自己实现。我的实现展示在下面:

1、简单的检查我们是否有键盘。

.globl KeyboardGetChar
KeyboardGetChar:
ldr r0,=KeyboardAddress
ldr r1,[r0]
teq r1,#0
moveq r0,#0
moveq pc,lr

2、r5 将会保存按键的索引,r4 保存键盘的地址。

push {r4,r5,r6,lr}
kbd .req r4
key .req r6
mov r4,r1
mov r5,#0
keyLoop$:
  mov r0,kbd
  mov r1,r5
  bl KeyboardGetKeyDown

2.1、 如果扫描码是 0,它要么意味着有错,要么说明没有更多按键了。

teq r0,#0
beq keyLoopBreak$

2.2、如果按键已经按下了,那么他就没意义了,我们只想知道按下的按键。

mov key,r0
bl KeyWasDown
teq r0,#0
bne keyLoopContinue$

2.3、如果一个按键有个超过 104 的扫描码,它将会超出我们的表,所以它是无关的按键。

cmp key,#104
bge keyLoopContinue$

2.4、我们需要知道修饰键来推断字符。

mov r0,kbd
bl KeyboardGetModifiers

2.5、当将字符更改为其 shift 变体时,我们要同时检测左 shift 键和右 shift 键。记住,tst 指令计算的是逻辑和,然后将其与 0 进行比较,所以当且仅当移位位都为 0 时,它才等于 0。

tst r0,#0b00100010
ldreq r0,=KeysNormal
ldrne r0,=KeysShift

2.6、现在我们可以从查找表加载按键了。

ldrb r0,[r0,key]

2.7、如果查找码包含一个 0,我们必须继续。为了继续,我们要增加索引,并检查是否到 6 次了。

teq r0,#0
bne keyboardGetCharReturn$
keyLoopContinue$:
add r5,#1
cmp r5,#6
blt keyLoop$

3、在这里我们返回我们的按键,如果我们到达 keyLoopBreak$ ,然后我们就知道这里没有按键被握住,所以返回 0。

keyLoopBreak$:
mov r0,#0
keyboardGetCharReturn$:
pop {r4,r5,r6,pc}
.unreq kbd
.unreq key

8、记事本操作系统

现在我们有了 KeyboardGetChar 方法,可以创建一个操作系统,只打印出用户对着屏幕所写的内容。为了简单起见,我们将忽略所有非常规的键。在 main.s,删除 bl SetGraphicsAddress 之后的所有代码。调用 UsbInitialise,将 r4r5 设置为 0,然后循环执行以下命令:

  1. 调用 KeyboardUpdate
  2. 调用 KeyboardGetChar
  3. 如果返回 0,跳转到步骤 1
  4. 复制 r4r5r1r2 ,然后调用 DrawCharacter
  5. r0 加到 r4
  6. 如果 r4 是 1024,将 r1 加到 r5,然后设置 r4 为 0。
  7. 如果 r5 是 768,设置 r5 为0
  8. 跳转到步骤 1

现在编译,然后在树莓派上测试。你几乎可以立即开始在屏幕上输入文本。如果没有工作,请参阅我们的故障排除页面。

当它工作时,祝贺你,你已经实现了与计算机的接口。现在你应该开始意识到,你几乎已经拥有了一个原始的操作系统。现在,你可以与计算机交互、发出命令,并在屏幕上接收反馈。在下一篇教程输入02中,我们将研究如何生成一个全文本终端,用户在其中输入命令,然后计算机执行这些命令。


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

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

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

屏幕04 课程基于屏幕03 课程来构建,它教你如何操作文本。假设你已经有了课程 8:屏幕03 的操作系统代码,我们将以它为基础。

1、操作字符串

能够绘制文本是极好的,但不幸的是,现在你只能绘制预先准备好的字符串。如果能够像命令行那样显示任何东西才是完美的,而理想情况下应该是,我们能够显示任何我们期望的东西。一如既往地,如果我们付出努力而写出一个非常好的函数,它能够操作我们所希望的所有字符串,而作为回报,这将使我们以后写代码更容易。曾经如此复杂的函数,在 C 语言编程中只不过是一个 sprintf 而已。这个函数基于给定的另一个字符串和作为描述的额外的一个参数而生成一个字符串。我们对这个函数感兴趣的地方是,这个函数是个变长函数。这意味着它可以带可变数量的参数。参数的数量取决于具体的格式字符串,因此它的参数的数量不能预先确定。

变长函数在汇编代码中看起来似乎不好理解,然而 ,它却是非常有用和很强大的概念。

这个完整的函数有许多选项,而我们在这里只列出了几个。在本教程中将要实现的选项我做了高亮处理,当然,你可以尝试去实现更多的选项。

函数通过读取格式化字符串来工作,然后使用下表的意思去解释它。一旦一个参数已经使用了,就不会再次考虑它了。函数的返回值是写入的字符数。如果方法失败,将返回一个负数。

表 1.1 sprintf 格式化规则

选项含义
除了 % 之外的任何支付复制字符到输出。
%%写一个 % 字符到输出。
%c将下一个参数写成字符格式。
%d%i将下一个参数写成十进制的有符号整数。
%e将下一个参数写成科学记数法,使用 eN,意思是 ×10 N
%E将下一个参数写成科学记数法,使用 EN,意思是 ×10 N
%f将下一个参数写成十进制的 IEEE 754 浮点数。
%g%e%f 的指数表示形式相同。
%G%E%f 的指数表示形式相同。
%o将下一个参数写成八进制的无符号整数。
%s下一个参数如果是一个指针,将它写成空终止符字符串。
%u将下一个参数写成十进制无符号整数。
%x将下一个参数写成十六进制无符号整数(使用小写的 a、b、c、d、e 和 f)。
%X将下一个参数写成十六进制的无符号整数(使用大写的 A、B、C、D、E 和 F)。
%p将下一个参数写成指针地址。
%n什么也不输出。而是复制到目前为止被下一个参数在本地处理的字符个数。

除此之外,对序列还有许多额外的处理,比如指定最小长度,符号等等。更多信息可以在 sprintf - C++ 参考 上找到。

下面是调用方法和返回的结果的示例。

表 1.2 sprintf 调用示例

格式化字符串参数结果
"%d"1313
"+%d degrees"12+12 degrees
"+%x degrees"24+1c degrees
"'%c' = 0%o"65, 65‘A’ = 0101
"%d * %d%% = %d"200, 40, 80200 * 40% = 80
"+%d degrees"-5+-5 degrees
"+%u degrees"-5+4294967291 degrees

希望你已经看到了这个函数是多么有用。实现它需要大量的编程工作,但给我们的回报却是一个非常有用的函数,可以用于各种用途。

2、除法

虽然这个函数看起来很强大、也很复杂。但是,处理它的许多情况的最容易的方式可能是,编写一个函数去处理一些非常常见的任务。它是个非常有用的函数,可以为任何底的一个有符号或无符号的数字生成一个字符串。那么,我们如何去实现呢?在继续阅读之前,尝试快速地设计一个算法。

除法是非常慢的,也是非常复杂的基础数学运算。它在 ARM 汇编代码中不能直接实现,因为如果直接实现的话,它得出答案需要花费很长的时间,因此它不是个“简单的”运算。

最简单的方法或许就是我在 课程 1:OK01 中提到的“除法余数法”。它的思路如下:

  1. 用当前值除以你使用的底。
  2. 保存余数。
  3. 如果得到的新值不为 0,转到第 1 步。
  4. 将余数反序连起来就是答案。

例如:

表 2.1 以 2 为底的例子

转换

新值余数
137681
68340
34170
1781
840
420
210
101

因此答案是 10001001 2

这个过程的不幸之外在于使用了除法。所以,我们必须首先要考虑二进制中的除法。

我们复习一下长除法

假如我们想把 4135 除以 17。

   0243 r 4
17)4135
   0        0 × 17 = 0000
   4135     4135 - 0 = 4135
   34       200 × 17 = 3400
   735      4135 - 3400 = 735
   68       40 × 17 = 680
   55       735 - 680 = 55
   51       3 × 17 = 51
   4        55 - 51 = 4

答案:243 余 4

首先我们来看被除数的最高位。我们看到它是小于或等于除数的最小倍数,因此它是 0。我们在结果中写一个 0。

接下来我们看被除数倒数第二位和所有的高位。我们看到小于或等于那个数的除数的最小倍数是 34。我们在结果中写一个 2,和减去 3400。

接下来我们看被除数的第三位和所有高位。我们看到小于或等于那个数的除数的最小倍数是 68。我们在结果中写一个 4,和减去 680。

最后,我们看一下所有的余位。我们看到小于余数的除数的最小倍数是 51。我们在结果中写一个 3,减去 51。减法的结果就是我们的余数。

在汇编代码中做除法,我们将实现二进制的长除法。我们之所以实现它是因为,数字都是以二进制方式保存的,这让我们很容易地访问所有重要位的移位操作,并且因为在二进制中做除法比在其它高进制中做除法都要简单,因为它的数更少。

        1011 r 1
1010)1101111
     1010
      11111
      1010
       1011
       1010
          1

这个示例展示了如何做二进制的长除法。简单来说就是,在不超出被除数的情况下,尽可能将除数右移,根据位置输出一个 1,和减去这个数。剩下的就是余数。在这个例子中,我们展示了 1101111 2 ÷ 1010 2 = 1011 2 余数为 1 2。用十进制表示就是,111 ÷ 10 = 11 余 1。

你自己尝试去实现这个长除法。你应该去写一个函数 DivideU32 ,其中 r0 是被除数,而 r1 是除数,在 r0 中返回结果,在 r1 中返回余数。下面,我们将完成一个有效的实现。

function DivideU32(r0 is dividend, r1 is divisor)
  set shift to 31
  set result to 0
  while shift ≥ 0
     if dividend ≥ (divisor << shift) then
       set dividend to dividend - (divisor <&lt shift)
       set result to result + 1
     end if
     set result to result << 1
     set shift to shift - 1
  loop
  return (result, dividend)
end function

这段代码实现了我们的目标,但却不能用于汇编代码。我们出现的问题是,我们的寄存器只能保存 32 位,而 divisor << shift 的结果可能在一个寄存器中装不下(我们称之为溢出)。这确实是个问题。你的解决方案是否有溢出的问题呢?

幸运的是,有一个称为 clz 计数前导零 count leading zeros )的指令,它能计算一个二进制表示的数字的前导零的个数。这样我们就可以在溢出发生之前,可以将寄存器中的值进行相应位数的左移。你可以找出的另一个优化就是,每个循环我们计算 divisor << shift 了两遍。我们可以通过将除数移到开始位置来改进它,然后在每个循环结束的时候将它移下去,这样可以避免将它移到别处。

我们来看一下进一步优化之后的汇编代码。

.globl DivideU32
DivideU32:
result .req r0
remainder .req r1
shift .req r2
current .req r3

clz shift,r1
lsl current,r1,shift
mov remainder,r0
mov result,#0

divideU32Loop$:
  cmp shift,#0
  blt divideU32Return$
  cmp remainder,current
  
  addge result,result,#1
  subge remainder,current
  sub shift,#1
  lsr current,#1
  lsl result,#1
  b divideU32Loop$
divideU32Return$:
.unreq current
mov pc,lr

.unreq result
.unreq remainder
.unreq shift

你可能毫无疑问的认为这是个非常高效的作法。它是很好,但是除法是个代价非常高的操作,并且我们的其中一个愿望就是不要经常做除法,因为如果能以任何方式提升速度就是件非常好的事情。当我们查看有循环的优化代码时,我们总是重点考虑一个问题,这个循环会运行多少次。在本案例中,在输入为 1 的情况下,这个循环最多运行 31 次。在不考虑特殊情况的时候,这很容易改进。例如,当 1 除以 1 时,不需要移位,我们将把除数移到它上面的每个位置。这可以通过简单地在被除数上使用新的 clz 命令并从中减去它来改进。在 1 ÷ 1 的案例中,这意味着移位将设置为 0,明确地表示它不需要移位。如果它设置移位为负数,表示除数大于被除数,因此我们就可以知道结果是 0,而余数是被除数。我们可以做的另一个快速检查就是,如果当前值为 0,那么它是一个整除的除法,我们就可以停止循环了。

clz dest,src 将第一个寄存器 dest 中二进制表示的值的前导零的数量,保存到第二个寄存器 src 中。
.globl DivideU32
DivideU32:
result .req r0
remainder .req r1
shift .req r2
current .req r3

clz shift,r1
clz r3,r0
subs shift,r3
lsl current,r1,shift
mov remainder,r0
mov result,#0
blt divideU32Return$

divideU32Loop$:
  cmp remainder,current
  blt divideU32LoopContinue$
  
  add result,result,#1
  subs remainder,current
  lsleq result,shift
  beq divideU32Return$
divideU32LoopContinue$:
  subs shift,#1
  lsrge current,#1
  lslge result,#1
  bge divideU32Loop$

divideU32Return$:
.unreq current
mov pc,lr

.unreq result
.unreq remainder
.unreq shift

复制上面的代码到一个名为 maths.s 的文件中。

3、数字字符串

现在,我们已经可以做除法了,我们来看一下另外的一个将数字转换为字符串的实现。下列的伪代码将寄存器中的一个数字转换成以 36 为底的字符串。根据惯例,a % b 表示 a 被 b 相除之后的余数。

function SignedString(r0 is value, r1 is dest, r2 is base)
  if value ≥ 0
  then return UnsignedString(value, dest, base)
  otherwise
    if dest > 0 then
      setByte(dest, '-')
      set dest to dest + 1
    end if
    return UnsignedString(-value, dest, base) + 1
  end if
end function

function UnsignedString(r0 is value, r1 is dest, r2 is base)
  set length to 0
  do
  
    set (value, rem) to DivideU32(value, base)
    if rem &gt 10
    then set rem to rem + '0'
    otherwise set rem to rem - 10 + 'a'
    if dest > 0
    then setByte(dest + length, rem)
    set length to length + 1
  
  while value > 0
  if dest > 0
  then ReverseString(dest, length)
  return length
end function

function ReverseString(r0 is string, r1 is length)
  set end to string + length - 1
  while end > start
    set temp1 to readByte(start)
    set temp2 to readByte(end)
    setByte(start, temp2)
    setByte(end, temp1)
    set start to start + 1
    set end to end - 1
  end while
end function

上述代码实现在一个名为 text.s 的汇编文件中。记住,如果你遇到了困难,可以在下载页面找到完整的解决方案。

4、格式化字符串

我们继续回到我们的字符串格式化方法。因为我们正在编写我们自己的操作系统,我们根据我们自己的意愿来添加或修改格式化规则。我们可以发现,添加一个 a % b 操作去输出一个二进制的数字比较有用,而如果你不使用空终止符字符串,那么你应该去修改 %s 的行为,让它从另一个参数中得到字符串的长度,或者如果你愿意,可以从长度前缀中获取。我在下面的示例中使用了一个空终止符。

实现这个函数的一个主要的障碍是它的参数个数是可变的。根据 ABI 规定,额外的参数在调用方法之前以相反的顺序先推送到栈上。比如,我们使用 8 个参数 1、2、3、4、5、6、7 和 8 来调用我们的方法,我们将按下面的顺序来处理:

  1. 设置 r0 = 5、r1 = 6、r2 = 7、r3 = 8
  2. 推入 {r0,r1,r2,r3}
  3. 设置 r0 = 1、r1 = 2、r2 = 3、r3 = 4
  4. 调用函数
  5. 将 sp 和 #4*4 加起来

现在,我们必须确定我们的函数确切需要的参数。在我的案例中,我将寄存器 r0 用来保存格式化字符串地址,格式化字符串长度则放在寄存器 r1 中,目标字符串地址放在寄存器 r2 中,紧接着是要求的参数列表,从寄存器 r3 开始和像上面描述的那样在栈上继续。如果你想去使用一个空终止符格式化字符串,在寄存器 r1 中的参数将被移除。如果你想有一个最大缓冲区长度,你可以将它保存在寄存器 r3 中。由于有额外的修改,我认为这样修改函数是很有用的,如果目标字符串地址为 0,意味着没有字符串被输出,但如果仍然返回一个精确的长度,意味着能够精确的判断格式化字符串的长度。

如果你希望尝试实现你自己的函数,现在就可以去做了。如果不去实现你自己的,下面我将首先构建方法的伪代码,然后给出实现的汇编代码。

function StringFormat(r0 is format, r1 is formatLength, r2 is dest, ...)
  set index to 0
  set length to 0
  while index < formatLength
    if readByte(format + index) = '%' then
      set index to index + 1
      if readByte(format + index) = '%' then
        if dest > 0
        then setByte(dest + length, '%')
        set length to length + 1
      otherwise if readByte(format + index) = 'c' then
        if dest > 0
        then setByte(dest + length, nextArg)
        set length to length + 1
      otherwise if readByte(format + index) = 'd' or 'i' then
        set length to length + SignedString(nextArg, dest, 10)
      otherwise if readByte(format + index) = 'o' then
        set length to length + UnsignedString(nextArg, dest, 8)
      otherwise if readByte(format + index) = 'u' then
        set length to length + UnsignedString(nextArg, dest, 10)
      otherwise if readByte(format + index) = 'b' then
        set length to length + UnsignedString(nextArg, dest, 2)
      otherwise if readByte(format + index) = 'x' then
        set length to length + UnsignedString(nextArg, dest, 16)
      otherwise if readByte(format + index) = 's' then
        set str to nextArg
        while getByte(str) != '\0'
          if dest > 0
          then setByte(dest + length, getByte(str))
          set length to length + 1
          set str to str + 1
        loop
      otherwise if readByte(format + index) = 'n' then
        setWord(nextArg, length)
      end if
    otherwise
      if dest > 0
      then setByte(dest + length, readByte(format + index))
      set length to length + 1
    end if
    set index to index + 1
  loop
  return length
end function

虽然这个函数很大,但它还是很简单的。大多数的代码都是在检查所有各种条件,每个代码都是很简单的。此外,所有的无符号整数的大小写都是相同的(除了底以外)。因此在汇编中可以将它们汇总。下面是它的汇编代码。

.globl FormatString
FormatString:
format .req r4
formatLength .req r5
dest .req r6
nextArg .req r7
argList .req r8
length .req r9

push {r4,r5,r6,r7,r8,r9,lr}
mov format,r0
mov formatLength,r1
mov dest,r2
mov nextArg,r3
add argList,sp,#7*4
mov length,#0

formatLoop$:
  subs formatLength,#1
  movlt r0,length
  poplt {r4,r5,r6,r7,r8,r9,pc}
  
  ldrb r0,[format]
  add format,#1
  teq r0,#'%'
  beq formatArg$

formatChar$:
  teq dest,#0
  strneb r0,[dest]
  addne dest,#1
  add length,#1
  b formatLoop$

formatArg$:
  subs formatLength,#1
  movlt r0,length
  poplt {r4,r5,r6,r7,r8,r9,pc}

  ldrb r0,[format]
  add format,#1
  teq r0,#'%'
  beq formatChar$
  
  teq r0,#'c'
  moveq r0,nextArg
  ldreq nextArg,[argList]
  addeq argList,#4
  beq formatChar$
  
  teq r0,#'s'
  beq formatString$
  
  teq r0,#'d'
  beq formatSigned$
  
  teq r0,#'u'
  teqne r0,#'x'
  teqne r0,#'b'
  teqne r0,#'o'
  beq formatUnsigned$

  b formatLoop$

formatString$:
  ldrb r0,[nextArg]
  teq r0,#0x0
  ldreq nextArg,[argList]
  addeq argList,#4
  beq formatLoop$
  add length,#1
  teq dest,#0
  strneb r0,[dest]
  addne dest,#1
  add nextArg,#1
  b formatString$

formatSigned$:
  mov r0,nextArg
  ldr nextArg,[argList]
  add argList,#4
  mov r1,dest
  mov r2,#10
  bl SignedString
  teq dest,#0
  addne dest,r0
  add length,r0
  b formatLoop$

formatUnsigned$:
  teq r0,#'u'
  moveq r2,#10
  teq r0,#'x'
  moveq r2,#16
  teq r0,#'b'
  moveq r2,#2
  teq r0,#'o'
  moveq r2,#8
  
  mov r0,nextArg
  ldr nextArg,[argList]
  add argList,#4
  mov r1,dest
  bl UnsignedString
  teq dest,#0
  addne dest,r0
  add length,r0
  b formatLoop$

5、一个转换操作系统

你可以使用这个方法随意转换你希望的任何东西。比如,下面的代码将生成一个换算表,可以做从十进制到二进制到十六进制到八进制以及到 ASCII 的换算操作。

删除 main.s 文件中 bl SetGraphicsAddress 之后的所有代码,然后粘贴以下的代码进去。

mov r4,#0
loop$:
ldr r0,=format
mov r1,#formatEnd-format
ldr r2,=formatEnd
lsr r3,r4,#4
push {r3}
push {r3}
push {r3}
push {r3}
bl FormatString
add sp,#16

mov r1,r0
ldr r0,=formatEnd
mov r2,#0
mov r3,r4

cmp r3,#768-16
subhi r3,#768
addhi r2,#256
cmp r3,#768-16
subhi r3,#768
addhi r2,#256
cmp r3,#768-16
subhi r3,#768
addhi r2,#256

bl DrawString

add r4,#16
b loop$

.section .data
format:
.ascii "%d=0b%b=0x%x=0%o='%c'"
formatEnd:

你能在测试之前推算出将发生什么吗?特别是对于 r3 ≥ 128 会发生什么?尝试在树莓派上运行它,看看你是否猜对了。如果不能正常运行,请查看我们的排错页面。

如果一切顺利,恭喜你!你已经完成了屏幕04 教程,屏幕系列的课程结束了!我们学习了像素和帧缓冲的知识,以及如何将它们应用到树莓派上。我们学习了如何绘制简单的线条,也学习如何绘制字符,以及将数字格式化为文本的宝贵技能。我们现在已经拥有了在一个操作系统上进行图形输出的全部知识。你可以写出更多的绘制方法吗?三维绘图是什么?你能实现一个 24 位帧缓冲吗?能够从命令行上读取帧缓冲的大小吗?

接下来的课程是输入系列课程,它将教我们如何使用键盘和鼠标去实现一个传统的计算机控制台。


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

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

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

屏幕03 课程基于屏幕02 课程来构建,它教你如何绘制文本,和一个操作系统命令行参数上的一个小特性。假设你已经有了课程 7:屏幕02 的操作系统代码,我们将以它为基础来构建。

1、字符串的理论知识

是的,我们的任务是为这个操作系统绘制文本。我们有几个问题需要去处理,最紧急的那个可能是如何去保存文本。令人难以置信的是,文本是迄今为止在计算机上最大的缺陷之一。原本应该是简单的数据类型却导致了操作系统的崩溃,从而削弱其他方面的加密效果,并给使用其它字母表的用户带来了许多问题。尽管如此,它仍然是极其重要的数据类型,因为它将计算机和用户很好地连接起来。文本是计算机能够理解的非常好的结构,同时人类使用它时也有足够的可读性。

那么,文本是如何保存的呢?非常简单,我们使用一种方法,给每个字母分配一个唯一的编号,然后我们保存一系列的这种编号。看起来很容易吧。问题是,那个编号的数量是不固定的。一些文本段可能比其它的长。保存普通数字,我们有一些固有的限制,即:32 位,我们不能超过这个限制,我们要添加方法去使用该长度的数字等等。“文本”这个术语,我们经常也叫它“字符串”,我们希望能够写一个可用于可变长度字符串的函数,否则就需要写很多函数!对于一般的数字来说,这不是个问题,因为只有几种通用的数字格式(字节、字、半字节、双字节)。

可变数据类型(比如文本)要求能够进行很复杂的处理。

因此,如何判断字符串长度?我想显而易见的答案是存储字符串的长度,然后去存储组成字符串的字符。这称为长度前缀,因为长度位于字符串的前面。不幸的是,计算机科学家的先驱们不同意这么做。他们认为使用一个称为空终止符(NULL)的特殊字符(用 \0 表示)来表示字符串结束更有意义。这样确定简化了许多字符串算法,因为你只需要持续操作直到遇到空终止符为止。不幸的是,这成为了许多安全问题的根源。如果一个恶意用户给你一个特别长的字符串会发生什么状况?如果没有足够的空间去保存这个特别长的字符串会发生什么状况?你可以使用一个字符串复制函数来做复制,直到遇到空终止符为止,但是因为字符串特别长,而覆写了你的程序,怎么办?这看上去似乎有些较真,但是,缓冲区溢出攻击还是经常发生。长度前缀可以很容易地缓解这种问题,因为它可以很容易地推算出保存这个字符串所需要的缓冲区的长度。作为一个操作系统开发者,我留下这个问题,由你去决定如何才能更好地存储文本。

缓冲区溢出攻击祸害计算机由来已久。最近,Wii、Xbox 和 Playstation 2、以及大型系统如 Microsoft 的 Web 和数据库服务器,都遭受到缓冲区溢出攻击。

接下来的事情是,我们需要确定的是如何最好地将字符映射到数字。幸运的是,这是高度标准化的,我们有两个主要的选择,Unicode 和 ASCII。Unicode 几乎将每个有用的符号都映射为数字,作为代价,我们需要有很多很多的数字,和一个更复杂的编码方法。ASCII 为每个字符使用一个字节,因此它仅保存拉丁字母、数字、少数符号和少数特殊字符。因此,ASCII 是非常易于实现的,与之相比,Unicode 的每个字符占用的空间并不相同,这使得字符串算法更棘手。通常,操作系统上字符使用 ASCII,并不是为了显示给最终用户的(开发者和专家用户除外),给终端用户显示信息使用 Unicode,因为 Unicode 能够支持像日语字符这样的东西,并且因此可以实现本地化。

幸运的是,在这里我们不需要去做选择,因为它们的前 128 个字符是完全相同的,并且编码也是完全一样的。

表 1.1 ASCII/Unicode 符号 0-127

0123456789abcdef
00NULSOHSTXETXEOTENQACKBELBSHTLFVTFFCRSOSI
10DLEDC1DC2DC3DC4NAKSYNETBCANEMSUBESCFSGSRSUS
20!#$%&.()*+,-./
300123456789:;<=>?
40@ABCDEFGHIJKLMNO
50PQRSTUVWXYZ[\]^\_
60`abcdefghijklmno
70pqrstuvwxyz{ }~DEL

这个表显示了前 128 个符号。一个符号的十六进制表示是行的值加上列的值,比如 A 是 41 16。你可以惊奇地发现前两行和最后的值。这 33 个特殊字符是不可打印字符。事实上,许多人都忽略了它们。它们之所以存在是因为 ASCII 最初设计是基于计算机网络来传输数据的一种方法。因此它要发送的信息不仅仅是符号。你应该学习的重要的特殊字符是 NUL,它就是我们前面提到的空终止符。HT 水平制表符就是我们经常说的 tab,而 LF 换行符用于生成一个新行。你可能想研究和使用其它特殊字符在你的操行系统中的意义。

2、字符

到目前为止,我们已经知道了一些关于字符串的知识,我们可以开始想想它们是如何显示的。为了显示一个字符串,我们需要做的最基础的事情是能够显示一个字符。我们的第一个任务是编写一个 DrawCharacter 函数,给它一个要绘制的字符和一个位置,然后它将这个字符绘制出来。

这就很自然地引出关于字体的讨论。我们已经知道有许多方式去按照选定的字体去显示任何给定的字母。那么字体又是如何工作的呢?在计算机科学的早期阶段,字体就是所有字母的一系列小图片而已,这种字体称为位图字体,而所有的字符绘制方法就是将图片复制到屏幕上。当人们想去调整字体大小时就出问题了。有时我们需要大的字母,而有时我们需要的是小的字母。尽管我们可以为每个字体、每种大小、每个字符都绘制新图片,但这种作法过于单调乏味。所以,发明了矢量字体。矢量字体不包含字体的图像,它包含的是如何去绘制字符的描述,即:一个 o 可能是最大字母高度的一半为半径绘制的圆。现代操作系统都几乎仅使用这种字体,因为这种字体在任何分辨率下都很完美。

在许多操作系统中使用的 TrueType 字体格式是很强大的,它内置有它自己的汇编语言,以确保在任何分辨率下字母看起来都是正确的。

不幸的是,虽然我很想包含一个矢量字体的格式的实现,但它的内容太多了,将占用这个网站的剩余部分。所以,我们将去实现一个位图字体,可是,如果你想去做一个像样的图形操作系统,那么矢量字体将是很有用的。

在下载页面上的字体节中,我们提供了几个 .bin 文件。这些只是字体的原始二进制数据文件。为完成本教程,从等宽、单色、8x16 节中挑选你喜欢的字体。然后下载它并保存到 source 目录中并命名为 font.bin 文件。这些文件只是每个字母的单色图片,它们每个字母刚好是 8 x 16 个像素。所以,每个字母占用 16 字节,第一个字节是第一行,第二个字节是第二行,依此类推。

这个示意图展示了等宽、单色、8x16 的字符 A 的 “Bitstream Vera Sans Mono” 字体。在这个文件中,我们可以找到,它从第 41 16 × 10 16 = 410 16 字节开始的十六进制序列:

00, 00, 00, 10, 28, 28, 28, 44, 44, 7C, C6, 82, 00, 00, 00, 00

在这里我们将使用等宽字体,因为等宽字体的每个字符大小是相同的。不幸的是,大多数字体的复杂之处就是因为它的宽度不同,从而导致它的显示代码更复杂。在下载页面上还包含有几个其它的字体,并包含了这种字体的存储格式介绍。

我们回到正题。复制下列代码到 drawing.s 中的 graphicsAddress.int 0 之后。

.align 4
font:
.incbin "font.bin"
.incbin "file" 插入来自文件 “file” 中的二进制数据。

这段代码复制文件中的字体数据到标签为 font 的地址。我们在这里使用了一个 .align 4 去确保每个字符都是从 16 字节的倍数开始,这是一个以后经常用到的用于加快访问速度的技巧。

现在我们去写绘制字符的方法。我在下面给出了伪代码,你可以尝试自己去实现它。按惯例 >> 的意思是逻辑右移。

function drawCharacter(r0 is character, r1 is x, r2 is y)
  if character > 127 then exit
  set charAddress to font + character × 16
  for row = 0 to 15
  set bits to readByte(charAddress + row)
  for bit = 0 to 7
    if test(bits >> bit, 0x1)
    then setPixel(x + bit, y + row)
    next
  next
  return r0 = 8, r1 = 16
end function

如果直接去实现它,这显然不是个高效率的做法。像绘制字符这样的事情,效率是最重要的。因为我们要频繁使用它。我们来探索一些改善的方法,使其成为最优化的汇编代码。首先,因为我们有一个 × 16,你应该会马上想到它等价于逻辑左移 4 位。紧接着我们有一个变量 row,它只与 charAddressy 相加。所以,我们可以通过增加替代变量来消除它。现在唯一的问题是如何判断我们何时完成。这时,一个很好用的 .align 4 上场了。我们知道,charAddress 将从包含 0 的低位半字节开始。这意味着我们可以通过检查低位半字节来看到进入字符数据的程度。

虽然我们可以消除对 bit 的需求,但我们必须要引入新的变量才能实现,因此最好还是保留它。剩下唯一的改进就是去除嵌套的 bits >> bit

function drawCharacter(r0 is character, r1 is x, r2 is y)
  if character > 127 then exit
  set charAddress to font + character << 4
  loop
    set bits to readByte(charAddress)
    set bit to 8
    loop
      set bits to bits << 1
      set bit to bit - 1
      if test(bits, 0x100)
      then setPixel(x + bit, y)
    until bit = 0
    set y to y + 1
    set chadAddress to chadAddress + 1
  until charAddress AND 0b1111 = 0
  return r0 = 8, r1 = 16
end function

现在,我们已经得到了非常接近汇编代码的代码了,并且代码也是经过优化的。下面就是上述代码用汇编写出来的代码。

.globl DrawCharacter
DrawCharacter:
cmp r0,#127
movhi r0,#0
movhi r1,#0
movhi pc,lr

push {r4,r5,r6,r7,r8,lr}
x .req r4
y .req r5
charAddr .req r6
mov x,r1
mov y,r2
ldr charAddr,=font
add charAddr, r0,lsl #4

lineLoop$:

  bits .req r7
  bit .req r8
  ldrb bits,[charAddr]
  mov bit,#8
  
  charPixelLoop$:
  
    subs bit,#1
    blt charPixelLoopEnd$
    lsl bits,#1
    tst bits,#0x100
    beq charPixelLoop$
    
    add r0,x,bit
    mov r1,y
    bl DrawPixel
    
    teq bit,#0
    bne charPixelLoop$
  
  charPixelLoopEnd$:
  .unreq bit
  .unreq bits
  add y,#1
  add charAddr,#1
  tst charAddr,#0b1111
  bne lineLoop$

.unreq x
.unreq y
.unreq charAddr

width .req r0
height .req r1
mov width,#8
mov height,#16

pop {r4,r5,r6,r7,r8,pc}
.unreq width
.unreq height

3、字符串

现在,我们可以绘制字符了,我们可以绘制文本了。我们需要去写一个方法,给它一个字符串为输入,它通过递增位置来绘制出每个字符。为了做的更好,我们应该去实现新的行和制表符。是时候决定关于空终止符的问题了,如果你想让你的操作系统使用它们,可以按需来修改下面的代码。为避免这个问题,我将给 DrawString 函数传递一个字符串长度,以及字符串的地址,和 xy 的坐标作为参数。

function drawString(r0 is string, r1 is length, r2 is x, r3 is y)
  set x0 to x
  for pos = 0 to length - 1
    set char to loadByte(string + pos)
    set (cwidth, cheight) to DrawCharacter(char, x, y)
    if char = '\n' then
      set x to x0
      set y to y + cheight
    otherwise if char = '\t' then
      set x1 to x
      until x1 > x0
        set x1 to x1 + 5 × cwidth
      loop
    set x to x1
    otherwise
      set x to x + cwidth
    end if
  next
end function

同样,这个函数与汇编代码还有很大的差距。你可以随意去尝试实现它,即可以直接实现它,也可以简化它。我在下面给出了简化后的函数和汇编代码。

很明显,写这个函数的人并不很有效率(感到奇怪吗?它就是我写的)。再说一次,我们有一个 pos 变量,它用于递增及与其它东西相加,这是完全没有必要的。我们可以去掉它,而同时进行长度递减,直到减到 0 为止,这样就少用了一个寄存器。除了那个烦人的乘以 5 以外,函数的其余部分还不错。在这里要做的一个重要事情是,将乘法移到循环外面;即便使用位移运算,乘法仍然是很慢的,由于我们总是加一个乘以 5 的相同的常数,因此没有必要重新计算它。实际上,在汇编代码中它可以在一个操作数中通过参数移位来实现,因此我将代码改变为下面这样。

function drawString(r0 is string, r1 is length, r2 is x, r3 is y)
  set x0 to x
  until length = 0
    set length to length - 1
    set char to loadByte(string)
    set (cwidth, cheight) to DrawCharacter(char, x, y)
    if char = '\n' then
      set x to x0
      set y to y + cheight
    otherwise if char = '\t' then
      set x1 to x
      set cwidth to cwidth + cwidth << 2
      until x1 > x0
        set x1 to x1 + cwidth
      loop
      set x to x1
    otherwise
      set x to x + cwidth
    end if
    set string to string + 1
  loop
end function

以下是它的汇编代码:

.globl DrawString
DrawString:
x .req r4
y .req r5
x0 .req r6
string .req r7
length .req r8
char .req r9
push {r4,r5,r6,r7,r8,r9,lr}

mov string,r0
mov x,r2
mov x0,x
mov y,r3
mov length,r1

stringLoop$:
  subs length,#1
  blt stringLoopEnd$
  
  ldrb char,[string]
  add string,#1
  
  mov r0,char
  mov r1,x
  mov r2,y
  bl DrawCharacter
  cwidth .req r0
  cheight .req r1
  
  teq char,#'\n'
  moveq x,x0
  addeq y,cheight
  beq stringLoop$
  
  teq char,#'\t'
  addne x,cwidth
  bne stringLoop$
  
  add cwidth, cwidth,lsl #2
  x1 .req r1
  mov x1,x0
  
  stringLoopTab$:
    add x1,cwidth
    cmp x,x1
    bge stringLoopTab$
  mov x,x1
  .unreq x1
  b stringLoop$
stringLoopEnd$:
.unreq cwidth
.unreq cheight

pop {r4,r5,r6,r7,r8,r9,pc}
.unreq x
.unreq y
.unreq x0
.unreq string
.unreq length

这个代码中非常聪明地使用了一个新运算,subs 是从一个操作数中减去另一个数,保存结果,然后将结果与 0 进行比较。实现上,所有的比较都可以实现为减法后的结果与 0 进行比较,但是结果通常会丢弃。这意味着这个操作与 cmp 一样快。

subs reg,#val 从寄存器 reg 中减去 val,然后将结果与 0 进行比较。

4、你的意愿是我的命令行

现在,我们可以输出字符串了,而挑战是找到一个有意思的字符串去绘制。一般在这样的教程中,人们都希望去绘制 “Hello World!”,但是到目前为止,虽然我们已经能做到了,我觉得这有点“君临天下”的感觉(如果喜欢这种感觉,请随意!)。因此,作为替代,我们去继续绘制我们的命令行。

有一个限制是我们所做的操作系统是用在 ARM 架构的计算机上。最关键的是,在它们引导时,给它一些信息告诉它有哪些可用资源。几乎所有的处理器都有某些方式来确定这些信息,而在 ARM 上,它是通过位于地址 100 16 处的数据来确定的,这个数据的格式如下:

1. 数据是可分解的一系列的标签。
2. 这里有九种类型的标签:`core`、`mem`、`videotext`、`ramdisk`、`initrd2`、`serial`、`revision`、`videolfb`、`cmdline`。
3. 每个标签只能出现一次,除了 `core` 标签是必不可少的之外,其它的都是可有可无的。
4. 所有标签都依次放置在地址 `0x100` 处。
5. 标签列表的结束处总是有两个<ruby>字<rt>word</rt></ruby>,它们全为 0。
6. 每个标签的字节数都是 4 的倍数。
7. 每个标签都是以标签中(以字为单位)的标签大小开始(标签包含这个数字)。
8. 紧接着是包含标签编号的一个半字。编号是按上面列出的顺序,从 1 开始(`core` 是 1,`cmdline` 是 9)。
9. 紧接着是一个包含 5441<sub>16</sub> 的半字。
10. 之后是标签的数据,它根据标签不同是可变的。数据大小(以字为单位)+ 2 的和总是与前面提到的长度相同。
11. 一个 `core` 标签的长度可以是 2 个字也可以是 5 个字。如果是 2 个字,表示没有数据,如果是 5 个字,表示它有 3 个字的数据。
12. 一个 `mem` 标签总是 4 个字的长度。数据是内存块的第一个地址,和内存块的长度。
13. 一个 `cmdline` 标签包含一个 `null` 终止符字符串,它是个内核参数。

在目前的树莓派版本中,只提供了 corememcmdline 标签。你可以在后面找到它们的用法,更全面的参考资料在树莓派的参考页面上。现在,我们感兴趣的是 cmdline 标签,因为它包含一个字符串。我们继续写一些搜索这个命令行(cmdline)标签的代码,如果找到了,以每个条目一个新行的形式输出它。命令行只是图形处理器或用户认为操作系统应该知道的东西的一个列表。在树莓派上,这包含了 MAC 地址、序列号和屏幕分辨率。字符串本身也是一个由空格隔开的表达式(像 key.subkey=value 这样的)的列表。

几乎所有的操作系统都支持一个“命令行”的程序。它的想法是为选择一个程序所期望的行为而提供一个通用的机制。

我们从查找 cmdline 标签开始。将下列的代码复制到一个名为 tags.s 的新文件中。

.section .data
tag_core: .int 0
tag_mem: .int 0
tag_videotext: .int 0
tag_ramdisk: .int 0
tag_initrd2: .int 0
tag_serial: .int 0
tag_revision: .int 0
tag_videolfb: .int 0
tag_cmdline: .int 0

通过标签列表来查找是一个很慢的操作,因为这涉及到许多内存访问。因此,我们只想做一次。代码创建一些数据,用于保存每个类型的第一个标签的内存地址。接下来,用下面的伪代码就可以找到一个标签了。

function FindTag(r0 is tag)
  if tag > 9 or tag = 0 then return 0
  set tagAddr to loadWord(tag_core + (tag - 1) × 4)
  if not tagAddr = 0 then return tagAddr
  if readWord(tag_core) = 0 then return 0
  set tagAddr to 0x100
  loop forever
    set tagIndex to readHalfWord(tagAddr + 4)
    if tagIndex = 0 then return FindTag(tag)
    if readWord(tag_core+(tagIndex-1)×4) = 0
    then storeWord(tagAddr, tag_core+(tagIndex-1)×4)
    set tagAddr to tagAddr + loadWord(tagAddr) × 4
  end loop
end function

这段代码已经是优化过的,并且很接近汇编了。它尝试直接加载标签,第一次这样做是有些乐观的,但是除了第一次之外的其它所有情况都是可以这样做的。如果失败了,它将去检查 core 标签是否有地址。因为 core 标签是必不可少的,如果它没有地址,唯一可能的原因就是它不存在。如果它有地址,那就是我们没有找到我们要找的标签。如果没有找到,那我们就需要查找所有标签的地址。这是通过读取标签编号来做的。如果标签编号为 0,意味着已经到了标签列表的结束位置。这意味着我们已经查找了目录中所有的标签。所以,如果我们再次运行我们的函数,现在它应该能够给出一个答案。如果标签编号不为 0,我们检查这个标签类型是否已经有一个地址。如果没有,我们在目录中保存这个标签的地址。然后增加这个标签的长度(以字节为单位)到标签地址中,然后去查找下一个标签。

尝试去用汇编实现这段代码。你将需要简化它。如果被卡住了,下面是我的答案。不要忘了 .section .text

.section .text
.globl FindTag
FindTag:
tag .req r0
tagList .req r1
tagAddr .req r2

sub tag,#1
cmp tag,#8
movhi tag,#0
movhi pc,lr

ldr tagList,=tag_core
tagReturn$:
add tagAddr,tagList, tag,lsl #2
ldr tagAddr,[tagAddr]

teq tagAddr,#0
movne r0,tagAddr
movne pc,lr

ldr tagAddr,[tagList]
teq tagAddr,#0
movne r0,#0
movne pc,lr

mov tagAddr,#0x100
push {r4}
tagIndex .req r3
oldAddr .req r4
tagLoop$:
ldrh tagIndex,[tagAddr,#4]
subs tagIndex,#1
poplt {r4}
blt tagReturn$

add tagIndex,tagList, tagIndex,lsl #2
ldr oldAddr,[tagIndex]
teq oldAddr,#0
.unreq oldAddr
streq tagAddr,[tagIndex]

ldr tagIndex,[tagAddr]
add tagAddr, tagIndex,lsl #2
b tagLoop$

.unreq tag
.unreq tagList
.unreq tagAddr
.unreq tagIndex

5、Hello World

现在,我们已经万事俱备了,我们可以去绘制我们的第一个字符串了。在 main.s 文件中删除 bl SetGraphicsAddress 之后的所有代码,然后将下面的代码放进去:

mov r0,#9
bl FindTag
ldr r1,[r0]
lsl r1,#2
sub r1,#8
add r0,#8
mov r2,#0
mov r3,#0
bl DrawString
loop$:
b loop$

这段代码简单地使用了我们的 FindTag 方法去查找第 9 个标签(cmdline),然后计算它的长度,然后传递命令和长度给 DrawString 方法,告诉它在 0,0 处绘制字符串。现在可以在树莓派上测试它了。你应该会在屏幕上看到一行文本。如果没有,请查看我们的排错页面。

如果一切正常,恭喜你已经能够绘制文本了。但它还有很大的改进空间。如果想去写了一个数字,或内存的一部分,或操作我们的命令行,该怎么做呢?在 课程 9:屏幕04 中,我们将学习如何操作文本和显示有用的数字和信息。


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

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

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

屏幕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中国 荣誉推出