背景

在 DOS 时代,键盘和鼠标的交互不像现在有统一的操作系统 API(Windows 的 GetMessage,Linux 的 read),而是需要程序员直接与 BIOS 中断和硬件端口打交道。Turbo C(TC)作为当年最流行的 C 语言开发环境,提供了一套封装好的库函数(conio.hdos.h),但理解其底层机制对于移植老程序、调试奇怪问题至关重要。

这篇文章从硬件层到库函数层,系统梳理 TC 时代的键盘鼠标编程。


第一部分:键盘编程

1.1 键盘硬件架构

IBM PC 的键盘控制器是 Intel 8042 芯片(或兼容芯片),通过以下方式与 CPU 通信:

组件 说明
I/O 端口 0x60 数据端口,读取扫描码(scan code)
I/O 端口 0x64 状态/命令端口
IRQ 1 中断 键盘按下/释放时触发硬件中断
BIOS INT 16h BIOS 提供的键盘服务中断

扫描码(Scan Code)

键盘每按下或释放一个键,都会向 CPU 发送一个扫描码

  • 按下(Make Code):正值,例如 ESC = 0x01A = 0x1E
  • 释放(Break Code):按下码 + 0x80,例如 A释放 = 0x9E

扫描码与 ASCII 码完全不同——扫描码是物理按键位置,ASCII 码是字符含义。Shift+A 和单独的 A 发送相同的扫描码 0x1E,但 BIOS 会根据 Shift 状态转换成不同的 ASCII 码('A' vs 'a')。


1.2 BIOS INT 16h 键盘服务

BIOS 提供了标准化的键盘访问接口,通过 INT 16h 中断调用:

功能号(AH) 功能说明 返回值
0x00 等待并读取一个按键 AH=扫描码,AL=ASCII码
0x01 检查键盘缓冲区是否有按键(不等待) ZF=1 无按键,ZF=0 有按键
0x02 读取键盘状态字节 AL=状态(Shift/Ctrl/Alt等)
0x10 增强键盘:等待并读取(支持F11/F12等) AH=扫描码,AL=ASCII码
0x11 增强键盘:检查缓冲区 ZF=1 无按键,ZF=0 有按键

键盘状态字节(INT 16h, AH=02h)

1
2
位7  位6  位5  位4  位3  位2  位1  位0
INS  CAPS NUM  SCRL ALT  CTRL LSHF RSHF
  • 位0:右 Shift 按下
  • 位1:左 Shift 按下
  • 位2:Ctrl 按下
  • 位3:Alt 按下
  • 位4:Scroll Lock 激活
  • 位5:Num Lock 激活
  • 位6:Caps Lock 激活
  • 位7:Insert 模式

1.3 TC 的键盘库函数(conio.h)

Turbo C 提供了方便的封装函数,底层都是调用 INT 16h。

int kbhit(void)

检查键盘缓冲区是否有按键(非阻塞)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <conio.h>

int main() {
    while (1) {
        if (kbhit()) {
            printf("有键按下!\n");
            getch();  // 取走按键
        }
        // 其他工作...
    }
    return 0;
}

底层实现

1
2
3
4
mov ah, 0x01    ; INT 16h 功能 01h
int 0x16        ; 调用 BIOS
jz no_key       ; ZF=1 表示无按键
; 有按键...

int getch(void)

等待并读取一个字符(不回显)。

1
2
3
4
5
6
7
8
int ch = getch();
if (ch == 0) {
    // 功能键(F1-F12、方向键等),需要再读一次
    int ext = getch();  // 扩展码
    printf("功能键扩展码: %d\n", ext);
} else {
    printf("ASCII 字符: %c\n", ch);
}

功能键的双字节机制

  • 第一次 getch() 返回 0(标记"这是功能键")
  • 第二次 getch() 返回扩展码(scan code)
按键 第一次返回 第二次返回(扩展码)
F1 0 59 (0x3B)
F10 0 68 (0x44)
0 72 (0x48)
0 80 (0x50)
0 75 (0x4B)
0 77 (0x4D)
ESC 27 (0x1B)

int getche(void)

等待并读取一个字符(带回显),相当于 getch() + putch()

