FPGA 记录

煮波之前学了很多东西,比如小程序,网页三件套,esp32 等等,由于这些东西的学习没有持续很久,三四个星期到两三个月不等,而且没有像C 语言这种类型的技能一样一直有在接触和使用,所以都忘得差不多了。写这个记录的主要目的就是给自己学过的内容开个证明,其次是等我要回看这部分内容时,先看这个记录效率会来得高一点。


FPGA 背景

FPGA 的主流开发厂商有Xilinx 和Altera (以及被Intel 收购),其对应的开发软件是Vivado 和 Quartus。

FPGA 是与单片机同一个层次的概念,两者在功能的完备性方面来说可以互相替代。也就是说用单片机的地方,用FPGA 也可以,换过来同样成立。区别在哪里呢?
单片采用的是冯洛伊曼架构,跟电脑主机相似, 其内部有CPU ,内存(RAM),硬盘(FLASH 等)。其工作的过程是有时序特点的,CPU 反复从硬盘中读取命令来对内存中的数据进行操作,通俗的来说,代码命令是一句句执行的。
FPGA 是一块儿电路,这个电路是可编辑的。 当你需要一块儿ROM 你可以用代码从底层去描述这个ROM 的构成,从逻辑门开始,一步步构成。编写完成ROM 的描述后,软件会自动将其转化为硬件架构,烧录到芯片后,这个ROM 就实打实的生成到了芯片中。除了ROM,UART,IIC 这些熟悉的外设外,其他任何外设,只要你能想得到,都能通过硬件描述语言描述,在FPFA 内部生成。其运行的时候,所有模块的电路都是同时运行的,只是在不同的时间里面,同一个模块的动作不同并且可能啥都不做罢了。

语言区别。 单片机的编程语言都是类似C 这种指令,最后都要转化成机器码让CPU 一句句执行,它们描述的是指令,是动作。FPGA 的编程语言是对硬件结构的描述,就像建筑的图纸,每张图纸都对应着一个建筑,它们描述的是硬件结构。

单片机的硬件资源是固定了的,一块儿芯片上有的外设数量和种类在出厂时已经确定了,而FPGA 则是在总资源有限的情况下,外设数量种类都是自定义的。FPGA 的优势就在于它的灵活性。

当时学计算机组成原理的时候,不太理解这东西,是因为没见过FPGA ,脑子里没有与其同层次的概念。

开发环境与流程

主流语言有Verilog HDL 和VHDL 前者更简单,后者更系统。这十几天我在海云捷迅那边用的是Altera 的cyclone IV(芯片),在Quartus 平台上用Verilog HDL进行开发。

开发流程的话,大概是这样。先确定要实现什么功能,如果方便的话可以画出输入,中间变量,输出的时序图,在软件上编写代码描述实现这个输入输出功能硬件。然后编译检查语法,根据编译好的结果将输入输出与芯片引脚绑定。最后就是烧录进芯片,然后调试。

开发(编码)

模块化。 编写代码之前先将整个模块划分成多个子模块,理清楚每个模块的输入输出是什么,以及模块与模块之间的联系。可以画图(没画完,懒):

基本语法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
module tube_ctl (
input wire clk,
input wire rst_n,
input wire [47:0] num_display,

output reg [5:0] SEL,
output reg [7:0] DIG
);

// 解包
wire [7:0] numbers[5:0];
assign numbers[0] = num_display[7:0];
assign numbers[1] = num_display[15:8];
assign numbers[2] = num_display[23:16];
assign numbers[3] = num_display[31:24];
assign numbers[4] = num_display[39:32];
assign numbers[5] = num_display[47:40];
/********************************状态机写法*******************************/
parameter [5:0] s1 = 6'b000_001;
parameter [5:0] s2 = 6'b000_010;
parameter [5:0] s3 = 6'b000_100;
parameter [5:0] s4 = 6'b001_000;
parameter [5:0] s5 = 6'b010_000;
parameter [5:0] s6 = 6'b100_000;
reg [5:0] cur_state;
reg [5:0] next_state;

