人机交互之Shell

简介: 人机交互之Shell

引言

  • 键盘驱动实现好了,那么接下来自然就是要实现人机交互接口 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.ckeyboard.hmain.capp.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 任务初步实现

  • 本次改动的代码见:shell.cshell.happ.c
  • 在 “shell.c” 文件中创建一个 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.cstring.h
  • 接下来通过一个简单的功能来实践一下 shell 命令吧
  • 目标:命令行输入 version 命令,打印出系统内核版本信息
  • 首先,在 “shell” 目录下创建 “version.c” 和 “version.h” 文件,每新增一个命令,我们可以创建与该命令名称相同的源文件
  • “shell” 目录下的 “version.c” 中的代码很简单,就是打印内核版本信息。见:version.cversion.h

voidKernelVersion(void)

{
    print("%s", GetKernelVersion());
}
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(" ");
        }
    }
}
  • 完整改动代码见:clear.cclear.h
  • 别忘记注册命令,只有注册了的命令才会生效

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"
        ]
}
目录
相关文章
|
8月前
|
存储 大数据 Shell
大数据技术之Shell(3)
大数据技术之Shell(3)
58 0
|
2月前
|
Shell 数据处理 C++
【Shell 编程设计】shell中${}和()的使用指南
【Shell 编程设计】shell中${}和()的使用指南
21 0
|
8月前
|
运维 大数据 Shell
大数据技术之Shell(1)
大数据技术之Shell(1)
79 0
|
5月前
|
Unix Shell Linux
shell脚本趣味教学
shell脚本趣味教学
19 1
|
8月前
|
存储 安全 Shell
shell脚本里的思维
shell脚本里的思维
25 1
|
8月前
|
大数据 Java Shell
大数据技术之Shell(2)
大数据技术之Shell(2)
44 1
|
12月前
|
存储 运维 监控
「速通Shell」初次走近Shell,Shell是什么?
对于开发者来说,除了掌握Java、C/C++等主要编程语言外,还需要掌握支撑性的工具语言和库,学习和掌握Shell,能够帮助我们高效便捷的编译和运行程序,让系统代替复杂的人工操作。
66 0
「速通Shell」初次走近Shell,Shell是什么?
|
运维 Java Shell
一文教你如何学会写Shell脚本
做 Java 的肯定都接触过 Linux 系统,那么很多时候我们在开发的过程中都是把我们项目打成一个jar包,或者是war包的形式,然后通过 XFTP 上传到我们服务器的指定目录,然后运行一端启动脚本,让我们的项目变得可以访问 就像 ./sh service.sh start 然后启动我们写好的 sh 的shell脚本。接下来我们就来学习一下关于 Shell 脚本是如何写出来的。

热门文章

最新文章