1
2
3
4
5
6
printf("请输入密码: ");
while (1) {
    char ch = getch();  // 不回显,用于密码输入
    if (ch == '\r') break;
    putch('*');  // 显示星号
}

1.4 直接读取硬件端口(端口 0x60)

对于实时性要求极高的场景(游戏、CAD),可以绕过 BIOS,直接读取键盘控制器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <dos.h>

unsigned char read_scancode() {
    return inportb(0x60);  // 从端口 0x60 读取扫描码
}

int main() {
    unsigned char code;
    while (1) {
        code = read_scancode();
        if (code == 0x01) {  // ESC 键的扫描码
            printf("ESC 被按下\n");
            break;
        }
        if (code & 0x80) {
            printf("按键释放: %02X\n", code & 0x7F);
        } else {
            printf("按键按下: %02X\n", code);
        }
    }
    return 0;
}

注意:直接读端口会绕过 BIOS 的缓冲区,可能导致按键丢失或重复。生产代码需要配合 IRQ 1 中断处理程序使用。


1.5 键盘中断劫持(高级技巧)

游戏和 TSR 程序(驻留程序)常用这个技术来实现热键、按键记录等功能。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <dos.h>

void interrupt (*old_keyboard_handler)();  // 保存原中断向量

void interrupt new_keyboard_handler() {
    unsigned char scancode = inportb(0x60);
    
    if (scancode == 0x3B) {  // F1 键
        printf("F1 热键被触发!\n");
    }
    
    // 调用原中断处理程序
    (*old_keyboard_handler)();
}

int main() {
    // 保存原中断向量
    old_keyboard_handler = getvect(0x09);  // IRQ 1 对应 INT 09h
    
    // 安装新中断
    setvect(0x09, new_keyboard_handler);
    
    printf("按 F1 测试,按 ESC 退出\n");
    while (getch() != 27);  // 等待 ESC
    
    // 恢复原中断
    setvect(0x09, old_keyboard_handler);
    return 0;
}

IRQ 与中断号的对应关系

  • IRQ 0(定时器)→ INT 08h
  • IRQ 1(键盘)→ INT 09h
  • IRQ 6(软盘)→ INT 0Eh

第二部分:鼠标编程

2.1 鼠标硬件与驱动架构

DOS 下的鼠标不是标准硬件,需要加载鼠标驱动程序(通常是 MOUSE.COMMOUSE.SYS)。驱动加载后,会在内存中注册一个软件中断 INT 33h,所有鼠标操作都通过这个中断完成。

1
硬件鼠标 → 串口/PS/2接口 → 鼠标驱动(MOUSE.COM)→ INT 33h → 应用程序

检查鼠标驱动是否存在

1
2
3
4
5
6
7
8
#include <dos.h>

int mouse_installed() {
    union REGS regs;
    regs.x.ax = 0x00;  // INT 33h 功能 00h:初始化鼠标
    int86(0x33, &regs, &regs);
    return (regs.x.ax == 0xFFFF);  // AX=FFFFh 表示驱动存在
}

2.2 INT 33h 鼠标服务

功能号(AX) 功能说明 输入 输出
0x00 初始化鼠标 AX=FFFFh(驱动存在),BX=按钮数
0x01 显示鼠标指针
0x02 隐藏鼠标指针
0x03 获取鼠标位置和按钮状态 BX=按钮,CX=X坐标,DX=Y坐标
0x04 设置鼠标位置 CX=X,DX=Y
0x07 设置水平移动范围 CX=最小X,DX=最大X
0x08 设置垂直移动范围 CX=最小Y,DX=最大Y
0x0C 设置鼠标事件回调函数 CX:DX=函数地址

2.3 基本鼠标操作示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <dos.h>
#include <graphics.h>

void init_mouse() {
    union REGS regs;
    regs.x.ax = 0x00;
    int86(0x33, &regs, &regs);
}

void show_mouse() {
    union REGS regs;
    regs.x.ax = 0x01;
    int86(0x33, &regs, &regs);
}

void hide_mouse() {
    union REGS regs;
    regs.x.ax = 0x02;
    int86(0x33, &regs, &regs);
}

