背景

Borland 时代 TC(Turbo C)系列的图形库依赖 BGI(Borland Graphics Interface)驱动文件(.BGI)实现跨显卡支持。这套机制在 20 世纪 80 年代是相当超前的——本质上就是"显卡驱动 + 图形框架接口合体"的架构,允许开发者在不改动图形库源码的前提下,通过替换不同的驱动文件来适配各种硬件。

然而,这些 BGI 文件包含的是 16 位实模式 x86 机器码,在现代环境(DJGPP 32 位保护模式、VESA VBE 高分辨率显示)上无法直接运行。我们有几千行基于 TC3 的老程序要迁移到 DJGPP + 高分辨率 VESA 模式,没有办法只能理解 BGI 驱动的底层设计思想,重新实现一套兼容接口。

这篇文章通过反汇编 EGAVGA.BGI 驱动文件,揭示其硬件交互细节,为后续的现代化兼容层实现奠定基础。

EGAVGA.BGI 文件结构

EGAVGA.BGI 的文件头包含了驱动的元数据和对外暴露的函数表。完整的二进制解析结果如下:

1
2
3
4
总文件大小: 5554 字节
Banner: "pk\x08\x08BGI Device Driver (EGAVGA) 2.00 - Mar 21 1988"
        "Copyright (c) 1987,1988 Borland International"
Banner长度: 98 字节

这段 banner 本身很有意思——pk\x08\x08 这个魔数序列是 Borland 的私有标记,用来识别 BGI 驱动文件。\x08\x08 对应了两个"退格"字符,可能是古早工具的产物。

文件的实质是一段可重定位的 16 位实模式机器码,包含:

  • 数据段(驱动状态、显示模式表、颜色调色板等)
  • 代码段(初始化、硬件访问、像素绘制等函数)
  • 导出函数表(图形库运行时通过中断或远调用跳转到这些函数)

BIOS 中断调用分析

BGI 驱动对硬件的初始化和模式切换全部通过 INT 10h(视频服务中断)实现。反汇编总共发现了 5 次 INT 10h 调用,按执行顺序和用途分类如下:

位置 功能码 (AH/AL) 功能说明 用途
0x14b AH=5 Select Active Display Page 选择显示页码(用于页交换动画)
0x1bb AH=0x10, AL=0x00 Set Individual Palette Register 探测/初始化调色板(EGA/VGA 通用)
0x1c9 AH=0x10, AL=0x10 Set Individual DAC Register VGA 专属功能,硬件探测(EGA vs VGA 区分)
0x3da AH=0x10, AL=0 Set Palette Register 正式设置调色板,参数来自驱动内部数据表
0x636 AH=0, AL=[0x4e5] Set Video Mode 关键调用:切换到显示模式,模式号从驱动数据表读取(0x10 = EGA 640x350 16色)

硬件探测逻辑

驱动在初始化时先后调用了两个不同的"设置调色板"子功能:

  • AL=0x00:EGA/VGA 都支持
  • AL=0x10:仅 VGA 支持

通过这个差异,驱动可以在运行时识别当前显卡是 EGA 还是 VGA。这是 1980 年代的经典检测手法——有些硬件特性不是通过状态寄存器读取,而是通过"尝试调用,不出错就说明支持"来反推的。

显卡寄存器直接操作

一旦进入图形模式,BIOS 太慢了(每次中断有几千个 CPU 周期的开销)。驱动改为直接操作 EGA/VGA 的 Graphics Controller 寄存器,绕过 BIOS。

这是关键部分。EGA/VGA 的像素操作采用了一套复杂的位面寄存器系统(planar memory architecture),4 个 1-bit 位面分别存储红(R)、绿(G)、蓝(B)、亮度(I)四个分量,8 个像素打包成一个字节。

关键寄存器组(Graphics Controller,I/O 端口 0x3CE/0x3CF)

寄存器索引 名称 反汇编中的用法
0 Set/Reset 预置要写入的颜色值
1 Enable Set/Reset 指定哪些位面参与 Set/Reset(通常设为 0x0F = 全 4 位面)
5 Mode Register 切换写模式(0/1/2),影响 latch 电路的工作方式
8 Bit Mask 掩码,指定字节内的哪些比特位会被写入

putpixel(画单像素)的硬件级实现

驱动的画点函数遵循教科书式的 EGA latch 读改写算法

 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
1. 设置 GC 寄存器 0(Set/Reset)= 要写入的颜色值
   mov dx, 0x3ce
   mov al, 0
   out dx, al
   inc dx
   mov al, <color>
   out dx, al

2. 设置 GC 寄存器 1(Enable Set/Reset)= 0x0F(全 4 位面启用)
   dec dx
   mov al, 1
   out dx, al
   inc dx
   mov al, 0x0F
   out dx, al

3. 设置 GC 寄存器 8(Bit Mask)= 目标像素的位掩码
   dec dx
   mov al, 8
   out dx, al
   inc dx
   mov al, <bit_mask>  ; 1 << (pixel_x % 8)
   out dx, al

4. 对显存执行"哑读"(加载该字节到 latch 电路)
   mov ax, [video_mem + offset]

5. 对同一地址执行"写"(latch 电路根据上述参数只改变指定比特)
   mov [video_mem + offset], al

Latch 电路详解