// 第一段
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin
cur_state <= s1;
end
else begin
cur_state <= next_state;
end
end
// 第二段
// 计时
reg [15:0] cnt;
wire [15:0] reach_1ms;
assign reach_1ms = (16'd50_000 - 16'b1 == cnt);
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin
cnt <= 16'b0;
end
else if(reach_1ms)begin
cnt <= 16'b0;
end
else begin
cnt <= cnt + 16'b1;
end
end
// 状态转移
always @(*) begin
if(!rst_n)begin
next_state <= s1;
end
else begin
case (cur_state)
s1:begin
if(reach_1ms)
next_state <= s2;
else
next_state <= cur_state;
end
s2:begin
if(reach_1ms)
next_state <= s3;
else
next_state <= cur_state;
end
s3:begin
if(reach_1ms)
next_state <= s4;
else
next_state <= cur_state;
end
s4:begin
if(reach_1ms)
next_state <= s5;
else
next_state <= cur_state;
end
s5:begin
if(reach_1ms)
next_state <= s6;
else
next_state <= cur_state;
end
s6:begin
if(reach_1ms)
next_state <= s1;
else
next_state <= cur_state;
end
default:next_state <= s1;
endcase
end
end
// 第三段
always @(*) begin
if(!rst_n)begin
SEL <= 6'b111_111;
DIG <= 8'h0;
end
else begin
case (cur_state)
s1:begin
SEL <= 6'b011111;
DIG <= numbers[0];
end
s2:begin
SEL <= 6'b101111;
DIG <= numbers[1];
end
s3:begin
SEL <= 6'b110111;
DIG <= numbers[2];
end
s4:begin
SEL <= 6'b111011;
DIG <= numbers[3];
end
s5:begin
SEL <= 6'b111101;
DIG <= numbers[4];
end
s6:begin
SEL <= 6'b111110;
DIG <= numbers[5];
end
endcase
end
end

endmodule

时序逻辑与组合逻辑。 每个模块都有自己的时钟。假如某个变量在本周期内的某个时刻发生了变化,采用时序逻辑编写的模块需要等到下个周期的上升沿才能获取到这个变化,而组合逻辑则在变化的一瞬间就已经获取。体现在硬件上可以理解为,时序逻辑的信息获取由触发器控制,只在上升沿的一瞬间才会触发,组合逻辑则是直接连线。它们在代码上也有区别。
组合逻辑:

1
2
3
4
5
6
7
8
9
10
always @(*) begin
if(!rst_n)begin

end
else begin

end

wire [15:0] reach_1ms;
assign reach_1ms = (16'd50_000 - 16'b1 == cnt);

时序逻辑:

1
2
3
4
5
6
7
8
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin

end
else begin

end
end

状态机。 由于FPGA 中所有的模块都是同时运行的:各自都跟着自己的时钟独立运行,模块之间没有时间顺序,区别于单片机的“执行完行代码的某个函数,得到了某个中间变量,再将这个中间变量输入给下一行代码的下个函数,最后得到结果”,FPGA 想要达到模块与模块之间的时序运行需要状态,每个时刻下系统的状态只有一个,而每个模块根据目前的状态来决定自己干不干事,干什么事。这就是状态机编程思想。FPGA 中所有的逻辑都可以用状态机实现,当然,也可以按照每个变量的时序图用always 块儿和assign 块儿分别对其进行赋值(FPGA 编码的本质就是编写出一套硬件结构,让输入与输出按照某种规定的时序变化),前者适用稍微复杂一点的情况,后者适用在简单情况。
要按照状态机的思想去编码,首先要画出状态转移图:

画图主要是为了定义出所有的状态,有的状态是为了判断然后分支,有的状态是为了计数,有的状态是为了改变输出。尽可能将所有涉及到的变量画在图里面。
编码一般分为三个always 语句,称之为三段式编码。第一段固定,第二段根据转移条件描述状态的跳变,第三段根据不同的状态对所有变量进行赋值。下面我用串口的rx 模块进行举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// 波特率可更换,理论上比9600 大的都行,目前只测试了9600 和 115200
// 模块监听RX 数据线,如果有数据则串行收集,最后并行发出
// done_rx 为一个持续一个时钟周期的高电平脉冲,高电平结束后,data_rx 会被置0
module rx(
input wire clk,
input wire rst_n,
input wire rx_M2,

output reg [7:0] data_rx,
output reg done_rx
);

parameter [4:0] IDLE = 5'b00001;
parameter [4:0] START = 5'b00010;
parameter [4:0] DATA = 5'b00100;
parameter [4:0] STOP = 5'b01000;
parameter [4:0] WAIT = 5'b10000;

parameter sys_clk = 50_000_000;
parameter bps = 115200;
parameter delay = sys_clk / bps;

(* preserve *)reg [4:0] cur_state;
(* preserve *)reg [4:0] next_state;
// 接受到的bit 数
reg [3:0] rx_num;
reg [15:0] cnt;

reg [1:0] rx_buffer;
// 缓存两位rx
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
rx_buffer <= 2'b11;
else
rx_buffer <= {rx_M2, rx_buffer[1]};
end