void get_mouse(int *x, int *y, int *button) {
    union REGS regs;
    regs.x.ax = 0x03;
    int86(0x33, &regs, &regs);
    
    *button = regs.x.bx;  // 位0=左键,位1=右键,位2=中键
    *x = regs.x.cx;
    *y = regs.x.dx;
}

int main() {
    int gd = DETECT, gm;
    initgraph(&gd, &gm, "");
    
    init_mouse();
    show_mouse();
    
    int x, y, btn;
    while (1) {
        get_mouse(&x, &y, &btn);
        
        if (btn & 1) {  // 左键按下
            circle(x, y, 5);
        }
        
        if (btn & 2) {  // 右键按下
            break;
        }
        
        delay(10);
    }
    
    closegraph();
    return 0;
}

2.4 鼠标坐标系统

DOS 鼠标的坐标与图形模式有关:

模式 分辨率 鼠标坐标范围 说明
文本模式(80x25) 640x200(虚拟) X: 0-639, Y: 0-199 每个字符占 8x8 像素
CGA 图形(320x200) 320x200 X: 0-319, Y: 0-199 1:1 映射
EGA 图形(640x350) 640x350 X: 0-639, Y: 0-349 1:1 映射
VGA 图形(640x480) 640x480 X: 0-639, Y: 0-479 1:1 映射

注意:在文本模式下,需要将鼠标坐标除以 8 才能得到字符位置:

1
2
int char_x = mouse_x / 8;
int char_y = mouse_y / 8;

2.5 鼠标事件回调(中断驱动)

对于实时应用(游戏、CAD),可以注册一个鼠标事件处理函数,避免轮询:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <dos.h>

void interrupt mouse_handler() {
    // AX = 事件类型
    // BX = 按钮状态
    // CX = X 坐标
    // DX = Y 坐标
    
    // 这里可以记录鼠标状态到全局变量
}

void register_mouse_handler() {
    union REGS regs;
    struct SREGS sregs;
    
    regs.x.ax = 0x0C;       // 功能 0Ch:设置回调
    regs.x.cx = 0x001F;     // 监听所有事件(移动+按钮)
    regs.x.dx = FP_OFF(mouse_handler);
    sregs.es = FP_SEG(mouse_handler);
    
    int86x(0x33, &regs, &regs, &sregs);
}

事件掩码(CX 参数)

  • 位0:鼠标移动
  • 位1:左键按下
  • 位2:左键释放
  • 位3:右键按下
  • 位4:右键释放

2.6 鼠标指针的自定义

DOS 鼠标驱动支持自定义光标形状(16x16 位图):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
void set_mouse_cursor() {
    union REGS regs;
    struct SREGS sregs;
    
    // 定义一个箭头光标(简化示例)
    unsigned int cursor_mask[16] = {
        0xFFFF, 0xFFFE, 0xFFFC, 0xFFF8,
        0xFFF0, 0xFFE0, 0xFFC0, 0xFF80,
        0xFF00, 0xFE00, 0xFC00, 0xF800,
        0xF000, 0xE000, 0xC000, 0x8000
    };
    
    regs.x.ax = 0x09;  // 功能 09h:设置图形光标
    regs.x.bx = 0;     // 热点 X
    regs.x.cx = 0;     // 热点 Y
    regs.x.dx = FP_OFF(cursor_mask);
    sregs.es = FP_SEG(cursor_mask);
    
    int86x(0x33, &regs, &regs, &sregs);
}

第三部分:综合应用示例

3.1 简单的绘图程序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <graphics.h>
#include <dos.h>
#include <conio.h>

