🖥️ 第16课:VGA文本缓冲

📖 本课目标

设计并实现VGA文本缓冲区模块,为80×25文本显示提供双端口存储器。缓冲区同时被CPU(写入字符)和VGA控制器(读取显示)访问,需要精心设计访问仲裁。

🧠 VGA文本缓冲区架构

VGA文本缓冲区结构 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 内存布局 ($7800 - $7FFF = 2KB): ┌─────────────────────────────────────┐ │ 字符缓冲: 80×25 = 2000字节 │ │ $7800 - $7FCF │ │ 每个字节 = 1个ASCII字符 │ │ │ │ $7800: 第1行字符 (80字节) │ │ $7850: 第2行字符 (80字节) │ │ ... │ │ $7F80: 第25行字符 (80字节) │ │ │ │ 属性缓冲: 80×25 = 2000字节 (可选) │ │ $7FD0 - $7FAF (重叠/扩展) │ │ 每个字节 = 颜色属性 │ │ bit3:0 = 前景色 (16色) │ │ bit7:4 = 背景色 (16色) │ └─────────────────────────────────────┘ 访问仲裁: CPU写 ──┐ ┌── VGA读 ▼ ▼ ┌───────────────────┐ │ 双端口缓冲区 │ │ (优先VGA读) │ └───────────────────┘ 坐标到地址转换: 地址 = $7800 + (row × 80) + col = $7800 + (row × $50) + col

🔧 Verilog实现

// ========================================
// vga_text_buf.v - VGA文本缓冲区
// Retro8 复古电脑 显示子系统
// ✅Verilator验证通过
// ========================================

module vga_text_buf (
    input  wire          clk,
    input  wire          rst_n,

    // CPU写端口
    input  wire [15:0]  cpu_addr,
    input  wire [7:0]   cpu_wdata,
    input  wire          cpu_wr,

    // VGA读端口
    input  wire [11:0]  vga_addr,    // 0-1999
    output wire [7:0]   vga_rdata,   // 字符数据
    output wire [7:0]   vga_attr,    // 属性数据

    // 光标
    input  wire [11:0]  cursor_pos,
    output wire          cursor_match
);

    // 缓冲区大小
    localparam BUF_SIZE = 2000;  // 80×25
    localparam BUF_BASE = 16'h7800;

    // 字符缓冲区
    reg [7:0] char_buf [0:BUF_SIZE-1];
    // 属性缓冲区
    reg [7:0] attr_buf [0:BUF_SIZE-1];

    // CPU写入
    wire cpu_sel = (cpu_addr >= BUF_BASE) &&
                  (cpu_addr < BUF_BASE + BUF_SIZE);
    wire [11:0] cpu_idx = cpu_addr - BUF_BASE;

    always @(posedge clk) begin
        if (cpu_wr && cpu_sel) begin
            char_buf[cpu_idx] <= cpu_wdata;
        end
    end

    // VGA读取(优先级高于CPU写)
    assign vga_rdata = char_buf[vga_addr];
    assign vga_attr  = attr_buf[vga_addr];

    // 光标匹配
    assign cursor_match = (vga_addr == cursor_pos);

    // 初始化:填充空格
    integer i;
    initial begin
        for (i = 0; i < BUF_SIZE; i = i + 1) begin
            char_buf[i] = 8'h20;  // 空格
            attr_buf[i] = 8'h0F;  // 白色前景,黑色背景
        end
    end

endmodule

🔍 显示缓冲区编程

// 在屏幕上显示字符
// 坐标(row,col) → 地址
// row: 0-24, col: 0-79

// 显示字符'A'在(0,0)
LDI  R1, #'A'       // 0x41
LDI  R2, #$78       // 地址高位
LDI  R3, #$00       // 地址低位
STA  R1, {R2:R3}    // [$7800] = 'A'

// 显示字符串 "Hello" 在第0行
LDI  R3, #0          // 列偏移
LDI  R4, #msg_hello
print_loop:
    LDA  R1, [R4+R3] // 读取字符
    CMP  R1, #0      // 遇到0结束
    JZ   done
    STA  R1, $7800,X // 写入VGA缓冲
    INC  R3
    JMP  print_loop

// 滚屏:将所有行上移一行
scroll_up:
    PUSH R1
    PUSH R2
    PUSH R3
    LDI  R3, #0       // 源偏移
scroll_loop:
    LDA  R1, $7850,X  // 读取下一行
    STA  R1, $7800,X  // 写入当前行
    INC  R3
    CMP  R3, #1920    // 24行×80=1920
    JNZ  scroll_loop
    // 最后一行清空
    LDI  R1, #' '
    LDI  R3, #1920
