15、VGA显示接口:VGA时序标准、行场同步信号生成、彩条显示、字符显示

VGA接口,说实话,在FPGA学习里是个绕不开的经典外设。我当年刚接触FPGA时,第一个觉得「哇,有点意思」的项目就是让屏幕显示出彩条。你想想看,一堆逻辑门和触发器,最后能变成屏幕上跳动的颜色,那种成就感还是很实在的。

这一章,我们就来把VGA显示接口彻底讲透。从时序标准到行场同步,再到彩条和字符显示,一步步来。

15.1 VGA时序标准——别被那些数字吓到

VGA的时序,说白了就是告诉显示器:「什么时候该换行,什么时候该换帧」。标准VGA分辨率是640x480 @60Hz,这个参数最常用,也最容易在FPGA上实现。

我个人的习惯是,先把时序参数表背下来,然后写代码时对照着看。这里给出最关键的几个参数:

参数 像素时钟 行同步 行后肩 有效行 行前肩 行总长
数值 25.175 MHz 96 48 640 16 800
参数 场同步 场后肩 有效场 场前肩 场总长
数值 2 33 480 10 525

注意,像素时钟25.175 MHz,这个频率有点尴尬。很多开发板直接用25 MHz或者27 MHz也能凑合,但严格来说会有微小偏差。我在项目中遇到过用27 MHz时钟导致画面边缘有轻微抖动的情况,后来还是老老实实用了PLL精确生成25.175 MHz。

注意:行同步和场同步的极性都是负极性。也就是说,同步信号有效时是低电平。这个细节容易搞反,我曾经因为这个让屏幕黑了一整天。

15.2 行场同步信号生成——计数器是核心

生成行场同步,本质上就是两个计数器在跑。一个行计数器,一个场计数器。行计数器从0数到799,场计数器从0数到524。

代码其实很简单,但有几个坑要注意。我直接给出核心代码:

// 行计数器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        h_cnt <= 11'd0;
    else if (h_cnt == 11'd799)
        h_cnt <= 11'd0;
    else
        h_cnt <= h_cnt + 1'b1;
end

// 场计数器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        v_cnt <= 11'd0;
    else if (h_cnt == 11'd799 && v_cnt == 11'd524)
        v_cnt <= 11'd0;
    else if (h_cnt == 11'd799)
        v_cnt <= v_cnt + 1'b1;
end

// 行同步信号
assign hsync = (h_cnt < 96) ? 1'b0 : 1'b1;

// 场同步信号
assign vsync = (v_cnt < 2) ? 1'b0 : 1'b1;

嗯,这里要注意:场计数器的加1条件,必须是行计数器到799的时候。这个逻辑如果写错了,画面会直接撕裂或者滚动。我刚开始学的时候,把场计数器写成了每个时钟都加1,结果屏幕疯狂闪烁,排查了半天才发现问题。

小技巧:调试VGA时,可以用逻辑分析仪抓一下hsync和vsync的波形。正常行同步应该是每800个时钟周期出现一次低脉冲,场同步是每525行出现一次。波形对了,画面基本就对了。

15.3 彩条显示——从黑到白,再到彩虹

彩条显示,说白了就是根据当前像素坐标,决定输出什么颜色。VGA用3位颜色(R、G、B各1位)就能显示8种颜色。当然,如果你板子上有DAC或者电阻网络,可以做到更多颜色。

我习惯把彩条分成8个竖条,每个竖条80个像素宽。这样640像素刚好分成8份。颜色顺序是:黑、蓝、绿、青、红、紫、黄、白。

// 彩条颜色生成
always @(*) begin
    if (h_cnt >= 144 && h_cnt < 784 && v_cnt >= 35 && v_cnt < 515) begin
        // 有效显示区域
        case (h_cnt[9:7])  // 取高3位,分成8段
            3'b000: rgb <= 3'b000;  // 黑
            3'b001: rgb <= 3'b001;  // 蓝
            3'b010: rgb <= 3'b010;  // 绿
            3'b011: rgb <= 3'b011;  // 青
            3'b100: rgb <= 3'b100;  // 红
            3'b101: rgb <= 3'b101;  // 紫
            3'b110: rgb <= 3'b110;  // 黄
            3'b111: rgb <= 3'b111;  // 白
        endcase
    end else begin
        rgb <= 3'b000;  // 消隐区输出黑色
    end
end

这里有个细节:有效显示区域的起始坐标是(144, 35)。为什么?因为行同步+后肩=96+48=144,场同步+后肩=2+33=35。这个偏移量算不对,彩条就会偏到屏幕外面去。

核心要点:VGA显示的本质就是「坐标映射」。你给每个像素坐标分配一个颜色值,显示器就忠实地呈现出来。彩条、棋盘格、渐变,本质上都是坐标的数学变换。

15.4 字符显示——把字变成点阵

字符显示比彩条复杂一些。你需要一个字符点阵库,把每个字符的像素信息存成ROM。常用的字库是8x16点阵,也就是每个字符用16个字节表示,每个字节的8位对应一行的8个像素。

我个人的做法是:先用Python或者MATLAB把ASCII字符转成点阵数据,然后生成一个coe文件,加载到FPGA的Block RAM里。显示时,根据字符的ASCII码和行列位置,从ROM里读出对应的点阵数据。

举个例子,字符'A'的点阵数据可能是这样的:

// 字符'A'的8x16点阵
0x00, 0x00, 0x10, 0x28, 0x44, 0x82, 0xFE, 0x82,
0x82, 0x82, 0x82, 0x82, 0x00, 0x00, 0x00, 0x00

显示逻辑的核心是:先确定当前像素属于哪个字符格子,然后从ROM里读出该字符对应行的点阵数据,最后判断当前像素在字符内的列位置,决定是否点亮。

// 字符显示核心逻辑
wire [3:0] char_row = v_cnt[3:0];      // 字符内行号(0-15)
wire [2:0] char_col = h_cnt[2:0];      // 字符内列号(0-7)
wire [6:0] char_addr = ascii_code;     // 字符ASCII码
wire [7:0] font_data;                  // 从ROM读出的点阵数据

assign pixel_on = font_data[7 - char_col];  // 判断当前像素是否点亮

我在项目中做过一个字符叠加的功能,用来在视频画面上显示OSD菜单。当时踩过一个坑:字符显示和视频数据需要做叠加,也就是字符区域要覆盖视频。这个叠加逻辑如果处理不好,字符边缘会有毛刺。后来我加了一个「字符使能」信号,只在字符区域才覆盖,其他地方透传视频数据,问题就解决了。

避坑指南:字符显示时,ROM的读取延迟要注意。如果你用组合逻辑直接读ROM,可能会产生毛刺。我建议用寄存器打一拍,虽然会多一个时钟的延迟,但信号干净很多。另外,字符的起始坐标也要算好偏移量,不然字符会跑到屏幕外面去。

15.5 实战建议——从简单到复杂

如果你刚开始学VGA,我建议按这个顺序来:

  1. 先让屏幕亮起来——只输出纯色,验证行场同步是否正确
  2. 再显示彩条——验证颜色和坐标映射
  3. 然后显示棋盘格——验证每个像素的定位是否准确
  4. 最后做字符显示——需要准备字库和坐标计算

每一步都验证通过后,再考虑更复杂的功能,比如图片显示、视频叠加等。说实话,VGA接口虽然老,但它的时序逻辑非常典型,学会了VGA,其他显示接口(比如HDMI、LVDS)的时序理解起来就轻松多了。

好了,这一章的内容就到这里。下一章我们会讲DDR3内存接口,那又是一个新的挑战。记得把VGA的代码多跑几遍,有问题随时调试。