Mkdir700's Note

Mkdir700's Note

暴力而优雅:DOS 程序员如何用两个字节控制屏幕

9
2026-02-12

我第一次接触计算机,是在小学的微机课上。

当时用的是 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 这个地址时:

  1. CPU 把数据放到总线上

  2. 显卡检测到“这是我的地址”

  3. 显卡把数据写入自己的显存

  4. 屏幕立即显示出来

没有系统调用,没有上下文切换,没有缓冲区拷贝。

这就是为什么 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>

代码解析:

  1. pos + i*2:每个字符占 2 字节,所以每次偏移 2

  2. video[pos + i*2]:写入字符的 ASCII 码

  3. video[pos + i*2 + 1]:写入属性字节 0xCE

运行效果

编译运行后,你会看到屏幕中央出现了这样的效果:

注意观察:

  • 文字在屏幕正中央

  • 红色背景,亮黄色文字

  • 以约 1 秒的频率闪烁(硬件控制)

这就是 DOS 时代程序员的日常 ——用二进制位在屏幕这张“布”上刺绣每一个像素。

没有 API 调用,没有框架封装,只有你和硬件之间的直接对话。


从显存到终端

今天,我们用 React 写一个按钮,可能要引入几十 MB 的依赖。

而在 1985 年,一个程序员只需要知道一个地址——0xB8000——就能在屏幕上画出任何东西。

这种方式快、直接、强大——但也脆弱。

它只能在 DOS 上运行。换一台机器、换一个操作系统,0xB8000 可能就不再是显存地址了。

当计算机从单用户单任务走向多用户多任务,当终端从物理设备变成软件模拟,程序员们需要一种新的方式来控制屏幕——一种不依赖特定硬件地址的方式。

于是,ANSI 转义序列出现了。

它不再直接写显存,而是通过发送特殊字符序列来控制终端。
这套标准从 1976 年延续至今,几乎所有现代终端都支持它。

但在聊 ANSI 之前,不妨记住今天的核心:

屏幕上的每一个字符,最终都只是内存里的两个字节。 那些花哨的 UI 框架,最底层做的事情,和 40 年前没有本质区别。

理解了 0xB8000,再去看现代终端、GUI、甚至 AI Agent 的界面——你会多一层视角。

不是"哪个更好",而是"它们各自在解决什么问题"。

下一篇,我们来看看这个延续至今的终端通用语言。