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,结果屏幕疯狂闪烁,排查了半天才发现问题。
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。这个偏移量算不对,彩条就会偏到屏幕外面去。
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菜单。当时踩过一个坑:字符显示和视频数据需要做叠加,也就是字符区域要覆盖视频。这个叠加逻辑如果处理不好,字符边缘会有毛刺。后来我加了一个「字符使能」信号,只在字符区域才覆盖,其他地方透传视频数据,问题就解决了。
15.5 实战建议——从简单到复杂
如果你刚开始学VGA,我建议按这个顺序来:
- 先让屏幕亮起来——只输出纯色,验证行场同步是否正确
- 再显示彩条——验证颜色和坐标映射
- 然后显示棋盘格——验证每个像素的定位是否准确
- 最后做字符显示——需要准备字库和坐标计算
每一步都验证通过后,再考虑更复杂的功能,比如图片显示、视频叠加等。说实话,VGA接口虽然老,但它的时序逻辑非常典型,学会了VGA,其他显示接口(比如HDMI、LVDS)的时序理解起来就轻松多了。
好了,这一章的内容就到这里。下一章我们会讲DDR3内存接口,那又是一个新的挑战。记得把VGA的代码多跑几遍,有问题随时调试。