ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA纯RTL点亮TFT RGB屏:彩条时序与上板调试

FPGA纯RTL点亮TFT RGB屏:彩条时序与上板调试

简介:面向FPGA初学者与显示驱动开发者的Altera平台RGB液晶屏驱动工程包,围绕TFT RGB接口与Colorbar测试图案展开,可用于学习像素时钟、行场同步、并行RGB数据传输及Quartus工程组织方式。压缩包共126个文件,约3.43MB,包含Verilog源码、Quartus工程文件(qpf/qsf)、引脚与配置文件、综合布局布线报告、sof烧录文件、readme说明及少量图片和日志,基本覆盖从源码到下载验证的工程要素。已有1722人学习,适合作为RGB屏点亮、时序调试与显示通路排查的参考。读者可据此梳理TFT驱动模块划分、同步信号生成、数据位映射和工程约束设置,并结合报告与说明理解常见编译告警和调试路径,为后续接入帧缓冲、颜色空间转换或更高分辨率显示打下基础。

1. 从一块点不亮的屏说起:tft_rgb_colorbar 到底解决了什么

排线插好、背光点亮,屏上却是满屏雪花或者干脆一片黑——第一次把 40Pin 的 TFT 屏接到 FPGA 开发板上,很多人卡在这一步。tft_rgb_colorbar 就是为这种时刻准备的最小工程:不接摄像头、不读图片,只用纯 RTL 在屏幕上刷出八条标准彩条,把「像素时钟 → 行场时序 → RGB 数据」这条链路一次性验证干净。它适合刚接触 FPGA 驱动显示的开发者,也适合手里有一块来路不明的 RGB 屏、想先确认屏本身好坏的硬件工程师。跑通它大概花不了半小时,但能省掉后面几个晚上的玄学调试。

2. 工程骨架与技术选型:一条不依赖 CPU 的纯 RTL 点屏链路

这个工程最容易被低估的地方是它「什么都没有」:没有软核处理器、没有图形库、没有帧缓存,甚至连一块外部存储器都不需要。整条链路是纯组合加计数器,一个 50MHz 晶振进去,一组 RGB 并行数据加三根同步信号出来。正因为砍掉了所有中间层,出问题时你面对的不是「驱动没适配好」,而是「第 37 行的 DE 少了一拍」这种能定位到信号级的确定性故障。

技术栈上它只用到了三样东西:Verilog-2001 写的 RTL、厂商工具生成的锁相环 IP、一份引脚与时序约束文件。这意味着它可以被搬到几乎任何一块带 RGB 屏接口的开发板上,你要改的只是 PLL 的输入频率和引脚编号,逻辑本身一行都不用动。对于想做二次开发的人来说,这是一个非常舒服的起点——它把「显示」这件事压缩到了最小的可验证集合。

2.1 解压后先认这几类文件

按点屏工程一贯的组织方式,包里的文件基本可以归成四类,认清它们比通读代码更重要:

路径内容你要不要动
rtl/tft_rgb_colorbar_top.v顶层,例化 PLL、时序、图案三个模块,直连引脚换板子时改引脚名
rtl/video_timing.v行场时序发生器,产出 hsync/vsync/de 和像素坐标换分辨率时改参数
rtl/color_bar.v彩条图案,按 x 坐标输出 RGB888换成自己的画面时替换
rtl/pll_33m3.v锁相环 IP 的包装,50MHz 进、33.3MHz 出换晶振频率时重新生成
sim/tb_video_timing.v时序仿真,统计一帧的 DE 拍数和 HSYNC 个数改完参数先跑它
constr/tft_rgb.pin引脚约束与时钟约束必须按原理图逐条核对
doc/panel_timing.txt从面板手册抄下来的时序参数抄错一个数就花屏

真正需要你读透的只有video_timing.v和color_bar.v两个文件,加起来不到两百行。顶层文件更像一张接线表,它的价值在于把「哪个信号接哪个引脚」这件事固化成代码,而不是散落在某个 Excel 里。

