ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

《从零手写操作系统 (12):键盘驱动与Shell——让人类与OS对话》

《从零手写操作系统 (12):键盘驱动与Shell——让人类与OS对话》 前言从“自动演示”到“交互系统”在前面的章节中我们构建了进程、用户态、ELF加载器和文件系统。但到目前为止内核仍然是一个“自说自话”的系统它启动后自动运行预设的任务人类无法向它下达任何指令。一个不能接受用户输入的操作系统永远只是一个技术演示。本章我们将打通人机交互的最后一公里PS/2键盘驱动与命令行Shell。通过解析键盘扫描码、实现行编辑缓冲区、构建命令解析循环你的OS将第一次能够倾听人类的指令并根据指令动态加载和执行程序。这是让操作系统从“代码”变成“工具”的决定性时刻。本章里程碑✅ 实现PS/2键盘IRQ1中断处理与扫描码→ASCII转换✅ 构建带退格、回车处理的行输入缓冲区✅ 实现简易Shell循环读取命令→解析→执行✅ 支持ls、cat、exec三个内置/外部命令✅ 验证键盘输入在调度器抢占下的正确性✅ 首次实现“人类输入→OS响应”的完整闭环核心概念扫描码、TTY缓冲与命令分发键盘不是“字符设备”而是“事件设备”键盘硬件发送的不是ASCII字符而是扫描码Scancode。按下A键发送0x1C释放时发送0x9CSet 1。Shift、CapsLock等修饰键是独立的状态标志。这意味着驱动必须维护一个状态机将扫描码序列翻译为字符流。⚠️致命陷阱键盘中断频率极高打字时可达每秒数十次。如果在IRQ1处理程序中执行kprintf或复杂逻辑会严重阻塞其他中断。IRQ1只做最小化工作读端口、查表、写入环形缓冲区其余全部推迟到Shell主循环中处理。TTY输入缓冲区的必要性Shell需要整行输入才能解析命令但键盘每次只产生一个字符。我们需要一个中间缓冲层回显按键后立即显示字符提供视觉反馈行编辑Backspace删除、Enter提交解耦中断生产者 vs Shell消费者通过缓冲区异步衔接这个缓冲区本质上是一个单生产者-单消费者环形队列在本章简化为线性缓冲原子索引。Shell的命令分发模型Shell是一个无限循环的用户态或内核态程序while (true) { print_prompt(); line read_line(); cmd, args parse(line); if (is_builtin(cmd)) execute_builtin(cmd, args); else exec_external(cmd, args); }内置命令如ls直接在Shell进程中执行外部命令需要通过forkexec本章简化为直接exec替换当前Shell后续章节完善。实战代码PS/2键盘驱动// keyboard.h keyboard.c #include stdint.h #include interrupt.h #include serial.h #define KEYBOARD_PORT_DATA 0x60 #define KEYBOARD_PORT_STATUS 0x64 #define INPUT_BUF_SIZE 256 // 简化的US QWERTY Set1扫描码→ASCII映射仅字母数字常用符号 static const char scancode_to_ascii[128] { 0, 27, 1,2,3,4,5,6,7,8,9,0,-,,\b, \t,q,w,e,r,t,y,u,i,o,p,[,],\n, 0, a,s,d,f,g,h,j,k,l,;,\,, 0, \\,z,x,c,v,b,n,m,,,.,/,0, *, 0, /* ... 其余为0 */ }; // 输入缓冲区 static volatile char input_buf[INPUT_BUF_SIZE]; static volatile uint32_t buf_head 0; // 写入位置IRQ写 static volatile uint32_t buf_tail 0; // 读取位置Shell读 static void keyboard_irq_handler(interrupt_frame_t *frame) { (void)frame; // 检查输出缓冲区是否有数据 uint8_t status inb(KEYBOARD_PORT_STATUS); if (!(status 0x01)) return; uint8_t scancode inb(KEYBOARD_PORT_DATA); // 仅处理按下事件最高位0忽略释放 if (scancode 0x80) return; // 基本范围检查 if (scancode 128) return; char c scancode_to_ascii[scancode]; if (c 0) return; // 未映射的键 // 写入环形缓冲区不溢出覆盖 uint32_t next (buf_head 1) % INPUT_BUF_SIZE; if (next ! buf_tail) { input_buf[buf_head] c; buf_head next; } // 注意此处绝不调用kprintf } // 供Shell调用的非阻塞读取 int keyboard_getchar(void) { if (buf_head buf_tail) return -1; // 无数据 char c input_buf[buf_tail]; buf_tail (buf_tail 1) % INPUT_BUF_SIZE; return (unsigned char)c; } // 阻塞读取配合hlt节能 char keyboard_getchar_blocking(void) { int c; while ((c keyboard_getchar()) -1) { asm volatile(hlt); } return (char)c; } void keyboard_init(void) { buf_head buf_tail 0; irq_register(1, keyboard_irq_handler); kprintf([KB] PS/2 keyboard initialized.\n); }行编辑器与TTY回显// tty.c #include keyboard.h #include serial.h #define LINE_MAX 256 // 读取一整行支持Backspace和Enter带回显 int tty_readline(char *buf, int max_len) { int pos 0; while (pos max_len - 1) { char c keyboard_getchar_blocking(); if (c \n) { serial_putc(\n); buf[pos] \0; return pos; } if (c \b) { if (pos 0) { pos--; // 回显退格光标左移 空格覆盖 再左移 serial_putc(\b); serial_putc( ); serial_putc(\b); } continue; } // 可打印字符 if (c 32 c 127) { buf[pos] c; serial_putc(c); // 即时回显 } } buf[pos] \0; return pos; }Shell实现// shell.c #include tty.h #include vfs.h #include initrd.h #include elf.h #include process.h #include serial.h #include string.h #define CMD_MAX 256 #define ARGV_MAX 16 static void shell_ls(void) { for (uint32_t i 0; i initrd_file_count; i) { kprintf(%s (%d bytes)\n, initrd_files[i].name, initrd_files[i].size); } } static void shell_cat(const char *filename) { int idx initrd_find(filename); if (idx 0) { kprintf(cat: %s: No such file\n, filename); return; } // 直接输出到串口绕过VFS以简化 for (uint32_t i 0; i initrd_files[idx].size; i) { serial_putc(initrd_files[idx].data[i]); } if (initrd_files[idx].size 0 initrd_files[idx].data[initrd_files[idx].size - 1] ! \n) { serial_putc(\n); } } static void shell_exec(const char *filename) { int idx initrd_find(filename); if (idx 0) { kprintf(exec: %s: No such file\n, filename); return; } uint32_t entry elf_load(initrd_files[idx].data, initrd_files[idx].size); if (!entry) { kprintf(exec: %s: Failed to load ELF\n, filename); return; } // 简化直接跳转到新程序入口替换当前Shell上下文 // 生产级应fork新进程此处为教学演示 kprintf([SHELL] Executing %s 0x%x...\n, filename, entry); // TODO: 实际应创建新用户进程并iret // 本章仅验证ELF可从文件系统加载 kprintf([SHELL] (exec demo complete, returning to shell)\n); } static int parse_command(char *line, char *argv[], int max_args) { int argc 0; char *p line; while (*p argc max_args - 1) { // 跳过空白 while (*p || *p \t) p; if (*p \0) break; argv[argc] p; // 找下一个空白 while (*p *p ! *p ! \t) p; if (*p) *p \0; } argv[argc] NULL; return argc; } void shell_run(void) { char line[CMD_MAX]; char *argv[ARGV_MAX]; kprintf(\n MyOS Shell v0.1 \n); kprintf(Commands: ls, cat file, exec elf\n\n); for (;;) { kprintf(myos ); tty_readline(line, CMD_MAX); int argc parse_command(line, argv, ARGV_MAX); if (argc 0) continue; if (strcmp(argv[0], ls) 0) { shell_ls(); } else if (strcmp(argv[0], cat) 0 argc 2) { shell_cat(argv[1]); } else if (strcmp(argv[0], exec) 0 argc 2) { shell_exec(argv[1]); } else if (strcmp(argv[0], help) 0) { kprintf(Available commands:\n); kprintf( ls List files in initrd\n); kprintf( cat file Print file contents\n); kprintf( exec file Load and run ELF program\n); kprintf( help Show this message\n); } else { kprintf(Unknown command: %s\n, argv[0]); } } }集成到内核启动// kernel_main.c void kernel_main(void) { serial_init(); memory_init(); paging_init(); interrupt_init(); keyboard_init(); // ★ 新增 vfs_init(); initrd_parse(initrd_blob, initrd_size); process_init(); // 将Shell作为第一个内核线程运行 process_create(shell_run, shell); current_process process_table[0]; current_process-state PROC_RUNNING; kprintf([KERNEL] System ready. Starting shell...\n); asm volatile(sti); for (;;) asm volatile(hlt); }关键细节解析1. 为什么键盘ISR必须极度精简IRQ1触发时CPU处于中断上下文所有同级及更低优先级中断被屏蔽。如果ISR耗时过长如调用kprintf涉及串口轮询等待会导致PIT Tick丢失、调度延迟、甚至鼠标/串口数据丢失。ISR的唯一职责是将原始扫描码存入无锁环形缓冲区所有翻译、回显、命令解析都推迟到进程上下文中完成。这是实时系统设计的基本原则。2. 为什么Shell运行在内核态而非用户态完整的用户态Shell需要fork()、exec()、wait()三个syscall协同工作。我们目前只有exec且语义不完整。将Shell暂时放在内核态可以快速验证整个I/O栈的正确性避免同时调试特权级切换和Shell逻辑的双重复杂性。下一章实现完整进程管理后再将Shell迁移到Ring 3。3. 环形缓冲区为什么不需要锁当前系统中只有一个生产者IRQ1和一个消费者Shell线程。buf_head只在ISR中修改buf_tail只在Shell中修改。由于x86对32位对齐内存的读写是原子的且不存在SMP竞争简单的模运算即可保证安全。引入SMP后需改为原子操作或自旋锁。调试Checklist键盘与Shell问题排查症状可能原因排查方法按键无任何反应IRQ1未注册/PIC未解除屏蔽/scancode表全零在ISR入口加计数器确认是否触发检查PIC IMR bit1是否为0dump scancode_to_ascii验证按A显示q / 乱码扫描码集混淆(Set1/Set2)/映射表偏移错误QEMU默认Set1用info registers或串口dump原始scancode值对照IBM PC XT手册Backspace不回退终端不支持\b序列/回显逻辑错误确认串口终端支持BS检查\b \b三字节序列是否正确发送快速打字丢字符缓冲区太小/ISR被其他长中断阻塞增大INPUT_BUF_SIZE检查其他ISR执行时间QEMU中用trace-events分析中断延迟Shell命令解析错误parse_command未正确处理连续空格/尾部换行单元测试parse函数传入各种边界字符串验证argc/argvexec后无法返回ShellELF程序未调用exit/Shell被永久替换当前简化实现预期如此验证ELF加载成功后应能正常执行syscall黄金法则键盘调试的最佳工具是原始扫描码dump。在ISR中无条件将每个scancode以十六进制输出到串口仅在开发阶段然后对照标准Set1表逐键验证。不要假设你的映射表是正确的——不同QEMU版本、不同宿主机键盘布局都可能产生差异。本章小结与下一步今天我们赋予了操作系统“耳朵”和“嘴巴”✅ 实现了低延迟、安全的PS/2键盘中断驱动✅ 构建了带行编辑功能的TTY输入子系统✅ 创建了可工作的命令行Shell与命令分发框架✅ 完成了“人类输入→OS处理→结果输出”的完整交互闭环从此你的操作系统不再是一个被动执行的程序集合而是一个能够响应人类意图、按需执行任务的交互式系统。当你第一次在自制Shell中输入ls看到文件列表、输入cat读到文件内容时那种“它在听我说话”的感觉是OS开发中最珍贵的体验之一。下一章预告《进程管理完善fork/wait/exec三位一体》当前Shell运行在内核态exec只是演示性的ELF加载。下一章将实现完整的fork()、wait()、exec()系统调用让Shell真正能够在用户态创建子进程、等待其结束、并无缝替换执行新程序达成Unix进程管理的经典范式。参考资料OSDev Wiki - PS/2 Keyboard / Text Mode CursorIntel SDM Vol.1 Chapter 18 (Input/Output)Linux Kernel:drivers/input/keyboard/atkbd.c,drivers/tty/xv6 Source:console.c,sh.c本系列完整代码[你的GitHub仓库链接]Commit:s1h2e3l作者注这是《从零手写操作系统》系列的第12篇。Shell是整个教程中“成就感密度”最高的章节——之前十几章积累的所有基础设施中断、进程、文件系统、ELF在这里第一次汇聚成一个你可以亲手操作的界面。如果你被键盘扫描码折磨得怀疑人生请记住Linus Torvalds在1991年也是对着同样的Set1表逐个敲出来的。建议先用QEMU的sendkey命令精确控制输入排除物理键盘干扰确认驱动无误后再切换到真实键盘测试。下一章我们让Shell获得真正的“生育能力”
返回列表