内容介绍
- 简介
本次项目基于Lattice Mxo2-4000hc的小脚丫平台实现,采用Lattice官方的平台Lattice Diamond进行开发,其中,为了减少错误,采用内置的第三方综合工具Synplify Pro代替Lattice LSE。电压的读取采用的8位的ADC,将0-255映射到0-33,并转化到BCD码,实现在OLED上的显示。
整体的设计思路遵循自顶向下、功能分解的设计思路,顶层模块下设有两大子模块和一个RAM模块(调用lattice硬核)。第一个子模块负责从RAM中读取待显示的像素,并将其通过SSD1306显示到OLED上。第二个子模块负责初始化RAM中的数据,并从ADC中读取电压,经过译码后,将相应的字形码写入RAM中对应的区域。其中,RAM是连接两个子模块的桥梁,其大小正好等于显示屏中像素的数目,使得两边的操作可以独立进行,没有影响(因为该RAM模块可以同时读写)。最终的结构和模块间的连接方式如下图所示(粗略的)。
详细的模块间的连接图鱼接口关系如下:
为了减少一些重复的操作,在两大子模块中,我又进一步基于功能进行模块拆分,以简化其中的的重复操作,例如SPI写入8个bit等。考虑到12MHz的时钟信号已经满足本次项目的需求,我也没有再利用PLL进行倍频操作。
- 实现的功能
RAM IP核的使用
在开始讲解两个子模块所实现的功能及其细节之前,我们先来看一下lattice ip核的使用,在Tool里面找到IPexpress,点击进入便可以看到官方提供的各自IP核,从常见的乘法器到PLL等,基本都可以在这里找到。我们在其中找到RAM_DP,这里的DP代表Dual Port,意味着可以同时读写,非常符合我们的希望使两个模块独立运行的期望,而RAM中所存储的正好就是待显示的像素。
进行如上设置后,我们需要注意的一点是其中的Reset与我们常见的低电平有效不同,是高电平有效的。我一开始一直无法正确读取写入RAM中的数据就是因为默认它是低电平有效,望大家引以为鉴,在使用时,不确定的东西还是要去查看官方的数据手册。
基于RAM的OLED显示模块
由于显示内容的刷新是下一个模块的任务,因此,该模块的主要任务就是从RAM中读取待显示的像素,然后通过SSD1306显示到OLED中。其中,不论是SSD1306的初始化,还是GDRAM的写入,都在重复根据SPI协议的时序约束,向SSD1306写入8个bit的操作,因此,为了使代码看起来不太臃肿,我在这里也将写入8位数据写成了一个该模块下的一个小型的子模块(如下)。
module write_8bit(
input csn_in, //片选信号的控制
input clk, //时钟信号
input [7:0]data_in, //待写入的8位数据
output data_out, //对应SPI中的MOSI
output csn_out //对应SPI中的CS
);
reg csn_out;
reg data_out;
reg [3:0]state;
always@(posedge clk)
begin
if(csn_in)
begin
csn_out <= 1'b1;
data_out <= 1'b0;
state <= 4'b0;
end
else
case(state)
4'd0: begin
csn_out <= 1'b0;
data_out <= data_in[7];
state <= state+1;
end
4'd1: begin
data_out <= data_in[6];
state <= state+1;
end
4'd2: begin
data_out <= data_in[5];
state <= state+1;
end
4'd3: begin
data_out <= data_in[4];
state <= state+1;
end
4'd4: begin
data_out <= data_in[3];
state <= state+1;
end
4'd5: begin
data_out <= data_in[2];
state <= state+1;
end
4'd6: begin
data_out <= data_in[1];
state <= state+1;
end
4'd7: begin
data_out <= data_in[0];
state <= state+1;
end
4'd8: begin
csn_out <= 1'b1;
end
endcase
end
endmodule
然后,按照SPI通信的基本时序关系,编写模块剩余的部分,以及对RAM的顺序读取程序即可。注意,这里的SSD1306,我选用的是按页写入的方式,每写完一页,都需要重新设置首地址。具体的命令和细节可以参考指令详解这篇博客。最后,一定要注意用指令启动电压泵,不然OLED将因驱动电压不够,无法显示。
因为这一部分的代码太长了,我已将本部分的代码上传至GitHub,点击这里,即可查看
基于ADC的电压测量与显示刷新模块
首先,需要从ADS7868中读取出8位原始的电压测量值,这一步的完成需要参考官方手册给出的时序图(如下图所示)。根据这个时序关系,我们知道,先要将片选引脚CS设置位低电平,然后保持3个时钟周期之后,就可以开始在之后的每个上升沿将SDO引脚的数据读取出来,注意,先读取出来的是高位数据,然后才是低位数据。读取完成后,CS引脚置于高电平,一个读取周期结束。
接下来的一部分就是此次项目的关键,即如何做到将电压从原始的8位二进制数转化到0.0-3.3v之间。这其中必然需要用到乘除法,常规的想法是先做个浮点运算,然后再取前两位有效数字。这种做法,相信大家都已经非常熟悉,在单片机上就是如此操作的。因此,这里我决定绕开除法,经利用整数间的加法和乘法的情况下,配合移位运算,解决该问题。
首先分析一下问题,这个问题可以等价为将0-255映射到0-33之间的整数。假设读取到的数字量为x,按照计算公式,真实的电压等于x*3.3/255,在简化后的问题,即为x*33/255。现在到了问题的关键,我们不能直接用x去乘33/255,如果用整数除法,这个的截断误差会随着x的增大而不断变大。所以我们先乘33,再除255,这样可以解决截断误差不断累积的问题,但是又会面临整数除法所带来的更多LUT消耗的问题。因此,我们不妨换个思路,采用移位来代替整除,我们虽然不好计算除255,但是整除256是非常容易做到的,等于向右移8位。这样一来,原式由x*33/255变成了x*33/256。
有人可能会问,这个不是有误差吗?那得到的结果还精确吗?于是,我们再来看一下误差分析,即为1/ 255=0.39%,考虑到我们显示电压的精度仅精确到0.1v,因此,这个误差是可以接受的。现在,又有一个问题就是当x=255时,按照上述公式,我们得到的结果就是32,而不是33,因此,我们这边需要在整除256前,也就是向右移8位前,对上述乘积加上一个常数k,作为补偿:255*33+k=256*33。解得k=33。所以,最终的转换公式如下:(x*33+33)>>8。
下图为我的推到的完整思路:
接下来,就是一个简单的8位二进制编码转8位BCD码,采用一个case语句就可以搞定。然后就是根据BCD码去更新RAM中对应的区域。因为这一部分的代码还要负责RAM的初始化,因此,我也单独再写了一个子模块,放到这个大的模块中。注意,这边我只使用了部分字符的16*8字码,大家如果感兴趣,可以使用该网页工具自动生成的。
这一部分的代码我也上传到了GitHub上,具体的细节可以点击这里。
最终得到的Top模块如下图所示
module top(
input clk_in,
input rst_n_in,
input adc_in,
output adc_clk,
output adc_csn,
output [7:0]led,
output oled_csn,
output oled_rst,
output oled_dcn,
output oled_clk,
output oled_dat
);
wire [8:0]rd_addr;
wire [8:0]wr_addr;
wire [7:0]rd_data;
wire [7:0]wr_data;
wire wr_en, rd_en;
wire [7:0]led;
wire oled_csn, oled_rst, oled_dcn, oled_clk, oled_dat;
wire adc_clk, adc_csn;
//RAM模块 512*8
ram_display ram_display_real(.WrAddress(wr_addr), .RdAddress(rd_addr), .Data(wr_data), .WE(wr_en),
.RdClock(oled_clk), .RdClockEn(rd_en), .Reset(~rst_n_in), .WrClock(clk_in), .WrClockEn(wr_en),
.Q(rd_data));
//OLED根据RAM中的内容刷新显示模块
spi_oled spi_oled_real(.clk_in(clk_in), .rst_n_in(rst_n_in), .rd_data(rd_data),
.oled_csn(oled_csn), .oled_rst(oled_rst), .oled_dcn(oled_dcn), .oled_clk(oled_clk),
.oled_dat(oled_dat), .rd_addr(rd_addr), .rd_en(rd_en));
//ADC读取并刷新RAM模块
adc_8bit adc_volt(.clk_in(clk_in), .rst_n_in(rst_n_in), .adc_in(adc_in), .adc_clk(adc_clk),
.adc_csn(adc_csn), .led(led), .wr_data(wr_data), .wr_addr(wr_addr), .wr_en(wr_en));
endmodule
为了便于验证我又利用了板载的8个LED,让它们直接显示原始的数字量,便于大家直观的判断。
- 功能演示
将编译生成的jed文件上传到fpga中,可以看到,当旋转电位计时,OLED上显示的电压也会随之改变。当旋转至最大时,电压为3.3v,板载的8个LED全部被点亮。(具体的演示请移步视频)
- 心得体会
在周围fpga大佬的鼓励下,我报名了这次活动。这其实是我第一次写verilog,之前一直都是在玩单片机相关的,现在我对fpga编程有了全新的认识。同时,也在各路大佬们的帮助下,学会了调bug和仿真。
非常感谢电子森林所提供的视频讲解,让我有幸可以了解到一点fpga的编程,整个探索的过程非常有趣。虽然我不如群里的大佬在这一领域有着扎实的基础,但是我还是非常开心,可以学到这么多新的知识,相信这种并行解决问题的思维会帮助我在以后解决更加困难的问题的。
总的来说学到了不少,非常感谢电子森林和Digikey举办的此次活动,也很感谢交流群里的大佬,帮忙解决各种疑问。