我的订单购物车(0)联系客服 帮助中心供应商合作 嘉立创产业服务群
领券中心备货找料立推专区爆款推荐PLUS会员BOM配单 品牌库新人福利工业品面板定制

FPGA其实不难!做了这个示波器之后,我好像真学会了……

2025-06-30 09:50:08阅读量:1084

这是一个双通道数字示波器

双通道数字示波器

买一个不是更香?为啥要自己DIY?

事情是这样的。

我很想尝试自己DIY一个性能不错的示波器

顺便巩固学习一下——模拟电路设计、示波器知识、verilog程序、单片机程序、各种通信协议、屏幕与菜单等等……好提升自己的综合能力!

正好看到立创·逻辑派G1……那就开搞!

 

01
项目特点
 

项目特点

演示一下

示波器演示

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

 

 

02
系统框图
 
系统框图

AD模拟电路包括两个信号输入通道,由两路相同的信号调理电路进行处理,接入AD9288 ADC的双通道中,由FPGA进行读取。DA输出也由FPGA实现DDS产生可控波形输出,通过简单的调理电路输出。

项目采用了异构的主控,ADC、DAC与LCD显示由FPGA处理,充分发挥并行接口与逻辑电路的性能。对于电路的程控以及显示菜单、控件、事件的处理,为避免在FPGA布置大量复杂的处理逻辑,转由MCU进行处理,二者通过SPI协议进行通信。

 

02
电路设计
 

底板_电源 原理图

底板_电源 原理图

 

底板_ADC模拟电路 原理图

底板_ADC模拟电路 原理图

底板_DAC模拟电路 原理图

底板_DAC模拟电路 原理图

底板_控制电路 原理图

底板_控制电路 原理图

底板_核心板 原理图

底板_核心板 原理图

底板_PCB图

底板_PCB图

底板_PCB 3D图

底板_PCB 3D图

 

顶板原理图

顶板原理图

 

顶板PCB图

顶板PCB图

01 
电源
 
电源

VDD_3.3V数字电源采用TPS82130 DCDC电路产生,满足较大的电流供给LCD、ADDA芯片等等;

VEE_4.5V为负压电源,用于各个运放的负电源轨供电,采用TPS5430 DCDC降压也是为了满足大电流的需求,若采用一般的电荷泵或LDO难以满足AD603等运放的供电;

VCC_4.5V与VCC_3.3V由低噪声LDO产生,减少电源纹波,用于模拟电源轨供电

 

02 
模拟输入电路
 
模拟输入电路

模拟前端电路基本借鉴OSCFUN开源示波器的模拟电路,结合详细的设计教程,很适合新手学习示波器知识。

输入使用固态继电器控制交直流耦合,接着信号继电器选择x1 x10两路衰减,用于实现各个电压档位,衰减的同时满足1M高阻输入与阻抗匹配。后接一级跟随器,进入AD603实现的压控增益(VCA)电路,以实现精确的各档位增益控制,不采用模拟开关芯片避免阻抗的影响。放大输出后进入ADA4932差分放大器,作为AD9288的驱动。

 

03 
模拟输出电路
 
模拟输出电路

3PD5651为电流输出型DAC,输出为差分信号,通过一级差分转单端的放大电路,后接跟随器,使用RC低通滤波滤除高频噪声,也可选择设计LC等更高阶的滤波器。

 

04 
其他电路
 

屏幕与8个按键放到了另一块PCB上,作为顶面面板,使用34pin同向fpc排线连接信号线,并用贴片螺母与螺丝螺柱固定上下PCB,屏幕为4.0寸无触摸LCD,tb链接:https://t.doruo.cn/1Nc3GiYN2 型号为ST7796-IPS显示屏(不带触摸)

底板上采用一片MCP4728产生四通道电压输出,并结合基准源与运放搬移电压,以此产生ADC前端双路的压控放大控制、电压偏置控制。控制电路的运放选择比较宽松,能有效处理直流信号不振荡即可。运放与基准源等的精度无需多虑,可以软件校准。

 

 

03
程序设计
 

 

FPGA程序

FPGA程序
01 
ADC输入、滤波
 

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;          endend

由于实测发现读取的数据存在较大噪声与毛刺,后级接入简单的滑动均值滤波,可根据需要替换为更优的滤波方案

  •  
  •  
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

 

02 
波形存储与触发
 

信号经过一个比较器后生成脉冲,捕获其上下边沿产生触发信号。

  •  
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:beginif(deci_valid) beginwrite_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;endelse beginstate<=state;ad_cnt<=ad_cnt;write_addr<=write_addr;end		  	end	   		2:beginif(auto_trig||trig_flag)beginwrite_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; endelse beginstate<=state;if(deci_valid) beginwrite_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 beginstate<=state;ad_cnt<=ad_cnt;write_addr<=write_addr;endendend	  	

 

03 
频率计
 

对信号周期脉冲采用常见的等精度测量方案测量频率

 

04 
MCU通信
 

在FPGA中布置了一组16位寄存器,长度可根据实际需要修改,编写一个SPI收发模块,用于接收MCU通过SPI总线发送的修改与访问寄存器指令与数据,根据寄存器地址将收到的数据写入寄存器中,单片机读取寄存器数据也同理

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
RECEIVE_ADDR: begin                    if (bit_count < 8begin                       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};   

寄存器表定义:

寄存器表定义

 

05 
LCD显示
 

屏幕的显示逻辑较为复杂,使用了一个庞大的状态机,主状态机为整个刷新周期的循环。里面又分成一个个小状态机例如屏幕初始化、波形绘制、字符显示等等,他们又会调用更底层的画点、刷屏状态机,用高级编程语言的角度来看就是一个个函数,只不过放在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界面设计:
UI界面设计

 

06 
DDS与DAC
 

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);    endend
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;        endcaseend

 

MCU程序

MCU程序
01 
FPGA通信
 

使用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, 0xFF0xFF}; // 发送地址和填充字节    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];}

 

 

02 
事件响应
 

由于逻辑派开发板设计,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按键:切换粗调细调

 

03 
菜单控件处理
 

将每个可操控的控件都定义一个任务函数,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;
}}

 

 

04
制作调试与其他
 

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 mVCalibration 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.时序报告与资源用量:

时序报告与资源用量

05
成本说明
 

逻辑派开发板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以上

可根据自己需求降本增效

 

06
开源网址
 

本项目已开源!

——想复刻想给作者点赞复制开源网址 前往原文。

开源网址:

https://oshwhub.com/greentor/logicpi-dual-channel-digital-osc