经过这一章,操作系统才拥有和用户交互的接口,用户才能通过键盘操作它,并在显示器获取结果。
键盘中断对应的是8259A的IRQ1,外部硬件中断处理的框架已经搭好,现在需要做的只是写好中断处理程序并把它的地址填进函数指针数组即可。
先写键盘中断处理函数(新建keyboard.c)
PUBLIC void keyboard_handler(int irq)
{
disp_str("*");
}
然后设置函数指针数组(keyboard.c)
PUBLIC void init_keyboard()
{
put_irq_handler(KEYBOARD_IRD,keyboard_handler);/*设定键盘中断程序*/
enable_irq(KEYBOARD_IRQ);/*开键盘中断*/
}
就是简单的接收键盘有输入就显示一个*.而且你会发现只能显示一次,后面就会显示键盘缓冲器满。
现在主流的键盘都是USB的了,曾经还有AT和PS/2键盘。
graph LR
8048-->8042
8042-->8259A
键盘敲击:动作+内容,所以8048不仅要反映是哪个键,还要反映是什么动作
敲击键盘产生的编码:扫描码Scan code,分成两类
有三套:Scan code set 1、set 2、set 3
现在主流的是set2,比AT更老的XT键盘则是用set1
8048检测到一个键的动作->把相应扫描码发给8042->8042把它转成相应的Scan code set1扫描码->将其放到输入缓冲期中->8042告诉8259A产生中断(IRQ1)
此时,如果有新的按键被按下,8042将不再接收,直到缓冲区被清空(所以这个需要我们在中断处理程序中读取清空)
也就是说也是用in指令来读取。in_byte(0x60)
值得注意的是:在keyboard_handler里in_byte后,可以持续读取键盘输入了,而且按一次出现两个星号(keyboard_handler被调用两次,两次中断)因为一次敲击包括按下Make Code和弹起Break Code两个动作
然后将得到的扫描码对照scan code set 1就能得到按键内容。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-w2HbRHEA-1575882874071)(http://yeholdon.top/wp-content/uploads/2019/12/IMG_20191206_170835-scaled.jpg)]
值得注意的是,虽然键盘支持的是Scan code set2,但是最后传入计算机的又是scan code set1.这时基于为XT键盘写的程序兼容性而考虑的。
简单来说就是扫描码和具体键盘字符的映射表。
写在keymap.h头文件中。其中每3个值是一组(MAP_COLS=3),分别为单独按、Shift+某键和有0xE0前缀的扫描码对应的字符。Esc,Enter等被定义成了宏,宏的具体数值无所谓,只要不造成冲突混淆即可,让OS认识即可。注意0xE0和0xE1开头的扫描码要区别对待。
缓冲区用结构体s_kb来定义:
/* Keyboard structure, 1 per console. */
typedef struct s_kb {
char* p_head; /* 指向缓冲区中下一个空闲位置 */
char* p_tail; /* 指向键盘任务应处理的字节 */
int count; /* 缓冲区中共有多少字节 */
char buf[KB_IN_BYTES]; /* 缓冲区 */
}KB_INPUT;
结构如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4HPKpKjP-1575882874072)(http://yeholdon.top/wp-content/uploads/2019/12/IMG_20191206_190617-scaled.jpg)]
注意这个是循环的,p_tail到达末尾后指针移到开头
前面设立了键盘缓冲区,就是为了方便后面针对一串扫描码串来统一处理。而且,这样就可以使键盘中断和码处理不是严格的同时进行的,这样做的好处就是可以用一个单独的任务(进程)来处理键盘操作,而无需全部都写在键盘中断处理程序里。而且,虽然不是有中断马上就处理,但是因为进程的切换很快,这个时间差可以忽略。
前面只是搭好了框架,可以正常获取扫描码了,下面开始真正的解析算法。较为复杂。。。具体工作就是扩充keyboard_read()
由简到难,先处理小写字母,再处理组合输入等情况
break code 是make code和0x80 或or的结果;make Code 是break code 和0x7F and与的结果
这三个每个都在键盘上有两个键,而且有的时候左右shift是不同的,所以用6个int来表示它们的状态。再加上caps lock ,num lo还有scroll lock等表示状态的键。
目前已经可以处理大部分按键了,但是还存在两个问题:
先解决第一个问题
下面解决第二个问题,不可打印的控制字符怎么处理
另外,很值得注意的一点就是,对于shift这些按键,它们的作用主要和和别的按键组合使用,所以很多时候只需要将它们是否按下的状态添加到正常的按键码上即可。而且这里无论单键还是组合键都是用的是32位的key表示,除去表示可打印字符和非可打印字符的第9位,还剩下32-9=23位来表示shift、ctrl、alt等键的状态,这足够链路。
熟悉的终端终于要出场了。因为随着键盘模块的完善,需要考虑它和屏幕输出的关系了。IO是密不可分的。
先了解一下终端和显示器的驱动方式
tty也称为终端,直观的认识是,通过按alt+f1等f功能键,可以切换到不同的屏幕界面,不同屏幕中分别有各自的IO,相互不受影响。但这只是表象,实际并没有那么简单
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VlhhpIVd-1575882874073)(http://yeholdon.top/wp-content/uploads/2019/12/IMG_20191207_145538-scaled.jpg)]
可以看到,三个tty共用一个keyboard,而且实际也公用了同一块显存,这就 需要在切换tty的时候让屏幕显示显存中某个位置的内容。这可以通过端口操作简单地做到。
实际,对于显示器的操作,我们可能操作的是显卡,也可能只是显存。显示器实际有很多种模式,现在我们接触到的只是默认的80*25文本模式,另外还有很多更复杂和强大的模式,用来显示更强大的色彩等。
TTY任务,也就是前面的task_tty(),在其中执行了一个循环,这个循环将轮询每一个TTY,然后处理轮询到的TTY的事件,包括从键盘缓冲区读取数据、显示字符等。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7ZJoV1Fn-1575882874074)(http://yeholdon.top/wp-content/uploads/2019/12/IMG_20191207_162330-scaled.jpg)]
新建两个结构体,分别为s_tty和s_console,定义在tty.h和console.h中。
typedef struct s_console
{
unsigned int current_start_addr; /* 当前显示到了什么位置 */
unsigned int original_addr; /* 当前控制台对应显存位置 */
unsigned int v_mem_limit; /* 当前控制台占的显存大小 */
unsigned int cursor; /* 当前光标位置 */
} CONSOLE;
可以看到,其实控制台就是TTY中,负责操作显存显示的部分。规定了该TTY当前显示的情况,比如占据的显存的位置,大小,以及当前显示到的位置。控制台是直接与用户接触的,当我们通过alt+fn切换终端时,其实就是在切换控制台。而控制台对应的TTY则是由TTY任务按照一定的频率轮询的。可以看成是一个程序的界面和后台的关系,后台进程被不断地调度,而前端的用户接口则保持同一个,除非用户自己切换。
typedef struct s_tty
{
u32 in_buf[TTY_IN_BYTES]; /* TTY 输入缓冲区 */
u32* p_inbuf_head; /* 指向缓冲区中下一个空闲位置 */
u32* p_inbuf_tail; /* 指向键盘任务应处理的键值 */
int inbuf_count; /* 缓冲区中已经填充了多少 */
struct s_console * p_console; // 指向对应console的指针
}TTY;
几点开发和调试的总结教训:
总结:TTY任务开始运行时,所有TTY被初始化,全局变量nr_current_console被赋默认初值0.然后轮询开始并一直进行下去。对于每一个TTY,先执行tty_do_read(),它将调用keyboard_read()并将读入的字符交给函数in_process()处理,如果是需要输出的字符,会被in_process放入当前TTY的缓冲区。然后tty_do_write()接着执行,如果缓冲区有数据,就会被送到out_char显示出来。
目前因为没有切换过current console所以,其他tty轮询到时,都被is_current_console()忽略掉了。
现在框架已经搭好,下面进行多个console的切换。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-a3L3Ufl4-1575882874076)(http://yeholdon.top/wp-content/uploads/2019/12/IMG_20191207_205530-scaled.jpg)]
可以看到,一个console的总大小是大于80* 25 的,所以可以做到滚屏,相当于将80 *25的窗口整体下移(或者上移)直到到达console的边界,就做到了滚屏。
图上标注了s_console结构里的成员,之前其实都还没有用到。
init_screen()函数主要是初始化CONSOLE结构的那4个成员,并显示标识console的序号和命令提示符$,并在init_tty()里调用
需要注意的是,结构CONSOLE的成员都是以Word计的,这符合VGA寄存器操作的使用习惯。
主要是传入CONSOLE,,然后根据该CONSOLE来显示,而非disp_pos
这一步是很关键的,其实说到底切换控制台就是通过设置VGA系统的Start Address High/Low Register来让当前这个只有80*25的显示窗口移到目的控制台所对应的那段显存空间。
所以切换控制台的操作是通过前面用过的滚屏的方法操作对应的CRT寄存器来实现的。
这里要注意!
我的电脑当前是Ubuntu18.04系统,第一次测试的时候,发现只有F3能够正确切换到console2,F1和F2貌似都不行,而且alt+F2貌似被系统占用了,按下触发的是系统的功能。因此我怀疑这里的很多组合键是被系统一直占用的,并没有映射到bochs虚拟机里。
经过测试,F5也是可以用的,但是F6和F7又不行。为了能够三个连续,最后找到了F9~F11都是可以的,为此,这个问题算是解决了。
然后是添加滚屏代码scroll_screen.
简单起见,当屏幕滚到最下端或者最上端后,再按shift+down和shift+up就不再响应。
但是这样还有一个问题,就是当写满当前屏幕后再往下写,按照习惯应该自动往下滚动一行,现在来自行添加这个功能。主要是在out_char里添加判断和滚屏。
再进一步,如果写满屏幕后手动向上滚动了几行,这时候又继续写,按照常规用户习惯,应该自动滚动到当前光标所在行继续写,这个其实也简单,就是在前面自动向下滚动一行的基础上先计算出当前屏幕显示的结尾和当前光标所在行差的行数,然后循环向下滚动这个差的行数即可。
到目前为止,只剩下键盘上的某些键的处理程序没有写了,现在补上。
运行在ring0的keyboard_handler主要是负责获得读入扫描码,存入kb_in缓冲区。而终端任务task_tty()则是运行在ring1下的一个任务(进程,至少目前这俩是等价的),它负责读取kb_in里的扫描码,处理得到具体的key后写入当前tty的缓冲区,再由console输出到屏幕显示。所以,比较特殊的是,这里的kb_in是在ring0下写,ring1下读的。这也是Minix的做法。
现在,我们有了四个进程(任务)分别是TTY、A、B、C。ABC对于我们的OS来说是可有可无的,不运行也不会有什么影响,而TTY则是必须的,没有它,我们无法使用键盘进行用户交互。因此,TTY应该是操作系统的一部分,我们有必要区分这两种进程。TTY称为任务,A、B、C则称为用户进程。实现上也做一些改变,让用户进程运行在ring3上,任务继续是ring1,总结来说,就是将系统任务和用户进程区分开来。这样将特权级进一步层次化,有利于管理和保护操作系统。
具体来讲就是将原来的NR_TASKS分成NR_TASKS和NR_PRCS用到的地方和描述符特权级也做相应修改,另外还有进程调度应该还是一起调,没有区别。
现在我们的TTY已经有了雏形,可以写一个用来在控制台输出的C库函数printf()了。这里要注意printf()这种函数并不是系统调用,而是C语言提供的库函数,但是printf也是通过调用系统提供的系统调用实现的,比如write()这个系统调用,因此,我们在实现自己的printf的时候还要先实现write系统调用。
调用printf的是进程,而输出的对象的TTY的控制台,printf调用系统调用的时候,从ring3转到了ring0,系统只能知道当前系统调用是由哪个进程触发的,因此为了让printf知道要输出到哪个控制台,就要让用户进程有从属于的TTY.具体实现可以在进程表中增加一个nr_tty成员。
printf的实现其实并不简单,因为它的参数个数和类型都是可变的,而且其中表示格式的参数形式很多样(%d,%x等等)。所以,由浅入深,先实现一个简单的——只支持"%x"一种格式的printf()
我们知道,printf的结构是这样的
printf("以%开头的格式串",和格式串里的格式对应的数量不定的输出对象);
所以除了第一个格式字符串,后面的参数个数是不定的,为此,C语言给出的是用…表示的可变参数。可变参数实现的原理也值得一说:
调用函数的过程是调用者将参数传入堆栈,被调用的函数获取堆栈里的参数,最后完成调用过程后需要有人清理堆栈,恢复调用前的状态。
这里就涉及两个问题:
规定这两个问题的叫做调用约定calling conventions
对于C语言来说,使用的是C调用约定:后面的参数先入栈,由调用者清理堆栈。
这种方式的好处:在支持可变参数时得到了充分体现,因为只有调用者才知道这次调用包含了几个参数,清理堆栈的时候就很方便。而被调用者虽然通过一定的方式(比如用一定有的第一个参数来间接地告知被调用者后面参数的情况)得知参数的情况,但是这显然有一定限制,也不够方便。
但是可变参数的情况下,被调用者也需要知道参数的具体情况才能区分和使用这些参数。所以,printf()的实现还是用到了第一个一定有的参数作为参考,传达给被调用者传入参数的个数和类型格式等。
具体实现:
增加一个系统调用的过程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uVx6IIK1-1575882874077)(http://yeholdon.top/wp-content/uploads/2019/12/IMG_20191208_211643-scaled.jpg)]
一个使用了系统调用的C库函数的调用过程(printf):
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aiU9aMv7-1575882874078)(http://yeholdon.top/wp-content/uploads/2019/12/IMG_20191209_103726-scaled-e1575859118288.jpg )]
最后再记录一下这次开发调试时遇到的问题和教训
这一节在完成开发后运行,发现虽然各个console里能够正确输出绑定在其上的用户进程的printf()输出。但是控制台却也随着输出在两个输出的console之间快速跳动。其实通过上面的分析很容易知道问题出在调用过程的最后一环也就是tty_write()里的out_char()函数,因为只有这个函数才会直接操作显存切换。
但是让我恼火的是,我第一次逐字核对了out_char函数,却没有问题,然后我就懵逼了。开始回溯整个过程,虽然发现了几个遗漏的小问题,但是都不是造成这个console快速切换跳动的原,有些强迫症的我还为此纠结了一晚上。结果第二天早上,我又从理性出发,觉得问题一定出在out_char,就又再核对了一遍,原来并不是out_char本身有问题,而是里面调用的flush()这个用来更新重新设置的当前console到显示器的函数的问题,因为flush应该是要判断当前TTY是对应当前console后才能刷新的,而我遗漏掉了这个,直接导致只要输出字符调用out_char就切换到了调用out_char的进程对应的console。
到这里,一个OS的雏形已经基本实现,我们可以在多个控制台之间切换,各个控制台下运行着各自的用户进程,彼此互不干扰,呈现出了一个多任务多控制台操作系统雏形。接下来将进入一个操作系统的其他一些基本组成模块的编写,包括进程间通信、文件系统、内存管理。