一、项目介绍
此项目是基于Lattice XO2-4000HC FPGA完成的一个数字电压表,具体功能如下:
1、旋转电位计可以产生0-3.3V的电压
2、利用板上的串行ADC对电压进行转换
3、将电压值在板上的OLED屏幕上显示出来
4、将电压值在板上的八段数码管上显示出来
5、通过LED灯点亮数量来反映电压大小
前三项为项目要求内容,后两项为自主发挥部分。
二、元件分析与系统级设计
此次采用了STEP-MXO2小脚丫的FPGA模块与综合技能训练板,其中板载了许多的资源。其中OLED与ADC就是本项目所需要的功能模块。
图1:小脚丫FPGA功能图
此外,核心板自身带有两个八段数码管和8个LED灯,符合拓展部分的需要。
按功能划分模块有顶层模块,ADC采集模块,OLED显示模块,数码管显示模块,LED显示模块。
图2:系统级设计
三、ADC模块&数据处理
ADC采用的ADS7868元件,8位 280KSPS 串行 ADC,采用SPI通讯协议。
对于SPI采用mode3 , CPHA=1 , CPOL=1
图3:SPI模式3时序
图4:ADS7868工作时序
由SPI时序和ADC工作时序可知,SCLK空闲时刻为高,先使能ADC,后SCLK开始变化,前三个SCLK上升沿时信息为无用信息,从第四个上升沿开始(包含)连续八个时钟接收八位数据,高位在前低位在后。从第十二个上升沿开始为无用信息,直到第十六个SCLK时钟,完成一个工作周期,SCLK置高后CS置高。
按设计,需要手动通过状态机切换来配置ADC时钟,会造成实际ADC时钟频率只有模块输入时钟1/2。本身的12M系统时钟得到的6M ADC时钟无法满足ADC工作要求。所以需要通过PLL进行倍频得到24M的模块输入时钟。
采集完成后得到0x00-0xFF的数字量结果,对应输入模拟电压值为0-3.3。要使用介于0-3.3V的数据前需要先对0-255的结果进行放缩处理。因为FPGA与verilog本身对小数的计算能力较弱且需要占用大量资源,所以选择将0-255放大至0-33000(乘129)。且因为分辨率有限,使用时仅取00000-33000的前三位且手动标注小数点即可。
此外,0-33000是以四位十六进制存储的,无法按十进制取前几位,且数码管和OLED需要按单个字符进行显示。所以需要将四位16进制数转化为五位BCD码。这里通过移位和进位进行完成,避免了使用除法。
/**********************************************************
模块名:ADC_spi_DRIVER
更新日期:2021.8.30
功能:
驱动基于SPI的8位串行模数转换器 ADS7868
将捕获数字值量转换回电压值(HEX) 0~255 -> 0-33000
将十六进制电压转换结果转换为BCD形式并输出
*部分借鉴于电子森林中的开源教程*
**********************************************************/
module ADC_spi_DRIVER
(
input clk_24M, //需要一个更高频时钟 12M->24M
input sys_rst_n, //系统复位 低有效
input adc_dat, //SPI总线SDA
output reg adc_cs, //SPI总线CS
output reg adc_clk, //SPI总线SCK
output reg [7:0] adc_data, //ADC采样数据 0~255
output reg [15:0] vol_result, //四位十六进制温度结果
output reg [19:0] vol_bcd //五位BCD码 温度结果
);
parameter HIGH = 1'b1;
parameter LOW = 1'b0;
reg adc_done;
reg [7:0] cnt; //计数器
//状态机计数
always @(posedge clk_24M or negedge sys_rst_n) begin
if(!sys_rst_n)
cnt <= 1'b0;
else if(cnt >= 8'd34)
cnt <= 1'b0;
else
cnt <= cnt + 1'b1;
end
reg [7:0] data; //临时存放捕获的数据
//根据spi协议传输数据 采用模式三
//即空闲高电平 第二边沿开始采样(上升沿采样)
always @(posedge clk_24M or negedge sys_rst_n) begin
if(!sys_rst_n) begin
adc_cs <= HIGH; adc_clk <= HIGH;
end
else
case(cnt)
8'd0 : begin adc_cs <= HIGH; adc_clk <= HIGH; end
8'd1 : begin adc_cs <= LOW; adc_clk <= HIGH; end //先使能ADC 后开始时钟变化
8'd2,8'd4,8'd6,8'd8,8'd10,8'd12,8'd14,8'd16,
8'd18,8'd20,8'd22,8'd24,8'd26,8'd28,8'd30,8'd32:
begin adc_cs <= LOW; adc_clk <= LOW; end //采样等工作在上升沿 所以下降沿只需给时钟即可
//进入16个时钟的ADC周期
8'd3 : begin adc_cs <= LOW; adc_clk <= HIGH; end //0
8'd5 : begin adc_cs <= LOW; adc_clk <= HIGH; end //1
8'd7 : begin adc_cs <= LOW; adc_clk <= HIGH; end //2
8'd9 : begin adc_cs <= LOW; adc_clk <= HIGH; data[7] <= adc_dat; end //3 开始取值高位优先
8'd11 : begin adc_cs <= LOW; adc_clk <= HIGH; data[6] <= adc_dat; end //4
8'd13 : begin adc_cs <= LOW; adc_clk <= HIGH; data[5] <= adc_dat; end //5
8'd15 : begin adc_cs <= LOW; adc_clk <= HIGH; data[4] <= adc_dat; end //6
8'd17 : begin adc_cs <= LOW; adc_clk <= HIGH; data[3] <= adc_dat; end //7
8'd19 : begin adc_cs <= LOW; adc_clk <= HIGH; data[2] <= adc_dat; end //8
8'd21 : begin adc_cs <= LOW; adc_clk <= HIGH; data[1] <= adc_dat; end //9
8'd23 : begin adc_cs <= LOW; adc_clk <= HIGH; data[0] <= adc_dat; end //10 此时8位结果均获取完毕
8'd25 : begin adc_cs <= LOW; adc_clk <= HIGH; adc_data <= data; end //11 统一传输给adc_data变量 可以保证adc_data是一组完整的数据
8'd27 : begin adc_cs <= LOW; adc_clk <= HIGH; adc_done <= HIGH; end //12 给出一个信号 通知其他单元一次采样已完成 可以进行后续转换
8'd29 : begin adc_cs <= LOW; adc_clk <= HIGH; adc_done <= LOW; end //13
8'd31 : begin adc_cs <= LOW; adc_clk <= HIGH; end //14
8'd33 : begin adc_cs <= LOW; adc_clk <= HIGH; end //15
8'd34 : begin adc_cs <= HIGH; adc_clk <= HIGH; end //关闭使能
default : begin adc_cs <= HIGH; adc_clk <= HIGH; end
endcase
end
//转换为电压结果 0~255 -> 0~33000
always @(negedge adc_done) begin
vol_result <= adc_data * 16'd129;
end
reg [35:0] shift_reg; //计算用的临时变量
//将四位十六进制电压结果转换为五位BCD码
always@(vol_result or sys_rst_n)begin //当vol_result更新时
shift_reg = {20'h0,vol_result};
if(!sys_rst_n)
vol_bcd = 0;
else begin
repeat(16) begin //循环16次
//BCD码各位数据作满5加3
if (shift_reg[19:16] >= 5) shift_reg[19:16] = shift_reg[19:16] + 2'b11; //用位移+进位来代替除法运算
if (shift_reg[23:20] >= 5) shift_reg[23:20] = shift_reg[23:20] + 2'b11; //要保证一步步运算 使用阻塞赋值
if (shift_reg[27:24] >= 5) shift_reg[27:24] = shift_reg[27:24] + 2'b11;
if (shift_reg[31:28] >= 5) shift_reg[31:28] = shift_reg[31:28] + 2'b11;
if (shift_reg[35:32] >= 5) shift_reg[35:32] = shift_reg[35:32] + 2'b11;
shift_reg = shift_reg << 1;
end
vol_bcd = shift_reg[35:16]; //取出最终有用的部分 赋值给输出的reg
end
end
endmodule
四、OLED显示模块
OLED采用SSD1306协议,采用显存显示的方式。简单的理解方式就是屏幕上128x32个像素点对应显存中128x32个单元位。按一定顺序将点阵信息更新到显存的对应单元中,oled便会按显存的信息来更新屏幕。而复杂的地方在于对于页、段、列,和换行方式等,需要将显示数据和设置命令“配套“使用,且这个状态机编写起来逻辑极为复杂,所以这部分我也借鉴了较多范例上的代码。
图5:SSD1306显存方式
/**********************************************************
模块名:OLED_spi_DRIVER
更新日期:2021.9.3
功能:
驱动基于SSD1306&SPI的128x32 OLED
使用8*8点阵字库,每行显示128/8=16个字符
将电压转换结果通过OLED显示出来
*部分借鉴于电子森林中的开源教程*
**********************************************************/
module OLED_spi_DRIVER
(
input sys_clk, //系统时钟
input sys_rst_n, //系统复位 低有效
input [11:0] oled_vol_result,//要显示的电压值 仅保留前三位
output reg oled_csn, //OLCD液晶屏使能
output reg oled_rst, //OLCD液晶屏复位
output reg oled_dcn, //OLCD数据/指令使能
output reg oled_clk, //OLCD时钟信号
output reg oled_dat //OLCD传输信号
);
localparam INIT_DEPTH = 16'd25; //LCD初始化的命令的数量
localparam IDLE = 6'h1, MAIN = 6'h2, INIT = 6'h4, SCAN = 6'h8, WRITE = 6'h10, DELAY = 6'h20;
localparam HIGH = 1'b1, LOW = 1'b0; //cs线的使能
localparam DATA = 1'b1, CMD = 1'b0; //ds线使能
reg [7:0] cmd [24:0]; //命令集
reg [39:0] mem [122:0]; //字库
reg [7:0] y_p, x_ph, x_pl; //OLED RAM 页 行高 行低
reg [(8*21-1):0] char; //
reg [7:0] num, char_reg;
reg [4:0] cnt_main, cnt_init, cnt_scan, cnt_write;
reg [15:0] num_delay, cnt_delay, cnt;
reg [5:0] state, state_back; //用于操作间跳转和回跳
//组成一个用于显示的变量 8位二进制代表一个字符
//效果为 X.XXV (X代表任意数字)
wire [39:0] showhex;
assign showhex[39:36] = 1'b0; //电压个位
assign showhex[35:32] = oled_vol_result[11:8]; //除了对应的ASCII码 前十六位是对应显示的 即0x00-0x0F也能对应显示出0-F
assign showhex[31:24] = 8'd46; //小数点 . 46
assign showhex[23:20] = 1'b0; //电压小数点后一位
assign showhex[19:16] = oled_vol_result[7:4]; //
assign showhex[15:12] = 1'b0; //电压小数点后两位
assign showhex[11:8] = oled_vol_result[3:0];
assign showhex[7:0] = 8'd86; //字母 V 86
//OLED显示
always@(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n) begin
cnt_main <= 1'b0;
cnt_init <= 1'b0;
cnt_scan <= 1'b0;
cnt_write <= 1'b0;
y_p <= 1'b0;
x_ph <= 1'b0;
x_pl <= 1'b0;
num <= 1'b0;
char <= 1'b0;
char_reg <= 1'b0;
num_delay <= 16'd5;
cnt_delay <= 1'b0;
cnt <= 1'b0;
oled_csn <= HIGH;
oled_rst <= HIGH;
oled_dcn <= CMD;
oled_clk <= HIGH;
oled_dat <= LOW;
state <= IDLE;
state_back <= IDLE;
end
else begin
case(state)
IDLE:begin
cnt_main <= 1'b0;
cnt_init <= 1'b0;
cnt_scan <= 1'b0;
cnt_write <= 1'b0;
y_p <= 1'b0;
x_ph <= 1'b0;
x_pl <= 1'b0;
num <= 1'b0;
char <= 1'b0;
char_reg <= 1'b0;
num_delay <= 16'd5;
cnt_delay <= 1'b0;
cnt <= 1'b0;
oled_csn <= HIGH;
oled_rst <= HIGH;
oled_dcn <= CMD;
oled_clk <= HIGH;
oled_dat <= LOW;
state <= MAIN;
state_back <= MAIN;
end
MAIN:begin
if(cnt_main >= 5'd5)
cnt_main <= 5'd5;
else
cnt_main <= cnt_main + 1'b1;
case(cnt_main) //显示数字
5'd0: begin state <= INIT; end
5'd1: begin y_p <= 8'hb0; x_ph <= 8'h10; x_pl <= 8'h00; num <= 5'd16; char <= "voltage: "; state <= SCAN; end
5'd2: begin y_p <= 8'hb1; x_ph <= 8'h10; x_pl <= 8'h00; num <= 5'd16; char <= " "; state <= SCAN; end
5'd3: begin y_p <= 8'hb2; x_ph <= 8'h10; x_pl <= 8'h00; num <= 5'd16; char <= " "; state <= SCAN; end
5'd4: begin y_p <= 8'hb3; x_ph <= 8'h10; x_pl <= 8'h00; num <= 5'd16; char <= " Designed by WYD"; state <= SCAN; end
//保留状态机23 因为之前可能会有别的程序的残留显示 需要刷屏清空一下
5'd5: begin y_p <= 8'hb0; x_ph <= 8'h15; x_pl <= 8'h00; num <= 5'd5; char <= showhex ; state <= SCAN; end //D
default: state <= IDLE;
endcase
end
INIT:begin //初始化状态 6
//会先rst_oled置低 然后依次传送25条配置指令 返回MAIN
case(cnt_init)
5'd0: begin
oled_rst <= LOW;
cnt_init <= cnt_init + 1'b1;
end //复位有效
5'd1: begin
num_delay <= 16'd25000;
state <= DELAY;
state_back <= INIT;
cnt_init <= cnt_init + 1'b1;
end //延时大于3us
5'd2: begin
oled_rst <= HIGH;
cnt_init <= cnt_init + 1'b1;
end //复位恢复
5'd3: begin
num_delay <= 16'd25000;
state <= DELAY;
state_back <= INIT;
cnt_init <= cnt_init + 1'b1;
end //延时大于220us
5'd4: begin
if(cnt>=INIT_DEPTH) begin //当25条指令及数据发出后,配置完成
cnt <= 1'b0;
cnt_init <= cnt_init + 1'b1;
end
else begin
cnt <= cnt + 1'b1;
num_delay <= 16'd5;
oled_dcn <= CMD;
char_reg <= cmd[cnt];
state <= WRITE;
state_back <= INIT;
end
end
5'd5: begin
cnt_init <= 1'b0;
state <= MAIN;
end //初始化完成,返回MAIN状态
default: state <= IDLE;
endcase
end
SCAN:begin //刷屏状态,从RAM中读取数据刷屏 显示字符
if(cnt_scan == 5'd11) begin //num为这句话一共多少个字符
if(num)
cnt_scan <= 5'd3; //而对一句话而言只需要定位一遍
else //所以显示完一个字符后跳过定位接下一个字符
cnt_scan <= cnt_scan + 1'b1; //当所有字符显示完了(num=0) 会跳到cnt12
end
else if(cnt_scan == 5'd12) //cnt清零且回到MAIN
cnt_scan <= 1'b0;
else
cnt_scan <= cnt_scan + 1'b1;
case(cnt_scan)
5'd0: begin oled_dcn <= CMD; char_reg <= y_p; state <= WRITE; state_back <= SCAN; end //定位列页地址
5'd1: begin oled_dcn <= CMD; char_reg <= x_pl; state <= WRITE; state_back <= SCAN; end //定位行地址低位
5'd2: begin oled_dcn <= CMD; char_reg <= x_ph; state <= WRITE; state_back <= SCAN; end //定位行地址高位
5'd3: begin num <= num - 1'b1;end
5'd4: begin oled_dcn <= DATA; char_reg <= 8'h00; state <= WRITE; state_back <= SCAN; end //将5*8点阵编程8*8
5'd5: begin oled_dcn <= DATA; char_reg <= 8'h00; state <= WRITE; state_back <= SCAN; end //将5*8点阵编程8*8
5'd6: begin oled_dcn <= DATA; char_reg <= 8'h00; state <= WRITE; state_back <= SCAN; end //将5*8点阵编程8*8
5'd7: begin oled_dcn <= DATA; char_reg <= mem[char[(num*8)+:8]][39:32]; state <= WRITE; state_back <= SCAN; end
5'd8: begin oled_dcn <= DATA; char_reg <= mem[char[(num*8)+:8]][31:24]; state <= WRITE; state_back <= SCAN; end
5'd9: begin oled_dcn <= DATA; char_reg <= mem[char[(num*8)+:8]][23:16]; state <= WRITE; state_back <= SCAN; end
5'd10: begin oled_dcn <= DATA; char_reg <= mem[char[(num*8)+:8]][15: 8]; state <= WRITE; state_back <= SCAN; end
5'd11: begin oled_dcn <= DATA; char_reg <= mem[char[(num*8)+:8]][ 7: 0]; state <= WRITE; state_back <= SCAN; end
//变量[起始地址+:数据位宽] 等价于 变量[(起始地址+数据位宽-1):起始地址]
5'd12: begin state <= MAIN; end
default: state <= IDLE;
endcase
end
WRITE:begin //WRITE状态,将数据按照SPI时序发送给屏幕
if(cnt_write >= 5'd17)
cnt_write <= 1'b0;
else
cnt_write <= cnt_write + 1'b1;
case(cnt_write)
5'd0: begin oled_csn <= LOW; end //使能OLED
5'd1: begin oled_clk <= LOW; oled_dat <= char_reg[7]; end //先发高位数据
5'd2: begin oled_clk <= HIGH; end
5'd3: begin oled_clk <= LOW; oled_dat <= char_reg[6]; end
5'd4: begin oled_clk <= HIGH; end
5'd5: begin oled_clk <= LOW; oled_dat <= char_reg[5]; end
5'd6: begin oled_clk <= HIGH; end
5'd7: begin oled_clk <= LOW; oled_dat <= char_reg[4]; end
5'd8: begin oled_clk <= HIGH; end
5'd9: begin oled_clk <= LOW; oled_dat <= char_reg[3]; end
5'd10: begin oled_clk <= HIGH; end
5'd11: begin oled_clk <= LOW; oled_dat <= char_reg[2]; end
5'd12: begin oled_clk <= HIGH; end
5'd13: begin oled_clk <= LOW; oled_dat <= char_reg[1]; end
5'd14: begin oled_clk <= HIGH; end
5'd15: begin oled_clk <= LOW; oled_dat <= char_reg[0]; end //后发低位数据
5'd16: begin oled_clk <= HIGH; end
5'd17: begin oled_csn <= HIGH; state <= DELAY; end //
default: state <= IDLE;
endcase
end
DELAY:begin //延时状态
if(cnt_delay >= num_delay) begin
cnt_delay <= 16'd0; state <= state_back;
end
else
cnt_delay <= cnt_delay + 1'b1;
end
default: state <= IDLE;
endcase
end
end
//OLED配置指令数据
always@(posedge sys_rst_n) begin
cmd[ 0] = {8'hae}; //关闭显示
cmd[ 1] = {8'h00}; //设置行低四位
cmd[ 2] = {8'h10}; //设置行高四位
cmd[ 3] = {8'h00}; //???这四行不太懂了
cmd[ 4] = {8'hb0}; //设置页0
cmd[ 5] = {8'h81}; //设置对比度
cmd[ 6] = {8'hff}; //最大对比度
cmd[ 7] = {8'ha1}; //段重定义设置,bit0:0,0->0 ;1,0->127;
cmd[ 8] = {8'ha6};
cmd[ 9] = {8'ha8}; //设置驱动路数
cmd[10] = {8'h1f};
cmd[11] = {8'hc8}; //设置COM扫描方向;bit3:0,普通模式;1,重定义模式 COM[N-1]->COM0; N:驱动路数
cmd[12] = {8'hd3}; //设置显示偏移
cmd[13] = {8'h00}; //偏移默认为0
cmd[14] = {8'hd5}; //设置时钟分频因子
cmd[15] = {8'h80};
cmd[16] = {8'hd9}; //设置预充电周期
cmd[17] = {8'h1f}; //[3:0],PHASE 1;[7:4],PHASE 2;
cmd[18] = {8'hda}; //设置COM硬件引脚配置
cmd[19] = {8'h00}; //[5:4]配置
cmd[20] = {8'hdb}; //设置VCOMH 电压倍率
cmd[21] = {8'h40}; //[6:4] 000,0.65*vcc;001,0.77*vcc;011,0.83*vcc
cmd[22] = {8'h8d}; //电荷泵设置
cmd[23] = {8'h14}; //开启电荷泵
cmd[24] = {8'haf}; //开启显示
end
//5*8点阵字库数据 对应其十进制ASCII码
always@(posedge sys_rst_n)begin
mem[ 0] = {8'h3E, 8'h51, 8'h49, 8'h45, 8'h3E}; // 48 0
mem[ 1] = {8'h00, 8'h42, 8'h7F, 8'h40, 8'h00}; // 49 1
mem[ 2] = {8'h42, 8'h61, 8'h51, 8'h49, 8'h46}; // 50 2
mem[ 3] = {8'h21, 8'h41, 8'h45, 8'h4B, 8'h31}; // 51 3
mem[ 4] = {8'h18, 8'h14, 8'h12, 8'h7F, 8'h10}; // 52 4
mem[ 5] = {8'h27, 8'h45, 8'h45, 8'h45, 8'h39}; // 53 5
mem[ 6] = {8'h3C, 8'h4A, 8'h49, 8'h49, 8'h30}; // 54 6
mem[ 7] = {8'h01, 8'h71, 8'h09, 8'h05, 8'h03}; // 55 7
mem[ 8] = {8'h36, 8'h49, 8'h49, 8'h49, 8'h36}; // 56 8
mem[ 9] = {8'h06, 8'h49, 8'h49, 8'h29, 8'h1E}; // 57 9
mem[ 10] = {8'h7C, 8'h12, 8'h11, 8'h12, 8'h7C}; // 65 A
mem[ 11] = {8'h7F, 8'h49, 8'h49, 8'h49, 8'h36}; // 66 B
mem[ 12] = {8'h3E, 8'h41, 8'h41, 8'h41, 8'h22}; // 67 C
mem[ 13] = {8'h7F, 8'h41, 8'h41, 8'h22, 8'h1C}; // 68 D
mem[ 14] = {8'h7F, 8'h49, 8'h49, 8'h49, 8'h41}; // 69 E
mem[ 15] = {8'h7F, 8'h09, 8'h09, 8'h09, 8'h01}; // 70 F
mem[ 32] = {8'h00, 8'h00, 8'h00, 8'h00, 8'h00}; // 32 sp
mem[ 33] = {8'h00, 8'h00, 8'h2f, 8'h00, 8'h00}; // 33 !
mem[ 34] = {8'h00, 8'h07, 8'h00, 8'h07, 8'h00}; // 34
mem[ 35] = {8'h14, 8'h7f, 8'h14, 8'h7f, 8'h14}; // 35 #
mem[ 36] = {8'h24, 8'h2a, 8'h7f, 8'h2a, 8'h12}; // 36 $
mem[ 37] = {8'h62, 8'h64, 8'h08, 8'h13, 8'h23}; // 37 %
mem[ 38] = {8'h36, 8'h49, 8'h55, 8'h22, 8'h50}; // 38 &
mem[ 39] = {8'h00, 8'h05, 8'h03, 8'h00, 8'h00}; // 39 '
mem[ 40] = {8'h00, 8'h1c, 8'h22, 8'h41, 8'h00}; // 40 (
mem[ 41] = {8'h00, 8'h41, 8'h22, 8'h1c, 8'h00}; // 41 )
mem[ 42] = {8'h14, 8'h08, 8'h3E, 8'h08, 8'h14}; // 42 *
mem[ 43] = {8'h08, 8'h08, 8'h3E, 8'h08, 8'h08}; // 43 +
mem[ 44] = {8'h00, 8'h00, 8'hA0, 8'h60, 8'h00}; // 44 ,
mem[ 45] = {8'h08, 8'h08, 8'h08, 8'h08, 8'h08}; // 45 -
mem[ 46] = {8'h00, 8'h60, 8'h60, 8'h00, 8'h00}; // 46 .
mem[ 47] = {8'h20, 8'h10, 8'h08, 8'h04, 8'h02}; // 47 /
mem[ 48] = {8'h3E, 8'h51, 8'h49, 8'h45, 8'h3E}; // 48 0
mem[ 49] = {8'h00, 8'h42, 8'h7F, 8'h40, 8'h00}; // 49 1
mem[ 50] = {8'h42, 8'h61, 8'h51, 8'h49, 8'h46}; // 50 2
mem[ 51] = {8'h21, 8'h41, 8'h45, 8'h4B, 8'h31}; // 51 3
mem[ 52] = {8'h18, 8'h14, 8'h12, 8'h7F, 8'h10}; // 52 4
mem[ 53] = {8'h27, 8'h45, 8'h45, 8'h45, 8'h39}; // 53 5
mem[ 54] = {8'h3C, 8'h4A, 8'h49, 8'h49, 8'h30}; // 54 6
mem[ 55] = {8'h01, 8'h71, 8'h09, 8'h05, 8'h03}; // 55 7
mem[ 56] = {8'h36, 8'h49, 8'h49, 8'h49, 8'h36}; // 56 8
mem[ 57] = {8'h06, 8'h49, 8'h49, 8'h29, 8'h1E}; // 57 9
mem[ 58] = {8'h00, 8'h36, 8'h36, 8'h00, 8'h00}; // 58 :
mem[ 59] = {8'h00, 8'h56, 8'h36, 8'h00, 8'h00}; // 59 ;
mem[ 60] = {8'h08, 8'h14, 8'h22, 8'h41, 8'h00}; // 60 <
mem[ 61] = {8'h14, 8'h14, 8'h14, 8'h14, 8'h14}; // 61 =
mem[ 62] = {8'h00, 8'h41, 8'h22, 8'h14, 8'h08}; // 62 >
mem[ 63] = {8'h02, 8'h01, 8'h51, 8'h09, 8'h06}; // 63 ?
mem[ 64] = {8'h32, 8'h49, 8'h59, 8'h51, 8'h3E}; // 64 @
mem[ 65] = {8'h7C, 8'h12, 8'h11, 8'h12, 8'h7C}; // 65 A
mem[ 66] = {8'h7F, 8'h49, 8'h49, 8'h49, 8'h36}; // 66 B
mem[ 67] = {8'h3E, 8'h41, 8'h41, 8'h41, 8'h22}; // 67 C
mem[ 68] = {8'h7F, 8'h41, 8'h41, 8'h22, 8'h1C}; // 68 D
mem[ 69] = {8'h7F, 8'h49, 8'h49, 8'h49, 8'h41}; // 69 E
mem[ 70] = {8'h7F, 8'h09, 8'h09, 8'h09, 8'h01}; // 70 F
mem[ 71] = {8'h3E, 8'h41, 8'h49, 8'h49, 8'h7A}; // 71 G
mem[ 72] = {8'h7F, 8'h08, 8'h08, 8'h08, 8'h7F}; // 72 H
mem[ 73] = {8'h00, 8'h41, 8'h7F, 8'h41, 8'h00}; // 73 I
mem[ 74] = {8'h20, 8'h40, 8'h41, 8'h3F, 8'h01}; // 74 J
mem[ 75] = {8'h7F, 8'h08, 8'h14, 8'h22, 8'h41}; // 75 K
mem[ 76] = {8'h7F, 8'h40, 8'h40, 8'h40, 8'h40}; // 76 L
mem[ 77] = {8'h7F, 8'h02, 8'h0C, 8'h02, 8'h7F}; // 77 M
mem[ 78] = {8'h7F, 8'h04, 8'h08, 8'h10, 8'h7F}; // 78 N
mem[ 79] = {8'h3E, 8'h41, 8'h41, 8'h41, 8'h3E}; // 79 O
mem[ 80] = {8'h7F, 8'h09, 8'h09, 8'h09, 8'h06}; // 80 P
mem[ 81] = {8'h3E, 8'h41, 8'h51, 8'h21, 8'h5E}; // 81 Q
mem[ 82] = {8'h7F, 8'h09, 8'h19, 8'h29, 8'h46}; // 82 R
mem[ 83] = {8'h46, 8'h49, 8'h49, 8'h49, 8'h31}; // 83 S
mem[ 84] = {8'h01, 8'h01, 8'h7F, 8'h01, 8'h01}; // 84 T
mem[ 85] = {8'h3F, 8'h40, 8'h40, 8'h40, 8'h3F}; // 85 U
mem[ 86] = {8'h1F, 8'h20, 8'h40, 8'h20, 8'h1F}; // 86 V
mem[ 87] = {8'h3F, 8'h40, 8'h38, 8'h40, 8'h3F}; // 87 W
mem[ 88] = {8'h63, 8'h14, 8'h08, 8'h14, 8'h63}; // 88 X
mem[ 89] = {8'h07, 8'h08, 8'h70, 8'h08, 8'h07}; // 89 Y
mem[ 90] = {8'h61, 8'h51, 8'h49, 8'h45, 8'h43}; // 90 Z
mem[ 91] = {8'h00, 8'h7F, 8'h41, 8'h41, 8'h00}; // 91 [
mem[ 92] = {8'h55, 8'h2A, 8'h55, 8'h2A, 8'h55}; // 92 .
mem[ 93] = {8'h00, 8'h41, 8'h41, 8'h7F, 8'h00}; // 93 ]
mem[ 94] = {8'h04, 8'h02, 8'h01, 8'h02, 8'h04}; // 94 ^
mem[ 95] = {8'h40, 8'h40, 8'h40, 8'h40, 8'h40}; // 95 _
mem[ 96] = {8'h00, 8'h01, 8'h02, 8'h04, 8'h00}; // 96 '
mem[ 97] = {8'h20, 8'h54, 8'h54, 8'h54, 8'h78}; // 97 a
mem[ 98] = {8'h7F, 8'h48, 8'h44, 8'h44, 8'h38}; // 98 b
mem[ 99] = {8'h38, 8'h44, 8'h44, 8'h44, 8'h20}; // 99 c
mem[100] = {8'h38, 8'h44, 8'h44, 8'h48, 8'h7F}; // 100 d
mem[101] = {8'h38, 8'h54, 8'h54, 8'h54, 8'h18}; // 101 e
mem[102] = {8'h08, 8'h7E, 8'h09, 8'h01, 8'h02}; // 102 f
mem[103] = {8'h18, 8'hA4, 8'hA4, 8'hA4, 8'h7C}; // 103 g
mem[104] = {8'h7F, 8'h08, 8'h04, 8'h04, 8'h78}; // 104 h
mem[105] = {8'h00, 8'h44, 8'h7D, 8'h40, 8'h00}; // 105 i
mem[106] = {8'h40, 8'h80, 8'h84, 8'h7D, 8'h00}; // 106 j
mem[107] = {8'h7F, 8'h10, 8'h28, 8'h44, 8'h00}; // 107 k
mem[108] = {8'h00, 8'h41, 8'h7F, 8'h40, 8'h00}; // 108 l
mem[109] = {8'h7C, 8'h04, 8'h18, 8'h04, 8'h78}; // 109 m
mem[110] = {8'h7C, 8'h08, 8'h04, 8'h04, 8'h78}; // 110 n
mem[111] = {8'h38, 8'h44, 8'h44, 8'h44, 8'h38}; // 111 o
mem[112] = {8'hFC, 8'h24, 8'h24, 8'h24, 8'h18}; // 112 p
mem[113] = {8'h18, 8'h24, 8'h24, 8'h18, 8'hFC}; // 113 q
mem[114] = {8'h7C, 8'h08, 8'h04, 8'h04, 8'h08}; // 114 r
mem[115] = {8'h48, 8'h54, 8'h54, 8'h54, 8'h20}; // 115 s
mem[116] = {8'h04, 8'h3F, 8'h44, 8'h40, 8'h20}; // 116 t
mem[117] = {8'h3C, 8'h40, 8'h40, 8'h20, 8'h7C}; // 117 u
mem[118] = {8'h1C, 8'h20, 8'h40, 8'h20, 8'h1C}; // 118 v
mem[119] = {8'h3C, 8'h40, 8'h30, 8'h40, 8'h3C}; // 119 w
mem[120] = {8'h44, 8'h28, 8'h10, 8'h28, 8'h44}; // 120 x
mem[121] = {8'h1C, 8'hA0, 8'hA0, 8'hA0, 8'h7C}; // 121 y
mem[122] = {8'h44, 8'h64, 8'h54, 8'h4C, 8'h44}; // 122 z
end
endmodule
五、数码管显示模块
数码管模块在initial部分中先预存了10个数字对应的七段数码管值,使用时可根据BCD值直接输出。根据八段数码管的特性,小数点dot独占一位,与数字部分没有冲突。总输出时将数字部分与小数点部分按位取或即可得到最终的八段数码管值输出。
由于预先已将电压结果转换为BCD码形式,所以直接使用BCD前八个位进行显示即可。手动将第一个数码管打开小数点。
/**********************************************************
模块名:Segment_LED_DRIVER
更新日期:2021.9.2
功能:
驱动一位共阴极8段数码管
*部分借鉴于电子森林中的开源教程*
**********************************************************/
module Segment_LED_DRIVER
(
input seg_dot, //是否显示小数点
input [3:0] seg_data, //要显示的数字(BCD)
output [8:0] seg_led //选位使能 dot g f e d c b a
);
reg [8:0] seg [9:0]; //定义了一个reg型的数组变量,相当于一个10*9的存储器,存储器一共有10个数,每个数有9位宽
reg [8:0] dot [1:0]; //用来存放小数点开启和不开启的状态
//initial模块中先预存10个数字对应的7段数码管值
initial begin
seg[0] = 9'h3f; //对存储器中第一个数赋值9'b0_0011_1111,相当于共阴极接地,DP点变低不亮 显示数字 0
seg[1] = 9'h06; //显示数字 1
seg[2] = 9'h5b; //显示数字 2
seg[3] = 9'h4f; //显示数字 3
seg[4] = 9'h66; //显示数字 4
seg[5] = 9'h6d; //显示数字 5
seg[6] = 9'h7d; //显示数字 6
seg[7] = 9'h07; //显示数字 7
seg[8] = 9'h7f; //显示数字 8
seg[9] = 9'h6f; //显示数字 9
dot[0] = 9'h00; //不显示小数点
dot[1] = 9'h80; //显示小数点
end
//仅一位数码管 组合逻辑即可完成
assign seg_led = seg[seg_data]|dot[seg_dot]; //数字结果和小数点结果相与就能得到最终输出的
endmodule
六、LED显示模块
设计通过LED的点亮数量来反映电压大小,板载8个数码管,可对应0x00-0xFF二进制的前三位变化。通过一组case语句将二进制转化为独热码即可。
/**********************************************************
模块名:LED_DRIVER
更新日期:2021.8.31
功能:
驱动核心板上的8个共阳极LED灯
通过灯的亮灭数量来反映电压值大小
*部分借鉴于电子森林中的开源教程*
**********************************************************/
module LED_DRIVER
(
input sys_clk,
input sys_rst_n,
input [2:0] vol_data,
output reg [7:0] LED_out
);
always @(posedge sys_clk or negedge sys_rst_n) begin
if(!sys_rst_n)
LED_out <= 8'b11111111; //复位全灭
else
case(vol_data) //LED8在下 LED1在上
3'd0 : LED_out <= 8'b01111111;
3'd1 : LED_out <= 8'b00111111;
3'd2 : LED_out <= 8'b00011111;
3'd3 : LED_out <= 8'b00001111;
3'd4 : LED_out <= 8'b00000111;
3'd5 : LED_out <= 8'b00000011;
3'd6 : LED_out <= 8'b00000001;
3'd7 : LED_out <= 8'b00000000;
default:LED_out <= 8'b11111111;
endcase
end
endmodule
七、顶层模块
例化每个模块,因为使用两个数码管显示,所以单数码管显示模块要例化两遍。
/**********************************************************
!!!!!!顶层模块!!!!!!
模块名:ADC_Voltmeter
更新日期:2021.9.5
功能:
利用ADC制作一个数字电压表
旋转电位计可以产生0-3.3V的电压
利用板上的串行ADC对电压进行转换
将电压值在板上的OLED屏幕上显示出来
将电压值在板上的八段数码管上显示出来
通过LED灯点亮数量来反映电压大小
*部分借鉴于电子森林中的开源教程*
**********************************************************/
module ADC_Voltmeter
(
input sys_clk, //系统时钟 12M
input sys_rst_n, //系统复位 低有效
input adc_input, //ADC数据信号
output adc_cs, //ADC使能信号
output adc_clk, //ADC时钟
output [8:0] seg_led_1, //八段数码管1 控制输出
output [8:0] seg_led_2, //八段数码管2 控制输出
output [7:0] LED_out, //对应8个LED输出
output oled_cs, //OLCD液晶屏使能
output oled_res, //OLCD液晶屏复位
output oled_dc, //OLCD数据指令控制
output oled_clk, //OLCD时钟信号
output oled_mosi //OLCD传输信号
);
wire clk_24M; //12M主频在此不能满足ADC模块的需要 需倍频得到更高时钟
wire [7:0] adc_data; //ADC采样数据 0~255
wire [15:0] vol_result; //四位十六进制温度结果
wire [19:0] vol_bcd; //五位BCD码 温度结果
PLL u_PLL
(
.CLKI (sys_clk),
.CLKOP (clk_24M)
);
ADC_spi_DRIVER u_ADC_spi_DRIVER
(
.clk_24M (clk_24M), //系统时钟 倍频得24M
.sys_rst_n (sys_rst_n), //系统复位,低有效
.adc_cs (adc_cs), //SPI总线CS
.adc_clk (adc_clk), //SPI总线SCK
.adc_dat (adc_input), //SPI总线SDA
.adc_data (adc_data), //ADC采样数据
.vol_result (vol_result),
.vol_bcd (vol_bcd)
);
Segment_LED_DRIVER u_Segment_LED_DRIVER_1
(
//左数码管
.seg_dot (1'b1), //左管显示小数点
.seg_data (vol_bcd[19:16]), //温度值第一位
.seg_led (seg_led_1) // 使能 dot g f e d c b a
);
Segment_LED_DRIVER u_Segment_LED_DRIVER_2
(
//右数码管
.seg_dot (1'b0),
.seg_data (vol_bcd[15:12]), //温度值第二位
.seg_led (seg_led_2) // 使能 dot g f e d c b a
);
OLED_spi_DRIVER u_OLED_spi_DRIVER
(
.sys_clk (sys_clk), //12MHz系统时钟
.sys_rst_n (sys_rst_n), //系统复位,低有效
.oled_vol_result (vol_bcd[19:8]), //要显示的温度结果(BCD只保留前三位)
.oled_csn (oled_cs), //OLCD液晶屏使能
.oled_rst (oled_res), //OLCD液晶屏复位
.oled_dcn (oled_dc), //OLCD数据指令控制
.oled_clk (oled_clk), //OLCD时钟信号
.oled_dat (oled_mosi) //OLCD数据信号
);
LED_DRIVER u_LED_DRIVER
(
.sys_clk (sys_clk),
.sys_rst_n (sys_rst_n),
.vol_data (adc_data[7:5]), //八个LED 所以直接用ADC结果的高三位做判断即可
.LED_out (LED_out)
);
endmodule
八、问题
1、除法报错
在ADC模块中,有一个需求是需要将转换得到的四位16进制0~33000转化为五位BCD码。最初设想的方式是通过做除法取商和余的方式得到。可在综合时却会报错,不是那种写在ERROR和WARNING里面的错,而是在output界面中出现error code 999,并提示软件错误,几经修改都未能正确,尝试使用Synplify来综合也没有很好的改观。
几经尝试后,发现这个问题就出现在除法上,一旦去掉了这部分十六进制转BCD码,就可以正常进行综合。但网上未能找到相关案例和错因分析。
图6 疑似出错代码 图7:错误提示
个人推测是由于FPGA与MCU不同,它对除法操作需要使用大量的资源及特别复杂的运算导致的。最终采用移位+手动进位的方式完成了转换BCD码。
*能用移位和进位代替的除法运算一定不要直接用“/”号!*
2、OLED显示方式
SSD1306采用的显存显示模式,数据要按页、列存入指定的位置才能在屏幕中显示出来。虽然我理解了工作原理,但对SSD1306工作方式配置命令依然存在不少疑惑。这也导致了我在尝试在屏幕上绘制图案时的失败,好在这个项目不需要额外绘制图案,所以并未造成过多影响。
九、小结
本人是初学FPGA,这也是第一次编写较多模块项目级的Verilog代码。和之前学习过的STM32不同,像这种硬件语言由于是并行执行的,就需要一种截然不同的编程逻辑和思考方式。如果说之前的单片机是需要顾前后,那HDL就需要在顾前后的同时,兼顾同时发生的左右,在一个模块中的几个Always块间是如此,不同的模块间亦是如此。
除此之外,这个项目让我对ADC、OLED、七段数码管等器件有了更深的认识。特别是SPI通信协议,通过这次项目实战的训练,我已经对它掌握的炉火纯青了。
最后,感谢电子森林和硬禾学堂提供的开发板和项目实战计划,同时给出了大量的开源学习资料和教学视频,让我能在学习FPGA的道路上乘风破浪。