计算机实验室之树莓派:课程 11 输入02
课程输入 02 是以课程输入 01 为基础讲解的,通过一个简单的命令行实现用户的命令输入和计算机的处理和显示。本文假设你已经具备 课程11:输入01 的操作系统代码基础。
1、终端
几乎所有的操作系统都是以字符终端显示启动的。经典的黑底白字,通过键盘输入计算机要执行的命令,然后会提示你拼写错误,或者恰好得到你想要的执行结果。这种方法有两个主要优点:键盘和显示器可以提供简易、健壮的计算机交互机制,几乎所有的计算机系统都采用这个机制,这个也广泛被系统管理员应用。
早期的计算一般是在一栋楼里的一个巨型计算机系统,它有很多可以输命令的’终端’。计算机依次执行不同来源的命令。
让我们分析下真正想要哪些信息:
- 计算机打开后,显示欢迎信息
- 计算机启动后可以接受输入标志
- 用户从键盘输入带参数的命令
- 用户输入回车键或提交按钮
- 计算机解析命令后执行可用的命令
- 计算机显示命令的执行结果,过程信息
- 循环跳转到步骤 2
这样的终端被定义为标准的输入输出设备。用于(显示)输入的屏幕和打印输出内容的屏幕是同一个(LCTT 译注:最早期的输出打印真是“打印”到打印机/电传机的,而用于输入的终端只是键盘,除非做了回显,否则输出终端是不会显示输入的字符的)。也就是说终端是对字符显示的一个抽象。字符显示中,单个字符是最小的单元,而不是像素。屏幕被划分成固定数量不同颜色的字符。我们可以在现有的屏幕代码基础上,先存储字符和对应的颜色,然后再用方法 DrawCharacter
把其推送到屏幕上。一旦我们需要字符显示,就只需要在屏幕上画出一行字符串。
新建文件名为 terminal.s
,如下:
1 |
|
这是文件终端的配置数据文件。我们有两个主要的存储变量:terminalBuffer
和 terminalScreen
。terminalBuffer
保存所有显示过的字符。它保存 128 行字符文本(1 行包含 128 个字符)。每个字符有一个 ASCII 字符和颜色单元组成,初始值为 0x7f(ASCII 的删除字符)和 0(前景色和背景色为黑)。terminalScreen
保存当前屏幕显示的字符。它保存 128x48 个字符,与 terminalBuffer
初始化值一样。你可能会觉得我仅需要 terminalScreen
就够了,为什么还要terminalBuffer
,其实有两个好处:
- 我们可以很容易看到字符串的变化,只需画出有变化的字符。
- 我们可以回滚终端显示的历史字符,也就是缓冲的字符(有限制)
这种独特的技巧在低功耗系统里很常见。画屏是很耗时的操作,因此我们仅在不得已的时候才去执行这个操作。在这个系统里,我们可以任意改变 terminalBuffer
,然后调用一个仅拷贝屏幕上字节变化的方法。也就是说我们不需要持续画出每个字符,这样可以节省一大段跨行文本的操作时间。
你总是需要尝试去设计一个高效的系统,如果在很少变化的情况下这个系统会运行的更快。
其他在 .data
段的值得含义如下:
terminalStart
写入到terminalBuffer
的第一个字符terminalStop
写入到terminalBuffer
的最后一个字符terminalView
表示当前屏幕的第一个字符,这样我们可以控制滚动屏幕temrinalColour
即将被描画的字符颜色
terminalStart
需要保存起来的原因是 termainlBuffer
是一个环状缓冲区。意思是当缓冲区变满时,末尾地方会回滚覆盖开始位置,这样最后一个字符变成了第一个字符。因此我们需要将 terminalStart
往前推进,这样我们知道我们已经占满它了。如何实现缓冲区检测:如果索引越界到缓冲区的末尾,就将索引指向缓冲区的开始位置。环状缓冲区是一个比较常见的存储大量数据的高明方法,往往这些数据的最近部分比较重要。它允许无限制的写入,只保证最近一些特定数据有效。这个常常用于信号处理和数据压缩算法。这样的情况,可以允许我们存储 128 行终端记录,超过128行也不会有问题。如果不是这样,当超过第 128 行时,我们需要把 127 行分别向前拷贝一次,这样很浪费时间。
环状缓冲区是数据结构一个例子。这是一个组织数据的思路,有时我们通过软件实现这种思路。
之前已经提到过 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
。尝试你自己实现。如果你感觉麻烦或者还没有完成屏幕系列课程,我们的实现如下:
1 |
|
2、文本显示
我们的终端第一个真正需要的方法是 TerminalDisplay
,它用来把当前的数据从 terminalBuffer
拷贝到 terminalScreen
和实际的屏幕。如上所述,这个方法必须是最小开销的操作,因为我们需要频繁调用它。它主要比较 terminalBuffer
与 terminalDisplay
的文本,然后只拷贝有差异的字节。请记住 terminalBuffer
是以环状缓冲区运行的,这种情况,就是从 terminalView
到 terminalStop
,或者 128*48 个字符,要看哪个来的最快。如果我们遇到 terminalStop
,我们将会假定在这之后的所有字符是 7f 16 (ASCII 删除字符),颜色为 0(黑色前景色和背景色)。
让我们看看必须要做的事情:
- 加载
terminalView
、terminalStop
和terminalDisplay
的地址。 - 对于每一行:
- 对于每一列:
- 如果
terminalView
不等于terminalStop
,根据terminalView
加载当前字符和颜色 - 否则加载 0x7f 和颜色 0
- 从
terminalDisplay
加载当前的字符 - 如果字符和颜色相同,直接跳转到第 10 步
- 存储字符和颜色到
terminalDisplay
- 用
r0
作为背景色参数调用TerminalColour
- 用
r0 = 0x7f
(ASCII 删除字符,一个块)、r1 = x
、r2 = y
调用DrawCharacter
- 用
r0
作为前景色参数调用TerminalColour
- 用
r0 = 字符
、r1 = x
、r2 = y
调用DrawCharacter
- 对位置参数
terminalDisplay
累加 2 - 如果
terminalView
不等于terminalStop
,terminalView
位置参数累加 2 - 如果
terminalView
位置已经是文件缓冲器的末尾,将它设置为缓冲区的开始位置 - x 坐标增加 8
- 如果
- y 坐标增加 16
- 对于每一列:
尝试去自己实现吧。如果你遇到问题,我们的方案下面给出来了:
1、我这里的变量有点乱。为了方便起见,我用 taddr
存储 textBuffer
的末尾位置。
1 |
|
2、从 yLoop
开始运行。
1 |
|
2.1、
1 |
|
从 xLoop
开始运行。
2.1.1、为了方便起见,我把字符和颜色同时加载到 char
变量了
1 |
|
2.1.2、这行是对上面一行的补充说明:读取黑色的删除字符
1 |
|
2.1.3、为了简便我把字符和颜色同时加载到 col
里。
1 |
|
2.1.4、 现在我用 teq
指令检查是否有数据变化
1 |
|
2.1.5、我可以容易的保存当前值
1 |
|
2.1.6、我用比特偏移指令 lsr
和 and
指令从切分 char
变量,将颜色放到 col
变量,字符放到 char
变量,然后再用比特偏移指令 lsr
获取背景色后调用 TerminalColour
。
1 |
|
2.1.7、写入一个彩色的删除字符
1 |
|
2.1.8、用 and
指令获取 col
变量的低半字节,然后调用 TerminalColour
1 |
|
2.1.9、写入我们需要的字符
1 |
|
2.1.10、自增屏幕指针
1 |
|
2.1.11、如果可能自增 view
指针
1 |
|
2.1.12、很容易检测 view
指针是否越界到缓冲区的末尾,因为缓冲区的地址保存在 taddr
变量里
1 |
|
2.1.13、 如果还有字符需要显示,我们就需要自增 x
变量然后到 xLoop
循环执行
1 |
|
2.2、 如果还有更多的字符显示我们就需要自增 y
变量,然后到 yLoop
循环执行
1 |
|
3、不要忘记最后清除变量
1 |
|
3、行打印
现在我有了自己 TerminalDisplay
方法,它可以自动显示 terminalBuffer
内容到 terminalScreen
,因此理论上我们可以画出文本。但是实际上我们没有任何基于字符显示的例程。 首先快速容易上手的方法便是 TerminalClear
, 它可以彻底清除终端。这个方法不用循环也很容易实现。可以尝试分析下面的方法应该不难:
1 |
|
现在我们需要构造一个字符显示的基础方法:Print
函数。它将保存在 r0
的字符串和保存在 r1
的字符串长度简单的写到屏幕上。有一些特定字符需要特别的注意,这些特定的操作是确保 terminalView
是最新的。我们来分析一下需要做什么:
- 检查字符串的长度是否为 0,如果是就直接返回
- 加载
terminalStop
和terminalView
- 计算出
terminalStop
的 x 坐标 - 对每一个字符的操作:
- 检查字符是否为新起一行
- 如果是的话,自增
bufferStop
到行末,同时写入黑色删除字符 - 否则拷贝当前
terminalColour
的字符 - 检查是否在行末
- 如果是,检查从
terminalView
到terminalStop
之间的字符数是否大于一屏 - 如果是,
terminalView
自增一行 - 检查
terminalView
是否为缓冲区的末尾,如果是的话将其替换为缓冲区的起始位置 - 检查
terminalStop
是否为缓冲区的末尾,如果是的话将其替换为缓冲区的起始位置 - 检查
terminalStop
是否等于terminalStart
, 如果是的话terminalStart
自增一行。 - 检查
terminalStart
是否为缓冲区的末尾,如果是的话将其替换为缓冲区的起始位置
- 存取
terminalStop
和terminalView
试一下自己去实现。我们的方案提供如下:
1、这个是 Print
函数开始快速检查字符串为0的代码
1 |
|
2、这里我做了很多配置。 bufferStart
代表 terminalStart
, bufferStop
代表terminalStop
, view
代表 terminalView
,taddr
代表 terminalBuffer
的末尾地址。
1 |
|
3、和通常一样,巧妙的对齐技巧让许多事情更容易。由于需要对齐 terminalBuffer
,每个字符的 x 坐标需要 8 位要除以 2。
1 |
|
4.1、我们需要检查新行
1 |
|
4.2、循环执行值到行末写入 0x7f;黑色删除字符
1 |
|
4.3、存储字符串的当前字符和 terminalBuffer
末尾的 terminalColour
然后将它和 x 变量自增
1 |
|
4.4、检查 x 是否为行末;128
1 |
|
4.5、设置 x 为 0 然后检查我们是否已经显示超过 1 屏。请记住,我们是用的循环缓冲区,因此如果 bufferStop
和 view
之前的差是负值,我们实际上是环绕了缓冲区。
1 |
|
4.6、增加一行字节到 view
的地址
1 |
|
4.7、 如果 view
地址是缓冲区的末尾,我们就从它上面减去缓冲区的长度,让其指向开始位置。我会在开始的时候设置 taddr
为缓冲区的末尾地址。
1 |
|
4.8、如果 stop
的地址在缓冲区末尾,我们就从它上面减去缓冲区的长度,让其指向开始位置。我会在开始的时候设置 taddr
为缓冲区的末尾地址。
1 |
|
4.9、检查 bufferStop
是否等于 bufferStart
。 如果等于增加一行到 bufferStart
。
1 |
|
4.10、如果 start
的地址在缓冲区的末尾,我们就从它上面减去缓冲区的长度,让其指向开始位置。我会在开始的时候设置 taddr
为缓冲区的末尾地址。
1 |
|
循环执行知道字符串结束
1 |
|
5、保存变量然后返回
1 |
|
这个方法允许我们打印任意字符到屏幕。然而我们用了颜色变量,但实际上没有设置它。一般终端用特性的组合字符去行修改颜色。如 ASCII 转义(1b 16)后面跟着一个 0 - f 的 16 进制的数,就可以设置前景色为 CGA 颜色号。如果你自己想尝试实现;在下载页面有一个我的详细的例子。
4、标志输入
现在我们有一个可以打印和显示文本的输出终端。这仅仅是说对了一半,我们需要输入。我们想实现一个方法:ReadLine
,可以保存文件的一行文本,文本位置由 r0
给出,最大的长度由 r1
给出,返回 r0
里面的字符串长度。棘手的是用户输出字符的时候要回显功能,同时想要退格键的删除功能和命令回车执行功能。它们还需要一个闪烁的下划线代表计算机需要输入。这些完全合理的要求让构造这个方法更具有挑战性。有一个方法完成这些需求就是存储用户输入的文本和文件大小到内存的某个地方。然后当调用 ReadLine
的时候,移动 terminalStop
的地址到它开始的地方然后调用 Print
。也就是说我们只需要确保在内存维护一个字符串,然后构造一个我们自己的打印函数。
按照惯例,许多编程语言中,任意程序可以访问 stdin 和 stdin,它们可以连接到终端的输入和输出流。在图形程序其实也可以进行同样操作,但实际几乎不用。
让我们看看 ReadLine
做了哪些事情:
- 如果字符串可保存的最大长度为 0,直接返回
- 检索
terminalStop
和terminalStop
的当前值 - 如果字符串的最大长度大约缓冲区的一半,就设置大小为缓冲区的一半
- 从最大长度里面减去 1 来确保输入的闪烁字符或结束符
- 向字符串写入一个下划线
- 写入一个
terminalView
和terminalStop
的地址到内存 - 调用
Print
打印当前字符串 - 调用
TerminalDisplay
- 调用
KeyboardUpdate
- 调用
KeyboardGetChar
- 如果是一个新行直接跳转到第 16 步
- 如果是一个退格键,将字符串长度减 1(如果其大于 0)
- 如果是一个普通字符,将它写入字符串(字符串大小确保小于最大值)
- 如果字符串是以下划线结束,写入一个空格,否则写入下划线
- 跳转到第 6 步
- 字符串的末尾写入一个新行字符
- 调用
Print
和TerminalDisplay
- 用结束符替换新行
- 返回字符串的长度
为了方便读者理解,然后然后自己去实现,我们的实现提供如下:
- 快速处理长度为 0 的情况
1 |
|
2、考虑到常见的场景,我们初期做了很多初始化动作。input
代表 terminalStop
的值,view
代表 terminalView
。Length
默认为 0
。
1 |
|
3、我们必须检查异常大的读操作,我们不能处理超过 terminalBuffer
大小的输入(理论上可行,但是 terminalStart
移动越过存储的 terminalStop`,会有很多问题)。
1 |
|
4、由于用户需要一个闪烁的光标,我们需要一个备用字符在理想状况在这个字符串后面放一个结束符。
1 |
|
5、写入一个下划线让用户知道我们可以输入了。
1 |
|
6、保存 terminalStop
和 terminalView
。这个对重置一个终端很重要,它会修改这些变量。严格讲也可以修改 terminalStart
,但是不可逆。
1 |
|
7、写入当前的输入。由于下划线因此字符串长度加 1
1 |
|
8、拷贝下一个文本到屏幕
1 |
|
9、获取最近一次键盘输入
1 |
|
10、检索键盘输入键值
1 |
|
11、如果我们有一个回车键,循环中断。如果有结束符和一个退格键也会同样跳出循环。
1 |
|
12、从 length
里面删除一个字符
1 |
|
13、写回一个普通字符
1 |
|
14、加载最近的一个字符,如果不是下划线则修改为下换线,如果是则修改为空格
1 |
|
15、循环执行值到用户输入按下
1 |
|
16、在字符串的结尾处存入一个新行字符
1 |
|
17、重置 terminalView
和 terminalStop
然后调用 Print
和 TerminalDisplay
显示最终的输入
1 |
|
18、写入一个结束符
1 |
|
19、返回长度
1 |
|
5、终端:机器进化
现在我们理论用终端和用户可以交互了。最显而易见的事情就是拿去测试了!删除 main.s
里 bl UsbInitialise
后面的代码后如下:
1 |
|
这块代码集成了一个简易的命令行操作系统。支持命令:echo
、reset
、ok
和 cls
。echo
拷贝任意文本到终端,reset
命令会在系统出现问题的是复位操作系统,ok
有两个功能:设置 OK 灯亮灭,最后 cls
调用 TerminalClear 清空终端。
试试树莓派的代码吧。如果遇到问题,请参照问题集锦页面吧。
如果运行正常,祝贺你完成了一个操作系统基本终端和输入系列的课程。很遗憾这个教程先讲到这里,但是我希望将来能制作更多教程。有问题请反馈至 awc32@cam.ac.uk。
你已经在建立了一个简易的终端操作系统。我们的代码在 commandTable 构造了一个可用的命令表格。每个表格的入口是一个整型数字,用来表示字符串的地址,和一个整型数字表格代码的执行入口。 最后一个入口是 为 0 的 commandStringEnd
。尝试实现你自己的命令,可以参照已有的函数,建立一个新的。函数的参数 r0
是用户输入的命令地址,r1
是其长度。你可以用这个传递你输入值到你的命令。也许你有一个计算器程序,或许是一个绘图程序或国际象棋。不管你的什么点子,让它跑起来!
via: https://www.cl.cam.ac.uk/projects/raspberrypi/tutorials/os/input02.html
作者:Alex Chadwick 选题:lujun9972 译者:guevaraya 校对:wxy