手上有一块 DE10-Lite,GPIO 排针空着一多半,除了点灯和数码管总觉得没把这块板子用起来。后来我把目标定在 VGA 上——不是因为它难,而是因为它能让你第一次完整地走一遍"数字时序信号如何变成人眼看到的画面"这条链路。整套东西的核心其实只有两个:一组精确到像素的时序计数器,和一组把 FPGA 的 3.3V 数字电平"翻译"成显示器能识别的模拟电压的权电阻网络。前者决定画面稳不稳,后者决定颜色对不对。
这篇内容我会把基于权电阻网络的 VGA 彩条显示从头拆一遍:VGA 接口上到底哪几根线在干活、75Ω 端接为什么决定了所有电阻的取值、640×480@60Hz 的时序参数一个都不能凭感觉填、1 位/通道和 4 位/通道两种权电阻网络的阻值怎么算、完整的 Verilog 代码怎么组织、DE0-CV 和 DE10-Lite 两块板子的板级差异在哪、上板不亮时按什么顺序排查。适合刚接触 FPGA 视频输出的人,也适合已经跑通过点灯和串口、想往上再走一步的人。
1. 先把 VGA 的物理账算清楚:五根信号线和 75Ω 端接的世界
很多人第一次接 VGA 就卡在"我到底要接几根线"上。其实标准 15 针的 DE15 接口里,真正参与成像的只有 5 根信号线加若干地线,剩下的全是 DDC/EDID 识别线、+5V 和几个保留针脚。搞不清这一点,你就会在排针上浪费一堆 IO 去接那些根本不影响显示的东西。
1.1 DE15 接口上真正干活的针脚
把这 15 个针脚按功能分一下,会发现只有前五个是信号,其余都是供电、识别和地。下面这张表是我自己接线时贴在工作台上的速查表:
| 针脚 | 名称 | 作用 | 本项目是否使用 |
|---|---|---|---|
| 1 | RED | 红色模拟量,0~0.7V | 使用 |
| 2 | GREEN | 绿色模拟量,0~0.7V | 使用 |
| 3 | BLUE | 蓝色模拟量,0~0.7V | 使用 |
| 4 | ID2 | 显示器识别位 | 不用 |
| 5 / 6 / 7 / 8 / 10 | GND | 模拟地(必须与 FPGA 共地) | 使用 |
| 9 | +5V | 部分老显示器用来检测线缆插入 | 一般不用 |
| 11 | ID0 | 显示器识别位 | 不用 |
| 12 | ID1 / SDA | DDC 数据 | 不用 |
| 13 | HSYNC | 行同步,TTL 电平 | 使用 |
| 14 | VSYNC | 场同步,TTL 电平 | 使用 |
| 15 | SCL | DDC 时钟 | 不用 |
关键的一条:5、6、7、8、10 这几个地脚必须全部接到 FPGA 的地。这不是"多接一根更保险",而是因为 RGB 三路是模拟信号,显示器内部对每一路都有一个独立到地的 75Ω 端接电阻,这些电阻的参考点就是这几个地脚。如果你的地线只接了其中一根,或者干脆没接,会看到画面出现亮度漂移、横纹干扰,甚至完全不显示。我在早期搭飞线的时候吃过这个亏:信号线接得整整齐齐,地线偷懒只接一根,结果屏幕上是满屏的斜纹,排查了两个小时才想到地。
1.2 75Ω 端接决定了你所有电阻的取值
VGA 的 RGB 三根线是模拟输入,标准规定显示器端提供 75Ω 的对地阻抗,输入电压满量程约为 0.7V。这句话是整个权电阻网络设计的全部前提,所有阻值都是从它推出来的。
FPGA 的 IO 输出的是 3.3V 的方波,如果直接怼到 VGA 的 RED 针脚上会怎么样?3.3V 通过显示器的 75Ω 端接电阻直接灌进显示器的输入保护电路,轻则亮度饱和、颜色永远是最亮的白,重则把显示器前端电路打坏,同时 FPGA 的 IO 也在持续输出约 44mA 的电流,远超大部分 FPGA 引脚的推荐值。
所以必须在 FPGA 和 VGA 针脚之间串电阻分压。设串联电阻为 R,则显示器端得到的电压是:
Vout = 3.3V × 75 / (R + 75)想得到标准满量程 0.7V,解出 R:
R = 75 × (3.3 / 0.7 - 1) = 75 × 3.714 ≈ 279Ω选常用的 E24 系列,270Ω 或者 300Ω 都合适。270Ω 算出来的实际电压是 3.3 × 75 / 345 = 0.717V,比 0.7V 高 2.4%,对显示没有任何影响,因为显示器对"满量程"本身也有容差,稍微超一点仍然是满色输出。反过来说,如果你手上只有 330Ω,算出来是 0.605V,屏幕仍然能认出白色,只是整体的最高亮度会往下掉一档。
这里有一个新手常踩的坑:看到"串 270Ω"就照抄,但那个 270Ω 是针对单一位驱动、满量程电压算出来的。如果你后面要搭多位权电阻网络,每一路的阻值都不一样,不能全部用 270Ω。这一点在第 3 节会展开。
1.3 3.3V 的电平去驱动同步信号,行不行
行同步和场同步是数字 TTL 信号,标准规定的逻辑高电平是 5V。现实情况是,绝大多数液晶显示器和一部分较新的 CRT 对 3.3V 的高电平判定阈值在 2.0V 左右,3.3V 完全够用。我用 DE10-Lite 的 GPIO 直驱过六台显示器,没有一台因为同步电平不够而不亮。
但有两个细节值得注意。第一,同步信号线可以各串一个 33Ω 到 100Ω 的电阻,作用不是分压而是抑制过冲和振铃,尤其是在飞线较长的时候;第二,如果你手上正好有一台很老、同步阈值偏高的显示器,最简单的办法是加一片 74HCT244 或 74LVC245 做缓冲,把 3.3V 转成 5V 摆幅,顺便把驱动能力提上去。我个人的建议是先用直连试,不行再上缓冲,不要一上来就把电路搞复杂。
2. 640×480@60Hz 的时序参数,一个数都不能凭感觉填
时序这块最容易被忽视,因为大部分人觉得"我数到 640 就归零不就行了"。如果你真这么写,看到的画面大概率是左右撕裂、上下滚动、或者干脆黑屏。VGA 的每一行、每一帧都不是"有效像素 + 直接回车",而是由同步脉冲、后沿、有效像素、前沿四段拼成的,四段的长度都有明确的行业约定。
2.1 四个区间和它们的顺序
一行的完整结构按顺序是:同步脉冲(Sync)→ 后沿(Back Porch)→ 有效像素(Active)→ 前沿(Front Porch)。一帧的结构完全一样,只不过单位从像素变成了行。
每个区间的功能不一样。同步脉冲是给显示器用来锁相和定位的"发令枪",它的宽度决定了显示器的 PLL 能不能锁定;后沿是从同步结束到有效像素开始之间的"让显示器缓一缓"的时间,老式 CRT 靠它控制电子束回扫,液晶显示器靠它做自动对中;前沿是有效像素结束到下一个同步脉冲之间的缓冲。三段加起来构成了所谓的"消隐区",在这个区域里 RGB 三路应该输出 0V,也就是黑色。
这里有个反直觉的点:消隐区不是"可以不输出",而是"必须输出黑色"。因为 VGA 是模拟信号,如果你在消隐期间让 RGB 引脚随便漂着,显示器在自动调整和识别黑电平时可能会误判,画面会出现边缘发灰或者整屏对比度异常。所以正确的做法是用一个 de(data enable)信号,只有在有效区才输出真实颜色,其余时间强制输出全 0。
2.2 行场时序的完整数字表
下面这组数字是 640×480@60Hz 的标准值,我直接列成表,接线的时候照着填就行:
| 参数 | 行方向(单位:像素) | 场方向(单位:行) |
|---|---|---|
| 同步脉冲 | 96 | 2 |
| 后沿 | 48 | 33 |
| 有效区 | 640 | 480 |
| 前沿 | 16 | 10 |
| 总长 | 800 | 525 |
| 同步极性 | 负极性 | 负极性 |
有了这组数,帧率是可以反推验证的:像素时钟 25.2MHz,一行 800 个时钟,一帧 525 行,所以帧率是 25,200,000 / (800 × 525) = 60.000 Hz,一场不多一场不少。行频是 25,200,000 / 800 = 31.5kHz,扫描线周期 31.75 微秒。
所谓负极性同步,意思是同步信号在"脉冲期"是低电平,其余时间是高电平。640×480@60Hz 的规范里 HSYNC 和 VSYNC 都是负极性,这个如果搞反了,表现是显示器显示"信号超出范围"或者画面上下颠倒并滚动。我第一次写的时候就是顺手写成高有效,结果显示器直接进了省电模式。
2.3 25MHz、25.175MHz 和 25.2MHz 该怎么选
标准 VESA 文档给出的 640×480@60Hz 像素时钟是 25.175MHz。这个数字很别扭,因为它是从 800 × 525 × 59.94 反推出来的,而 59.94 这个帧率又是为了让彩色副载波和音频载波的差拍干扰最小才定下来的历史遗留值。
实际动手时你有三个选择:
- 直接对板上 50MHz 晶振做二分频,得到 25.00MHz。这是最省事的方法,偏差 0.7%。我实测过多台显示器都能正常锁定,包括一些对时钟比较挑的型号,因为它们内部通常允许 ±1% 以上的容差。
- 用 PLL 生成 25.2MHz。在 50MHz 输入下,只要把倍频系数设成 63、分频系数设成 125,就能精确得到 25.2MHz,配合 800×525 恰好是 60.000Hz。DE0-CV 和 DE10-Lite 上的 MAX 10 PLL 都支持这种整数分频配置,这是我最推荐的做法。
- 死磕 25.175MHz。只在你的显示器真的挑时钟、前两种都点不亮时才值得试,因为 PLL 要实现这个非整数比值,输出抖动会变大,反而不一定更好。
我的经验是先用 25.2MHz 试,不亮再退到 25MHz,两个都不亮的时候问题基本不在时钟上,而是时序参数或者硬件接线错了,别在时钟上继续消耗时间。
2.4 计数器的复位边界和极性表达式
计数器逻辑有一处必须写对:行计数器归零的时候,场计数器的行为。我见过不少写法是"行到了 800 就归零、场到了 525 就归零"两个独立的 if,这种写法在绝大多数时候没问题,但如果两个条件在同一拍同时成立,仿真里会出现场计数器多走一格或者少走一格,表现为画面每隔一段时间抖动一下。正确写法是把两者嵌套起来,行计数的终点作为场计数的使能条件。
极性表达同样容易写错。因为同步是负极性,所以在"计数器小于同步脉冲宽度"的区间里,输出应该是 0。用一行组合逻辑写出来就是hs <= ~(h_cnt < 96)。写成hs <= (h_cnt >= 96)是等价的,但写成hs <= (h_cnt < 96)就反了。
有效区坐标的提取也要小心。有效像素的起始位置是"同步脉冲 + 后沿" = 96 + 48 = 144,场方向是 2 + 33 = 35。只有在 h_cnt 大于等于 144 之后,px 才等于 h_cnt 减去 144,否则 px 应该给 0 而不是继续算下去,否则你在消隐区拿到的坐标会绕回到很大值,彩条的边界就会出现莫名其妙的色块。
3. 权电阻网络:从 8 色到 4096 色的阻值算法
"权电阻网络"这个名字听起来很唬人,本质其实很朴素:每一位数字输出对应一个电阻,电阻的阻值和这一位的权重成反比——权重越大的位,配的电阻越小,因为它要贡献更多的电流。所有位在同一个节点上按电流叠加,输出的模拟电压就等于所有"1"位贡献之和。这就是一个最原始的 DAC。
3.1 一位一通道的极简方案:三只 270Ω 电阻搞定 8 色
如果你的目标就是标题里说的彩条,那么红绿蓝每路只需要一位,也就是"这个颜色通道要么全亮要么全灭",一共 2³ = 8 种颜色。硬件上只需要三只 270Ω 电阻,分别从 FPGA 的三个 IO 接到 VGA 的 1、2、3 脚。
这三只电阻的取值在第 1.2 节已经算过了,就是让单一位导通时显示器端刚好得到 0.7V 满量程。因为彩条里每一路的取值只有 0 和满量程两种状态,所以你完全不需要关心电阻的精度和通道间的匹配——用 5% 的碳膜电阻和用 1% 的金属膜电阻,屏幕上看不出任何区别。这一点很实用:手边有什么用什么,不用特意去买精密电阻。
下面对应的是标准八色彩条的编码,顺序就是经典的 VGA 测试条顺序,从左到右:
| 序号 | 颜色 | R | G | B |
|---|---|---|---|---|
| 0 | 白 | 1 | 1 | 1 |
| 1 | 黄 | 1 | 1 | 0 |
| 2 | 青 | 0 | 1 | 1 |
| 3 | 绿 | 0 | 1 | 0 |
| 4 | 洋红 | 1 | 0 | 1 |
| 5 | 红 | 1 | 0 | 0 |
| 6 | 蓝 | 0 | 0 | 1 |
| 7 | 黑 | 0 | 0 | 0 |
640 像素宽度平均分给 8 条,每条正好 80 像素,这是个很舒服的数字,后面写 Verilog 的时候用一个除法就能得到条序号。
3.2 多位二进制加权网络的电流叠加模型与阻值推导
现在把每路从 1 位扩展到 N 位。假设每路有 N 位数字输入,第 i 位的电阻是 Ri,权重是 2^i(i = 0 是最低位)。所有位都通过各自电阻接到同一个输出节点,显示器在这个节点上提供 75Ω 对地端接。
因为整个网络是线性的,可以用叠加定理直接写出节点电压。设每位电阻的电导是 Gi = 1/Ri,逻辑 1 对应电压 3.3V,逻辑 0 对应 0V(相当于该支路接地),那么:
Vout = 3.3V × (Σ G_on) / (Σ G_all + 1/75)注意分母里是所有位的电导之和,而不是只有导通位的电导之和。这是很多人推导时出错的地方:逻辑 0 的位虽然输出 0V,但它的电阻依然挂在这个节点上,依然在"分流"。
为了得到正确的二进制权重,各路的电导要按 2 的幂分布,也就是最低位电导为 G,次低位 2G,依此类推,最高位 2^(N-1) 乘 G。以 4 位为例,所有位电导之和是 (1+2+4+8)G = 15G。
让全 1 时输出满量程 0.7V:
3.3 × 15G / (15G + 1/75) = 0.7设 u = 15G × 75,方程变成 3.3u / (u + 1) = 0.7,解得 u = 0.2692,于是 15G = 0.2692 / 75 = 0.00359,G = 0.0002393,最低位电导对应电阻 R = 1/G = 4178Ω。
再按 1:2:4:8 的电导比换算成电阻,对照 E24 标准值取整,就得到了 4 位权电阻网络的实际阻值表:
| 位 | 理论阻值 | 建议取值 | 该位单独导通时电流 |
|---|---|---|---|
| bit3(最高位) | 522Ω | 560Ω | 5.9mA |
| bit2 | 1045Ω | 1.1kΩ | 3.0mA |
| bit1 | 2090Ω | 2.2kΩ | 1.5mA |
| bit0(最低位) | 4178Ω | 4.3kΩ | 0.77mA |
用这组取值实际代入算一遍满量程电压:总电导是 1/560 + 1/1100 + 1/2200 + 1/4300 = 0.003382,满量程电压是 3.3 × 0.003382 / (0.003382 + 0.013333) = 0.668V。比 0.7V 略低一点,屏幕亮度差不到一档,完全可接受。
这套阻值的另一个好处是电流都在安全范围内。最高位电阻 560Ω,导通时约 5.9mA;全部位一起导通时总电流约 11.2mA,分散在四个引脚上,每个引脚都不会超过 MAX 10 的推荐输出电流。
3.3 R-2R 梯形网络和二进制加权网络,选哪个
除了上面这种"每个位配不同阻值"的二进制加权方案,还有一种经典结构叫 R-2R 梯形网络,只使用两种阻值。两者的取舍很值得说清楚,因为很多教程含混地写一句"用 R-2R 更好"就带过了。
| 对比项 | 二进制加权网络 | R-2R 梯形网络 |
|---|---|---|
| 需要的阻值种类 | N 种,比例跨度 2^(N-1) | 2 种(R 和 2R) |
| 4 位时的阻值跨度 | 约 8 倍 | 无跨度 |
| 阻值匹配对线性的影响 | 低位电阻误差影响大 | 对匹配相对宽容 |
| 是否需要精确的 2R | 不需要 | 需要,且 2R 必须是 R 的两倍 |
| 元件采购难度 | 需要凑齐一串阻值 | 只需买两种,容易批量配对 |
| 适合的位宽 | 1~4 位 | 4~8 位 |
我自己的判断是:做彩条,1 位就用二进制加权(其实就一只电阻);要做 16 级灰阶渐变这种真正需要线性度的场景,位数超过 4 位,就上 R-2R。原因是 R-2R 只需要两种阻值,你可以买一包 1% 金属膜电阻,从里面挑出一对阻值刚好是 2 倍关系的,匹配度容易保证;而二进制加权网络到了 8 位,最低位电阻会大到 100kΩ 级别,此时 FPGA 引脚的漏电流和输入偏置都会开始影响精度,得不偿失。
3.4 线性度、引脚电流和那一级缓冲器的取舍
这里有一个我在实际调试中才真正想明白的事:权电阻网络的线性度,本质上是在"低输出阻抗"和"FPGA 引脚电流"之间做取舍。
从 3.2 的公式可以看出来,当负载电导 1/75 相对于网络总电导不可忽略时,输出电压就不是理想的比例关系了。用上面那组 4 位阻值算一下最低位单独导通的电压:3.3 × (1/4300) / (0.003382 + 0.013333) = 0.058V,而理想值应该是满量程的 1/15,也就是 0.047V。也就是说最低位大约偏亮了 23%。这就是负载效应导致的非线性:导通位数越少,网络等效输出阻抗越高,负载分压得越厉害。
要改善线性度,就得把网络总电导做大,也就是整体把电阻值往小取,让等效输出阻抗远小于 75Ω。但这么做的代价是电流飙升:如果要把等效输出阻抗压到 20Ω 左右,最高位电阻会掉到 60Ω 上下,导通时电流超过 50mA,直接超出 FPGA 引脚的能力范围,有烧 IO 的风险。
所以真正的工程做法是在电阻网络后面加一级缓冲,比如用 74LVC244 或者一个简单的射极跟随器,把输出阻抗从几百欧降到几欧,再去驱动 75Ω 端接。加了缓冲之后,电阻网络本身可以按较大的阻值设计,电流小、效率高,线性度还更好。
不过回到我们这篇文章的主题:彩条显示只用到 0 和满量程两个状态,非线性完全体现不出来。所以做彩条的时候,上面这一堆关于线性度的分析,你不用真的去实现,但值得知道——因为当你想把画面从"彩条"升级到"渐变、图片、游戏"的时候,这些就是你必须面对的问题了。我建议的做法是:先用简单的一只 270Ω 电阻把彩条点出来,验证整条链路通了,再去考虑要不要上多位网络和缓冲。
4. Verilog 实现:分频、计数、坐标映射与彩条生成
代码部分我习惯拆成三个模块:时钟生成、时序发生、图案生成。这样拆的好处是,后面从彩条升级到图片或者小游戏时,只需要替换图案生成模块,时序部分一行都不用改。这一节我把完整的可综合代码给出来,并逐段解释每个写法的意图。
4.1 时钟与复位:不要让 PLL 的锁定时间坑了你
DE0-CV 和 DE10-Lite 上都有一颗 50MHz 的有源晶振。在 Quartus 里用 IP Catalog 调一个 PLL,输入 50MHz,输出 25.2MHz,就得到了像素时钟。如果不想用 PLL(比如想少配置一个 IP),也可以直接对 50MHz 做二分频得到 25MHz,代码就是always @(posedge clk_50m) clk_25m <= ~clk_25m;。
用 PLL 的时候有一个必须注意的点:PLL 上电后需要一段时间锁定,在锁定之前输出的时钟是不稳定的。如果你不加处理就直接用这个时钟驱动计数器,可能会在锁定的瞬间产生一段畸变的同步信号,某些显示器会因此进入"信号异常"状态而不显示,即使后面时钟稳定了也不恢复。解决办法是接出 PLL 的 locked 信号,用locked来作为复位释放的条件之一。
复位本身我倾向于自己做一个上电复位计数器,而不是直接用板上的按键。因为按键复位需要人手动按,做实验的时候你希望上电就能跑。写起来很简单:
// 上电复位:约 2.6ms 后自动释放 reg [15:0] por_cnt = 16'd0; wire rst_n = &por_cnt; always @(posedge clk_pix) if (!rst_n) por_cnt <= por_cnt + 16'd1;用 16 位计数器全 1 作为释放条件,在 25.2MHz 下大约是 2.6 毫秒,足够让 PLL 完成锁定和内部状态稳定。
4.2 行场同步计数器的完整写法
这是整个项目里最关键的一段代码,所有的画面偏移、滚动、撕裂问题,八成都能在这里找到原因。核心思路是用两个计数器分别数行内位置和帧内行号,总数分别是 800 和 525,然后在"同步 + 后沿"的位置上提取有效区坐标。
module vga_timing ( input wire clk_pix, // 25.2MHz 像素时钟 input wire rst_n, output reg vga_hs, output reg vga_vs, output reg de, // 有效像素区标志 output wire [9:0] px, // 0 ~ 639 output wire [9:0] py // 0 ~ 479 ); localparam H_SYNC = 96; localparam H_BP = 48; localparam H_ACT = 640; localparam H_FP = 16; localparam H_TOTAL = 800; localparam V_SYNC = 2; localparam V_BP = 33; localparam V_ACT = 480; localparam V_FP = 10; localparam V_TOTAL = 525; localparam H_START = H_SYNC + H_BP; // 144 localparam V_START = V_SYNC + V_BP; // 35 reg [9:0] h_cnt; reg [9:0] v_cnt; // 行场计数器:行归零时才决定场是否前进 always @(posedge clk_pix or negedge rst_n) begin if (!rst_n) begin h_cnt <= 10'd0; v_cnt <= 10'd0; end else if (h_cnt == H_TOTAL - 1) begin h_cnt <= 10'd0; v_cnt <= (v_cnt == V_TOTAL - 1) ? 10'd0 : v_cnt + 10'd1; end else begin h_cnt <= h_cnt + 10'd1; end end // 负极性同步输出 always @(posedge clk_pix or negedge rst_n) begin if (!rst_n) begin vga_hs <= 1'b1; vga_vs <= 1'b1; end else begin vga_hs <= ~(h_cnt < H_SYNC); vga_vs <= ~(v_cnt < V_SYNC); end end assign px = (h_cnt >= H_START) ? (h_cnt - H_START) : 10'd0; assign py = (v_cnt >= V_START) ? (v_cnt - V_START) : 10'd0; always @(posedge clk_pix or negedge rst_n) begin if (!rst_n) de <= 1'b0; else de <= (h_cnt >= H_START) && (h_cnt < H_START + H_ACT) && (v_cnt >= V_START) && (v_cnt < V_START + V_ACT); end endmodule这段代码里有三个细节值得单独说。第一,v_cnt的递增逻辑写在h_cnt归零的分支里,两者天然同步,不会出现错位。第二,px和py在消隐区被强制赋 0,而不是让它继续做减法,避免负值溢出成巨大的无符号数。第三,de用的是 h_cnt 和 v_cnt 的原始值做比较,而不是比较 px 和 py,这样逻辑更直观,也少一层延迟。
4.3 有效区坐标到彩条分块的映射
有了 0~639 和 0~479 的坐标,彩条就非常简单了:把 640 像素宽平均分成 8 份,每份 80 像素,用 px 除以 80 得到 0~7 的条序号,再按 3.1 节的颜色表查表输出。
module pattern_gen ( input wire de, input wire [9:0] px, input wire [9:0] py, output reg [3:0] vga_r, output reg [3:0] vga_g, output reg [3:0] vga_b ); wire [2:0] bar_idx = px / 10'd80; // 0 ~ 7 wire [3:0] gray = px / 10'd40; // 0 ~ 15,底部灰阶用 always @(*) begin vga_r = 4'h0; vga_g = 4'h0; vga_b = 4'h0; if (de) begin if (py < 10'd416) begin // 上面 416 行画彩条 case (bar_idx) 3'd0: begin vga_r = 4'hF; vga_g = 4'hF; vga_b = 4'hF; end // 白 3'd1: begin vga_r = 4'hF; vga_g = 4'hF; vga_b = 4'h0; end // 黄 3'd2: begin vga_r = 4'h0; vga_g = 4'hF; vga_b = 4'hF; end // 青 3'd3: begin vga_r = 4'h0; vga_g = 4'hF; vga_b = 4'h0; end // 绿 3'd4: begin vga_r = 4'hF; vga_g = 4'h0; vga_b = 4'hF; end // 洋红 3'd5: begin vga_r = 4'hF; vga_g = 4'h0; vga_b = 4'h0; end // 红 3'd6: begin vga_r = 4'h0; vga_g = 4'h0; vga_b = 4'hF; end // 蓝 default: begin vga_r = 4'h0; vga_g = 4'h0; vga_b = 4'h0; end endcase end else begin // 底部 64 行画 16 级灰阶 vga_r = gray; vga_g = gray; vga_b = gray; end end end endmodule关于px / 80这个除法,有必要解释一下。很多人一看到除法就担心资源消耗,其实除以常数在综合器眼里根本不是除法器,它会自动转换成乘加运算。在 25.2MHz 的像素时钟下,这点组合逻辑完全不会成为时序瓶颈。
不过如果你确实想避开除法,有一个很实用的小技巧:把彩条的宽度选成 2 的幂。比如把 640 分成 10 条 64 像素宽的彩条,只需要取px[9:6]就能得到条序号,一位逻辑都不多花。或者分成 5 条 128 像素的宽条,取px[9:7]。我早期做实验的时候就是用 5 条宽彩条先验证通路,因为宽条对时序错误更宽容——即使坐标算错了几十个像素,你也能看出它是彩条,而不是一堆乱码。
4.4 加一条灰阶测试条,验证权电阻网络的线性
上面代码里我特意留了底部 64 行做 16 级灰阶,宽度 40 像素一级。这一条不是装饰,它是用来验证权电阻网络是否按预期工作的。
如果你接的是 1 位/通道的极简网络,这 16 级灰阶会全部显示成同一种灰色(因为你的网络只能表达"亮"或"灭"两种状态,gray 值 1 到 15 全部会被最高位截断成"亮")。这本身就是个有效的验证:它说明你的网络确实是 1 位。
如果你接的是 4 位权电阻网络,这 16 级灰阶应该呈现出肉眼可辨的明暗递进。判断标准不是"每一级亮度差完全相同"——因为负载效应,低灰阶级别会偏亮,这是正常的;判断标准是单调递增且没有跳变。如果你看到灰阶条中间某两级亮度一样,或者某两级顺序反了,那就是对应位的电阻装错了阻值或者接错了引脚。
我在第一次搭 4 位网络的时候就遇到过这个问题:灰阶条的前一半看起来正常,后一半突然跳到最亮。查了半天发现是最高位和第二高位的电阻装反了,560Ω 和 1.1kΩ 的位置互换了。因为这两个阻值在肉眼下差别不大(都是几百欧的小电阻),焊完不测根本看不出来。后来我养成了一个习惯:焊完电阻网络,先用万用表把每一路的阻值量一遍,和表里对一遍再上电。这个习惯至少帮我省下了三次拆焊重来的时间。
5. DE0-CV 和 DE10-Lite 的板级差异与连接方式
同样是跑这套代码,两块板子的动手量完全不同。DE0-CV 上 VGA 接口和电阻网络是板载的,你要做的只是在 Quartus 里分配引脚;DE10-Lite 上什么都没有,得自己搭一个小电路。这一节把两种情况的差异讲清楚。
5.1 DE0-CV:板载网络,重点是找对引脚
DE0-CV 的板子上已经焊好了 15 针的 VGA 母座和配套的权电阻网络,你需要做的只有两件事。
第一件事是打开官方的原理图 PDF,找到 VGA 那一页,看清楚 RGB 三路各是几位。Terasic 这块板子上的网络是每通道多位加权设计的,具体位宽和引脚编号以你手上的原理图为准。确认位宽很重要,因为它直接决定你在 Verilog 里输出几位。如果板载是 4 位/通道,你就输出 4 位;如果只有 1 位(有些精简板子确实如此),那你就只需要输出最高位,把其余位悬空不分配。
第二件事是在 Pin Planner 里把信号名和引脚号对应起来。命名上我建议统一用vga_r[3:0]、vga_g[3:0]、vga_b[3:0]、vga_hs、vga_vs这样的向量形式,分配的时候逐个对应到原理图上标注的网络名。有一点要特别注意:原理图上标注的位序和你在 Verilog 里定义的位序要一致,哪个是最高位、哪个是最低位,一定要对着原理图确认。位序反了的表现是灰阶条整体翻转(亮的变暗、暗的变亮),而彩条本身看起来却是对的——因为彩条只用 0 和满量程,翻转后 0 变满、满变 0,颜色确实会全反,所以彩条也能看出来问题。
另外,DE0-CV 的 VGA 网络已经包含了必要的串联电阻,你千万不要再在外面串一层电阻,否则满量程电压会掉到 0.35V 左右,画面会变得非常暗。
5.2 DE10-Lite:GPIO 外接网络的接法和走线注意
DE10-Lite 没有 VGA 接口,需要自己在 GPIO 扩展口上搭网络。我实际的做法是找一小块洞洞板,把 VGA 母座、电阻和排针焊在上面,做成一个可以反复插拔的小模块。这样做比直接在杜邦线上串电阻靠谱得多,原因后面会讲。
接线上的关键点有几个:
- 电阻必须紧靠 VGA 母座,从 FPGA 到电阻那一段走的是纯数字信号,长一点没关系;从电阻到 VGA 针脚那一段是模拟信号,越短越好。
- 每个通道的地线都要接,VGA 的 5、6、7、8、10 全部连到扩展口的地脚上,最好用一根较粗的线或者多根并联。
- 同步信号线可以各串一只 33Ω 电阻,位置也尽量靠近 VGA 座,用来抑制过冲。
- 不要用太长的杜邦线。我在最开始图省事,用 20 厘米的杜邦线从扩展口直接飞到 VGA 座,结果画面上出现了明显的重影,彩条边缘有一层淡淡的拖影。换成 10 厘米的线并缩短了模拟段的长度之后,重影就消失了。原因是 25MHz 下信号的上升沿大约几纳秒纳秒级别,长导线分布电感电容会造成反射,而模拟段的反射直接表现为图像重影。
DE10-Lite 的扩展口 IO 是 3.3V 电平,在 Quartus 里把对应的 IO Standard 设成 3.3-V LVTTL 就行。另外要注意,扩展口上有些引脚是有特殊功能的(比如接到 ADC 或者加速计),分配引脚的时候最好避开这些,虽然只要不改配置文件它们一般不会干扰,但避开能省掉很多莫名其妙的调试时间。
5.3 工程设置和引脚分配的几个实务细节
Quartus 工程里需要检查的几项设置,我列一下,都是踩过坑的:
第一,确认芯片型号选对了。DE0-CV 是 Cyclone V 系列的 5CEBA4F23C8,DE10-Lite 是 MAX 10 系列的 10M50DAF484C7G。型号选错的话,前期编译都能过,最后下载的时候才报错,白折腾。
第二,时序约束基本不用手写。如果你用了 PLL,Quartus 会自动为 PLL 的输出时钟生成约束,你不需要在 SDC 里手动create_clock。如果没用 PLL,而是直接对 50MHz 做二分频生成 25MHz,那么 Quartus 有可能把生成出来的时钟当作普通数据信号来分析,这时候需要在 SDC 里给它加一句生成时钟定义,否则 TimeQuest 会给出无意义的时序报告。
第三,引脚分配的 I/O Standard 要逐个确认。VGA 相关的引脚全部设成 3.3-V LVTTL,电流强度用默认的 8mA 就够(我们的最大电流只有 5.9mA),摆率(Slew Rate)建议设成 Slow,可以减小高速切换时的过冲和电磁干扰。这个设置很多人会忽略,但它在长线飞接的时候对画面质量有实际影响。
第四,编译完之后用 Programmer 下载的是 .sof 文件,掉电就没了。如果想让板子一上电就自动跑,需要把 .sof 转换成 .jic 或者 .pof 烧到配置芯片里。做实验阶段一般不需要,下载 .sof 就够了。
6. 上板不亮、画面抖动、颜色错乱:按现象排查的完整链条
这一节是整篇文章里我最想写清楚的部分,因为前面所有工作是否做对,最终都体现在"屏幕到底出不出画面"上。而排查的顺序有讲究,乱试一通只会越来越乱。我把它整理成按现象分类的排查链路,每一类都按"最可能到最不可能"的顺序排。
6.1 显示器提示无信号或者直接黑屏
这种情况占所有失败案例的一半以上,而且绝大多数不是代码问题。排查顺序应该是这样的。
第一步,确认显示器有没有真的切到 VGA 输入源。这个听起来很傻,但我第一次做实验的时候折腾了四十分钟,最后发现显示器还停留在 HDMI 上,它根本没去检测 VGA 那一路。很多显示器有自动检测机制,但也有些需要手动切。
第二步,用示波器或者逻辑分析仪量同步信号。如果手边没有仪器,可以用最简单的办法:把 HSYNC 接到示波器上看周期,应该是 31.75 微秒;VSYNC 的周期是 16.67 毫秒。这两个数只要对上了,说明时序逻辑是对的,问题一定在硬件或者显示器那一侧。如果这两个数完全不对,那就是代码里的时序参数错了,回去检查 H_TOTAL 和 V_TOTAL 有没有算错。
第三步,量 RGB 三路的静态电压。在显示彩条的情况下,三个通道不可能同时是 0。如果量出来三路都是 0V,说明 de 信号没有拉高,或者坐标计算有问题,图案生成模块根本没有输出有效颜色。这时候可以先临时把 de 强制置 1,如果画面出来了(虽然会有消隐区的杂色),就说明是 de 的逻辑有问题。
第四步,检查同步极性。如果示波器上看到同步脉冲是高电平有效而不是低电平,那极性就是反的。表现是显示器显示"信号超出范围"然后进省电模式,或者进来之后画面上下滚动不止。
另外还有一个几乎所有人都忽略过的原因:某些显示器对 25MHz 和 25.2MHz 的容忍度确实不一样。我在一台老款三星上遇到过,25MHz 死活点不亮,换成 25.2MHz 立刻正常。所以如果你的时序和接线都确认没错,换一个像素时钟试试是值得的。
6.2 画面滚动、撕裂、左右偏移
画面能出来,但在滚动或者撕裂,这类问题的根源集中在计数器的复位边界和坐标偏移上。
如果画面上下滚动,是场同步的问题。先确认 VSYNC 的周期是不是 16.67 毫秒,如果周期是对的,那问题多半在场计数的复位逻辑上——具体说,就是行计数归零和场计数递增之间有没有严格的从属关系。前面 4.2 节强调过,场计数的递增必须写在行计数归零的分支里,不能写成两个独立的判断。
如果画面左右撕裂或者在中间断开,是行同步的问题。最常见的原因是行计数器归零的时机和同步脉冲的边界没有对齐。用一种很好用的调试方法:把整个画面填充成某种纯色,然后在左侧画一条 1 像素宽的竖线,看这条竖线在屏幕上的位置离左边缘有多远。多出来的那一段就是偏移量,可以直接换算成后沿的数值去修正。
如果画面整体偏移,但尺寸是对的,那是在有效区坐标提取的时候,后沿的值没有减掉。有效像素的起点是"同步 + 后沿",如果你忘了减后沿,画面会整体右移 48 像素,同时左边 48 像素会显示成上一行的内容,看起来就像被水平平移了。
6.3 颜色不对:通道接反和电阻装错
彩条的颜色不对,有两种典型表现。
第一种是颜色系统性错位,比如应该是白色的条显示成黄色,应该是绿色的条显示成青色。这说明有一路信号接错了位置,比如红色的引脚接到了绿色的电阻上。对照 3.1 节的八色表逐个检查每一路的高低电平,很容易定位。
第二种是灰色和黑色的边界不清,或者满色不够亮。这通常是电阻值选得不对。如果所有颜色都偏暗,说明串联电阻太大了,实际得到的电压远低于 0.7V;如果所有颜色都亮得发白、分不清黄色和白色,说明电阻太小了,电压接近或者超过了显示器的输入上限。
这里分享一个我常用的快速验证方法:只点亮一个通道,其他两个通道强制输出 0,屏幕上应该出现纯红、纯绿、纯蓝三条独立的彩条图样。如果红色通道点亮的时候屏幕上出现的是青色或者其他颜色,那就说明通道映射错了。这个方法比盯着八色表逐一比对效率高得多。
6.4 图像有噪点、重影、边缘发虚
这类问题基本都是硬件层面的信号完整性问题,和代码无关。
重影是最常见的,表现为彩条边缘有一层淡淡的拖影。原因几乎一定是模拟段走线太长,或者三路信号的地线没有接好。解决办法是缩短从电阻到 VGA 座的距离,把地线加粗或者用多根并联。
噪点表现为画面上零星出现白色或者彩色的像素点。这种情况要先怀疑时钟质量。如果用的是 PLL,检查一下输出时钟的抖动;如果是直接二分频,检查一下二分频逻辑有没有用组合逻辑实现(组合逻辑分频会产生毛刺,一定是时序逻辑)。另外,如果 RGB 的走线和同步信号的走线挨得太近,串扰也会产生噪点,分开布线能改善。
边缘发虚是 VGA 模拟信号的带宽问题。25.2MHz 的像素时钟意味着像素周期只有 39.7 纳秒,对应的模拟信号最高频率分量大约在 12MHz 以上。如果模拟段的走线电容太大或者串了额外的电容,高频分量会被衰减,表现为黑白边界变得模糊。这个在飞线搭的电路上比较难完全消除,但把模拟段做短、不加多余的元件,能改善很多。
7. 把这套东西继续做深:从静态彩条到可动画面
彩条跑通之后,这套框架其实还留着很大的扩展空间。因为时序部分是完全独立的,你只要换掉图案生成模块,就能做出完全不同的画面。这一节说说几个我自己试过的方向,以及它们各自会引入什么新问题。
7.1 用 ROM 存图:像素时钟下的取址关系
最简单的升级是把彩条换成一张固定的图片。做法是用 Quartus 的 ROM IP,把图片的像素数据预先存进去,然后在图案生成模块里用坐标算地址取数据。
地址的算法很直接:addr = py × 图片宽度 + px。不过这里有个必须注意的位宽问题:640 × 480 = 307200,需要 19 位地址线。如果图片宽度不是 2 的幂,乘法就要占资源;如果用 2 的幂宽度(比如 256),py × 256 + px就退化成拼接,几乎不占逻辑。
还有一个资源占用的问题要提前算清楚。用 4 位/像素的灰度图,640 × 480 就需要 640 × 480 × 4 = 1,228,800 比特,约 1.17Mbit 的存储。DE10-Lite 上的 MAX 10 10M50 内部总共大约 1.6Mbit 的 M9K 存储块,单缓冲能塞得下,但基本没有余量了,而且布局布线时会出现存储块不够用导致编译失败的尴尬。DE0-CV 上的 Cyclone V 片内存储更充裕一些,也谈不上宽裕。所以真要做全屏图片,建议先把分辨率降到 320×240,存储需求立刻降到四分之一,同时还可以做一个简单的 2 倍像素复制来铺满屏幕。
7.2 画个动态图形:帧计数器和双缓冲
如果想让画面动起来,比如做一个方块在屏幕上移动,思路就完全不一样了——你需要引入一个帧计数器,在每帧的场同步期间更新方块的位置,然后图案生成模块判断当前像素是否落在方块范围内。
判断像素是否在矩形内只需要四个比较:px >= x0 && px < x0 + w && py >= y0 && py < y0 + h。这四个比较在 25.2MHz 下完全够用,不需要任何存储。
但如果你想做的是多图层叠加或者滚动背景,就会遇到"读写冲突"的问题——图案生成模块在不停地读画面数据,同时某个逻辑在不停地改画面数据,两边抢同一个存储块。解决这个问题的标准做法是双缓冲:用两块存储,一块用来显示,一块用来写入,每帧结束时交换。代价是存储需求翻倍,1.17Mbit 就变成了 2.34Mbit,超过了 MAX 10 10M50 的片内存储容量。这种情况下就必须用板载的 SDRAM 了,DE10-Lite 上有一颗 64MB 的 SDRAM,可以放很多帧缓冲,但代价是你得写一个 SDRAM 控制器,或者用现成的 IP。
我的建议是分阶段来:先用全组合逻辑画简单几何图形(方块、圆、直线),这部分不需要任何存储;等这套玩熟了,再上 ROM 图片;最后如果真要做复杂画面,再考虑 SDRAM。一上来就想做完整图形系统,很容易在存储控制器上卡住,反而把 VGA 这条主线搞丢了。
7.3 加交互:从按键到帧同步
最后说一个体验提升很明显但实现很简单的小改动:把板上的按键接进来,用按键改变某些参数,比如彩条的宽度、方块的速度。
这里有一个坑必须提:按键信号一定要做跨时钟域同步和消抖。按键是异步于像素时钟的,直接把它采样进像素时钟域会引入亚稳态,表现为偶尔的颜色跳变或者位置突跳。正确做法是用两级触发器做同步,再加一个计数器做消抖(比如连续 10 毫秒保持同一状态才认定有效)。这个细节在跑静态彩条的时候体现不出来,但一旦加了交互,不做同步就会变成最难查的那种偶发故障——一个小时出现一次,调试的时候又复现不了。
把这些都做完,回头再看,从一块空着的 GPIO 到屏幕上稳定的彩条,中间真正难的不是写代码,而是把"75Ω 端接 → 电阻取值 → 时序参数 → 坐标映射"这条链条上的每一个环节都算清楚。彩条本身只是个验证工具,它验证的是你对这条链条的理解是否完整。我自己的习惯是每换一块新板子,都先跑一遍彩条,因为只要彩条正常,就说明时钟、时序、IO 电平、外部电路这四件事全都是对的,后面做任何画面都有了可靠的地基。