
课程输入 02 是以课程输入 01 为基础讲解的,通过一个简单的命令行实现用户的命令输入和计算机的处理和显示。本文假设你已经具备 课程11:输入01 的操作系统代码基础。
1、终端
几乎所有的操作系统都是以字符终端显示启动的。经典的黑底白字,通过键盘输入计算机要执行的命令,然后会提示你拼写错误,或者恰好得到你想要的执行结果。这种方法有两个主要优点:键盘和显示器可以提供简易、健壮的计算机交互机制,几乎所有的计算机系统都采用这个机制,这个也广泛被系统管理员应用。
早期的计算一般是在一栋楼里的一个巨型计算机系统,它有很多可以输命令的'终端'。计算机依次执行不同来源的命令。
让我们分析下真正想要哪些信息:
- 计算机打开后,显示欢迎信息
- 计算机启动后可以接受输入标志
- 用户从键盘输入带参数的命令
- 用户输入回车键或提交按钮
- 计算机解析命令后执行可用的命令
- 计算机显示命令的执行结果,过程信息
- 循环跳转到步骤 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这是文件终端的配置数据文件。我们有两个主要的存储变量:
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 颜色编码
我们将前景色保存到颜色的低字节,背景色保存到颜色高字节。除了棕色,其他这些颜色遵循一种模式如二进制的高位比特代表增加 ⅓ 到每个组件,其他比特代表增加 ⅔ 到各自组件。这样很容易进行 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 SetForeColour2、文本显示
我们的终端第一个真正需要的方法是
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
尝试去自己实现吧。如果你遇到问题,我们的方案下面给出来了:
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,taddr2、从
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,#0x7f2.1.3、为了简便我把字符和颜色同时加载到
col里。ldrh col,[screen]2.1.4、 现在我用
teq指令检查是否有数据变化teq col,char beq xLoopContinue$2.1.5、我可以容易的保存当前值
strh char,[screen]2.1.6、我用比特偏移指令
lsr和and指令从切分char变量,将颜色放到col变量,字符放到char变量,然后再用比特偏移指令lsr获取背景色后调用TerminalColour。lsr col,char,#8 and char,#0x7f lsr r0,col,#4 bl TerminalColour2.1.7、写入一个彩色的删除字符
mov r0,#0x7f mov r1,x mov r2,y bl DrawCharacter2.1.8、用
and指令获取col变量的低半字节,然后调用TerminalColourand r0,col,#0xf bl TerminalColour2.1.9、写入我们需要的字符
mov r0,char mov r1,x mov r2,y bl DrawCharacter2.1.10、自增屏幕指针
xLoopContinue$: add screen,#22.1.11、如果可能自增
view指针teq view,stop addne view,#22.1.12、很容易检测
view指针是否越界到缓冲区的末尾,因为缓冲区的地址保存在taddr变量里teq view,taddr subeq view,#128*128*22.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 stop3、行打印
现在我有了自己
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现在我们需要构造一个字符显示的基础方法:
r0的字符串和保存在r1的字符串长度简单的写到屏幕上。有一些特定字符需要特别的注意,这些特定的操作是确保terminalView是最新的。我们来分析一下需要做什么:
- 检查字符串的长度是否为 0,如果是就直接返回
- 加载
terminalStop和terminalView- 计算出
terminalStop的 x 坐标- 对每一个字符的操作:
- 检查字符是否为新起一行
- 如果是的话,自增
bufferStop到行末,同时写入黑色删除字符- 否则拷贝当前
terminalColour的字符- 检查是否在行末
- 如果是,检查从
terminalView到terminalStop之间的字符数是否大于一屏- 如果是,
terminalView自增一行- 检查
terminalView是否为缓冲区的末尾,如果是的话将其替换为缓冲区的起始位置- 检查
terminalStop是否为缓冲区的末尾,如果是的话将其替换为缓冲区的起始位置- 检查
terminalStop是否等于terminalStart, 如果是的话terminalStart自增一行。- 检查
terminalStart是否为缓冲区的末尾,如果是的话将其替换为缓冲区的起始位置试一下自己去实现。我们的方案提供如下:
1、这个是
.globl Print Print: teq r1,#0 moveq pc,lr2、这里我做了很多配置。
bufferStart代表terminalStart,bufferStop代表terminalStop,view代表terminalView,taddr代表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*23、和通常一样,巧妙的对齐技巧让许多事情更容易。由于需要对齐
terminalBuffer,每个字符的 x 坐标需要 8 位要除以 2。and x,bufferStop,#0xfe lsr x,#14.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,#14.4、检查 x 是否为行末;128
charLoopContinue$: cmp x,#128 blt noScroll$4.5、设置 x 为 0 然后检查我们是否已经显示超过 1 屏。请记住,我们是用的循环缓冲区,因此如果
bufferStop和view之前的差是负值,我们实际上是环绕了缓冲区。mov x,#0 subs r0,bufferStop,view addlt r0,#128*128*2 cmp r0,#128*(768/16)*24.6、增加一行字节到
view的地址addge view,#128*24.7、 如果
view地址是缓冲区的末尾,我们就从它上面减去缓冲区的长度,让其指向开始位置。我会在开始的时候设置taddr为缓冲区的末尾地址。teq view,taddr subeq view,taddr,#128*128*24.8、如果
stop的地址在缓冲区末尾,我们就从它上面减去缓冲区的长度,让其指向开始位置。我会在开始的时候设置taddr为缓冲区的末尾地址。noScroll$: teq bufferStop,taddr subeq bufferStop,taddr,#128*128*24.9、检查
bufferStop是否等于bufferStart。 如果等于增加一行到bufferStart。teq bufferStop,bufferStart addeq bufferStart,#128*24.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的地址到它开始的地方然后调用按照惯例,许多编程语言中,任意程序可以访问 stdin 和 stdin,它们可以连接到终端的输入和输出流。在图形程序其实也可以进行同样操作,但实际几乎不用。
让我们看看
ReadLine做了哪些事情:
- 如果字符串可保存的最大长度为 0,直接返回
- 检索
terminalStop和terminalStop的当前值- 如果字符串的最大长度大约缓冲区的一半,就设置大小为缓冲区的一半
- 从最大长度里面减去 1 来确保输入的闪烁字符或结束符
- 向字符串写入一个下划线
- 写入一个
terminalView和terminalStop的地址到内存- 调用
- 调用
TerminalDisplay- 调用
KeyboardUpdate- 调用
KeyboardGetChar- 如果是一个新行直接跳转到第 16 步
- 如果是一个退格键,将字符串长度减 1(如果其大于 0)
- 如果是一个普通字符,将它写入字符串(字符串大小确保小于最大值)
- 如果字符串是以下划线结束,写入一个空格,否则写入下划线
- 跳转到第 6 步
- 字符串的末尾写入一个新行字符
- 调用
TerminalDisplay- 用结束符替换新行
- 返回字符串的长度
为了方便读者理解,然后然后自己去实现,我们的实现提供如下:
- 快速处理长度为 0 的情况
.globl ReadLine ReadLine: teq r1,#0 moveq r0,#0 moveq pc,lr2、考虑到常见的场景,我们初期做了很多初始化动作。
input代表terminalStop的值,view代表terminalView。Length默认为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,#03、我们必须检查异常大的读操作,我们不能处理超过
terminalBuffer大小的输入(理论上可行,但是terminalStart移动越过存储的 terminalStop`,会有很多问题)。cmp maxLength,#128*64 movhi maxLength,#128*644、由于用户需要一个闪烁的光标,我们需要一个备用字符在理想状况在这个字符串后面放一个结束符。
sub maxLength,#15、写入一个下划线让用户知道我们可以输入了。
mov r0,#'_' strb r0,[string,length]6、保存
terminalStop和terminalView。这个对重置一个终端很重要,它会修改这些变量。严格讲也可以修改terminalStart,但是不可逆。readLoop$: str input,[taddr,#terminalStop-terminalStart] str view,[taddr,#terminalView-terminalStart]7、写入当前的输入。由于下划线因此字符串长度加 1
mov r0,string mov r1,length add r1,#1 bl Print8、拷贝下一个文本到屏幕
bl TerminalDisplay9、获取最近一次键盘输入
bl KeyboardUpdate10、检索键盘输入键值
bl KeyboardGetChar11、如果我们有一个回车键,循环中断。如果有结束符和一个退格键也会同样跳出循环。
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,#114、加载最近的一个字符,如果不是下划线则修改为下换线,如果是则修改为空格
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、重置
terminalView和terminalStop然后调用TerminalDisplay显示最终的输入str input,[taddr,#terminalStop-terminalStart] str view,[taddr,#terminalView-terminalStart] mov r0,string mov r1,length add r1,#1 bl Print bl TerminalDisplay18、写入一个结束符
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 view5、终端:机器进化
现在我们理论用终端和用户可以交互了。最显而易见的事情就是拿去测试了!删除
main.s里bl 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这块代码集成了一个简易的命令行操作系统。支持命令:
echo、reset、ok和cls。echo拷贝任意文本到终端,reset命令会在系统出现问题的是复位操作系统,ok有两个功能:设置 OK 灯亮灭,最后cls调用 TerminalClear 清空终端。试试树莓派的代码吧。如果遇到问题,请参照问题集锦页面吧。
如果运行正常,祝贺你完成了一个操作系统基本终端和输入系列的课程。很遗憾这个教程先讲到这里,但是我希望将来能制作更多教程。有问题请反馈至 [email protected]。
你已经在建立了一个简易的终端操作系统。我们的代码在 commandTable 构造了一个可用的命令表格。每个表格的入口是一个整型数字,用来表示字符串的地址,和一个整型数字表格代码的执行入口。 最后一个入口是 为 0 的
commandStringEnd。尝试实现你自己的命令,可以参照已有的函数,建立一个新的。函数的参数r0是用户输入的命令地址,r1是其长度。你可以用这个传递你输入值到你的命令。也许你有一个计算器程序,或许是一个绘图程序或国际象棋。不管你的什么点子,让它跑起来!
