FPGA其实不难!做了这个示波器之后,我好像真学会了……
2025-06-30 09:50:08阅读量:1084
这是一个双通道数字示波器!

买一个不是更香?为啥要自己DIY?
事情是这样的。
我很想尝试自己DIY一个性能不错的示波器
顺便巩固学习一下——模拟电路设计、示波器知识、verilog程序、单片机程序、各种通信协议、屏幕与菜单等等……好提升自己的综合能力!
正好看到立创·逻辑派G1……那就开搞!
演示一下

下面,将分享项目的系统框图分析、软硬件设计说明、调试说明。同时,文末也会附上成本说明、开源网址。

AD模拟电路包括两个信号输入通道,由两路相同的信号调理电路进行处理,接入AD9288 ADC的双通道中,由FPGA进行读取。DA输出也由FPGA实现DDS产生可控波形输出,通过简单的调理电路输出。
项目采用了异构的主控,ADC、DAC与LCD显示由FPGA处理,充分发挥并行接口与逻辑电路的性能。对于电路的程控以及显示菜单、控件、事件的处理,为避免在FPGA布置大量复杂的处理逻辑,转由MCU进行处理,二者通过SPI协议进行通信。
底板_电源 原理图

底板_ADC模拟电路 原理图

底板_DAC模拟电路 原理图
底板_控制电路 原理图
底板_核心板 原理图
底板_PCB图
底板_PCB 3D图
顶板原理图
顶板PCB图

VDD_3.3V数字电源采用TPS82130 DCDC电路产生,满足较大的电流供给LCD、ADDA芯片等等;
VEE_4.5V为负压电源,用于各个运放的负电源轨供电,采用TPS5430 DCDC降压也是为了满足大电流的需求,若采用一般的电荷泵或LDO难以满足AD603等运放的供电;
VCC_4.5V与VCC_3.3V由低噪声LDO产生,减少电源纹波,用于模拟电源轨供电

模拟前端电路基本借鉴OSCFUN开源示波器的模拟电路,结合详细的设计教程,很适合新手学习示波器知识。
输入使用固态继电器控制交直流耦合,接着信号继电器选择x1 x10两路衰减,用于实现各个电压档位,衰减的同时满足1M高阻输入与阻抗匹配。后接一级跟随器,进入AD603实现的压控增益(VCA)电路,以实现精确的各档位增益控制,不采用模拟开关芯片避免阻抗的影响。放大输出后进入ADA4932差分放大器,作为AD9288的驱动。

3PD5651为电流输出型DAC,输出为差分信号,通过一级差分转单端的放大电路,后接跟随器,使用RC低通滤波滤除高频噪声,也可选择设计LC等更高阶的滤波器。
屏幕与8个按键放到了另一块PCB上,作为顶面面板,使用34pin同向fpc排线连接信号线,并用贴片螺母与螺丝螺柱固定上下PCB,屏幕为4.0寸无触摸LCD,tb链接:https://t.doruo.cn/1Nc3GiYN2 型号为ST7796-IPS显示屏(不带触摸)
底板上采用一片MCP4728产生四通道电压输出,并结合基准源与运放搬移电压,以此产生ADC前端双路的压控放大控制、电压偏置控制。控制电路的运放选择比较宽松,能有效处理直流信号不振荡即可。运放与基准源等的精度无需多虑,可以软件校准。
FPGA程序

