编写-MSDOS-系统笔记-全-
编写 MSDOS 系统笔记(全)
001:开发环境搭建与第一个程序
概述
在本节课中,我们将学习如何为古老的 MS-DOS 系统搭建一个 C 语言开发环境,并编写、编译和运行一个经典的“Hello World”程序。我们将使用 DOSBox 模拟器来创建一个复古的开发环境。

开发环境搭建

上一节我们介绍了本系列课程的目标,本节中我们来看看如何搭建开发环境。由于并非每个人都拥有一台老式 PC,我们将从模拟器开始。
选择模拟器:DOSBox
最著名的 MS-DOS 模拟器是 DOSBox。它是开源且免费的,支持多种操作系统。
以下是获取和安装 DOSBox 的步骤:
- 下载:访问 DOSBox 官网,根据你的操作系统(Windows、macOS、Linux)下载对应的安装包或可执行文件。
- 安装:Windows 用户运行安装程序;macOS 用户可以将应用拖入“应用程序”文件夹;Linux 用户通常可以通过包管理器安装。
- 文档:DOSBox 拥有一个内容丰富的 Wiki,包含大量配置文档,建议查阅。



启动 DOSBox 后,你会看到一个模拟的 DOS 命令行界面。
选择编程语言:Turbo C 2.0
我们将使用 Turbo C 2.0 作为编程语言。它体积小巧,其核心概念对现代编程语言(如 Java、JavaScript、C++)仍有借鉴意义。
以下是获取和安装 Turbo C 2.0 的步骤:
- 获取:由于原版已下线,可通过互联网档案馆的 Wayback Machine 找到并下载 Turbo C 2.0 的 ZIP 文件。
- 准备文件:解压 ZIP 文件,你会得到三个磁盘镜像目录(DISK1, DISK2, DISK3)。为方便起见,将所有文件复制到同一个文件夹(例如
TC文件夹)中。 - 在 DOSBox 中安装:
- 使用
mount命令将你的TC文件夹挂载为 DOSBox 的 A 盘(软盘驱动器)。 - 在 DOSBox 中切换到 A 盘,运行
install.exe。 - 在安装程序中,选择源驱动器为 A,并将 Turbo C 安装到硬盘(例如 C 盘的
\TC目录)。
- 使用
- 创建工作目录:安装完成后,在 C 盘创建一个用于存放代码的目录,例如
\letscode。注意 DOS 的文件名限制为 8.3 格式(主文件名最多 8 个字符,扩展名最多 3 个字符)。


编写第一个 C 程序
环境搭建完毕,现在我们来编写第一个程序。我们将使用 Turbo C 的集成开发环境(IDE)。
启动 Turbo C IDE
在 DOSBox 中,切换到 Turbo C 的安装目录(例如 C:\TC),然后运行 tc.exe 启动 IDE。这个 IDE 界面简洁,包含菜单栏、编辑区和消息窗口。
理解 C 程序的基本结构
C 语言程序由函数构成,其中必须有一个名为 main 的主函数。它是程序的入口点。
一个基本的 main 函数结构如下:
int main()
{
/* 你的代码写在这里 */
return 0;
}
int表示该函数返回一个整数。main()是函数名和参数列表(目前为空)。{和}之间的部分是函数体,包含要执行的语句。return 0;表示程序正常结束,向操作系统返回 0。
输出“Hello World”
为了在屏幕上显示文字,我们需要使用 C 标准库中的 printf 函数。在使用它之前,需要包含对应的头文件。
以下是完整的“Hello World”程序代码:
#include <stdio.h>
int main()
{
printf("Hello World!\n");
return 0;
}
#include <stdio.h>:这条预处理指令告诉编译器包含标准输入输出头文件,这样我们才能使用printf函数。printf("Hello World!\n");:调用printf函数输出字符串。字符串用双引号包围,\n表示换行。- 每条语句以分号
;结尾。
编译与运行程序
在 Turbo C IDE 中:
- 保存文件:将代码保存到你的工作目录(例如
C:\letscode\),文件名为hello.c。 - 编译:按
F9键进行编译。如果代码无误,会在下方看到“Success”消息。 - 运行:按
Ctrl+F9运行程序。要查看输出,可以按Alt+F5切换到用户屏幕。
你也可以使用命令行编译器 tcc.exe 来编译:
tcc hello.c
这将生成一个 hello.exe 文件,直接在 DOS 命令行中键入 hello 即可运行。

总结
本节课中我们一起学习了为 MS-DOS 搭建 C 语言开发环境的完整流程。我们首先利用 DOSBox 模拟器创建了复古的开发平台,然后安装并配置了 Turbo C 2.0 编译器。最后,我们编写、分析并成功运行了第一个 C 语言程序——“Hello World”,理解了程序的基本结构、头文件包含以及编译运行的过程。这为后续学习更复杂的图形编程和游戏开发打下了基础。在下一节课中,我们将尝试进入图形模式,开始探索如何在 DOS 下进行图形编程。
002:键盘输入
概述
在本节课中,我们将学习如何在MS-DOS程序中读取键盘输入。你将学会如何检测普通按键以及特殊按键(如方向键),并利用这些输入来控制程序流程,为后续开发小游戏打下基础。

上一节我们介绍了如何编写第一个MS-DOS程序并清屏。本节中我们来看看如何与键盘进行交互。
清屏与键盘检测
为了获得干净的输出界面,我们可以使用 conio.h 头文件提供的 clrscr() 函数来清屏。该头文件还提供了控制台输入输出的相关功能。
检测键盘是否有按键被按下,可以使用 kbhit() 函数。它返回一个布尔值,指示是否有按键事件发生,但不会告诉我们具体是哪个键。
以下是清屏和检测按键的基本代码框架:
#include <conio.h>
#include <stdio.h>
int main() {
clrscr(); // 清屏
printf("Hello World\n");
if (kbhit()) {
// 有按键被按下
}
return 0;
}

读取按键代码
要获取具体的按键信息,需要使用 getch() 函数。它会返回被按下键的扫描码。
需要注意的是,普通字符(如字母、数字)返回一个字节的ASCII码。而特殊键(如方向键、功能键)则返回一个两字节的序列:第一个字节是 0,第二个字节是该键特有的扫描码。
我们可以通过一个无限循环来持续读取键盘输入,并在按下特定键(如ESC键)时退出循环。
以下是实现持续读取按键并显示其十六进制代码的示例:
#include <conio.h>
#include <stdio.h>
int main() {
unsigned char key = 0;
clrscr();
while (1) {
if (kbhit()) {
key = getch();
printf("Key pressed: 0x%02X\n", key);
if (key == 0x1B) { // ESC键的代码
break; // 退出循环
}
}
// 此处可放置游戏主逻辑
}
return 0;
}
处理特殊按键
根据上面的知识,我们可以通过判断读取到的第一个字节是否为 0 来识别特殊按键。如果是,则需要再次调用 getch() 读取第二个字节(扫描码)以确定具体是哪个特殊键。
以下是处理方向键等特殊按键的逻辑结构:
#include <conio.h>
#include <stdio.h>
#include <string.h>
int main() {
unsigned char key = 0;
char key_desc[255];
clrscr();
while (key != 0x1B) { // 循环直到按下ESC键
if (kbhit()) {
key = getch();
if (key == 0) {
// 特殊键处理:读取第二个字节
key = getch();
switch (key) {
case 0x48:
strcpy(key_desc, "Up Arrow");
break;
case 0x50:
strcpy(key_desc, "Down Arrow");
break;
// 可以在此添加更多特殊键的case,如左箭头(0x4B)、右箭头(0x4D)
default:
sprintf(key_desc, "Special Key: 0x00%02X", key);
break;
}
} else {
// 普通键处理
sprintf(key_desc, "Normal Key: 0x%02X", key);
}
printf("Key pressed: %s\n", key_desc);
}
}
return 0;
}
总结
本节课中我们一起学习了在MS-DOS环境下进行键盘输入编程的核心知识。

我们掌握了如何使用 conio.h 中的 kbhit() 和 getch() 函数来检测并读取按键。关键点在于理解了普通键与特殊键(返回两字节序列)的区别,并学会了通过 switch 语句来处理不同的特殊键扫描码。
现在,你已经可以编写一个能响应键盘输入(包括方向键)并可控退出的程序框架了。在下一节中,我们将利用这些知识开始构建一个简单的互动游戏。
003:让我们编写MS-DOS 0x03 - VGA显卡
在本节课中,我们将学习如何初始化VGA显卡并绘制图形。我们将从设置视频模式开始,然后了解如何直接操作显存来绘制像素,最后学习如何设置调色板来改变屏幕颜色。
概述
上一节我们处理了文本模式下的输入输出。本节中,我们将进入图形模式。图形编程是一个复杂的主题,但我们会从基础开始。我们将学习如何初始化VGA显卡,以便能够在屏幕上绘制图形。这将是后续动画和更复杂图形的基础。
首先,我们需要理解当前程序运行在标准文本模式下。文本模式下的“窗口”边框实际上是由字符组成的,无法实现真正的彩色图形。为了制作游戏,我们需要切换到VGA的256色图形模式。
设置视频模式
要设置视频模式,我们需要调用PC的BIOS中断服务。BIOS中断是PC访问硬件的封闭源代码API。
具体来说,我们需要使用中断 0x10(视频服务),其功能号 0 用于设置视频模式。
调用中断时,需要通过CPU寄存器传递参数:
- AH寄存器(高字节) 需要设置为
0x00(功能号:设置模式)。 - AL寄存器(低字节) 需要设置为目标模式代码,例如
0x13(对应320x200分辨率,256色模式)。
在Turbo C中,我们可以使用 int86 函数和 REGS 联合体来操作寄存器和调用中断。
以下是设置视频模式的函数代码:
#include <dos.h>
#define VIDEO_INT 0x10
#define SET_MODE 0x00
#define VGA_256_COLOR_MODE 0x13
#define TEXT_MODE 0x03
void set_video_mode(uint8_t mode) {
union REGS regs;
regs.h.ah = SET_MODE;
regs.h.al = mode;
int86(VIDEO_INT, ®s, ®s);
}
在程序开始时,我们调用 set_video_mode(VGA_256_COLOR_MODE) 进入图形模式。在程序退出前,调用 set_video_mode(TEXT_MODE) 切换回文本模式,这是一个好习惯。



绘制像素:理解显存布局
设置好图形模式后,下一步是绘制像素。为此,我们需要了解VGA显存在内存中的布局。
在模式 0x13 下,显存起始于内存地址 0xA0000。屏幕分辨率是320像素宽,200像素高。每个像素用一个字节表示,该字节是调色板的颜色索引(0-255),而不是直接的RGB值。

由于8086CPU使用分段内存寻址,我们需要使用远指针来访问这个内存区域。以下是如何定义指向VGA显存的指针:
unsigned char far *vga = (unsigned char far *)0xA0000000L;
有了这个指针,我们就可以通过计算偏移量来设置特定坐标的像素颜色。将二维坐标转换为一维内存偏移量的公式是:
偏移量 = y * 屏幕宽度 + x

以下是设置和获取像素的宏:
#define SCREEN_WIDTH 320
#define SCREEN_HEIGHT 200
#define SET_PIXEL(x, y, color) (vga[(y) * SCREEN_WIDTH + (x)] = (color))
#define GET_PIXEL(x, y) (vga[(y) * SCREEN_WIDTH + (x)])
注意:这些宏不会检查坐标是否越界。错误的写入可能会破坏系统,因此需要小心。
现在,我们可以编写一个函数来绘制背景。例如,绘制一个彩色渐变:
void draw_background() {
int x, y;
for (y = 0; y < SCREEN_HEIGHT; y++) {
for (x = 0; x < SCREEN_WIDTH; x++) {
SET_PIXEL(x, y, y); // 使用行号作为颜色索引,产生垂直渐变
}
}
}
运行此代码,你会看到基于默认VGA调色板的彩虹渐变。


设置自定义调色板
默认的256色调色板以16种标准CGA颜色开始,然后是灰度渐变,最后是不同饱和度的彩虹色。为了获得更美观的颜色(例如蓝天绿地),我们需要自定义调色板。
VGA调色板寄存器使用6位精度(0-63)表示每个RGB通道。我们通过I/O端口来设置它们。
以下是相关的端口和步骤:
- 端口
0x3C8:写入要修改的调色板颜色索引。 - 端口
0x3C9:依次写入该颜色的R、G、B值(每个值范围0-63)。
在Turbo C中,使用 outportb 函数向I/O端口写入数据。
首先,我们创建一个函数来生成一个“天空”调色板数组:
#include <alloc.h>
#define NUM_COLORS 256
uint8_t* get_sky_palette() {
int i;
uint8_t *pal = (uint8_t*)malloc(NUM_COLORS * 3); // 为RGB值分配内存
if (!pal) return NULL;
// 上半部分(天空):蓝色渐变
for (i = 0; i < 100; i++) {
pal[i * 3 + 0] = i / 2; // 红色分量
pal[i * 3 + 1] = i / 2; // 绿色分量
pal[i * 3 + 2] = i; // 蓝色分量(递增)
}
// 下半部分(草地):绿色渐变
for (i = 100; i < NUM_COLORS; i++) {
pal[i * 3 + 0] = 10; // 少量红色
pal[i * 3 + 1] = 63 - (i - 100) / 4; // 绿色递减
pal[i * 3 + 2] = 10; // 少量蓝色
}
return pal;
}
然后,编写函数将调色板数组写入VGA硬件:
#define PALETTE_INDEX 0x3C8
#define PALETTE_DATA 0x3C9
void set_palette(uint8_t *pal) {
int i;
outportb(PALETTE_INDEX, 0); // 从索引0开始设置
for (i = 0; i < NUM_COLORS * 3; i++) {
outportb(PALETTE_DATA, pal[i]); // 依次写入所有RGB值
}
}

在主函数中,调用这些函数来应用新的调色板:
uint8_t *my_palette = get_sky_palette();
if (my_palette) {
set_palette(my_palette);
free(my_palette); // 释放分配的内存
}
draw_background(); // 现在绘制背景会使用新的调色板颜色
运行程序,你将看到一个由自定义调色板渲染出的、带有蓝天和草地的渐变背景。
总结
本节课中我们一起学习了VGA图形编程的基础知识。
我们首先学习了如何通过BIOS中断 0x10 设置视频模式,从文本模式切换到320x200分辨率、256色的图形模式。
接着,我们了解了VGA显存的布局,学会了如何定义远指针来访问显存地址 0xA0000,并编写了宏来根据坐标设置和获取像素。

最后,我们探索了VGA调色板的工作原理。通过I/O端口 0x3C8 和 0x3C9,我们能够自定义256种颜色的RGB值,从而将默认的彩虹渐变调色板替换为更符合游戏场景的天空和草地色调。

你现在已经掌握了在MS-DOS下初始化图形模式、绘制像素和控制颜色的基本技能。下一节,我们将利用这些知识来绘制更复杂的图形并让它们动起来。
004:让画面动起来!


概述
在本节课中,我们将学习如何绘制游戏中的可移动对象(玩家挡板),并为其添加动画效果。我们将涵盖从内存中保存和恢复背景、处理玩家输入、绘制图形到屏幕,以及利用垂直回扫期消除画面闪烁等核心概念。
回顾与目标
上一节我们搭建了编码环境,学习了如何处理按键,以及如何初始化VGA显卡、设置调色板并绘制背景。到目前为止,这还算不上一个游戏。在本节中,我们将在屏幕上绘制一些东西。虽然内容会很简单,但我们仍有许多基础知识需要掌握。我们还将为绘制在屏幕上的内容添加动画。同样,动画会很简单,但你会学到一些基本概念,以便日后构建更复杂的游戏。
定义玩家状态
首先,我们需要让这个程序更像一个游戏。这个游戏将会有两个玩家。我们暂时不确定是让其中一个由电脑控制,还是让两个玩家都用键盘操作。随着课程的进行,我们会看到,这其实并不重要。
在 main 函数中,我们需要维护玩家的状态。我们称他们为玩家P1和P2。我们需要稍后定义玩家是什么,但让我们先说说要对它们做什么。
每个玩家都有一种颜色。为了简单起见,我将使用颜色255,因为这是调色板中的最后一个颜色。背景已经使用了前几百种颜色,所以我直接使用最后一个颜色。
每个玩家还有一些关联的几何属性:高度和宽度。假设高度为30像素,宽度为5像素(高而薄)。玩家还有一个位置,包含X坐标和Y坐标。我们将X坐标设为一个较小的值。Y坐标则设在屏幕中间:用屏幕高度除以2,再减去玩家高度的一半,这样就能精确地居中显示。
玩家还会有分数,我们稍后需要追踪,不过现在暂时不需要。
此外,当我们在屏幕上绘制东西时,会破坏它下面的原有内容。有不同的方法可以解决这个问题。我们将使用最简单的一种:保存玩家移动前其所在位置的背景,当玩家移动后再将其恢复回去。对于这类游戏,这种方法可行;对于其他游戏,可能会更复杂,需要使用不同的算法。但现在,这就够了。
我们需要分配内存来保存背景。我们需要多少字节呢?基本上就是玩家的大小(宽度乘以高度)。分配的内存最初会包含垃圾数据。为了清晰起见,我们将使用 memset 将分配的所有字节设置为一个预定义的值,比如颜色0。虽然我们不会使用这个初始备份,但为了安全起见,这样做可以避免屏幕上出现垃圾数据。
memset 很简单:给它一个指向我们想要设置的内存区域的指针(也就是 malloc 返回的指针),告诉它要使用的字符(我们想用字符0),然后是内存区域的大小(我们分配的大小)。
对于第二个玩家,我们只需复制整个代码块,并将 P1 手动替换为 P2。玩家的位置应该不同,我们希望把它放在右侧,所以取屏幕宽度减去玩家P2的宽度再减5,这样它就从边框开始。这应该能编译,但目前还不行,因为我们还没有定义 player 结构。
定义玩家结构
在C语言中,我们不能使用对象,但我们可以使用 struct(结构体)。结构体基本上类似于C++或Java中的类,但它不能包含函数,它只是变量的集合。
我们将为结构体定义一个别名:typedef struct player。这就是新的类型 player。
它包含什么?它有一个颜色(color),类型是 byte,因为VGA颜色是字节。它有一个位置(x, y),如果我们使用 int,这将是16位整数,足以寻址整个屏幕。它还有宽度(width)和高度(height),我们可以将其限制为字节,但这里问题不大。它有一个分数(score)。它还有一个备份缓冲区(backup),这是一个指向屏幕字节的指针。这样就完成了。
现在代码应该可以编译了,但会有两个关于未初始化变量的警告。我们定义了结构,但屏幕上还不会显示任何东西,因为我们还没有绘制。
设计游戏循环
我们有一个主循环,只要不按ESC键就会一直运行。我们循环检查是否有按键。但步骤是什么?在每一个绘制循环中,我们想要恢复玩家下方原有的内容。我们在这里做这件事:restore_player(P1) 和 restore_player(P2)。这样屏幕就处于一个正常的状态。
然后我们处理所有的键盘输入。处理完键盘输入后,我们可能需要更新状态并进行其他绘制。然后,我们存储玩家当前位置下的新内容:store_player(P1) 和 store_player(P2)。接着,我们根据玩家新的状态绘制内容。
这样就把所有功能分解成了相当小的、我们可以理解和推理的函数。
实现玩家移动逻辑
首先,我们来实现键盘处理。我们不需要处理特殊键,所以去掉其他键的处理代码。当我们按下“上”键时,我们希望玩家向上移动。基本上就是将玩家P1的 y 坐标减去2。然后我们需要检查是否移出了屏幕:如果 y 小于0,我们就设 y 等于0,这样就从屏幕顶部截断了。
对于“下”键,我们做类似的操作:给当前玩家的 y 坐标加2。如果 y 加上玩家高度大于屏幕高度,我们就设 y 等于屏幕高度减去玩家高度,这样就从底部截断了。
现在我们已经有了一些游戏逻辑:可以让玩家上下移动了。
实现存储与恢复函数
store_player 函数需要做什么?我们需要进行一些块传输(blitting)。在游戏编程中,这被称为“位块传输”(Bit Blitting)。像Amiga这样的电脑有专门的芯片来做这件事,但PC没有,所以我们将进行一些内存拷贝。
我们将为此创建一个实用函数,称之为 blit_to_mem,因为我们将把数据从VGA帧缓冲区传输到我们自己分配的内存(备份缓冲区)中。这个函数将目标指针(玩家的备份缓冲区)、玩家的当前坐标以及玩家的宽度和高度作为参数。
blit_to_mem 函数会是什么样子?它将获取一个指向目的地的指针。我们在小内存模型中,只有64K的代码和数据,所有指针都是近指针(near pointers)。但 malloc 可能返回远指针(far pointers)。为了安全起见,也为了稍后实现 blit_to_vga(这肯定需要远指针,因为VGA内存在完全不同的段中),我们将使用远指针。
函数还需要X、Y坐标以及宽度和高度。我们需要逐行扫描。我们需要计算源指针(指向VGA内存)和目的指针。源指针是VGA缓冲区指针加上Y坐标乘以屏幕宽度再加上X坐标。目的指针就是用户提供给我们的指针。
然后我们可以开始迭代,从Y到Y+高度,遍历所有扫描线。我们需要一个能处理远指针的函数。标准C的 memcpy 只接受 void* 指针,在小内存模型中就是近指针,所以这不行。但是,有一个函数叫 movedata,它接受段和偏移量。

我们可以这样使用 movedata:movedata(source_segment, source_offset, dest_segment, dest_offset, num_bytes)。如何获取段和偏移量?我们可以使用 FP_SEG 和 FP_OFF 宏来从远指针中提取段和偏移量。
在每次 movedata 调用后,我们需要递增源指针和目的指针。对于源指针(VGA),我们加上屏幕宽度以跳到下一行。对于目的指针(备份缓冲区),我们加上玩家宽度以跳到下一行。
blit_to_vga 函数与此类似,只是方向相反:源是备份缓冲区,目的是VGA内存。迭代逻辑和指针递增方式也相应调整。
实现绘制函数
draw_player 函数在这个游戏中很简单:我们将绘制一个矩形。它直接绘制到VGA帧缓冲区。我们调用一个 draw_rectangle 函数,传入坐标X和Y、宽度、高度以及颜色。
绘制矩形很简单:我们遍历从Y到Y+高度的所有扫描线,对于每一行,再遍历从X到X+宽度的所有像素。对于每个像素,我们计算其在VGA缓冲区中的地址:VGA + y * SCREEN_WIDTH + x,然后将颜色值存储到该地址。
消除画面闪烁
现在代码可以编译运行了,但画面会闪烁。闪烁发生是因为我们清除了玩家原来的位置,然后重新绘制它。屏幕刷新速度很快,我们会看到这个过程的一部分。
在老式的阴极射线管显示器上,电子束扫描屏幕,当它到达底部时,会回扫到顶部。在这段回扫期间,不显示任何图像。我们可以利用这个间隙进行所有的重绘。
我们需要查询VGA的状态寄存器。端口 0x3DA 有一个状态寄存器。读取这个端口并查看第3位(十六进制值 0x08),可以告诉我们是否处于垂直回扫期。当这一位为1时,表示正在回扫。
我们将编写一个 wait_for_retrace 函数。它首先循环,直到回扫位为0(确保我们不在回扫中),然后循环直到回扫位变为1(等待回扫开始)。一旦我们退出这个循环,我们就知道有大约1.25毫秒的安全时间可以进行绘制。
我们在每个绘制循环的开始调用 wait_for_retrace 函数。这样,所有的存储、恢复和绘制操作都在垂直回扫期内完成,从而消除了闪烁。
调整与优化
现在玩家可以移动了,但可能移动得太慢。我们可以增加每次按键移动的像素数(例如从2改为4)。我们也可以调整玩家的宽度,让它更容易看到。颜色也可以从默认值进行更改,这可以通过修改调色板设置函数来实现。
总结

在本节课中,我们一起学习了如何为MS-DOS游戏创建可移动的玩家对象。我们定义了玩家的数据结构,实现了从VGA内存到备份缓冲区的块传输(blit_to_mem)以及反向传输(blit_to_vga)。我们编写了绘制矩形的基本函数,并处理了键盘输入来控制玩家移动。最重要的是,我们通过利用垂直回扫期进行绘制,成功消除了屏幕闪烁问题。现在,我们有了一个可以上下平滑移动的玩家挡板,为下一节课实现真正的游戏逻辑(如球和计分)打下了基础。
005:游戏机制实现
概述
在本节课中,我们将为之前创建的MS-DOS双人挡板程序添加核心游戏机制。我们将引入一个球体,并实现其移动、与挡板的碰撞检测以及简单的得分逻辑。通过本节课,你将学习如何在低层环境中构建基本的游戏交互循环。
从静态挡板到动态游戏
上一节我们实现了两个可由玩家控制的挡板。本节中,我们来看看如何引入一个球体,并让它与挡板互动,从而形成一个类似经典“Pong”游戏的雏形。
我们需要一个球体。我们已经有了用于玩家的结构体,它包含颜色、位置、高度、分数以及用于备份图形内存的字段。我们将复用这个结构体来表示球体。
虽然使用面向对象编程会更优雅,但我们现在只有一个结构体的副本。为了保持代码简洁,我们不会重命名这个结构体,它仍然叫player,但我们会为球体添加两个新字段:dx和dy,代表球在X轴和Y轴上的移动速度。
// 在player结构体中添加速度字段(仅球体使用)
int dx; // X轴速度
int dy; // Y轴速度
初始化游戏对象
在我们的主函数中,我们将添加一个球体对象。实际上,我们的变量名players现在包含了两个玩家和一个球,或许改名为game_objects更合适。
以下是初始化球体的代码:
// 初始化球体
ball.color = 15; // 白色
ball.width = 5;
ball.height = 5;
ball.x = SCREEN_WIDTH / 2; // 屏幕中央
ball.y = SCREEN_HEIGHT / 2;
ball.dx = 1; // 初始X速度
ball.dy = 1; // 初始Y速度
ball.score = 0; // 球体不使用分数
与玩家挡板一样,我们也需要为球体备份其所在位置的原始图形内存,以便在每个游戏循环中正确地擦除和重绘。
这样做的好处是,我们可以使用同一个draw函数来绘制所有游戏对象(玩家和球体),这非常方便。
更新球体位置与碰撞检测
现在,球体已经出现在屏幕中央,但它还不会动。我们需要在每个游戏循环中根据其速度更新球的位置,并检测它是否被任一挡板接住,或者是否碰到了屏幕边界。
我们需要一个处理球体逻辑的函数。它接收球体指针以及两个玩家结构体的指针作为参数。
void handle_ball(struct player *b, struct player *p1, struct player *p2) {
// 1. 移动球体
b->x += b->dx;
b->y += b->dy;
// 2. 碰撞检测与响应逻辑(见下文)
}
实现碰撞逻辑
以下是碰撞检测的核心逻辑步骤。我们需要检查球体是否碰到了屏幕的左右边界(与玩家相关)或上下边界。
检查与玩家1(左侧)的碰撞
如果球的X坐标小于某个边界(例如玩家挡板的宽度),我们需要判断它是否被玩家1的挡板接住。
if (b->x < PADDLE_BORDER) {
if (does_ball_hit_player(b, p1)) {
// 被接住:球反向弹回
b->dx = -b->dx;
} else {
// 未被接住:玩家2得分,重置球的位置和速度
p2->score++;
reset_ball(b);
// 重置速度,例如让球飞向玩家2
b->dx = 1;
}
}
检查与玩家2(右侧)的碰撞
逻辑与左侧对称,但方向相反。
if (b->x > (SCREEN_WIDTH - PADDLE_BORDER - b->width)) {
if (does_ball_hit_player(b, p2)) {
b->dx = -b->dx;
} else {
p1->score++;
reset_ball(b);
b->dx = -1; // 球飞向玩家1
}
}
检查与屏幕上下边界的碰撞
如果球碰到屏幕顶部或底部,它应该反弹。
if (b->y < TOP_BORDER || b->y > (SCREEN_HEIGHT - BOTTOM_BORDER)) {
b->dy = -b->dy;
}
实现挡板命中检测函数
does_ball_hit_player函数是关键。我们需要检查球的Y坐标范围是否与挡板的Y坐标范围重叠。
int does_ball_hit_player(struct player *b, struct player *p) {
// 检查球的垂直范围是否与挡板的垂直范围相交
if ( (b->y >= p->y) && (b->y <= (p->y + p->height)) ) {
return 1; // 命中
}
return 0; // 未命中
}
在实际测试中,我们可能发现碰撞检测不够精确(例如球似乎“嵌入”了挡板)。这时需要调整检测逻辑,将球的宽度和高度也考虑进去,进行更精确的矩形碰撞检测。
优化键盘控制与游戏速度
在游戏循环中,我们处理键盘输入来控制两个挡板。为了更清晰地区分普通键和特殊键(如方向键),我们可以对键码进行移位处理。
int key_code = get_key();
if (key_code == 0) {
// 这是一个特殊键(如方向键)
key_code = get_key() << 8; // 移位使其成为唯一整数
}
// 根据key_code更新玩家1(例如方向键)和玩家2(例如W/S键)
switch(key_code) {
case UP_ARROW_CODE: p1->y -= PADDLE_SPEED; break;
case DOWN_ARROW_CODE: p1->y += PADDLE_SPEED; break;
case 'w': p2->y -= PADDLE_SPEED; break;
case 's': p2->y += PADDLE_SPEED; break;
}
我们可能注意到游戏运行速度不理想。可以通过调整挡板移动速度PADDLE_SPEED和球速来改善手感。同时,我们使用了wait_for_retrace来同步垂直回扫,避免闪烁,但这可能限制了最大帧率。更高级的优化(如页翻转)将在未来的章节探讨。
总结
本节课中我们一起学习了如何为MS-DOS图形程序添加核心游戏机制。我们引入了可移动的球体,实现了其位置更新、与屏幕边界及玩家挡板的碰撞检测,并建立了简单的得分逻辑。同时,我们优化了键盘控制,使两位玩家可以分别使用不同的按键进行游戏。
目前,游戏已具备基本可玩性,但尚未显示分数和游戏结束状态。在下一节(可能是本系列的最后一节),我们将添加计分显示、游戏结束判定,并可能对代码进行性能优化。
006:VGA调色板技巧与代码重构 🎮
概述
在本节课中,我们将学习如何为我们的MS-DOS游戏添加更平滑的视觉效果,特别是通过操作VGA调色板来实现淡入淡出效果。同时,我们还将对现有代码进行重构,使其结构更清晰、更易于维护。
上一节我们实现了双人游戏的基本框架,但视觉效果和代码结构仍有提升空间。本节中我们来看看如何优化它们。
淡入效果实现 🎨
首先,我们希望游戏启动时屏幕能平滑地淡入,而不是突然出现。这可以通过在绘制任何内容之前,先将调色板设置为全黑来实现。
以下是实现淡入效果的核心步骤:
- 在绘制背景前,将调色板所有颜色值设置为0(黑色)。
- 绘制背景图像。
- 逐步将调色板颜色值从0增加到目标值,实现淡入。
为了实现这个效果,我们需要一个自定义的fade_in_palette函数。其核心逻辑是循环递增每个颜色分量,直到达到目标值。
void fade_in_palette(unsigned char* target_pal) {
unsigned char current_pal[768];
memset(current_pal, 0, sizeof(current_pal)); // 初始化为全黑
for (int step = 0; step < 64; step++) {
wait_for_retrace(); // 同步到垂直刷新,控制速度
outportb(0x3C8, 0); // 告诉VGA从颜色索引0开始写入
for (int i = 0; i < 768; i++) {
if (current_pal[i] < target_pal[i]) {
current_pal[i]++; // 递增当前颜色值
}
outportb(0x3C9, current_pal[i]); // 写入VGA调色板寄存器
}
}
}
淡出效果实现 🌒

同样地,在游戏退出时,我们希望屏幕能平滑地淡出到黑色。淡出是淡入的逆过程。
以下是实现淡出效果的核心步骤:
- 从当前调色板状态开始。
- 逐步将调色板所有颜色值递减至0。
我们创建fade_out_palette函数,其逻辑与淡入相反。
void fade_out_palette(unsigned char* current_pal) {
for (int step = 0; step < 64; step++) {
wait_for_retrace();
outportb(0x3C8, 0);
for (int i = 0; i < 768; i++) {
if (current_pal[i] > 0) {
current_pal[i]--; // 递减当前颜色值
}
outportb(0x3C9, current_pal[i]);
}
}
}
通过这两个函数,我们实现了平滑的视觉过渡,显著提升了游戏的观感。
代码重构:分离游戏逻辑 🔧
目前,所有游戏逻辑都集中在main函数中,导致其冗长且难以管理。为了提高代码的可读性和可维护性,我们将游戏逻辑提取到一个独立的函数中。
重构的目标是创建一个handle_game函数,它负责所有游戏状态的管理和更新。
以下是重构的核心思路:
- 分离职责:
main函数负责程序流程(如模式设置、调色板操作、调用游戏循环),handle_game函数负责具体的游戏逻辑(玩家移动、球体运动、胜负判断)。 - 状态保持:使用
static关键字确保游戏状态(如玩家位置、分数)在多次调用handle_game时得以保留。 - 明确返回值:
handle_game函数通过返回值来告知主程序游戏状态(例如,哪个玩家获胜、是否退出)。
重构后的main函数结构如下:
void main() {
// 1. 初始化图形模式
set_video_mode(0x13);
// 2. 保存原始调色板并设置为黑色
unsigned char original_palette[768];
get_palette(original_palette);
set_black_palette();
// 3. 绘制背景
draw_background();
// 4. 淡入到原始调色板
fade_in_palette(original_palette);
int game_result = 0;
// 5. 游戏主循环
do {
wait_for_retrace(); // 控制帧率
game_result = handle_game(game_result == 0); // 处理游戏逻辑,参数表示是否是新游戏
} while (game_result == 0); // 当游戏未结束且未退出时继续
// 6. 游戏结束,淡出并恢复文本模式
fade_out_palette(original_palette);
set_video_mode(0x03);
}
而handle_game函数则封装了之前main函数中的游戏循环、输入检测、球拍和球的运动逻辑。通过返回值(如0-进行中,1-玩家1胜,2-玩家2胜,3-退出)来与主循环通信。
总结
本节课中我们一起学习了两个重要的技巧:
- VGA调色板动画:通过直接读写VGA颜色寄存器,实现了高效的屏幕淡入和淡出效果,极大地改善了游戏的视觉体验。
- 代码重构:我们将庞杂的
main函数拆分为职责清晰的模块,将游戏逻辑独立到handle_game函数中。这使得代码结构更清晰,为后续添加计分、游戏结束判定等功能打下了良好的基础。
现在,我们的游戏不仅看起来更专业,代码也更容易扩展和维护。在下一节课中,我们将为游戏添加计分系统和简单的游戏结束画面,让它成为一个更完整的作品。
007:游戏结束!
在本节课中,我们将完成之前开始的游戏项目。我们将添加游戏结束屏幕和胜利条件,并学习如何在图形模式下打印文本。最后,我们会实现一个带有动画效果的胜利/结束界面。

概述与准备工作

上一节我们实现了游戏的核心循环和动画。本节中,我们来看看如何判定游戏结束并向玩家展示结果。
首先,我们需要定义一些常量并完善视频模式设置。
定义最大分数
我们设定一个最大分数,当玩家达到这个分数时,将触发游戏结束。
#define MAX_SCORE 5

完善调色板处理

为了在文本打印时使用标准的VGA颜色,我们需要读取并保留VGA卡上默认的前16种颜色,而不是覆盖它们。
// 读取默认的VGA前16色到我们的调色板缓冲区
for(i = 0; i < 16; i++) {
outportb(PALETTE_READ_INDEX, i); // 设置要读取的颜色索引
palette[i*3] = inportb(PALETTE_DATA); // 读取红色分量
palette[i*3+1] = inportb(PALETTE_DATA); // 读取绿色分量
palette[i*3+2] = inportb(PALETTE_DATA); // 读取蓝色分量
}
// 我们自己的游戏颜色从索引16开始设置
for(i = 16; i < 256; i++) {
// ... 设置游戏颜色
}
同时,修改绘制背景的函数,使其从颜色索引16开始绘制,以避开前16种系统颜色。
在图形模式下打印文本

在MS-DOS图形模式下,标准的C库printf函数会导致屏幕滚动,并不适合在固定位置显示信息。我们需要使用BIOS中断来手动控制光标和字符打印。
以下是实现此功能的两个关键BIOS函数:
- 设置光标位置:中断
0x10,功能0x02。 - 写字符及属性:中断
0x10,功能0x09。

实现打印函数
由于直接使用BIOS的“写字符串”功能(功能0x13)在Turbo C环境下会遇到寄存器冲突问题,我们选择逐个字符打印的方式。
以下是print_text函数的核心逻辑:
void print_text(int x, int y, char color, char far *str) {
int i;
int len = strlen(str);
for(i = 0; i < len; i++) {
// 1. 设置光标位置 (AH=0x02)
union REGS r;
r.h.ah = 0x02; // 功能号:设置光标位置
r.h.bh = 0x00; // 显示页码(图形模式为0)
r.h.dh = y; // 行 (Y坐标)
r.h.dl = x + i;// 列 (X坐标,随字符递增)
int86(0x10, &r, &r);
// 2. 写字符及属性 (AH=0x09)
r.h.ah = 0x09; // 功能号:写字符及属性
r.h.al = str[i]; // 要打印的字符
r.h.bh = 0x00; // 显示页码
r.h.bl = color; // 字符属性(前景色)
r.x.cx = 1; // 重复次数(打印1个字符)
int86(0x10, &r, &r);
}
}

在游戏中显示分数

现在,我们可以在游戏循环中使用这个函数来显示双方玩家的分数。
首先,创建一个格式化的字符串:
char buffer[256];
sprintf(buffer, "Player 1: %03d Player 2: %03d", score_p1, score_p2);
print_text(0, 0, 0x0F, buffer); // 0x0F是亮白色
然后,在handle_game函数中,当分数更新时(例如球出界后),调用上述代码刷新分数显示。为了避免每帧都调用缓慢的BIOS中断,我们只在分数实际发生变化时才更新文本。


实现游戏结束逻辑
我们需要在handle_ball函数中检测分数是否达到了MAX_SCORE。
检测胜利条件


在球出界并加分后,加入胜利条件检查:

int handle_ball(...) {
// ... 原有的球移动和碰撞逻辑 ...
// 如果球出界,给对应玩家加分
if (ball_x <= 0) {
score_p2++;
// 检查玩家2是否获胜
if (score_p2 >= MAX_SCORE) {
return 2; // 返回胜利者代码:2
}
reset_ball();
} else if (ball_x >= SCREEN_WIDTH) {
score_p1++;
// 检查玩家1是否获胜
if (score_p1 >= MAX_SCORE) {
return 1; // 返回胜利者代码:1
}
reset_ball();
}
return 0; // 游戏继续
}

创建游戏结束处理函数
在主游戏循环中,当handle_ball返回非零值(1或2)时,表示游戏结束。我们调用一个专门的handle_game_over函数。
这个函数将:
- 清屏或保持当前画面。
- 在屏幕中央打印获胜者信息。
- 实现一个颜色闪烁的动画效果。
- 等待玩家按下空格键后退出。
以下是handle_game_over函数的核心部分:
void handle_game_over(int winner) {
char *msg1, *msg2;
char buffer[256];
int i = 0, dir = 1; // i用于控制颜色强度,dir控制变化方向
// 根据获胜者设置消息
if (winner == 1) {
msg1 = "Congratulations Player 1!";
} else {
msg1 = "Congratulations Player 2!";
}
msg2 = "Press SPACE to exit";
// 主循环:显示消息并等待按键
while(1) {
// 打印消息
print_text(11, 10, i, msg1); // 使用变化的颜色i
print_text(11, 11, 0x0F, msg2); // 第二行固定为白色
// 简单的颜色脉冲动画
i += dir;
if (i > 62) dir = -1; // 达到最亮,开始变暗
if (i < 1) dir = 1; // 达到最暗,开始变亮
// 更新调色板中“白色”的颜色值以实现闪烁
// 这里简化处理,实际是修改打印颜色对应的调色板条目
// wait_for_retrace(); // 等待垂直回扫以平滑动画
// 检查按键
if (kbhit()) {
if (getch() == ' ') { // 如果按下空格键
break; // 退出循环
}
}
}
}

总结

本节课中我们一起学习了如何为MS-DOS图形模式游戏画上句号。我们主要完成了以下工作:

- 完善了系统集成:通过读取和保留VGA默认调色板,使系统文本颜色与游戏颜色和谐共存。
- 实现了图形模式文本输出:利用BIOS中断
0x10的功能0x02和0x09,我们绕过了标准C库的限制,实现了在像素图形屏幕上精确打印字符的功能。 - 建立了游戏状态显示:使用
sprintf和自定义的print_text函数,在屏幕顶部创建了一个实时更新的分数状态栏。 - 设计了游戏结束流程:通过检测
MAX_SCORE来判定胜负,并引入了一个独立的handle_game_over函数来展示获胜信息。该函数还包含了一个简单的颜色脉冲动画,并等待用户输入后优雅退出。
通过这个完整的项目,我们已经掌握了MS-DOS下使用C语言和BIOS中断进行图形编程、输入处理和动画制作的基本流程。你可以在此基础上,添加更多的功能,如音效、更复杂的图形、鼠标支持或双缓冲技术来创造更流畅、更丰富的游戏体验。
008:使用鼠标
概述
在本节课中,我们将学习如何在MS-DOS环境下,通过汇编语言和中断调用来控制鼠标。我们将编写一个简单的绘图程序,实现鼠标光标的显示、隐藏、位置获取以及通过鼠标按键进行绘图的功能。
鼠标基础与初始化
上一节我们完成了图形模式的设置和键盘输入处理。本节中,我们来看看如何与鼠标进行交互。
鼠标是PC发展后期的产物。最初的PC于1981年诞生,而鼠标在几年后才出现,并且存在多种标准。其中最广泛使用的是微软鼠标标准及其驱动程序。该驱动程序提供了一个API,类似于我们之前使用的图形API,通过中断 0x33 来调用各种鼠标功能。
今天,我们主要使用其中的三个功能:
- 功能0:重置鼠标并检测驱动程序是否安装。
- 功能1:显示鼠标光标。
- 功能2:隐藏鼠标光标。
- 功能3:获取鼠标位置和按键状态。
首先,我们定义一些常量来表示这些中断和功能。
; 鼠标中断号
MOUSE_INT equ 0x33
; 鼠标功能号
MOUSE_INIT equ 0x00
MOUSE_SHOW equ 0x01
MOUSE_HIDE equ 0x02
MOUSE_GET_STATUS equ 0x03
接下来,我们编写初始化鼠标的函数 init_mouse。这个函数会调用中断 0x33 的功能0。
int init_mouse() {
union REGS in, out;
in.x.ax = MOUSE_INIT; // 设置功能号:初始化/重置
int86(MOUSE_INT, &in, &out); // 调用中断 0x33
return (out.x.ax == 0xFFFF); // 如果鼠标存在,AX 寄存器返回 0xFFFF
}

在主函数中,我们可以调用 init_mouse 来检查鼠标是否存在。如果不存在,则打印错误信息并退出。
if (!init_mouse()) {
printf("Mouse not found.\n");
return 1;
}
显示与隐藏鼠标光标


成功初始化鼠标后,我们就可以控制光标的显示了。以下是显示和隐藏鼠标光标的函数。

显示鼠标光标只需调用功能1。


void show_mouse() {
union REGS in;
in.x.ax = MOUSE_SHOW; // 设置功能号:显示光标
int86(MOUSE_INT, &in, NULL);
}
隐藏鼠标光标则调用功能2。
void hide_mouse() {
union REGS in;
in.x.ax = MOUSE_HIDE; // 设置功能号:隐藏光标
int86(MOUSE_INT, &in, NULL);
}


在主循环开始前调用 show_mouse(),并在程序结束返回文本模式前调用 hide_mouse(),就能看到系统默认的鼠标光标在图形界面中移动了。

获取鼠标状态与坐标
现在我们已经可以显示鼠标了,接下来要实现有用的功能:获取鼠标的位置和按键状态,并用它来绘图。
我们将编写 get_mouse 函数,它通过中断 0x33 的功能3来获取信息。

void get_mouse(int *x, int *y, int *left, int *right) {
union REGS in, out;
in.x.ax = MOUSE_GET_STATUS; // 设置功能号:获取状态
int86(MOUSE_INT, &in, &out);
// CX 寄存器包含 X 坐标 (范围 0-639)
// 我们的图形模式是 320x200,所以需要除以2
*x = out.x.cx / 2;
// DX 寄存器包含 Y 坐标 (范围 0-199)
*y = out.x.dx;
// BX 寄存器的第0位是左键状态,第1位是右键状态
*left = out.x.bx & 1;
*right = out.x.bx & 2;
}
注意:标准鼠标接口的坐标范围固定为640x200(CGA分辨率)。即使在我们的320x200模式下,返回的X坐标仍然是0-639,因此需要除以2来适配屏幕。

实现一个简单的绘图程序


有了获取鼠标状态的能力,我们可以创建一个简单的绘图程序。思路是:按住左键时在光标位置画点(设置为白色),按住右键时则擦除(设置为黑色)。
我们定义两个颜色常量,并设置一个变量来跟踪当前绘图颜色。
#define BLACK 0
#define WHITE 15 // 默认调色板中的白色
byte current_color = WHITE;
在主循环中,我们不断获取鼠标状态,并根据按键情况绘图。
int mouse_x, mouse_y, left_button, right_button;
get_mouse(&mouse_x, &mouse_y, &left_button, &right_button);
if (left_button) {
// 在 (mouse_x, mouse_y) 位置用 current_color 画一个点
set_pixel(mouse_x, mouse_y, current_color);
}
if (right_button) {
// 在 (mouse_x, mouse_y) 位置用黑色画一个点(即擦除)
set_pixel(mouse_x, mouse_y, BLACK);
}
但是,这里会遇到一个问题:鼠标驱动程序会保存光标下方的图像,绘制光标,移动时再恢复。如果我们直接在屏幕上画点,这个点很快会被鼠标光标恢复操作覆盖掉。

解决方案是:在画点之前隐藏鼠标光标,画点之后立即显示它。
if (left_button) {
hide_mouse(); // 隐藏光标,防止绘图被覆盖
set_pixel(mouse_x, mouse_y, current_color);
show_mouse(); // 立即重新显示光标
}
// 对右键擦除操作也采用同样的方法

这样,我们就能流畅地使用鼠标进行绘图了。

增加色彩与键盘交互

为了让程序更有趣,我们可以通过键盘来改变绘图颜色。例如,按 + 键增加颜色索引,按 - 键减少颜色索引。
在主循环的键盘处理部分添加以下代码:
if (key_pressed(KEY_PLUS)) { // 假设 KEY_PLUS 是 '+' 键的代码
current_color++;
}
if (key_pressed(KEY_MINUS)) { // 假设 KEY_MINUS 是 '-' 键的代码
current_color--;
}

现在,运行程序时,你不仅可以用鼠标左键绘图、右键擦除,还可以通过 + 和 - 键在16种默认颜色中循环切换,绘制出彩色的图案。

总结
本节课中我们一起学习了在MS-DOS环境下使用鼠标的基本方法。我们掌握了如何通过中断 0x33 来初始化鼠标、显示和隐藏光标,以及获取鼠标的坐标和按键状态。利用这些知识,我们构建了一个简单的交互式绘图程序,并解决了鼠标光标与直接屏幕写入冲突的问题。最后,我们还为程序添加了通过键盘切换颜色的功能。

虽然这只是一个基础示例,但它涵盖了鼠标交互的核心概念。你可以在此基础上扩展功能,例如绘制线条、添加图形菜单或实现文件保存/加载,从而创建更完整的应用程序。
009:检测SoundBlaster声卡
概述
在本节课中,我们将要学习如何检测计算机中是否存在SoundBlaster声卡。这是进行DOS下声音编程的第一步。我们将编写一个C语言程序,通过查询I/O端口和环境变量来识别声卡的基本地址、中断请求线和DMA通道。
准备工作

上一节我们介绍了VGA和键盘编程的基础。本节中我们来看看如何与声卡这类更复杂的硬件交互。首先,我们需要包含必要的头文件并声明一些全局变量和函数。
#include <dos.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <mem.h>
int sb_base = 0;
int sb_irq = 0;
int sb_dma = 0;
void (interrupt far *old_irq)();
int sb_detect();
int sb_reset(unsigned short port);
void sb_init();
代码解释:
dos.h提供了端口输入输出和中断处理的函数。sb_base,sb_irq,sb_dma用于存储检测到的声卡信息。old_irq是一个函数指针,用于保存旧的中断处理程序。sb_detect,sb_reset,sb_init是我们将要实现的函数。
实现检测函数


检测函数 sb_detect 是我们的核心。它的任务是遍历可能的I/O基地址,尝试复位声卡的数字信号处理器,并确认其响应。
以下是检测函数的主要逻辑步骤:
- 遍历可能地址:SoundBlaster声卡可能的基地址是
0x200,0x210,0x220,0x230,0x240,0x250,0x260,0x280。注意0x270通常被其他设备占用,需要跳过。 - 尝试复位:对每个地址,调用
sb_reset函数尝试复位声卡的DSP。 - 解析环境变量:如果复位成功,则从
BLASTER环境变量中解析出中断请求线和DMA通道号。
int sb_detect() {
int temp;
int found = 0;
for(temp = 1; temp <= 8; temp++) {
if(temp != 7) { // 跳过 0x270
unsigned short test_port = 0x200 + (temp << 4);
if(sb_reset(test_port)) {
sb_base = test_port;
found = 1;
break;
}
}
}
if(!found) return 0;
char* blaster = getenv("BLASTER");
if(blaster == NULL) return 0;
// 从 BLASTER 环境变量解析 DMA 通道
for(temp = 0; temp < strlen(blaster); temp++) {
if((blaster[temp] | 32) == 'd') {
sb_dma = blaster[temp + 1] - '0';
break;
}
}
// 从 BLASTER 环境变量解析 IRQ
for(temp = 0; temp < strlen(blaster); temp++) {
if((blaster[temp] | 32) == 'i') {
sb_irq = blaster[temp + 1] - '0';
if(blaster[temp + 2] != ' ') {
sb_irq = sb_irq * 10 + (blaster[temp + 2] - '0');
}
break;
}
}
return 1;
}
实现复位函数
复位函数 sb_reset 负责与声卡硬件进行具体的交互。其原理是向声卡的复位寄存器写入 1,等待短暂时间后再写入 0,然后检查数据端口是否返回正确的就绪信号。
以下是复位操作的步骤:

- 触发复位:向
端口基地址 + 0x6写入1。 - 短暂延迟:等待约3微秒(实际代码中延迟更长以确保稳定)。
- 结束复位:向同一个地址写入
0。 - 检查状态:轮询读取状态端口
端口基地址 + 0xE,直到其最高位为1,表示数据就绪。 - 验证响应:从数据端口
端口基地址 + 0xA读取一个字节,如果其值为0xAA,则表明复位成功且设备是SoundBlaster。

#define SB_RESET_REG 0x6
#define SB_READ_DATA_STATUS_REG 0xE
#define SB_READ_DATA_REG 0xA


int sb_reset(unsigned short port) {
// 1. 写入 1 启动复位
outportb(port + SB_RESET_REG, 1);
delay(10); // 延迟等待
// 2. 写入 0 结束复位
outportb(port + SB_RESET_REG, 0);
delay(10); // 延迟等待
// 3. 轮询状态端口,等待数据就绪
int timeout = 1000;
while(timeout-- > 0) {
if((inportb(port + SB_READ_DATA_STATUS_REG) & 0x80) == 0x80) {
// 4. 数据就绪,读取并验证
if(inportb(port + SB_READ_DATA_REG) == 0xAA) {
return 1; // 成功检测到声卡
}
}
}
return 0; // 超时或响应错误
}


编写主程序进行测试
现在,我们可以编写一个简单的 main 函数来测试我们的检测代码。
int main() {
printf("正在检测SoundBlaster声卡...\n");
if(sb_detect()) {
printf("成功检测到SoundBlaster声卡!\n");
printf("基地址: 0x%X\n", sb_base);
printf("IRQ: %d\n", sb_irq);
printf("DMA: %d\n", sb_dma);
} else {
printf("未检测到SoundBlaster声卡。\n");
}
return 0;
}
编译并运行此程序,如果系统中安装了SoundBlaster兼容声卡且 BLASTER 环境变量设置正确,程序将输出类似以下的信息:
成功检测到SoundBlaster声卡!基地址: 0x220 IRQ: 7 DMA: 1


总结

本节课中我们一起学习了在MS-DOS环境下检测SoundBlaster声卡的基础方法。我们实现了两个关键函数:sb_detect 用于遍历和定位声卡,sb_reset 用于通过硬件端口操作验证设备。我们还学习了如何从 BLASTER 环境变量中解析配置信息。这是进行后续声音播放编程至关重要的第一步。在下一节中,我们将在此基础上设置DMA缓冲区和中断处理程序,为实际播放音频数据做好准备。
010:MS-DOS 0x0A - 单周期SoundBlaster播放!
概述

在本节课中,我们将学习如何为Sound Blaster声卡编写单周期音频播放程序。我们将涵盖中断请求(IRQ)设置、直接内存访问(DMA)缓冲区分配、DMA控制器编程以及如何向Sound Blaster DSP发送命令以播放原始音频数据。
课程内容
上一节我们介绍了如何检测Sound Blaster声卡。本节中,我们将实际编写代码,让声卡播放声音。


为了实现播放功能,我们需要三个主要函数:
- 一个初始化函数,用于设置IRQ和DMA缓冲区。
- 一个播放函数,用于播放单个音频文件。
- 一个清理函数,用于撤销IRQ和DMA的设置。


首先,我们需要一个全局变量来跟踪播放状态。
volatile int playing;
volatile关键字表示该变量可能在任何时候被改变,例如被中断处理程序修改。编译器会因此避免对其进行某些优化。
接下来是初始化函数 SB_init。
void SB_init() {
init_IRQ(); // 设置IRQ处理
assign_DMA_buf(); // 分配DMA缓冲区
SB_enable_speaker(); // 启用声卡扬声器
}
我们需要编写一系列函数。让我们从向DSP写入命令的函数开始,因为我们会频繁用到它。
以下是向Sound Blaster DSP写入命令的步骤。在写入数据之前,必须检查写缓冲区状态端口,确保其为空(即第7位为0)。
void SB_write_DSP(unsigned char cmd) {
while ((inportb(SB_BASE + SB_WRITE_STATUS) & 0x80) != 0) {
// 等待写缓冲区为空
}
outportb(SB_BASE + SB_WRITE_DATA, cmd);
}
这里,SB_WRITE_STATUS 和 SB_WRITE_DATA 都对应寄存器 0x0C。
现在,让我们编写初始化IRQ的函数 init_IRQ。我们还需要一个对应的 deinit_IRQ 函数用于清理。
首先,我们需要保存旧的IRQ处理程序(向量)。我们的IRQ处理程序还需要调用旧的向量,因为可能还有其他设备(如定时器)需要处理这个中断。
对于286及以上CPU引入的高位IRQ(2,10,11),需要特殊处理。IRQ 9与IRQ 2相同,因为它们是级联的。
void init_IRQ() {
if (SB_IRQ == 2) {
old_IRQ_vector = getvect(0x71); // 获取旧向量
setvect(0x71, SB_IRQ_handler); // 设置新向量
} else if (SB_IRQ == 10) {
old_IRQ_vector = getvect(0x72);
setvect(0x72, SB_IRQ_handler);
} else if (SB_IRQ == 11) {
old_IRQ_vector = getvect(0x73);
setvect(0x73, SB_IRQ_handler);
} else {
// 对于普通PC使用的IRQ(如5,7)
old_IRQ_vector = getvect(SB_IRQ + 8);
setvect(SB_IRQ + 8, SB_IRQ_handler);
}
// ... 还需要编程主板上的中断控制器
}
接下来,我们需要编程主板上的可编程中断控制器(PIC)来启用我们的IRQ线。这有点复杂,尤其是对于级联的中断。
if (SB_IRQ == 2 || SB_IRQ == 10 || SB_IRQ == 11) {
// 处理高位IRQ,涉及第二个PIC (0xA1端口)
unsigned char mask = inportb(0xA1);
mask &= ~(1 << (SB_IRQ - 8)); // 清除对应位以启用中断
outportb(0xA1, mask);
// 同时也要启用主PIC上的级联IRQ (IRQ 2)
mask = inportb(0x21);
mask &= ~(1 << 2);
outportb(0x21, mask);
} else {
// 处理低位IRQ
unsigned char mask = inportb(0x21);
mask &= ~(1 << SB_IRQ); // 清除对应位以启用中断
outportb(0x21, mask);
}
现在,让我们编写分配DMA缓冲区的函数 assign_DMA_buf。这是另一个略有技巧的部分。
DMA缓冲区不能跨越64KB的物理“页”边界,这是因为PC中使用的DMA控制器最初是为只能寻址64KB内存的计算机设计的。
void assign_DMA_buf() {
char* temp_buf;
unsigned long linear_addr;
unsigned int page1, page2;
do {
temp_buf = (char*)malloc(32768); // 分配32KB,用于双缓冲(两页)
linear_addr = FP_SEG(temp_buf) * 16 + FP_OFF(temp_buf); // 计算20位线性地址
page1 = linear_addr >> 16; // 第一页的页号
page2 = (linear_addr + 32768 - 1) >> 16; // 第二页的页号
if (page1 != page2) {
// 如果两半不在同一物理页,重新分配
free(temp_buf);
}
} while (page1 != page2); // 直到分配到一个合适的缓冲区
// 保存DMA控制器需要的页和偏移量
DMA_buffer = temp_buf;
DMA_page = page1;
DMA_offset = (unsigned int)linear_addr & 0xFFFF;
}
我们还需要定义相关的全局变量。
char* DMA_buffer;
unsigned int DMA_page;
unsigned int DMA_offset;
清理函数 SB_deinit 相对简单,我们复制初始化函数的结构并反向操作。
void SB_deinit() {
SB_disable_speaker(); // 关闭扬声器输出
free(DMA_buffer); // 释放DMA缓冲区
deinit_IRQ(); // 取消IRQ设置
}

deinit_IRQ 函数需要恢复旧的IRQ向量,并重新屏蔽PIC上的中断位。

void deinit_IRQ() {
// 恢复旧的IRQ向量
if (SB_IRQ == 2) {
setvect(0x71, old_IRQ_vector);
} else if (SB_IRQ == 10) {
setvect(0x72, old_IRQ_vector);
} else if (SB_IRQ == 11) {
setvect(0x73, old_IRQ_vector);
} else {
setvect(SB_IRQ + 8, old_IRQ_vector);
}
// 在PIC上重新屏蔽(禁用)我们的IRQ线
if (SB_IRQ == 2 || SB_IRQ == 10 || SB_IRQ == 11) {
unsigned char mask = inportb(0xA1);
mask |= (1 << (SB_IRQ - 8)); // 设置对应位以禁用中断
outportb(0xA1, mask);
// 主PIC上的级联IRQ (IRQ 2) 通常保持启用,但这里也恢复
mask = inportb(0x21);
mask |= (1 << 2);
outportb(0x21, mask);
} else {
unsigned char mask = inportb(0x21);
mask |= (1 << SB_IRQ); // 设置对应位以禁用中断
outportb(0x21, mask);
}
}
我们还需要编写IRQ处理程序 SB_IRQ_handler。根据Sound Blaster文档,我们需要从DSP读取数据以确认中断。
void interrupt SB_IRQ_handler() {
inportb(SB_BASE + SB_READ_DATA_STATUS); // 读取DSP状态,确认中断
// 通知PIC中断已处理
outportb(0x20, 0x20); // 主PIC
if (SB_IRQ == 2 || SB_IRQ == 10 || SB_IRQ == 11) {
outportb(0xA0, 0x20); // 从PIC(如果使用高位IRQ)
}
playing = 0; // 对于单周期播放,标记播放结束
}



启用和禁用扬声器的命令很简单。
#define SB_ENABLE_SPEAKER 0xD1
#define SB_DISABLE_SPEAKER 0xD3
现在,进入核心的播放函数 SB_play。这个函数负责打开文件、读取数据、设置采样率并启动播放。
void SB_play(const char* filename) {
FILE* file;
long file_size;
// 先将DMA缓冲区静音,防止噪音
memset(DMA_buffer, 0, 32768);
file = fopen(filename, "rb");
if (!file) return;
// 获取文件大小
fseek(file, 0, SEEK_END);
file_size = ftell(file);
rewind(file);
// 读取整个文件(假设文件小于DMA缓冲区)
fread(DMA_buffer, 1, file_size, file);
fclose(file);
// 设置Sound Blaster的播放采样率(例如11kHz)
SB_set_sample_rate(11000);
// 设置要播放的字节数,并启动单周期播放
to_be_played = file_size;
SB_single_cycle_play(file_size);
}
设置采样率的函数需要计算一个时间常数。
void SB_set_sample_rate(unsigned int rate) {
unsigned int time_constant = 256 - (1000000 / rate);
SB_write_DSP(0x40); // 设置时间常数命令
SB_write_DSP(time_constant);
}

最后,是最关键的 SB_single_cycle_play 函数,它负责编程DMA控制器并命令DSP开始播放。


首先,我们需要定义DMA控制器的一些端口地址。


// DMA控制器端口定义(以通道1为例)
#define DMA_MASK_REG 0x0A
#define DMA_MODE_REG 0x0B
#define DMA_CLEAR_FF_REG 0x0C
#define DMA_ADDR_REG(ch) (0x00 + ((ch) * 2))
#define DMA_COUNT_REG(ch) (0x01 + ((ch) * 2))
#define DMA_PAGE_REG(ch) ((ch)==0?0x87:((ch)==1?0x83:0x82))
void SB_single_cycle_play(unsigned int length) {
playing = 1; // 标记开始播放
// 1. 屏蔽DMA通道
outportb(DMA_MASK_REG, 0x04 | SB_DMA_CHANNEL);
// 2. 清除字节指针触发器
outportb(DMA_CLEAR_FF_REG, 0);
// 3. 设置DMA传输模式(单周期读,通道自动初始化关闭)
outportb(DMA_MODE_REG, 0x48 | SB_DMA_CHANNEL); // 模式 0x48: 单周期,读传输
// 4. 设置DMA缓冲区地址(偏移量)
outportb(DMA_ADDR_REG(SB_DMA_CHANNEL), DMA_offset & 0xFF); // 低字节
outportb(DMA_ADDR_REG(SB_DMA_CHANNEL), (DMA_offset >> 8) & 0xFF); // 高字节
// 5. 设置DMA缓冲区地址(页)
outportb(DMA_PAGE_REG(SB_DMA_CHANNEL), DMA_page & 0xFF);
// 6. 设置传输字节数(长度-1,因为从0计数)
unsigned short count = length - 1;
outportb(DMA_COUNT_REG(SB_DMA_CHANNEL), count & 0xFF); // 低字节
outportb(DMA_COUNT_REG(SB_DMA_CHANNEL), (count >> 8) & 0xFF); // 高字节
// 7. 取消屏蔽DMA通道,允许传输
outportb(DMA_MASK_REG, SB_DMA_CHANNEL);
// 8. 命令Sound Blaster DSP开始单周期播放
SB_write_DSP(0x14); // 8位单周期DAC模式命令
SB_write_DSP(length & 0xFF); // 长度低字节
SB_write_DSP((length >> 8) & 0xFF); // 长度高字节
}
主函数 main 将调用这些函数。


int main() {
SB_init();
SB_play("sound.raw"); // 播放一个11kHz,8位无符号的原始音频文件
while (playing) {
// 等待播放完成(由中断处理程序将playing设为0)
}
SB_deinit();
return 0;
}
总结

本节课中,我们一起学习了如何为Sound Blaster声卡实现单周期音频播放。
我们学习了如何初始化IRQ处理并分配符合DMA控制器要求的缓冲区。
我们深入了解了如何编程PC的DMA控制器来进行单周期播放,这个过程有些复杂。
我们还学习了如何注销IRQ处理程序。
最后,我们编写了读取原始音频文件并播放的完整流程。
下一节课,我们将学习如何播放更长的文件以及进行更复杂的操作,例如自动初始化模式(双缓冲播放)。由于我们已经完成了大部分困难的工作,下一节将会轻松许多。
011:SoundBlaster 自动初始化DMA播放

概述
在本节课中,我们将学习如何为 Sound Blaster 声卡实现自动初始化DMA(Auto-Init DMA)播放模式。上一节我们介绍了单周期播放,它只能播放一小段音频。本节中,我们将通过双缓冲技术实现连续、长时间的音频播放。
从单周期播放到自动初始化播放
上一节我们学习了如何进行单周期播放。这意味着我们编程了PC的DMA控制器,为Sound Blaster卡提供PCM数据,即可以播放数字声音的波形。具体来说,我们可以播放一个DMA缓冲区大小的声音,根据DMA缓冲区大小(例如32KB),在11kHz采样率下大约能播放3秒的音频。这显然不够。有时我们需要连续播放语音或音乐。为此,我们需要切换到一种略有不同的模式。单周期播放对此并不适用。因此,让我们来增强这个程序。
首先,我们需要更改文件名,因为我准备了另一个更长的文件,它实际上有几分钟长,你会在视频结尾认出它。这无法仅用单周期播放来实现。
我们将在这里做一些不同的事情,实际上使用一个不同的函数。我们将调用它 SB_AutoPlay。我们会保留单次播放函数,以便也能调用它。我们还会加入键盘输入检查,以便可以中止播放。因为现在播放时间不止3秒,能够停止播放会很有用。我们需要一个特殊的停止函数来实际停止所有的DMA操作。DMA单元保持不变。我认为目前这就足够了。
现在,让我们逐一编写这些函数。SB_Stop 函数应该是最简单的,我们可以从它开始。
全局状态变量
我们将使用原始文件指针和文件大小。我们将把它们移到文件顶部,因为我们需要一直使用它们,它们将成为全局状态。全局变量通常不好,但我们在使用C语言,并且没有太多其他选择,所以可以接受。
以下是需要定义的全局变量:
FILE *raw_file;
long file_size;
volatile long to_be_played;
volatile long to_be_read;
short read_buffer;
unsigned char far *dma_buffer;
int playing;
实现停止函数
SB_Stop 函数的工作原理如下:我们向DSP写入一个新的命令码 SB_PAUSE_PLAYBACK。我们将 playing 变量设置为0。playing 变量为1时,表示仍有内容在播放。当从while循环内部调用此函数时,它也会退出循环。出于安全原因或在检测到按键时调用它,在这里都无关紧要。为了保持整洁,我们也会关闭文件。然而,这并非绝对必要,因为当我们退出DOS时,文件会自动关闭。
以下是停止函数的代码:
void SB_Stop(void) {
SB_Write(SB_PAUSE_PLAYBACK);
playing = 0;
fclose(raw_file);
}
实现自动播放函数
SB_AutoPlay 函数接收一个文件名字符串。它的工作原理如下:
首先,检查DMA缓冲区是否已分配。如果没有,则直接返回,否则程序可能会崩溃。
我们需要使用多个缓冲区。我们分配的DMA缓冲区大小是我们实际需要的两倍。例如,我们分配了32KB,每个DMA缓冲区是16KB,这样我们就可以进行双缓冲。
思路是预加载两个缓冲区,填满缓冲区0和缓冲区1,然后开始播放缓冲区0。当缓冲区0播放结束时,我们会从Sound Blaster收到一个中断。此时Sound Blaster会继续播放缓冲区1。与此同时,在播放缓冲区1时,我们将新数据读入缓冲区0。当缓冲区1播放结束时,自动初始化单元会切换回缓冲区0进行播放,同时我们将数据加载到缓冲区1。这就是所谓的双缓冲。我们基本上有两个缓冲区,轮流进行播放和从文件填充。这样,我们不需要将整个文件加载到内存,而是按需加载。
我们需要一个 read_buffer 变量,它基本上只是一个整数。我们从缓冲区0开始。我们还需要将DMA缓冲区的内容初始化为0,包括两个半区。这将是全局状态,因为我们需要在IRQ处理程序中访问它。
以下是自动播放函数的框架和初始化步骤:
void SB_AutoPlay(char *filename) {
if (dma_buffer == NULL) return;
read_buffer = 0;
_fmemset(dma_buffer, 128, DMA_BUFFER_SIZE); // 用静音值(128)初始化
raw_file = fopen(filename, "rb");
if (raw_file == NULL) {
printf("File not found: %s\n", filename);
return;
}
fseek(raw_file, 0, SEEK_END);
file_size = ftell(raw_file);
rewind(raw_file);
to_be_played = file_size;
to_be_read = file_size;
SB_SetPlaybackRate(11025); // 设置播放频率为11kHz
SB_Write(SB_TURN_ON_SPEAKER); // 开启扬声器
// 填充两个缓冲区
ReadBuffer(0);
ReadBuffer(1);
if (to_be_read > 0) {
// 文件较大,使用自动初始化模式
SB_AutoInitPlayback();
} else {
// 文件很小,使用单周期播放即可
SB_SinglePlay();
}
playing = 1;
}
实现缓冲区读取函数
ReadBuffer 函数接收一个参数,该参数指示DMA缓冲区的哪一半将被新数据覆盖。
如果已经没有数据可读(to_be_read <= 0),则直接返回。
我们播放的是无符号整数格式的PCM。波形范围是0到255。静音(无声)对应的值是128,这基本上是振幅为零的中心线。要得到负振幅,你在这里放0;要得到正振幅,你放255(如果对波形能量进行归一化)。128正好是中间值,这是一条平坦的线,不会产生任何声音。
因此,我们首先用128初始化缓冲区,然后读取需要读取的字节数。
以下是 ReadBuffer 函数的代码:
void ReadBuffer(short buffer) {
unsigned int buffer_offset = buffer ? SOUND_BLASTER_BLOCK_LENGTH : 0;
if (to_be_read <= 0) return;
if (to_be_read < SOUND_BLASTER_BLOCK_LENGTH) {
// 最后一次读取,数据不足一个完整缓冲区
_fmemset(dma_buffer + buffer_offset, 128, SOUND_BLASTER_BLOCK_LENGTH);
fread(dma_buffer + buffer_offset, 1, to_be_read, raw_file);
to_be_read = 0;
} else {
// 读取一个完整的缓冲区
fread(dma_buffer + buffer_offset, 1, SOUND_BLASTER_BLOCK_LENGTH, raw_file);
to_be_read -= SOUND_BLASTER_BLOCK_LENGTH;
}
}
实现自动初始化播放函数
SB_AutoInitPlayback 函数看起来与单周期播放函数非常相似,包含所有的DMA编程,会同样复杂。
首先,编程DMA控制器。我们需要写入屏蔽寄存器和模式寄存器。对于自动初始化模式,模式寄存器的值不同。
单周期播放和自动初始化播放的区别仅在于模式寄存器中的两位。在自动初始化模式下,我们使用值 0x58,而在单周期模式下是 0x48。这两位指示DMA芯片使用自动初始化模式。
其余部分相同:写入DMA缓冲区的地址偏移量(需要按字节写入并进行位操作),写入页寄存器,然后写入块长度。
对于DMA控制器,块长度是整个DMA缓冲区大小(例如32KB)。对于Sound Blaster,我们需要通过 SB_SET_BLOCK_SIZE 命令设置其块长度,这是半个DMA缓冲区大小(例如16KB)。然后,我们发送 SB_AUTO_INIT_PLAYBACK 命令开始播放。
以下是关键的命令定义和函数框架:
#define SB_PAUSE_PLAYBACK 0xD0
#define SB_SET_BLOCK_SIZE 0x48
#define SB_AUTO_INIT_PLAYBACK 0x1C
#define SB_TURN_ON_SPEAKER 0xD1
void SB_AutoInitPlayback(void) {
// 1. 屏蔽DMA通道
outportb(DMA_MASK_REG, DMA_CHANNEL | 0x04);
// 2. 清除字节指针触发器
outportb(DMA_CLEAR_FF_REG, 0xFF);
// 3. 设置模式寄存器为自动初始化、读操作
outportb(DMA_MODE_REG, 0x58 | DMA_CHANNEL);
// 4. 写入DMA缓冲区地址(低字节、高字节)
unsigned long buf_addr = (unsigned long)dma_buffer;
unsigned short offset = (unsigned short)(buf_addr & 0xFFFF);
unsigned char page = (unsigned char)((buf_addr >> 16) & 0x0F);
outportb(DMA_ADDRESS_REG, offset & 0xFF);
outportb(DMA_ADDRESS_REG, offset >> 8);
// 5. 写入页寄存器
outportb(DMA_PAGE_REG, page);
// 6. 写入DMA块长度(整个缓冲区大小)
outportb(DMA_COUNT_REG, DMA_BLOCK_LENGTH & 0xFF);
outportb(DMA_COUNT_REG, DMA_BLOCK_LENGTH >> 8);
// 7. 解除DMA通道屏蔽
outportb(DMA_MASK_REG, DMA_CHANNEL);
// 8. 设置Sound Blaster块大小
SB_Write(SB_SET_BLOCK_SIZE);
SB_Write(SOUND_BLASTER_BLOCK_LENGTH & 0xFF);
SB_Write(SOUND_BLASTER_BLOCK_LENGTH >> 8);
// 9. 开始自动初始化播放
SB_Write(SB_AUTO_INIT_PLAYBACK);
}
修改中断处理程序
我们当前的中断处理程序没有做太多有用的事情。它播放一个缓冲区就结束了。对于单周期播放,我们只需要确认中断即可。但对于自动初始化模式,我们需要做更多工作。
我们仍然需要读取状态并确认中断。如果仍在播放,我们需要递减 to_be_played 计数器。然后检查是否还有数据需要播放。
如果还有数据需要播放,我们需要读取数据到当前缓冲区(由 read_buffer 指示,每次中断后通过异或操作在0和1之间切换)。然后,如果剩余要播放的数据小于一个完整的Sound Blaster缓冲区,我们切换到单周期播放模式来处理最后一点数据。如果剩余数据小于整个DMA缓冲区大小(即两个Sound Blaster块),我们需要停止自动初始化模式,否则它会继续播放缓冲区中的剩余内容。如果没有数据需要播放了,我们将 playing 设置为0并停止。
以下是中断处理程序的逻辑:
void interrupt far sb_isr(...) {
// 读取并确认中断
unsigned char status = inportb(SB_DSP_READ);
outportb(SB_DSP_ACK, status);
if (playing) {
to_be_played -= SOUND_BLASTER_BLOCK_LENGTH;
if (to_be_played > 0) {
// 读取数据到下一个缓冲区
ReadBuffer(read_buffer);
read_buffer ^= 1; // 切换缓冲区索引
if (to_be_played < SOUND_BLASTER_BLOCK_LENGTH) {
// 最后一点数据,使用单周期播放
SB_SinglePlay();
} else if (to_be_played < DMA_BLOCK_LENGTH) {
// 数据不足一个完整DMA缓冲区,停止自动初始化模式
SB_StopAutoInit();
}
// 否则,继续自动初始化播放
} else {
// 播放完毕
playing = 0;
SB_Stop();
}
}
// ... 向中断控制器发送EOI等操作
}
还需要一个 SB_StopAutoInit 函数,它简单地发送暂停播放命令。
void SB_StopAutoInit(void) {
SB_Write(SB_PAUSE_PLAYBACK);
}
总结

本节课中,我们一起学习了如何为Sound Blaster声卡实现自动初始化DMA播放。我们从单周期播放的局限性出发,引入了双缓冲技术和自动初始化模式来实现长时间连续播放。我们定义了必要的全局状态,实现了停止播放、读取缓冲区、设置自动初始化播放以及修改中断处理程序等关键函数。DMA编程部分,特别是模式寄存器的设置和地址/页寄存器的写入,是其中最复杂的部分。现在,你拥有了播放短音频片段或任意长度音频块所需的全部代码。有了这些,你就可以在你的程序中实现出色的声音效果了。希望你能享受编程的乐趣!
012:加载 GIF 图像 🖼️

在本节课中,我们将学习如何在 MS-DOS 程序中加载和显示 GIF 图像文件。我们将了解 GIF 文件的基本结构,并实现一个简单的 GIF 解码器,以便在 VGA 图形模式下显示图片。

概述
上一节我们通过纯代码绘制了图形。本节我们将学习如何从外部文件加载图片。我们将重点讲解 GIF 格式,因为它使用 LZW 压缩算法,在空间利用上比纯位图更高效,适合在资源有限的旧机器上使用。

GIF 文件简介
GIF 是“图形交换格式”的缩写,诞生于 20 世纪 80 年代末。它支持动画和透明色,至今仍被广泛使用。GIF 文件使用 LZW 压缩算法,该算法能有效压缩颜色较少、图形简单的图像。
程序结构
我们的程序将包含以下几个核心部分:
- 一个
load_gif函数,用于读取和解码 GIF 文件。 - 一个
image结构体,用于存储图像的像素数据、调色板和尺寸。 - 一个解码器状态结构体,用于管理 LZW 解压缩过程。
- 复用之前编写的 VGA 图形模式设置和像素绘制例程。
以下是主程序的简化流程:
int main() {
// 加载 GIF 图像
struct image *img = load_gif("test.gif");
if (img == NULL) return 1;
// 切换到 VGA 图形模式
set_vga_mode(0x13);
// 设置从 GIF 读取的调色板
set_palette(img->palette);
// 将图像数据复制到显存
draw_image(img->data, 320, 200);
// 等待按键后返回文本模式
wait_for_key();
set_text_mode();
return 0;
}
该程序目前硬编码支持 320x200 分辨率、256 色的 GIF 图像。

LZW 压缩算法原理
LZW 算法通过动态构建字典来压缩数据。它不需要存储完整的字典,而是在解码过程中逐步重建。
核心思想:
- 初始字典包含所有可能的单字节值(0-255)。
- 编码器读取输入流,寻找最长的、已存在于字典中的序列。
- 当遇到一个新序列时,输出前一个已知序列的代码,并将新序列添加到字典中。
- 解码器反向操作,利用代码流和逐步重建的字典还原原始数据。
例如,对于文本 “TO BE OR NOT TO BE”:
- 初始字典包含字母。
- 遇到 “T”,输出 T 的代码。
- 遇到 “TO”,这是一个新序列。输出 T 的代码,并将 “TO” 加入字典(代码 256)。
- 之后再次遇到 “TO” 时,直接输出代码 256,实现了压缩。
解码器实现详解
下面我们逐步分析 load_gif 函数和解码器的关键部分。
1. 读取文件头

首先,我们读取并验证 GIF 文件头。
struct gif_header header;
fread(&header, 1, sizeof(header), file);
if (strncmp(header.signature, "GIF", 3) != 0) {
// 不是有效的 GIF 文件
fclose(file);
return NULL;
}
文件头包含签名、图像尺寸和一些标志位。我们主要关心全局调色板是否存在以及颜色深度。
2. 处理调色板

如果文件头指示存在全局调色板,我们就读取它。GIF 调色板每个颜色分量是 8 位,而 VGA 是 6 位,因此需要右移两位来适配。
if (header.flags & 0x80) { // 全局调色板标志位
unsigned char palette_rgb[256][3];
fread(palette_rgb, 1, 3 << ((header.flags & 0x07) + 1), file);
for (int i = 0; i < 256; i++) {
// 将 8 位 RGB 转换为 VGA 的 6 位
img->palette[i][0] = palette_rgb[i][0] >> 2;
img->palette[i][1] = palette_rgb[i][1] >> 2;
img->palette[i][2] = palette_rgb[i][2] >> 2;
}
}
3. 定位图像数据块
GIF 文件由多个块组成。我们循环读取,直到找到图像描述符块。
unsigned char block_type;
do {
block_type = fgetc(file);
if (block_type == 0x2C) { // 图像描述符块
break;
} else if (block_type == 0x21) { // 扩展块
// 跳过我们不关心的扩展块(如注释、图形控制等)
skip_extension_block(file);
} else if (block_type == 0x3B) { // 文件结束
break;
}
} while (!feof(file));
4. 读取图像描述符与初始化解码器
找到图像块后,读取其描述符,它包含了该图像在逻辑屏幕中的位置和尺寸。接着,我们读取 LZW 压缩数据的初始码大小,并初始化解码器状态结构体 decoder_state。
该结构体保存了解码所需的所有信息:
- 文件指针和图像缓冲区指针。
- 当前读取的压缩数据块。
- 字典表:
prefix_code和suffix_char。 - 解码状态:当前码大小、最大码、下一个空闲码等。
- 图像绘制位置(x, y 坐标)。
5. 核心解码循环
解码在一个大循环中进行,直到遇到图像结束码。
int old_code = -1;
do {
int code = read_code(&state); // 从位流中读取下一个代码
if (code == state.end_code) break; // 结束码
if (code == state.clear_code) {
// 重置解码器字典
state.free_code = state.first_free;
state.code_size = state.init_code_size;
state.max_code = 1 << state.code_size;
code = read_code(&state);
old_code = code;
// 输出第一个像素
output_pixel(&state, code);
} else {
if (code < state.free_code) {
// 代码在字典中,直接输出对应字符串
output_string(&state, code);
// 更新字典:旧代码 + 新字符串的第一个字符
add_to_dictionary(&state, old_code, first_char_of_string(code));
} else {
// 遇到新代码(特殊情况)
output_string(&state, old_code);
output_pixel(&state, first_char_of_string(old_code));
add_to_dictionary(&state, old_code, first_char_of_string(old_code));
}
old_code = code;
}
// 如果字典满了,增加代码位宽(最多到 12 位)
if (state.free_code > state.max_code && state.code_size < 12) {
state.code_size++;
state.max_code = 1 << state.code_size;
}
} while (code != state.end_code);
6. 关键辅助函数
以下是几个关键函数的说明:
read_code:从压缩数据位流中读取指定比特数的代码。它内部管理一个字节缓冲区,按需从文件读取下一个数据块。output_pixel:将解码出的颜色索引写入图像缓冲区的正确位置,并处理换行和隔行扫描。output_string:根据代码从字典中还原出完整的像素序列。它从后向前工作,利用suffix_char表找到最后一个字符,再利用prefix_code表找到前一个代码,如此反复直至遇到一个基础码(小于clear_code)。add_to_dictionary:将新的序列(由旧代码和当前字符组成)添加到字典中,prefix_code存储旧代码,suffix_char存储新字符。
运行与优化
编译并运行程序,屏幕上将显示加载的 GIF 图像。控制台打印的每个 “.” 代表一个扫描线解码完成。


目前这个解码器实现侧重于清晰易懂,但速度较慢。在模拟的 286/386 机器上,解码一张 320x200 的图片可能需要数秒。主要的性能瓶颈在于:
read_code函数逐位操作。- 频繁的单字节文件读取和小块数据处理。



未来的优化方向包括:
- 将
read_code内联或改用查表法。 - 一次读取更大的数据块到内存。
- 用汇编重写核心循环。

总结

本节课我们一起学习了如何在 MS-DOS 环境下加载和显示 GIF 图像。我们了解了 GIF 文件的结构、LZW 压缩算法的基本原理,并实现了一个完整的、易于理解的解码器。虽然当前版本效率不高,但它为在 DOS 游戏中集成外部图像资源奠定了基础。下一节,我们将探讨如何组织更大的项目代码,并开始为制作一个完整的游戏做准备。
013:近指针、远指针与中断请求


在本节课中,我们将学习如何修复一个在真实MS-DOS硬件上无法运行的程序。我们将探讨近指针与远指针的概念,以及如何正确地在中断服务例程中处理硬件中断。通过理解PC架构的基础知识,我们将解决程序在DOSBox模拟器中运行正常,但在真实机器上失败的问题。

PC架构快速回顾
上一节我们介绍了程序在真实硬件上遇到的问题。为了理解问题的根源,本节中我们来看看原始的IBM PC架构。
最初的IBM PC 5150基于Intel 8088和8086 CPU。8088是更便宜的版本,具有8位数据总线,而8086具有16位数据总线。两者都拥有20位地址总线,可寻址最多1 MB的RAM。
架构本身大致如下:CPU通过其地址和数据线,在所谓的ISA总线上与其他外围设备通信。外围设备包括用于控制硬件中断请求的中断控制器、用于直接内存访问和内存传输的DMA控制器,当然还有RAM。
问题在于,我们的程序需要特定的内存模型,而为了理解内存模型,我们需要了解8086如何与硬件或内存通信。
内存模型与分段
在硬件层面,CPU首先有一组寄存器。这些是CPU上直接的小型16位存储空间,包括通用寄存器(AX, BX, CX, DX)和段寄存器(CS, DS, ES, SS)。CS是代码段,指向当前执行的程序;DS是数据段,默认指向我们加载的数据;SS是栈段,用于函数调用等操作。
由于使用16位寄存器只能寻址最多64 KB的内存,但通过一个段寄存器和一个其他寄存器组合,可以寻址更多。内存分段的工作原理如下:我们有四个段寄存器(代码、数据、附加、栈),与通用或专用寄存器一起,可以寻址1 MB。
例如,数据段DS和源索引SI这对寄存器组合,可以给出内存中的某个1 MB位置。要获得线性地址,需要计算 DS * 16 + SI,因为每个段在16字节后开始,但每个段本身仍然是64 KB。段可以并且确实会重叠。
如果我们在内存中进行复制,可以使用rep(重复)指令和移动字符串字命令movsw。这将把16位值从源地址(DS:SI)复制到目标地址(ES:DI)。重复次数存储在CX寄存器中。
近指针与远指针
为了节省内存,编译器可以使用所谓的近指针,而不是两个16位值(一个段寄存器和一个偏移寄存器)。你可以只使用一个8位或16位偏移量,从而在当前段内寻址256字节或64 KB。
这当然节省了大量空间,因为存储地址时只需存储8或16位,而不是32位(否则需要将段寄存器存储在内存中的某个地方和偏移寄存器)。这也提高了速度,因为需要从内存加载或解码的代码更少。
以下是一个示例。助记符mov在语法中,目标在前,源在后。
mov si, [bp] ; 这是一个近指针操作示例
mov di, [bp+2]
rep movsw
这相当于写mov si, ds:[bp]。使用近指针在程序中占用的空间更少。
选择正确的内存模型
在Turbo C中,进入选项和编译器菜单,你会看到指针模型。默认是“小”内存模型。小内存模型为代码提供64 KB,为数据和栈提供另一个64 KB。在DOS中,如果没有DOS扩展器,总共可以使用640 KB的代码、数据和栈。这个模型只使用了大约10%的代码和10%的数据和栈空间,程序确实非常小。
重要的是第二部分:默认情况下,所有函数和数据指针都是近指针。这意味着当我们分配某些东西或传递某些东西时,它只会传递近指针(仅偏移量)。这可能会导致问题,特别是在使用malloc和分配DMA缓冲区时。
当我们切换到“紧凑”模型时,这是第一个声明“所有函数默认为近指针,所有数据指针默认为远指针”的模型。这对我们很重要。我们的代码库不大,所以我们的代码可能适合64K,因此函数指针是近指针没问题。我们这里也不使用函数指针,所以这应该没问题。
如果你不满意,甚至可以选更大的模型。“大”内存模型允许最多1 MB的代码、64 KB的栈和1 MB的堆。所有函数和数据指针都是远指针,应该完全没有问题。还有“巨大”内存模型,它提供多个数据段,每个大小为64 KB,代码最多1 MB,栈64 KB。所有函数和数据指针似乎都是远指针。
这当然会使你的代码稍微大一些,运行时性能可能稍差,但对于这些情况,应该选择紧凑型或大型。然而,现在我把它编译成了小内存模型。在DOSBox中运行正常,但在真实机器上可能不行,因为DOSBox模拟并不完美。
中断请求处理
下一个问题实际上是我的代码中的第二个错误,即IRQ处理,这在DOSBox和真实机器上也有些不同。
IRQ是什么?它们从哪里来?让我们看看实际的ISA总线插槽。这是一个8位插槽,用于插入声卡和其他设备。首先,有数据引脚。这是一个8位总线,所有数据都通过这8个数据引脚发送到任何设备。
哪个设备?为此,有地址线。每个地址要么是内存地址,要么是I/O端口地址,这将告诉设备当前在总线上流动的数据是否是为它准备的。
然后是IRQ线。ISA上的每个设备都可以占用其中一条IRQ线。在原始PC中,有IRQ线2、3、4、5、6和7。0和1不可用。每当设备说“我需要做某事”时,它就会触发这条线,CPU将响应并为此特定中断执行一个IRQ处理程序。
例如,声霸卡将根据其配置触发IRQ 5或7。这将开始调用我们编程到声霸卡代码中的IRQ处理程序,该处理程序将处理例如加载下一个样本。

当然,还有DRQ线,即直接内存访问请求线。在原始IBM PC上有三个通道,实际上是四个通道(0、1、2、3)。在AT机上,这被扩展了。IRQ也是如此,但从软件的角度来看,这几乎是相同的,需要考虑一些事情,但基本上保持不变。
然后是I/O端口,我提到过。它们重用部分地址线。前10条地址线可以使用,当读取或写入I/O端口时,将触发IOW和IOR线。例如,当我们与声霸卡的基本地址(通常是十六进制的220)通信时,当我们向DSP发送东西时,将使用in或out函数,它将触发IOW/IOR标志并发送适当的地址,声霸卡就会知道“现在有人在跟我说话”。
修复中断处理程序代码
你可能还记得,在代码的某个地方,我们确实有这个函数:
void interrupt sb_irq_handler(...) { ... }
关键字interrupt是Turbo C和DOS特有的,在标准C中不可用。这意味着这是一个特殊的函数,实际上是一个中断处理程序,并将被用作这样的处理程序。它周围有一些额外的魔法代码。
在更改之前,它在DOSBox中工作,但在真实机器上效果不佳。原因有两个。首先,进入IRQ处理程序时应该做的是禁用所有其他IRQ(不可屏蔽的除外)。离开中断处理程序时,必须重新启用它们。
在dos.h头文件中有两个函数:disable()和enable()。disable函数禁用中断,禁用所有硬件中断(NMI除外)。因为在处理声霸卡代码期间,可能会触发其他中断,比如定时器。然后你正在写入声霸卡或使用outport命令,或者你正在写入IRQ或DMA控制器,一切都会变得混乱。这非常糟糕。所以当我们写入硬件时,我们绝不能被打断。这就是我们禁用中断的原因。在函数结束时,我们再次启用它们。
第二个问题是,在DOSBox中,一切都非常快。例如,输入和输出非常快。在更改之前,我们曾经调用read_buffer函数,该函数将进行磁盘访问并将缓冲区的下一部分(16 KB的数据)加载到内存中。这是非常昂贵的操作。在慢速机器上可能需要几毫秒。中断处理程序不应停留太久,因为可能还有其他硬件需求。如果你禁用所有中断,机器将变得不稳定。这就是我们在这里看到的情况。
所以实际上,在IRQ例程中进行加载是一个愚蠢的想法。相反,我们可以做的是引入一个新变量。它叫做do_read,默认设置为0。它是另一个volatile变量。volatile意味着它可以被中断改变,所以编译器无法知道在任何给定时间的值,因为它可能通过其控制之外的副作用被改变。
因此,与其真正读取数据,我们只是告诉变量“我们现在应该读取一些东西,因为我们的缓冲区快用完了”。所以我们在这里所做的只是将do_read设置为1,并像以前一样做所有事情。如果只剩下很少的字节,我们进行单周期播放;如果剩下更多但不多,我们实际上停止音频播放。否则,如果没有其他要播放的内容,我们就完成了。
我们还将缓冲区变量从0交换到1或从1交换到0。现在,当我们查看主函数时,曾经有一个忙循环或事件循环,它只是说“当正在播放且没有键盘中断时,什么都不做”。但现在我们实际上可以做些事情。即,如果do_read变量突然变为1,那么我们实际上读取缓冲区。然后我们将do_read设置回0。这样,我们将在IRQ处理程序中花费最少的时间。
由于我们在IRQ处理程序中交换了buffer变量,我们必须在这里再次执行以写入正确的缓冲区。当不读取时,我们现在也显示一个百分比,因为我想看看什么时候会卡住。这并不那么有趣,但我只是在这里等待10毫秒,这样我就不会做一个极其繁忙的循环,而是让进程休眠一会儿。然后计算百分比并打印这些内容。
总结

本节课中我们一起学习了如何修复一个在真实MS-DOS硬件上无法运行的程序。我们探讨了PC架构的基础知识,理解了内存分段、近指针与远指针的区别,以及它们如何影响程序在不同内存模型下的行为。我们深入研究了ISA总线和中断请求的工作原理,并修复了中断服务例程中的关键问题,通过引入volatile变量和将耗时操作移出中断上下文,显著提高了程序的稳定性和兼容性。最后,我们验证了修复后的程序在真实硬件上能够正常运行。这些知识对于编写健壮的底层系统软件至关重要。
014:PowerBasic圣诞特辑 🎄
在本节课中,我们将学习如何使用PowerBasic编译器,在MS-DOS环境下编写一个简单的圣诞贺卡程序。我们将涵盖图形绘制、动画、音乐播放和文本输出等基础概念。
概述与背景
上一节我们介绍了MS-DOS下的编程环境。本节中,我们将探索一款名为PowerBasic的BASIC语言编译器,并利用它创建一个具有节日气氛的图形程序。
PowerBasic是一款由Bob Zale开发、最初源自Borland Turbo Basic的BASIC编译器。它能够生成独立的.EXE可执行文件,在90年代的德国颇为流行。与解释型的GW-BASIC或QBasic不同,PowerBasic将代码编译为机器码,因此执行速度更快。
环境准备与安装
首先,我们需要在MS-DOS系统中安装PowerBasic。以下是安装步骤:
- 将包含PowerBasic的3.5英寸软盘插入驱动器(例如A:)。
- 在硬盘上创建一个新目录,用于存放PowerBasic文件。
mkdir C:\PBASIC - 切换到该目录,并使用
XCOPY命令复制软盘上的所有文件(包括子目录)。cd C:\PBASIC xcopy A:\*.* /S - 复制完成后,运行
PBRT.EXE即可启动PowerBasic运行时编译器。
第一个图形程序:绘制彩虹圆圈

成功启动PowerBasic后,我们可以开始编写第一个图形程序。以下是一个简单的例子,它在屏幕上绘制一系列彩色的同心圆。

SCREEN 7
FOR i = 0 TO 15
CIRCLE (160, 100), i * 5 + 5, i
NEXT i

代码解释:
SCREEN 7:将图形模式设置为EGA 320x200,16色。FOR...NEXT循环:循环变量i从0到15,代表16种颜色。CIRCLE命令:用于画圆。参数依次是圆心的X坐标、Y坐标、半径和颜色。

运行这段代码,你将在屏幕中央看到一组彩色的同心圆,就像一个彩虹。

创建动画:飘落的雪花 ❄️


静态图形很有趣,但动画更能吸引人。接下来,我们让一些“雪花”像素点从屏幕顶部随机位置飘落。




实现动画需要以下几个步骤:
- 初始化:创建数组来存储大量雪花的位置(X和Y坐标),并为其赋予随机起始值。
- 主循环:在循环中不断更新每个雪花的位置(主要是Y坐标增加,模拟下落)。
- 绘制与擦除:在雪花的新位置绘制一个白点,并在其旧位置绘制一个黑点(或背景色)以擦除痕迹,从而产生运动效果。
- 边界检测与重置:当雪花落到屏幕底部时,将其重置到顶部的一个新随机位置。



以下是核心代码框架:


RANDOMIZE TIMER
DIM snowX(49), snowY(49)


' 初始化雪花位置
FOR i = 0 TO 49
snowX(i) = INT(RND * 320)
snowY(i) = INT(RND * 200)
NEXT i

SCREEN 7



' 主动画循环
DO
FOR i = 0 TO 49
' 擦除旧雪花
PSET (snowX(i), snowY(i)), 0
' 更新位置
snowY(i) = snowY(i) + 1
' 检查是否落地
IF snowY(i) >= 200 THEN
snowY(i) = 0
snowX(i) = INT(RND * 320)
END IF
' 绘制新雪花
PSET (snowX(i), snowY(i)), 15
NEXT i
' 检测按键退出
LOOP UNTIL INKEY$ <> ""


关键概念:
RANDOMIZE TIMER:用系统时间初始化随机数种子。RND函数:生成一个0到1之间的随机浮点数。PSET (x, y), color:在指定坐标(x, y)绘制一个指定颜色的像素点。INKEY$函数:检测是否有按键被按下。循环会一直运行,直到用户按下任意键。


添加背景音乐 🎵
一个完整的节日程序怎么能缺少音乐呢?PowerBasic提供了PLAY命令,可以方便地通过PC扬声器播放音乐。


PLAY命令使用一个特殊的字符串来定义音符、音长和节奏。以下是一段《铃儿响叮当》(Jingle Bells)的示例:


PLAY "MB T200 L16 O2 E L4 E L2 E L4 E L4 E L2 E L4 G L4 C L4 D L1 E"


字符串解释:
MB:在后台播放音乐,这样程序可以同时做其他事情(如动画)。T200:设置节奏(速度)。L16:设置默认音长为16分音符。O2:设置音阶为第2个八度。- 后面的
E、G、C、D等字母代表音符。


为了让音乐在播放完毕后自动重复,我们可以检查音乐缓冲区。以下是如何将音乐整合到主循环中:


' 设置更大的声音缓冲区
SOUND 4000, 0



' 在程序开始处启动音乐
PLAY "MB T200 L16 O2 E L4 E L2 E L4 E L4 E L2 E L4 G L4 C L4 D L1 E"


DO
' ... (这里是雪花动画的代码) ...
' 检查音乐是否快播完了,如果是则重新开始
IF PLAY(0) < 2 THEN
PLAY "MB T200 L16 O2 E L4 E L2 E L4 E L4 E L2 E L4 G L4 C L4 D L1 E"
END IF
LOOP UNTIL INKEY$ <> ""
添加节日问候文本

最后,让我们在屏幕上显示一句节日祝福。在图形模式下,我们可以使用PRINT语句,但需要手动定位。


由于PowerBasic的图形模式没有直接的文本光标定位命令,我们可以通过打印多个空行和空格来模拟居中效果:

COLOR 5 ' 设置文本颜色为洋红色
FOR i = 1 TO 16
PRINT ' 打印16个空行,将光标移动到屏幕下半部分
NEXT i
PRINT " Merry Christmas!" ' 使用空格进行水平居中



程序整合与运行
现在,我们将所有部分组合起来:彩虹圆圈作为背景,飘落的雪花作为动画,循环播放的《铃儿响叮当》作为背景音乐,以及屏幕上的节日祝福文本。



运行这个完整的程序,你将看到一个充满节日气氛的MS-DOS圣诞贺卡。

总结与思考

本节课中我们一起学习了如何使用PowerBasic在MS-DOS下进行编程。我们完成了一个包含图形、动画、音乐和文本的综合项目,回顾了以下核心技能:
- 设置图形模式(
SCREEN)。 - 使用
CIRCLE、PSET等命令进行图形绘制。 - 利用数组和循环创建简单动画。
- 使用
PLAY命令播放音乐。 - 在图形模式下输出文本。
PowerBasic作为一款历史悠久的编译器,对于体验复古编程和了解BASIC语言很有帮助。然而,对于希望深入学习系统编程或开发高性能应用的现代学习者,C或汇编语言可能是更合适的选择。不过,这次怀旧之旅无疑是一次有趣且富有教育意义的体验。

祝你编程愉快,并预祝节日快乐!
015:VGA铜条效果教程
概述
在本节课中,我们将学习如何在MS-DOS环境下,利用VGA显卡的硬件特性实现一个经典的图形效果——“铜条”或“光栅条”效果。这种效果在80年代和90年代的家用电脑上非常流行,常用于演示程序或破解软件的片头动画。我们将通过直接操作VGA寄存器,在文本模式下创造出平滑移动、带有渐变色彩的彩色横条。
铜条效果原理
上一节我们介绍了VGA的基本概念,本节中我们来看看铜条效果的工作原理。
这种效果的名字“铜条”源于Amiga电脑,其芯片上的一个名为“Copper”的协处理器可以实现复杂的视频特效。虽然PC的VGA芯片功能相对简单,但也能实现类似的效果。
其核心原理基于CRT显示器的扫描方式。显示器电子束从左到右、从上到下逐行扫描屏幕来绘制图像。通过在每个水平扫描行期间动态切换调色板颜色,我们就能创造出色彩平滑变化的横条效果。
为了实现这一点,我们需要精确地知道电子束当前扫描到了哪一行。VGA的输入状态寄存器(地址0x3DA)提供了这个能力。我们之前已经用它来同步垂直回扫。该寄存器的第0位是“显示禁用”位,当该位为1时,表示显示器正处于水平或垂直回扫区间。通过监视这个位的状态切换,我们可以精确地计数当前正在显示的行数。
准备工作与代码结构
理解了原理后,我们开始搭建程序框架。以下是实现效果所需的主要步骤和代码结构。
首先,我们需要包含必要的头文件,并定义一些常量和全局数据结构。
#include <dos.h> // 用于端口访问
#include <math.h> // 用于sin函数(初始化阶段)
#include <stdio.h>
#include <conio.h>
#ifndef M_PI
#define M_PI 3.14159 // 旧C编译器可能没有定义PI
#endif
// VGA寄存器地址定义
#define INPUT_STATUS 0x3DA
#define PALETTE_INDEX 0x3C8
#define PALETTE_DATA 0x3C9
// 输入状态寄存器的位定义
#define VERTICAL_RETRACE 0x08 // 第3位
#define HORIZONTAL_RETRACE 0x01 // 第0位
// 预计算的256字节正弦查找表,用于快速生成动画值
unsigned char sin_table[256];
为了获得流畅的动画并避免在每帧都调用昂贵的sin()函数,我们将预先计算一个正弦值查找表。这个表将角度映射到0-255的亮度值。
void init_sin_table() {
int i;
for (i = 0; i < 256; i++) {
// 将i映射到0到2π的范围,计算sin值,结果从[-1,1]缩放到[0,255]
double rad = (i / 255.0) * 2.0 * M_PI;
sin_table[i] = (unsigned char)((sin(rad) + 1.0) / 2.0 * 255);
}
}
主程序逻辑与动画实现
现在进入核心部分。我们将设置主循环,初始化变量,并实现逐行控制颜色的逻辑。
主函数首先初始化正弦表,然后进入一个循环,直到用户按键退出。在循环中,我们需要跟踪帧数、每个铜条的起始位置和移动速度。
int main() {
int frame = 0;
int copper_start[3] = {0, 0, 0}; // 三个铜条(红、绿、蓝)的起始行
int copper_delta[3] = {3, 1, 2}; // 每个铜条的移动速度(查表步进)
int y, i, color_rgb[3];
unsigned char status;
init_sin_table();
while (!kbhit()) { // 主循环,直到有按键
// 1. 等待垂直回扫开始新的一帧
disable(); // 禁用中断,确保计时精确
do {
status = inportb(INPUT_STATUS);
} while ((status & VERTICAL_RETRACE) == 0);
enable(); // 重新启用中断
// 2. 逐行绘制屏幕(约350行)
y = 0;
while (y < 350) {
// 等待当前行结束(水平回扫开始)
disable();
do {
status = inportb(INPUT_STATUS);
} while ((status & HORIZONTAL_RETRACE) == 0);
enable();
// 3. 根据当前行号y,计算并设置颜色
// 我们将修改调色板中“黑色”(索引0)的颜色值
// 这会使屏幕上所有黑色像素瞬间变色,形成横条
color_rgb[0] = 0; color_rgb[1] = 0; color_rgb[2] = 0; // 默认黑色
// 为每个铜条检查当前行是否在其范围内
for (i = 0; i < 3; i++) {
if (y >= copper_start[i] && y < copper_start[i] + 64) {
// 计算当前行在铜条内的相对位置 (0-63)
int pos_in_bar = y - copper_start[i];
// 创建渐变:前半段亮度增加,后半段亮度减少
int intensity;
if (pos_in_bar < 32) {
intensity = pos_in_bar * 2; // 0 -> 62
} else {
intensity = (63 - pos_in_bar) * 2; // 62 -> 0
}
// 将强度值赋给对应的颜色通道
color_rgb[i] = intensity;
}
}
// 将计算出的RGB值写入VGA调色板
outportb(PALETTE_INDEX, 0); // 选择颜色索引0(黑色)
outportb(PALETTE_DATA, color_rgb[0]); // 红色分量
outportb(PALETTE_DATA, color_rgb[1]); // 绿色分量
outportb(PALETTE_DATA, color_rgb[2]); // 蓝色分量
y++; // 处理下一行
}
// 4. 更新动画:根据帧数和速度移动铜条位置
frame++;
for (i = 0; i < 3; i++) {
// 使用正弦查找表产生平滑的上下移动
// (frame * delta) % 256 确保在表内循环
copper_start[i] = sin_table[(frame * copper_delta[i]) % 256];
}
}
// 程序结束前,将颜色恢复为黑色
outportb(PALETTE_INDEX, 0);
outportb(PALETTE_DATA, 0);
outportb(PALETTE_DATA, 0);
outportb(PALETTE_DATA, 0);
return 0;
}
关键技术与注意事项
在实现过程中,有几个技术细节需要特别注意,它们直接关系到效果能否正确运行。
- 精确计时:使用
disable()和enable()函数临时关闭中断,防止后台任务干扰我们对水平回扫信号的精确检测。这是效果稳定的关键。 - DOSBox配置:在DOSBox模拟器中运行此程序时,需要将机器类型设置为
machine=svga_s3或machine=vgaonly,以确保输入状态寄存器被正确模拟。默认的SVGA S3模拟可能不支持此特性。 - 行数限制:循环中我们只处理大约350行,而不是全部的400行。这是因为在真实的硬件上,代码执行速度可能无法跟上最高的行频,导致错过一些水平回扫信号。350是一个安全的数值。
- 调色板操作:我们通过修改调色板中索引0的颜色(通常是黑色)来产生效果。这意味着屏幕上所有显示为“黑色”的像素都会瞬间变成我们设置的颜色。在文本模式下,这完美地创造了彩色横条,而无需绘制任何像素。

总结

本节课中我们一起学习了如何在MS-DOS环境下利用VGA硬件实现经典的铜条效果。我们深入了解了CRT扫描原理,掌握了通过0x3DA端口检测水平回扫来精确控制逐行颜色的方法。通过预计算正弦表优化性能,并直接操作调色板寄存器,最终在文本模式下创造出了带有平滑渐变和动画的彩色横条。

这个效果展示了直接操作硬件带来的强大控制力和效率,即使是在功能相对简单的VGA芯片上。你可以尝试修改代码中的颜色、速度、渐变形状,甚至同时控制多个调色板索引,来创造出属于自己的独特演示效果。
016:VGA 文本模式平滑滚动教程
概述
在本节课中,我们将学习如何在 MS-DOS 环境下,利用 VGA 显卡的 CRTC 控制器寄存器,在文本模式下实现平滑的垂直滚动效果。我们将从上一节课的“铜条”效果程序出发,修改它以支持逐像素级别的平滑滚动。
准备工作
上一节我们介绍了如何通过修改调色板实现 VGA 文本模式下的“铜条”效果。本节中,我们来看看如何利用 VGA 的硬件特性实现平滑滚动。

首先,我们需要一个基础程序。我们将使用上一节课的代码,但需要做一些关键修改。
以下是需要调整的核心部分:

- 正弦函数表:为了获得更平滑的滚动,我们需要更高精度的正弦波。我们将表的长度从 256 增加到 512,并将输出值范围映射到 0-400(对应文本模式的 400 像素高度)。
#define SINE_TABLE_SIZE 512 unsigned char sine_table[SINE_TABLE_SIZE]; // ... 初始化代码,将值归一化到 0-511 范围 ... - 核心变量:我们需要跟踪当前帧、垂直行偏移和像素偏移。
unsigned int frame = 0; unsigned int line = 0; unsigned int pixel = 0; unsigned int offset = 0;
理解滚动原理
平滑滚动的核心在于操纵 VGA CRTC(阴极射线管控制器)的两个寄存器。
以下是相关的两个关键寄存器:
- 起始地址高位寄存器:索引为
0x0C。它指定屏幕左上角字符在显示内存中地址的高 8 位。 - 起始地址低位寄存器:索引为
0x0D。它指定地址的低 8 位。

通过修改这个“起始地址”字段,我们可以告诉 VGA 从显存的不同位置开始读取数据并显示,从而实现整行(字符行)级别的滚动。在文本模式下,每行有 80 个字符,因此偏移量计算为 行号 * 80。

然而,仅靠起始地址寄存器只能实现跳跃式的行滚动。为了实现像素级别的平滑滚动,我们需要另一个寄存器。
实现像素级偏移

为了实现像素级的精细控制,我们需要使用 预置行扫描寄存器。

这个寄存器的索引是 0x08。它允许我们将渲染的起始行向上偏移最多 31 个扫描行(使用其低 5 位)。对于文本模式(每个字符块高 16 像素),我们最多需要偏移 15 个像素,因此使用低 4 位即可。
其工作原理是:当我们通过起始地址寄存器切换到一个新的字符行时,预置行扫描寄存器可以指定从该字符块的第几个像素行开始显示,从而实现字符行内的平滑滚动。
整合代码流程
现在,我们将上述原理整合到主循环中。以下是每一帧需要执行的操作步骤:
- 计算滚动位置:根据正弦函数和当前帧数,计算出当前垂直位置的像素坐标
y。y = sine_table[frame % SINE_TABLE_SIZE]; // y 范围 0-511 - 分解为行和像素:将像素坐标
y分解为字符行号line和行内像素偏移pixel。line = y / 16; // 每个字符高16像素 pixel = y % 16; - 计算显存偏移:根据行号计算显存中的字节偏移量。
offset = line * 80; // 每行80字符 - 写入起始地址寄存器:在垂直回扫期间,将偏移量的高字节和低字节分别写入 CRTC 寄存器,以实现行切换。
outportb(CRTC_INDEX, START_ADDRESS_HIGH); outportb(CRTC_DATA, (offset >> 8)); outportb(CRTC_INDEX, START_ADDRESS_LOW); outportb(CRTC_DATA, offset & 0xFF); - 写入预置行扫描寄存器:紧接着,写入像素偏移量,实现行内的平滑滚动。
outportb(CRTC_INDEX, PRESET_ROW_SCAN); outportb(CRTC_DATA, pixel & 0x0F); // 只取低4位 - 同步垂直回扫:所有寄存器操作必须与垂直回扫同步,以避免屏幕撕裂。
// 等待垂直回扫开始 while ((inportb(INPUT_STATUS) & VRETRACE_MASK)); // 等待垂直回扫结束 while (!(inportb(INPUT_STATUS) & VRETRACE_MASK)); - 更新帧计数器:递增帧数,为下一帧计算新的位置。
frame++;


增强视觉效果(可选)
为了让效果更明显,我们可以用图像填充显存,而不是空白屏幕。可以使用像 “The Draw” 这样的 DOS 绘图工具创建图像,并将其导出为 C 语言头文件,然后在程序中包含并复制到显存。
以下是加载图像的示例代码:
#include “image1.h“ // 包含图像数据
unsigned char far *buffer = (unsigned char far *)0xB8000000L; // 文本模式显存地址
// 将图像数据复制到显存
for(i = 0; i < image_data_1_length; i++) {
buffer[i] = image_data_1[i];
}
总结


本节课中,我们一起学习了 VGA 文本模式下的平滑滚动技术。
我们掌握了两个核心的 CRTC 寄存器:
- 起始地址寄存器:用于控制显存中屏幕起始点的字符行级定位。
- 预置行扫描寄存器:用于实现字符行内部的像素级精细偏移。

通过结合使用这两个寄存器,并与垂直回扫同步,我们成功实现了极其平滑的垂直滚动动画。这个效果即使在真实的 386 机器上也能以 70Hz 的刷新率流畅运行。这项技术是许多经典 DOS 游戏和演示场景中实现视差滚动、菜单动画等效果的基础。在后续课程中,我们将以此为基础,探索水平滚动以及图形模式下的平滑滚动技术。
017:神秘的VGA模式X
概述
在本节课中,我们将要学习如何解锁VGA显卡的全部潜力,进入一个被称为“模式X”或“模式Y”的隐藏图形模式。这个模式提供了远超标准256色模式13的功能,包括页面翻转和平滑滚动能力,是编写流畅动画和游戏的关键。
PC内存布局回顾
上一节我们介绍了VGA编程的基础,本节我们来看看PC的内存布局。理解这一点对于访问VGA的全部内存至关重要。
原始的PC可以访问1 MB的RAM。其中较低的640 KB用于应用程序和DOS。紧接着640 KB之后,是段地址 0xA000,这是EGA和VGA显卡的帧缓冲区。
然后是一些上端内存块,可用于扩展卡或加载驱动程序。文本模式CGA的帧缓冲区位于 0xB800。在1 MB内存的顶端,是BIOS ROM。
对于VGA而言,其内存段 0xA000 只有64 KB大小。然而,所有VGA卡,即使是IBM最早的型号,都至少安装了256 KB内存。许多卡甚至有512 KB或1 MB。但64 KB的段地址意味着我们无法直接访问剩余的192 KB内存,这非常可惜。
VGA架构与位平面
VGA的前身EGA卡引入了“位平面”的概念。在16色模式下使用了4个位平面,但一次只能激活一个平面。VGA延续了这个概念,但我们称之为“字节平面”,它们可以用来切换访问整个256 KB内存。
以下是VGA的主要组件,我们需要对它们进行编程才能实现上述功能:
- 图形控制器:位于系统总线上,负责总线接口和读写逻辑以访问视频内存。
- 视频内存:组织为4个64 KB的平面,总计256 KB。
- 定序器:读取视频内存,并与DAC通信。
- 数模转换器:与CRT控制器通信。
- CRT控制器:在屏幕上生成图像。
我们可以通过VGA的I/O端口来编程这些组件。VGA I/O端口是索引式的。这意味着首先,我们将要写入的寄存器索引发送到对应的索引端口,然后,在第二个数据端口上进行读写操作。
例如,属性控制器的索引端口是 0x3C0,数据端口是 0x3C1。定序器有类似的端口 0x3C4 和 0x3C5。
从模式13到模式X/Y
之前我们学习了模式13,这是我们已经知道的、由VGA BIOS提供的唯一支持256色的模式。

它的优点是兼容MCGA的64 KB标准,并且编程非常简单。我们拥有平坦的像素寻址方式,该段内的每个字节直接对应320x200屏幕上的一个像素,并且刷新率为70 Hz,基本无闪烁。
它的缺点是功能不够强大。没有页面翻转功能,无法进行平滑滚动,并且像素不是正方形的。
页面翻转正是我们最想要的功能之一,配合滚动能力,可以实现流畅的动画。
页面翻转的原理是:你有一个当前正由CRT控制器显示在屏幕上的前缓冲区,然后在后台,你向一个后缓冲区进行绘制,这个缓冲区不会显示在屏幕上。重绘有时会花费超过一帧屏幕刷新的时间。如果我们在前缓冲区进行绘制,你就能看到屏幕是如何被一笔笔画出来的,从而产生屏幕撕裂和各种奇怪的伪影。一旦完成后缓冲区的绘制,你就可以无缝地切换到后缓冲区,然后循环往复。这对于动画和游戏尤其有用。
那么,如何进入模式X和模式Y呢?我们基本上是通过进入模式13来开始的,它已经设置了所有重要的图形服务。模式X由Michael Abrash推广,它是一种隐藏模式,因为BIOS并不支持它。但实际上,VGA的所有寄存器都有很好的文档记录。
模式X真正释放了VGA的全部威力。它使页面翻转和滚动成为可能,并且我们可以访问大约3个屏幕页。每个页面在320x240分辨率下拥有方形像素,图像以60 Hz刷新。
如果我们调整刷新率和屏幕大小,最终会得到一种被称为模式Y的模式。它与模式X相同,但使用了熟悉的320x200分辨率,并具有70 Hz的刷新率。这为我们提供了恰好四个屏幕页,外加一些额外的行,我们可以以各种方式使用它们,并且仍然可以使用为320x200分辨率制作的所有图形资源。
设置模式Y
以下是设置模式Y的步骤:
- 首先,我们通过调用中断
0x10切换到模式13。 - 然后,我们将VGA定序器控制器中的内存模式寄存器设置为
0x06。查看该寄存器中的位,这意味着我们启用了256 KB RAM、顺序访问和链4模式。链4模式允许我们写入映射掩码寄存器,以选择当前哪个位平面可用于读取或写入。这是实际访问VGA卡全部内存的关键步骤之一。 - 其次,我们将CRTC下划线位置寄存器设置为
0,以禁用所谓的“字寻址模式”,这进一步允许我们访问所有视频内存。 - 最后,我们将CRTC模式控制寄存器设置为十六进制值
0xE3。查看字节模式,这最终启用了字节寻址,这是启用访问全部256 KB的最后一步。
如果这些对你来说意义不大,我们稍后会再回顾。你只需要查找这些值并将它们写入正确的寄存器。弄清楚如何做到这一点并不难,这可能是困难的部分,但早在30多年前,那些可能比我们聪明得多的人就已经完成了这项工作。无论如何,我们可以利用他们的知识,获得一个非常适合编程游戏的模式。
在模式Y中访问像素
现在,在模式Y中,我们如何使用映射掩码寄存器来访问单个像素呢?如前所述,我们用它来选择四个字节平面中的一个。
每个字节平面包含我们能看到的所有视频页,但只包含单个视频页的大约四分之一。因此,要访问一个像素,我们取像素的x坐标并计算 x % 4。例如,对于前四个像素,我们得到0,1,2,3,然后又回到0。所以,每第四个像素将在字节平面0上,然后是字节平面1、2、3,依此类推。
整个内存包含所有四个页面,我们可以将其可视化。这就是我们之前看到的内存布局,也是VGA卡实际看到的方式,以及它如何映射到段 0xA000。
我们可以使用映射掩码寄存器来,例如,选择蓝色字节平面并写入我们想要的所有像素。完成后,我们可以切换到红色、橙色和绿色字节平面。当然,这比我们在模式13中拥有的寻址方式要复杂得多。
你应该避免过于频繁地切换字节平面,因为这是一个非常耗时的操作。当你设置单个像素时,你可能每次都无法绕过选择映射掩码寄存器。但是,当你在进行块传输时,例如复制内存的整个部分,你应该总是先为字节平面0复制一帧所需的所有内容,然后只有当字节平面0中的所有操作都完成后,才切换到字节平面1,依此类推。
代码实现
现在,让我们看看代码是如何实际完成的。我们将增强之前的图像加载器,加载四张不同的图像,以展示四个VGA页面的使用。
我们将实现以下关键函数:
set_mode_y(): 初始化模式Y。set_pixel(): 在指定页面和坐标设置像素颜色(当前版本较慢)。copy_to_page(): 将源图像数据复制到指定的VGA页面。page_flip(): 执行页面翻转,交换前后缓冲区。
在 set_mode_y() 函数中,我们依次执行以下操作:
- 调用
set_mode(VGA_256_COLOR_MODE)进入模式13。 - 初始化四个页面的偏移量地址:
page_offset = (VGA_WIDTH * VGA_HEIGHT / 4) * page_index。 - 向定序器内存模式寄存器(索引
0x04)写入0x06,禁用链4模式。 - 向CRTC下划线位置寄存器(索引
0x14)写入0x00,禁用双字模式。 - 向CRTC模式控制寄存器(索引
0x17)写入0xE3,启用字节模式。 - 使用映射掩码寄存器选择所有平面(写入
0x0F),并清除全部256 KB视频内存。
set_pixel(page, x, y, color) 函数的工作流程如下:
- 计算目标像素所在的字节平面:
plane = x & 3。 - 向定序器映射掩码寄存器(索引
0x02)写入1 << plane,以选择对应的平面。 - 计算在选定平面内的内存地址:
address = page_offset + (VGA_WIDTH * y + x) / 4。 - 向
address处写入color。

copy_to_page(src, page_offset, height) 函数简单地遍历x和y坐标,对每个像素调用 set_pixel。
page_flip() 函数的核心是交换前后缓冲区的页偏移量指针,然后计算并设置CRTC的起始地址高位和低位寄存器,以告诉硬件从新的内存地址开始显示。为了无撕裂地切换,我们通常需要等待垂直回扫期。

总结
本节课中我们一起学习了神秘的VGA模式X/Y。我们了解了标准模式13的局限性,探索了通过直接编程VGA寄存器来解锁全部256 KB视频内存、实现页面翻转功能的方法。我们回顾了VGA的位平面内存架构,并动手实现了设置模式Y、像素写入、图像复制和页面翻转的代码。虽然目前的像素写入例程速度较慢,但它为我们构建更复杂的图形操作(如快速位块传输、滚动和精灵)奠定了坚实的基础。在接下来的课程中,我们将优化这些例程,并探索模式X/Y带来的更多可能性。
019:VGA Mode X 快速位块传输 🚀
在本节课中,我们将学习如何优化在VGA Mode X图形模式下将图像数据(位块)从内存传输到屏幕的过程。我们将通过改进现有的copy_to_page函数,实现一个名为blit_to_page的更快版本,并调整GIF加载器以支持Mode X格式的内存布局。
概述

上一节我们介绍了VGA Mode X的基本概念和页面翻转技术。本节中,我们将重点解决在Mode X下绘制图形(位块传输)速度过慢的问题。原始的逐像素设置方法效率低下,我们将通过按平面(plane)组织内存并进行批量复制来大幅提升性能。

问题分析:为什么原始的 copy_to_page 很慢?
原始的 copy_to_page 函数循环遍历每个像素,并使用 set_pixel 函数将其写入VGA内存。set_pixel 函数的核心问题在于,每次写入一个像素都需要通过两次I/O端口操作(OUT指令)来选择合适的图形平面(plane)。
核心瓶颈:
- 频繁的端口I/O操作:
OUT指令在x86架构上相对较慢。 - 单字节写入:每次只能写入一个字节,无法利用处理器的批量数据传输能力(如
REP MOVSB指令)。
这导致动画帧率极低,大约只有每秒1帧。
解决方案:blit_to_page 函数
为了解决上述问题,我们设计一个新的 blit_to_page 函数。其核心思想是:
- 按VGA的四个图形平面分别处理数据。
- 为每个平面只执行一次端口I/O操作来设置掩码。
- 然后使用内存复制指令(
memcpy)一次性复制整行数据。
以下是该函数的关键步骤和伪代码逻辑:
void blit_to_page(int page, byte* source, int x, int y, int width, int height) {
for (int plane = 0; plane < 4; plane++) {
// 1. 计算当前平面在源位图中的起始偏移量
dword bitmap_offset = plane * (width * height / 4);
// 2. 计算当前平面在目标VGA内存中的起始偏移量
// 考虑目标坐标(x,y)和平面偏移
int effective_plane = (plane + x) % 4;
dword screen_offset = (y * SCREEN_WIDTH + x) / 4;
// 3. 通过一次端口写入,设置当前要操作的VGA平面
set_plane_mask(1 << effective_plane);
// 4. 按行复制数据
for (int row = 0; row < height; row++) {
memcpy(VGA_MEM + page_offset + screen_offset,
source + bitmap_offset,
width / 4); // 每行数据量是宽度/4
// 更新偏移量,指向下一行
bitmap_offset += width / 4;
screen_offset += SCREEN_WIDTH / 4;
}
}
}
关键公式与概念:
- 平面计算:
plane = x % 4。在Mode X下,水平方向上每4个像素属于不同的图形平面。 - 内存布局:源图像数据在主内存中也必须按照“平面优先”的方式存储,即所有像素的第0平面数据连续存放,然后是第1平面,以此类推。这与VGA显存中的布局一致。
- 行复制:设置好目标平面后,可以一次性复制
width / 4字节的一整行数据,这比逐像素写入快几个数量级。
修改GIF加载器以支持Mode X格式
为了让 blit_to_page 函数正常工作,我们从GIF文件加载的图像数据在主内存中就必须是Mode X格式(平面化存储),而不是标准的线性(模式13h)格式。

我们需要修改GIF解码器中的 next_pixel 例程:
以下是修改后的像素存储逻辑:
if (decoder->mode_x) {
// Mode X存储方式
int plane = decoder->x % 4; // 确定像素属于哪个平面
int x1 = decoder->x / 4; // 平面内的x坐标
// 计算在平面化数组中的偏移量
dword offset = plane * (decoder->width * decoder->height / 4);
// 计算在目标平面行内的具体位置
offset += decoder->y * (decoder->width / 4) + x1;
// 存储像素颜色
image_data[offset] = color;
} else {
// 传统的线性存储方式 (用于模式13h)
image_data[decoder->y * decoder->width + decoder->x] = color;
}
修改要点:
- 向解码器状态添加一个
mode_x标志。 - 在加载GIF时,根据此标志决定以哪种格式将解压的像素存入内存数组。
- 对于Mode X,计算像素对应的平面和在平面内的新坐标,然后存入正确位置。
性能对比与测试
完成上述修改后,我们进行测试:
- 启用优化 (
mode_x = 1):动画运行极其流畅,帧率仅受垂直同步(约70Hz)限制,实现了每秒数十帧的渲染速度。 - 禁用优化 (
mode_x = 0):切换回原始的copy_to_page函数,动画变得非常卡顿,大约只有每秒1-2帧。


这个对比清晰地展示了优化带来的性能提升,可能达到两个数量级(100倍) 的差异。


即使在真实的386 PC机(ISA总线,约8-16MB/s带宽)上测试,优化后的位块传输速度也令人满意,足以支撑动态游戏场景的渲染。

总结

本节课中我们一起学习了如何为VGA Mode X实现高速位块传输(Blitting)。


核心收获:
- 理解了瓶颈:原始的逐像素写入因频繁的I/O端口操作而效率低下。
- 掌握了优化策略:通过按图形平面组织数据,将多次I/O操作减少为每平面一次,并利用
memcpy进行批量内存复制。 - 实现了完整链路:不仅改进了绘制函数 (
blit_to_page),还调整了资源加载器(GIF解码器),确保内存中的数据格式与VGA期望的格式匹配。 - 验证了效果:优化带来了巨大的性能提升,为后续实现游戏中的精灵(sprite)动画和动态场景打下了坚实基础。

现在,我们拥有了一个高效的图形渲染基础,可以在接下来的课程中构建更复杂的游戏对象和交互逻辑。
020:使用 x86 汇编编写 Hello World
概述
在本节课中,我们将学习如何使用 Turbo Assembler (TASM) 编写一个简单的 MS-DOS 汇编程序。我们将从设置环境开始,逐步编写一个能在屏幕上打印“Hello World”的程序,并了解汇编程序的基本结构。
环境准备与工具介绍
上一节我们介绍了汇编语言的基础概念,本节中我们来看看如何搭建一个简单的汇编开发环境。



首先,你需要一个汇编器。有多种选择,例如 Microsoft Macro Assembler、Netwide Assembler (NASM) 或 A86。在本系列中,我们使用与 Turbo C 2.01 配套的 Turbo Assembler 2.0。

你可以通过一些软件存档网站找到 Turbo Assembler 2.0。此外,SourceForge 上也有一个名为 “Gui Turbo Assembler” 的 Windows 版本,它包含了 TASM 和 TLINK。

安装过程很简单。运行安装程序,选择安装路径(例如 C:\TASM)。安装完成后,切换到 TASM 目录,你会看到许多示例文件,这对学习很有帮助。
以下是使用 TASM 和 TLINK 的基本流程:
- 使用
TASM命令将汇编源文件(.ASM)编译成目标文件(.OBJ)。 - 使用
TLINK命令将目标文件链接成可执行的.EXE文件。

汇编程序的基本结构
现在我们已经准备好了工具,接下来看看一个典型的 MS-DOS 汇编程序是什么样子的。
一个汇编程序由不同的“段”组成。最重要的几个段是:
- 代码段:存放程序指令。
- 数据段:存放变量和常量。
- 堆栈段:为函数调用和临时数据提供空间。
此外,我们通常会在程序开头使用 .MODEL 指令指定内存模型(如 SMALL),并使用 DOSSEG 指令确保段按照 MS-DOS 的约定顺序排列。
编写 Hello World 程序
了解了程序结构后,让我们动手编写第一个程序。我们将创建一个名为 HELLO.ASM 的文件。
首先,定义程序的基本框架:
.MODEL SMALL
.STACK 100h
.DOSSEG
.DATA
; 变量将在这里定义
.CODE
; 代码将在这里开始
接下来,在数据段(.DATA)中定义我们要显示的字符串。在汇编中,使用 DB(Define Byte)来定义字节数据。MS-DOS 的打印字符串功能要求字符串以美元符号 $ 结尾。
.DATA
hello DB ‘Hello World$‘
然后,在代码段(.CODE)中编写打印逻辑。MS-DOS 通过“中断”提供系统服务。打印字符串对应的是中断 21h 的功能 09h。
我们需要做三件事:
- 将字符串的段地址加载到
DS寄存器。 - 将字符串的偏移地址加载到
DX寄存器。 - 将功能号
09h放入AH寄存器,然后调用中断21h。
代码如下:
.CODE
start:
MOV AX, @data ; 获取数据段的地址
MOV DS, AX ; 将其设置到 DS 寄存器
LEA DX, hello ; 将 hello 字符串的偏移地址加载到 DX
MOV AH, 09h ; 设置功能号:打印字符串
INT 21h ; 调用 DOS 中断
最后,程序需要正确退出。我们使用中断 21h 的功能 4Ch。
MOV AX, 4C00h ; 设置功能号 4Ch (退出),返回码 00h (成功)
INT 21h ; 调用 DOS 中断
END start ; 程序入口点标记为 ‘start‘
编译、链接与运行
代码编写完成后,我们需要将其转换为可执行文件。
以下是操作步骤:
- 打开命令提示符,切换到源文件目录。
- 使用 TASM 进行汇编:
如果成功,将生成TASM HELLO.ASMHELLO.OBJ文件。 - 使用 TLINK 进行链接:
这将生成TLINK HELLO.OBJHELLO.EXE文件。 - 运行程序:
屏幕上应该会显示 “Hello World”。HELLO
程序改进:添加换行
你可能会注意到,如果连续打印多个字符串,它们会连在一起。这是因为打印字符串功能不会自动添加换行。
在 MS-DOS 中,换行由两个字符组成:回车(CR,ASCII 13)和换行(LF,ASCII 10)。我们可以在字符串中直接包含它们。
.DATA
hello DB ‘Hello World‘, 13, 10, ‘$‘
message DB ‘Route 42. Assembly, yay!‘, 13, 10, ‘$‘
.CODE
start:
MOV AX, @data
MOV DS, AX
LEA DX, hello
MOV AH, 09h
INT 21h ; 打印第一行
LEA DX, message
MOV AH, 09h
INT 21h ; 打印第二行
MOV AX, 4C00h
INT 21h
END start
重新汇编、链接并运行,现在两行文字就会分别显示了。
总结
本节课中我们一起学习了 x86 汇编编程的基础。我们了解了 MS-DOS 汇编程序的基本结构,包括代码段、数据段和堆栈段。我们学会了如何使用 DB 定义字符串,如何通过中断 21h 的功能 09h 在屏幕上输出文本,以及如何使用功能 4Ch 正确退出程序。最后,我们还掌握了如何为输出添加换行符。

虽然这个程序用 C 语言只需几行,但通过汇编语言,你能更深入地理解计算机底层是如何工作的。汇编程序通常非常紧凑,我们生成的这个可执行文件只有几百字节。在未来的课程中,我们可能会探索如何编写更小的 .COM 文件,甚至尝试用汇编语言编写简单的图形程序或游戏。
021:让我们编写正弦表
概述
在本节课中,我们将学习如何在MS-DOS汇编编程中使用正弦表。我们将探讨为何要使用预计算的正弦表来代替实时计算,并详细解释如何构建和使用这些表来创建平滑的动画和图形。
上一节我们介绍了图形编程的基础,本节中我们来看看如何利用数学函数来优化动画效果。
正弦表的作用
一位观众提出,不理解我在不同程序中使用正弦表的目的。正弦表在多个程序中被使用,例如在“铜条”程序中,用于通过正弦波驱动动画。
如果你不了解正弦波,建议查阅相关资料。这是基础的三角函数知识。近期有一个视频很好地解释了正弦函数,我可能会在卡片中链接它。
在动画制作中,正弦和余弦是基础。我们使用正弦和余弦表的原因是:实时计算正弦和余弦的代价非常高昂。
实时计算的成本
让我们看看8087指令集。8087是PC的浮点协处理器。在486之前的时代,并非所有机器都配备协处理器。对于FSIN和FCOS指令,它们仅在387协处理器上被引入。
执行一次正弦或余弦计算需要122到771个时钟周期。相比之下,简单的移动操作或某些基础操作只需几个周期。因此,即使使用协处理器,计算正弦和余弦也极其昂贵。
8087和287协处理器不直接支持这些指令,因此你需要编写自己的近似算法。但我们希望避免这种情况,因为我们可以有现成的实现。
然而,我们希望程序运行得更快,因此使用查表法来替代实时计算。
构建正弦表
我最终使用了类似下面的表达式,它看起来复杂,但实际上并不难理解。
value = (int)( (cos( (i / 256.0) * 2 * PI ) + 1) * 100 );
我将绘制这个函数的图形,以展示它的样子。通常你会问,为什么不直接取余弦值存入表中?我们可以试试。
假设 i 从0到256,因为我们想存储256个值。运行后,我们得到一条近乎平坦的线,偶尔有凹陷,它位于屏幕顶部,看起来并不好。


这是因为余弦函数的值域在 -1 到 1 之间,这最多只能产生几个像素的变化。正弦函数同理,因为正弦和余弦只是在水平轴上有所偏移。
调整振幅
我们可以通过乘以一个系数来增大函数的振幅。例如,乘以10会使垂直方向的值增大10倍。



这样做之后,不再是一条线,而是变成了一堆散点。这是因为我们给函数输入了0到256的值,而正弦和余弦的定义域是弧度。
弧度是角度的度量单位。一个完整的圆(360度)等于 2π 弧度。


因此,我们需要一个大约 2π 大小的输入。余弦是周期函数,一个完整周期后会重复,所以我们只需要存储一个周期。

我们可以尝试 2 * PI * i。但这会得到更糟糕的结果,是乱码。
我们真正需要的是0到 2π 之间的值。可以通过 i / 256.0 来获得。这里的 .0 很重要,否则将是整数除法,结果总是0或1。
如果 i=0,得到0;i=1,得到 1/256;i=256,得到 256/256 = 1。这样我们就得到了从0到1递增的序列。
仅从0到1,图形看起来已经好一些,但会突然下降,因为我们只从0到1,而不是0到 2π(约6.28)。


因此,我们需要将这个结果乘以 2π,以获得0到 2π 之间的值。




运行后会得到一个相当平滑的函数,但由于某些原因会超出屏幕。


这是因为余弦值在 -1 到 1 之间。当它为-1时,会跑到屏幕上方绘制,然后下降再上升。
我们需要进一步处理:将余弦函数的结果加上1。这样,-1变为0(屏幕顶部),+1变为+2。再乘以10,我们得到0到20之间的值,应该能相对较大地显示在屏幕上。


确实,我们在屏幕的前10%区域得到了一个非常平滑的余弦函数图形,看起来很不错。

适配屏幕
如果想使用整个屏幕宽度来绘制,需要进一步缩放。通常你会使用2的幂次方进行乘法,但我们是为屏幕绘制。
我们的屏幕高度是200像素。目前表达式产生0到2之间的值。如果乘以100,将得到0到200。这样整个表达式就产生0到200的值,完美适配屏幕的200条扫描线。
运行后,会得到一个全屏的余弦函数图形。由于分辨率不高,像素点之间的跳跃会大于一个像素,所以会有一些空隙。



但可以看到,这是一个非常完美的余弦函数。它是可用的。
调整频率
我们可以通过调整输入参数来增加或减少频率。例如,不直接用 2π * i / 256.0,可以乘以一个系数,比如乘以4来提高频率。




这样会得到高频的余弦曲线,适合快速动画。



或者可以使其更慢,例如乘以0.5。


会得到非常宽缓的曲线。实际上,它已经不适合我们的表了,所以会出现跳跃。



但我们不需要那样。我们只需要精确的周期函数,因为之后可以缩放。当数组用尽并从头开始时,你会得到周期性的结果。


这就是为什么你会像这样计算正弦表和余弦表,然后在函数中使用它。
应用示例
以下是我绘制图形时使用的函数。我取X坐标,并确保在超出320像素后回绕到左侧。在Y轴上,我们使用余弦函数。我们可以用 frame % 256,也可以在这里增加频率,例如 frame * 2,这会得到更短的周期。


或者乘以8,当然分辨率会变差。



我们甚至可以右移一位(相当于除以2),或者使用其他工具。这样会再次得到一条漂亮的低频曲线。当然,由于量化,会出现一些间隙,但对于动画来说足够好,而且速度极快。
你甚至可以预计算多个不同分辨率的正弦表,当然这需要存储更多样本。

绘制圆形和利萨如图形
我们可以同时使用两个值来绘制不同部分。例如,用正弦控制X方向,余弦控制Y方向,不做任何调整。

将这两个函数这样结合,会得到一个非常重要的图形:圆形。


这会画出一个圆。为了得到完美的圆(在像素上),我们需要缩放。在320x200模式下,像素不是正方形,有点压扁。所以我们需要在X方向也乘以约1.2的因子。


这样就能得到一个完美的圆。你可以用它让物体在屏幕上做圆周运动。
通过操纵输入频率并使用不同的相位偏移(余弦本身就有四分之一圆的相位偏移),你甚至可以绘制所谓的利萨如图形,得到非常漂亮的图案。


这是一个利萨如图形,你可以绘制更多。显然,这比实时计算快得多。
性能对比
让我们看看这大致需要多少开销。这是整数代码。MOV 指令将数据从内存移动到寄存器,在286上大约需要13+个周期,在8088上大约需要13+个周期。这比使用 FSIN 指令至少快一个数量级。
在386上,MOV 只需2个周期,而 FSIN 至少需要200个周期,这快了两个数量级。因此,使用查表法无疑快得多,而且非常实用。
许多演示程序都使用这个技巧。几乎所有使用动画的演示都会用到它。

总结
本节课中我们一起学习了在MS-DOS汇编程序中使用预计算正弦表的原因和方法。我们了解到实时计算三角函数的成本很高,而查表法能极大提升性能。我们逐步构建了一个正弦表,并演示了如何用它来生成平滑的动画波形、圆形以及利萨如图形。关键在于将弧度计算、值域映射和屏幕坐标缩放结合起来。掌握这个技巧,你就能高效地创建各种动态图形效果。
022:实现火焰特效 🔥


在本节课中,我们将学习如何在MS-DOS环境下,使用C语言和VGA图形模式,实现一个经典的火焰特效。我们将从算法原理开始,逐步编写代码,并利用VGA显卡的特性进行优化,最终得到一个运行流畅的火焰效果。

概述
火焰特效是一种经典的计算机图形效果,曾出现在许多演示程序和游戏中。其核心算法是通过在屏幕底部随机生成“燃料”像素,然后逐行向上计算模糊效果,模拟火焰的上升和扩散。虽然计算量较大,但通过巧妙的优化和利用VGA显卡的特定模式,我们可以在老旧的硬件上实现可观的帧率。
准备工作
在开始编写代码之前,我们需要一些基础。本教程基于之前的课程,因此你需要了解 types.h 和 vga.h 头文件。如果你还没看过之前的课程,建议先回顾一下。
我们还需要一个调色板文件 palette.h,它定义了火焰的颜色。为了简化,我们直接使用一个现成的256色调色板数据。
此外,我们将使用一个位于系统内存中的帧缓冲区(frame buffer),因为对系统内存的读写通常比对VGA显卡的直接操作更快,这对于我们的优化至关重要。
我们将定义简单的 SET_PIXEL 和 GET_PIXEL 宏来操作这个帧缓冲区,其逻辑与标准的VGA 13h模式类似。
#define SET_PIXEL(fb, x, y, c) (fb[(y) * SCREEN_WIDTH + (x)] = (c))
#define GET_PIXEL(fb, x, y) (fb[(y) * SCREEN_WIDTH + (x)])
注意,在定义这样的宏时,务必将参数用括号括起来,以避免当参数是表达式时可能出现的运算优先级问题。
主程序结构
上一节我们介绍了基础概念和宏定义,本节中我们来看看主程序 main 函数的整体结构。程序主要分为初始化、主循环和清理三个阶段。
以下是主函数的主要步骤:
- 变量声明:我们需要一个变量来记录燃料强度(
fuel_intensity),一个帮助退出的变量,以及循环计数器。 - 初始化随机数生成器:使用
srand函数,基于时间生成不同的随机序列,确保每次运行火焰效果都不同。 - 设置图形模式:切换到 Mode Y(320x200,4个视频页)。这是一种特殊的VGA模式,能帮助我们实现快速缩放。
- 设置调色板:载入预定义的火焰颜色。
- 分配帧缓冲区:在内存中分配一块与屏幕分辨率相同大小的区域。
- 清空帧缓冲区:将帧缓冲区所有像素值设为0,避免显示垃圾数据。
- 主循环:
- 检测键盘按键,实现火焰的淡出效果。
- 调用
draw_fuel函数在屏幕底部绘制燃料。 - 调用
draw_fire函数计算并绘制火焰效果。 - 调用
blit4函数将帧缓冲区的内容快速缩放并显示到屏幕上。
当用户按下按键后,程序会逐渐降低燃料强度,使火焰慢慢熄灭,然后退出图形模式。
绘制燃料
在了解了主循环后,我们首先实现最简单的部分:draw_fuel 函数。它的作用是在屏幕底部生成随机的“热煤”像素。
draw_fuel 函数接收一个矩形区域参数,但实际只在该区域的底部一行绘制像素。其逻辑如下:
- 遍历指定宽度内的每一个X坐标。
- 为每个位置生成一个随机字节值。
- 如果这个随机值大于128,则使用当前的
fuel_intensity作为像素亮度;否则,亮度为0。这种“开关”式随机性使得火焰底部更具闪烁感和扰动感。 - 使用
SET_PIXEL宏将这个亮度值写入帧缓冲区对应的底部位置。
你可以修改这个随机逻辑,例如使用平滑的随机值,或者绘制特定形状(如文字、圆圈)作为燃料源,来创造不同的火焰起源效果。
核心算法:绘制火焰
现在,我们进入最核心的部分:draw_fire 函数。这个函数实现了火焰向上蔓延和模糊的效果。
算法原理
火焰效果的原理是对每个像素,取其下方及左右相邻共3个像素(有时是3x3区域)的颜色值进行平均,然后将这个平均值稍作衰减后,作为当前像素的新颜色。这样,每一帧中,底部的热源(燃料)颜色会向上“传播”并逐渐变暗、模糊。
基础实现与优化
最直接的方法是使用9次 GET_PIXEL 调用和求和运算,但这样效率很低。
我们注意到,当从左到右、从上到下遍历像素时,每次移动到下一个像素,只有3个新的相邻像素需要读取(当前像素右下方的3个)。我们可以利用这个特性进行优化:
- 我们维护一个大小为3的环形缓冲区(
sum_buffer),用于存储当前计算涉及的3个像素列的临时和。 - 在遍历每个像素时,我们计算并更新这个缓冲区。
- 当前像素的新颜色值,可以通过对这个缓冲区中的3个值求和,再除以一个系数(如9),并减去一个衰减值(如2)来得到。衰减是为了防止火焰无限增强。
这种优化将9次像素读取和复杂的求和,简化为3次读取和一次简单的缓冲区更新与计算,显著提升了性能。
以下是优化后核心计算的示意代码:
// 计算并更新环形缓冲区中对应于当前列索引 (i % 3) 的值
sum_buffer[idx] = GET_PIXEL(...) + GET_PIXEL(...) + GET_PIXEL(...);
// 计算当前像素的新颜色:对缓冲区三个值求和、平均、衰减
color = (sum_buffer[0] + sum_buffer[1] + sum_buffer[2]) / 9;
if(color > 2) color -= 2;
else color = 0;
SET_PIXEL(framebuffer, i, j, color);
关键优化:快速缩放显示
我们已经优化了火焰的生成算法,但最大的性能瓶颈可能在于将计算结果显示到屏幕上。本节中我们来看看如何利用VGA Mode Y的特性进行快速缩放。
blit4 函数是我们的显示利器。它的目标是将内存中 80x50 分辨率的帧缓冲区,快速放大4倍,以 320x200 的全屏分辨率显示出来。
原理
在 Mode Y 下,屏幕内存被组织成4个位平面(bit planes)。通过一次写入操作,可以同时影响4个水平相邻的像素。这相当于免费获得了4倍的水平缩放。
实现步骤
以下是 blit4 函数的工作流程:
- 设置图形控制器:通过写入特定端口,告诉VGA卡我们将同时对4个位平面进行写入操作。
- 计算偏移:
src_offset:源帧缓冲区中的像素索引。screen_offset:目标屏幕内存中的字节地址。由于一次写影响4像素,所以目标地址是src_offset / 4。
- 循环复制:
- 外层循环遍历每一行(50行)。
- 对于每一行,我们需要在垂直方向上也复制4次以实现4倍缩放。因此,内层循环执行4次,将同一行源数据写入屏幕的连续4行。
- 每次内层循环,更新
screen_offset以指向下一行。 - 每完成一行源数据的处理,才更新
src_offset以指向下一行源数据。
通过这种方式,我们仅通过内存复制和VGA的特殊写入模式,就高效地完成了4x4的缩放显示,这是帧率得以大幅提升的关键。
如果你想获得2倍缩放而非4倍,可以调整设置,使其同时写入2个相邻的位平面,并相应修改循环逻辑。



总结
本节课中,我们一起学习了如何在MS-DOS环境下实现一个优化的火焰特效。
- 我们首先了解了火焰特效的基本算法:底部随机生成燃料,并通过向上平均相邻像素来模拟火焰的上升与模糊。
- 接着,我们构建了程序的主框架,处理了初始化、主循环和退出逻辑。
- 然后,我们实现了
draw_fuel函数来生成随机燃料源。 - 核心部分是
draw_fire函数,我们通过引入环形缓冲区和对相邻像素读取的优化,显著减少了计算量。 - 最后,我们利用VGA Mode Y的特性,编写了
blit4函数,将低分辨率的火焰缓冲区快速缩放到全屏显示,这是提升整体性能的最重要一步。

通过本教程,你不仅学会了一个经典图形效果的实现,更掌握了在受限环境下进行算法和硬件级优化的实用思路。你可以尝试修改燃料生成逻辑、火焰衰减系数或缩放因子,来创造出属于你自己的独特火焰效果。
023:让我们来扭曲 - 编写扭曲条效果教程


概述
在本节课中,我们将学习如何在MS-DOS环境下,使用C语言和VGA图形模式,实现一个经典的“扭曲条”动画效果。我们将从零开始,逐步构建代码,理解其背后的数学原理,并最终在屏幕上看到动态的扭曲动画。
准备工作
上一节我们介绍了火苗效果的实现,本节中我们来看看如何创建一个“扭曲条”效果。这个效果在Atari 2600和Amiga等老式平台上很常见,但在PC的VGA卡上实现则需要更多的CPU计算。

首先,我们需要一个基础的程序框架。我们将复用之前教程中的VGA初始化代码和正弦查找表。
以下是初始化步骤:
- 包含必要的头文件并定义屏幕尺寸。
- 初始化随机数生成器,为动画提供一个随机的起始状态。
- 初始化一个包含4096个条目的正弦查找表,以获得更高的精度。
- 设置VGA图形模式(模式13h)。
- 加载一个合适的调色板(例如火苗效果的调色板)。
- 分配一个离屏帧缓冲区。
#include <conio.h>
#include <dos.h>
#include <malloc.h>
#include <stdlib.h>
#include “vga.h”
#define SCREEN_WIDTH 320
#define SCREEN_HEIGHT 200
#define SCREEN_SIZE (SCREEN_WIDTH * SCREEN_HEIGHT)
unsigned char far *framebuffer;
int frame = 0;
主程序循环
设置好图形环境后,我们需要一个主循环来驱动动画。这个循环负责清除帧缓冲区、绘制扭曲条、等待垂直回扫以避免屏幕撕裂,最后将帧缓冲区复制到显存。
以下是主循环的核心步骤:
- 使用
memset将帧缓冲区清零。 - 调用
draw_twister函数,传入位置、大小和当前帧数。 - 调用
wait_for_retrace函数,等待显示器垂直回扫。 - 使用
memcpy将整个帧缓冲区快速复制到VGA内存。 - 递增帧计数器,以推进动画。
- 检测键盘输入,以便在按下任意键时退出程序。
void main() {
// ... 初始化代码(设置模式、调色板、分配内存等)...
while (!kbhit()) {
// 1. 清除离屏缓冲区
memset(framebuffer, 0, SCREEN_SIZE);
// 2. 在位置(100, 0)绘制一个128x200的扭曲条
draw_twister(100, 0, 128, 200, frame);
// 3. 等待垂直回扫
wait_for_retrace();
// 4. 复制到显存
memcpy(VGA_PTR, framebuffer, SCREEN_SIZE);
// 5. 更新动画帧
frame++;
}
// 6. 退出前恢复文本模式
set_text_mode();
}
绘制扭曲条算法
draw_twister函数是这个效果的核心。其原理是计算一个旋转的矩形条在屏幕上的投影。这个矩形条有四个角,每个角的X坐标由一个正弦波函数决定,从而产生扭曲效果。
我们首先需要定义一些变量:
amplitude:控制扭曲的幅度。x_mod:在X方向添加额外的正弦调制,产生弯曲的波浪效果。x1, x2, x3, x4:扭曲条四个角在屏幕上的X坐标。
算法的核心是一个遍历矩形条高度的循环。对于每一行(Y坐标),我们计算当前行的振幅和X调制量,然后据此计算出四个角点的X坐标。
void draw_twister(int x0, int y0, int width, int height, int time) {
int amplitude, x_mod;
int x1, x2, x3, x4;
int y;
for (y = y0; y < y0 + height; y++) {
// 计算基础振幅:基于时间和Y坐标的正弦波
amplitude = sin_table[((time << 4) + (y << 1)) & 0xFFF];
amplitude = (amplitude * width) >> 2; // 缩放至宽度的1/4
// 计算X方向的调制波
x_mod = sin_table[((time << 4) + (y << 2)) & 0xFFF];
x_mod = (x_mod * width) >> 3; // 缩放至宽度的1/8
x_mod += x0; // 加上基础X位置
// 计算四个角点的X坐标,每个相差90度(1024个条目中的256个)
x1 = x_mod + (sin_table[((time << 4) + (y << 1)) & 0xFFF] * amplitude >> 2);
x2 = x_mod + (sin_table[((time << 4) + (y << 1) + 256) & 0xFFF] * amplitude >> 2);
x3 = x_mod + (sin_table[((time << 4) + (y << 1) + 512) & 0xFFF] * amplitude >> 2);
x4 = x_mod + (sin_table[((time << 4) + (y << 1) + 768) & 0xFFF] * amplitude >> 2);
// ... 接下来绘制可见的边 ...
}
}
绘制可见边并上色
计算出四个角点的坐标后,我们需要决定绘制哪几条边。由于矩形条在旋转,我们只能看到朝向观众的那两面。判断规则是:如果前一个角的X坐标小于后一个角的X坐标,则这条边是可见的。
对于每一条可见的边,我们调用h_line函数绘制一条水平线。为了增强视觉效果,我们使用不同的颜色绘制每条边,并在绘制一条线时让颜色值递增,从而产生平滑的渐变着色效果。
以下是绘制边的逻辑:
- 如果
x1 < x2,则在当前Y坐标,从x1到x2画一条颜色为33的水平线。 - 如果
x2 < x3,则从x2到x3画一条颜色为49的水平线。 - 如果
x3 < x4,则从x3到x4画一条颜色为65的水平线。 - 如果
x4 < x1,则从x4到x1画一条颜色为81的水平线。
// 在循环内部,计算完x1,x2,x3,x4后:
if (x1 < x2) {
h_line(x1, x2, y, 33);
}
if (x2 < x3) {
h_line(x2, x3, y, 49);
}
if (x3 < x4) {
h_line(x3, x4, y, 65);
}
if (x4 < x1) {
h_line(x4, x1, y, 81);
}
绘制水平线函数
h_line函数是一个简单的实用函数,用于在帧缓冲区中绘制一条水平线段。它接收起点和终点的X坐标、Y坐标以及颜色值。
函数首先进行简单的边界检查,确保Y坐标在屏幕范围内。然后,它遍历从x_start到x_end(确保x_start <= x_end)的每个X坐标,将对应的像素设置为指定颜色。在绘制过程中,颜色值会轻微递增,以产生微妙的渐变效果。
void h_line(int x_start, int x_end, int y, unsigned char color) {
int x;
if (y < 0 || y >= SCREEN_HEIGHT) return; // 边界检查
// 确保x_start是较小的那个
if (x_start > x_end) {
int temp = x_start;
x_start = x_end;
x_end = temp;
}
// 裁剪X坐标到屏幕范围内
if (x_start < 0) x_start = 0;
if (x_end >= SCREEN_WIDTH) x_end = SCREEN_WIDTH - 1;
for (x = x_start; x <= x_end; x++) {
framebuffer[y * SCREEN_WIDTH + x] = color++;
// 颜色递增产生渐变
}
}

效果优化与扩展
现在,一个基本的扭曲条效果已经完成了。它在486 33MHz的机器上可以流畅运行。你可以通过调整代码中的参数来改变效果。
以下是一些可以尝试的优化与扩展方向:
- 调整参数:修改
draw_twister函数中与时间、Y坐标相关的移位和缩放因子,可以改变扭曲的速度、波浪形状和幅度。 - 移除渐变:在
h_line函数中固定颜色值不递增,可以提升一些绘制速度。 - 添加纹理:不再绘制纯色水平线,而是根据一个纹理数组来获取每个像素的颜色,可以在扭曲条上显示文字或图案。
- 多个扭曲条:同时绘制多个位置、大小或运动速度不同的扭曲条,创造更复杂的场景。
- 尝试Mode X/Y:使用VGA的Mode X或Mode Y图形模式,利用其分页特性可能实现更高效的绘制(尽管代码会更复杂)。
总结

本节课中我们一起学习了如何在MS-DOS的VGA图形模式下实现“扭曲条”动画效果。我们了解了其核心算法:通过正弦波函数计算一个旋转矩形条的投影,并仅绘制其可见边。我们构建了从图形初始化、主循环到具体绘制函数的完整代码流程,并看到了如何通过简单的整数运算和调色板技巧来创造视觉上吸引人的动态效果。这个效果是纯CPU计算的,体现了复古编程的独特魅力。你可以基于这个基础,自由地调整参数和添加新功能,创造出属于自己的演示场景效果。
024:让我们编写MS-DOS 0x18 - VGA等离子效果
概述
在本节课中,我们将学习如何在MS-DOS环境下,使用Turbo C语言实现一个经典的“等离子”视觉效果。这个效果是许多演示程序中的标志性元素,我们将通过组合正弦函数来创建动态、色彩斑斓的波浪图案。
等离子效果的原理
等离子效果的核心是数学函数。它通常由几个简单的正弦波图案组合而成。
以下是构成该效果的基本元素:
- 垂直条纹:一个基于屏幕X坐标和时间的正弦函数,会产生水平滚动的波浪。
- 水平条纹:一个基于屏幕Y坐标和时间的正弦函数,会产生垂直滚动的波浪。
- 旋转图案:一个更复杂的项,使用
x * sin + y * cos的公式来创建旋转的圆形条纹。
通过将这些不同频率和速度的正弦值相加并映射到颜色,就能产生复杂的、不断变化的等离子效果。
项目基础
我们将基于之前课程中的“火焰效果”程序进行修改。该程序使用了mode Y(13h模式的一种变体)和blocky B4函数来实现快速的低分辨率渲染,即使在旧机器上也能流畅运行。
我们移除了绘制火焰的例程,取而代之的是一个draw_plasma函数。这个函数接收一个时间参数t,用于驱动动画。程序初始化时会随机化起始时间,使每次启动的效果略有不同。

为了提高性能,我们预先计算了一个包含256个值的正弦查找表(sinetable),避免在渲染循环中进行耗时的浮点正弦计算。

绘制等离子效果
现在,让我们深入draw_plasma函数的具体实现。我们将循环遍历屏幕上的每个像素,并根据其坐标和时间计算颜色值。
以下是实现步骤:
- 定义变量:我们需要循环变量
i(X坐标)和j(Y坐标),以及用于存储各分项颜色值的变量c1,c2,c3。为了在相加时避免溢出,这些变量应使用16位整型(如int)。 - 嵌套循环:外层循环遍历Y轴,内层循环遍历X轴,访问每个像素。
- 计算颜色分量:
c1 = sine((i * scale1 + t) % 256):产生垂直移动的条纹。c2 = sine((j * scale2 + t) % 256):产生水平移动的条纹。c3的计算涉及旋转图案,稍后详细说明。
- 合并颜色:将
c1,c2,c3相加后除以3(或求平均值),得到最终的颜色索引c。 - 设置像素:使用
set_pixel(i, j, c)函数将计算出的颜色写入屏幕。

实现旋转图案
旋转图案是效果的关键,它使用三角学来旋转坐标空间。
实现旋转图案的步骤如下:
- 计算旋转因子:根据时间
t计算正弦(v1)和余弦(v2)值。注意,余弦可以通过将正弦表的索引偏移64(即90度)来获得。v1 = sine((t * rotation_speed1) % 256); v2 = sine((t * rotation_speed2 + 64) % 256); // +64 得到余弦 - 应用旋转变换:对于每个像素
(i, j),计算旋转后的坐标分量。rotated_value = (i * v1 / SCREEN_WIDTH) + (j * v2 / SCREEN_HEIGHT) + t; - 生成图案:将上述结果代入正弦函数,得到旋转的颜色分量。
这里对c3 = sine(rotated_value % 256);i和j进行归一化(除以屏幕宽高)是为了防止数值过大导致溢出和视觉失真。
优化性能
最初的实现可能比较慢。一个关键的优化点是识别并提取循环中不依赖于内层循环变量的计算。
例如,c2 的计算只依赖于j和t,而t在函数调用期间不变,j只在外层循环中变化。因此,我们可以将c2的计算移到内层i循环之外,在外层j循环中计算一次并重复使用。同样,旋转因子v1和v2只依赖于t,可以提到所有循环之前计算。
这种优化能显著提升执行速度,即使编译器没有自动进行,在编写汇编代码时也需要手动处理。
效果调整与运行
我们可以通过调整各个正弦函数中的缩放系数(如i*3, t*5)和时间增量来改变等离子效果的波动速度、条纹密度和运动模式。不同的调色板(这里沿用了火焰效果的调色板)也会极大地改变视觉效果。
此程序设计为在80x50的低分辨率下运行以确保流畅性。你也可以尝试修改为标准的13h模式(320x200),但请注意在真正的旧硬件上帧率可能会下降。

在真实的486计算机和MS-DOS环境下测试,这个优化后的程序能够流畅运行,产生令人满意的等离子动画效果。

总结
本节课中,我们一起学习了如何在MS-DOS环境下创建VGA等离子效果。我们了解了其背后的数学原理——主要是正弦函数的组合应用。我们从基础循环结构开始,逐步实现了垂直、水平和旋转的图案,并通过预计算正弦表和提取循环不变计算等技巧优化了性能。最终,我们获得了一个在旧硬件上也能流畅运行的、色彩斑斓的动态演示效果。你可以自由调整参数和颜色来创造属于自己的独特等离子秀。
025:使用PowerBasic与汇编语言制作烟花效果
在本节课中,我们将学习如何使用PowerBasic编程语言,结合少量x86汇编代码,在MS-DOS的EGA/VGA图形模式下创建一个烟花模拟效果。我们将从初始化图形模式开始,逐步实现粒子的物理模拟、绘制和爆炸效果。
概述与准备工作
首先,我们需要设置编程环境。我们使用的是PowerBasic 3.2,它可以编译成独立的EXE文件。代码也适用于免费的运行时版本,只需稍作修改。
我们首先初始化图形模式。在PowerBasic中,使用 SCREEN 命令。我们将使用EGA标准的320x200分辨率、16色模式,即 SCREEN 7。程序结束时,需要切换回文本模式 SCREEN 0。
以下是程序的主循环结构:
SCREEN 7
DO
' 更新和绘制粒子的代码将放在这里
LOOP UNTIL INKEY$ <> ""
SCREEN 0
为了实现无闪烁动画,我们将使用双缓冲技术。EGA/VGA显卡支持多个显示页。我们定义两个变量 active 和 visible 来代表前后缓冲区,并在每帧交换它们。
active = 1
visible = 0
DO
' ... 计算和绘制代码 ...
SWAP active, visible
SCREEN , , active, visible
LOOP
为了将帧率同步到显示器的垂直刷新率,我们需要一个垂直同步(Vsync)例程。这可以避免屏幕撕裂。
实现垂直同步(Vsync)
垂直同步的原理是等待显示器完成一次垂直回扫。在VGA显卡上,我们可以通过读取输入状态寄存器(端口 &H3DA)来检查垂直回扫位(第3位)。
以下是使用BASIC内联汇编实现的高效Vsync例程:
SUB Vsync INLINE
! CLI ; 清除中断标志
! MOV DX, &H3DA ; VGA输入状态寄存器端口
Vsync1:
! IN AL, DX ; 读取状态
! TEST AL, 8 ; 测试垂直回扫位
! JNZ Vsync1 ; 如果在回扫期,则等待其结束
Vsync2:
! IN AL, DX
! TEST AL, 8
! JZ Vsync2 ; 等待进入新的回扫期
! STI ; 恢复中断标志
END SUB
在程序的主循环中,我们将在交换显示页之前调用 Vsync。
定义全局变量与数组
接下来,我们定义模拟所需的全局变量和数组。
frame: 帧计数器。t#: 模拟时间。g#: 重力常数,我们使用一个缩放后的值0.00981。dt#: 时间增量,设为0.05。n: 当前活跃的粒子数。isExploded: 标记火箭是否已爆炸。- 数组
x#(255),y#(255),vx#(255),vy#(255): 分别存储粒子的位置和速度。我们预留了256个位置。
DIM x#(255), y#(255), vx#(255), vy#(255)
frame = 0
t# = 0
g# = 0.00981
dt# = 0.05
n = 1
isExploded = 0
初始化新火箭
火箭本身被视为一个粒子。InitNewRocket 子程序负责初始化这个粒子的位置和速度。
- 初始X位置: 屏幕中部(120到200像素之间的随机值)。
- 初始Y位置: 屏幕底部(我们将在绘制时转换为屏幕坐标)。
- 初始X速度:
-1到1之间的随机值。 - 初始Y速度:
-3到-1之间的随机值(负值表示向上)。
SUB InitNewRocket (x#(), y#(), vx#(), vy#())
RANDOMIZE TIMER
x#(0) = 0
y#(0) = 0
x0# = 120 + 80 * RND
y0# = 0
vx#(0) = 2 * RND - 1
vy#(0) = - (1 + 2 * RND)
END SUB
程序启动时,我们需要调用一次 InitNewRocket 来创建第一个火箭。
更新粒子状态
UpdateParticles 子程序根据物理公式更新所有粒子的位置。我们使用经典的抛射体运动公式:
- 水平位移公式:
x = x0 + vx * t - 垂直位移公式:
y = y0 + vy * t + 0.5 * g * t^2
以下是该子程序的实现:
SUB UpdateParticles (t#, n, x#(), y#(), vx#(), vy#())
FOR i = 0 TO n - 1
x#(i) = x#(i) + vx#(i) * t#
y#(i) = y#(i) + vy#(i) * t# + 0.5 * g# * t# * t#
NEXT i
END SUB
在主循环中,我们每帧都会调用此子程序,并增加模拟时间 t#。
绘制粒子
DrawParticles 子程序负责将粒子绘制到屏幕上。我们使用 PSET 命令来画点。
- 颜色: 我们使用帧计数器对15取模,然后加1,使颜色在1到15之间循环,产生闪烁效果。
- 坐标转换: 我们的模拟坐标系原点在屏幕左下角,但屏幕坐标原点在左上角。因此,Y坐标需要转换:
screenY = 199 - y。
SUB DrawParticles (n, x#(), y#(), frame)
colour = (frame MOD 15) + 1
FOR i = 0 TO n - 1
PSET (x#(i), 199 - y#(i)), colour
NEXT i
END SUB
初始化爆炸效果
当火箭到达顶点或特定时间后,它会爆炸,生成许多新的粒子。InitExplosion 子程序负责初始化这些爆炸粒子。
- 所有粒子从火箭的最终位置
(x1#, y1#)开始。 - 每个粒子被赋予一个随机的速度和方向(角度),使其从中心向外呈圆形散开。
- 速度大小在
0.5到2.0之间随机。 - 角度在
0到2π之间随机。
速度的X和Y分量通过三角函数计算:
vx = v0 * COS(angle)vy = v0 * SIN(angle)
SUB InitExplosion (n, x#(), y#(), vx#(), vy#(), x1#, y1#)
FOR i = 0 TO n - 1
x#(i) = x1#
y#(i) = y1#
v0# = 0.5 + 1.5 * RND
angle# = 6.283185 * RND
vx#(i) = v0# * COS(angle#)
vy#(i) = v0# * SIN(angle#)
NEXT i
END SUB
主程序逻辑与循环
现在,我们将所有部分组合到主循环中。核心逻辑是:
- 每帧更新粒子位置并绘制。
- 每170帧触发一个事件:如果火箭未爆炸,则使其爆炸;如果爆炸动画已结束,则发射一枚新火箭。
- 在爆炸期间,粒子数
n会变为100;在新火箭期间,粒子数n重置为1。
以下是主循环的完整结构:
SCREEN 7
active = 1: visible = 0
InitNewRocket(x#(), y#(), vx#(), vy#())
DO
' 1. 清空当前活动页
SCREEN , , active, visible
CLS
' 2. 更新和绘制粒子
UpdateParticles t#, n, x#(), y#(), vx#(), vy#()
DrawParticles n, x#(), y#(), frame
' 3. 每170帧处理爆炸/发射新火箭
IF (frame MOD 170) = 0 THEN
t# = 0
IF isExploded THEN
' 爆炸结束,发射新火箭
isExploded = 0
n = 1
InitNewRocket(x#(), y#(), vx#(), vy#())
ELSE
' 火箭爆炸
isExploded = 1
n = 100
InitExplosion n, x#(), y#(), vx#(), vy#(), x#(0), y#(0)
END IF
ELSE
' 4. 更新模拟时间
t# = t# + dt#
END IF
frame = frame + 1
' 5. 垂直同步并交换页面
Vsync
SWAP active, visible
LOOP UNTIL INKEY$ <> ""
SCREEN 0

编译与运行

在编写完所有代码后,我们需要设置编译选项以获得最佳性能:
- 设置CPU目标为至少80286。
- 仅链接EGA图形库以减少代码大小。
- 关闭数组边界检查等运行时检查。
- 优化速度。





使用PowerBasic编译器进行编译,生成EXE文件。在MS-DOS或DOSBox中运行该程序,即可看到火箭发射、爆炸成烟花的效果。











总结





本节课中,我们一起学习了如何使用PowerBasic在MS-DOS环境下创建一个图形化的烟花模拟效果。我们涵盖了以下核心内容:
- 初始化EGA/VGA图形模式 并使用双缓冲技术避免闪烁。
- 编写高效的垂直同步例程,使用了x86内联汇编。
- 定义粒子系统,用数组管理位置和速度。
- 模拟物理运动,实现了抛射体运动公式
x = x0 + vx * t和y = y0 + vy * t + 0.5 * g * t^2。 - 绘制粒子 并进行屏幕坐标转换。
- 实现爆炸效果,通过随机角度和速度使粒子从中心向外扩散。
- 组织主循环逻辑,控制火箭发射、爆炸和粒子更新的节奏。








通过这个项目,你将BASIC语言的易用性与底层汇编的控制能力相结合,实现了一个既美观又有趣的图形效果。你可以尝试调整重力、速度、颜色和粒子数量等参数,创造出属于自己的烟花表演。
026:编写旋转缩放效果
概述





在本节课中,我们将学习如何在MS-DOS环境下,使用x86汇编和VGA图形模式,实现一个经典的“旋转缩放”视觉效果。这个效果源自1993年Future Crew小组发布的著名演示程序《Second Reality》。我们将从数学原理开始,逐步讲解如何将线性代数中的旋转变换转化为高效的代码,并最终在屏幕上实现动态的纹理旋转与缩放。
从《Second Reality》到旋转缩放原理
上一节我们提到了《Second Reality》演示程序及其标志性的旋转缩放效果。本节中,我们来看看这个效果背后的核心数学原理。
旋转缩放本质上是一种二维纹理映射技术。它涉及将一个源图像(纹理)经过旋转、缩放和平移变换后,绘制到屏幕上。
我们有两个坐标系:
- 纹理空间: 使用
(U, V)坐标来描述源图像中的像素位置。 - 屏幕空间: 使用我们熟悉的
(X, Y)坐标来描述屏幕上的像素位置。
我们的目标是为屏幕上的每一个像素 (X, Y),找到其在纹理图像中对应的源像素 (U, V)。
旋转变换的数学基础
对一个二维平面上的点或向量进行旋转,可以通过一个旋转矩阵来实现。
一个二维向量可以表示为:
v = [x, y]^T
一个旋转矩阵 R(旋转角度为 θ)定义为:
R = [ [cosθ, -sinθ], [sinθ, cosθ] ]
将向量 v 旋转 θ 角度得到新向量 v' 的运算为:
v' = R * v
将其展开为公式,新坐标 (x', y') 为:
x' = x * cosθ - y * sinθ
y' = x * sinθ + y * cosθ
整合缩放与平移
在旋转的基础上,我们还可以加入缩放因子 Z 和平移向量 (Tx, Ty)。
完整的纹理坐标 (U, V) 计算过程可以描述为:
- 对屏幕坐标
(X, Y)应用平移:(X + Tx, Y + Ty) - 对平移后的坐标应用旋转矩阵
R。 - 对旋转后的坐标应用缩放因子
Z。 - 将结果映射到纹理图像的尺寸范围内(通常使用取模运算
%来实现纹理平铺)。
核心公式可以概括为:
U = (( (X+Tx) * cosθ - (Y+Ty) * sinθ ) * Z) % TextureWidth
V = (( (X+Tx) * sinθ + (Y+Ty) * cosθ ) * Z) % TextureHeight
代码实现与分析
理解了数学原理后,我们进入代码实现环节。我们将基于一个已有的演示程序框架,重点实现 draw_roto 函数。
以下是实现该效果的关键步骤和代码结构:
首先,我们需要计算当前帧的旋转角度、缩放因子和平移量。为了性能,我们预先计算好 sinθ 和 cosθ。
float angle = M_PI * time_index / 180.0f; // 将角度转换为弧度
float sin_theta = sin(angle);
float cos_theta = cos(angle);
float zoom = sin_theta + 1.5f; // 确保缩放因子不为零
float translate_x = sin_theta * 64.0f;
float translate_y = cos_theta * 64.0f;
接下来是核心的双重循环。外层循环遍历屏幕的Y坐标,内层循环遍历X坐标。为了提高效率,我们避免在每次循环中都进行完整的矩阵乘法运算。
我们利用一个数学技巧:对于等差数列 i * cosθ,当 i 每次递增1时,结果值每次递增 cosθ。因此,我们可以用加法来替代昂贵的乘法运算。
以下是内层循环更新纹理查找坐标的关键变量初始化与更新逻辑:
// 外层循环 (j 循环) 初始化
float js = (y_start + translate_y) * sin_theta;
float jc = (y_start + translate_y) * cos_theta;
// 内层循环 (i 循环) 初始化
float u_coord = (x_start + translate_x) * cos_theta - js;
float v_coord = (x_start + translate_x) * sin_theta + jc;
for (int i = 0; i < width; i++) {
// 计算纹理坐标并取模,确保在图像尺寸内
int tex_u = ((int)(u_coord * zoom)) % texture_width;
int tex_v = ((int)(v_coord * zoom)) % texture_height;
// 从纹理中获取颜色并写入屏幕缓冲区
*pixel_ptr++ = get_pixel_from_texture(tex_u, tex_v);
// 关键优化:使用加法更新坐标,替代 i * cosθ 和 i * sinθ 的重复计算
u_coord += cos_theta;
v_coord += sin_theta;
}
// 外层循环更新
js += sin_theta;
jc += cos_theta;
为了在低分辨率的VGA模式(如Mode Y)下获得可接受的帧率,代码还实现了一次写入多个像素的优化。同时,程序提供了使用浮点数(便于理解)和定点数(为了在古董CPU上获得更高性能)两种计算路径,通过预编译宏进行切换。
总结
本节课中,我们一起学习了MS-DOS演示场景中经典的旋转缩放效果的实现。
我们从《Second Reality》的震撼效果引入,剖析了其背后的二维纹理映射与旋转变换的数学原理,核心是旋转矩阵公式 [x', y'] = [x*cosθ - y*sinθ, x*sinθ + y*cosθ]。
接着,我们将其转化为实际的C语言代码,并重点介绍了通过将乘法替换为加法来优化性能的关键技巧。最终,我们成功在模拟器中复现了动态的旋转、缩放与平移的纹理效果。
这个例子完美展示了在极度有限的硬件资源(如33MHz的486 CPU)下,程序员如何通过深入理解数学原理并精心优化代码,创造出令人惊叹的视觉体验。在下一节课中,我们将探讨如何用定点数运算进一步优化这个效果,使其在真实的古董硬件上也能流畅运行。
027:让我们编写MS-DOS 0x1B - 定点算术与旋转缩放器
概述
在本节课中,我们将学习如何将上一节中基于浮点运算的旋转缩放效果,改写为使用定点算术来实现。定点算术在缺乏强大浮点单元(FPU)的旧式CPU(如386、486)上运行速度更快。我们将探讨其基本原理,并逐步将代码从浮点版本转换为定点版本。
上一节我们介绍了旋转缩放效果的数学原理和浮点实现,本节中我们来看看如何利用定点数来优化性能。
定点算术简介
浮点数之所以称为“浮点”,是因为其小数点可以浮动,以兼顾数值范围和精度,例如 1.0 × 10⁻⁶ 表示 0.000001。然而,浮点运算在旧CPU上非常耗时。
整数运算在这些旧CPU上则快得多。定点数是一种折中方案:我们固定小数点的位置。例如,使用一个16位数,我们可以分配8位给整数部分,8位给小数部分(或7位小数加1位符号位)。这样,数值范围大约在 -127 到 127 之间,并具有约 1/256 的精度。
核心公式:定点数的加法和普通整数加法相同。但乘法后,小数点位置会改变,需要进行“重规范化”,即右移相应的位数。如果我们使用7位小数精度,乘法后就需要右移7位。
准备工作:查找表
在浮点版本中,我们使用了 sin 和 cos 函数。为了在定点版本中加速,我们将预先计算并存储正弦值在查找表中。我们将使用三个不同的正弦表:
- 一个包含256个条目的基础正弦表。
- 一个专门用于缩放、带有偏移的正弦表。
- 一个包含512个条目的正弦表,用于图像平移以获得更高精度。
以下是定义缩放因子的示例代码:
#define FIXED_POINT_SCALE (1 << 7) // 2^7 = 128,即7位小数精度
使用查找表意味着我们无需在运行时计算耗时的三角函数,只需通过索引获取整数值。
代码转换:从浮点到定点
现在,我们开始将核心计算逻辑从浮点转换为定点。大部分代码结构保持不变,但数据类型和部分计算需要调整。
1. 初始化旋转与缩放参数
首先,我们需要从查找表中获取当前的余弦(c)、正弦(s)、缩放因子(zoom)和平移向量(tx, ty)。注意,余弦可以通过相位偏移(如加64,即1/4周期)从正弦表中获得。
以下是获取这些值的示例:
int c = sin_table_256[(t + 64) % 256]; // 余弦
int s = sin_table_256[t % 256]; // 正弦
int zoom = sin_zoom_table[t % 256]; // 缩放因子
int tx = sin_table_512[t % 512]; // X平移
int ty = sin_table_512[(t + 128) % 512]; // Y平移(使用余弦相位)
2. 预计算外循环变量
和浮点版本一样,我们需要预计算一些在外循环中不变的值,例如 j * s 和 j * c(即 js 和 jc)。这些现在也是定点整数。
3. 主循环结构
外循环遍历图像的每一行(j),内循环遍历每一列(i)。这个框架与浮点版本完全一致。
4. 内循环:坐标变换与像素查找
这是最关键的部分。我们需要计算源图像坐标 (u, v)。
核心计算步骤:
- 使用旋转矩阵计算中间值:
icjs = (x + tx) * c - js和isjc = (x + tx) * s + jc。注意,这里的乘法是定点乘法。 - 将结果重规范化(右移7位)。
- 乘以缩放因子
zoom,并再次重规范化。 - 通过取模运算或位与运算(如果图像尺寸是2的幂)确保坐标在图像边界内,实现平铺效果。
以下是内循环中计算 u 坐标的示例代码:
int u = (((icjs >> 7) * zoom) >> 7) & 0x7F; // 假设图像宽度为128
v 坐标的计算方式类似,使用 isjc。
5. 像素绘制与参数更新
根据计算出的 (u, v) 坐标从源图像获取颜色,并绘制到屏幕上。这部分代码与浮点版本完全相同。
在内循环末尾,我们需要更新旋转矩阵的增量值,即给 icjs 加上 c,给 isjc 加上 s。由于这些都是定点整数,加法操作保持不变。
性能对比与优化方向
完成转换后,在DOSBox中运行程序。即使将CPU周期数从浮点版本所需的约50000调低,动画也能更流畅地运行,这证明了定点算术在旧硬件上的效率优势。
不过,你可能会注意到旋转时有轻微的“阶梯”感,这是因为我们只使用了7位小数精度。为了进一步提升性能和精度,可以考虑以下方向:
- 使用汇编语言重写:充分利用CPU的32位整数运算单元,速度会显著提升。
- 提高定点数精度:例如,在32位整数中使用16位或更多位作为小数部分。
- 优化内存访问:确保查找表和图像数据对齐,减少缓存未命中。
许多经典的演示场景效果都依赖于定点算术。在现代CPU拥有强大FPU的今天,浮点运算编程更加简便,但理解定点数技术对于深入理解计算机图形学和优化底层代码仍然非常有价值。
总结
本节课中我们一起学习了定点算术的原理及其在MS-DOS环境下优化图形效果的应用。我们通过将旋转缩放器效果从浮点运算转换为定点整数运算,显著提升了在386、486等老式计算机上的运行性能。关键点包括:理解定点数的表示与重规范化、使用查找表替代实时三角函数计算、以及保持算法核心结构不变的同时更改数据类型。虽然定点数在数值范围和精度上有所取舍,但在追求极限性能的场合,它始终是一项重要的技术。
028:Dweezil分形缩放效果教程
概述

在本节课中,我们将学习如何在MS-DOS环境下,使用x86汇编和VGA图形模式,实现一个源自Amiga演示场景的经典视觉效果——Dweezil分形缩放器。这个效果结合了动态缩放和旋转,通过将屏幕分割成多个矩形块并巧妙地进行内存拷贝来模拟出复杂的视觉运动。
背景介绍
上一节我们探讨了基础的图形操作,本节中我们来看看一个更复杂的演示场景效果。该效果最初出现在1993年Amiga平台的演示程序“Banana Man by Stlar”中,被称为“无限分形缩放器”。它并非进行真正的像素级变换,而是通过将屏幕划分为网格,并快速拷贝矩形区域来近似实现缩放和旋转效果,这在当时硬件性能有限的情况下是一种非常高效的技巧。
核心原理
网格划分与坐标系统
效果的核心是将屏幕划分为一个奇数行奇数列的网格(例如13x13)。每个网格单元称为一个“图块”(Tile)。我们建立一个以屏幕中心为原点(0,0)的坐标系。
公式:
相对X坐标 = 当前图块X索引 - (总图块数X / 2)
相对Y坐标 = 当前图块Y索引 - (总图块数Y / 2)
例如,在13x13的网格中,最左侧图块的X索引为0,其相对X坐标为 0 - 6 = -6;中心图块的相对坐标则为 6 - 6 = 0。
缩放操作
缩放效果是通过在拷贝每个图块时,根据其相对于中心的位置进行位移来实现的。离中心越远的图块,位移量越大,从而模拟出透视缩放感,类似于穿过隧道时边缘物体移动更快的视觉效果。
代码逻辑:
在拷贝源图块到目标缓冲区时,源坐标会根据其相对坐标进行偏移。
源X坐标偏移 = 相对X坐标 * 缩放因子
源Y坐标偏移 = 相对Y坐标 * 缩放因子
旋转操作
旋转效果是通过交换并取反相对坐标来实现的。这会导致拷贝源在图块网格中沿对角线方向移动,从而产生旋转的视觉印象。
代码逻辑:
旋转后的源X偏移 = -相对Y坐标 * 旋转因子
旋转后的源Y偏移 = 相对X坐标 * 旋转因子
“分形”抖动效果
为了产生更有机、类似分形的图案,每一帧都会随机选择一个微小的偏移量(在一个图块大小内),并将其应用到所有拷贝操作中。这使得图案的中心点在每一帧都略有不同,打破了严格的对称性,产生了更复杂的动态。
代码实现步骤
以下是实现该效果的主要步骤。
1. 初始化设置
首先,我们需要设置图形模式并分配必要的缓冲区。
代码:
// 进入320x200 256色模式(模式13h)
set_video_mode(0x13);
// 分配两个缓冲区:一个帧缓冲区,一个用于中间处理的数据块缓冲区
unsigned char far *framebuf = farmalloc(BUFFER_SIZE);
unsigned char far *datachunks = farmalloc(BUFFER_SIZE);
// 设置调色板(例如火焰效果的调色板)
set_palette(fire_palette);
2. 主循环结构
主循环负责处理用户输入、更新效果状态并将最终图像输出到屏幕。
代码:
while (!key_pressed(KEY_ESCAPE)) {
handle_input(&zoom, &rotation, &do_shift); // 处理缩放、旋转、抖动的开关
draw_dweezil(framebuf, datachunks, zoom, rotation, do_shift); // 核心绘制函数
wait_for_retrace(); // 等待垂直回扫,同步帧率
copy_to_vga(framebuf); // 将帧缓冲区复制到VGA内存
}
3. 核心绘制函数 draw_dweezil
这个函数实现了效果的核心算法。
以下是该函数内部的关键操作列表:
-
计算随机抖动:如果启用抖动,则生成一个随机偏移量。
shift = (do_shift) ? rand() % PIECE_SIZE : 0; -
填充中心随机像素:在中心图块区域生成随机颜色的像素,作为效果的“种子”。
set_pixel(framebuf, center_x + rand_x, center_y + rand_y, random_color); -
遍历所有图块:使用嵌套循环遍历网格中的每一个图块。
for (iy = 0; iy < NUM_PIECES_Y; iy++) { for (ix = 0; ix < NUM_PIECES_X; ix++) { ... } } -
计算源与目标坐标:
- 基础坐标:
src_x = ix * PIECE_SIZE;dst_x = ix * PIECE_SIZE; - 应用抖动:
src_x += shift;dst_x -= shift;(反向应用以稳定图像) - 应用缩放偏移:基于相对坐标(
rel_x,rel_y)计算偏移并叠加。 - 应用旋转偏移:交换并取反相对坐标,计算偏移并叠加到缩放偏移上。
- 基础坐标:
-
执行矩形拷贝:使用优化的内存拷贝函数,将计算出的源矩形区域拷贝到目标位置。
mem_copy_rectangle(datachunks, framebuf, BUFFER_WIDTH, BUFFER_HEIGHT, src_x, src_y, dst_x, dst_y, PIECE_SIZE, PIECE_SIZE); -
交换缓冲区:将处理后的
datachunks缓冲区内容拷贝回framebuf,为下一帧或最终显示做准备。
memcpy(framebuf, datachunks, BUFFER_SIZE);
4. 优化与显示

为了提升性能并避免边缘伪影,我们进行了一些处理。
- 直接内存拷贝:使用
memcpy和自定义的mem_copy_rectangle进行大块内存操作,这比逐像素操作快得多。 - 屏幕居中与裁剪:计算偏移将最终图像居中显示在320x200的屏幕上。同时,裁剪掉最左边和最上边的一行图块,因为拷贝时的环绕会导致这些边缘出现不正确的图像。
参数交互与效果控制
通过调整参数,可以产生多种不同的视觉效果。
- 缩放因子 (
zoom):1:放大(从图像中向外缩放)。-1:缩小(向图像中心缩放)。0:关闭缩放。
- 旋转因子 (
rotation):1:顺时针旋转。-1:逆时针旋转。0:关闭旋转。
- 抖动开关 (
do_shift):true:启用随机抖动,产生更破碎、分形式的外观。false:关闭抖动,产生更平滑、几何式的隧道效果。
总结
本节课中我们一起学习了Dweezil分形缩放效果的原理与实现。我们了解到,通过将屏幕离散化为网格,并利用相对坐标来控制每个图块的拷贝位移,可以高效地模拟出复杂的缩放和旋转视觉效果。引入随机抖动则能打破规律性,创造出更有机的动态图案。这个例子完美展示了在有限硬件(如古老的Amiga或MS-DOS PC)上,通过巧妙的算法而非蛮力计算,也能实现令人印象深刻的图形特效。在未来的课程中,我们可能会探索更适合此效果的视频模式,并进一步优化代码性能。
029:VGA屏幕融化效果教程

概述
在本节课中,我们将学习如何利用VGA显卡的特定寄存器,实现两种高效的屏幕“融化”视觉效果。这两种效果的核心在于巧妙地操作EGA/VGA寄存器,而非通过大量绘制像素来实现,因此即使在较慢的MS-DOS系统上也能流畅运行。
VGA屏幕绘制原理
上一节我们介绍了课程目标,本节中我们来看看实现这些效果的基础:CRT显示器的扫描原理。
在MS-DOS时代,显示器使用阴极射线管。电子束从左到右、从上到下扫描屏幕,绘制出图像。
- 水平回扫:当电子束完成一行扫描,从右侧返回左侧准备绘制下一行时,这个过程称为水平回扫。在此期间,我们可以安全地修改VGA寄存器。
- 垂直回扫:当电子束完成一整帧(例如400条扫描线)的绘制,从屏幕右下角返回左上角时,这个过程称为垂直回扫。此时屏幕没有绘制操作,也是修改寄存器的安全时机。
理解这些时机对于实现精准的屏幕效果至关重要。

效果一:最大扫描线寄存器融化
第一种效果通过动态修改最大扫描线寄存器来实现。这个寄存器控制每条扫描线在屏幕上重复显示的次数。
以下是该寄存器的关键位域说明:
- 位 7:扫描线加倍位(在标准320x200图形模式下通常设为1,将200线倍增至400线输出)。
- 位 5-6:用于其他控制功能(如行比较、垂直消隐开始),我们需要保存和恢复它们。
- 位 0-4:最大扫描线计数。这5位值(0-31)决定了每条扫描线重复显示的次数,公式为
重复次数 = 寄存器值 + 1。
通过动画改变这5位的值(例如从0递增到31),可以让图像像橡皮筋一样向下拉伸(融化),然后再收缩回来。
现在,让我们来看看实现这个效果的代码核心部分。
以下是操作最大扫描线寄存器的关键步骤:
// 1. 选择最大扫描线寄存器(索引为0x09)
outportb(CRTC_INDEX, MAX_SCAN_LINE_REG);

// 2. 读取寄存器的原始值
original_value = inportb(CRTC_DATA);

// 3. 构造新值:保留高3位,替换低5位为动画参数
new_value = (original_value & 0xE0) | (timer & 0x1F);
// 4. 将新值写回寄存器
outportb(CRTC_INDEX, MAX_SCAN_LINE_REG);
outportb(CRTC_DATA, new_value);
在主循环中,我们只需让 timer 变量在0到31之间循环递增和递减,然后调用上述函数,即可产生屏幕垂直融化的动画效果。这种方法没有移动任何像素数据,效率极高。
效果二:行偏移寄存器融化
上一节我们实现了垂直方向的拉伸融化,本节中我们来看看一个更复杂的水平方向“切割”融化效果。这需要用到行偏移寄存器。
行偏移寄存器决定了VGA在完成一条扫描线后,其帧缓冲区地址指针的增量。通常,对于320像素宽的模式,这个值是40(320字节 / 8位每字节),以确保指针指向下一行的开头。
这个效果的思路是:
- 让屏幕正常绘制一定数量的扫描线(比如50行)。
- 在某一时刻,将行偏移寄存器设置为0。
- 此后,VGA将反复绘制同一行数据,直到帧结束,在屏幕上产生一条“切割”线。
- 通过动画控制开始“切割”的位置,就能实现屏幕从上到下逐渐被“融化”或“切割”的效果。
以下是实现此效果的核心逻辑流程:
- 保存原始值:读取并保存行偏移寄存器的原始值。
- 等待垂直回扫结束:确保操作从新的一帧开始。
- 计数扫描线:通过检测水平回扫状态位,精确等待N条扫描线经过。
- 修改寄存器:将行偏移寄存器设置为0。
- 等待帧结束:等待垂直回扫开始,表明当前帧绘制完毕。
- 恢复寄存器:将行偏移寄存器恢复为原始值,以便下一帧正常显示。
由于此过程需要精确的时序,并且要防止被中断打扰,代码中会临时禁用中断,并在操作完成后重新启用。
总结
本节课中我们一起学习了两种在MS-DOS系统下利用VGA硬件寄存器实现的屏幕融化效果。
- 效果一通过动态修改最大扫描线寄存器,控制扫描线的重复次数,实现了高效的垂直方向拉伸动画。
- 效果二则通过在中途将行偏移寄存器置零,使VGA重复绘制同一行,创造了屏幕被水平“切割”融化的视觉效果。
这两种方法的共同优点是极高的效率,它们通过直接操纵硬件寄存器实现动画,避免了庞大的内存拷贝操作,因此即使在古老的X86机器上也能表现出色。你可以尝试调整动画参数,将这些效果用于场景切换或创造更独特的视觉演示。
030:让我们编写x86汇编 - 0x01 Hello World


在本节课中,我们将学习如何编写一个简单的x86汇编程序,在MS-DOS环境下运行并打印“Hello World”。我们将从设置开发环境开始,逐步讲解汇编语言的基础概念,如寄存器、指令、中断和循环,最终完成并运行我们的第一个程序。
设置开发环境 🛠️
要开始编写x86汇编程序,首先需要搭建开发环境。这主要包括一个汇编器和一个MS-DOS模拟器。
以下是所需工具及其获取方式:
-
汇编器:Netwide Assembler (NASM)
- 访问 nasm.us 下载最新版本,支持Linux、macOS和Windows。
- 在macOS或Linux上,也可以通过包管理器安装,例如使用命令
brew install nasm。
-
MS-DOS模拟器:DOSBox
- 访问 dosbox.com 下载适用于您操作系统的版本。
- DOSBox易于使用,无需复杂的虚拟机配置。启动后,可以使用
mount命令将本地目录挂载为DOS驱动器,方便访问源代码。例如:mount d /path/to/your/programs。
设置好环境后,就可以用任何文本编辑器(如Notepad++、Vim等)编写汇编代码了。
编写第一个程序:Hello World ✍️
上一节我们准备好了工具,本节中我们来看看如何编写一个能在MS-DOS下运行的“Hello World”程序。汇编文件使用分号 ; 进行注释。
程序起始点
MS-DOS的.COM程序有其固定格式。程序代码必须从内存地址 0x100 开始,因为前面的256字节(0x00 到 0xFF)被MS-DOS的程序控制块占用。
org 0x100 ; 告知汇编器,程序将从地址0x100开始加载和执行
接着,我们定义一个标签 start,作为程序代码的实际起点。
start:
定义数据
我们需要在内存中存储要打印的字符串。使用 db(Define Byte)指令来声明字节数据。
string db 'Hello World', 0x01, 0 ; 定义字符串,0x01是笑脸符号,0是字符串终止符
这里,string 是一个标签,代表了这段数据在内存中的起始地址。字符串以数字 0(空字符)结尾,这是C语言风格,便于我们检测字符串的结束。
使用寄存器
CPU中有一些用于临时存储数据的空间,称为寄存器。在8086处理器中,主要的通用寄存器是16位宽的:
- AX:累加器,常用于算术运算。
- BX:基址寄存器,常用于存放内存地址。
- CX:计数寄存器,常用于循环计数。
- DX:数据寄存器,常用于I/O操作。
每个16位寄存器还可以拆分为两个8位寄存器使用:
- AX = AH(高8位) + AL(低8位)
- BX = BH + BL
- CX = CH + CL
- DX = DH + DL
加载字符串地址
要将字符串打印到屏幕,首先需要知道它在内存中的位置。我们使用 mov(移动)指令将地址加载到BX寄存器中。
mov bx, string ; 将字符串`string`的地址存入BX寄存器
现在,BX寄存器就像一个指针,指向了字符串的开头。
循环打印字符
打印过程需要逐个字符进行,因此我们使用一个循环。首先定义一个循环标签 repeat。
repeat:
在循环内部,我们需要做以下几件事:
-
获取当前字符:通过BX寄存器指向的地址,取出一个字节(字符)放到AL寄存器中。方括号
[bx]表示“取BX所指地址处的值”。mov al, [bx] ; 将BX指向的内存地址中的字节(字符)加载到AL寄存器 -
检查是否结束:检查AL中的字符是否为0(字符串结尾)。我们使用
test指令,它会对两个操作数进行按位与运算,并根据结果设置标志寄存器中的零标志位(ZF)。如果AL为0,则test al, al会将ZF设为1。test al, al ; 测试AL的值是否为零 jz end ; 如果为零(ZF=1),则跳转到`end`标签,结束循环 -
保存指针并递增:在调用打印功能前,需要暂时保存BX的值(当前字符指针),因为打印功能会使用BX寄存器。我们使用栈来保存和恢复数据。
push bx ; 将BX寄存器的值压入栈中保存 inc bx ; 将BX加1,指向字符串中的下一个字符 -
打印字符:通过MS-DOS/BIOS提供的中断服务来打印字符。中断
int 0x10是视频服务,其子功能0x0E用于在屏幕上以电传打字机模式写一个字符。- 将子功能号
0x0E放入AH寄存器。 - 将待打印的字符(已在AL中)放入AL寄存器。
- 设置BH为页码(通常为0),BL为颜色属性(在文本模式下常被忽略)。
mov ah, 0x0E ; 设置中断功能:电传打字机输出 mov bh, 0 ; 页码为0 mov bl, 0x0F ; 颜色属性为白色(在图形模式下有效) int 0x10 ; 调用视频中断,打印字符 - 将子功能号
-
恢复指针并继续循环:字符打印完毕后,从栈中恢复BX原来的值(指向当前已打印的字符),然后跳回循环开始,处理下一个字符。
pop bx ; 从栈中弹出值,恢复BX寄存器(指向当前字符) jmp repeat ; 无条件跳转回`repeat`标签,继续循环
程序结束
当循环检测到字符串结束符(0)并跳转到 end 标签时,程序需要正常退出。使用中断 int 0x20 可以终止程序,将控制权交还给MS-DOS。
end:
int 0x20 ; 调用DOS中断,终止程序
汇编与运行程序 ⚙️
代码编写完成后,需要将其汇编成MS-DOS可执行的.COM文件。
使用NASM汇编器,指定输出格式为纯二进制(-f bin),因为.COM文件没有复杂的头部结构。
nasm -f bin asm1.asm -o asm1.com
如果汇编成功,将生成一个 asm1.com 文件。用DOSBox运行它:
# 在DOSBox中,假设你的程序在D盘
D:\> asm1.com
运行后,屏幕上应该会显示“Hello World”和一个笑脸符号。
扩展尝试:图形模式下的颜色 🎨
为了展示颜色属性的作用,我们可以尝试切换到图形模式。通过中断 int 0x10 的子功能 0x00(设置显示模式)可以实现。
在程序开头(start:标签后)添加以下代码切换到320x200、16色的图形模式:
mov ax, 0x0013 ; AH=0(设置模式),AL=0x13(图形模式编号)
int 0x10 ; 调用视频中断
运行程序,你会看到“Hello World”显示在图形模式的屏幕上。此时,之前设置的 mov bl, 0x0F(白色)颜色属性可能会生效(取决于具体环境)。要切换回文本模式,可以使用模式 0x03:
mov ax, 0x0003 ; AH=0, AL=0x03(80x25文本模式)
int 0x10
总结 📚
本节课中我们一起学习了x86汇编语言的基础。我们完成了一个完整的MS-DOS .COM 程序,它能够打印“Hello World”。我们涵盖了以下核心概念:
- 程序结构:
.COM程序从org 0x100开始,使用标签组织代码和数据。 - 数据定义:使用
db指令定义字节数据,并以0终止字符串。 - 寄存器操作:使用
mov指令在寄存器和内存间移动数据。了解了AX、BX、CX、DX等通用寄存器及其高低字节。 - 流程控制:使用
jmp进行无条件跳转,使用test配合jz(或je)进行条件跳转,实现循环逻辑。 - 中断调用:通过
int指令调用系统服务,如int 0x10进行屏幕输出,int 0x20终止程序。 - 栈操作:使用
push和pop指令临时保存和恢复寄存器的值。
这个简单的程序是进入x86汇编和PC系统编程世界的第一步。在接下来的课程中,我们将探索如何处理用户输入、进行数学运算以及利用更多的BIOS和硬件功能。


031:用Power BASIC绘制科赫雪花
概述
在本节课中,我们将学习如何使用Power BASIC编程语言在MS-DOS环境下绘制科赫雪花。科赫雪花是一种基于迭代函数系统(IFS)的分形图形,我们将通过实现一个L系统来生成它。课程将涵盖从设置图形模式到实现递归绘图函数的完整过程。
图形模式初始化与主循环
首先,我们需要将屏幕切换到VGA图形模式,并设置一个主循环来持续绘制雪花,直到用户按下任意键。
以下代码初始化图形模式并设置主循环结构:
SCREEN 12 ' 切换到640x480分辨率、16色的VGA模式
RANDOMIZE TIMER ' 初始化随机数生成器
angle = 0 ' 初始化当前角度变量
count = 0 ' 初始化绘制计数器
DO UNTIL INKEY$ <> "" ' 主循环,直到有按键按下
IF count > 100 THEN ' 每绘制100个雪花后清屏
CLS
count = 0
ELSE
count = count + 1
END IF
' ... 此处将放置绘制单个雪花的代码 ...
SLEEP 1 ' 暂停1秒,控制绘制速度
LOOP

SCREEN 0 ' 程序退出前切换回文本模式
计算雪花初始位置与参数
在绘制每个雪花前,我们需要计算其初始位置、大小和颜色。这些参数通过随机数生成,以确保每个雪花都独一无二。
以下是计算这些参数的代码:
scale = 0.5 + RND * 2 ' 缩放因子,在0.5到2.5之间随机
x = RND * 640 - 160 / scale ' X坐标,考虑偏移使雪花分布均匀
y = RND * 480 - 120 / scale ' Y坐标
colour = INT(RND * 16) ' 颜色,0到15之间的随机整数
PSET (x, y), colour ' 设置绘图的起始像素点
定义科赫雪花绘制子程序
科赫雪花的绘制逻辑封装在一个名为 co 的子程序中。它接收长度、递归深度、缩放因子和颜色作为参数,并执行绘制雪花的“公理”(初始三角形)。
co 子程序的定义如下:
SUB co (length AS DOUBLE, split AS INTEGER, scale AS DOUBLE, colour AS INTEGER)
' 公理:绘制一个等边三角形
CALL f(length, split - 1, scale, colour)
CALL turn(120)
CALL f(length, split - 1, scale, colour)
CALL turn(120)
CALL f(length, split - 1, scale, colour)
END SUB
实现L系统规则函数 f
函数 f 是L系统的核心,它实现了科赫曲线的生成规则。当递归深度 split 为0时,它绘制一条直线;否则,它将应用规则进行递归细分。
f 函数的实现代码如下:
SUB f (length AS DOUBLE, split AS INTEGER, scale AS DOUBLE, colour AS INTEGER)
IF split = 0 THEN
' 递归基础情况:向前绘制线段
CALL forward(length * scale, colour)
ELSE
' 递归规则:F -> F+F--F+F
CALL f(length, split - 1, scale, colour)
CALL turn(60)
CALL f(length, split - 1, scale, colour)
CALL turn(-120)
CALL f(length, split - 1, scale, colour)
CALL turn(60)
CALL f(length, split - 1, scale, colour)
END IF
END SUB
实现基础绘图函数
为了支持 f 函数的操作,我们需要两个基础函数:forward 用于沿当前方向绘制线段,turn 用于改变当前方向。
以下是这两个函数的实现:
SUB forward (length AS DOUBLE, colour AS INTEGER)
' 根据当前角度和长度计算终点,并画线
LINE -STEP(COS(angle) * length, SIN(angle) * length), colour
END SUB
SUB turn (degrees AS DOUBLE)
' 更新当前角度,将角度转换为弧度
angle = angle + degrees * 3.14159265 / 180
END SUB
整合与运行
最后,在主循环中调用 co 子程序,传入计算好的参数,即可开始绘制雪花。



调用示例如下:


CALL co(5, 4, scale, colour) ' 绘制一个雪花,线段基础长度5像素,递归深度4
总结

本节课我们一起学习了如何在MS-DOS环境下使用Power BASIC绘制科赫雪花。我们了解了L系统的基本概念,实现了从图形初始化、参数计算、递归规则到最终绘制的完整流程。通过修改角度、规则或递归深度,你可以创造出无数种不同的分形图案。完整的源代码可在提供的GitHub链接中找到,欢迎下载并尝试修改以探索更多可能性。
032:检测图形显卡 🖥️
在本节课中,我们将学习如何在 MS-DOS 环境下,通过编程自动检测系统中安装的图形显卡类型。这对于编写需要适配不同显示硬件的应用程序(如游戏)至关重要。
概述
早期的 IBM PC 及其兼容机(直到大约 90 年代中期)没有现代即插即用(Plug and Play)的硬件识别机制。显卡(如 CGA、Hercules、EGA、VGA)都是“哑”设备,不会主动向系统报告自身信息。因此,程序员需要编写代码来探测和识别系统中安装的显卡,尤其是当系统可能同时安装了一块彩色显卡和一块单色显卡时。
我们将学习如何从最新的 VGA 标准回溯到最老的 CGA 标准,逐步检测显卡。核心方法是利用 BIOS 中断调用和直接端口读写。
检测原理与方法
上一节我们概述了检测显卡的必要性,本节中我们来看看具体的检测顺序和原理。
检测应从最新的标准开始,逐步向旧标准回溯。因为较新的显卡(如 VGA、EGA)的 BIOS 提供了查询自身信息的功能,而较老的显卡(如 CGA、Hercules)则需要通过直接与硬件交互来探测。
以下是我们的检测流程:
- 首先尝试检测 VGA 显卡。
- 如果未检测到 VGA,则尝试检测 EGA 显卡。
- 如果仍未检测到,则尝试检测基于 Motorola 6845 芯片的显卡(CGA 和 Hercules/MDA),并区分它们。
检测 VGA 显卡 🎯

VGA 及以上的显卡标准提供了 BIOS 中断来查询显示组合。这是最直接的方法。



我们使用 INT 0x10(视频服务中断)的子功能 0x1A(获取显示组合代码)。


代码示例:
union REGS regs;
regs.x.ax = 0x1A00; // AH = 0x1A, AL = 0x00
int86(0x10, ®s, ®s);




if (regs.h.al == 0x1A) { // 功能调用成功
// BL 寄存器包含主显示卡代码
// BH 寄存器包含次显示卡代码
primaryCard = vgaCodeToString(regs.h.bl);
secondaryCard = vgaCodeToString(regs.h.bh);
// 检测完成,可跳过后续检测
}
调用成功后,BL 和 BH 寄存器中的代码分别对应主显示卡和次显示卡。我们可以将这些代码转换为可读的字符串(如 “VGA with analog color display”)。


注意: 如果返回的代码表示是单色显示适配器(MDA),它可能是 Hercules 卡,因为 VGA BIOS 无法区分 MDA 和 Hercules。我们稍后需要专门检测 Hercules。



检测 EGA 显卡 🔍



如果 VGA 检测失败,我们接下来检测 EGA 显卡。EGA 也提供了 BIOS 功能来识别自身。


我们使用 INT 0x10 的子功能 0x12,并设置 BL = 0x10(获取视频子系统配置信息)。



代码示例:
union REGS regs;
regs.h.ah = 0x12;
regs.h.bl = 0x10;
int86(0x10, ®s, ®s);

if (regs.h.bl != 0x10) { // 值被改变,说明 EGA BIOS 存在
// 检测到 EGA 卡
// CL 寄存器包含卡上的 DIP 开关设置
unsigned char dipSettings = regs.h.cl >> 1;
if (dipSettings == 5) {
// EGA 单色模式
primaryCard = “EGA Mono”;
} else if (dipSettings == 3) {
// EGA 彩色模式(连接 CGA 显示器)
primaryCard = “EGA Color (CGA monitor)”;
} else {
// EGA 高分辨率彩色模式
primaryCard = “EGA Enhanced Color”;
}
}
通过读取 CL 寄存器中的 DIP 开关设置,我们可以判断 EGA 卡是工作在单色模式、标准彩色模式还是增强彩色模式。根据检测到的模式,我们可以推断系统中是否还可能存在其他类型的彩色或单色显卡。
检测 6845 芯片显卡(CGA/Hercules/MDA)🕹️
对于更老的 CGA 和 Hercules/MDA 显卡,它们基于 Motorola 6845 CRT 控制器(CRTC)。我们需要通过直接读写硬件端口来探测。
这些显卡的 CRTC 寄存器位于特定的 I/O 端口:
- CGA: 索引端口
0x3D4,数据端口0x3D5 - Hercules/MDA: 索引端口
0x3B4,数据端口0x3B5
检测思路是:向一个特定的、安全的 CRTC 寄存器(如光标低位寄存器,编号 0x0F)写入一个任意值,稍等片刻再读回。如果读回的值与我们写入的值相同,则说明该端口存在一个 6845 芯片。
核心检测函数 test6845 代码逻辑:
int test6845(unsigned port) {
unsigned char oldValue, newValue, testValue = 0x55; // 任意测试值
// 选择光标低位寄存器 (0x0F)
outportb(port, 0x0F);
// 保存旧值
oldValue = inportb(port + 1);
// 写入测试值
outportb(port + 1, testValue);
// 短暂延迟
delay(10);
// 读回新值
newValue = inportb(port + 1);
// 恢复旧值
outportb(port + 1, oldValue);
// 比较并返回结果
return (newValue == testValue);
}
区分 CGA
如果 test6845(0x3D4) 返回真,则检测到 CGA 显卡。它通常是主彩色显卡。

区分 Hercules 与 MDA

如果 test6845(0x3B4) 返回真,则检测到一个单色显卡,可能是 MDA 或 Hercules。
为了区分两者,我们需要检查 Hercules 卡独有的特性:其状态寄存器(端口 0x3BA)的第 7 位会在水平回扫期间切换,而 MDA 卡的这一位是固定的。


区分代码逻辑:
if (test6845(0x3B4)) {
// 至少是 MDA
unsigned char status = inportb(0x3BA);
// 检查第7位是否会在循环中发生变化
for (int i = 0; i < 32767; i++) {
if ((inportb(0x3BA) & 0x80) != (status & 0x80)) {
// 位发生变化,是 Hercules 卡
*card = “Hercules Graphics Card”;
return;
}
}
// 位未变化,是 MDA 卡
*card = “MDA”;
}


总结与注意事项



本节课中我们一起学习了在 MS-DOS 环境下自动检测图形显卡的完整方法。



我们首先利用 VGA BIOS 的高级查询功能,这是最准确的方式。若不成功,则降级使用 EGA BIOS 的配置查询。对于更古老的 CGA 和 Hercules/MDA 显卡,我们通过直接探测 6845 CRTC 控制器端口并检查硬件特定行为来识别。

重要提示:
- 模拟器选择: 此类底层硬件检测代码需要高精度的模拟器(如 86Box)或真实硬件才能可靠运行。DOSBox 等侧重于兼容性和速度的模拟器可能无法准确模拟 6845 芯片行为。
- 用户覆盖: 自动检测并非 100% 可靠,特别是面对非标准或兼容性不佳的硬件时。因此,在商业软件中,提供一个允许用户手动选择显卡类型的配置选项始终是明智之举。


通过掌握这些技术,你可以编写出像《波斯王子》或《国王密使》那样能够自动识别并适配最佳图形模式的经典 DOS 程序。
033:Bresenham直线绘制算法 🖥️

在本节课中,我们将学习如何在MS-DOS环境下,使用C语言和VGA图形模式实现经典的Bresenham直线绘制算法。我们将创建一个动态的、色彩循环的线条动画效果,类似于经典的屏幕保护程序。
概述 📋
Bresenham直线算法是计算机图形学中用于在栅格显示器上绘制直线的标准算法。它的主要优势在于完全使用整数运算,避免了耗时的浮点计算,因此在古老的MS-DOS机器上也能高效运行。

Bresenham算法核心原理 🧮


上一节我们介绍了算法的目标,本节中我们来看看其核心思想。Bresenham算法,也称为中点算法,其核心是通过计算并累加一个“误差”值来决定下一个像素点的位置。

该算法基于直线的隐式方程。对于由两点 (x0, y0) 和 (x1, y1) 定义的直线,我们计算差值:
dx = |x1 - x0|
dy = |y1 - y0|
同时,确定X和Y方向的步进符号 sx 和 sy。


初始误差值 error 的计算公式为:
error = -dx - dy
然后,算法进入循环,在每一步:
- 绘制当前像素点
(x, y)。 - 计算两倍的误差值
e2 = 2 * error。 - 根据
e2与-dy和dx的比较,决定是沿X方向步进、沿Y方向步进,还是同时步进,并更新误差值。

以下是该算法“全象限”版本的伪代码,它通过处理符号和绝对值,避免了传统Bresenham算法需要分八个象限处理的复杂性:
function draw_line(x0, y0, x1, y1, color)
dx = abs(x1 - x0)
dy = abs(y1 - y0)
sx = (x0 < x1) ? 1 : -1
sy = (y0 < y1) ? 1 : -1
error = -dx - dy
while true do
plot_pixel(x0, y0, color)
if x0 == x1 and y0 == y1 then break
e2 = 2 * error
if e2 >= -dy then
if x0 == x1 then break
error = error - dy
x0 = x0 + sx
end if
if e2 <= dx then
if y0 == y1 then break
error = error + dx
y0 = y0 + sy
end if
end while
end function

代码实现:绘制单条直线 💻
现在,我们将上述伪代码转化为可在Turbo C中运行的C语言函数。


我们需要包含必要的头文件并定义绝对值宏。函数接收直线的起点、终点坐标和颜色值。
#include <conio.h>
#include <dos.h>
#include <math.h>
#include <stdlib.h>
#include <time.h>
#define abs(a) (((a) < 0) ? -(a) : (a))


void draw_line(int x0, int y0, int x1, int y1, unsigned char color) {
int dx = abs(x1 - x0);
int dy = abs(y1 - y0);
int sx = (x0 < x1) ? 1 : -1;
int sy = (y0 < y1) ? 1 : -1;
int error = -dx - dy;
int e2;
while (1) {
/* 在此处写入像素到屏幕内存 (例如,0xA000段) */
pokeb(0xA000, y0 * 320 + x0, color);
if (x0 == x1 && y0 == y1) break;
e2 = 2 * error;
if (e2 >= -dy) {
if (x0 == x1) break;
error -= dy;
x0 += sx;
}
if (e2 <= dx) {
if (y0 == y1) break;
error += dx;
y0 += sy;
}
}
}
创建动态线条动画 🎨
仅仅绘制静态直线不够有趣。接下来,我们将利用这个函数创建一个动态效果:多条线段在屏幕边界反弹,并通过调色板旋转实现色彩渐变。
以下是实现该效果的核心步骤:
首先,我们需要定义存储多条线段坐标和速度的数组。
#define MAX_LINES 256
int line_x0[MAX_LINES], line_y0[MAX_LINES];
int line_x1[MAX_LINES], line_y1[MAX_LINES];
int dx0[MAX_LINES], dy0[MAX_LINES];
int dx1[MAX_LINES], dy1[MAX_LINES];
在程序初始化时,随机生成线段的位置和移动速度。
void init_lines() {
int i;
randomize();
for (i = 0; i < MAX_LINES; i++) {
line_x0[i] = rand() % 320;
line_y0[i] = rand() % 200;
line_x1[i] = rand() % 320;
line_y1[i] = rand() % 200;
dx0[i] = (rand() % 3) + 1;
dy0[i] = (rand() % 3) + 1;
dx1[i] = (rand() % 3) + 1;
dy1[i] = (rand() % 3) + 1;
}
}
在主循环中,我们每帧只做三件事:
- 用黑色擦除最老的那条线(即255帧前画的那条)。
- 根据速度更新最新一条线的位置,并处理屏幕边界碰撞。
- 用当前颜色绘制最新的那条线。
这种“只画两条线”的策略极大地提升了性能,使得在8086级别的机器上运行动画成为可能。
int t = 0; // 时间索引
while (!kbhit()) {
int last_idx = t % MAX_LINES;
int prev_idx = (t - 1) % MAX_LINES;
int first_idx = (t - MAX_LINES) % MAX_LINES;
// 1. 擦除最老的线(如果已存在)
if (t >= MAX_LINES) {
draw_line(line_x0[first_idx], line_y0[first_idx],
line_x1[first_idx], line_y1[first_idx], 0);
}
// 2. 更新最新线的位置(基于上一帧的位置)
line_x0[last_idx] = line_x0[prev_idx] + dx0[prev_idx];
line_y0[last_idx] = line_y0[prev_idx] + dy0[prev_idx];
line_x1[last_idx] = line_x1[prev_idx] + dx1[prev_idx];
line_y1[last_idx] = line_y1[prev_idx] + dy1[prev_idx];
// 处理屏幕边界碰撞反射
if (line_x0[last_idx] <= 0 || line_x0[last_idx] >= 319) dx0[prev_idx] = -dx0[prev_idx];
if (line_y0[last_idx] <= 0 || line_y0[last_idx] >= 199) dy0[prev_idx] = -dy0[prev_idx];
if (line_x1[last_idx] <= 0 || line_x1[last_idx] >= 319) dx1[prev_idx] = -dx1[prev_idx];
if (line_y1[last_idx] <= 0 || line_y1[last_idx] >= 199) dy1[prev_idx] = -dy1[prev_idx];
// 3. 绘制最新的线,颜色基于时间索引
draw_line(line_x0[last_idx], line_y0[last_idx],
line_x1[last_idx], line_y1[last_idx], (t % 255) + 1);
t++;
rotate_palette(); // 旋转调色板以产生色彩流动效果
delay(10); // 控制帧率
}
实现调色板旋转 🌈
为了实现线条从亮白色渐变为暗红色的色彩流动效果,我们不需要重绘每条线,只需每帧旋转VGA调色板即可。这是实现高效色彩动画的关键技巧。
以下是旋转调色板的函数:
void rotate_palette() {
int i;
int start = (254 - 2) % 255; // 计算旋转起始索引
outp(0x3C8, 0); // 设置调色板索引寄存器,从索引0开始写
// 写入索引0的颜色(通常为黑色,保持不变)
outp(0x3C9, 0);
outp(0x3C9, 0);
outp(0x3C9, 0);
// 旋转其余255个颜色索引
for (i = 1; i < 256; i++) {
int idx = (start + i) % 255;
// 假设 palette 是一个存储了 RGB 值的数组
outp(0x3C9, palette[idx * 3]); // R
outp(0x3C9, palette[idx * 3 + 1]); // G
outp(0x3C9, palette[idx * 3 + 2]); // B
}
}
总结 🎯

本节课中我们一起学习了Bresenham直线绘制算法的原理与实现,并在此基础上创建了一个生动的MS-DOS图形动画。我们掌握了以下关键点:
- Bresenham算法:使用纯整数运算高效绘制直线,通过误差累积决定像素位置。
- 动画技巧:通过每帧只更新(擦除和绘制)两条线段,而非重绘全部256条线,实现了在低性能硬件上的流畅动画。
- 色彩效果:利用VGA调色板旋转技术,在不修改帧缓冲区像素的情况下,创造了动态的色彩渐变和流动效果。
- 性能优化:这些技术(整数运算、最小化绘制操作、硬件调色板操作)是早期计算机图形编程的核心,即使在现代编程中,理解其思想也很有价值。

你可以尝试修改代码,例如改变线条数量、速度或碰撞逻辑,也可以尝试实现每帧清屏并重绘所有线段的效果,以对比性能差异。
034:使用0x21中断和定点数学绘制3D线框立方体教程 🎮
在本节课中,我们将学习如何在MS-DOS环境下,使用C语言和定点数学运算,绘制一个旋转的3D线框立方体。我们将从基础概念讲起,逐步实现代码,最终得到一个在VGA 320x200 256色模式下运行的动画。
概述 📖
3D图形编程是演示场景和游戏开发的核心技能。在早期PC(如286、386)上,由于缺乏浮点运算单元(FPU),使用浮点数进行3D计算会非常缓慢。因此,定点数学成为了一种高效的选择。本节课,我们将利用定点数学、旋转矩阵和透视投影,实现一个简单的旋转立方体。
1. 背景与参考资料 📚
在20世纪90年代,互联网尚未普及,编程知识主要来源于杂志和书籍。一本名为《PC Underground》的德文书籍,由Data Becker公司出版,详细介绍了DOS、VGA和3D编程,是当时许多德国编程爱好者的重要学习资料。
这本书包含了大量用Pascal和汇编语言编写的图形例程,虽然出版于DOS时代的晚期,但其中的知识至今仍有价值。幸运的是,这本书现在可以在archive.org上找到。
2. 3D图形编程基础 🧮
上一节我们介绍了学习背景,本节中我们来看看3D编程所需的核心数学概念。
进行3D编程需要了解线性代数,特别是向量和矩阵。为了简化,我们暂时忽略点和向量的齐次坐标(第四维),只使用三维向量。
一个三维向量包含三个分量,可以表示为 (x, y, z)。它可以表示空间中的一个点,也可以表示一个平移。
以下是向量的基本运算:
- 向量加法/减法:对应分量相加或相减。用于物体的平移。
- 标量积(点积):可用于计算两向量夹角的余弦值,未来可用于光照计算。
- 向量长度:公式为
sqrt(x*x + y*y + z*z),本节课暂不需要。
2.1 透视投影 👁️
如何将三维空间中的点 (x, y, z) 投影到二维屏幕 (x‘, y’) 上?这需要透视投影。
假设相机位于原点,屏幕在相机前方,距离为焦距 a。那么投影公式非常简单:
x‘ = a * x / z
y‘ = a * y / z
可以看到,只需要一次除法运算。z 坐标决定了物体的大小和远近感。
2.2 旋转变换 🔄
为了让立方体旋转,我们需要旋转变换。旋转可以通过矩阵乘法来实现。
以下是绕X轴旋转的矩阵公式(旋转角度为 α):
x‘ = x
y‘ = y * cos(α) - z * sin(α)
z‘ = y * sin(α) + z * cos(α)
类似地,可以推导出绕Y轴和Z轴旋转的矩阵。结合三个轴的旋转和平移,我们就可以让物体在三维空间中自由运动。
3. 定点数学 🎯
上一节我们介绍了3D变换的数学原理,本节中我们来看看如何在缺乏FPU的CPU上高效实现这些计算——使用定点数学。
定点数学的核心思想是使用整数来模拟小数。我们指定整数中的某几位来表示小数部分。
例如,我们使用32位长整型(long),并规定其中较低的9位为小数部分。这意味着我们将数值放大了 2^9 = 512 倍。
以下是核心的转换和运算宏定义:
#define FIXED_PRECISION 9
#define TO_FIXED(x) ((long)((x) * (1 << FIXED_PRECISION)))
#define TO_LONG(x) ((long)((x) / (1 << FIXED_PRECISION)))
#define TO_DOUBLE(x) ((double)(x) / (1 << FIXED_PRECISION))
// 加法和减法直接进行
// 乘法需要修正
#define FIXED_MUL(a, b) (((a) * (b)) >> FIXED_PRECISION)
#define FIXED_SQR(a) FIXED_MUL((a), (a))
// 除法需要预先将被除数放大
#define FIXED_DIV(a, b) (((a) << FIXED_PRECISION) / (b))
注意:除法运算 FIXED_DIV 在数值较大时可能溢出,因为我们将被除数放大了。在286等16位平台上需要格外小心,避免操作过大的数。更稳健的方法是使用64位中间值或手动处理高位。
4. 代码实现 💻
现在,让我们切换到代码层面,一步步实现旋转立方体。
4.1 程序框架与初始化
首先,我们来看程序的主干和初始化部分。
#include <stdio.h>
#include <dos.h>
#include <math.h>
#include “vga.h” // 包含我们自己的VGA图形和画线函数
#define SINE_SIZE 512
long sin_table[SINE_SIZE + SINE_SIZE/4]; // 多分配一些空间,用于同时存储sin和cos
long *cos_table = sin_table + SINE_SIZE/4; // cos表是sin表的相位偏移
int main() {
int key_code = 0;
long time = 0;
// 初始化正弦表(使用浮点数计算一次,可预先计算存入文件)
for(int i = 0; i < SINE_SIZE + SINE_SIZE/4; i++) {
double angle = 2.0 * M_PI * i / SINE_SIZE;
sin_table[i] = TO_FIXED(sin(angle));
}
// 设置VGA 320x200 256色模式
set_vga_mode(0x13);
// 主循环
while(key_code != 1) { // 1 代表 ESC 键
// 清空后台缓冲区
clear_buffer();
// 绘制立方体
draw_cube(time);
// 等待垂直回扫,减少闪烁
wait_for_retrace();
// 将后台缓冲区复制到VGA显存
copy_buffer_to_vga();
// 更新动画时间
time += 2;
// 检查键盘输入
if(kbhit()) {
key_code = getch();
}
}
// 恢复文本模式
set_text_mode();
return 0;
}
4.2 定义立方体模型 🔲
我们需要定义立方体的8个顶点和12条边(线框)。
以下是顶点坐标(使用定点数),定义了一个中心在原点、边长为2的立方体:
// 顶点坐标 (-1, -1, -1) 到 (1, 1, 1)
long cube_x[] = { TO_FIXED(-1), TO_FIXED(1), TO_FIXED(1), TO_FIXED(-1),
TO_FIXED(-1), TO_FIXED(1), TO_FIXED(1), TO_FIXED(-1) };
long cube_y[] = { TO_FIXED(-1), TO_FIXED(-1), TO_FIXED(1), TO_FIXED(1),
TO_FIXED(-1), TO_FIXED(-1), TO_FIXED(1), TO_FIXED(1) };
long cube_z[] = { TO_FIXED(-1), TO_FIXED(-1), TO_FIXED(-1), TO_FIXED(-1),
TO_FIXED(1), TO_FIXED(1), TO_FIXED(1), TO_FIXED(1) };
以下是边的定义,每两个数字构成一条线段,连接两个顶点:
int edges[] = {
0,1, 1,3, 3,2, 2,0, // 底面
4,5, 5,7, 7,6, 6,4, // 顶面
0,4, 1,5, 2,6, 3,7 // 侧面垂直边
};
4.3 核心绘制函数
draw_cube 函数是核心,它负责对每个顶点进行旋转、平移、投影,然后绘制所有边。
以下是该函数的关键步骤:
-
旋转:首先绕Y轴旋转,然后绕X轴旋转(顺序可调)。使用之前定义的旋转矩阵公式和定点乘法。
// 绕Y轴旋转 temp_x = FIXED_MUL(cube_x[i], cos_table[angle_y]) + FIXED_MUL(cube_z[i], sin_table[angle_y]); temp_z = -FIXED_MUL(cube_x[i], sin_table[angle_y]) + FIXED_MUL(cube_z[i], cos_table[angle_y]); rot_y[i] = cube_y[i]; // Y坐标不变 // 绕X轴旋转 (使用上一步的结果) rot_x[i] = temp_x; rot_y[i] = FIXED_MUL(rot_y[i], cos_table[angle_x]) - FIXED_MUL(temp_z, sin_table[angle_x]); rot_z[i] = FIXED_MUL(rot_y[i], sin_table[angle_x]) + FIXED_MUL(temp_z, cos_table[angle_x]); -
平移:将立方体沿Z轴正方向移动一段距离,使其位于相机前方。
trans_z[i] = rot_z[i] + TO_FIXED(4.0);

-
透视投影:应用投影公式
x‘ = a * x / z。#define FOCAL_LENGTH TO_FIXED(2.0) proj_x[i] = FIXED_DIV(FIXED_MUL(FOCAL_LENGTH, rot_x[i]), trans_z[i]); proj_y[i] = FIXED_DIV(FIXED_MUL(FOCAL_LENGTH, rot_y[i]), trans_z[i]); -
缩放与居中:将投影后的坐标转换为屏幕像素坐标。
#define SCALE 40 screen_x1 = TO_LONG(FIXED_MUL(proj_x[edge_start], SCALE)) + SCREEN_WIDTH/2; screen_y1 = TO_LONG(FIXED_MUL(proj_y[edge_start], SCALE)) + SCREEN_HEIGHT/2; -
绘制线段:遍历所有边,调用画线函数连接两个投影后的屏幕坐标点。
draw_line(screen_x1, screen_y1, screen_x2, screen_y2, 15); // 15是白色
5. 总结与展望 🚀

本节课中,我们一起学习了在MS-DOS环境下进行3D图形编程的基础。
我们回顾了向量、矩阵、透视投影和旋转变换等核心数学概念。重点掌握了定点数学技术,它通过整数运算模拟小数,在旧款CPU上能获得极高的性能。我们实现了从浮点数到定点数的转换宏,以及定点数的加、减、乘、除运算。

通过代码实践,我们定义了一个立方体模型,对其顶点应用了旋转、平移和透视投影变换,最终将投影后的2D坐标用线段连接起来,在屏幕上绘制出一个旋转的线框立方体。
这个程序是3D图形编程一个非常简洁的起点。在此基础上,你可以进行许多优化和扩展:
- 性能优化:使用汇编语言重写关键计算函数;直接向VGA显存绘制以减少内存拷贝。
- 功能增强:添加绕Z轴旋转;实现背面剔除和平面着色(填充多边形);加载更复杂的3D模型。
- 效果丰富:添加颜色渐变、纹理映射或简单的光照模型。


希望本教程能为你打开MS-DOS时代3D编程的大门。尝试修改参数,优化代码,并创造属于你自己的图形效果吧!
035:实现Second Reality隧道效果

在本节课中,我们将学习如何为MS-DOS系统编写一个经典的“隧道”视觉效果。这个效果因Future Crew的演示程序《Second Reality》而闻名。我们将使用C语言和汇编知识,在VGA图形模式下,通过预计算坐标和利萨茹曲线动画来创建一个具有3D透视感的动态隧道。
概述
我们将基于上一课的“DOS立方体”代码进行构建。核心思路是预计算一系列同心椭圆环(代表隧道的横截面),然后使用利萨茹曲线为这些环添加平滑的波动动画,最后通过透视投影将它们绘制到屏幕上,并利用灰度颜色模拟深度感。

初始化隧道数据

上一节我们介绍了项目的基础结构,本节中我们来看看如何初始化构成隧道的静态数据。我们需要定义隧道的环数、每环的点数,并预计算它们的坐标。
首先,定义两个常量:
LEVELS: 隧道的环数,例如108。POINTS: 每个环由多少个点构成,例如64。
这总共需要计算 LEVELS * POINTS 个点。
我们声明两个数组来存储数据:
int tunnel[LEVELS][POINTS][2]; // 存储每个点的原始(x, y)坐标
int liss[LEVELS][2]; // 存储每个环的利萨茹曲线偏移量(x, y)
tunnel 数组存储了未经动画偏移的、应用了透视投影的椭圆环坐标。liss 数组将用于存储使每个环产生波动的偏移量。
以下是初始化 tunnel 数组的步骤:
- 遍历每一个环 (
i从 0 到LEVELS-1)。 - 计算当前环的透视缩放因子
projection。公式为:projection = (i + 1) / LEVELS。环越远(i越大),projection值越大,坐标就被缩放得越小。 - 定义椭圆在X和Y轴上的缩放系数,例如
scale_x = 50,scale_y = 31,以适应屏幕的宽高比。 - 遍历环上的每一个点 (
j从 0 到POINTS-1)。 - 计算每个点的角度:
angle = (j * TABLE_SIZE) / POINTS。这确保了能均匀地覆盖整个圆周。 - 使用正弦和余弦函数,结合缩放系数和透视因子,计算该点的屏幕坐标:
x = (cos(angle) * scale_x) / projectiony = (sin(angle) * scale_y) / projection
- 将计算出的
(x, y)存入tunnel[i][j]。
接下来,我们使用利萨茹曲线为每个环生成一个初始的偏移量,让隧道在起始时就带有一定的扭曲,而非完全笔直。
以下是计算利萨茹曲线坐标的函数:
void lissajous(int *x, int *y, int t) {
int scale = 50; // 控制波动幅度
// 使用 3:2 的频率比,产生特定的利萨茹图案
*x = (cosine((t * 2) % TABLE_SIZE) * scale) >> FIXED_SHIFT;
*y = (sine((t * 3) % TABLE_SIZE) * scale) >> FIXED_SHIFT;
}
这个函数根据输入的时间 t,计算出一对 (x, y) 偏移量。


在初始化函数中,我们为每个环 (i) 调用 lissajous(&liss[i][0], &liss[i][1], i),将结果存入 liss 数组。这样,每个环都获得了一个基于其索引的独特偏移,形成了初始的扭曲隧道形状。
实现动画与绘制循环
初始化工作完成后,本节我们来看看如何在主循环中实现隧道的动画和绘制。动画的核心是让利萨茹曲线的偏移量随时间变化,并叠加到预计算的隧道坐标上。
在主绘制函数 draw_tunnel 中,我们接收一个时间参数 T,它随着每一帧递增。
以下是绘制每一帧的步骤:
- 遍历每一个环 (
i从 0 到LEVELS-1)。 - 创建运动条纹:为了实现隧道内运动的条纹效果,我们让部分环不绘制。例如,判断
(T + i) % 10 < 5,如果成立则跳过当前环的绘制。这会产生每隔5个环出现一次空白带的效果,并且随着T增加,空白带会向隧道深处移动。 - 计算颜色:根据环的深度决定其亮度,模拟透视衰减。公式类似于:
color = 31 - (i / (LEVELS / 16))。这样,前面的环是亮白色(索引31),越往深处的环颜色越暗(索引向16靠近)。 - 遍历当前环上的每一个点 (
j从 0 到POINTS-1)。 - 计算最终屏幕坐标:
- 从
tunnel[i][j]中获取该点的基准坐标(base_x, base_y)。 - 根据当前帧时间
T和环索引i,从liss数组中获取动画偏移量。索引计算为(T + i) % LEVELS,确保动画连贯。 - 最终坐标:
screen_x = (base_x + liss_x_offset) + SCREEN_CENTER_X - 最终坐标:
screen_y = (base_y + liss_y_offset) + SCREEN_CENTER_Y
- 从
- 边界检查与绘制:检查
screen_x和screen_y是否在屏幕范围内(0 <= x < 320, 0 <= y < 200)。如果在范围内,则使用set_pixel(screen_x, screen_y, color)宏将该点绘制到后缓冲区。
完成一帧所有点的绘制后,程序在垂直回扫期间将后缓冲区的内容复制到VGA显示内存,从而实现平滑的动画显示。
总结
本节课中我们一起学习了如何在MS-DOS环境下创建经典的“Second Reality”隧道效果。
我们首先定义了隧道的基本结构——由多个椭圆环组成,并预计算了它们的坐标,同时引入了透视投影来增强3D感。接着,我们利用利萨茹曲线为每个环生成动态的偏移量,这是实现隧道“蠕动”效果的关键。最后,在主绘制循环中,我们将预计算的坐标与动态偏移量结合,通过简单的灰度着色和条纹遮挡技巧,绘制出具有深度感和运动感的隧道动画。


这个效果演示了即使使用有限的硬件资源(如16色VGA模式),通过巧妙的数学计算和预优化,也能实现令人印象深刻的视觉效果。
036:实现“第二现实”透镜效果教程




概述
在本节课中,我们将学习如何在MS-DOS环境下,使用x86汇编和VGA图形模式,复现经典演示程序“第二现实”中的透镜扭曲效果。我们将创建一个模拟玻璃球体的特效,它会扭曲并着色背景图像。
上一节我们介绍了基础的图形操作,本节中我们来看看如何实现一个预计算的扭曲效果。

开发环境设置
我们使用EMU8086作为开发环境,因为它便于代码编辑。为了最终演示,我们会切换回Turbo C++进行编译和运行。


效果原理分析
今天要实现的效果来自“第二现实”演示程序,它出现在“旋转祖玛”效果之前。该效果模拟了一个扭曲背景图像并赋予其淡蓝色调的玻璃球体。


本质上,它模拟了一种能扭曲背景图像的玻璃球体。我们将实现除反弹和边缘阴影外的所有核心部分。为了简化,我们复用上一期隧道效果的Lissajous曲线动画。边缘阴影部分可以作为练习,因为“第二现实”的源代码是公开的。
“第二现实”源代码中的图形略有不同,例如缺少五角星图案。我们将实现略有不同的扭曲公式,以产生更具玻璃质感的外观。
核心扭曲公式
我们使用的扭曲公式基于一个模拟透镜或球体表面的函数。该公式会产生一个缩放因子,导致图像中心被放大,并向边缘逐渐减弱。
公式 的核心计算如下:
shift = d / sqrt(d^2 - (x^2 + y^2))
其中 d 是缩放因子或焦距,x 和 y 是相对于透镜中心的坐标。
这个函数描述了一个球面。一个真正的玻璃球体会使图像倒置,但我们的简化版本仍然能产生有趣且可信的扭曲效果。缩放因子 d 决定了效果更像透镜还是更像球体;d 值越小,扭曲越强烈。

我们可以预先计算这些扭曲值(偏移量)。在渲染时,对于透镜上的每个像素点 (x, y),我们不直接绘制该点的颜色,而是根据预计算的偏移量,从源图像的另一位置 (x + offset_x, y + offset_y) 获取颜色。
需要注意的技术点是,我们需要在垂直回扫期间备份和恢复被透镜覆盖的屏幕矩形区域,以避免闪烁。
代码结构与全局变量
我们将重用大量旧代码,包括用于加载图像的 g.h 头文件。图像已从LBM格式转换为PCX,再通过ImageMagick转换为GIF。
以下是需要定义的全局变量和常量:
#define LENS_SIZE 80 // 透镜直径
#define LENS_RADIUS (LENS_SIZE/2)
#define LENS_ZOOM 16 // 缩放因子,值越小扭曲越强
#define SCREEN_WIDTH 320
#define SCREEN_HEIGHT 200
Gfx *g = NULL; // 图像数据结构
int lens[LENS_SIZE][LENS_SIZE]; // 预计算的偏移量数组
char backup[LENS_SIZE * LENS_SIZE]; // 背景备份缓冲区
int lens_x, lens_y; // 透镜在屏幕上的当前位置
初始化函数
初始化函数 init_lens 负责预计算扭曲偏移量。由于球体是中心对称的,我们只需计算一个象限的值,然后通过镜像得到其他三个象限的值,以提高效率。
void init_lens() {
int ls = LENS_SIZE / 2;
int r2 = LENS_RADIUS * LENS_RADIUS;
float zoom = (float)LENS_ZOOM;
for (int y = 0; y < ls; y++) {
int y2 = y * y;
for (int x = 0; x < ls; x++) {
int x2 = x * x;
// 检查是否在圆形透镜内
if ((x2 + y2) < r2) {
// 计算扭曲偏移量
float shift = zoom / sqrt(zoom*zoom - (x2 + y2));
int offset_x = (int)(x * (shift - 1.0));
int offset_y = (int)(y * (shift - 1.0));
// 为四个象限赋值
lens[ls + y][ls + x] = offset_y * SCREEN_WIDTH + offset_x;
lens[ls + y][ls - x] = offset_y * SCREEN_WIDTH - offset_x;
lens[ls - y][ls + x] = -offset_y * SCREEN_WIDTH + offset_x;
lens[ls - y][ls - x] = -offset_y * SCREEN_WIDTH - offset_x;
} else {
// 透镜区域外的点标记为特殊值
lens[ls + y][ls + x] = INT_MAX;
lens[ls + y][ls - x] = INT_MAX;
lens[ls - y][ls + x] = INT_MAX;
lens[ls - y][ls - x] = INT_MAX;
}
}
}
}
绘制透镜函数
绘制函数 draw_lens 在每一帧中执行。它首先恢复上一帧的背景,然后计算透镜新位置并备份该处背景,最后根据预计算的偏移量绘制扭曲后的图像。
void draw_lens(int time_index) {
// 1. 恢复旧位置的背景
restore_rectangle(lens_x, lens_y, backup);
// 2. 更新透镜位置(使用Lissajous曲线)
lens_x = ...; // 根据time_index计算新x坐标
lens_y = ...; // 根据time_index计算新y坐标
// 3. 备份新位置的背景
backup_rectangle(lens_x, lens_y, backup);
// 4. 计算VGA内存中的起始位置
int vga_offset = lens_y * SCREEN_WIDTH + lens_x;
// 5. 遍历透镜的每个像素进行绘制
for (int row = 0; row < LENS_SIZE; row++) {
int img_offset = vga_offset;
for (int col = 0; col < LENS_SIZE; col++) {
int offset = lens[row][col];
if (offset != INT_MAX) {
// 从源图像获取扭曲后的像素颜色(添加64以使用调色板中蓝色调的部分)
char color = g->data[img_offset + offset] + 64;
// 写入VGA内存
vga_memory[vga_offset + col] = color;
}
}
vga_offset += SCREEN_WIDTH; // 移动到下一行
}
}
辅助函数 backup_rectangle 和 restore_rectangle 负责矩形区域的内存拷贝,实现简单,未做边界检查。
实现蓝色调效果
“第二现实”中的蓝色调是通过VGA调色板技巧实现的。原图像使用64色。我们复制这64种颜色到调色板的下一组64个条目中,并增强每种颜色的蓝色分量,从而创建出着色玻璃的效果。


以下是修改调色板的函数:
void init_palette() {
// 设置前64种颜色(原始图像颜色)
for (int i = 0; i < 64; i++) {
set_palette_color(i, g->palette[i].r, g->palette[i].g, g->palette[i].b);
}
// 设置后64种颜色(增强蓝色的版本)
for (int i = 0; i < 64; i++) {
int new_r = g->palette[i].r;
int new_g = g->palette[i].g;
int new_b = (g->palette[i].b + 16) > 63 ? 63 : (g->palette[i].b + 16); // 增加蓝色,确保不超过63
set_palette_color(i + 64, new_r, new_g, new_b);
}
}
在绘制透镜时,我们从源图像获取颜色索引后,加上64,即可使用调色板中蓝色调的部分,从而实现着色效果。
主程序流程

主函数协调所有步骤,构成程序的主要循环。
int main() {
int time_index = 1; // 从1开始,0用于初始化透镜背景备份
// 初始化
load_gfx("background.gif");
init_sin_table(); // 用于Lissajous曲线
set_vga_mode();
init_palette();
init_lens();
// 初始备份透镜位置的背景
backup_rectangle(lens_x, lens_y, backup);
// 主循环
while (!key_pressed(KEY_ESCAPE)) {
wait_for_vertical_retrace(); // 等待垂直回扫以减少闪烁
draw_lens(time_index);
time_index++;
}
// 清理并返回文本模式
set_text_mode();
return 0;
}
总结
本节课中我们一起学习了如何在MS-DOS环境下实现“第二现实”的透镜扭曲效果。我们分析了其核心原理,即通过一个预计算的球面扭曲公式来偏移像素位置。我们编写了代码来初始化扭曲映射、在每一帧中绘制扭曲图像,并运用VGA调色板技巧为透镜添加了蓝色色调。整个效果的关键在于预计算,这使得实时渲染成为可能。虽然这是一个简化版本,但它展示了演示编程中利用简单数学和硬件特性创造视觉奇迹的精髓。你可以尝试修改扭曲公式、调色板或添加阴影来进一步探索这个效果。
037:低级键盘编程教程
在本节课中,我们将学习如何为MS-DOS编写一个低级的键盘中断处理程序。我们将绕过BIOS,直接与键盘控制器通信,以实现无按键重复、支持多键同时按下的精确输入控制。这对于编写游戏等需要快速响应的程序至关重要。

概述
键盘控制器通过硬件中断9(IRQ 1)与系统通信。每当按键被按下或释放时,控制器会生成一个唯一的“通码”(按下)和“断码”(释放)。我们将编写一个中断处理程序来捕获这些原始扫描码,并将其状态存储在一个数组中,供主程序查询。
核心概念与代码
我们将使用以下端口与键盘控制器通信:
- 键盘数据端口:
0x60,用于读取扫描码。 - 系统控制端口:
0x61,用于确认扫描码的接收。

中断处理程序的核心逻辑是:读取扫描码,判断是通码还是断码,更新对应按键的状态,然后向键盘控制器和中断控制器发送确认信号。
实现步骤
1. 主程序框架


首先,我们建立程序的基本循环结构,并设置图形模式。
void main() {
// 初始化按键状态数组
volatile unsigned char keys[256] = {0};
// 进入图形模式
set_video_mode(0x13); // 320x200, 256色
// 主循环
while (!keys[KEY_ESC]) {
// 等待垂直回扫以控制速度
wait_for_retrace();
// 此处将处理绘图和按键查询逻辑
}
// 退出前恢复文本模式
set_video_mode(0x03);
}
2. 设置中断处理程序
我们需要保存原有的中断9向量,并用我们自己的处理函数替换它。程序退出前必须恢复原向量。
void interrupt (*old_kb_handler)(); // 保存原中断处理程序地址

void setup_keyboard_interrupt() {
// 获取并保存原中断向量
old_kb_handler = _dos_getvect(0x09);
// 设置新的中断向量
_dos_setvect(0x09, our_kb_handler);
}

void restore_keyboard_interrupt() {
// 程序退出前恢复原中断向量
_dos_setvect(0x09, old_kb_handler);
}

3. 编写键盘中断处理程序

这是教程的核心部分。处理程序将直接读取硬件端口,处理扫描码。
void interrupt our_kb_handler() {
unsigned char scancode;
unsigned char ctrl_port;
// 禁用中断,防止处理过程被打断
disable();
// 从键盘数据端口读取扫描码
scancode = inportb(0x60);
// 从系统控制端口读取当前状态
ctrl_port = inportb(0x61);
// 判断是通码还是断码(断码 = 通码 + 0x80)
if (scancode & 0x80) {
// 是断码(按键释放)
keys[scancode - 0x80] = 0; // 将对应按键状态设为0
} else {
// 是通码(按键按下)
keys[scancode] = 1; // 将对应按键状态设为1
}
// 通过切换系统控制端口的第7位来确认接收扫描码
outportb(0x61, ctrl_port | 0x80); // 禁用键盘
outportb(0x61, ctrl_port); // 重新启用键盘(完成确认)
// 通知中断控制器(PIC)中断已处理
outportb(0x20, 0x20);
// 重新启用中断
enable();
}
4. 定义按键常量与状态数组
为了方便使用,我们为常用按键定义其通码值。
#define KEY_ESC 0x01
#define KEY_W 0x11
#define KEY_A 0x1E
#define KEY_S 0x1F
#define KEY_D 0x20
#define KEY_LSHIFT 0x2A
#define KEY_LCTRL 0x1D
// 全局按键状态数组,volatile确保中断中的修改能被主循环看到
volatile unsigned char keys[256];



5. 在主循环中应用按键输入
现在,我们可以在主循环中查询keys数组,并根据按键状态实现一个简单的绘图程序。
以下是主循环中处理移动和颜色变化的逻辑示例:
int x = 160, y = 100; // 初始位置在屏幕中心
int color = 15; // 初始颜色为白色

while (!keys[KEY_ESC]) {
wait_for_retrace();
// 根据WASD键移动点
if (keys[KEY_W] && y > 0) y--;
if (keys[KEY_S] && y < 199) y++;
if (keys[KEY_A] && x > 0) x--;
if (keys[KEY_D] && x < 319) x++;
// 根据Shift和Ctrl键改变颜色
if (keys[KEY_LSHIFT]) {
color = (color + 1) % 256; // 颜色值循环增加
}
if (keys[KEY_LCTRL]) {
color = (color - 1 + 256) % 256; // 颜色值循环减少
}
// 在屏幕上绘制一个点
putpixel(x, y, color);
}
总结

本节课中,我们一起学习了MS-DOS下的低级键盘编程。我们掌握了以下核心内容:
- 原理:了解了键盘通过中断9(IRQ 1)与CPU通信,并生成通码和断码。
- 关键操作:学会了使用
0x60端口读取扫描码,以及使用0x61端口确认接收。 - 中断处理:编写了一个完整的中断服务程序(ISR),包括禁用/启用中断、读取端口、更新状态数组、发送确认信号等标准流程。
- 应用:实现了一个简单的绘图程序,演示了如何利用直接键盘输入实现多键检测和即时响应。

通过直接控制键盘控制器,我们获得了比标准BIOS调用更快速、更灵活的输入处理能力,这是开发DOS游戏和实时应用程序的重要基础。你可以将此框架扩展,用于检测更多按键或实现更复杂的输入逻辑。
038:温馨的BASIC圣诞教程 🎄
概述
在本教程中,我们将学习如何使用PowerBASIC编译器,在MS-DOS环境下编写一个程序,加载一张圣诞场景的PCX格式图片,并在壁炉区域实现一个动态的火焰动画效果。我们将涵盖图形模式设置、PCX文件解码、VGA调色板操作以及火焰粒子系统的模拟。


章节 1:项目介绍与环境准备 🛠️
我们将在本次“Let's Code Attos”圣诞特辑中使用PowerBASIC。这是一个将BASIC代码编译为DOS可执行文件(.EXE)的编译器,而非解释器。本教程使用的是3.2版本。
首先,我们需要一张背景图片。这里使用了一张转换为PCX格式的256色圣诞场景图。PCX格式使用一种简单的游程编码(RLE)压缩算法,虽然压缩率不如GIF,但编码相对简单。

我们的目标是在图片的壁炉区域渲染一个动画火焰。为此,我们将使用VGA的256色调色板:低128位用于存储图片本身的颜色,高128位则预留给火焰动画的渐变颜色。

章节 2:主程序结构与图形模式设置 🖥️
上一节我们介绍了项目目标,本节我们来看看程序的主框架和如何设置VGA图形模式。
主程序结构如下:
- 清屏。
- 初始化随机数生成器。
- 调用子程序初始化图形模式。
- 调用子程序加载PCX图片。
- 调用子程序初始化火焰区域。
- 进入主循环,直到有按键按下。
- 循环内进行垂直同步并更新火焰。
- 退出循环后,返回文本模式。
在QBasic或QuickBASIC中,通常使用 SCREEN 13 来设置320x200、256色的图形模式。但PowerBASIC并不直接支持此模式。因此,我们需要通过DOS中断调用来手动设置。
以下是设置图形模式的代码:
SUB initGraphics ()
REG 1, &H13
CALL INTERRUPT &H10
END SUB
这里,REG 1 设置AX寄存器为 0x13(设置显示模式的功能号),然后调用 0x10 号中断(BIOS视频服务)。返回文本模式只需将AX设为 0x03 并再次调用中断即可。
章节 3:实现垂直同步与PCX文件加载 ⏱️
上一节我们成功进入了图形模式,本节中我们来实现垂直同步和图片加载功能,以确保动画流畅且无撕裂。
垂直同步可以确保我们在屏幕回扫期间更新画面,避免撕裂。我们可以通过读取VGA状态端口来实现。
以下是垂直同步子程序:
SUB vSync ()
vsync0:
IF (INP(&H3DA) AND 8) <> 0 THEN GOTO vsync0
vsync1:
IF (INP(&H3DA) AND 8) = 0 THEN GOTO vsync1
END SUB
接下来是加载PCX文件。PCX文件由128字节的文件头、经过RLE压缩的图像数据和768字节的调色板数据组成。
加载PCX文件的步骤如下:
- 以二进制模式打开文件。
- 读取文件头,验证魔数(第一个字节为0x0A)和编码方式(第四个字节为1表示RLE编码)。
- 使用
SEEK语句定位到文件末尾的调色板数据并读取。 - 将调色板数据写入VGA调色板寄存器。
- 重新定位到图像数据开始处,逐个解码像素并写入VGA内存(地址
&HA000)。
由于BASIC处理二进制文件时只能读取到字符串变量中,且字符串有长度限制,因此我们选择逐字节读取和解码。虽然效率较低,但代码更清晰易懂。
章节 4:初始化火焰区域与燃料 🔥
图片加载完成后,我们需要在壁炉的特定区域“挖”出一个矩形,用于放置火焰动画。这个区域将使用我们调色板中高128位的起始颜色(冷色)进行填充。
首先,我们需要确定壁炉区域在屏幕上的坐标。通过图像编辑器测量,我们确定矩形区域为:Y坐标从103到133,X坐标从140到195。
以下是初始化火焰区域的子程序:
SUB initFire ()
DEF SEG = &HA000
FOR y = 103 TO 133
FOR x = 140 TO 195
offset = x + y * 320
POKE offset, 128
NEXT x
NEXT y
DEF SEG
END SUB
这段代码将指定矩形内的所有像素设置为颜色索引128,即我们火焰调色板中的“冷”黑色,为后续的火焰计算准备好画布。
章节 5:生成与更新火焰动画 🌟
上一节我们准备好了火焰的“画布”,本节我们来看看如何生成动态的火焰效果。核心思想是模拟热量的传播和衰减。
火焰动画通过以下步骤实现:
- 添加燃料:在火焰区域底部(Y=132,133的两行)随机生成高温像素点(颜色值在128-255之间),作为火焰的“燃料”。
r1 = 128 + INT(RND * 127) r2 = 128 + INT(RND * 127) - 传播热量:从火焰区域底部向上遍历每个像素(Y从104到131)。对于每个像素,查看其下方一行(Y+1)以及左右随机偏移(-1,0,1)位置的三个像素的颜色值。
- 计算平均值:将这三个像素的颜色值求和。
- 衰减:将求和结果除以一个系数(例如3.4),模拟热量在上升过程中的冷却。这个系数控制火焰的高度和衰减速度。
- 写入新值:将计算得到的新颜色值写入当前像素。
通过在主循环中不断重复“添加燃料”和“传播热量”这两个步骤,就能产生持续摇曳、上升的火焰视觉效果。随机偏移的引入增加了火焰的不规则性和真实感。



总结
在本教程中,我们一起学习了如何在MS-DOS环境下使用PowerBASIC创建一个带有动态火焰效果的圣诞图形程序。我们涵盖了从设置VGA 13h图形模式、解码PCX图像文件、操作VGA调色板,到实现一个基于粒子系统的简易火焰动画的全过程。虽然BASIC的执行效率有限,但通过清晰的步骤,我们成功实现了一个视觉效果不错的温馨圣诞场景。你可以尝试修改火焰参数、使用不同的背景图片,或优化代码逻辑来获得更好的性能或不同的效果。
039:AdLib鼓机编程教程
在本节课中,我们将学习如何为MS-DOS系统下的AdLib声卡(基于Yamaha YM3812 OPL2芯片)编写一个简单的鼓机程序。我们将涵盖声卡的基本原理、寄存器编程方法,并最终实现一个可以录制和播放鼓点序列的程序。
概述
AdLib声卡使用FM合成技术来产生声音。它有两种主要模式:旋律模式和节奏模式。本节课我们将专注于节奏模式,学习如何编程底鼓、军鼓、通鼓、踩镲和吊镲这五种打击乐声音。
声卡基础与FM合成
上一节我们介绍了本课程的目标。本节中,我们来看看AdLib声卡和FM合成的基本概念。
AdLib声卡通过两个I/O端口进行控制:
- 索引寄存器:
0x388 - 数据寄存器:
0x389
向芯片写入数据时,需要先向索引寄存器写入目标寄存器的地址,再向数据寄存器写入数据。这与VGA显卡的编程方式类似。
该声卡的核心是FM合成。它使用称为“算子”的振荡器来生成声音。每个算子产生一个波形(如正弦波)。通过让一个算子(调制器)去调制另一个算子(载波器)的频率,可以创造出复杂多变的音色。
以下是声卡可以产生的基本波形:
- 正弦波
- 半正弦波(类似锯齿波)
- 绝对值正弦波(类似三角波)
- 脉冲正弦波(类似方波)
声音的音量由包络发生器控制,遵循ADSR模型:
- A (Attack):起音,声音从零到达峰值的时间。
- D (Decay):衰减,从峰值下降到持续电平的时间。
- S (Sustain):持续,按住音符时保持的音量电平。
- R (Release):释音,释放音符后音量降到零的时间。

寄存器详解与鼓模式
了解了声卡的基本工作原理后,我们现在深入查看其寄存器结构,特别是用于控制鼓模式的寄存器。
OPL2芯片的寄存器分为全局寄存器和通道专用寄存器。对于鼓模式,关键寄存器如下:
- 节奏模式使能寄存器:地址
0xBD。将其第5位(0x20)设置为1可启用节奏模式。 - 鼓乐器开关位:同样在
0xBD寄存器中,通过设置特定的位来打开或关闭各个鼓乐器:- 底鼓 (Bass Drum):第4位 (
0x10) - 军鼓 (Snare Drum):第3位 (
0x08) - 通鼓 (Tom Tom):第2位 (
0x04) - 吊镲 (Cymbal):第1位 (
0x02) - 踩镲 (Hi-Hat):第0位 (
0x01)
- 底鼓 (Bass Drum):第4位 (
要播放一个鼓点,需要先启用节奏模式,然后置位对应的鼓乐器位。声音会使用预编程的振荡器参数(如音高、ADSR)立即播放。
每个鼓乐器对应一个或多个固定的振荡器(算子)。例如,底鼓使用两个振荡器。这些振荡器的参数(如音高、ADSR)需要提前设置好,以定义每个鼓的声音特性。
核心编程步骤
掌握了理论之后,本节我们将把编程过程分解为几个具体的步骤。
编写鼓机程序主要分为三个步骤:
- 重置并初始化声卡:将声卡置于已知状态,配置所有振荡器的默认参数(如ADSR、波形选择)。
- 启用并配置节奏模式:打开节奏模式,并为各个鼓乐器设置更合适的音高和包络参数。
- 实现主循环与交互:检测键盘输入,将鼓点记录到序列数组中,并按照定时回放。
以下是实现这些功能所需的核心函数和常量定义示例:
#define ADLIB_INDEX 0x388
#define ADLIB_DATA 0x389
#define ADLIB_REG_DRUM 0xBD
// 鼓乐器位定义
#define DRUM_BASS 0x10
#define DRUM_SNARE 0x08
#define DRUM_TOM 0x04
#define DRUM_CYMBAL 0x02
#define DRUM_HIHAT 0x01
// 向AdLib寄存器写入数据的函数(包含延迟)
void adlib_write(uint8_t reg, uint8_t value) {
outportb(ADLIB_INDEX, reg);
for(int i = 0; i < 6; i++) { inportb(ADLIB_INDEX); } // 写入前延迟
outportb(ADLIB_DATA, value);
for(int i = 0; i < 35; i++) { inportb(ADLIB_DATA); } // 写入后延迟
}
计算与设置音高
要让鼓发出正确音调的声音,我们需要计算并设置振荡器的音高。本节中我们来看看如何将音乐音符转换为芯片可以理解的频率数值。
音高由两个参数共同决定:块号 和 频率编号。
- 块号 代表八度音阶,存储在寄存器的高几位中。
- 频率编号 是一个10位数值(低8位在一个寄存器,高2位在另一个寄存器),代表该八度内的具体音高。


对于一个给定的音符(例如A4,440Hz),其频率编号 F 可以通过以下公式计算:


F = (音符频率 * 2^(20 - 块号)) / 49716


其中 49716 是芯片内部的采样频率。计算出的 F 取整后,其低8位写入 0xA0 至 0xA8 系列寄存器(对应不同通道),高2位与块号一起组合写入 0xB0 至 0xB8 系列寄存器。


例如,要设置通道0播放A5(块号5,计算得频率编号约290):
- 向寄存器
0xA0写入290 & 0xFF(低8位)。 - 向寄存器
0xB0写入((5 << 2) | (290 >> 8))(块号左移2位后与高2位组合)。


程序实现与演示


现在,我们将把前面所有的知识整合起来,完成鼓机程序的编写。
程序的主体是一个循环,它执行以下操作:
- 使用
kbhit()和getch()检测键盘输入。 - 如果按下数字键1-5,则将对应的鼓点记录到一个循环数组
drum_sequence[]的当前位置。 - 每一帧(使用
wait_for_retrace实现约70Hz定时),读取drum_sequence中当前时刻的值,并通过写入0xBD寄存器来触发记录的鼓点。 - 在屏幕上显示当前播放的位置。
核心播放逻辑如下:
// 在定时循环中
current_tick = (current_tick + 1) % SEQUENCE_LENGTH;
uint8_t drums_to_play = drum_sequence[current_tick];
// 先关闭所有鼓(可选,也可用其他方式实现键位释放)
adlib_write(ADLIB_REG_DRUM, rhythm_mode_enabled_bit);
// 播放当前时刻的鼓点
adlib_write(ADLIB_REG_DRUM, rhythm_mode_enabled_bit | drums_to_play);
// 显示当前节拍
printf("Tick: %d\n", current_tick / 32); // 假设32 tick为一小节
wait_for_retrace(); // 等待垂直回扫,控制速度
通过按下不同的数字键,用户可以实时录制一个鼓点循环,程序随后会不断播放这个循环。


总结


本节课中我们一起学习了AdLib声卡鼓机编程的全过程。
我们首先了解了AdLib声卡和FM合成的基础知识,包括其寄存器寻址方式和工作原理。然后,我们深入研究了用于控制节奏模式的关键寄存器。接着,我们分步讲解了编程的核心环节:初始化声卡、配置音高和ADSR包络、启用节奏模式。最后,我们实现了一个简单的主循环,能够响应键盘输入来录制和播放鼓点序列。
你现在已经掌握了:
- AdLib声卡的基本编程方法。
- 如何计算和设置FM合成器的音高。
- 如何配置声音的ADSR包络。
- 如何启用和控制节奏模式下的各种打击乐器。
- 如何构建一个简单的交互式鼓机序列器。



你可以在此基础上进行扩展,例如添加旋律通道的支持、实现更复杂的序列编辑功能,或者调整振荡器波形来创造更独特的音色。希望这为你探索MS-DOS时代的音频编程提供了一个良好的起点。
040:AdLib OPL2旋律编程教程

在本节课中,我们将学习如何为AdLib OPL2合成器编写旋律程序。我们将定义乐器结构,实现音符播放功能,并将PC键盘的一部分映射为钢琴键盘,从而创建一个简单的旋律合成器。
概述

上一节我们实现了一个基于OPL2合成器的简单鼓机。本节我们将专注于旋律编程。我们将使用OPL2的旋律模式,该模式提供6个旋律声道和5个打击乐声道。核心任务是创建一个可以定义乐器和播放音符的系统。
核心概念与准备工作

首先,我们需要理解OPL2合成器的几个核心概念。所有声音都基于一个正弦波,该波形以只读方式存储了四分之一波,并通过镜像复制来生成完整的波形。


声音的包络由ADSR(Attack, Decay, Sustain, Release) 生成器控制。Key On和Key Off信号触发包络的不同阶段。特别地,Sustain(持续)位 决定了在按下琴键期间是否使用持续电平。如果该位关闭,则在衰减阶段后会立即进入释放阶段,类似于钢琴上不踩下延音踏板的效果。

此外,调制器(Modulator)可以调制载波器(Carrier),产生频率调制(FM)效果。颤音(Vibrato) 和震音(Tremolo/Amplitude Modulation) 功能可以进一步丰富音色。



为了编程,我们需要定义一些数据结构和查找表。

定义声道与振荡器映射

在旋律模式下,6个旋律声道各自使用两个特定的振荡器(操作器)。以下是它们的映射关系,我们将用一个二维数组来存储:


static int adlib_voice_oscillators[6][2] = {
{0x00, 0x03}, // 声道 0 使用振荡器 1 和 4
{0x01, 0x04}, // 声道 1 使用振荡器 2 和 5
{0x02, 0x05}, // 声道 2 使用振荡器 3 和 6
{0x08, 0x0B}, // 声道 3 使用振荡器 7 和 10
{0x09, 0x0C}, // 声道 4 使用振荡器 8 和 11
{0x0A, 0x0D} // 声道 5 使用振荡器 9 和 12
};
定义音符频率表
OPL2使用一个10位的频率数值(F-Number)和一个3位的区块数值(Block Number,代表八度)来确定音高。我们可以预先计算一个八度内所有半音的频率数值。计算公式基于标准音高A4(440Hz)。这里我们计算出一个八度的频率数值:
unsigned int adlib_notes[12] = {
172, 182, 193, 205, 217, // C, C#, D, D#, E
230, 243, 258, 273, 290, // F, F#, G, G#, A
307, 325 // A#, B
};
通过改变区块数值,我们可以用这组数据生成所有八度的音符。

定义乐器结构



一个乐器由两个振荡器(载波器和调制器)及其连接方式定义。我们首先定义单个振荡器的参数结构:
typedef struct {
unsigned char attack_rate;
unsigned char decay_rate;
unsigned char sustain_level;
unsigned char release_rate;
unsigned char tremolo; // 振幅调制(震音)
unsigned char vibrato; // 频率调制(颤音)
unsigned char sustaining; // 持续开关
unsigned char key_scale; // 键位缩放
unsigned char total_level; // 总电平(衰减)
unsigned char wave_select; // 波形选择
} Adlib_Oscillator;
接着,定义完整的乐器结构,包含两个振荡器和一些全局设置:
typedef struct {
Adlib_Oscillator modulator; // 调制器
Adlib_Oscillator carrier; // 载波器
unsigned char connection; // 连接方式:0为FM合成,1为加法合成
unsigned char feedback; // 反馈强度(0-7)
} Adlib_Instrument;
实现设置乐器函数


adlib_set_instrument 函数接收一个乐器结构和一个声道号,并将所有参数写入对应的OPL2寄存器。这涉及大量的位操作。
以下是设置攻击率(Attack Rate)和衰减率(Decay Rate)的示例代码:


void adlib_set_instrument(Adlib_Instrument *instrument, int voice) {
// 获取该声道对应的两个振荡器编号
int op0 = adlib_voice_oscillators[voice][0]; // 调制器
int op1 = adlib_voice_oscillators[voice][1]; // 载波器
// 设置调制器的攻击率和衰减率(寄存器 0x20 - 0x35)
unsigned char value = (instrument->modulator.attack_rate & 0xF) << 4;
value |= (instrument->modulator.decay_rate & 0xF);
adlib_register(0x20 + op0, value);
// 设置载波器的攻击率和衰减率
value = (instrument->carrier.attack_rate & 0xF) << 4;
value |= (instrument->carrier.decay_rate & 0xF);
adlib_register(0x20 + op1, value);
// ... 类似地设置 Sustain Level & Release Rate (0x40-0x55),
// Tremolo/Vibrato/Sustain/KSR/Multi (0x60-0x75),
// Key Scale & Total Level (0x80-0x95),
// Waveform Select (0xE0-0xF5) 等寄存器
}

我们需要为每个振荡器重复此过程,设置所有相关参数,如持续电平、释放率、震音、颤音、键位缩放、总电平和波形选择。

最后,设置声道的连接方式和反馈参数(寄存器 0xC0 - 0xC8):
unsigned char value = (instrument->feedback & 0x7) << 1;
value |= (instrument->connection & 0x1);
adlib_register(0xC0 + voice, value);
实现播放音符函数
adlib_play_note 函数负责在指定声道上播放一个音符。它需要处理频率设置和 Key On/Off 事件。
为了在关闭一个音符时不改变其音高,我们需要记录每个声道当前正在播放的音符频率。我们使用一个静态数组来存储这些信息:
static unsigned int current_note_freq[6] = {0};
函数逻辑如下:
- 首先,发送
Key Off信号来停止当前音符(如果正在播放)。这通过清除对应寄存器中的Key On位(第5位)来实现,同时保留原有的区块和高位频率数值。 - 然后,设置新的频率数值(F-Number)到低位和高位寄存器。
- 最后,发送
Key On信号,并结合新区块数值写入高位寄存器,同时更新current_note_freq数组。
void adlib_play_note(int voice, unsigned int note) {
// 1. Key Off: 关闭当前音符,保持原有音高
unsigned char key_off_value = (current_note_freq[voice] >> 8) & 0x1F; // 清除Key On位
adlib_register(0xB0 + voice, key_off_value);
// 2. 设置新的频率数值(低8位)
adlib_register(0xA0 + voice, note & 0xFF);
// 3. Key On: 结合区块数值(例如第3八度)和频率高2位,并设置Key On位
unsigned char block = 3; // 示例:使用第3八度
unsigned char key_on_value = 0x20; // Key On 位
key_on_value |= (block << 2);
key_on_value |= ((note >> 8) & 0x03);
adlib_register(0xB0 + voice, key_on_value);
// 4. 记录当前音符频率
current_note_freq[voice] = note;
}
创建键盘映射与主程序逻辑
现在,我们将PC键盘的一部分键映射为一个八度的钢琴键盘(12个半音)。我们使用以下键位布局,模拟钢琴的白键和黑键:
A -> C
W -> C#
S -> D
E -> D#
D -> E
F -> F
T -> F#
G -> G
Y -> G#
H -> A
U -> A#
J -> B
在主循环中,我们检测按键,并将按下的键映射到对应的音符频率数值(结合之前计算的 adlib_notes 和区块数值)。然后,我们需要一个简单的声部管理策略来将音符分配给6个可用的旋律声道。
一个简单的方法是使用一个循环计数器,依次将新音符分配给下一个可用声道,实现基本的复音效果:
int current_voice = 0;
// ... 在按键处理循环内 ...
if (key_pressed) {
unsigned int note_freq = adlib_notes[note_index] | (block << 10);
adlib_play_note(current_voice, note_freq);
current_voice = (current_voice + 1) % 6; // 循环使用6个声道
}
我们还可以在屏幕上显示当前哪些声道正在播放音符,例如用 # 号表示。
定义示例乐器并初始化
在程序开始时,我们需要定义并设置一个乐器。以下是一个示例乐器配置,它创建了一个具有FM合成特性的音色:
Adlib_Instrument my_instrument;
// 配置载波器
my_instrument.carrier.tremolo = 1;
my_instrument.carrier.vibrato = 1;
my_instrument.carrier.sustaining = 1;
my_instrument.carrier.attack_rate = 0x06;
my_instrument.carrier.decay_rate = 0x06;
my_instrument.carrier.sustain_level = 0x0F;
my_instrument.carrier.release_rate = 0x06;
my_instrument.carrier.total_level = 0x00;
my_instrument.carrier.wave_select = 0; // 正弦波
// 配置调制器(参数类似,数值可不同以产生不同效果)
my_instrument.modulator.tremolo = 0;
// ... 设置其他调制器参数 ...



// 设置连接方式为FM合成(0),反馈强度为0
my_instrument.connection = 0;
my_instrument.feedback = 0;



// 为所有6个声道设置这个乐器
for (int i = 0; i < 6; i++) {
adlib_set_instrument(&my_instrument, i);
}



你可以通过调整这些参数(如攻击/释放时间、波形、反馈强度等)来创造各种各样的音色,例如风琴、钢琴或更电子化的声音。





总结
本节课我们一起学习了AdLib OPL2合成器的旋律编程。我们从理解ADSR包络和FM合成等核心概念开始,然后逐步实现了定义乐器结构、设置合成器参数、播放音符以及管理多声道复音的功能。通过将PC键盘映射为钢琴键盘,我们创建了一个可以交互演奏的简单旋律合成器。


你现在已经掌握了使用OPL2芯片进行音乐编程的基础知识。可以在此基础上进行扩展,例如实现更复杂的声部管理、读取音乐文件、或者创建一个图形化的乐器编辑器。
041:扩展内存 (EMS) 教程 💾
在本节课中,我们将要学习如何在 MS-DOS 环境下使用扩展内存。扩展内存是一种突破早期 PC 640KB 常规内存限制的技术,允许程序访问更多内存,用于存储图形、声音或关卡数据,从而加快加载速度。
背景与原理
上一节我们介绍了 VGA 图形编程的基础。本节中我们来看看如何突破内存限制。
最初的 PC 使用 8088 或 8086 CPU,它们有 20 条地址线,最多可寻址 2^20 字节,即 1 MB 内存。其中,高端的 384 KB 被用于 ROM 扩展和 BIOS,留给程序的可用内存只有 640 KB。这是一个严重的限制。

后来的 80286 和 80386 CPU 将寻址能力提升到了 16 MB 和 4 GB。但对于已经存在的旧软件,640KB 的限制依然存在。为了解决这个问题,Lotus、Intel 和 Microsoft 三家公司联合制定了 LIM 标准,定义了所谓的“扩展内存”。



扩展内存通常位于一块额外的 ISA 插卡上,带有数 MB 的 RAM。这块卡通过“分页切换”技术,将内存“窗口”映射到常规内存中一个未使用的段(通常在 D000:0000 到 E000:0000 之间)。程序通过一个标准化的 API(通过中断 0x67 访问)与这块卡通信。你可以分配内存、释放内存,并将扩展内存中的 16 KB 页面 切换到常规内存的“页框”中查看。后来,MS-DOS 自带了一个名为 EMM386 的扩展内存管理器,它通过软件模拟硬件卡的功能,允许旧程序在 386 及以上 CPU 上使用扩展内存。


使用 EMS 的好处是,它可以在从第一台 PC 开始的所有系统上工作,让你能编写使用超过 640KB 内存的 DOS 程序,同时又无需处理保护模式等复杂概念。

核心功能与实现


以下是使用扩展内存需要了解的核心功能,我们将通过代码示例来逐一讲解。
1. 检测 EMS 驱动程序




首先,需要检查系统是否安装了 EMS 驱动程序。可以通过检查中断 0x67 的处理程序地址处是否存在特定字符串来实现。


#define EMS_INT 0x67
#define EMS_NAME "EMMXXXX0"



int ems_installed() {
union REGS regs;
struct SREGS sregs;
regs.h.ah = 0x35; // DOS 功能号:获取中断向量
regs.h.al = EMS_INT;
intdosx(®s, ®s, &sregs); // 调用 DOS 中断
// 检查中断处理程序地址处是否有 “EMMXXXX0” 字符串
if (memcmp(MK_FP(sregs.es, 0x0A), EMS_NAME, strlen(EMS_NAME)) == 0) {
return 1; // 已安装
}
return 0; // 未安装
}


2. 获取 EMS 版本



获取驱动程序的版本号,返回值是二进制编码的十进制数。




int ems_version() {
union REGS regs;
regs.h.ah = 0x46; // 功能号:获取版本
int86(EMS_INT, ®s, ®s);
if (regs.h.ah != 0) return -1; // 出错
// 将 BCD 码转换为十进制数
int version = ((regs.h.al >> 4) * 10) + (regs.h.al & 0x0F);
return version;
}








3. 查询页面信息
扩展内存以 16 KB 的“页面”为单位进行管理。需要查询系统总共有多少页面,以及有多少空闲页面。


// 获取总页面数
int ems_page_count() {
union REGS regs;
regs.h.ah = 0x42; // 功能号:获取未分配页面数
int86(EMS_INT, ®s, ®s);
if (regs.h.ah != 0) return -1; // 出错
return regs.x.dx; // 总页面数在 DX 中
}





// 获取空闲页面数
int ems_free_pages() {
union REGS regs;
regs.h.ah = 0x42;
int86(EMS_INT, ®s, ®s);
if (regs.h.ah != 0) return -1;
return regs.x.bx; // 空闲页面数在 BX 中
}

4. 获取页框段地址
页框是常规内存中用于映射扩展内存页面的窗口,通常是一个段地址。

unsigned short ems_frame_segment() {
union REGS regs;
regs.h.ah = 0x41; // 功能号:获取页框基地址
int86(EMS_INT, ®s, ®s);
if (regs.h.ah != 0) return 0; // 出错
return regs.x.bx; // 段地址在 BX 中
}
5. 分配与释放内存
程序需要先分配一定数量的页面,并获得一个用于标识该内存块的“句柄”。
// 分配页面
int ems_allocate(int num_pages) {
union REGS regs;
regs.h.ah = 0x43; // 功能号:分配页面
regs.x.bx = num_pages; // BX = 请求的页面数
int86(EMS_INT, ®s, ®s);
if (regs.h.ah != 0) return -1; // 出错
return regs.x.dx; // 返回分配句柄 (在 DX 中)
}
// 释放页面
int ems_free(int handle) {
union REGS regs;
regs.h.ah = 0x45; // 功能号:释放页面
regs.x.dx = handle; // DX = 要释放的句柄
int86(EMS_INT, ®s, ®s);
return (regs.h.ah == 0) ? 0 : -1; // 返回成功或失败
}
重要提示:与常规的 malloc 不同,EMS 分配的内存不会在程序退出时自动释放。你必须显式调用 ems_free,否则会造成内存泄漏。
6. 映射页面
这是核心操作:将扩展内存中已分配的逻辑页面,映射到页框中的物理页窗口(共4个,编号0-3),使其在常规内存中可见。
int ems_map(int handle, int logical_page, int physical_page) {
union REGS regs;
regs.h.ah = 0x44; // 功能号:映射页面
regs.h.al = physical_page; // AL = 物理页号 (0-3)
regs.x.bx = logical_page; // BX = 逻辑页号
regs.x.dx = handle; // DX = 句柄
int86(EMS_INT, ®s, ®s);
return (regs.h.ah == 0) ? 0 : -1; // 返回成功或失败
}


7. 访问映射内存
映射后,可以通过页框段地址和偏移量来访问数据。以下是一个辅助宏,用于获取指向特定物理页的远指针。
#define EMS_PAGE_SIZE 16384 // 16 KB
unsigned short ems_frame_seg; // 存储获取到的页框段地址
#define EMS_PAGE(n) (MK_FP(ems_frame_seg, (n) * EMS_PAGE_SIZE))
// 使用示例:将数据复制到物理页0
_fmemcpy(EMS_PAGE(0), source_data, copy_size);


实战示例:加载并播放动画
让我们将这些功能组合起来,实现一个将多帧动画图片加载到 EMS 内存,然后循环播放的程序。
以下是程序的主要步骤:
- 初始化与检测:检查 EMS 驱动,获取版本和页面信息。
- 分配内存:根据动画帧数和每帧大小(例如 VGA Mode 13h 的 64000 字节),计算所需页面数并进行分配。
- 加载数据:循环读取每个动画帧文件(如 GIF),解码后,通过映射和内存拷贝,将像素数据存入 EMS 中预先分配好的连续逻辑页面。
- 优化技巧:可以一次映射4个连续的物理页,然后执行一次大内存拷贝,而不是每16KB映射拷贝一次。
- 播放循环:
- 进入图形模式。
- 对于每一帧动画:
- 将该帧对应的 EMS 逻辑页面映射到物理页窗口。
- 使用
_fmemcpy将数据从页框(如EMS_PAGE(0))快速拷贝到 VGA 显示内存(0xA000:0000)。 - 设置该帧的调色板。
- 延时以控制帧率。
- 清理退出:播放完毕或用户中断后,释放所有分配的 EMS 内存,并返回文本模式。


通过这种方式,即使动画数据总量远超 640KB,也能在传统的 DOS PC 上流畅预加载和播放,避免了播放时从磁盘实时解码的缓慢过程。
总结


本节课中我们一起学习了 MS-DOS 扩展内存的基本概念和使用方法。
扩展内存通过一个位于常规内存 D000:0000 附近的页框窗口和分页切换机制工作,兼容从 8088 到 386 的所有 PC。其驱动程序可通过中断 0x67 的向量地址处是否存在 “EMMXXXX0” 字符串来检测。核心操作遵循固定模式:将功能号放入 AH 寄存器,参数放入其他寄存器,然后调用中断 0x67。我们必须管理内存的分配、映射和释放,并特别注意手动释放已分配的内存以防止泄漏。


利用 EMS,我们可以为 DOS 程序赋予访问数 MB 内存的能力,非常适合用于存储游戏中的大型资源,是 DOS 时代突破内存瓶颈的关键技术之一。
042:扩展内存XMS编程教程
概述
在本节课中,我们将学习如何在MS-DOS环境下使用扩展内存规范来突破640KB的内存限制。我们将编写一个程序,利用XMS驱动程序将动画帧加载到扩展内存中,并在VGA图形模式下播放。
背景知识:MS-DOS内存限制与解决方案
MS-DOS因其640KB的内存屏障而闻名。这意味着在常规的MS-DOS程序中,只能访问0到640KB之间的内存。
然而,即使是1981年IBM推出的第一台PC,也支持高达1MB的总内存。这是8088和8086处理器能够直接寻址的最大值。在640KB到1MB之间的空间,通常被显卡的显存、BIOS ROM以及其他内存映射设备或ROM占用。
随着80286处理器的出现,最大内存容量增加到了16MB。但由于8088和8086使用段和偏移量寻址内存的方式,在通常的实模式下,286仍然只能寻址最多1MB的内存。它需要切换到所谓的“保护模式”才能使用高达16MB的“扩展内存”。
80386处理器进一步扩展了寻址空间,达到了4GB,尽管在那个时代的主板上几乎不可能支持如此大的内存。486处理器也是如此,它们只是拥有更多的地址线来寻址扩展内存。
为了突破640KB或1MB的限制,出现了几种解决方案。上一节我们介绍了“扩展内存”,它本质上是“分页切换”技术,利用显存和BIOS之间的空白区域来分页或切换扩展内存,一次最多可见16KB或64KB。其优点是,如果有支持此功能的硬件板,分页切换速度相对较快。
后来的机器如386和286可以通过软件模拟这种方式,但速度较慢,因为至少需要部分地复制数据。EMS的主要优点是它也能在低端的8086和8088机器上运行。

扩展内存与XMS标准
本节中我们来看看“扩展内存”。根据XMS标准,扩展内存实际上只适用于286及以后的机器。甚至可以通过BIOS支持直接操作扩展内存,这一点我很久以后才知道。
在中断INT 15h的系统BIOS例程中,有几个相关的函数。例如,子程序88h可以返回以1KB块为单位的扩展内存大小。它只适用于286和386,基本上返回CMOS中设置的、机器启动时检测到的内存信息。
还有函数89h用于切换到保护模式,我们今天不涉及这部分,我们将坚持使用实模式。函数87h可以在扩展内存之间或与常规内存之间复制数据块,但操作起来有些困难,也不是通常使用的方法。
取而代之的是XMS标准。XMS标准提供了一种访问扩展内存中几个不同区域的方法:
- 高端内存区:这是扩展内存的第一个64KB。由于8086设计中的一个巧妙漏洞,即使在实模式下,你也可以访问第一个1MB之后的这64KB内存,无需切换到保护模式。
- 上位内存块:指640KB到1024KB之间如果有空闲空间的内存区域,也可以通过XMS驱动程序使用。
- 扩展内存块:这是我们将要使用的部分,即可以分配、复制数据到/从其中复制的实际扩展内存。
XMS的主要优点是,相比EMS,你可以拥有更多的内存。在386及以上的机器上,这是访问扩展内存的常规方式,无需特殊的硬件支持。XMS总是通过切换到保护模式来完成,这在386上相当快。但在286上,从保护模式切换回实模式非常慢,因为Intel认为这种情况永远不会发生。因此,在286上运行XMS可能相对较慢,而如果有主板硬件支持,EMS可能更快。不过我们不会深究这一点,我们假设在386上运行并具有快速切换能力。
我们将编写一个与上一节EMS程序功能相同的程序:将35帧动画从硬盘加载到扩展内存,然后复制到VGA显存中进行播放。
XMS驱动程序函数简介
以下是XMS驱动程序提供的一些主要函数:
- 首先,必须检测XMS驱动程序是否已安装。
- 然后,可以查询驱动程序的版本号。
- 可以处理高端内存区,但我们不会使用。
- 我们将主要处理扩展内存块的分配、复制等操作。
- 我们不会涉及A20线(用于寻址高端内存区的技巧)。
程序结构与实现
现在让我们切换到代码编辑器,看看程序的结构。
我已经复制了do_ems.c文件,并更改了一些名称,因为我们现在处理的是XMS。主函数与之前的非常相似。
以下是程序的主要步骤:
- 清屏并尝试初始化XMS驱动程序。如果未找到,则打印错误并返回。
- 查询XMS驱动程序的版本号以及高端内存区是否可用(尽管我们不会使用它)。
- 打印系统的可用内存信息。
- 尝试分配足够的内存来存储所有动画帧(以1KB为单位分配)。
- 存储XMS句柄。所有XMS函数调用都会有错误检查。
- 通过读取所有帧的
.GIF文件,将数据加载到XMS中。 - 使用复制到XMS的函数,并复制调色板进行存储。
- 切换到图形模式,设置第一个调色板。
- 循环遍历所有图像,将数据从XMS直接复制到VGA显存中,无需任何绘制操作,驱动程序会为我们复制到原始的13h模式缓冲区。同时设置调色板。
- 添加一个小的延迟(7帧刷新等待),以实现大约每秒10帧的动画速度。
- 如果按下按键,则切换回文本模式并释放所有内存。
核心数据结构与调用机制
我们需要介绍几个关键的数据结构和调用机制。
首先是XMSREGS寄存器结构体和XMSMOVE扩展内存移动结构体。因为XMS驱动程序不像BIOS中断INT 15h那样使用软件中断,而是使用一个远过程调用,所有参数都通过寄存器传递。
XMSMOVE结构体用于移动数据,XMS驱动程序期望以下值:
length:要移动的字节数(32位值)。src_handle:源句柄(16位值)。如果为0,则表示源在实模式内存中(低于1MB),此时src_offset是一个由段和偏移量构成的远指针。src_offset:源偏移量(32位值)。dest_handle:目标句柄。如果为0,同理,表示目标在实模式内存中。dest_offset:目标偏移量。

我们还有一个指向驱动程序的远函数指针XMSpointer,以及全局错误变量XMSerror和程序使用的句柄XMSHandle。

调用XMS驱动程序需要使用内联汇编。因为Turbo C的旧版本IDE不支持内联汇编,所以我们必须使用命令行编译器进行编译。我写了一个名为make.bat的批处理文件,它使用TCC命令行编译器,并带有一些优化选项(针对286编译),将我们需要的三个文件(dosxms.c、vga.c和gif.c)编译链接成一个可执行文件。
关键函数详解
XMS驱动程序调用函数
XMScall函数是核心,它使用内联汇编来设置寄存器、调用XMS驱动程序,并获取返回值。关键步骤包括保存寄存器、设置函数号、加载参数寄存器、执行远调用,然后恢复寄存器并读取返回值。
初始化XMS
initXMS函数用于初始化并获取XMS驱动程序的入口点。它通过DOS多路中断INT 2Fh的功能43h来检查XMS驱动程序是否存在并获取其地址。
查询版本与内存信息
XMSversion函数调用XMS驱动程序的0x00号功能,返回版本号(主版本在AH,副版本在AL),并通过DX寄存器指示HMA是否可用。
XMSquery函数调用0x08号功能,返回总的扩展内存大小(KB)和最大空闲块大小(KB)。
内存分配与释放
XMSalloc函数调用0x09号功能,按KB单位分配扩展内存,并返回一个句柄。
XMSfree函数调用0x0A号功能,传入句柄来释放之前分配的内存。

内存复制


XMScopy是通用复制函数,调用0x0Bh号功能。它接受一个XMSMOVE结构体指针,可以处理在扩展内存之间、扩展内存与常规内存之间的任意方向复制。
copyToXMS和copyFromXMS是基于XMScopy的便捷函数,分别用于将数据从常规内存复制到扩展内存,以及从扩展内存复制到常规内存。


编译与运行
由于使用了内联汇编,我们需要使用命令行工具进行编译。运行make.bat批处理文件可以完成编译链接。
首先在DOSBox中测试程序。程序会显示XMS版本(如3.0)、HMA可用性、总扩展内存大小(例如15MB),并分配约2188KB(35帧 * 64KB/帧)的内存。动画能够成功播放。


然后在真实的486 DX2/66机器(配备20MB RAM)上测试。程序检测到HMA可用,总扩展内存约16MB,并成功分配内存播放动画。虽然由于显卡速度等原因存在一些屏幕撕裂,但证明了概念是可行的。
总结

本节课中,我们一起学习了如何在MS-DOS下使用扩展内存规范来突破640KB的内存限制。我们了解了XMS与EMS的区别,掌握了通过XMS驱动程序查询内存、分配/释放扩展内存块以及在扩展内存与常规内存间复制数据的方法。我们成功编写并运行了一个程序,将动画数据加载到扩展内存中并在VGA模式下流畅播放。这为开发需要更大内存的DOS程序提供了有效手段,而无需直接处理复杂且耗时的保护模式切换。
043:编写 Adlib 背景音乐播放器
在本节课中,我们将学习如何利用 Reality Adlib Tracker 工具及其附带的汇编播放器,在 MS-DOS 环境下播放 Adlib 背景音乐。我们将从加载音乐文件开始,逐步实现一个使用可编程中断定时器(PIT)进行精确 50Hz 播放的 C 语言程序。
概述:Reality Adlib Tracker 简介
上一节我们介绍了 Adlib 的旋律和打击乐编程。本节中,我们来看看一个更接近真实音乐创作的工具:Reality Adlib Tracker。

这是一个由演示场景小组 Reality 在 1995 年制作的音乐追踪器程序。它的优势在于:
- 它可以在低端的 286 机器上流畅运行。
- 与 Pro Tracker 等使用采样音频的追踪器不同,它直接使用 Adlib OPL2 芯片的 FM 合成功能来生成音乐。
- 它附带了一个用汇编语言编写的简单播放器例程及其源代码,我们可以将其集成到自己的程序中。
这个程序拥有 9 个旋律通道(将鼓点也作为旋律乐器处理)、乐器编辑器、模式编辑器和顺序列表。音乐数据以 .rad 格式存储,文件非常小巧(通常在 2KB 到 18KB 之间),因为只需要存储振荡器参数,无需音频采样,非常适合在慢速机器上播放。


第一步:加载音乐文件
要播放音乐,首先需要将 .rad 文件加载到内存中。以下是加载函数的关键步骤:
- 打开文件:以二进制读取模式打开指定的音乐文件。
- 获取文件大小:使用
fseek和ftell函数来确定文件长度。 - 分配内存:使用
allocmem函数(而非malloc)在段边界上分配内存,因为汇编播放器要求音乐数据位于段边界地址。 - 读取数据:将整个文件读入分配好的缓冲区。
- 关闭文件并返回指针:操作完成后关闭文件句柄,将缓冲区指针返回给调用者。
核心代码逻辑如下:
unsigned char *load_music(const char *filename) {
FILE *fp = fopen(filename, "rb");
if (!fp) return NULL;
fseek(fp, 0, SEEK_END);
long size = ftell(fp);
fseek(fp, 0, SEEK_SET);
unsigned int segment;
unsigned char *buffer = (unsigned char *)allocmem((size + 15) >> 4, &segment);
if (!buffer) {
fclose(fp);
return NULL;
}
fread(buffer, 1, size, fp);
fclose(fp);
return buffer;
}
第二步:集成汇编播放器
Reality Tracker 附带的播放器包含三个主要的汇编函数,我们需要在 C 语言中声明并调用它们。
以下是需要在头文件中声明的函数原型:
int init_player(unsigned int music_segment);
void play_music(void);
void end_player(void);
init_player:初始化播放器,传入音乐数据所在的段地址。成功返回 0,失败返回 1。play_music:这是需要以 50Hz 频率调用的核心例程,负责处理音乐数据的播放。end_player:停止播放并重置 Adlib 芯片。
汇编文件 (player.asm) 使用特定的调用约定。为了从 C 中调用,函数名需要添加下划线前缀并被声明为 PUBLIC。函数参数通过栈传递。

在 C 主程序中,初始化流程如下:
unsigned char *music_data = load_music("ALLORUN.RAD");
if (music_data) {
// 获取音乐数据所在的段地址
unsigned int music_segment = FP_SEG(music_data);
if (init_player(music_segment) == 0) {
// 初始化成功,可以开始播放
}
}

第三步:实现精确的 50Hz 定时播放



最简单的播放方式是循环调用 play_music,但这样无法控制速度。为了实现精确的 50Hz 播放,我们需要使用 PC 的可编程中断定时器。

1. 理解 PC 定时器系统
IBM PC 使用一个 8253/8254 PIT 芯片。其中:
- 计数器 0 用于系统定时器,默认以大约 18.206 Hz 的频率触发 INT 8h 硬件中断。
- INT 8h 的中断服务程序会调用一个软件中断 INT 1Ch,这是一个供用户程序使用的“定时器滴答”钩子。


我们的策略是:接管 INT 1Ch 中断向量,在自己的中断处理函数中调用 play_music,并重新编程 PIT 的计数器 0,使其以 50Hz 触发中断。


2. 编写中断处理函数
中断处理函数需要使用 interrupt 关键字声明,并保存和恢复旧的向量。
void interrupt (*old_timer_handler)(...);
void interrupt new_timer_handler(...) {
static unsigned long t = 0;
// 禁用中断以确保原子操作
disable();
// 调用音乐播放例程
play_music();
// 累加时间计数器,用于在50Hz下正确调用旧的18.206Hz处理程序
t += TIMER_FREQ_OLD; // TIMER_FREQ_OLD = 18206
if (t >= TIMER_FREQ_NEW) { // TIMER_FREQ_NEW = 10000 (对应50Hz两次滴答)
t -= TIMER_FREQ_NEW;
if (old_timer_handler) {
old_timer_handler();
}
}
// 启用中断
enable();
}
3. 设置和恢复中断向量
使用 DOS 提供的 getvect 和 setvect 函数来管理中断向量。
void set_interrupt(void) {
disable();
old_timer_handler = getvect(0x1C);
setvect(0x1C, new_timer_handler);
set_timer(50); // 将PIT设置为50Hz
enable();
}

void reset_interrupt(void) {
disable();
setvect(0x1C, old_timer_handler);
reset_timer(); // 将PIT恢复为默认的18.206Hz
enable();
}


4. 编程 PIT 芯片
PIT 的计数器 0 由端口 0x40(数据)和 0x43(控制字)控制。我们需要将其设置为模式 3(方波发生器),并写入新的计数值。
#define TIMER_PORT0 0x40
#define TIMER_MODE_CTRL 0x43
#define PIT_CLOCK_RATE 1193181L // PIT 的基准时钟频率

void set_timer(unsigned int hz) {
unsigned int counter = (unsigned int)(PIT_CLOCK_RATE / hz);
// 设置控制字:选择计数器0,读写低/高字节,模式3,二进制计数
outportb(TIMER_MODE_CTRL, 0x36);
// 写入计数值(先低字节,后高字节)
outportb(TIMER_PORT0, counter & 0xFF);
outportb(TIMER_PORT0, (counter >> 8) & 0xFF);
}


void reset_timer(void) {
// 恢复为默认的18.206 Hz (计数值为0,相当于65536)
outportb(TIMER_MODE_CTRL, 0x36);
outportb(TIMER_PORT0, 0);
outportb(TIMER_PORT0, 0);
}
公式 counter = PIT_CLOCK_RATE / desired_hz 计算了产生指定频率所需的分频计数值。
第四步:主程序流程
将所有部分组合起来,主程序的逻辑如下:
- 加载
.rad音乐文件。 - 调用
init_player初始化汇编播放器。 - 调用
set_interrupt安装新的 50Hz 定时器中断处理程序。此后,音乐将在后台自动播放。 - 主程序可以进入一个循环,等待用户按下退出键(如 ESC)。
- 用户退出时,调用
end_player停止音乐,调用reset_interrupt恢复系统定时器,并释放内存。

一个有趣的扩展是,在设置好中断后,可以调用 system("COMMAND.COM"); 来启动一个 DOS 命令行外壳。这样,你可以在音乐背景下执行其他命令,直到退出命令行,程序才会继续执行清理步骤并结束。



总结






本节课中我们一起学习了如何在 MS-DOS 环境下创建一个 Adlib 背景音乐播放器。我们首先利用 Reality Adlib Tracker 工具生成小巧的 .rad 音乐文件,然后通过 C 语言程序加载这些文件。接着,我们集成了现成的汇编语言播放器例程。最后,为了实现精确的 50Hz 播放,我们深入探讨了 PC 的可编程中断定时器系统,编写了中断服务程序,并编程了 8253 PIT 芯片以改变系统定时器的频率。



通过本教程,你掌握了将硬件定时、中断处理和外部汇编模块结合到 C 程序中的方法,为创建具有背景音乐的 DOS 游戏或演示程序奠定了基础。你可以尝试使用 Reality Adlib Tracker 创作自己的音乐,并利用这个播放器框架将其融入你的项目中。
044:内存与指针
在本节课中,我们将学习IBM PC及其兼容机上的内存管理,以及C语言中指针的概念、用法和原理。这对于理解如何在MS-DOS环境下进行底层编程至关重要。
上一节我们介绍了MS-DOS编程的基础,本节中我们来看看内存寻址和指针。
内存寻址基础
最初的IBM PC和XT使用8088 CPU,该CPU只能寻址最多1MB的内存。这1MB空间被分为两部分:较低的640KB(0x00000 - 0x9FFFF)通常可供程序自由使用,较高的384KB(0xA0000 - 0xFFFFF)则被系统保留,用于视频RAM、BIOS和扩展卡等。

8088/8086 CPU使用一种称为分段内存寻址的机制。这与6502或Z80等使用平坦内存模型(16位地址线,64KB寻址空间)的CPU不同。
分段寻址使用两个16位的值来构成一个20位的物理地址:
- 段地址:指向一个以16字节为边界的起始位置。
- 偏移地址:指向该段内的具体字节。
物理地址的计算公式为:
物理地址 = 段地址 × 16 + 偏移地址
每个段的大小是64KB(2^16字节)。由于每16字节就有一个新的段起始点,因此总共可以寻址 16 × 65536 = 1,048,576 字节,即1MB。
在C语言中,这种分段细节通常被隐藏,但在需要直接操作特定内存区域(如视频缓冲区)时就会变得重要。


指针是什么?

指针本质上是一个内存地址。在MS-DOS的实模式下,这个地址包含段和偏移两部分。指针“指向”内存中某个特定数据的位置,就像门牌号指向一栋房子。

以下是一些语言对指针的处理方式:
- 隐藏指针:BASIC、Java等语言对程序员隐藏了指针。
- 显式使用指针:Pascal、C/C++等语言要求程序员显式地使用和操作指针。
指针的基本用法



让我们通过一个简单的C程序来理解指针。首先,我们看一个不需要指针的函数。



int sum(int a, int b) {
return a + b;
}
void main() {
int a = 1, b = 2;
printf(“%d + %d = %d”, a, b, sum(a, b));
getch();
}
这个程序输出 1 + 2 = 3。这里参数通过值传递。


如果我们想计算一个可变长度数组的总和,直接传递多个参数是不现实的。这时,我们可以传递一个数组。
int sum_array(int a[], int n) {
int s = 0, i;
for(i = 0; i < n; i++) {
s += a[i];
}
return s;
}
void main() {
int arr[] = {1, 2, 3};
printf(“Sum = %d”, sum_array(arr, 3));
getch();
}
程序输出 Sum = 6。
这里我们已经隐式地使用了指针。在C语言中,数组名就是指向数组第一个元素的指针。函数签名 int a[] 等价于 int *a。我们可以用指针形式重写这个函数。


int sum_pointer(int *a, int n) {
int s = 0, i;
for(i = 0; i < n; i++) {
s += a[i]; // 指针同样可以用下标运算符[]
}
return s;
}
它的行为与数组版本完全一致。int *a 声明了一个指向整型的指针。
我们可以用Turbo C提供的宏来查看这个指针在内存中的实际地址(段和偏移)。


#include <dos.h>
void main() {
int arr[] = {1, 2, 3};
printf(“Segment:Offset = %04X:%04X\n”, FP_SEG(arr), FP_OFF(arr));
}
运行程序会输出类似 5522:FFD4 的结果,具体值取决于程序加载的位置。
动态内存分配
当数据大小在编译时未知时(例如从文件读取可变数量的数据),我们需要动态分配内存。这是指针的核心用途之一。
以下是使用动态内存的步骤:
- 使用
malloc函数申请内存。 - 使用指针操作这块内存。
- 使用完毕后,用
free函数释放内存,避免内存泄漏。

#include <alloc.h> // 包含malloc和free的原型
void main() {
int n = 123, i;
int *dynamic_arr; // 声明一个指针
dynamic_arr = (int*) malloc(n * sizeof(int)); // 分配内存
if(dynamic_arr == NULL) {
printf(“Memory allocation failed!\n”);
return;
}
// 初始化动态数组
for(i = 0; i < n; i++) {
dynamic_arr[i] = i;
}
// 使用动态数组(例如计算总和)
int s = sum_pointer(dynamic_arr, n);
printf(“Sum of first %d numbers = %d\n”, n, s);
free(dynamic_arr); // 释放内存
getch();
}
近指针与远指针

在DOS的C编译器(如Turbo C)中,由于分段内存模型,指针有近指针和远指针之分。
以下是它们的关键区别:
- 近指针:只包含16位的偏移地址,默认使用当前数据段。大小为 2字节。
- 远指针:包含16位的段地址和16位的偏移地址。大小为 4字节。
你可以使用 near 和 far 关键字来显式声明指针类型。

int near *near_ptr; // 近指针
int far *far_ptr; // 远指针
printf(“Sizeof near*: %d\n”, sizeof(near_ptr));
printf(“Sizeof far*: %d\n”, sizeof(far_ptr));
输出会显示近指针占2字节,远指针占4字节。


选择近指针可以节省内存和提升访问速度,但前提是你确定数据在当前64KB的数据段内。对于通过 malloc 在堆上分配的内存,其指针类型取决于编译时选择的内存模型(Tiny, Small, Compact, Large, Huge)。Turbo C也提供了 farmalloc 和 farfree 函数来专门管理远堆内存。

指针与复杂数据结构

对于结构体等复杂数据类型,通常也通过指针来传递,以避免复制整个结构体带来的性能开销。

struct Player {
int x, y;
int health;
};
void draw_player(struct Player *p) {
// 通过指针p来访问和修改玩家的数据
printf(“Drawing player at (%d, %d)\n”, p->x, p->y);
}
void main() {
struct Player hero = {100, 200, 50};
draw_player(&hero); // 传递结构体的地址
getch();
}
总结
本节课中我们一起学习了MS-DOS实模式下的内存管理和C语言指针。
- 内存布局:理解了1MB地址空间的分段(640KB常规内存,384KB上位内存)以及8088/8086的分段寻址机制。
- 指针本质:指针是内存地址,在DOS下由段和偏移两部分组成。
- 指针用途:用于处理数组、动态内存分配以及高效传递复杂数据结构。
- 动态内存:学会了使用
malloc和free来管理堆内存,并理解了防止内存泄漏的重要性。 - 近与远指针:了解了DOS C编程中特有的近指针和远指针概念及其适用场景。
- 指针操作:掌握了通过指针访问数据、获取变量地址(
&运算符)等基本操作。

C语言要求程序员手动管理内存,这是其强大和灵活的原因之一,但也增加了责任。理解这些概念是进行MS-DOS系统编程和深入理解计算机工作原理的关键一步。
045:制作噪点3D动画
在本节课中,我们将学习如何修改一个已有的固定算术3D旋转立方体程序,将其转变为一个利用XOR绘图和随机屏幕噪点来创造独特视觉效果的动画。这个效果的特点是,物体仅在运动时可见,一旦停止,就会完全“消失”在噪点背景中。
概述与准备工作
上一节我们实现了一个不使用浮点数的3D旋转立方体。本节中,我们将以此为基础进行大幅修改。

首先,为了支持不同的几何模型,我们将顶点和边的数据从主函数中移出,放入独立的头文件中。我们定义了两个模型:一个立方体和一个由两个金字塔组成的八面体。由于是老式C代码,我们需要一个常量来定义数组的最大尺寸,这里设为64。

#define MAX_VERTS 64
重构几何数据管理
接下来,我们清理并重构绘图函数。核心变化是使用指针来指向不同几何模型的顶点和边数据,而不是硬编码的数组。
以下是管理几何数据的关键变量:
int *vertex_x, *vertex_y, *vertex_z; // 指向顶点坐标数组的指针
int *edges; // 指向边连接数组的指针
int num_verts, num_edges; // 顶点和边的数量
我们通过一个switch语句,根据传入的类型参数(0代表立方体,1代表八面体)来为这些指针赋值。这样就能轻松切换渲染的模型。
从Blender导出模型数据
为了能使用自定义的几何体,我编写了一个Blender Python脚本。这个脚本可以导出模型的顶点和边数据,并生成一个可直接包含在C项目中的头文件。你可以在代码仓库中找到这个脚本。
重要限制:模型不能超过64个顶点或边,并且过于复杂的模型在此效果下表现并不好,因此建议使用简单的几何体。


实现XOR绘图与动画控制
为了实现“消失”效果,我们不使用常规的画线函数,而是采用XOR(异或)模式来绘制线条。

我们实现了一个set_pixel_xor函数:
void set_pixel_xor(int x, int y, char color) {
VGA_BUFFER[y * SCREEN_WIDTH + x] ^= color;
}
这个函数将指定颜色与屏幕上已有的像素颜色进行XOR运算。当同一线条被绘制两次时,它会擦除自己。

在动画循环中,我们还将物体与屏幕的距离改为随时间正弦变化,从而产生平滑的缩放效果。
此外,我们增加了键盘交互功能:
- 按键 1 和 2 用于在立方体和八面体模型之间切换。
- 按键 Q 和 W 用于停止和启动动画。这是体验效果的关键:动画停止时,物体将不可见。
创造噪点画布与最终效果
最后一步是创造那个神奇的噪点背景。在程序初始化时,我们调用randomize()函数初始化随机数生成器。
然后,我们用随机黑白像素填充整个屏幕缓冲区:
for (int i = 0; i < SCREEN_HEIGHT * SCREEN_WIDTH; i++) {
VGA_BUFFER[i] = (rand() % 2) * 15; // 生成0(黑)或15(白)
}
现在运行程序,你会看到屏幕上充满噪点,而3D物体在其中旋转。其视觉原理在于:物体的运动(结合XOR绘图造成的闪烁)与静态的随机噪点形成对比,你的大脑会从中识别并构建出物体的形状。一旦动画停止,这种动态对比消失,物体也就完全融入背景噪点,仿佛从未存在过。
你可以尝试按 Q 键停止旋转,观察物体如何“消失”;再按 W 键启动,看它如何重新“出现”。

总结
本节课中我们一起学习了如何将一个标准的3D线框渲染器,改造为一个利用XOR绘图、随机噪点和视觉暂留原理的趣味动画。我们重构了代码以支持多模型,实现了交互控制,并最终得到了一个物体“动则显,静则隐”的奇妙视觉效果。这个项目展示了,简单的图形技巧结合对人类视觉的理解,可以创造出非常有趣的演示程序。
046:使用L系统绘制圣诞树 🎄

在本节课中,我们将学习如何在MS-DOS环境下,使用PowerBASIC编程语言和L系统(Lindenmayer系统)来生成并绘制一个动态的、类似圣诞树的复杂分形图案。我们将从零开始构建代码,涵盖L系统的基本概念、状态栈的实现以及图形绘制。
概述
L系统是一种用于模拟生物生长和分形结构的字符串重写系统。它从一个初始字符串(公理)开始,通过一系列规则迭代替换,最终生成一个描述图形绘制指令的字符串。本节课,我们将实现一个特定的L系统来生成圣诞树形状。

全局变量与数据结构
首先,我们需要定义一些全局变量来存储程序的状态。虽然全局变量在大型项目中不推荐使用,但在此演示中,它们能使代码更易于阅读和理解。
以下是所需的核心变量:
length: 一个双精度浮点数,表示基础线段的绘制长度。co: 一个整数,表示绘制颜色(在VGA 16色模式下,范围为0-15)。scale: 一个双精度浮点数,用于整体缩放绘制的树。angle: 一个双精度浮点数,表示绘图光标当前的旋转角度(弧度制)。posx,posy: 两个双精度浮点数,表示绘图光标当前的X和Y坐标。stackx(),stacky(),stacka(): 三个数组,分别用作X坐标、Y坐标和角度的栈,用于保存和恢复绘图状态。sp: 一个整数,作为栈指针,指示栈顶位置。


在PowerBASIC中,这些变量可以如下声明:
DIM length AS DOUBLE
DIM co AS INTEGER
DIM scale AS DOUBLE
DIM angle AS DOUBLE
DIM posx AS DOUBLE
DIM posy AS DOUBLE
DIM stackx(16) AS DOUBLE
DIM stacky(16) AS DOUBLE
DIM stacka(16) AS DOUBLE
DIM sp AS INTEGER
核心功能函数
有了数据结构,接下来我们实现绘图所需的核心功能。这些函数将处理状态保存、移动和转向。
状态栈操作:PUSH 和 POP
L系统中的方括号 [ 和 ] 分别对应状态的保存(入栈)和恢复(出栈)。状态包括当前位置和角度。
PUSH函数 将当前状态存入栈中:
SUB PushState
stackx(sp) = posx
stacky(sp) = posy
stacka(sp) = angle
sp = sp + 1
END SUB

POP函数 从栈中恢复之前保存的状态:
SUB PopState
sp = sp - 1
posx = stackx(sp)
posy = stacky(sp)
angle = stacka(sp)
END SUB

绘图与转向
Forward函数 根据当前角度和指定长度,从当前位置画一条线到新位置。
SUB Forward (len AS DOUBLE, col AS INTEGER)
DIM newx AS DOUBLE
DIM newy AS DOUBLE
newx = posx + COS(angle) * len * scale
newy = posy + SIN(angle) * len * scale
LINE (posx, posy)-(newx, newy), col
posx = newx
posy = newy
END SUB
Turn函数 将当前角度旋转指定的度数。
SUB Turn (deg AS DOUBLE)
angle = angle + deg * 3.14159265 / 180
END SUB


L系统规则实现
现在,我们来实现生成圣诞树的具体L系统规则。我们使用的规则是:
公理: F
规则: F -> F[+F]F[-F]F


在代码中,我们通过一个递归函数 F 来模拟这个替换过程。参数 n 表示剩余的递归深度。
SUB F (n AS INTEGER)
IF n = 0 THEN
Forward length * scale, co
ELSE
F n - 1
PushState
Turn 36
F n - 1
PopState
F n - 1
PushState
Turn -36
F n - 1
PopState
F n - 1
END IF
END SUB
当 n 为0时,我们直接绘制一条线段。否则,我们根据规则 F[+F]F[-F]F 递归调用自身,并在需要时使用 PushState/PopState 和 Turn 来管理分支状态。


主程序与动画循环
所有部件准备就绪后,我们将它们组合到主程序中,创建一个动态的屏幕保护程序效果。


初始化函数 Xmas 设置初始状态并开始绘制树。
SUB Xmas (maxiter AS INTEGER)
angle = 0
sp = 0
Turn 90
F maxiter
END SUB

主程序流程:
- 进入VGA图形模式(640x480,16色)。
- 初始化随机数生成器。
- 进入一个主循环,直到用户按下任意键。
- 在循环中,每隔一定帧数清屏。
- 每次循环,随机生成树的位置、大小、颜色和缩放比例。
- 调用
Xmas函数(例如设置迭代深度为5)绘制一棵树。 - 循环继续,绘制无数棵随机变化的树。
- 退出循环后,切换回文本模式。
关键的主循环部分逻辑如下:
SCREEN 12
RANDOMIZE TIMER
count = 0
WHILE INKEY$ = ""
count = count + 1
IF count > 10 THEN CLS : count = 0
scale = 0.5 + RND * 2.0
posx = -160 / scale + RND * 640
posy = -120 / scale + RND * 480
co = INT(RND * 14) + 1
length = 2
Xmas 5
WEND
SCREEN 0
总结

本节课中,我们一起学习了如何在MS-DOS环境下使用PowerBASIC实现一个L系统。我们从定义全局状态变量和栈数据结构开始,逐步实现了状态保存与恢复(PUSH/POP)、线段绘制(Forward)、方向旋转(Turn)等核心功能。接着,我们通过递归函数编码了特定的L系统规则来生成圣诞树分形。最后,我们将所有部分整合到一个主循环中,创建了一个能够持续绘制随机大小、位置和颜色的动态圣诞树林的图形程序。通过这个项目,你不仅接触了分形图形编程,也实践了状态管理和基础图形绘制的概念。
047:让我们编写MS-DOS 0x2E - VGA可重定义字符集
在本节课中,我们将学习如何在MS-DOS的文本模式下,利用VGA显卡的可重定义字符集功能,结合调色板修改和双缓冲技术,创建一个动态的“等离子”效果和滚动字幕。我们将使用Turbo C 2.0进行编程,并深入探讨一些底层VGA编程技巧。
概述与目标
上一节我们介绍了图形模式下的效果。本节中,我们来看看如何在文本模式下实现类似的效果。VGA显卡在文本模式下拥有一些有趣的特性,例如可重定义字符集和调色板修改功能。我们将利用这些特性,在80x25的文本屏幕上创建动态视觉效果。
核心步骤与函数
以下是实现效果的主要步骤,我们将在后续小节中逐一详细讲解。
- 设置调色板:将16色调色板重新映射到VGA的调色板寄存器。
- 禁用光标:通过BIOS中断调用隐藏屏幕光标。
- 设置字符宽度:将默认的9列字符模式改为8列,以获得更紧凑的显示。
- 定义自定义字符:创建一组从小到大的方块字符,用于表示不同强度的“等离子”点。
- 绘制等离子效果:使用正弦函数生成动态颜色值,并映射到自定义字符上。
- 绘制滚动字幕:在屏幕上实现一个从右向左滚动的文本。
- 实现双缓冲:利用文本模式的多页面特性消除闪烁。
- 应用运行时补丁:修复Turbo C 2.0中一个长达35年的BUG,以正确加载自定义字符。

设置调色板


首先,我们需要设置一个16色的调色板。VGA显卡的16色文本模式使用独立的调色板寄存器,其映射关系并非连续。



我们定义了一个palette数组,它是对火焰调色板进行16色采样的结果。然后,我们需要一个映射数组palette_reg,将逻辑颜色索引对应到实际的VGA调色板寄存器号。




unsigned char palette[48] = { ... }; // 16色火焰调色板数据
unsigned char palette_reg[16] = {0,1,2,3,4,5,0x14,7,0x38,0x39,0x3A,0x3B,0x3C,0x3D,0x3E,0x3F};


设置调色板的函数set_palette会遍历这16种颜色。与256色模式直接写入颜色值不同,这里需要先写入要设置的寄存器索引,再写入RGB颜色值。
void set_palette() {
int i;
for(i=0; i<16; i++) {
// 选择要设置的调色板寄存器
outportb(PALETTE_INDEX, palette_reg[i]);
// 写入该寄存器的RGB值
outportb(PALETTE_DATA, palette[i*3]);
outportb(PALETTE_DATA, palette[i*3+1]);
outportb(PALETTE_DATA, palette[i*3+2]);
}
}



禁用光标

为了获得干净的显示效果,我们需要隐藏文本光标。这可以通过BIOS中断0x10的功能0x01(设置光标类型)来实现。通过将光标的结束扫描线设置为小于起始扫描线,即可使其消失。

以下是禁用光标的函数实现:



void disable_cursor() {
union REGS regs;
regs.h.ah = 0x01; // 功能号:设置光标类型
regs.h.ch = 0x3F; // 设置起始扫描线为63(二进制00111111)
regs.h.cl = 0x00; // 设置结束扫描线为0
int86(0x10, ®s, ®s); // 调用视频中断
}
代码中使用了union REGS结构来设置CPU寄存器。AH寄存器存放功能号,CH和CL寄存器分别控制光标的起始和结束扫描线。将其设置为0x3F和0x00即可禁用光标。
设置字符宽度
VGA文本模式默认使用9像素宽的字符(80列 * 9像素 = 720像素水平分辨率)。为了获得更紧凑的方块效果,我们将其切换为8像素宽(640像素)。这需要直接对VGA硬件寄存器进行编程。




以下是设置字符宽度的关键步骤:
- 读取并修改“杂项输出寄存器”的时钟选择位,以选择640像素模式。
- 通过序列器(Sequencer)的时钟模式寄存器,确认字符宽度为8点每字符。
- 配置属性控制器(Attribute Controller)以确保像素对齐。
void set_char_width_8() {
unsigned char x;
// 1. 设置杂项输出寄存器,选择640像素模式
x = inportb(MISC_OUTPUT_READ) & 0xF3;
x |= 0x04; // 设置位2和3,选择9列模式(实际为了后续步骤)
outportb(MISC_OUTPUT_WRITE, x);
// 2. 设置序列器时钟模式,启用8点每字符
outportb(SEQ_INDEX, RESET_REG); // 选择复位寄存器
outportb(SEQ_DATA, 0x00); // 解除复位
outportb(SEQ_INDEX, CLOCKING_MODE_REG);
outportb(SEQ_DATA, 0x01); // 清除位0,启用8点每字符
// 3. 配置属性控制器像素平移
x = inportb(INPUT_STATUS_1); // 读取状态以切换属性控制器到索引模式
outportb(ATTRIBUTE_CONTROLLER_INDEX, 0x20 | PIXEL_PANNING_REG); // 选择像素平移寄存器
outportb(ATTRIBUTE_CONTROLLER_DATA, 0x08); // 设置像素平移为0
outportb(ATTRIBUTE_CONTROLLER_INDEX, x); // 恢复之前的状态
}
这个过程涉及多个VGA寄存器,具体位定义可以参考VGA编程手册。执行后,屏幕水平分辨率将从720像素变为640像素。

定义自定义字符

这是本教程的核心。VGA允许我们重新定义字符发生器中的字符形状。我们计划用128-132这五个字符代码来表示五个不同大小的实心方块。


首先,我们需要定义字符的点阵数据。每个字符由16行、每行8位(1字节)组成,位为1表示点亮像素。


unsigned char char_table[] = {
// 字符 128: 空方块
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// 字符 129: 2x2方块
0x00, 0x00, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// 字符 130: 4x4方块 (0x3C = 00111100)
0x00, 0x00, 0x3C, 0x3C, 0x3C, 0x3C, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// 字符 131: 6x6方块 (0x7E = 01111110)
0x00, 0x7E, 0x7E, 0x7E, 0x7E, 0x7E, 0x7E, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// 字符 132: 8x8方块 (0xFF = 11111111)
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};

接下来,我们需要调用BIOS中断0x10的功能0x11(用户字符加载)来上传这些数据。这个功能需要设置ES:BP寄存器对指向我们的字符数据。然而,Turbo C 2.0标准的int86函数不支持设置ES和BP寄存器。为此,我们使用了一个特殊的intr函数和REGS联合体。

但这里存在一个Turbo C 2.0运行时的BUG:intr函数生成的代码错误地计算了BP寄存器的偏移量。社区成员Charlie发现并提供了一个运行时补丁函数patch_intr,它会在内存中搜索特定的指令序列并将其修复。
// 补丁函数,修复intr的BUG
int patch_intr() {
unsigned char *ptr = (unsigned char*)intr; // 获取intr函数地址
unsigned char patch[] = {0xC6, 0x46, 0xF4, 0xE2}; // 要查找的错误指令
while(ptr < (unsigned char*)intr + 1024) { // 在函数代码段中搜索
if(memcmp(ptr, patch, 4) == 0) {
ptr[3] = 0xDE; // 将错误的偏移0xE2改为正确的0xDE
printf("Patched intr at %p\n", ptr);
return (ptr - (unsigned char*)intr);
}
ptr++;
}
return -1; // 未找到,可能已修复
}
应用补丁后,我们可以正确调用中断来定义字符:
void define_chars(int num, int first, int table, int height, int len, void *buffer) {
union REGS regs;
// 设置寄存器参数
regs.h.ah = 0x11; // 功能:用户字符加载
regs.h.al = 0x00; // 子功能:加载用户表
regs.h.bh = height; // 每个字符的字节数(行数)
regs.h.bl = table; // 字符生成器表
regs.x.cx = num; // 要定义的字符数量
regs.x.dx = first; // 第一个字符的ASCII码
// 设置ES:BP指向字符数据缓冲区
regs.x.es = FP_SEG(buffer);
regs.x.bp = FP_OFF(buffer);
// 调用中断(此时intr已被补丁修复)
intr(0x10, ®s);
}


绘制等离子效果
等离子效果的原理是组合多个在空间上变化的正弦波,生成平滑变化的颜色值。我们将颜色值(0-255)映射到之前定义的5个方块字符(128-132)上,同时利用前景色和背景色来丰富色彩层次。
以下是绘制等离子效果的核心循环:
void draw_plasma(int x, int y, int width, int height) {
int i, j;
unsigned char c, c1, c2, c3, c4;
int v1 = sintab[(t) % 256];
int v2 = sintab[(32 + t*2) % 256];
for(j=y; j<y+height; j++) {
int ysin = sintab[(j*6 + t) % 256];
for(i=x; i<x+width; i++) {
// 计算四个不同的正弦波分量
c1 = (i * 5);
c2 = (i * v1 + j * v2) / (height+2);
c3 = sintab[((i + ysin) % 256)];
c4 = sintab[((i - ysin) % 256)];
// 合并分量并得到最终颜色索引 (0-255)
c = (unsigned char)((c1 + c2 + c3 + c4) >> 2);
// 设置“像素”:字符索引 + 颜色属性
set_pixel(i, j, c);
}
}
}
set_pixel函数负责将计算出的颜色值c写入视频内存。它做了两件事:
- 将颜色值
c除以51,映射到0-4的范围,加上128得到对应的方块字符代码。 - 将颜色值
c的高4位作为前景色,次高3位作为背景色,组合成一个文本属性字节。
void set_pixel(int x, int y, unsigned char c) {
int offset = (y * VGA_WIDTH + x) * 2 + back_page * VGA_PAGE_SIZE;
// 写入字符代码
vga_buffer[offset] = 128 + (c / 51); // 映射到字符128-132
// 写入颜色属性:前景色 + 背景色
vga_buffer[offset + 1] = (c >> 4) + ((c >> 5) << 4);
}

绘制滚动字幕

滚动字幕的原理是不断改变文本在屏幕上的起始X坐标。我们使用一个静态变量time_step来控制滚动速度,当文本完全滚出屏幕左侧时,将其重置到屏幕右侧的一个随机行。
void draw_scroller() {
static int time_step = 0;
int x, index;
int offset = time_step >> 1; // 控制滚动速度
static int scroller_y = 12;
int len = strlen(scroll_text);
time_step--;
if(offset < -len) { // 如果文本完全滚出屏幕
time_step = VGA_WIDTH * 2; // 重置到右侧
scroller_y = rand() % 25; // 随机新行
offset = time_step >> 1;
}
// 计算文本在屏幕上的可见范围
int start_x = max(0, offset);
int end_x = min(VGA_WIDTH, offset + len);
for(x = start_x; x < end_x; x++) {
int pos = (scroller_y * VGA_WIDTH + x) * 2 + back_page * VGA_PAGE_SIZE;
// 写入字符
vga_buffer[pos] = scroll_text[x - offset];
// 写入属性(白色)
vga_buffer[pos + 1] = 0x0F;
}
}
实现双缓冲与主循环
文本模式支持多个显示页面。我们可以使用一个页面(如前页,page 0)进行显示,同时在另一个页面(后页,page 1)进行绘制,完成后再切换页面,从而实现无闪烁的双缓冲。
主循环的流程如下:
- 初始化(设置模式、禁用光标、定义字符、设置调色板)。
- 进入循环,直到有按键按下。
- 在每一帧中,先在后台页面绘制等离子效果。
- 然后在同一后台页面绘制滚动字幕(覆盖部分等离子)。
- 切换显示页面到后台页面。
- 交换前后台页面索引,为下一帧做准备。
void main() {
// ... 初始化变量
patch_intr(); // 应用运行时补丁
init_sintab(); // 初始化正弦表
set_mode(TEXT_MODE); // 设置文本模式
disable_cursor(); // 禁用光标
set_char_width_8(); // 设置8列字符
define_chars(5, 128, 0, 16, 5*16, char_table); // 定义自定义字符
set_palette(); // 设置调色板
back_page = 1; // 从页面1开始绘制
while(!kbhit()) { // 主循环,直到按键
draw_plasma(0, 0, VGA_WIDTH, VGA_HEIGHT); // 绘制等离子
draw_scroller(); // 绘制滚动字幕
set_display_page(back_page); // 切换显示页面
back_page ^= 1; // 交换前后台页面索引
t++; // 更新时间
}
// ... 恢复文本模式并退出
}


总结
本节课中我们一起学习了如何在MS-DOS的VGA文本模式下创造动态视觉效果。我们涵盖了以下核心内容:
- VGA调色板重映射:学习了16色文本模式下调色板寄存器的特殊映射方式。
- 底层VGA编程:通过直接操作硬件寄存器来禁用光标和修改字符宽度。
- 可重定义字符集:利用BIOS中断上传自定义字符形状,并修复了Turbo C 2.0的一个历史BUG。
- 算法生成效果:使用正弦函数生成“等离子”效果,并将连续的颜色值离散化到有限的字符和颜色上。
- 文本模式双缓冲:利用多页面显示实现无闪烁动画。
- 滚动字幕实现:掌握了在固定缓冲区中实现物体运动的基本方法。
通过结合这些技术,我们能够在有限的文本模式下创造出令人印象深刻的动画效果,这展示了早期计算机编程中硬件直接控制的强大能力和创意空间。
048:绘制曼德博分形 🌀
在本节课中,我们将学习如何在MS-DOS环境下,使用Turbo C和VGA图形模式,编写一个曼德博分形生成器。我们将涵盖从设置图形模式、实现分形算法、创建动态调色板到实现颜色循环动画的完整过程。
概述
曼德博集是一个著名的数学分形。其核心算法是迭代一个复数公式,根据迭代次数为屏幕上的每个像素着色。虽然背后的数学理论涉及复数,但实现算法本身相对简单直接。
我们将使用VGA的320x200分辨率,并实现一个优化的逃逸时间算法来绘制它。
设置图形模式与主程序框架
上一节我们介绍了课程目标,本节中我们来看看程序的基本框架。首先,我们需要设置VGA图形模式并初始化一个特殊的调色板。
以下是主程序的基本结构:
void main() {
// 设置VGA 13h图形模式 (320x200, 256色)
set_vga_mode();
// 初始化自定义调色板
init_palette();
// 绘制曼德博分形
draw_mandelbrot();
// 主循环:等待按键,并进行颜色循环动画
while(!kbhit()) {
// 延迟以控制帧率
delay();
// 执行颜色循环
color_cycle();
}
// 恢复文本模式
set_text_mode();
}


实现曼德博分形绘制算法
上一节我们搭建了程序框架,本节中我们来看看核心的分形绘制函数 draw_mandelbrot。
算法的核心思想是遍历屏幕上的每一个像素,将其坐标映射到复平面上的一个特定区域(通常是x轴[-2.5, 1.0], y轴[-1.12, 1.12]),然后进行迭代计算。
以下是该函数的关键步骤:
- 变量定义:我们需要像素坐标、映射后的复平面坐标、迭代计数器等。
- 坐标映射:将像素坐标
(px, py)通过缩放和平移,转换为复平面上的点(x0, y0)。 - 迭代循环:对每个
(x0, y0),使用曼德博公式进行迭代,直到点“逃逸”出半径为2的圆或达到最大迭代次数。 - 着色:根据逃逸所需的迭代次数为像素选择颜色。
以下是核心迭代公式的优化版本代码:
// 初始化
x = 0.0;
y = 0.0;
x2 = 0.0;
y2 = 0.0;
iteration = 0;


// 迭代循环
while ((x2 + y2) <= 4.0 && iteration < max_iteration) {
y = (x + x) * y + y0; // 优化:2*x*y 写为 (x+x)*y
x = x2 - y2 + x0;
x2 = x * x;
y2 = y * y;
iteration++;
}
// 着色:颜色基于迭代次数
color = iteration % 256;
putpixel(px, py, color);
通过调整映射区域的中心点 (center_x, center_y) 和缩放因子 zoom,我们可以查看曼德博集的不同部分。


创建动态调色板


上一节我们实现了分形绘制,但颜色可能不够美观。本节中我们来看看如何创建一个平滑过渡、色彩丰富的自定义调色板。

我们的目标是生成一个在RGB颜色空间中循环变化的渐变调色板。思路是为红、绿、蓝三个通道分别设置一个初始值和变化方向(递增或递减),当值达到边界(0或63)时反转方向。
以下是初始化调色板的步骤:

- 为红(R)、绿(G)、蓝(B)通道分别定义初始值和变化步长
(dr, dg, db)。例如,让它们以不同速度变化。 - 循环256种颜色索引。
- 对每种颜色,根据当前R、G、B值设置VGA调色板寄存器。
- 更新R、G、B值,并在其达到边界时反转对应的变化方向。
这样会产生不断循环变化的颜色梯度,为分形提供丰富的色彩。
实现颜色循环动画
上一节我们创建了漂亮的静态调色板,本节中我们通过颜色循环让它动起来,为分形添加动态效果。



颜色循环的原理是周期性地偏移整个调色板中颜色的索引。在每一帧中,我们将调色板数据整体向前或向后移动一个位置。

实现步骤如下:
- 在程序主循环中,维护一个随时间递增的
time_index。 - 在
color_cycle函数中,遍历所有256种颜色。 - 对于每种颜色索引
i,从原始调色板中读取位置为(i + time_index) % 256的颜色值。 - 将这个颜色值写入VGA调色板寄存器的第
i个位置。
警告:此效果会产生闪烁、快速变化的图像。对光敏感的用户请注意。


通过改变 time_index 的增减方向,可以控制颜色流动的方向。你还可以增加逻辑,让用户通过按键切换或关闭颜色循环。



总结

本节课中我们一起学习了在MS-DOS环境下创建曼德博分形生成器的完整过程。
我们首先建立了使用VGA图形模式的基本程序框架。然后,我们实现了曼德博集的核心迭代算法,理解了如何将像素坐标映射到复平面并进行逃逸时间计算。接着,为了提升视觉效果,我们创建了一个RGB通道独立变化的动态调色板。最后,我们通过周期性地偏移调色板数据,实现了令人眼花缭乱的颜色循环动画。
这个项目融合了数学、图形编程和底层硬件操作,虽然算法代码简短,但最终效果非常迷人。你可以尝试修改坐标和缩放参数来探索曼德博集的不同区域,或者调整调色板生成逻辑来创造属于自己的独特分形艺术。
049:VGA 文本模式下的 3D 图形 🎮


在本节课中,我们将学习如何利用 EGA 和 VGA 显卡在文本模式下的高级功能,实现一个在文本窗口中绘制 3D 图形的效果。我们将绕过速度较慢的 BIOS 中断,直接操作显卡硬件,并利用其额外的字符集实现双缓冲,从而流畅地显示一个旋转的立方体。






背景与目标 🎯
在之前的课程中,我们通过调用中断来重定义字符,实现了文本模式下的等离子效果。然而,为了在文本中嵌入动态图形,我们需要一种更快、更灵活的方法。
本节的目标是:在屏幕的一个小窗口内,绘制任意的 3D 图形,同时不影响周围正常文本的显示。我们将利用 VGA 显卡的第二个 256 字符集作为图形缓冲区,并通过直接操作显卡寄存器来读写字符点阵数据。
VGA 文本模式内存布局与属性字节 💾
在 VGA 文本模式(起始地址 0xB800)下,内存是字符和属性字节交错排列的:
字符0, 属性0, 字符1, 属性1, 字符2, 属性2...


一个属性字节的结构如下:
位 0-3: 前景色 (16色)
位 4-6: 背景色 (8色)
位 7: 闪烁控制位
IBM PC 的设计中,前景色的最高位(第3位)被用来在两个不同的 256 字符集之间切换。这相当于为字符索引增加了一个“第9位”,使我们总共可以访问 512 个不同的字符。


访问字符点阵数据(字体内存) 🔧
标准的 256 个字符(ASCII 和 IBM 扩展字符)存储在第一个字符集中。VGA/EGA 卡还提供了额外的字符集。在我们的程序中,我们定义了以下内存位置:



#define CHAR_BUFFER 0xA0000 // 图形内存段,用于访问字符点阵
#define CHAR_SET_0 0x0000 // 第一个字符集(工作集)
#define CHAR_SET_1 0x4000 // 第二个字符集(前缓冲区)
#define CHAR_SET_2 0x8000 // 第三个字符集(后缓冲区)




为了在文本模式下访问和修改 CHAR_BUFFER 中的字符点阵数据(位于位平面 2),我们需要重新配置显卡的序列控制器和图形控制器。

以下是配置步骤:



- 禁用中断,防止干扰我们的设置。
- 配置序列控制器,以启用对位平面 2 的写入访问。
- 配置图形控制器,设置内存映射和读/写模式。
- 完成操作后,恢复原始配置,确保系统能正常使用文本模式。


核心的寄存器设置代码如下:



// 1. 序列控制器设置
unsigned short seq_values[] = {
0x0100, // 索引0:复位寄存器,启动异步复位
0x0402, // 索引2:映射屏蔽寄存器,仅启用位平面2写入 (值 0x04)
0x0704, // 索引4:内存模式寄存器,禁用奇偶模式,启用扩展内存
0x0300 // 索引0:复位寄存器,解除复位
};
// 使用 `outpw` 依次写入上述值到序列控制器端口 (0x3C4)
// 2. 图形控制器设置
unsigned short gfx_values[] = {
0x0204, // 索引4:读映射选择寄存器,选择位平面2
0x0005, // 索引5:模式寄存器,禁用奇偶寻址等
0x0006 // 索引6:杂项寄存器,启用对 A0000 段的访问
};
// 使用 `outpw` 依次写入上述值到图形控制器端口 (0x3CE)
操作完成后,必须执行反向操作,将寄存器恢复为文本模式的标准值,特别是重新启用奇偶寻址(用于交错字符和属性)。


构建屏幕与帧缓冲区 🖼️
上一节我们介绍了如何“解锁”显卡以访问字符点阵数据。本节中,我们来看看如何在屏幕上创建一个用于显示图形的“窗口”。
我们首先在屏幕上绘制一个边框,然后在边框内填充第二个字符集(CHAR_SET_1)中的字符,从 0 到 239。这创建了一个 24x10 的“瓦片”网格,每个瓦片对应一个可被重新定义的字符。
以下是绘制边框和填充字符的关键代码逻辑:
// 绘制边框(使用第一个字符集中的制表符)
void draw_border(int x, int y, int w, int h) {
// 绘制左上角、水平线、右上角
put_char(x, y, 201, attribute); // 左上角
for (int i = 0; i < w; i++) put_char(x+1+i, y, 205, attribute); // 上水平线
put_char(x+w+1, y, 187, attribute); // 右上角
// ... 类似地绘制垂直边和底边
}


// 填充图形窗口内的字符
void fill_frame_buffer(int x, int y, int w, int h) {
unsigned char char_index = 0;
for (int row = 0; row < h; row++) {
for (int col = 0; col < w; col++) {
// 写入字符索引(来自第二个字符集)
put_char(x+1+col, y+1+row, char_index++, 0);
// 写入属性,并设置“字符集选择位”(第3位)以指向第二个字符集
unsigned char attr = (1 << 3); // 启用高亮度前景色,同时选择字符集B
put_attr(x+1+col, y+1+row, attr);
}
}
}
通过设置属性字节的第3位,我们告诉 VGA 卡为该单元格使用第二个字符集(CHAR_SET_1)。此时,虽然字符集内容尚未改变,但我们已经为图形绘制准备好了画布。
实现双缓冲与像素绘制 ✏️
现在我们已经有了图形窗口,接下来需要实现动态绘制。为了消除闪烁,我们将使用双缓冲技术:在一个不可见的“后缓冲区”(CHAR_SET_2)字符集中绘制图形,然后快速切换到显示它。
以下是关键步骤:
- 初始化双缓冲:我们使用两个字符集作为前后缓冲区(例如
CHAR_SET_1和CHAR_SET_2)。 - 绘制到后缓冲区:所有的
set_pixel操作都修改后缓冲区字符集的点阵数据。 - 交换缓冲区:通过 VGA 的字符映射选择寄存器,交换显示所用的字符集。
- 清空新的后缓冲区,为下一帧绘制做准备。
交换字符集的函数使用 BIOS 中断 0x10 的功能 0x11(子功能 0x03):
void select_character_maps(unsigned char map0, unsigned char map1) {
union REGS regs;
regs.h.ah = 0x11; // 字符生成器功能
regs.h.al = 0x03; // 子功能:设置显示定义表
regs.h.bl = (map1 << 2) | map0; // 组合两个映射选择值
int86(0x10, ®s, ®s);
}
像素绘制是核心,其逻辑是计算像素点位于哪个字符单元格(Tile)内,以及在该字符点阵中的具体行和列:
void set_pixel(int x, int y, int on) {
// 边界检查
if (x < 0 || x >= FRAME_WIDTH*8 || y < 0 || y >= FRAME_HEIGHT*16) return;
// 1. 计算字符索引 (Tile Index)
int char_x = x >> 3; // x / 8
int char_y = y >> 4; // y / 16
int char_index = char_y * FRAME_WIDTH + char_x;
// 2. 计算在字符点阵中的行和列
int row = 15 - (y & 0x0F); // 字符点阵行 (0在底部)
int col = 7 - (x & 0x07); // 字符点阵列 (0在最右边)
// 3. 计算目标内存地址(后缓冲区)
unsigned char far *char_ptr = CHAR_BUFFER + back_page + char_index * 32 + row;
// 4. 设置或清除特定位
if (on) {
*char_ptr |= (1 << col);
} else {
*char_ptr &= ~(1 << col);
}
}
有了 set_pixel 函数,我们就可以调用现有的 3D 立方体绘制逻辑(使用定点数运算),将它的输出从设置屏幕像素改为调用我们的 set_pixel 函数。
整合与最终效果 🚀


我们将所有部分整合到主循环中:



- 初始化屏幕,绘制边框并填充字符集索引。
- 进入主循环:
a. 打开对字符点阵内存的访问 (open_font_access)。
b. 清空后缓冲区。
c. 运行立方体旋转计算,并为每条边调用set_pixel。
d. 关闭字体访问 (close_font_access)。
e. 交换前后字符集 (swap_buffers)。
f. 短暂延迟,控制帧率。 - 由于我们只修改了位平面 2(字符形状),而位平面 0 和 1(字符代码和属性)在初始化后就固定不变,因此我们可以轻松地在屏幕其他位置复制多个立方体,而性能开销极低。这是图形模式无法实现的优势。

最终,我们得到了一个在文本模式窗口中平滑旋转的 3D 立方体,同时屏幕其他部分仍可正常显示文本。这展示了 VGA 硬件作为原始“基于瓦片的图形引擎”的强大能力。
总结 📚
本节课中我们一起学习了 VGA/EGA 显卡在文本模式下的高级图形技巧:
- 原理:利用了属性字节中未使用的位来在两个 256 字符集之间切换,并将额外的字符集用作图形帧缓冲区。
- 关键技术:通过直接编程序列控制器和图形控制器,在文本模式下访问并修改字符点阵数据(位平面 2)。
- 实现:构建了图形窗口,实现了双缓冲机制和像素级绘制函数。
- 优势:此方法允许在文本旁嵌入动态图形,且能利用硬件层叠多个图形实例,效率高于纯图形模式。

这种技术虽然历史上应用不多(例如用于创建文本模式下的鼠标指针),但它充分展示了老式硬件设计的灵活性与编程的乐趣。希望本教程能激发你探索更多 MS-DOS 和 x86 汇编编程的奥秘。

浙公网安备 33010602011771号