Latch 是 EGA/VGA 显卡上的一个硬件缓冲电路,用来加速位面像素的读改写操作。它的工作原理:

  1. 读取时:当 CPU 从显存地址读取一个字节时,硬件会同时从 4 个位面(R/G/B/I)的同一偏移地址读出 4 个字节,存入 latch 寄存器的 4 个 latch 单元中,但只把其中一个(由 Read Plane Select 寄存器选定的位面)返回给 CPU。这样 4 个位面的数据都被"锁存"住了,不会被后续操作破坏。

  2. 写入时:Bit Mask 寄存器指定了本次写操作只影响哪些比特位。硬件根据 Set/Reset 寄存器的值和 Enable Set/Reset 寄存器的掩码,把 latch 中已有的数据和新值通过逻辑电路混合,最后只把 Bit Mask 选中的位写回显存。

为什么这套电路这么关键?

考虑画一个像素的场景:一个字节存 8 个像素,如果要改其中一个像素的颜色,传统做法是:

  • ① CPU 读字节
  • ② CPU 在寄存器中修改那一位
  • ③ CPU 写回字节

这需要 3 步操作,而且读出的数据可能在读和写之间被其他设备(DMA、刷新电路)改掉(竞态条件)。

EGA latch 的方案是:

  • ① CPU 读字节 → latch 同时锁存 4 个位面的数据
  • ② 硬件根据 Set/Reset、Bit Mask 等寄存器设置直接在 latch 内部修改
  • ③ CPU 写字节 → 使用 latch 中已修改过的数据

这样只需 2 步 CPU 操作,且修改发生在硬件逻辑内,不受其他设备干扰。在 CPU 频率远低于现在的年代,这是一种关键的性能优化。

这套算法的巧妙之处在于:利用硬件 latch 电路,只用一次读+一次写就能改单个比特,而不需要从内存读出整字节、手工修改某一位、再写回(如果没有 latch,需要 3 步操作且容易出现竞态条件)。

批量写入优化(写模式 2)

对于画线(line)和填充(bar)等需要连续写入的操作,驱动切换到 Write Mode 2,配合 CPU 的字节操作直接批量写入。

1
2
3
4
5
mov al, 5           ; 选 Mode Register
out dx, al
inc dx
mov al, 2           ; 切换到 Write Mode 2
out dx, al

Write Mode 2 下,CPU 写入的每个字节的值直接被解释为"这 8 个像素对应的颜色值",硬件自动完成 8 个像素的同步更新,大幅加速批量绘制。

端口 I/O 的访问模式

反汇编统计了所有的 IN/OUT 端口指令

1
2
3
Graphics Controller 索引端口(0x3CE): 11 次
Graphics Controller 数据端口(0x3CF): 992 次(主要是写入操作)
其他端口: 无

0x3CF 的 992 次访问说明驱动的大部分执行时间花在了改写 GC 寄存器上——这正是画点/画线的核心瓶颈。现代 CPU 上端口 I/O 已经被虚拟化/模拟,性能相对无关紧要,但在 1980 年代的 8086/286/386 上,直接操作这些寄存器是追求画图速度的必然选择。

总结:BGI 驱动的架构设计

这个 EGAVGA.BGI 驱动体现了当年 Borland 对图形系统设计的理解:

  1. 分层架构

    • 图形库内核(graphics.lib): 坐标变换、直线算法、字体渲染等通用逻辑,独立于硬件
    • 驱动(.BGI 文件): 纯硬件相关操作(模式切换、寄存器操作),可热插拔
  2. BIOS 作为"初始化工具"

    • 使用 INT 10h 做"一次性"的事(切换模式、初始化调色板)
    • 初始化完成后,绘制循环全部绕过 BIOS,直接操作硬件寄存器
  3. 微观优化

    • 利用 EGA/VGA 的 latch 电路加速单像素写入
    • 批量操作时切换写模式降低单像素的指令数
    • 最小化端口 I/O 次数

迁移到 DJGPP 和 VESA 的障碍

16 位实模式 BGI 驱动无法在 DJGPP(32 位保护模式)上直接运行的原因:

方面 16 位实模式(BGI) 32 位保护模式(DJGPP)
内存模型 段式寻址(CS:IP) 平坦内存空间,32 位线性地址
中断机制 实模式 INT 向量表 保护模式 IDT,需要 DPMI
显卡访问 直接端口 I/O(ring 0) 需要 DPMI 代理(__dpmi_int)
显存映射 固定地址 0xA0000 需要 __dpmi_physical_address_mapping 映射
显示模式 BIOS INT 10h(实模式) DPMI INT 10h 模拟(需 __dpmi_int)
高分辨率 最高 640x480(VGA) VESA VBE 3.0,1024x768+ 甚至 4K

最后一点最关键:EGA/VGA 的位面寄存器架构根本无法支持现代的高色深显示。VESA 模式普遍采用 LFB(线性帧缓冲)——一个像素直接对应内存中的 1~4 个字节(8-bit 调色板、16-bit 真彩色、24/32-bit 真彩色),没有复杂的位面寄存器逻辑。

迁移方案(预告)

鉴于现有 BGI 文件的架构限制,我们无法"反编译后直接复用"其二进制代码。正确的做法是:

  1. 理解 BGI 的接口契约(如本文所示)
  2. 重新实现一套 graphics.h 兼容层,接口完全遵循 BGI,但底层用 VESA VBE + DJGPP DPMI 实现
  3. 可选:设计新的驱动文件格式(比如 .VBD),允许加载不同的显示后端(VESA、FrameBuffer、SDL 等),保持 BGI 时代"可热插拔驱动"的优势

现有代码里 BGI 调用范围很小(只用 17 个函数),工作量可控。


参考资料

  • Borland Graphics Interface Developer’s Guide
  • VGA/EGA 硬件手册(Graphics Controller 寄存器定义)
  • DJGPP 文档:DPMI 服务、物理地址映射
  • VESA VBE 3.0 规范

相关阅读