背景
在 DOS 时代,键盘和鼠标的交互不像现在有统一的操作系统 API(Windows 的 GetMessage,Linux 的 read),而是需要程序员直接与 BIOS 中断和硬件端口打交道。Turbo C(TC)作为当年最流行的 C 语言开发环境,提供了一套封装好的库函数(conio.h、dos.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 = 0x01,A = 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.COM 或 MOUSE.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, ®s, ®s);
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, ®s, ®s);
}
void show_mouse() {
union REGS regs;
regs.x.ax = 0x01;
int86(0x33, ®s, ®s);
}
void hide_mouse() {
union REGS regs;
regs.x.ax = 0x02;
int86(0x33, ®s, ®s);
}
void get_mouse(int *x, int *y, int *button) {
union REGS regs;
regs.x.ax = 0x03;
int86(0x33, ®s, ®s);
*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, ®s, ®s, &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, ®s, ®s, &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, ®s, ®s);
regs.x.ax = 0x01; // 显示鼠标
int86(0x33, ®s, ®s);
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, ®s, ®s);
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, ®s);
return (regs.x.ax == 0xFFFF);
}
void get_mouse(int *x, int *y, int *btn) {
__dpmi_regs regs;
regs.x.ax = 0x03;
__dpmi_int(0x33, ®s);
*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 接口 → 鼠标控制器
|
关键要点
- 键盘:优先用
kbhit()/getch(),性能要求高时才直接读端口 0x60
- 鼠标:必须先加载
MOUSE.COM,然后通过 INT 33h 访问
- 功能键:双字节机制,第一次返回 0,第二次返回扫描码
- 中断劫持:高级技术,需要保存/恢复原中断向量,谨慎使用
- 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编程实战(中文经典书籍)
相关阅读