2.2 为什么第一版是彩条而不是一张图片

彩条看上去像个偷懒的 Demo,实际上它是显示链路里信息密度最高的测试图。八条竖彩条的颜色顺序是固定的:白、黄、青、绿、品红、红、蓝、黑。这个顺序同时编码了三件事——R/G/B 三根通道有没有接反、位宽拼接有没有错位、像素坐标和屏幕物理位置是不是一一对应。你把屏点亮看到彩条,等于一次性验证了引脚映射、数据位序、时序参数、时钟频率四个环节。

如果第一版就上图片,故障面会立刻扩大一倍:图不显示,可能是时序错,也可能是 ROM 初始化没生效、地址算错、存储器带宽不够、跨时钟域没处理。这时候你面对的是一个黑匣子,只能靠猜。先用彩条把显示链路钉死,再往上叠图像源,出了问题就能一刀切开——彩条还正常,那问题一定在新加的那一层。这是我做显示类项目一直坚持的分层排查习惯,血泪经验换来的。

彩条还有个好處:它是纯组合逻辑从坐标算出来的,不占 BRAM、不占 DSP,资源占用几乎为零。这意味着你可以在同一块板子上把它和一个复杂的图像通路并存,随时切回来自检。

2.3 像素时钟与复位:整条链路的两个前提

像素时钟必须由 PLL 产生,不能用逻辑计数器分频。原因不在频率对不对,而在抖动和偏斜:计数器分频出来的时钟占空比固定、边沿抖动大,一路送到 IO 引脚上,屏那边的采样窗口本来就只有几个纳秒,很容易踩在建立保持时间的边缘上。表现出来就是画面轻微横向抖动、颜色发暗、偶尔整屏闪一下,而且是温度相关的,非常难查。

复位同样有坑。按键复位是异步的,如果直接把它送进像素时钟域的触发器,复位撤销的那一刻有可能正好落在时钟沿附近,导致部分寄存器复位、部分没复位,出现只在某次上电才复现的怪现象。稳妥写法是异步复位、同步释放:

// 异步复位、同步释放:避免复位撤销沿落在像素时钟的建立保持窗口里 reg [1:0] rst_sync; always @(posedge pix_clk or negedge rst_n) begin if (!rst_n) rst_sync <= 2'b00; else rst_sync <= {rst_sync[0], 1'b1}; end wire pix_rst_n = rst_sync[1]; // 之后所有像素时钟域的逻辑都用这个复位

两级触发器的作用是把复位撤销的时刻「挪」到时钟沿之后,代价是两个时钟周期的延迟,换来的结果是每次上电的行为完全一致。PLL 的locked信号也可以合并进来,写成wire pix_rst_n = rst_sync[1] & pll_locked;,这样 PLL 还没锁定时逻辑保持复位,不会输出半截垃圾数据把屏刷花。

3. 时序发生器拆解:HSYNC、VSYNC、DE 是怎么算出来的

RGB 屏不像 HDMI 那样自带握手,它是一块「你给什么就显示什么」的裸屏。整屏的扫描完全靠三根信号驱动:HSYNC 告诉屏一行结束,VSYNC 告诉屏一帧结束,DE(Data Enable)标记当前这组 RGB 数据是有效像素还是消隐期。屏内部没有帧存,所以这三个信号的节拍一个都不能错,错了就是花屏、撕裂或者整屏偏移。

3.1 先算一笔时间账:1056 × 525 的由来

以 800×480 这块最常见的 7 寸屏为例,一行并不是只有 800 个像素时钟。前面要有 HSYNC 脉冲、后沿(Back Porch),后面要留前沿(Front Porch),加起来才是完整的一行时间。同理一帧也不止 480 行。这些数字全部来自面板手册的 timing 表,不能自己拍脑袋。

本工程用的这组值:

参数数值含义
H_ACTIVE800一行有效像素
H_SYNC20行同步脉冲宽度
H_BACK46行后沿(消隐)
H_FRONT190行前沿(消隐)
H_TOTAL1056一行总时钟数
V_ACTIVE480一帧有效行
V_SYNC / V_BACK / V_FRONT10 / 23 / 12场同步与前后沿
V_TOTAL525一帧总行数
像素时钟33.3 MHz1056 × 525 × 60 ≈ 33.26MHz

帧率就是这么反推出来的:像素时钟除以(H_TOTAL × V_TOTAL),得到约 60.06Hz。换屏的时候先用这个公式算出需要的像素时钟,再去配 PLL;顺序反了会出现「PLL 生成了一堆奇怪的频率,然后到处试」的场面。

3.2 双计数器主体与三个输出信号

整个时序发生器就是两个嵌套计数器,行计数器数满一行清零并让场计数器加一,场计数器数满一帧清零。所有输出信号都是从这两个计数值比较出来的:

module video_timing #( parameter H_ACTIVE = 800, H_FRONT = 190, H_SYNC = 20, H_BACK = 46, parameter V_ACTIVE = 480, V_FRONT = 12, V_SYNC = 10, V_BACK = 23, parameter HSYNC_POL = 1'b1, // 面板手册标 negative 的改成 1'b0 parameter VSYNC_POL = 1'b1 )( input wire pix_clk, input wire rst_n, output reg hsync, output reg vsync, output reg de, output reg [11:0] x_pos, // 当前像素的横坐标,0~799 output reg [11:0] y_pos // 当前像素的纵坐标,0~479 ); localparam H_TOTAL = H_ACTIVE + H_FRONT + H_SYNC + H_BACK; // 1056 localparam V_TOTAL = V_ACTIVE + V_FRONT + V_SYNC + V_BACK; // 525 reg [11:0] h_cnt, v_cnt; always @(posedge pix_clk or negedge rst_n) begin if (!rst_n) begin h_cnt <= 12'd0; v_cnt <= 12'd0; end else if (h_cnt == H_TOTAL - 1) begin h_cnt <= 12'd0; v_cnt <= (v_cnt == V_TOTAL - 1) ? 12'd0 : v_cnt + 1'b1; end else begin h_cnt <= h_cnt + 1'b1; end end

计数器本体只有十几行,但它是整块屏的节拍源。注意h_cnt == H_TOTAL - 1这个判断用的是「数到最后一个数」而不是「数到 H_TOTAL」,差一会让一行多出一个时钟,屏幕右边会多出一条竖黑边,而且这条黑边会随帧漂移。

三个输出信号在同一个always块里更新,用同一份计数值比较,保证它们之间的相位关系是确定的:

always @(posedge pix_clk or negedge rst_n) begin if (!rst_n) begin hsync <= 1'b0; vsync <= 1'b0; de <= 1'b0; x_pos <= 12'd0; y_pos <= 12'd0; end else begin // 行同步:落在 H_ACTIVE+H_FRONT 之后的 H_SYNC 个时钟内 hsync <= ((h_cnt >= H_ACTIVE + H_FRONT) && (h_cnt < H_ACTIVE + H_FRONT + H_SYNC)) ^ ~HSYNC_POL; vsync <= ((v_cnt >= V_ACTIVE + V_FRONT) && (v_cnt < V_ACTIVE + V_FRONT + V_SYNC)) ^ ~VSYNC_POL; // 数据有效区:行、场都还在有效范围内 de <= (h_cnt < H_ACTIVE) && (v_cnt < V_ACTIVE); // 坐标与 de 同一拍更新,这是后面数据对齐的关键 x_pos <= h_cnt; y_pos <= v_cnt; end end endmodule

^ ~HSYNC_POL这一句是极性翻转的写法:参数为 1 时异或 0,信号原样输出;参数为 0 时异或 1,整体取反。面板手册上标 "negative" 或者画了个下降沿的,就把参数改成 0,不用去改逻辑。

