引言
- 键盘驱动实现好了,那么接下来自然就是要实现人机交互接口 Shell 了,不然要键盘有啥用
- 什么是 Shell ?Shell 就是用户和系统的简单命令行交互窗口,系统根据用户输入的命令来实现对应的功能
优化按键读取
- 在实现 shell 之前,我们先把上一章节实现的按键读取功能完善一下
- 按键读取的功能已经实现了,但是并不完善,哪里不完善呢?我们来看一下按键读取功能,应用程序可以频繁调用 ReadKey() 函数,以我们目前的实现方式,即便是我们没有敲击按键,但程序依旧会频繁的在内核和应用之间切换,浪费 CPU 资源
- 这合理吗?这显然是不合理的,我们可以作如下设计,当没有按键按下时,此时如果有任务想要读取按键,那么我们就把这个任务挂起,不让该任务参与任务调度,直到有按键按下时,此时才恢复任务,让其参与任务调度
- 我们可以使用事件机制来实现按键读取优化功能
- 首先创建一个按键事件,并初始化,其实就是按键事件初始化
// keyboard.c 中 static EVENT* keyEvent = NULL; // 按键事件 void KeyboardInit(void) { keyEvent = SYS_EventCreat(); } // main.c 中调用初始化 S32 main(void) { ... KeyboardInit(); // 键盘初始化 ... }
- 按键读取函数改动如下:
U32 SYS_ReadKey(void) { U32 ret = 0; SYS_WaitEvent(keyEvent); ret = ScanCodeAnalysis(&keyQueue); if(0 == keyQueue.len) SYS_ClearEvent(keyEvent); return ret; }
- 按键中断服务程序改动如下:
void KeyboardIntHandle(void) { U08 key = 0; // in al, 0x60 ; 从 0x60 端口读一个字节数据 asm volatile("inb $0x60, %%al; nop;" :"=a"(key)); RING_PUT(key, &keyQueue); SYS_SetEvent(keyEvent); write_m_EOI(); }
- “app.c” 任务中改动如下:
void TaskA(void) { U32 key = 0; while (1) { key = ReadKey(); print("%x ", key); } }
- 相关改动见:keyboard.c、keyboard.h、main.c、app.c
- 改动结束,本以为一切 OK,然而意外却仍然出现了,当按下按键后,打印的键值前面多了一个数据 0x0D
- 这又是怎么回事呢?
问题剖析及解决
- 经过反复摸索,调试,发现并不是按键中断服务程序中键位读取出问题,也不是循环队列读写出问题
- 问题出在了 ReadKey 系统调用上,当然了,在没增加按键事件前是没问题的
- 当调用任务执行 key = ReadKey(); 语句时,此时如果没有按键,那么该任务挂起,内核中的 ScanCodeAnalysis() 函数也不会执行。于是没人改变寄存器 eax 的值, eax 的值还是 _NR_ReadKey,即 0x0D, 当有按键按下时,key = ReadKey(); 并不会被再次执行,此时程序会继续向下执行,eax 寄存器中的值赋值给了 key,于是就打印出了 0x0D
- 想要解决这个问题,肯定是要让 ReadKey 的系统调用重复执行啦,具体改动如下:
U32 ReadKey(void) { U32 ret = 0; do { ret = _SYS_CALL0(_NR_ReadKey); } while (!ret || (_NR_ReadKey == ret)); return ret; }
- 这是一种打补丁的方式,前提是按键返回值中不能有与 _NR_ReadKey 相同的值
shell 工程目录结构设计
- 考虑到 shell 的扩展性,我们在 “app” 下单独创建一个 “shell” 文件夹,以后实现的所有 shell 功能都放到该目录下
- 整体目录结构如下:
KOS |--- BUILD.json |--- 其它 |--- app | |--- shell | | |--- BUILD.json | | |--- inc | | | |--- shell.h | | |--- src | | | |--- shell.c
- 首先,由于 “app” 目录下新增 “shell” 目录,所以 “app” 目录下的 “BUILD.json” 配置文件也要给 "dir" 项新增 "shell" 元素,完整内容见:
{ "dir" : [ "shell" ], "src" : [ "aentry.asm", "app.c" ], "inc" : [ "../user/include" ] }
- "shell" 目录下包含 “src” 工程源文件夹,所以 "shell" 目录下的 BUILD.json 配置文件中 "dir" 项要增加 "src" 元素,完整内容见:
{ "dir" : [ "src" ], "src" : [ ], "inc" : [ ] }
- 进入 “src” 目录下,这里面就是 shell 相关的工程源文件了,我们还得创建一个 “BUILD.json” 配置文件用于管理这些源文件,其中 "src" 项就是当期目录下的源文件,"inc" 项为当期目录下源文件所需要的头文件路径(相对于编译脚本 AppBuild.py 的相对路径),详见:
{ "dir" : [ ], "src" : [ "shell.c" ], "inc" : [ "../user/include", "shell/inc" ] }
shell 任务初步实现
U08 ShellStack[256] = {0xFF}; // shell 任务私有栈 void ShellTask(void) { U32 key = 0; while (1) { key = ReadKey(); // ... } }
- 在 “app.c” 中注册 shell 任务,“app.c” 中的测试读取按键的任务 TaskA 也删除吧
void AppInit(void) { ... AppRegister(ShellTask, ShellStack, sizeof(ShellStack), "Shell", E_APP_PRI0); ... }
- ShellTask 这个任务就是上面的 TaskA 任务,现在只是把它从 “app.c” 中挪到 “shell.c” 中,顺便改了个名字
- 在读到按键值之后,我们接着向下处理呗,首先就是解析键值,从中获取到键值对应的 ascii 码和虚拟键值 vcode,ascii 码用于打印显示,vcode 用于命令分类,目前我们只分了 "BackSpace" 键和 "Enter" 键两类
static void CmdLineHandle(U08 ascii, U08 vcode) { // 命令行输入显示 if(ascii) { if(cmd_index < CMD_MAX) { cmd_buf[cmd_index++] = ascii; print("%c", ascii); } } switch(vcode) { case VCODE_BACKSPACE: // 按下 "BackSpace" 键 BackSpaceHandle(); break; case VCODE_ENTER: // 按下 "Enter" 键 EnterHandle(); break; default: break; } } void ShellTask(void) { ... // 固定命令行的显示位置,并打印提示字符 "Enter command:" SetCursorPos(CMDLINE_POS_X, CMDLINE_POS_Y); print(CMDLINE); while (1) { key = ReadKey(); if(IS_KEYDOWN(key)) // 如果是按键按下 { ascii = GET_CHAR(key); // 获取键值中的 ASCII vcode = GET_VCODE(key); // 获取键值中的虚拟键码 CmdLineHandle(ascii, vcode); // 命令行处理 } } }
- 接下来就是 "BackSpace" 键和 "Enter" 键的具体处理函数了。这两个按键我们经常使用,所以实现起来并不困难, "BackSpace" 键的作用就是删除最后一个字符,其实现方法是先将光标位置前移一个字符,然后打印一个空格字符,于是最后一个字符我们就看不到了,但是此时光标位置却又向后移动了一个字符,于是,我们再次重新设置一下前移一个字符后的光标位置就可以了;"Enter" 键对应的处理函数 EnterHandle 也是没什么难度的,首先剔除掉空字符的情况,接下来就是利用 DoCmd() 函数解析命令,如果解析成功,则执行命令,如果解析不成功,那么我们就在命令行的下一行打印 "Unknown command:xxx" 提示符,DoCmd() 这个函数具体内容还没有实现,目前该函数返回的是不成功,所以不管输入什么命令,都会有 "Unknown command:xxx" 提示
static void BackSpaceHandle(void) { if(cmd_index) { // 首先将光标位置前移一个位置,打印 " " 空格字符,打印完成后光标位置又向后偏移了一个位置,需要重新设置回来 cmd_index--; SetCursorPos(sizeof(CMDLINE) -1 + cmd_index, CMDLINE_POS_Y); print(" "); SetCursorPos(sizeof(CMDLINE) -1 + cmd_index, CMDLINE_POS_Y); } } static void EnterHandle(void) { U32 i = 0; // 输入的命令行字符为空,则直接退出 if(0 == cmd_index) return; cmd_buf[cmd_index] = 0; // 先在输入的字符串最后添加字符串结束标志 '\0' // 在开始解析命令之前,先将命令行的下一行清空,用于接下来的命令提示 SetCursorPos(CMDLINE_POS_X, CMDLINE_POS_Y+1); for(i = 0; i < sizeof(CMD_UNKNOWN) -1 + CMD_MAX; i++) print(" "); // 如果命令执行失败,则打印 "Unknown command:xxx" if(E_ERR == DoCmd(cmd_buf)) { SetCursorPos(CMDLINE_POS_X, CMDLINE_POS_Y+1); print(CMD_UNKNOWN); print("%s", cmd_buf); ResetCmdLine(); // 复位命令行 } }
- 成果展示:
命令注册
- 上面我们已经实现了 shell 任务的基本框架,只剩下真正的命令执行 DoCmd() 函数尚未实现了
- 想要执行命首先得有命令吧,所以现在第一步要做的就是实现命令注册机制,针对命令个数的不确定特性,我们可以采用链表的方式来管理命令
- 于是先创建一个链表头
static LIST CMD_LIST_HEAD = {0}; // shell 命令链表头
- 找个地方初始化一下这个链表
void ShellTask(void) { ... ListInit(&CMD_LIST_HEAD); // 初始化 shell 命令链表 ... while (1) { ... } } • 定义命令链表的节点类型 typedef void (*CmdFunc)(); typedef struct SHELL_CMD { LIST_NODE node; U08* cmd; CmdFunc func; } SHELL_CMD; • 最后来实现命令注册函数 E_RET CmdRegister(const U08* cmd, CmdFunc func) { // 检查参数合法性 if(NULL == cmd || NULL == func) return E_ERR; // 申请一个命令节点的内存空间 SHELL_CMD* shell_cmd = (SHELL_CMD *)Malloc(sizeof(SHELL_CMD)); if(NULL == shell_cmd) return E_ERR; shell_cmd->cmd = (U08 *)cmd; shell_cmd->func = func; ListAddHead(&CMD_LIST_HEAD, &(shell_cmd->node)); // 将命令节点插入命令链表中 return E_OK; }
命令执行
- 现在才是真正的命令处理实现,该函数的实现无外乎就是遍历命令链表
static E_RET DoCmd(U08* cmd) { LIST_NODE* pListNode = NULL; SHELL_CMD* nodeTmp = NULL; // 遍历命令链表 LIST_FOR_EACH(&CMD_LIST_HEAD, pListNode) { nodeTmp = (SHELL_CMD *)LIST_NODE(pListNode, SHELL_CMD, node); if(StrCmp(nodeTmp->cmd, cmd, -1)) { nodeTmp->func(); return E_OK; } } return E_ERR; }
- 然而比较字符串函数 StrCmp() 并没有实现,还得实现一下几个常用的字符串处理函数,这个具体就不介绍了,见 “user” 目录下:string.c、string.h
- 接下来通过一个简单的功能来实践一下 shell 命令吧
- 目标:命令行输入 version 命令,打印出系统内核版本信息
- 首先,在 “shell” 目录下创建 “version.c” 和 “version.h” 文件,每新增一个命令,我们可以创建与该命令名称相同的源文件
- “shell” 目录下的 “version.c” 中的代码很简单,就是打印内核版本信息。见:version.c、version.h
voidKernelVersion(void)
{ print("%s", GetKernelVersion()); }
- GetKernelVersion() 函数为系统调用,其对应内核中的 SYS_GetKernelVersion() 函数,系统调用的实现这里不做叙述,见:version.c、version.h、u_syscall.c、u_syscall.h、syscall.c
- 最后就是注册命令,见:sehll.c
void ShellTask(void) { ... ListInit(&CMD_LIST_HEAD); // 初始化 shell 命令链表 CmdRegister(CMD_VERSION, (CmdFunc)KernelVersion); // 注册命令 while (1) { ... } }
- 编译运行,输入 version 命令,打印 “KOS-0.1”
- 再实现一个 clear 命令,作用:清空打印区。关键函数: Clear()
void Clear(void) { U32 i = 0, j = 0; for(i = OUTPUT_POS_Y; i < SCREEN_HEIGHT; i++) { for(j = OUTPUT_POS_X; j < SCREEN_WIDTH; j++) { print(" "); } } }
CmdRegister(CMD_CLEAR,(CmdFunc)Clear); // 注册命令
- clear 命令的实现效果就不展示了,自己尝试一下
- 最后,我们稍微调整一下 shell 源码所在的目录结构吧,虽然 shell 任务运行于 3 特权级,属于应用程序,shell 相关代码放到 “app” 目录下也是可以的,但是 shell 功能开发并不是用户实现的,“app” 下放应用业务代码比较合适,我们把 “shell” 文件夹从 “app” 目录下移到工程根目录下,当然了,shell 相关代码源文件还是由 “app” 目录下的 “BUILD.json” 配置文件管理,其内容改动如下:
{ "dir" : [ "../shell" ], "src" : [ "aentry.asm", "app.c" ], "inc" : [ "../user/include", "../shell/inc" ] }