// 第一段
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
cur_state <= IDLE;
else
cur_state <= next_state;
end
// 第二段
always @(*) begin
if(!rst_n)begin
next_state = IDLE;
end
else begin
case (cur_state)
IDLE :begin
if(rx_buffer == 2'b01)
next_state = WAIT;
else
next_state = cur_state;
end
WAIT :begin
if(cnt == delay - 1'b1)
next_state = START;
else
next_state = cur_state;
end
START :begin
if(rx_num == 4'd8)
next_state = STOP;
else
next_state = DATA;
end
DATA :begin
if(cnt == delay - 1'b1)
next_state = START;
else
next_state = DATA;
end
STOP :begin
next_state = IDLE;
end
default: next_state = IDLE;
endcase
end
end
// 第三段
always @(posedge clk or negedge rst_n) begin
if(!rst_n)begin
data_rx <= 1'b0;
done_rx <= 1'b0;
rx_num <= 1'b0;
cnt <= 1'b0;
end
else begin
case (cur_state)
IDLE :begin
// data_rx <= 1'b0;
done_rx <= 1'b0;
rx_num <= 1'b0;
cnt <= 1'b0;
end
WAIT :begin
data_rx <= 1'b0;
done_rx <= 1'b0;
rx_num <= 1'b0;
if(cnt == delay - 1'b1)
cnt <= 1'b0;
else
cnt <= cnt + 1'b1;
end
START :begin
done_rx <= 1'b0;
cnt <= 1'b0;
rx_num <= rx_num + 1'b1;
end
DATA :begin
done_rx <= 1'b0;
// 计时
if(cnt == delay - 1'b1)
cnt <= 1'b0;
else
cnt <= cnt + 1'b1;
// 输出
if(cnt == delay/2-1)
data_rx[rx_num-1] <= rx_buffer[1];
// data_rx <= data_rx + 1;
end
STOP :begin
rx_num <= 1'b0;
cnt <= 1'b0;
done_rx <= 1'b1;
end
default:begin
data_rx <= 1'b0;
done_rx <= 1'b0;
rx_num <= 1'b0;
cnt <= 1'b0;
end
endcase
end
end

endmodule

调试

在线调试。


这些界面的使用就不详细说了,大概阐述一下里面的逻辑。在线调试就是在你的代码之外额外给你添加了一部分代码(你看不到),这部分代码花费了FPGA 的一部分资源构建了一部分硬件,这些硬件按照你选择的时钟去抓取你选择的变量,并通过下载线传到软件进行显示。所以采样深度越高,耗费资源越多,能看到触发点前后的变量的值就越多。
仿真。 要编写一个.vt 文件,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 前者是编码时间单位(#), 后者是仿真的步骤分辨率
`timescale 1 ns/ 1 ps

// 用于测试的模块, 这里的变量可以被可视化
// 变量不写在() 内部,而且没有用output input 声明, 说明这个模块不能被其他模块例化
module "module_name"_vlg_tst();
reg clk;
reg rst_n;

// 需要测试的模块的例化, 相当于变量声明
"module_name" i1 (
.clk(clk),
.rst_n(rst_n)
);

// 仿真专用写法
always begin
#10
clk = ~clk;
end

// 仿真专用写法
initial begin
$display("-------------------------------------Running testbench-------------------------------------------");
clk = 1'b0;
rst_n = 1'b0;

#100;
rst_n = 1'b1;

$display("-------------------------------------testbench end-------------------------------------------");
$stop;
end

endmodule

这个文件将你的工程的顶层模块例化了,并且新建了一个测试模块儿。测试模块只有输入,这些输入会被仿真窗口显示。大致逻辑就是,这个文件模拟了输入信号,仿真出接收了这些信号后的测试模块儿中变量的值。具体配置的话(quartus 内部)就不写了。

其他

IP 核。 就是厂商帮你写好了一些外设,你直接调用就完事儿了。这些天一共用了这几个:

  • FIFO:一块儿数据缓存区
  • PLL:时钟的分倍频率
  • rom:储存,在配置IP 的时候传入一个.mif 文件,这个文件就是rom 储存的内容

Avalon 接口。 本身也是个IP 核,但比较特殊。它是一种总线协议,也有对应的硬件。将很多使用不同通讯协议的设备统一。只需要把你的设备连接到Avalon 引脚,你就可以通过Avalon 的统一的时序去与这个设备通讯。

VGA 协议。 VGA 是一种与HDMI 和DP 同层次的协议,但其只能传输视频流不能传输音频流。具体内容,我竟然讲不出来,看来这部分学的一般啊。

通用工程

我将常用的功能写成代码模块儿,装在一个project 里面,虽然这些模块只能在固定的开发板上使用,但代码逻辑是不变的,以后可以借鉴。
general_project.7z