3.3 打一拍对齐:DE 和数据必须同拍

上面这段代码里有个容易被忽略的细节:x_pos <= h_cnt;和de <= (h_cnt < H_ACTIVE)是在同一个时钟沿赋值的。这不是写法习惯,是必须。

如果坐标直接从计数器组合输出(assign x_pos = h_cnt;),而de是寄存器输出,那么在同一时刻,de反映的是上一个周期的h_cnt,而x_pos是当前周期的,两者差一拍。彩条这种按坐标分块的图案,差一拍的表现就是整屏内容向左偏移一个像素——单看彩条你可能都发现不了,但一旦换成带竖线的测试图,或者显示文字,偏移会非常刺眼。

再往后一级,输出寄存器那层还会再打一拍。只要保证「数据和它的使能信号在同一级寄存器里一起被延迟」,这个相对关系就不会破。我在这个工程里把规则简化成一句话:任何一级流水线,DE 和 RGB 必须成对进出。带图像源的时候更要守这条,ROM 读出来有 1~2 拍延迟,DE 就得跟着延迟同样的拍数,否则整个画面会右移。

3.4 换分辨率只改这几个参数

模块全部参数化之后,换屏的流程变得很短:查手册填参数、重算像素时钟、重新生成 PLL、重新分配引脚。下面是两组常见屏的参数对照,注意不同批次的面板即使尺寸一样,消隐区也可能不同,一切以你手上那块屏的手册为准:

参数800×480480×272备注
H_ACTIVE800480手册有效像素
H_FRONT / H_SYNC / H_BACK190 / 20 / 462 / 41 / 2三者顺序别抄反
V_ACTIVE480272手册有效行
V_FRONT / V_SYNC / V_BACK12 / 10 / 232 / 10 / 2小屏消隐区往往极小
H_TOTAL × V_TOTAL1056 × 525525 × 286决定像素时钟
像素时钟(60Hz)33.3 MHz9.0 MHz用公式反推

改完参数别急着上板,先在仿真里跑一遍。计数器类逻辑的 bug 在波形上一眼就能看出来,上板之后就只能靠猜了。

4. 彩条图案生成:从像素坐标到 RGB565 的最后一公里

时序对了只是保证屏能扫,屏幕上显示什么,取决于你在这 800×480 个有效像素位置上送了什么数据。彩条模块要做的事情非常朴素:拿到当前像素的横坐标,判断它落在第几条彩条里,输出对应的颜色。但就是这几十行组合逻辑,藏着两个容易翻车的点——除法的位宽和位序的拼接。

4.1 用 x 坐标切八条竖彩条

800 像素分八条,每条正好 100 像素宽。所以条索引就是x_pos / 100,取值范围 0~7,正好三位:

module color_bar #( parameter H_ACTIVE = 800, parameter BAR_NUM = 8 )( input wire pix_clk, input wire rst_n, input wire de, // 来自时序模块,高有效 input wire [11:0] x_pos, output reg [23:0] rgb888 // 组合输出,{R[7:0], G[7:0], B[7:0]} ); localparam BAR_W = H_ACTIVE / BAR_NUM; // 100 // 除以常数在综合时会被展开成移位加比较,不消耗除法器 wire [2:0] bar_idx = (x_pos / BAR_W > 7) ? 3'd7 : x_pos[11:0] / BAR_W; always @(*) begin if (!de) begin rgb888 = 24'h000000; // 消隐期送黑,避免屏上出现残影 end else begin case (bar_idx) 3'd0: rgb888 = 24'hFFFFFF; // 白 3'd1: rgb888 = 24'hFFFF00; // 黄 3'd2: rgb888 = 24'h00FFFF; // 青 3'd3: rgb888 = 24'h00FF00; // 绿 3'd4: rgb888 = 24'hFF00FF; // 品红 3'd5: rgb888 = 24'hFF0000; // 红 3'd6: rgb888 = 24'h0000FF; // 蓝 default: rgb888 = 24'h000000; // 黑 endcase end end endmodule

