第三十二章 HDMI方块移动实验

本章在讲什么

上一章我们用 HDMI 成功点亮了显示器,显示的是静态彩条。这一章就一个目标:让画面动起来——在屏幕上显示一个 40×40 的黑色小方块,它以 100Hz 的频率在屏幕上移动,碰到四周 40 像素宽的蓝色边框就反弹。

硬件设计、引脚约束与 HDMI 彩条实验完全相同(TMDS 差分输出、74.25MHz 像素时钟),所以本章只改一个模块:video_display。这正体现了 FPGA 工程里"分层复用"的好处——传输链路(PLL、video_driver、dvi_transmitter_top)原封不动,只替换"画什么"。

核心知识点就两个:

  1. 顶点坐标移动法:用 (block_x, block_y) 记录方块左上角顶点,移动就是改坐标;
  2. 分频产生移动节拍:像素时钟 74.25MHz 太快,用计数器分频出 100Hz 的 move_en 脉冲控制坐标加减。

核心概念拆解

32.1 系统架构:只换"画笔",不动"画布"

系统框图和彩条实验一致:

sys_clk → PLL → pixel_clk (74.25MHz) / pixel_clk_5x (371.25MHz)
                        ↓
        video_driver(时序+坐标) → video_display(方块绘制) ← 本次唯一修改点
                        ↓
              dvi_transmitter_top → TMDS 差分 → HDMI 显示器

video_display 的接口与彩条版完全一样:输入像素坐标 pixel_xpos/pixel_ypos,输出该点颜色 pixel_data。也就是说,显示内容的变化被完全封装在模块内部。

32.2 顶点坐标移动法:把"方块移动"降维成"坐标移动"

一个 40×40 的方块由无数像素点组成,如果每个点单独算运动,代码没法写。技巧是:方块大小固定时,只要知道左上角顶点坐标,就能画出整个方块。于是把"方块移动"简化为"顶点 (block_x, block_y) 的移动"。

参数定义(都在 video_display 内):

parameter  H_DISP  = 11'd1280;      // 行分辨率
parameter  V_DISP  = 11'd720;       // 列分辨率
parameter  DIV_CNT = 22'd750000;    // 分频计数器(决定移动速度)

localparam SIDE_W  = 11'd40;        // 屏幕边框宽度
localparam BLOCK_W = 11'd40;        // 方块宽度
localparam BLUE    = 24'h0000ff;    // 边框蓝色
localparam WHITE   = 24'hffffff;    // 背景白色
localparam BLACK   = 24'h000000;    // 方块黑色

想改方块大小、边框宽度、移动速度、颜色,只动这几个参数即可。

32.3 100Hz 移动脉冲:像素时钟太快,分频来凑

屏幕刷新是 60Hz,但方块如果每个像素时钟都动一步,瞬间就飞出屏幕了。解决办法:用计数器 div_cnt 数到 DIV_CNT = 750000(74.25MHz ÷ 750000 = 99Hz ≈ 100Hz)时拉高一个周期的 move_en

assign move_en = (div_cnt == DIV_CNT) ? 1'b1 : 1'b0;

always @(posedge pixel_clk or negedge sys_rst_n) begin
    if (!sys_rst_n)
        div_cnt <= 22'd0;
    else begin
        if (div_cnt < DIV_CNT)
            div_cnt <= div_cnt + 22'd1;
        else
            div_cnt <= 22'd0;       // 计数达 10ms 后清零
    end
end

move_en 每 10ms 出现一次,方块每秒移动 100 个像素点。DIV_CNT 越大,方块移动越慢——这就是调节移动速度的旋钮。

32.4 方向翻转:撞墙反弹的实现

反弹逻辑用两个方向寄存器 h_direct(1=右移,0=左移)和 v_direct(1=下移,0=上移)实现:

// 当方块移动到边界时,改变移动方向
always @(posedge pixel_clk or negedge sys_rst_n) begin
    if (!sys_rst_n) begin
        h_direct <= 1'b1;           // 初始水平向右
        v_direct <= 1'b1;           // 初始竖直向下
    end
    else begin
        if (block_x == SIDE_W + 11'd1)                      // 到达左边界,向右
            h_direct <= 1'b1;
        else if (block_x == H_DISP - SIDE_W - BLOCK_W + 11'd1)  // 到达右边界,向左
            h_direct <= 1'b0;
        else
            h_direct <= h_direct;

        if (block_y == SIDE_W + 11'd1)                      // 到达上边界,向下
            v_direct <= 1'b1;
        else if (block_y == V_DISP - SIDE_W - BLOCK_W + 11'd1)  // 到达下边界,向上
            v_direct <= 1'b0;
        else
            v_direct <= v_direct;
    end
end

以水平方向为例:屏幕宽 1280,两侧各留 40 像素边框,方块宽 40。右边界临界值为 1280 - 40 - 40 + 1 = 1201——顶点到达 1201 时方块最右缘刚好贴住右边框内侧,此时翻转方向向左。仿真验证了这一点:block_x == 41h_direct 拉高(向右),block_x == 1201 时拉低(向左);竖直方向同理(41 翻转向下,641 翻转向上)。

32.5 坐标更新与区域上色:move_en 有效时才动,按坐标范围画三块区域

坐标更新只在 move_en 有效时执行一次,方向寄存器决定加减:

always @(posedge pixel_clk or negedge sys_rst_n) begin
    if (!sys_rst_n) begin
        block_x <= SIDE_W + 11'd1;  // 初始位置在左上角
        block_y <= SIDE_W + 11'd1;
    end
    else if (move_en) begin
        if (h_direct) block_x <= block_x + 11'd1;   // 右移
        else          block_x <= block_x - 11'd1;   // 左移
        if (v_direct) block_y <= block_y + 11'd1;   // 下移
        else          block_y <= block_y - 11'd1;   // 上移
    end
end

最后按当前像素坐标判断落在哪个区域,涂对应颜色——这是整个绘制逻辑的"调色板":

// 给不同的区域绘制不同的颜色
always @(posedge pixel_clk or negedge sys_rst_n) begin
    if (!sys_rst_n)
        pixel_data <= BLACK;
    else begin
        if ( (pixel_xpos < SIDE_W) || (pixel_xpos >= H_DISP - SIDE_W)
          || (pixel_ypos <= SIDE_W) || (pixel_ypos > V_DISP - SIDE_W) )
            pixel_data <= BLUE;                 // 边框:蓝色
        else if ( (pixel_xpos >= block_x - 11'd1) && (pixel_xpos < block_x + BLOCK_W - 11'd1)
               && (pixel_ypos >= block_y) && (pixel_ypos < block_y + BLOCK_W) )
            pixel_data <= BLACK;                // 方块:黑色
        else
            pixel_data <= WHITE;                // 背景:白色
    end
end

判断顺序是边框 → 方块 → 背景,先判最外层。注意方块区域判断里出现的 block_x - 1'd1 偏移,与方向翻转里 +1 的临界值是配套的,几处坐标必须自洽,改任何一处都要同步检查其余几处。

32.6 顶层与仿真:架构照搬,defparam 缩短等待

顶层 top_hdmi_block_move 与彩条实验几乎完全相同:PLL 输出 74.25MHz 像素时钟和 5 倍频 371.25MHz,依次例化 video_drivervideo_displaydvi_transmitter_top,唯一的区别是 PLL 时钟频率从 1080p 彩条配置换成了 720p 的 74.25MHz。

仿真时有个现实问题:DIV_CNT = 750000,如果照实仿真,要等非常久才能看到方块动一步。TB 里用 defparam 把参数改小:

defparam top_hdmi_block_move_inst.u_video_display.DIV_CNT = 75;

这样 div_cnt 数到 75 就拉高 move_en,几微秒就能看到一次移动,大幅缩短仿真时间。下载到板上时用的还是真实参数 750000,defparam 只作用于仿真。

图片对照

以下图片配合上文食用效果更佳(路径对应 /images/zynq/ch32/):

HDMI系统总体框图

图:HDMI 系统总体框图——只有 video_display 是本章新改动的模块

HDMI显示模块波形图

图:显示模块波形图——div_cnt 计数、move_en 脉冲、block_x/block_y 移动与方向翻转的关系一目了然

方块垂直移动方向波形

图:方块垂直移动方向仿真波形——block_y 到达边界时 v_direct 翻转

HDMI方块移动效果图

图:上板效果图——黑色方块在蓝色边框内弹跳

初学者容易踩的坑

  1. 边界临界值搞混:翻转条件是 block_x == SIDE_W + 1(左)和 block_x == H_DISP - SIDE_W - BLOCK_W + 1(右),这几个 +1 来自坐标偏移的约定(方块区域判断用了 block_x - 1)。不要凭直觉写成 40 或 1200,否则方块会"陷进"边框或提前反弹。
  2. 方块不动:检查 move_en 是否正确产生。DIV_CNT = 750000 对应 22 位计数器,如果寄存器位宽不够(比如只给了 20 位),计数永远到不了目标值,方块就永远静止。
  3. 仿真等太久:不修改 DIV_CNT 直接仿真,一个移动节拍要 750000 个时钟周期,波形窗口里几乎看不到变化。务必用 defparam 把参数改小再仿真。
  4. 改了参数没同步:把 BLOCK_W 改成 80 时,方向翻转公式 H_DISP - SIDE_W - BLOCK_W + 1 里用的是参数名,会自动跟着变;但如果你手写了 1201 这类常数就会错。坚持用参数表达式而非硬编码数字。
  5. 以为要改传输链路:本章只改 video_display。如果动了 dvi_transmitter_top 或 PLL 反而把好的改坏了——先确认"屏幕上没有方块"是显示内容问题,而不是链路问题。

小结 & 下一步

本章在 HDMI 彩条实验基础上,仅修改 video_display 模块就实现了动态图案显示,掌握了三个可迁移的套路:

  • 顶点坐标法:固定大小的图形,用左上角顶点坐标代表整个图形,移动即改坐标;
  • 分频节拍法:用计数器从高速时钟里分出低频使能脉冲,控制"动画帧率"(DIV_CNT 越大越慢);
  • 区域上色法:按像素坐标的范围判断涂色,绘制边框、运动物体和背景。

这套"坐标 + 节拍 + 上色"的思路正是所有屏幕动画的雏形,之后做贪吃蛇、打砖块小游戏都是它的扩展。

下一步:第三十二章结束,接下来进入第三十三章 I2C 协议读写 EEPROM 实验——这是第一次接触真正的板间串行通信协议,将学习 I2C 的起始/停止条件、应答机制以及状态机驱动的读写控制。