- 项目需求:
- 旋转电位计可以产生0-3.3V的电压
- 利用板上的串行ADC对电压进行转换
- 将电压值在板上的OLED屏幕上显示出来
- 整体功能框图:
- 操作方法:
滑动旋钮,在OLED上即可显示此时测得的电压值
- 实现的思路:
首先逐条分析这些需求;
第一点:旋转电位计可以产生0-3.3V的电压
通过查看扩展板原理图可以看到,电位计上可以通过旋钮分得0-3.3V的电压,然后接入到了40Pin插座的34脚,然后再接入到高速比较器TP1961的正极输入端。
第二点:利用板上的串行ADC对电压进行转换
扩展板上并没有现成的ADC可以使用,实际上得用PWM波配合高速比较器实现ADC的功能。
首先PWM是由一串连续行走在某输出管脚上的0、1交替出现的信号组成,我们称高电平1为ON,低电平0为OFF,ON+OFF为一个周期T,ON的持续时间除以周期T就为占空比 - Duty Cycle,如下图。
如果发送端用脉冲的占空比来传递“电压值”,也就是将某个数字的电压值对脉冲的占空比进行调制,就可以在接收端通过RC低通滤波器(也就是解调器)从调制脉宽的数据流中得到需要的模拟电压值,从而达到DAC的目的。看下图 - 假设脉冲的占空比为0的时候(整个周期全部为OFF - 低电平)代表电压值为0,占空比为100%的时候(整个周期全部为ON - 高电平)代表电压值为最高电压,比如3.3V,则40%的占空比就是40%*3.3V。占空比改变-每个周期的脉宽改变,也就意味着输出的电压值在改变,如下图:
具体实现也很简单,首先我使用状态机将整个过程分成三个状态:IDLE、TEST、SHOW。这样做为了防止在OLED显示前一个测试结果的时候,新的测试结果也产生,可能会让OLED显示起来感觉在抖动,不稳定。IDLE是初始状态和一次完整测量之后的等待状态;TEST是进行电压测量的状态;SHOW是将数据显示到OLED上的状态。代码如下:
wire IDLE_2_TEST;
wire TEST_2_SHOW;
wire SHOW_2_IDLE;
assign IDLE_2_TEST = oled_res == 1'b1;
assign TEST_2_SHOW = compare_i_negedge;
assign SHOW_2_IDLE = oled_display_done;
always @ (*)
begin
case (state)
IDLE : if (IDLE_2_TEST) next = TEST;
else next = IDLE ;
TEST : if (TEST_2_SHOW) next = SHOW;
else next = TEST;
SHOW : if (SHOW_2_IDLE) next = IDLE;
else next = SHOW;
default : next = IDLE;
endcase
end
IDLE跳转到TEST的条件是OLED初始化完成;TEST跳转到SHOW的条件是检测到了比较器输出由高到低跳变;SHOW跳转到IDLE的条件是OLED显示完成,如下图所示:
本次设计将0-3.3V分成34份,所以精度约为0.1V,先用一个计数器cnt_pulse来产生PWM波的周期,从0计数到33,再用一个计数器cnt_high来产生PWM波的高电平,每当cnt_pluse计数到等于33时,cnt_high累加1,代码如下:
localparam pwm_divide = 8'd34 ;
localparam MAX_NUM_pulse = pwm_divide;
reg [7:0] cnt_pulse ;
wire add_cnt_pulse,end_cnt_pulse;
assign add_cnt_pulse = (state == TEST);
assign end_cnt_pulse = add_cnt_pulse && cnt_pulse==(MAX_NUM_pulse)-1;
always @(posedge clk or negedge rst_n_i)
begin
if(!rst_n_i) cnt_pulse <= 1'b0;
else if (state == SHOW) cnt_pulse <= 0;
else if (add_cnt_pulse)
begin
if (end_cnt_pulse) cnt_pulse <= 0;
else cnt_pulse <= cnt_pulse + 1'b1;
end
end
localparam MAX_NUM_high = pwm_divide;
reg [7:0] cnt_high ;
wire add_cnt_high,end_cnt_high;
assign add_cnt_high = end_cnt_pulse;
assign end_cnt_high = add_cnt_high && cnt_high==(MAX_NUM_high)-1;
always @(posedge clk or negedge rst_n_i)
begin
if(!rst_n_i) cnt_high <= 1'b0;
else if (state == SHOW) cnt_high <= 0;
else if (add_cnt_high)
begin
if (end_cnt_high) cnt_high <= 0;
else cnt_high <= cnt_high + 1'b1;
end
end
最后将两个计数器进行比较,当cnt_high大于cnt_pulse的时候,便将pwm_o输出1,否则输出0;并且除了在TEST状态,其他状态的pwm_o都输出0,具体代码如下:
always @ (posedge clk or negedge rst_n_i)
if (~rst_n_i) pwm_o <= 1'b0;
else
begin
case (state)
IDLE : pwm_o <= 1'b0;
TEST : pwm_o <= (cnt_high > cnt_pulse);
SHOW : pwm_o <= 1'b0;
default : pwm_o <= 1'b0;
endcase
end
最终,将得到一个占空比从0%开始递增,每次递增1/34,一直递增到33/34的PWM波,经过一个电阻和电容组成的低通滤波就可以将PWM中携带的电压信息“解调”成模拟的电压值。同时不断检测比较器的输出是否产生了一个由高变低的跳变,如果有,便是PWM波等效的直流电压大于电位计的电压,可以认为此时的cnt_high的值即为测得的电位计的值。
第三点:将电压值在板上的OLED屏幕上显示出来
此次用到的OLED规格是128*64的,和我去年寒假用的128*32大同小异,都是使用SSD1306进行驱动,有个128*64bit的RAM存储像素信息,可以理解为一个128列*64行的点阵,点阵存储的数据若为高位1,则OLED屏的对应像素点点亮(可以改设置低位点亮),因此只需要把“3.30V”等这些字符的字模数据发送到SSD1306就可以显示相应的字符了。此款芯片有多种通信方式,而安装在训练板上之后设置成了spi通信模式,所以需要先写一个spi发送数据的模块。
其次,每次发送给SSD1306的数据是8bit(1 word)的数据,学习了SSD1306芯片的数据手册和网上资料,需要先将芯片进行复位(复位拉低100ms左右)然后写入初始化信息(如显示模式,按行写或者按写等等),最后循环写入要显示的数据信息(循环是因为数据是会变的,要实时更新)。得到点阵信息之后初始化到例化的IP——ROM里面备用。例化现有的IP会比使用寄存器更节省资源。
然后,由于电压数据是用6位二进制数存储的,此时还应该先用“左移+3法“将其转换成BCD码存储,将整数位和十分位分别用8位二进制数表示,高4位表示整数位,低4位表示十分位.通过这个8位二进制数的高4位和低4位的值,得到所对应的阿拉伯数字的点阵数据对应的rom地址,读取其中的内容由spi发送给SSD1306即可。
需要注意的是,rom只有一个地址输入,需要根据此时所需要显示的数据,得到对应的地址。其中voltage_1、voltag_2等是使能信号,通过使能信号选择地址。
关键代码如下:
//write voltage_1
4'd2 :
case(j)
6'd0 :
begin y <= y_voltage; voltage_1 <= 1'b1; j <= j + 1'b1;end
//set the page address
6'd1,6'd5://,6'd9,6'd13:
if(spi_write_done) begin start <= 1'b0; j <= j + 1'b1; end
else begin data <= {2'b00,4'hb,y}; start <= 1'b1; end
//set the higher bit of colume address
6'd2,6'd6://,6'd10,6'd14:
if(spi_write_done) begin start <= 1'b0; j <= j + 1'b1; end
else begin data <= {2'b00,4'h1,seg_h_voltage_1};
start <= 1'b1; end
//set the lower bit of colume
6'd3,6'd7://,6'd11,6'd15:
if(spi_write_done) begin start <= 1'b0; j <= j + 1'b1; end
else begin data <= {2'b00,4'h0,seg_l_voltage_1};
start <= 1'b1; end
//write 8 colume
6'd4 : //,6'd12:
if(x==3'd7 & spi_write_done)
begin y <= y + 1'b1; x <= 3'd0; j <= j + 1'b1; start <= 1'b0; end
else
if(spi_write_done) begin start <= 1'b0; x <= x + 1'b1; end
else begin data <= {2'b01,writting_data}; start <=
1'b1; end
6'd8 : //,6'd16:
if(x==3'd7 & spi_write_done)
begin y <= y + 1'b1; x <= 3'd0; j <= j + 1'b1; start <= 1'b0; end
else
if(spi_write_done) begin start <= 1'b0; x <= x + 1'b1; end
else begin data <= {2'b01,writting_data}; start <=
1'b1; end
6'd9 :
begin voltage_1 <= 1'b0;j <= 6'd0; i <= i + 1'b1; end
endcase
这样为写一个字符需要用到的代码,先发送要显示的位置的页数(就是第几行)坐标,再发送要显示的列数高八位和低八位坐标,最后将从rom读出的字模数据写入到oled,即可显示出字符。
- 完成的功能:
测量电位计上的电压
- 达到的性能:
资源使用情况截图
- 遇到的主要难题和解决过程:
- 一开始我尝试想把电压分辨率做到0.01V,尝试将PWM波周期设为256个clk,相当于8位的DAC。做出来之后进行测试,发现一个奇怪的现象:当电位计从0V递增到1.60V(用电压表实测),数字量从0递增到200;当电位计从1.60V递增到3.30时,数字量从200递增到256。这相当不科学!于是接下来就是各种怀疑,猜测,找来找去都没发现有什么bug。后来我隐约感觉,PWM波经过低通滤波器输出到比较器上,到底是不是像原理上说的,等效为一个直流电平?为了验证想法,我用示波器测试比较器的负极输入端,结果让我瞬间清醒!测试结果如下图所示,原来比较器的负极输入端不是一个理想的直流电平,而是一些类似锯齿波的东西。分析了一下原理,觉得是当PWM输出高电平时,在比较器负极输入端前的电容上充电,而当PWM输出低电平时,电容由开始放电了,而PWM的占空比影响的,实际上是电容能充电到的电平高度!这些问题有点明朗了,在占空比比较高的时候,放电速度要快很多,所以实际上接入到比较器负极输入端的所谓“电平”,是相当不准确的,导致了测量结果也不准确!为了解决这一问题,我又重新去看了下PWM相关的原理,发现在苏老师写的系列文章其实有提到低通滤波器的选择。经过计算,板上的电容位1000pF,电阻位1kΩ,截止频率大约为159kHz,而我使用的时钟为12M晶振,PWM波的频率为352kHz,这个低通滤波器并不能把PWM的高次谐波滤干净!但是板子上的晶振和低通滤波器已经定好了不方便改,PWM的周期为34个clk也没法再低了,能做的只有提高运行的时钟了,于是想到了使用内部的PLL进行4倍频,经过测试,纹波改善了非常多,如下图所示:
- 将运行时钟倍频到48M之后解决了PWM的问题,却引入了时序违例的问题,之前写的oled的驱动模块跑不了48M的频率,如下图所示:
经过重新修改一些逻辑,插入寄存器,将时序违例减小了一部分,
后面实在是不好再修改逻辑了,只能选择将oled驱动模块的时钟进行分频,最后才解决时序问题
- 未来的计划等:
- 有时间可以将OLED的逻辑重新写一下,争取可以跑更高的时序
- 测电压的算法可以尝试使用二分法,然后比较一下精度是否能达到要求,这样可以更快的测出结果