暴力而优雅:DOS 程序员如何用两个字节控制屏幕
编辑我第一次接触计算机,是在小学的微机课上。
当时用的是 Windows XP,从那时起,鼠标、窗口、图标这些 GUI 交互方式几乎成了默认的“电脑使用方式”。
相信很多人也是类似的经历。
前一段时间,我的时间线上经常出现关于 GUI 和 TUI 的争论。
有人不理解:都这个时代了,为什么还需要终端?
甚至像 Claude Code、Codex 这样的工具,也是终端形态,有人觉得终端早就过时了。
而另一批人则认为,终端工具高效、简洁,在服务器运维等场景中依然不可替代。
今天我不打算参与这场争论,只想简单聊聊 DOS 时代的终端——看看几十年前的程序员,是如何在那个环境下控制屏幕、编写程序的。
没有火热的 AI、Agent 概念,只有对内存地址的精雕细琢。
如果你今天写一个终端界面程序,
大概率会使用成熟的 TUI 框架。
它们帮你处理:
光标定位
颜色管理
屏幕刷新
终端兼容
你几乎不用考虑硬件。
但在 DOS 时代
屏幕不是通过 API 显示内容的,
而是通过直接写入显卡内存实现的。
没有抽象层,没有驱动保护。
想显示字符?——那就往内存地址中写字节。
这是一种近乎“裸奔”的编程方式。
也是一种独特的工程美学:
暴力,但高效。

在 MS-DOS 时代,电脑不像今天的 Windows 或 macOS。
开机后,没有窗口,没有后台任务,只有命令行。
一次只能运行一个程序。
那是一种更接近硬件本质的世界。
一台典型 PC 可能只有:
4.77 MHz 的 Intel 8086 CPU
640KB 内存
80×25 字符文本显示
屏幕本质上是一块字符矩阵。
每个位置只存两字节:
一个 ASCII 字符
一个颜色属性
程序员可以直接修改这些数据。
屏幕就随之改变。
没有 GUI,没有 API,
只有内存地址。
1985 年,一个程序员的日常
你坐在一台 IBM PC 前。机箱发出低沉的风扇噪音,CRT 显示器散发着微弱的臭氧味道。

你的任务:
在屏幕中央闪烁 “Hello World”,红底黄字。
你没有:
没有 ncurses
没有图形库
没有
printf()能控制颜色没有操作系统保护机制
你只有:
一本《IBM PC 技术参考手册》
一个 Turbo C 编译器
对硬件地址的直接访问权
你翻开手册,找到关键信息:
VGA 文本模式显存地址:0xB8000
每个字符占用 2 字节:ASCII 码 + 属性字节
于是你开始写代码。不是调用函数,而是直接往内存地址里塞数据。
显存的秘密:0xB8000 为什么如此神圣?
内存映射 I/O (MMIO) 的魔法
现代计算机里,内存是内存,显卡是显卡,它们通过复杂的驱动程序和 API 交互。
但在 DOS 时代,硬件设计者用了一个非常直接和简单的方案:把显存直接映射到 CPU 的地址空间。
这意味着:
CPU 地址空间:
0x00000 ━━━━━━━━━━━━━ 常规内存 (RAM)
0xA0000 ━━━━━━━━━━━━━ 图形模式显存
0xB8000 ━━━━━━━━━━━━━ 文本模式显存 ← 这里!
0xC0000 ━━━━━━━━━━━━━ ROM BIOS
当你写入 0xB8000 这个地址时:
CPU 把数据放到总线上
显卡检测到“这是我的地址”
显卡把数据写入自己的显存
屏幕立即显示出来
没有系统调用,没有上下文切换,没有缓冲区拷贝。
这就是为什么 DOS 程序能如此快速地刷新屏幕——它们在和硬件直接对话。
显存的结构:每个字符的双字节秘密
80x25 文本模式意味着屏幕上有 2000 个字符位置(80 列 × 25 行)。
每个字符占用 2 个字节:
字节 0: ASCII 码 (0x00-0xFF)
字节 1: 属性字节 (颜色、闪烁等)所以整个显存是:
0xB8000: [字符0][属性0][字符1][属性1]...[字符1999][属性1999]
↑ ↑
屏幕左上角 屏幕右下角内存布局示意:
屏幕坐标 (0,0) → 内存地址 0xB8000
屏幕坐标 (1,0) → 内存地址 0xB8002
屏幕坐标 (0,1) → 内存地址 0xB80A0 (80*2 = 160 = 0xA0)
屏幕坐标 (x,y) → 内存地址 0xB8000 + (y*80 + x)*2
属性字节的二进制艺术
属性字节的 8 位编码了所有视觉效果:
Bit: 7 6 5 4 3 2 1 0
[闪烁][背景色][高亮][前景色]
前景色 (Bit 0-2):
000 = 黑色 100 = 红色
001 = 蓝色 101 = 品红
010 = 绿色 110 = 黄色
011 = 青色 111 = 白色
背景色 (Bit 4-6): 同上
Bit 3: 高亮位 (前景色亮度加倍)
Bit 7: 闪烁位 (字符闪烁)
例子:
0x4E = 0100 1110
↓ ↓
红底 黄字(高亮)
0x1F = 0001 1111
↓ ↓
蓝底 白字(高亮)
0x70 = 0111 0000
↓ ↓
白底 黑字这就是为什么那个时代的程序员能精确控制每个字符的外观——他们在手工编码二进制位。
让你的第一个字符出现在屏幕上
这就像是刺绣一样,让我们在这张“布”的 [0,0] 位置开始戳下第一针。
DOS 下的 CPP 代码
#include <dos.h>
#include <conio.h>
int main() {
char far *video = (char far *)0xB8000000L;
video[0] = 'A';
video[1] = 0x4E;
getch();
return 0;
}
</conio.h></dos.h>编译运行:

屏幕的左上角出现了一个字符 ‘A’,背景是红色,文字是黄色
char far *video ——这是 DOS 时代特有的“远指针”,它能让你的程序突破 64KB 的段限制,直接伸手去触碰那块神圣的显存区域。
0xB8000000L ——这串数字就是文本模式显存的起始地址。
然后是最暴力的部分:
video[0] = 'A' ——你把字符 ‘A’ 的 ASCII 码直接塞进内存。video[1] = 0x4E ——紧接着,你告诉显卡:“用红底黄字显示它。”
0x4E = 0100 1110
↓ ↓
红底 黄字(高亮)没有函数调用,没有权限检查。
你刚刚用 5 行代码,就完成了对硬件的直接控制。
进阶:显示一个单词
现在让我们显示多个字符。试试在屏幕第一行显示 “DOS”。
回顾上一个例子:我们通过写入 video[0] 和 video[1] 显示了单个字符。要显示多个字符,只需按照同样的规律连续写入即可。
以 “DOS” 为例,三个字符需要分别占据显存中的 0、2、4 号位置——每个字符占用 2 字节,所以位置索引以 2 递增。代码可以这样写:
#include <dos.h>
#include <conio.h>
int main() {
char far *video = (char far *)0xB8000000L;
// 在屏幕第一行显示 "DOS"
video[0] = 'D';
video[1] = 0x4E; // 红底黄字
video[2] = 'O';
video[3] = 0x4E;
video[4] = 'S';
video[5] = 0x4E;
getch();
return 0;
}
</conio.h></dos.h>运行结果如图:

Hello World
现在让我们完成最初的挑战:在屏幕正中央显示一个闪烁的、红底黄字、高亮的 “Hello World”。
这个看似简单的任务,实际上需要精确计算内存地址和手工编码属性字节。
步骤 1:计算屏幕中央的内存地址
对于 80 列 × 25 行的屏幕,中心位置在:
行坐标:25 ÷ 2 = 12(第 13 行,从 0 开始计数)
列坐标:80 ÷ 2 = 40(第 41 列)
但 “Hello World” 有 11 个字符。为了让它居中,需要向左偏移 5 个字符(11 ÷ 2),所以起始列应该是:
40 - 5 = 35(第 36 列)内存地址计算:
位置 = (行 × 80 + 列) × 2
= (12 × 80 + 35) × 2
= (960 + 35) × 2
= 995 × 2
= 1990为什么要乘以 2?
因为每个字符占用 2 个字节:
第 1 个字节:ASCII 码(显示什么字符)
第 2 个字节:属性字节(颜色、闪烁等效果)
步骤 2:手工编码属性字节
现在来构造属性字节 0xCE,它需要同时满足:闪烁、红底、黄字、高亮。
二进制构造过程:
初始状态: 0 0 0 0 0 0 0 0
1. 闪烁 (Bit 7 = 1):
1 0 0 0 0 0 0 0
2. 红色背景 (Bit 6~4 = 100):
1 1 0 0 0 0 0 0
3. 高亮 (Bit 3 = 1):
1 1 0 0 1 0 0 0
4. 黄色前景 (Bit 2~0 = 110):
1 1 0 0 1 1 1 0转换为十六进制:
1100 1110 (二进制)
↓ ↓
C E (十六进制)
= 0xCE步骤 3:编写完整代码
#include <dos.h>
#include <conio.h>
int main() {
// 获取显存指针
char far *video = (char far *)0xB8000000L;
// 计算屏幕中央位置
int pos = (12 * 80 + 35) * 2;
char *msg = "Hello World";
// 逐字符写入显存
for (int i = 0; msg[i]; i++) {
video[pos + i*2] = msg[i]; // ASCII 码
video[pos + i*2 + 1] = 0xCE; // 属性字节
}
getch(); // 等待按键
return 0;
}
</conio.h></dos.h>代码解析:
pos + i*2:每个字符占 2 字节,所以每次偏移 2video[pos + i*2]:写入字符的 ASCII 码video[pos + i*2 + 1]:写入属性字节0xCE
运行效果
编译运行后,你会看到屏幕中央出现了这样的效果:
注意观察:
文字在屏幕正中央
红色背景,亮黄色文字
以约 1 秒的频率闪烁(硬件控制)
这就是 DOS 时代程序员的日常 ——用二进制位在屏幕这张“布”上刺绣每一个像素。
没有 API 调用,没有框架封装,只有你和硬件之间的直接对话。
从显存到终端
今天,我们用 React 写一个按钮,可能要引入几十 MB 的依赖。
而在 1985 年,一个程序员只需要知道一个地址——0xB8000——就能在屏幕上画出任何东西。
这种方式快、直接、强大——但也脆弱。
它只能在 DOS 上运行。换一台机器、换一个操作系统,0xB8000 可能就不再是显存地址了。
当计算机从单用户单任务走向多用户多任务,当终端从物理设备变成软件模拟,程序员们需要一种新的方式来控制屏幕——一种不依赖特定硬件地址的方式。
于是,ANSI 转义序列出现了。
它不再直接写显存,而是通过发送特殊字符序列来控制终端。
这套标准从 1976 年延续至今,几乎所有现代终端都支持它。
但在聊 ANSI 之前,不妨记住今天的核心:
屏幕上的每一个字符,最终都只是内存里的两个字节。 那些花哨的 UI 框架,最底层做的事情,和 40 年前没有本质区别。
理解了 0xB8000,再去看现代终端、GUI、甚至 AI Agent 的界面——你会多一层视角。
不是"哪个更好",而是"它们各自在解决什么问题"。
下一篇,我们来看看这个延续至今的终端通用语言。
- 1
- 0
-
分享