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
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
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
28.6 系统集成与调试
把所有模块拼在一起后,调试是最磨人的阶段。我总结了几条经验:
- 先调ADC:给一个已知的直流电压,看看ADC读出来的值对不对
- 再调SRAM:写一组固定数据进去,读出来对比,确认读写没问题
- 然后调VGA:先显示一个静态的测试图案,比如棋盘格
- 最后联调:把ADC的数据存到SRAM,再显示到VGA上
我记得第一次联调成功时,屏幕上出现了一个正弦波,虽然有点毛刺,但那种成就感真的难以形容。后来加了简单的数字滤波,波形就漂亮多了。
| 调试步骤 | 测试内容 | 预期结果 |
|---|---|---|
| 1 | ADC输入1.5V直流 | ADC输出约0x80 |
| 2 | SRAM写0x55, 0xAA | 读出相同数据 |
| 3 | VGA显示棋盘格 | 屏幕显示清晰网格 |
| 4 | 输入1kHz正弦波 | 屏幕显示稳定正弦波 |
嗯,这个项目做完,基本上FPGA开发的几个核心技能就都练到了。ADC接口、SRAM控制、VGA时序、按键处理、状态机设计——这些在后续的项目里都会反复用到。所以,别急着跳过去,多花点时间把这个项目吃透。
下一章我们会做一个更复杂的项目——以太网数据包抓取与分析。到时候会用到千兆以太网接口,挑战更大,但收获也更多。