always @(*)是刻意的选择:这个模块不写寄存器,纯组合逻辑映射到 LUT,从x_pos到rgb888的组合延迟只有几级,输出级打一拍就能收住时序。如果这里也用always @(posedge pix_clk),模块内部就多了一拍延迟,而de还在上一级,两者又会错位。

(x_pos / BAR_W > 7) ? 3'd7 : ...这个保护不是多余的。x_pos是 12 位,理论上最大 4095,如果哪天H_ACTIVE改小了但x_pos的位宽没跟着收,除法结果会溢出三位,case跑进default分支,屏幕右边会出现一条黑边——而你会以为是时序问题,去改消隐参数,越改越乱。

4.2 位宽裁剪:RGB888 到 RGB565 怎么取舍

彩条内部用 24 位 RGB888 描述颜色好看,但真实的面板接口很少是 24 位的,常见的是 16 位 RGB565 或者 18 位 RGB666。RGB565 的分配是 R 占 5 位、G 占 6 位、B 占 5 位,原因很实在:人眼对绿色最敏感,把多出来的一位给 G,整体观感损失最小。

从 888 裁到 565,标准做法是丢掉低位、保留高位:

// RGB888 -> RGB565:每通道丢低位,不做四舍五入也能接受 wire [4:0] r565 = rgb888[23:19]; // 取 R 的高 5 位 wire [5:0] g565 = rgb888[15:10]; // 取 G 的高 6 位 wire [4:0] b565 = rgb888[7:3]; // 取 B 的高 5 位

丢低位而不是取平均值,是因为彩条只有八种纯色,高五位截断之后仍然是精确值,不会出现色偏。换成照片就要考虑加一位舍入(rgb888[23:19] + rgb888[18]),否则暗部会出现明显的色阶断层。

还有一种常见的翻车是位序反了。有些面板手册写的是R[4:0],但排线定义里 R0 对应的是最高位,接上去之后红色变成蓝色、白条还是白条(因为全 1),你盯着白条看半天看不出问题。这就是为什么彩条的颜色顺序是天然的排错工具——白、黄、青、绿、品红、红、蓝、黑这个顺序错了,一定是位序或者通道映射的问题,跟时序无关。

4.3 输出级打拍与 DE 门控

最后一站是输出寄存器。它的作用有两个:把组合逻辑的延迟收进一个时钟周期内,以及在消隐期把 RGB 强制拉零。

// 输出级:RGB 与 DE 在同一个时钟沿寄存,保证对齐 always @(posedge pix_clk or negedge pix_rst_n) begin if (!pix_rst_n) begin lcd_de <= 1'b0; lcd_r <= 5'd0; lcd_g <= 6'd0; lcd_b <= 5'd0; end else begin lcd_de <= de; // DE 同步延迟一拍 if (de) begin lcd_r <= rgb888[23:19]; lcd_g <= rgb888[15:10]; lcd_b <= rgb888[7:3]; end else begin lcd_r <= 5'd0; lcd_g <= 6'd0; lcd_b <= 5'd0; end end end

lcd_de <= de;这一行不能省。省掉之后,RGB 延迟了一拍而 DE 没有,屏会认为第一组数据在消隐期就到了,整屏右移一个像素,最右边一列跑到最左边去,看起来像裂纹。

消隐期送黑也不是必须的,但强烈建议。部分面板在 DE 无效时仍然会采样数据线,如果这时候线上是彩条的残留值,边缘会出现一列杂色。送黑之后这个问题彻底消失,代价是几条 LUT。

顶层就是把这些串起来,顺带做引脚映射:

// 顶层:PLL -> 时序 -> 彩条 -> 输出寄存 pll_33m3 u_pll ( .inclk0 (sys_clk), // 板载 50MHz .c0 (pix_clk), // 33.3MHz 像素时钟 .locked (pll_locked) ); wire pix_rst_n = rst_sync[1] & pll_locked; video_timing #( .H_ACTIVE(800), .H_FRONT(190), .H_SYNC(20), .H_BACK(46), .V_ACTIVE(480), .V_FRONT(12), .V_SYNC(10), .V_BACK(23) ) u_timing ( .pix_clk(pix_clk), .rst_n(pix_rst_n), .hsync(lcd_hsync), .vsync(lcd_vsync), .de(de), .x_pos(x_pos), .y_pos(y_pos) ); assign lcd_pclk = pix_clk; // 像素时钟走专用时钟引脚 assign lcd_bl = 1'b1; // 背光常开,调试时可以先接按键

背光那根线建议一开始就接到按键或者拨码上。屏全黑的时候,第一件事是确认背光到底亮没亮——背光没开和时序不对,在暗处看是同一个现象,很容易在这上面浪费半小时。

5. 上板排查:黑屏、花屏、颜色错位的五条血泪记录

时序逻辑有个特点:仿真波形全对,上板照样可能黑屏。下面这五条是我在这个工程上真实踩过的,按现象、原因、解决三段写,遇到问题时对着排一遍。

5.1 背光亮、屏幕纯黑,一点噪点都没有

现象:上电后屏的背光是亮的,但显示区完全黑色,没有雪花、没有闪烁,像是根本没通电。

原因:lcd_pclk没有真正送到屏,或者 DE 信号一直是 0。屏在没有像素时钟的时候不会做任何扫描,表现出来就是一块黑玻璃。另一种可能是 PLL 没锁,逻辑一直卡在复位状态,所有输出都是 0。

解决:先用示波器量像素时钟引脚,看有没有 33.3MHz、幅度对不对。如果没有,检查 PLL 的输入频率参数是不是填成了实际晶振频率(50MHz 的板子填成 27MHz 是很常见的抄错)。如果有时钟但还是黑,把de临时改成常 1 重新综合,屏幕应该出现横向条纹或者乱码花屏——出现说明数据通路是通的,问题在时序参数;还是不出现,问题在数据线或者初始化。

5.2 满屏花屏,彩条位置乱漂

现象:能看到彩条的影子,但位置不停漂移,左右撕裂,颜色块宽度不均匀。

原因:H_TOTAL 或者 V_TOTAL 算小了。前后沿少算几个时钟,行还没扫完就被 HSYNC 打断,屏会把下一行的数据接到当前行末尾,逐行累积偏移,看起来就是斜向撕裂。

解决:把doc/panel_timing.txt里的参数和面板手册的 timing 表逐项核对,特别注意手册里 Front Porch 和 Back Porch 的顺序——有的手册先写 Back 后写 Front,抄反了参数看着都对,现象就是花屏。核对完在仿真里数一遍 H_TOTAL 是不是等于 1056,比上板试快得多。

5.3 白条正常,红蓝互换

现象:彩条出来了,位置也对,但颜色顺序是白、青、黄、绿、品红、蓝、红、黑——红和蓝调了个个儿。

原因:R 通道和 B 通道接反,或者 RGB565 拼接时位序颠倒。这类问题不影响白条和黑条(全 1 和全 0 怎么换都一样),所以第一眼很容易漏过去。

解决:彩条的颜色顺序就是判据。正常的顺序是白黄青绿品红红蓝黑,只要红蓝位置互换,先查rgb888[23:19]有没有接到lcd_b,再查排线上 R0~R4 和 B0~B4 的分组。别去动时序参数,动了只会更乱。

5.4 画面轻微横向抖动,颜色发暗

现象:能显示,但整个画面像隔着一层水波纹,横向有细微抖动,白色区域明显不够白。

原因:像素时钟是用计数器分频出来的,不是 PLL 输出。逻辑分频的时钟占空比固定、边沿抖动大,送到 IO 之后屏的采样点落在数据跳变沿附近,采到的值不稳定。

