1 项目需求
- 实现一个可定时时钟的功能,用小脚丫FPGA核心模块的4个按键设置当前的时间,OLED显示数字钟的当前时间,精确到分钟即可,到整点的时候比如8:00,蜂鸣器报警,播放音频信号,最长可持续30秒;
- 实现温度计的功能,小脚丫通过板上的温度传感器实时测量环境温度,并同时间一起显示在OLED的屏幕上;
- 定时时钟整点报警的同时,将温度信息通过UART传递到电脑上,电脑上能够显示当前板子上的温度信息(任何显示形式都可以),要与OLED显示的温度值一致;
- PC收到报警的温度信号以后,将一段音频文件(自己制作,持续10秒钟左右)通过UART发送给小脚丫FPGA,蜂鸣器播放收到的这段音频文件,OLED屏幕上显示的时间信息和温度信息都停住不再更新;
- 音频文件播放完毕,OLED开始更新时间信息和当前的温度信息
2 实现的思路
- 使用OLED例程代码驱动OLED显示,并尝试改变显示内容;
- 用分频器得到1Hz的时钟信号,实现定时时钟的计时,并显示在屏幕上;
- 实现按键消抖,并通过按键控制时间的调节;
- 了解温度传感器的协议,将温度转换为BCD码并显示在屏幕上;
- 使用UART例程,例化单字节UART发送与接收,并编写字符串的UART发送与接收模块;
- 将温度信息通过UART发送至上位机;
- 使用蜂鸣器例程,首先不连接上位机,将音乐写进程序中直接播放;
- 删除程序内的音乐,编写上位机程序,将音乐通过上位机以UART方式发送,蜂鸣器播放;
- 整合各个模块,明确各模块间的触发关系。
3 完成的功能
3.1 时间与温度显示
通电后,OLED屏幕中心显示时间与温度信息。
3.2 时间调整
拨码开关1、2为时间调整开关,按键1、2用来改变时间。其功能表如所示:
拨码开关 按键开关 | 按下KEY1 | 按下KEY2 |
1OFF 2OFF | 无变化,正常计时 | 无变化,正常计时 |
1OFF 2ON | 分钟数+1 | 分钟数-1 |
1ON 2OFF | 小时数+1 | 小时数-1 |
1ON 2ON | 小时数+1 | 小时数-1 |
3.3 整点报时
整点时,FPGA向电脑发送当前温度信息。电脑同时运行Matlab脚本读取温度信息并显示,之后立即将已编码的二进制音乐文件逐字节通过串口发送,全部发送完毕后蜂鸣器播放音乐,同时OLED停止更新。播放完毕后继续更新。
4 实现过程
4.1 层次结构
资源消耗:
4.2 数字钟部分
// clock_module: 时钟模块
// 输入:
// clk: 12MHz时钟信号
// rst_n: 复位信号,低电平有效
// calibrate: 校准开关,2'b00不校准,2'b01校准分钟,2'b10和2'b11校准小时
// up_pulse: 每输入一个高电平脉冲校准时小时/分钟数加一
// down_pulse: 每输入一个低电平脉冲校准时小时/分钟数减一
// 输出:
// sec: 秒输出 (BCD编码)
// min: 分输出 (BCD编码)
// hour: 时输出 (BCD编码)
// hour_update: 整点信号,整点时输出一个时钟周期的高电平脉冲
module clock_module (
input clk,
input rst_n,
input [1:0] calibrate,
input up_pulse,
input down_pulse,
output reg [7:0] sec,
output reg [7:0] min,
output reg [7:0] hour,
output reg hour_update
);
wire clk_1hz;
reg min_update;
reg [7:0] prev_sec, prev_min;
// 得到1Hz的时钟信号,作为秒钟的触发时钟
divide get1hz(
.clk(clk),
.rst_n(rst_n),
.clkout(clk_1hz)
);
// 秒控制
always @(posedge clk_1hz or negedge rst_n) begin
if (!rst_n)
sec <= 8'h0;
else if (sec >= 8'h59)
sec <= 8'h0;
else if (sec[3:0] == 4'h9) // BCD码进位要在低位加7
sec <= sec + 8'h7;
else if (!calibrate) // 非校准时正常计时
sec <= sec + 8'h1;
end
// 记录上一时钟(12MHz)周期的秒数
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
prev_sec <= 8'h0;
else
prev_sec <= sec;
end
// 当秒数从59变为0且不在校准状态时,产生一个时钟(12MHz)周期的分钟更新信号
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
min_update <= 1'b0;
else if (!calibrate && sec == 8'h0 && prev_sec == 8'h59)
min_update <= 1'b1;
else
min_update <= 1'b0;
end
// 分控制,由于需要校准,其触发频率不能为1Hz,需要使用12MHz时钟
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
min <= 8'h0;
else if (calibrate == 2'b10) begin // 校准分钟
if (up_pulse) begin
if (min >= 8'h59)
min <= 8'h0;
else if (min[3:0] == 4'h9)
min <= min + 8'h7;
else
min <= min + 8'h1;
end
else if (down_pulse) begin
if (min == 8'h0)
min <= 8'h59;
else if (min[3:0] == 4'h0)
min <= min - 8'h7;
else
min <= min - 8'h1;
end
end
else if (min_update) begin // 不在校准状态,正常计时
if (min >= 8'h59)
min <= 8'h0;
else if (min[3:0] == 4'h9)
min <= min + 8'h7;
else
min <= min + 8'h1;
end
end
// 记录上一时钟(12MHz)周期的分钟数
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
prev_min <= 8'h0;
else
prev_min <= min;
end
// 当分钟数从59变为0且不在校准状态时,产生一个时钟(12MHz)周期的小时数更新信号
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
hour_update <= 1'b0;
else if (!calibrate && min == 8'h0 && prev_min == 8'h59)
hour_update <= 1'b1;
else
hour_update <= 1'b0;
end
// 时控制
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
hour <= 8'h0;
else if (calibrate[0]) begin // 校准小时
if (up_pulse) begin
if (hour >= 8'h23)
hour <= 8'h0;
else if (hour[3:0] == 4'h9)
hour <= hour + 8'h7;
else
hour <= hour + 8'h1;
end
else if (down_pulse) begin
if (hour == 8'h0)
hour <= 8'h23;
else if (hour[3:0] == 4'h0)
hour <= hour - 8'h7;
else
hour <= hour - 8'h1;
end
end
else if (hour_update) begin // 不在校准状态,正常计时
if (hour >= 8'h23)
hour <= 8'h0;
else if (hour[3:0] == 4'h9)
hour <= hour + 8'h7;
else
hour <= hour + 8'h1;
end
end
endmodule
4.3 温度计部分
温度传感器的底层驱动使用电子森林提供的代码,对采集到的数据处理部分代码如下:(将温度转化为bcd码字符串,保留一位小数)
// temp_data_decode: 对传感器传来的原始数据进行处理
// 输入:
// rst_n: 复位信号,低电平有效
// data_in: 温度传感器的原始数据
// 输出:
// data_out: 处理后的温度数据,为bcd码字符串,如" 30.1"
module temp_data_decode (
input rst_n,
input [15:0] data_in,
output reg [39:0] data_out
);
wire [11:0] temp_int;
always @(*) begin
if (!rst_n)
data_out <= " 00.0";
else begin
data_out[31:8] = {temp_int[7:4] + 8'h30, temp_int[3:0] + 8'h30, "."};
if (data_in[15:11] == 5'b11111)
data_out[39:32] = "-";
else if (temp_int[11:8] == 4'h1)
data_out[39:32] = "1";
else
data_out[39:32] = " ";
case (data_in[3:0])
4'h0: data_out[7:0] <= 8'h30;
4'h1, 4'h2: data_out[7:0] <= 8'h31;
4'h3: data_out[7:0] <= 8'h32;
4'h4, 4'h5: data_out[7:0] <= 8'h33;
4'h6, 4'h7: data_out[7:0] <= 8'h34;
4'h8: data_out[7:0] <= 8'h35;
4'h9, 4'ha: data_out[7:0] <= 8'h36;
4'hb: data_out[7:0] <= 8'h37;
4'hc, 4'hd: data_out[7:0] <= 8'h38;
4'he, 4'hf: data_out[7:0] <= 8'h39;
default: data_out[7:0] <= 8'h30;
endcase
end
end
bin_to_bcd temp_data_conversion(
.bin_data(data_in[10:4]),
.bcd_data(temp_int)
);
endmodule
4.4 UART部分
UART的单字节发送与接收使用电子森林的代码,利用单字节模块,编写字符串的UART发送与接收代码:
// uart_send: UART发送模块,发送整段温度数据
// 输入:
// clk: 12MHz时钟信号
// rst_n: 复位信号,低电平有效
// tx_data: 要发送的数据(字符串)
// length: 发送数据字节数
// send_start: 发送开始信号,输入一个时钟周期高电平脉冲代表整段序列的发送开始
// bps_clk: 发送时钟输入
// 输出:
// bps_en: 发送时钟使能
// rs232_tx: UART发送端口
module uart_send(
input clk,
input rst_n,
input [255:0] tx_data,
input [7:0] length,
input send_start,
input bps_clk,
output bps_en,
output rs232_tx
);
reg tx_start; // 发送一个字节的开始信号,并非整个序列
reg [7:0] tx_data_addr; // 当前发送字节在整个发送序列的地址
reg [7:0] tx_byte;
always @(*) begin
if (!rst_n)
tx_byte = 8'h0;
else if (tx_start) // 每次发送一个字节前,先将该字节写入发送寄存器
tx_byte = tx_data[tx_data_addr-:8];
end
// 更新发送字节的地址
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
tx_data_addr = 8'd255;
else if (send_start)
tx_data_addr = length * 8 - 8'd1;
else if (tx_finish) begin
tx_data_addr = tx_data_addr - 8'd8;
end
end
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
tx_start <= 1'b0;
else if (send_start)
tx_start <= 1'b1;
else if (tx_finish && tx_data_addr != 8'd255)
tx_start <= 1'b1;
else
tx_start <= 1'b0;
end
//UART发送字节模块 例化
Uart_Tx Uart_Tx_uut
(
.clk_in (clk ), //系统时钟
.rst_n_in (rst_n ), //系统复位,低有效
.bps_en (bps_en ), //发送时钟使能
.bps_clk (bps_clk ), //发送时钟输入
.tx_start (tx_start ), //发送开始信号
.tx_byte (tx_byte ), //需要发出的数据 (字节)
.rs232_tx (rs232_tx ), //UART发送输出
.tx_finish (tx_finish ) //发送结束信号 (该字节发送完毕,并非整个序列)
);
endmodule
// uart_recv: UART接收模块,接收数据并进行数据写入处理
// 输入:
// clk: 12MHz时钟信号
// rst_n: 复位信号,低电平有效
// rs232_rx: UART接收端口
// bps_clk: 接收时钟输入
// bps_en: 接收时钟使能
// 输出:
// rx_tone: 当前接收音符音高
// rx_duration: 当前接收音符时值
// duration_wr_en: 时值写入使能
// tone_wr_en: 音高写入使能
// wr_addr: 写入地址
// recv_finish: 接收完毕信号,整段音乐接收完毕产生一个时钟周期的高电平脉冲
module uart_recv(
input clk,
input rst_n,
input rs232_rx,
input bps_clk,
output bps_en,
output reg [4:0] rx_tone,
output reg [4:0] rx_duration,
output reg duration_wr_en,
output reg tone_wr_en,
output reg [5:0] wr_addr,
output reg recv_finish
);
reg bps_en_r;
wire rx_finish = bps_en_r & (~bps_en);
integer i;
wire [7:0] rx_byte;
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
bps_en_r <= 1'b0;
else
bps_en_r <= bps_en;
end
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
rx_tone <= 5'o0;
rx_duration <= 5'd0;
duration_wr_en <= 1'b0;
tone_wr_en <= 1'b0;
wr_addr <= 6'b111111;
recv_finish <= 1'b0;
end
else if (recv_finish)
recv_finish <= 1'b0;
else if (rx_finish) begin
if (rx_byte == 8'hff) begin
if (tone_wr_en) begin
tone_wr_en <= 1'b0;
duration_wr_en <= 1'b1;
end
else begin
recv_finish <= 1'b1;
duration_wr_en <= 1'b0;
end
wr_addr <= 6'b111111;
end
else if (duration_wr_en) begin
wr_addr <= wr_addr + 6'd1;
rx_duration <= rx_byte[4:0];
end
else begin
tone_wr_en <= 1'b1;
wr_addr <= wr_addr + 6'd1;
rx_tone <= {rx_byte[5:4], rx_byte[2:0]};
end
end
end
//UART接收字节模块 例化
Uart_Rx Uart_Rx_uut
(
.clk_in (clk ), //系统时钟
.rst_n_in (rst_n ), //系统复位,低有效
.bps_en (bps_en ), //接收时钟使能
.bps_clk (bps_clk ), //接收时钟输入
.rs232_rx (rs232_rx ), //UART接收输入
.rx_byte (rx_byte ) //接收到的数据
);
endmodule
4.5 音乐播放部分
蜂鸣器的驱动使用电子森林的代码,整段音乐的处理和播放模块代码如下:
// music_module: 音乐播放模块
// 输入:
// clk: 12MHz时钟信号
// rst_n: 复位信号,低电平有效
// music_start: 开始播放信号,一个时钟周期的高电平脉冲代表音乐开始
// music_tone: 音符音高输入,用5位八进制数表示
// 0~2位代表音级(do~si: 3'o1~3'o7, 休止符: 3'o0)
// 3~4位代表音组(高音: 2'o2、中音: 2'o1、低音: 2'o0)
// music_duration: 音符时值输入,用5位十进制数表示,单位为1/8秒,0代表音乐结束
// 输出:
// music_playing: 音乐播放信号,播放音乐时输出高电平
// rd_addr: RAM读地址,用来读取音高和时值信息
// beeper_pin: 蜂鸣器输出端口
module music_module (
input clk,
input rst_n,
input music_start,
input [4:0] music_tone,
input [4:0] music_duration,
output reg music_playing,
output reg [5:0] rd_addr,
output beeper_pin
);
wire clk_8hz;
reg music_start_r;
reg [4:0] duration_cnt;
// 检测music_start的正脉冲,检测到后将music_playing置为1
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
music_start_r <= 1'b0;
else if (music_start == 1'b1) begin
music_start_r <= 1'b1;
end
else if (music_playing)
music_start_r <= 1'b0;
end
always @(posedge clk_8hz or negedge rst_n) begin
if (!rst_n)
music_playing <=1'b0;
else if (music_start_r)
music_playing <= 1'b1;
else if (music_duration == 5'd0)
music_playing <= 1'b0;
end
// 时值控制
always @(posedge clk_8hz or negedge rst_n) begin
if (!rst_n)
duration_cnt <= 5'd0;
else if (!music_playing)
duration_cnt <= 5'd0;
else if (duration_cnt >= music_duration - 5'd1)
duration_cnt <= 5'd0;
else
duration_cnt <= duration_cnt + 5'd1;
end
// 音乐RAM地址更新
always @(posedge clk_8hz or negedge rst_n) begin
if (!rst_n)
rd_addr <= 6'd0;
else if (!music_playing)
rd_addr <= 6'd0;
else if (duration_cnt >= music_duration - 1)begin // 该音符播放完毕
rd_addr <= rd_addr + 6'd1;
end
end
// 例化蜂鸣器
Beeper bp
(
.clk_in(clk), //系统时钟
.rst_n_in(rst_n), //系统复位,低有效
.music_playing(music_playing), //蜂鸣器使能信号
.tone(music_tone), //蜂鸣器音节控制
.piano_out(beeper_pin) //蜂鸣器控制输出
);
// 产生8Hz时钟信号
divide #(.N(1_500_000), .WIDTH(21)) metronome(
.clk(clk),
.rst_n(rst_n),
.clkout(clk_8hz)
);
endmodule
// music_ram: 音乐RAM模块
// 输入:
// clk: 12MHz时钟信号
// rst_n: 复位信号,低电平有效
// tone_wr_en: 音高写入使能,高电平使能
// duration_wr_en: 时值写入使能,高电平使能
// rd_en: 读取使能
// wr_addr: 写入地址(0~63)
// rd_addr: 读取地址(0~63)
// tone_in: 音高写入数值
// duration_in: 时值写入数值
// 输出:
// tone_out: 音高读取数值
// duration_out: 时值读取数值
module music_ram(
input clk,
input rst_n,
input tone_wr_en,
input duration_wr_en,
input rd_en,
input [5:0] wr_addr,
input [5:0] rd_addr,
input [4:0] tone_in,
input [4:0] duration_in,
output [4:0] tone_out,
output [4:0] duration_out
);
reg [4:0] tone_ram[63:0];
reg [4:0] duration_ram[63:0];
integer i;
always @(posedge clk or negedge rst_n)
begin
if (!rst_n) begin
for(i=0;i<64;i=i+1) begin
tone_ram[i] <= 5'b0;
duration_ram[i] <= 5'b0;
end
end
else if (tone_wr_en)
tone_ram[wr_addr] <= tone_in;
else if (duration_wr_en)
duration_ram[wr_addr] <= duration_in;
end
assign tone_out = rd_en? tone_ram[rd_addr] : 5'bz;
assign duration_out = rd_en? duration_ram[rd_addr] : 5'bz;
endmodule
4.6 上位机
上位机始终运行Matlab脚本监视串口,一旦串口有信息发送就将其显示。同时发送音乐信息。
上位机Matlab脚本代码如下:
music_file = fopen("music.bin");
music_data = fread(music_file);
fclose(music_file);
s = serialport("COM7", 9600, "Timeout", 4000);
configureTerminator(s,"CR/LF");
while 1
disp(readline(s));
write(s, music_data, "uint8");
end
其中上位机发送的音乐文件由另一个脚本生成。该脚本将各音符的音调和时值以二进制模式编码。每个音符的音调和时值位宽都是8位,最多支持63个音符。音调前4位为低音(0)、中音(1)和高音(2),后四位为do~si(1~7)和休止符(0),其余的编码均无效。时值为1~31的整数,单位时间为1/8秒,时值0代表音乐的结束。音调和时值的数组结尾为0xff作为接收结束标志。
生成音乐文件的Matlab脚本代码如下:
f = fopen("music.bin", "w");
tone = [
0x21, 0x15, 0x21, 0x25, 0x24, 0x23, 0x22, 0x17, 0x00, 0x17, 0x15, 0x17, 0x23, 0x22, 0x17, 0x21, 0x23,...
0x21, 0x15, 0x21, 0x25, 0x24, 0x23, 0x22, 0x17, 0x00, 0x17, 0x15, 0x17, 0x23, 0x22, 0x17, 0x21,...
0x23, 0x25, 0x24, 0x25, 0x24, 0x23, 0x22, 0x17, 0x22, 0x23, 0x24, 0x23, 0x22, 0x21,...
0x23, 0x25, 0x24, 0x23, 0x24, 0x25, 0x26, 0x25, 0x24, 0x23, 0x24, 0x23, 0x24, 0x23, 0x22, 0x21, 0x00];
duration = [
0x02, 0x02, 0x02, 0x04, 0x04, 0x02, 0x02, 0x0d, 0x01, 0x02, 0x02, 0x02, 0x04, 0x04, 0x02, 0x02, 0x0e,...
0x02, 0x02, 0x02, 0x04, 0x04, 0x02, 0x02, 0x0d, 0x01, 0x02, 0x02, 0x02, 0x04, 0x04, 0x02, 0x10,...
0x08, 0x08, 0x02, 0x02, 0x02, 0x02, 0x08, 0x08, 0x08, 0x02, 0x02, 0x02, 0x02, 0x08,...
0x08, 0x08, 0x02, 0x02, 0x02, 0x02, 0x08, 0x04, 0x02, 0x02, 0x08, 0x02, 0x02, 0x02, 0x02, 0x08, 0x00];
fwrite(f, [tone 0xff duration 0xff]);
fclose(f);
5 遇到的主要难题
这个项目是我接触FPGA的首个项目,之前我一直学习的是单片机的编程,习惯了单片机程序顺序执行的思想,所以刚开始面对FPGA的并行思想时感到无从下手。经过查找资料,学习FPGA相关的例程,并通过简单的项目,如按键消抖等开始练习,逐渐编写各个模块,最后进行综合,完成了任务要求。
对我来说,FPGA编程的时序逻辑是非常重要的一方面,编写程序时需要明确各个功能块何时触发,有时差一个时钟周期就不能得到想要的结果。另外,有些模块可以用组合逻辑来实现,不需要考虑其触发时间。时序逻辑的问题通过仿真,一般都会发现,仿真是FPGA编程中必不可少的一部分。
6 未来的计划建议
- 由于开发板上没有纽扣电池,所以只要开发板一断电,之间的时间信息就会丢失,上电后需要重新校准时间,非常麻烦。当连接上位机时,上位机可以将当前时间通过串口发送给开发板,开发板自动校准时间,更为方便。
- 可以增加闹钟功能,除整点外可以另外设置一个响铃时间,并播放不同的音乐。