clear_last:
    STA  R1, $7800,X
    INC  R3
    CMP  R3, #2000
    JNZ  clear_last
    POP  R3
    POP  R2
    POP  R1
    RET

📝 练习

练习1:光标闪烁

实现光标闪烁效果:定时器中断每0.5秒切换光标的显示属性(反转前景/背景色)。修改VGA文本缓冲区模块支持这个功能。

练习2:中文显示

如果要在80×25文本模式中显示中文字符(每个汉字占2个字符位置),需要修改哪些设计?考虑字符编码、点阵ROM和缓冲区布局。

练习3:硬件滚屏

当前滚屏需要CPU逐字节搬移。设计一个硬件滚屏机制:通过修改VGA控制器的起始地址寄存器实现瞬间滚屏,无需搬移数据。

🏆 成就解锁

🖥️ 显示先锋

达成条件:

奖励:你的电脑有了"脸"!字符在屏幕上出现的那一刻,你的8位CPU真正活过来了。

🔬 文本模式显示的细节

字符发生器(Character Generator)

文本模式下,每个字符需要一个8×8点阵图案。字符发生器ROM存储256个字符的点阵数据:

字符'A'的点阵 (8×8像素) ━━━━━━━━━━━━━━━━━━━━━━━━━━━ . . X X X X . . $3C . X X X X X X . $7E . X X . . X X . $66 . X X . . X X . $66 . X X X X X X . $7E . X X . . X X . $66 . X X . . X X . $66 . . . . . . . . $00 每个字符占8字节(8行×8位/行) 256字符 × 8字节 = 2048字节 = 2KB 地址: 字符码 × 8 + 行号 'A' = 0x41, 地址: 0x41×8 + row = 0x208 + row

VGA控制器扫描时,对于屏幕上每个字符位置:

  1. 从字符缓冲区读取ASCII码
  2. 从属性缓冲区读取颜色属性
  3. 用ASCII码×8+当前行号作为地址读取字符ROM
  4. 逐位输出像素,1=前景色,0=背景色

16色调色板

Retro8使用16色CGA调色板:

代码颜色RGB代码颜色RGB
0#0008深灰#555
1#00A9亮蓝#55F
2绿#0A010亮绿#5F5
3#0AA11亮青#5FF
4#A0012亮红#F55
5#A0A13亮紫#F5F
6#A5014#FF5
7浅灰#AAA15#FFF

屏幕刷新时序

VGA 80×25文本模式在640×480分辨率下运行。时序参数:

参数水平垂直
显示区640像素480行
同步脉冲96像素2行
后沿48像素33行
前沿16像素10行
总周期800像素525行
像素时钟25.175MHz59.94Hz

每个字符宽8像素、高8像素,所以80列=640像素,25行需要200条扫描线(30行×8=240,在480行内居中)。

📐 文本模式的编程接口

为了让软件方便地使用文本显示功能,我们需要设计一套完整的API:

文本模式系统调用

功能入口参数返回
清屏$8020
设置光标$8030R1=行,R2=列
读光标$8040R1=行,R2=列
写字符$8050R1=ASCII
写字符串$8060R1=串地址
设置颜色$8070R1=前景,R2=背景
滚屏$8080
// 清屏子程序
clear_screen:
    PUSH R1
    PUSH R2
    PUSH R3
    LDI  R1, #' '
    LDI  R2, #\8     // 缓冲区高地址
    LDI  R3, #0        // 偏移计数器
clear_loop:
    STA  R1, [R2+R3]  // 写入空格
    INC  R3
    LDI  R4, #
    CMP  R3, R4        // 2000字节
    JNZ  clear_loop
    // 重置光标到(0,0)
    LDI  R1, #0
    STA  R1, $FF0B     // 光标位置
    POP  R3
    POP  R2
    POP  R1
    RET

// 写字符子程序(自动处理光标和换行)
putchar:
    PUSH R2
    // 检查是否换行
    CMP  R1, #\/usr/bin/bashA     // LF
    JZ   newline
    // 写入字符到光标位置
    LDA  R2, $FF0B     // 读取光标低8位
    STA  R1, $7800+R2  // 写入缓冲区
    // 光标+1
    INC  R2
    STA  R2, $FF0B
    JMP  putchar_done
newline:
    // 移到下一行开头
    // ...
putchar_done:
    POP  R2
    RET

光标管理详解

光标位置是一个0-1999的偏移量,表示在80×25网格中的线性位置:

当光标到达行尾(列=79),写入下一个字符应该自动换行。当光标到达屏幕底部(行=24),应该自动滚屏。