28、项目实战1:数字存储示波器(ADC采集+SRAM缓存+VGA显示+按键控制)

终于到了第一个综合项目实战了。

说实话,每次带新人做这个项目,我都能看到他们眼睛一亮——因为这是第一次把ADC、SRAM、VGA、按键这些模块真正拼在一起,变成一个能用的东西。你想想看,一个能抓取真实模拟信号、存下来、再显示到屏幕上的示波器,完全由你写的Verilog代码驱动,这种感觉真的很爽。

28.1 项目整体架构

这个数字存储示波器,说白了就是四个核心模块的协同工作:

  • ADC采集模块:负责把模拟信号变成数字信号
  • SRAM缓存模块:负责暂存采集到的数据
  • VGA显示模块:负责把数据画到屏幕上
  • 按键控制模块:负责调节触发、时基、电压档位

我个人的习惯是,先画一张顶层模块连接图。虽然这里没法画图,但我用文字描述一下:

模拟信号 → ADC芯片 → ADC采集模块 → SRAM写控制 → SRAM芯片
                                            ↓
按键输入 → 按键消抖 → 控制逻辑 → SRAM读控制 → VGA显示模块 → 显示器

嗯,这里要注意:SRAM是读写共用的,所以需要一个仲裁机制。我在项目中遇到过因为读写冲突导致显示花屏的问题,后来加了一个简单的状态机来分时复用,问题就解决了。

28.2 ADC采集模块设计

我们用的是紫光同创开发板上的ADC芯片,8位并行输出,采样率最高能到50MSPS。但实际项目中,我建议先跑20MSPS,稳定第一。

ADC采集模块的核心代码其实不复杂:

module adc_capture (
    input  wire        clk_adc,      // ADC采样时钟
    input  wire        rst_n,        // 复位
    input  wire [7:0]  adc_data,     // ADC数据输入
    input  wire        trig_en,      // 触发使能
    input  wire [7:0]  trig_level,   // 触发电平
    output reg  [7:0]  cap_data,     // 采集数据输出
    output reg         cap_valid,    // 数据有效标志
    output reg         trig_done     // 触发完成标志
);

    reg [1:0] state;
    localparam IDLE   = 2'b00,
               WAIT   = 2'b01,
               CAPTURE = 2'b10;

    always @(posedge clk_adc or negedge rst_n) begin
        if (!rst_n) begin
            state <= IDLE;
            cap_data <= 8'd0;
            cap_valid <= 1'b0;
            trig_done <= 1'b0;
        end else begin
            case (state)
                IDLE: begin
                    cap_valid <= 1'b0;
                    trig_done <= 1'b0;
                    if (trig_en) begin
                        state <= WAIT;
                    end
                end
                WAIT: begin
                    // 检测触发条件:上升沿过触发电平
                    if (adc_data > trig_level && $past(adc_data) <= trig_level) begin
                        state <= CAPTURE;
                        trig_done <= 1'b1;
                    end
                end
                CAPTURE: begin
                    cap_data <= adc_data;
                    cap_valid <= 1'b1;
                    // 采集完1024个点后回到IDLE
                    // 这里用外部计数器控制
                end
            endcase
        end
    end
endmodule

这段代码里有个小技巧——$past这个系统函数。它可以直接拿到上一个时钟周期的值,用来做边沿检测非常方便。不过要注意,有些综合工具不支持这个函数,仿真用没问题,综合时建议自己写一个寄存器打拍。

我的经验:触发电平最好设计成可调的,用按键或者旋钮控制。我曾经在调试一个低频信号时,触发电平设得太高,死活触发不了,折腾了半天才发现是电平设置的问题。

28.3 SRAM缓存模块设计

SRAM用的是IS61LV25616,256K×16bit的异步SRAM。我们只需要8位数据,所以高8位可以不用,或者用来存其他信息(比如时间戳)。

读写仲裁是这里的关键。我设计了一个简单的状态机:

module sram_arbiter (
    input  wire        clk,
    input  wire        rst_n,
    // 写端口
    input  wire        wr_req,
    input  wire [17:0] wr_addr,
    input  wire [7:0]  wr_data,
    // 读端口
    input  wire        rd_req,
    input  wire [17:0] rd_addr,
    output reg  [7:0]  rd_data,
    // SRAM接口
    output reg  [17:0] sram_addr,
    inout  wire [15:0] sram_data,
    output reg         sram_ce_n,
    output reg         sram_oe_n,
    output reg         sram_we_n
);

    reg [1:0] state;
    localparam IDLE  = 2'b00,
               WRITE = 2'b01,
               READ  = 2'b10;

    // 双向数据总线控制
    reg [15:0] sram_data_out;
    assign sram_data = (state == WRITE) ? {8'd0, sram_data_out} : 16'hzzzz;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            state <= IDLE;
            sram_ce_n <= 1'b1;
            sram_oe_n <= 1'b1;
            sram_we_n <= 1'b1;
        end else begin
            case (state)
                IDLE: begin
                    sram_ce_n <= 1'b1;
                    if (wr_req) begin
                        state <= WRITE;
                    end else if (rd_req) begin
                        state <= READ;
                    end
                end
                WRITE: begin
                    sram_addr <= wr_addr;
                    sram_data_out <= wr_data;
                    sram_ce_n <= 1'b0;
                    sram_we_n <= 1'b0;
                    state <= IDLE;
                end
                READ: begin
                    sram_addr <= rd_addr;
                    sram_ce_n <= 1'b0;
                    sram_oe_n <= 1'b0;
                    // 读数据需要等一个周期
                    rd_data <= sram_data[7:0];
                    state <= IDLE;
                end
            endcase
        end
    end
endmodule
注意:异步SRAM的时序要求比较严格。我曾经因为地址建立时间不够,导致读出来的数据全是乱的。后来在地址变化后加了一个时钟周期的延迟,问题就解决了。建议仔细看芯片的数据手册,特别是地址建立时间和保持时间。

28.4 VGA显示模块设计

VGA显示这块,我们用的是640×480@60Hz的标准时序。显示波形时,我把SRAM里的1024个点映射到屏幕的640列上,每列显示一个点,不够的列就插值。

波形显示的核心思路:

  • 水平方向:每个像素列对应一个采样点(或者插值点)
  • 垂直方向:采样点的值映射到屏幕的Y坐标
  • 颜色:波形用绿色,网格用灰色,背景用黑色

这里有个坑——VGA的扫描是从左到右、从上到下的,而SRAM读数据需要时间。我建议用双缓冲:一个缓冲区在显示,另一个缓冲区在填充数据。不过我们用的SRAM够大,直接分两个区域就行。

// VGA波形显示核心逻辑
always @(posedge clk_vga or negedge rst_n) begin
    if (!rst_n) begin
        pixel_color <= BLACK;
    end else begin
        // 判断是否在波形显示区域
        if (hcount >= 10'd160 && hcount < 10'd800 &&
            vcount >= 10'd40  && vcount < 10'd520) begin
            
            // 计算当前像素对应的采样点索引
            pixel_index = (hcount - 160) >> 0;  // 640列对应1024点,这里简化处理
            
            // 判断当前像素是否在波形上
            if (vcount == (480 - sram_data[pixel_index])) begin
                pixel_color <= GREEN;  // 波形点
            end else if (grid_line) begin
                pixel_color <= GRAY;   // 网格线
            end else begin
                pixel_color <= BLACK;  // 背景
            end
        end else begin
            pixel_color <= BLACK;
        end
    end
end
小技巧:为了让波形看起来更连续,可以在相邻两个采样点之间画一条直线。用Bresenham算法或者简单的线性插值都行。我一般用线性插值,代码简单,效果也够用。

28.5 按键控制模块设计

按键控制其实是个老生常谈的话题了。但在这个项目里,按键不只是简单的开关,而是要控制:

  • 触发模式:上升沿触发、下降沿触发、自动触发
  • 时基:每格多少毫秒/微秒
  • 电压档位:每格多少伏
  • 运行/停止:冻结当前波形

我设计了一个按键状态机,每个按键对应一个功能:

module key_control (
    input  wire        clk,
    input  wire        rst_n,
    input  wire [3:0]  key_in,      // 4个按键
    output reg  [1:0]  trig_mode,   // 触发模式
    output reg  [2:0]  time_base,   // 时基
    output reg  [2:0]  volt_base,   // 电压档位
    output reg         run_stop     // 运行/停止
);

    // 按键消抖
    reg [3:0] key_reg;
    reg [19:0] cnt;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            key_reg <= 4'hf;
            cnt <= 20'd0;
        end else begin
            if (key_in != key_reg) begin
                cnt <= 20'd0;
                key_reg <= key_in;
            end else if (cnt < 20'd1000000) begin
                cnt <= cnt + 1'b1;
            end
        end
    end

    // 按键功能分配
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            trig_mode <= 2'b00;
            time_base <= 3'b000;
            volt_base <= 3'b000;
            run_stop <= 1'b1;
        end else begin
            // key[0]: 切换触发模式
            if (key_reg[0] == 1'b0 && cnt == 20'd1000000) begin
                trig_mode <= trig_mode + 1'b1;
            end
            // key[1]: 切换时基
            if (key_reg[1] == 1'b0 && cnt == 20'd1000000) begin
                time_base <= time_base + 1'b1;
            end
            // key[2]: 切换电压档位
            if (key_reg[2] == 1'b0 && cnt == 20'd1000000) begin
                volt_base <= volt_base + 1'b1;
            end
            // key[3]: 运行/停止
            if (key_reg[3] == 1'b0 && cnt == 20'd1000000) begin
                run_stop <= ~run_stop;
            end
        end
    end
endmodule
避坑指南:按键消抖的计数器值要根据你的时钟频率来算。我用的是50MHz时钟,20位计数器大概能消抖20ms。如果你时钟频率不一样,记得重新算一下。我曾经直接复制别人的代码,结果按键反应迟钝,后来才发现是计数器位数不对。

28.6 系统集成与调试

把所有模块拼在一起后,调试是最磨人的阶段。我总结了几条经验:

  1. 先调ADC:给一个已知的直流电压,看看ADC读出来的值对不对
  2. 再调SRAM:写一组固定数据进去,读出来对比,确认读写没问题
  3. 然后调VGA:先显示一个静态的测试图案,比如棋盘格
  4. 最后联调:把ADC的数据存到SRAM,再显示到VGA上

我记得第一次联调成功时,屏幕上出现了一个正弦波,虽然有点毛刺,但那种成就感真的难以形容。后来加了简单的数字滤波,波形就漂亮多了。

调试步骤 测试内容 预期结果
1 ADC输入1.5V直流 ADC输出约0x80
2 SRAM写0x55, 0xAA 读出相同数据
3 VGA显示棋盘格 屏幕显示清晰网格
4 输入1kHz正弦波 屏幕显示稳定正弦波

嗯,这个项目做完,基本上FPGA开发的几个核心技能就都练到了。ADC接口、SRAM控制、VGA时序、按键处理、状态机设计——这些在后续的项目里都会反复用到。所以,别急着跳过去,多花点时间把这个项目吃透。

下一章我们会做一个更复杂的项目——以太网数据包抓取与分析。到时候会用到千兆以太网接口,挑战更大,但收获也更多。