AD9288输出的是补码,在每个时钟上升沿读取输出数据并转换为偏移码
wire [7:0] DIN_B = $unsigned(AD9288_DIN_B + 8'd127);
always @(posedge clk) begin
if (!rst_n) begin
AD9288_DOUT_A <= 0;
end
else begin
AD9288_DOUT_A <= DIN_A;
end
end
由于实测发现读取的数据存在较大噪声与毛刺,后级接入简单的滑动均值滤波,可根据需要替换为更优的滤波方案
wire [11:0] sum = data_reg[0] + data_reg[1] + data_reg[2] + data_reg[3] + data_reg[4] + data_reg[5] + data_reg[6] + data_reg[7];
assign dout = sum >> AVE_DATA_BIT; //右移3 等效为÷8
信号经过一个比较器后生成脉冲,捕获其上下边沿产生触发信号。
wire trig_pulse = trig_edge ? ((ad_data_r >= trig_level) && ((trig_ch ? ad_data_b : ad_data_a) < trig_level)) : ((ad_data_r <= trig_level) && ((trig_ch ? ad_data_b : ad_data_a) > trig_level)) ;
在数字波形存储的状态机中,先往环形ram预先存入一半深度的信号数据,等待触发,触发成功后继续存储直到达指定存储深度。
1:begin
if(deci_valid) begin
write_enable<=1;
write_addr<=(write_addr+1'b1)&14'h3FFF;
write_data_a<=ad_data_a; write_data_b<=ad_data_b;
ad_cnt<=ad_cnt+1;
state<=(ad_cnt>=8191)?(state+1'b1):state;
end
else begin
state<=state;
ad_cnt<=ad_cnt;
write_addr<=write_addr;
end
end
2:begin
if(auto_trig||trig_flag)begin
write_enable <= 1;
write_addr <= (write_addr+1'b1)&14'h3FFF;
trig_pos <= (write_addr+1'b1)&14'h3FFF;
ad_cnt <= ad_cnt+1;
write_data_a<=ad_data_a; write_data_b<=ad_data_b;
state <= state+1;
if(trig_mode==2) trig_ok<=1;
else trig_ok<=0; end
else begin
state<=state;
if(deci_valid) begin
write_enable<=1;
write_addr<=(write_addr+1'b1)&14'h3FFF;
ad_cnt<=8191;
write_data_a<=ad_data_a; write_data_b<=ad_data_b;
end else begin
state<=state;
ad_cnt<=ad_cnt;
write_addr<=write_addr;
end
end
end
对信号周期脉冲采用常见的等精度测量方案测量频率
在FPGA中布置了一组16位寄存器,长度可根据实际需要修改,编写一个SPI收发模块,用于接收MCU通过SPI总线发送的修改与访问寄存器指令与数据,根据寄存器地址将收到的数据写入寄存器中,单片机读取寄存器数据也同理
RECEIVE_ADDR: begin
if (bit_count < 8) begin
if(sck_pos)begin
// 接收地址的每一位
address[7-bit_count] <= MOSI;
bit_count <= bit_count + 1;
end
end
else begin
// 根据WR信号选择操作
if (WR) begin // WR高电平为写操作
current_state <= WRITE_DATA;
bit_count <= 0;
end
else begin // WR低电平为读操作
current_state <= SEND_DATA;
if(address < 8'd16) data_to_read <= regs[address[4:0]]; // 使用地址的低5位
else if(address == 8'd32) data_to_read <= key_reg;
bit_count <= 0;
end
end
end
各个寄存器的值将会经过译码,生成各路控制线与数据线,控制全局模块的各个功能
/****耦合方式****/
assign acdc = regs[0][0];
assign dis_acdc_buff = acdc?"AC":"DC";
/****触发边沿****/
assign trig_edge = regs[1][2];
assign dis_trig_edge_buff = trig_edge?"NEG":"POS";
/****触发模式****/
assign trig_mode = regs[1][1:0];
assign dis_trig_mode_buff = (trig_mode==2'b0)?"AUTO ":((trig_mode==2'b01)?"NORMAL":"SINGLE");
/****触发电平****/
assign trig_level = regs[6][7:0];
wire [3:0] trig_level_unit;
wire [3:0] trig_level_ten;
wire [3:0] trig_level_hun;
wire [8:0] trig_level_1=trig_level>>2;
bcd_8421 bcd_8421_1(
.sys_clk(clk),
.sys_rst_n(rst_n),
.data(trig_level_1),
.unit(trig_level_unit),
.ten(trig_level_ten),
.hun(trig_level_hun)
);
assign dis_trig_level_buff = {4'd0,trig_level_hun,4'd0,trig_level_ten,4'd0,trig_level_unit};
寄存器表定义:

屏幕的显示逻辑较为复杂,使用了一个庞大的状态机,主状态机为整个刷新周期的循环。里面又分成一个个小状态机例如屏幕初始化、波形绘制、字符显示等等,他们又会调用更底层的画点、刷屏状态机,用高级编程语言的角度来看就是一个个函数,只不过放在verilog里面需要用状态跳转来实现复杂的逻辑。
SCAN:begin
case(cnt_scan)
5'd0: begin ram_addr<=trig_pos_minus_step;; cnt_scan <= cnt_scan + 1'b1; end // 使用预计算地址
5'd1: begin ram_en <= HIGH; cnt_scan <= cnt_scan + 1'b1;end
5'd2: begin ram_addr<=ram_addr_next; cnt_scan <= cnt_scan + 1'b1; end// RAM时钟使能
5'd3: begin
ram_data_r1 <= (255-ram_data_a);
ram_data_r2 <= (255-ram_data_b);
cnt_scan <= cnt_scan + 1'b1;
end
5'd4: begin
if(y_cnt == 256) begin
y_cnt <= 0;
if(x_cnt==400) begin x_cnt<=0; cnt_scan<=cnt_scan+1'b1; end// 如果是最后一行就跳出循环
else begin
x_cnt <= x_cnt + 1'b1;
cnt_scan <= 5'd2; // 提早返回到地址更新,形成流水线
end
end else begin
if(ram_data_r1==y_cnt)begin
current_y1 <= ram_data_r1;
prev_y1 <= current_y1;
end
if(ram_data_r2==y_cnt)begin
current_y2 <= ram_data_r2;
prev_y2 <= current_y2;
end
y_cnt <= y_cnt + 1'b1;
y <= y_cnt;
x <= ((ram_data_r1==y_cnt || ram_data_r2==y_cnt) && (x_cnt!=400))?x_cnt+1:x_cnt;
if(trig_flag) color <= ORANGE;
else if((ram_data_r1==y_cnt)||line_flag1) color <= YELLOW;
else if((ram_data_r2==y_cnt)||line_flag2) color <= BLUE;
else if(cor_flag) color <= WHITE;
else if(cor_flag1) color <= GREY;
else color <= color_b;
state <= POINT; // 跳转至WRITE状态
state_backback <= SCAN; // 执行完WRITE及DELAY操作后返回SCAN状态
end
end
5'd5: begin cnt_scan <= 0; state <= MAIN; ram_en <= LOW; end
default: state <= IDLE;
endcase
end
UI界面设计:
DDS调用了高云的DDS_II IP核,其实这个IP核和XILINX的DDS IP核一模一样,熟悉XILINX的人马上就能直接使用起来。DDS根据32位频率控制字会产生指定频率的正弦波,将其通过一个比较器就得到了方波。同时dds还支持输出相位字,其波形是一个锯齿波,经过整流后可以生成三角波,这就形成了四个常见波形。
reg signed [9:0] square_wave; // 方波输出
// 方波生成
always @(posedge clk) begin
if (dds_phase[31:16] < 16'h8000) // 比较相位累加器的高 16 位
square_wave <= 10'd511; // 高电平(满量程)
else
square_wave <= -10'd512; // 低电平(满量程)
end
/*****************************************************************************/
wire signed [9:0] dds_phase_2 = dds_phase>>>22;
reg signed [9:0] triangle_wave; // 10 位三角波输出
// 三角波生成
always @(posedge clk) begin
if (dds_phase_2 >= 0) begin
triangle_wave <= (dds_phase_2 - 12'd256)<<<1; // 映射到 -512 到 +511
end else begin
triangle_wave <= (12'd768 - dds_phase_2)<<<1; // 映射到 -512 到 +511
end
使用移位加法可以控制输出的波形的幅度衰竭
reg signed [9:0] temp_add1, temp_add2;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
temp_add1 <= 10'd0;
temp_add2 <= 10'd0;
end else begin
temp_add1 <= (wave_reg >>> 1) + (wave_reg >>> 2);
temp_add2 <= (wave_reg >>> 3);
end
end
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
DAC_out_r <= 10'd0;
else
case (attenuation_sel)
8'd0: DAC_out_r <= wave_reg;
8'd1: DAC_out_r <= temp_add1 + temp_add2;
8'd2: DAC_out_r <= temp_add1;
8'd3: DAC_out_r <= (wave_reg >>> 1) + (wave_reg >>> 3);
8'd4: DAC_out_r <= wave_reg >>> 1;
8'd5: DAC_out_r <= (wave_reg >>> 2) + (wave_reg >>> 3);
8'd6: DAC_out_r <= wave_reg >>> 2;
8'd7: DAC_out_r <= wave_reg >>> 3;
default: DAC_out_r <= wave_reg;
endcase
end
MCU程序

使用GD32的硬件SPI读写FPGA的内部寄存器,可以尽可能减少响应时间。
// 写寄存器函数
void SPI_WriteRegister(uint8_t address, uint16_t data) {
// 组合发送数据:地址 + 高8位 + 低8位
uint8_t tx_buf[3] = {address, (data >> 8) & 0xFF, data & 0xFF};
gpio_bit_reset(SPI_GPIO_PORT, SPI_PIN_CS);
gpio_bit_set(SPI_GPIO_PORT, SPI_PIN_WR);// 设置WR为写模式(高电平)
for (int i = 0; i < 3; i++) {
while (spi_i2s_flag_get(SPI_INSTANCE, SPI_FLAG_TBE) == RESET);
spi_i2s_data_transmit(SPI_INSTANCE, tx_buf[i]);
while (spi_i2s_flag_get(SPI_INSTANCE, SPI_STAT_RBNE) == RESET);
spi_i2s_data_receive(SPI_INSTANCE);
}
gpio_bit_set(SPI_GPIO_PORT, SPI_PIN_CS);
gpio_bit_reset(SPI_GPIO_PORT, SPI_PIN_WR);
}
// 读寄存器函数
uint16_t SPI_ReadRegister(uint8_t address) {
uint8_t tx_buf[3] = {address, 0xFF, 0xFF}; // 发送地址和填充字节
uint8_t rx_buf[3];
gpio_bit_reset(SPI_GPIO_PORT, SPI_PIN_CS);
// 设置WR为读模式(低电平)
gpio_bit_reset(SPI_GPIO_PORT, SPI_PIN_WR);
for (int i = 0; i < 3; i++) {
while (spi_i2s_flag_get(SPI_INSTANCE, SPI_FLAG_TBE) == RESET);
spi_i2s_data_transmit(SPI_INSTANCE, tx_buf[i]);
while (spi_i2s_flag_get(SPI_INSTANCE, SPI_STAT_RBNE) == RESET);
rx_buf[i] = spi_i2s_data_receive(SPI_INSTANCE);
}
gpio_bit_set(SPI_GPIO_PORT, SPI_PIN_CS);
// 组合返回的16位数据(高8位+低8位)
return ((uint16_t)rx_buf[1] << 8) | rx_buf[2];
}
由于逻辑派开发板设计,GD32引脚不足,所以编码器与各个按键连接到了fpga的引脚,然后在定时器中断处理中,通过上述的SPI接口读取FPGA存储按键电平的寄存器,并进行消抖、双击长按识别。
// 定时器中断服务程序
void TIMER2_IRQHandler(void) {
static bool A1=1,B1=1,A1_r=1,B1_r=1;
static bool A2=1,B2=1,A2_r=1,B2_r=1;
if (RESET != timer_interrupt_flag_get(TIMER2, TIMER_INT_FLAG_UP)) {
timer_interrupt_flag_clear(TIMER2, TIMER_INT_FLAG_UP);
uint16_t key_state = SPI_ReadRegister(32);
A1 = (key_state&0x0400)==0x0400; B1 = (key_state&0x0800)==0x0800;
A2 = (key_state&0x1000)==0x1000; B2 = (key_state&0x2000)==0x2000;
if(!A1 && A1_r && B1) EC11_flag1 = 2;//反传
A1_r = A1;
if(!B1 && B1_r && A1) EC11_flag1 = 1;//正转
B1_r = B1;
if(!A2 && A2_r && B2) EC11_flag2 = 2;
A2_r = A2;
if(!B2 && B2_r && A2) EC11_flag2 = 1;
B2_r = B2;
for(uint8_t i = 0;i < 10;i++)
{
key[i].key_status = (key_state >> i) & 0x1;
//此处是判断按键是否稳定
switch(key[i].click_status)
{
case 0://状态0:第一次按下
if(key[i].key_status == RESET)
{
key[i].click_status = 1;//跳转状态1
}
break;
case 1://状态1:电平已稳定
if(key[i].key_status == RESET)
{
key[i].click_status = 2;
key[i].click_time = 0; //计时器清零,准备调用
}
else
{
key[i].click_status = 0;
}
break;
.................................................
按键功能:
8个顶板按键:耦合方式、活跃通道切换、打开/关闭(通道)、光标模式、自动触发、正常触发、单次触发、暂停/启动
编码器1:切换控件、切换DAC选择位数
编码器2:选项切换、数值增减
编码器1按键:进入与退出
编码器2按键:切换粗调细调
将每个可操控的控件都定义一个任务函数,main主循环将依次访问各控件任务,执行事件响应
int main(void)
{
systick_config();
usart_init();
printf("helloworld\n");
bsp_spi_init();
timer2_init();
I2C_Init();
ctrl_init();
for(int i=0;i<17;i++){
update_reg(i);
}
update_reg(20);
update_ch_gear();
update_reg(6);
while(1){
object_task();
}
}
void TRIG_CH_task(){
if(EC11_flag1 == 2){
EC11_flag1=0;
object++; update_reg(14);
}else if(EC11_flag1 == 1){
EC11_flag1=0;
object=11; update_reg(14);
}else if(EC11_flag2 == 1){
EC11_flag2=0;
trig_ch=!trig_ch;
update_reg(1);
}else if(EC11_flag2 == 2){
EC11_flag2=0;
trig_ch=!trig_ch;
update_reg(1);
}else if(key[8].signed_flag==1){
key[8].signed_flag=0;
}
else if(key[9].signed_flag==1){
key[9].signed_flag=0;
}
}
1.注意原理图标注的部分,有些地方是不用焊接的。
2.上下板直接要使用螺柱与贴片螺母连接,需要自备合适高度的螺柱、螺丝等,实测上下板需要有25mm的高度。
3.编码器在3D图中是直插的,实际上需要使用侧插的编码器。
4.原理图BOM不一定准确,建议根据器件型号和值来选。
5.上下板连接的排线选择34P-0.5mm-同向,如果买的是5cm或者更长,需要卷曲一下不然会翘出去。
6.各个电压量程的实现需要同时进行继电器控制衰竭档位切换与改变MCP4728输出AD603增益控制电压,校准信息在GD32的程序
typedef struct
{
uint16_t dac_poff;//通道增益控制字
uint16_t vertical_offset;//通道垂直基线偏移
bool gain_sel;//衰减选择
}Calibration;
//对应10个档位5 10 20 50 100 200 500 1000 2000 5000 mV
Calibration ch1_cali[10]={
{600,2330,1}, //5mV
{900,2435,1}, //10mV
{1300,2485,1},//20mV
{1850,2520,1},//50mV
{2270,2530,1},//100mV
{2675,2530,1},//200mV
{1425,2530,0},//500mV
{1840,2535,0},//1V
{2270,2540,0},//2V
{2740,2535,0},//5V
};
校准方法:
信号源输出直流0V,改变第二个参数即电压偏移,使0V的波形直线归零到正中央,偏移电压即可确定。
再让信号源输出一定的电压,例如1V档位时输入3V电压,改变第一个参数即放大增益,时波形直线落在3V的坐标线上,那么增益也确定下来。
MCP4728的控制字范围是0-4095。第三个参数是继电器控制x1 x10档位的,一般情况不用改。
7.板上有四个可调电容,用于调节示波器通道的阻抗匹配,当出现同一增益下直流量与交流量放大倍数不一致的时候,可以尝试调节电容,这将会改变交流信号的幅度与相位;或者当输入的方波失真,可进行调节电容校准。
8.由于设计思路改变以及代码的一些时序问题,部分预期的功能并没有实际实现,例如cursor测量功能、峰峰值测量、通道开关等等,但是也留了一些占位槽。
9.时序报告与资源用量:
逻辑派开发板138+AD9288芯片12+3PD5651芯片6+AD603芯片4.5x2+ADA4932芯片12x2+tps82130芯片6.5+AD8065芯片6x2+继电器共10+MCP4728芯片8.6+BNC接头3.5x3+4.0寸LCD屏幕37+剩余的各种阻容芯片和接线座50。
粗略计算的最终成本在300以上
可根据自己需求降本增效
本项目已开源!
——想复刻?想给作者点赞?可复制开源网址 前往原文。
https://oshwhub.com/greentor/logicpi-dual-channel-digital-osc

AM26C31IDR/缓冲器/驱动器/收发器 | 0.73 | |
INA180A2IDBVR/电流感应放大器 | 0.4313 | |
SN65176BDR/RS-485/RS-422芯片 | 0.4352 | |
SN65HVD232DR/CAN收发器 | 2.45 | |
TPA3110D2PWPR/音频功率放大器 | 2.5 | |
TMP112AIDRLR/温度传感器 | 0.9818 | |
ADS1256IDBR/模数转换芯片ADC | 30.15 | |
REF5050AIDR/电压基准芯片 | 3.01 | |
XTR111AIDGQR/ADC/DAC-专用型 | 1.75 | |
OPA2277UA/2K5/精密运放 | 2.03 |