int main() {
    int gd = DETECT, gm;
    initgraph(&gd, &gm, "");
    
    // 初始化鼠标
    union REGS regs;
    regs.x.ax = 0x00;
    int86(0x33, &regs, &regs);
    regs.x.ax = 0x01;  // 显示鼠标
    int86(0x33, &regs, &regs);
    
    int x, y, btn, last_x = -1, last_y = -1;
    
    outtextxy(10, 10, "Left: Draw | Right: Exit | Space: Clear");
    
    while (1) {
        // 检查键盘
        if (kbhit()) {
            int key = getch();
            if (key == ' ') {
                cleardevice();
                outtextxy(10, 10, "Left: Draw | Right: Exit | Space: Clear");
            }
        }
        
        // 读取鼠标
        regs.x.ax = 0x03;
        int86(0x33, &regs, &regs);
        btn = regs.x.bx;
        x = regs.x.cx;
        y = regs.x.dx;
        
        if (btn & 1) {  // 左键按下
            if (last_x != -1) {
                setcolor(WHITE);
                line(last_x, last_y, x, y);
            }
            last_x = x;
            last_y = y;
        } else {
            last_x = -1;
        }
        
        if (btn & 2) {  // 右键退出
            break;
        }
        
        delay(10);
    }
    
    closegraph();
    return 0;
}

3.2 菜单选择界面

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <stdio.h>
#include <conio.h>

void draw_menu() {
    clrscr();
    printf("=== 主菜单 ===\n");
    printf("1. 新建文件\n");
    printf("2. 打开文件\n");
    printf("3. 保存文件\n");
    printf("4. 退出\n");
    printf("\n请选择 (按数字键或方向键): ");
}

int main() {
    int choice = 1;
    int key;
    
    while (1) {
        draw_menu();
        gotoxy(1, 1 + choice);
        printf(">>");  // 光标
        
        key = getch();
        
        if (key == 0) {  // 功能键
            key = getch();  // 读扩展码
            if (key == 72) choice--;  // ↑
            if (key == 80) choice++;  // ↓
        } else if (key >= '1' && key <= '4') {
            choice = key - '0';
            break;
        } else if (key == 13) {  // 回车
            break;
        }
        
        if (choice < 1) choice = 4;
        if (choice > 4) choice = 1;
    }
    
    printf("\n\n你选择了选项 %d\n", choice);
    return 0;
}

第四部分:移植到 DJGPP 的注意事项

4.1 API 差异

TC 函数 DJGPP 替代 说明
kbhit() kbhit() 需要 <conio.h>,DJGPP 有兼容实现
getch() getch() 同上
int86() __dpmi_int() DPMI 接口,需要 <dpmi.h>
inportb() inportb() 需要 <pc.h>
setvect() _go32_dpmi_allocate_iret_wrapper() 保护模式中断复杂得多

4.2 DJGPP 的 INT 33h 调用示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <dpmi.h>
#include <sys/farptr.h>

int mouse_installed() {
    __dpmi_regs regs;
    regs.x.ax = 0x00;
    __dpmi_int(0x33, &regs);
    return (regs.x.ax == 0xFFFF);
}

void get_mouse(int *x, int *y, int *btn) {
    __dpmi_regs regs;
    regs.x.ax = 0x03;
    __dpmi_int(0x33, &regs);
    
    *btn = regs.x.bx;
    *x = regs.x.cx;
    *y = regs.x.dx;
}

总结

TC 键盘编程三层架构

1
2
3
4
5
应用层:   kbhit() / getch() / getche()
BIOS层:   INT 16h(标准化接口)
硬件层:   端口 0x60 + IRQ 1 中断

TC 鼠标编程依赖链

1
2
3
4
5
应用层:   自己封装的 get_mouse() 等函数
驱动层:   INT 33h(MOUSE.COM 提供)
硬件层:   串口/PS2 接口 → 鼠标控制器

关键要点

  1. 键盘:优先用 kbhit()/getch(),性能要求高时才直接读端口 0x60
  2. 鼠标:必须先加载 MOUSE.COM,然后通过 INT 33h 访问
  3. 功能键:双字节机制,第一次返回 0,第二次返回扫描码
  4. 中断劫持:高级技术,需要保存/恢复原中断向量,谨慎使用
  5. DJGPP 移植:用 __dpmi_int() 代替 int86(),中断处理改用 DPMI 包装器

参考资料

  • IBM Personal Computer Technical Reference Manual
  • Ralf Brown’s Interrupt List(INT 16h/INT 33h 完整文档)
  • Turbo C User’s Guide(Borland 官方手册)
  • DJGPP DPMI Programming Guide
  • DOS编程实战(中文经典书籍)

相关阅读