解决:改成 PLL 输出,并且走专用时钟引脚。如果 PLL 输出必须经过一层逻辑(比如做门控),用时钟使能代替门控时钟,也就是保持时钟纯净、用if (ce)控制数据更新,不要在时钟路径上插组合逻辑。

5.5 功能仿真全对,下载后毫无反应

现象:仿真波形完美,DE 宽度、帧率都对,综合也没报错,但上板什么都不显示。

原因:约束文件漏了像素时钟的create_clock,或者引脚分配把 RGB 数据分到了电压标准不匹配的 bank。前者让工具按默认频率优化,实际布出来的路径延迟超标但工具不报错;后者更隐蔽,3.3V 的屏接到 1.8V 的 bank 上,信号幅度不够,屏根本识别不了。

解决:约束文件里必须显式声明pix_clk的周期(33.3MHz 约 30ns),并确认所有 LCD 相关引脚的电平标准是 3.3V LVCMOS。上板前扫一眼工具的时序报告,建立时间余量为正再下载,这一步能挡掉大半「仿真对、上板黑」的问题。

6. 二次开发:把彩条换成自己的画面

彩条跑通之后,绝大多数人的下一步都是「我想显示自己的图」。最直接的改法是把color_bar换成一个 ROM 读图模块,但要先算一笔容量账:800×480 的 RGB565 全屏图是 768000 字节,普通 FPGA 的片上 BRAM 撑不住,所以常见做法是缩到 400×240 再 2×2 放大,占用降到 192KB 左右,刚好能塞进中等规模的器件。

替换的时候有个细节必须处理:ROM 是同步读,输出比地址晚一拍。地址由x_pos/y_pos组合算出,那么de也要跟着延迟一拍,否则画面整体右移一个像素。

// 400x240 图像 2x2 放大居中显示,注意 ROM 有一拍读延迟 wire [17:0] rom_addr = y_pos[9:1] * 18'd400 + x_pos[9:1]; wire [15:0] rom_data; img_rom u_rom (.address(rom_addr), .clock(pix_clk), .q(rom_data)); reg de_d1; always @(posedge pix_clk) de_d1 <= de; // DE 跟着延迟一拍 // 输出级用 de_d1 而不是 de,配合 rom_data 的时序

y_pos[9:1]这种写法就是除以 2,综合出来是纯连线,不消耗逻辑。放大倍数改成 4 就把位宽切成[11:3],同时地址里的行宽也要跟着除,改错会看到画面被斜着切。

想再进一步,就得上帧缓存了:外挂一颗 SDRAM 或者 PSRAM,写口接串口或摄像头,读口按显示时序连续读,两个时钟域之间用异步 FIFO 过渡。这一步的工作量比前面所有加起来都大,但架构上还是那三件事——时序、数据、使能对齐,彩条阶段建立的直觉在这里完全适用。

改完参数一定要先跑仿真。我在tb_video_timing.v里加了一段统计逻辑,每帧数一遍 DE 有效拍数和 HSYNC 上升沿个数,前者应该正好是 384000,后者正好是 480,两个数对上再上板:

// 统计一帧的 DE 拍数与 HSYNC 个数,改完参数先看这两行打印 reg hsync_d; always @(posedge clk) hsync_d <= hsync; always @(posedge clk) begin if (de) de_cnt = de_cnt + 1; if (hsync & ~hsync_d) hs_cnt = hs_cnt + 1; end

从那以后我每次改分辨率或者换图像源,都强制先跑一遍这个测试台,两个数字对不上就不往下走。吃过一次亏:参数改完直接上板,屏上显示的是一张被横向压缩的图,我花了一个晚上怀疑 ROM 初始化没生效,最后发现是 H_ACTIVE 改了但彩条模块里的BAR_W还按 800 算的。仿真里跑一遍,三十秒就能看出 DE 拍数不对。希望这些能帮到你少走几个晚上。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进