这一篇在干嘛?
CPU 里最贵的运算之一就是乘法。这一篇我们不上 DSP、不上硬核乘法器 IP,而是用最朴素也最经典的「移位相加」方法,在 FPGA 上用纯 Verilog 搭出一个 4 位乘法器:两个乘数用按键现场输入,乘积直接显示在数码管上。读完你会明白:乘法本质上就是「移位 + 加法」的循环。
实验目标与原理
实验目的只有一句话:学习应用移位相加原理设计四位乘法器。
为什么要用移位相加?
FPGA 里没有天然的「乘法指令」。虽然现代 FPGA 有嵌入式硬件乘法器(DSP 单元),但要理解乘法的本质,最好的办法是自己用加法器把它搭出来。
回忆一下我们小学列竖式算乘法的过程:被乘数的每一位乘以乘数,结果错位相加。二进制世界里这件事更简单——被乘数的每一位只有 0 和 1 两种可能:
- 该位是 1:把(移位后的)乘数加进部分和;
- 该位是 0:加个全零,等于什么都不做。
所以二进制乘法可以完全退化为「判断每一位、移位、相加」三步。官方原文的描述是:
该乘法器是由 4 位加法器构成的以时序方式设计的 4 位乘法器。原理是:乘法通过逐项移位相加来实现相乘。从被乘数的最低位开始,若为 1,则乘数左移后与上一次的和相加;若为 0,左移后以全零相加,直至被乘数的最高位。
手算一遍:5 × 3 = 15
被乘数 A = 5 = 0101,乘数 B = 3 = 0011,结果 R = 0:
| 轮次 | B 的最低位 | 操作 | AT(移位后的 A) | R(累加和) |
|---|---|---|---|---|
| 初始 | — | — | 0000_0101 | 0000_0000 |
| 第 1 轮 | 1 | R += AT,再移位 | 0000_1010 | 0000_0101 |
| 第 2 轮 | 1 | R += AT,再移位 | 0001_0100 | 0000_1111 |
| 第 3 轮 | 0 | 只移位 | 0010_1000 | 0000_1111 |
| 第 4 轮 | 0 | 只移位 | 0101_0000 | 0000_1111 |
4 轮结束后 R = 0000_1111 = 15,正是 5 × 3。这就是「移位相加」的全部秘密:A 左移一位相当于 ×2,B 右移一位相当于丢弃已处理过的最低位。4 位 × 4 位最大结果是 15 × 15 = 225,刚好 8 位装得下,所以输出端口定义成 [7:0]。
空间换时间的直觉
4 位乘法只需循环 4 次,8 位乘法循环 8 次——位数翻倍,循环次数也翻倍。这就是为什么大位宽乘法在软件里慢、在硬件里要用流水线或 DSP 单元加速。理解了移位相加,再看乘法器 IP 的手册就不会一头雾水了。
实验里到底有几个模块?
原文给了两段程序,分工如下:
mul_con:按键计数模块。mul1_key、mul2_key两个按键每按一次,对应缓存寄存器mul1_buf、mul2_buf加 1,这两个 8 位值就是两个乘数,同时直接输出到数码管显示。M8B:核心乘法模块。用for循环描述移位相加过程,输入 A、B(各 4 位),输出 8 位乘积 R。
Verilog 实现
原文代码一字不改,全部保留如下。
模块一:按键计数模块,负责现场输入两个乘数:
module mul_con(
input clk,
input reset_n,
input mul1_key,
input mul2_key,
output reg [7:0] mul1,
output reg [7:0] mul2
);
reg [7:0] mul1_buf, mul2_buf;
always @ (negedge mul1_key or negedge reset_n)
if(!reset_n)
begin
mul1_buf <= 0;
end
else if (!mul1_key)
mul1_buf <= mul1_buf + 1'b1;
always @ (negedge mul2_key or negedge reset_n)
if(!reset_n)
begin
mul2_buf <= 0;
end
else if (!mul2_key)
mul2_buf <= mul2_buf + 1'b1;
always @(mul1_buf or mul2_buf)
begin
mul1 <= mul1_buf;
mul2 <= mul2_buf;
end
endmodule模块二:移位相加乘法器本体:
module M8B (R,A,B);
parameter S=4;
output[2*S:1] R;
input[S:1] A,B;
reg[2*S:1] R,AT; reg[S:1] BT,CT;
always @(A,B) begin
R=0; AT = {{S{1'B0}}},A};
BT = B; CT = S;
for(CT=S; CT>0; CT=CT-1)
begin if(BT[1]) R=R+AT;
AT = AT<<1; //左移 1 位
BT = BT>>1; //右移 1 位
end end
endmodule原文对这两段代码的说明是:
这两则使用了 for 循环语句的以移位相加方式实现的 4 位乘法器的设计。对于循环控制变量增值表达式,前者采用增值的方式,后者采用了减值的方式。
读 M8B 的几个关键点:
parameter S=4:位宽参数化,想改成 8 位乘法器只需把 S 改成 8,其余结构不动。AT = {{S{1'B0}}},A}:把 4 位被乘数 A 高位补零扩展成 8 位,放在 AT 里准备参与累加——因为乘积最多 8 位,加法必须按 8 位算。for(CT=S; CT>0; CT=CT-1):循环 4 次,每次看BT[1](乘数当前最低位)。是 1 就R=R+AT,然后无论是不是 1,AT左移一位、BT右移一位,进入下一轮。- 综合器会把
for循环展开成 4 份组合逻辑加法器,这不是软件里那种按时间执行的循环,而是「同一时刻、四份电路」并行判断。这是硬件描述语言和 C 语言最大的思维差别。
这是组合逻辑乘法器,没有时钟
M8B的always @(A,B)是纯组合逻辑,A、B 一变结果立刻重算,没有时序控制。在按键输入这种场景没问题,但如果要做高速乘法,就需要改成流水线(时序)结构,否则关键路径太长会限制最高时钟频率。这也是原文说「时序方式设计」时值得琢磨的地方。
实验仿真
原文给出的仿真激励配置如下:
/MUX_BUZZ_vlg_tst/clk 0
/MUX_BUZZ_vlg_tst/reset_n 1
/MUX_BUZZ_vlg_tst/s0 1
/MUX_BUZZ_vlg_tst/s1 1
/MUX_BUZZ_vlg_tst/buzz St0仿真时重点观察:给 A、B 赋不同组合(如 0×0、5×3、9×7、15×15),确认 R 输出等于十进制乘积;再拨动 reset_n 验证按键计数模块能清零、能递增。
引脚分配
本实验的输入输出集中在数码管、按键和时钟上。数码管段选 DIG[0..6] 与位选 SEL[0..7] 的完整约束原文如下:
set_pin_assignment { DIG[0] } { LOCATION = C15; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { DIG[1] } { LOCATION = C16; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { DIG[2] } { LOCATION = B6; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { DIG[3] } { LOCATION = A5; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { DIG[4] } { LOCATION = B5; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { DIG[5] } { LOCATION = A8; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }set_pin_assignment { DIG[6] } { LOCATION = A7; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[0] } { LOCATION = D11; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[1] } { LOCATION = E11; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[2] } { LOCATION = F10; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[3] } { LOCATION = C13; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[4] } { LOCATION = E10; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[5] } { LOCATION = E12; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[6] } { LOCATION = E16; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { SEL[7] } { LOCATION = F16; IOSTANDARD = LVCMOS33;
DRIVESTRENGTH = 8; PULLTYPE = NONE; }
set_pin_assignment { clk } { LOCATION = R7; IOSTANDARD = LVCMOS33;
PULLTYPE = PULLUP; }
set_pin_assignment { mul1_key } { LOCATION = A2; IOSTANDARD = LVCMOS33;
PULLTYPE = PULLUP; }
set_pin_assignment { mul2_key } { LOCATION = B2; IOSTANDARD = LVCMOS33;
PULLTYPE = PULLUP; }
set_pin_assignment { reset_n } { LOCATION = C8; IOSTANDARD = LVCMOS33;
PULLTYPE = PULLUP; }整理成速查表:
| 信号 | 方向 | 引脚 | 说明 |
|---|---|---|---|
| clk | 输入 | R7 | 系统时钟 |
| reset_n | 输入 | C8 | 复位(低有效) |
| mul1_key | 输入 | A2 | 乘数 1 加 1 按键 |
| mul2_key | 输入 | B2 | 乘数 2 加 1 按键 |
| DIG[7:0] | 输出 | C15/C16/B6/A5/B5/A8/A7 | 数码管段选 |
| SEL[7:0] | 输出 | D11/E11/F10/C13/E10/E12/E16/F16 | 数码管位选 |
所有 IO 均为 LVCMOS33 电平,输入类信号带 PULLUP 上拉,输出类驱动强度设为 8。
上板流程
- 新建工程,加入
mul_con与M8B两个设计文件,完成顶层连接与综合。 - 按上一节逐条录入引脚约束,然后布局布线。
- 生成比特流,通过下载线缆烧写进 FPGA。
- 将开关 SW6 拨到 DIG 档,让数码管显示本实验内容。
现象与总结
原文描述的上板现象:
将开关 SW6 拨到 DIG 档,初始左侧两个数码管以及右侧两个数码管显示 0,点击 KEY3,KEY4 可以分别给两个乘数加 1,此时左侧两个数码管会对应显示乘数的值,左侧侧两个数码管显示 1 进制的乘法结果。本设计只能显示两个乘数为 1-15 的值,若继续点击,对应乘数的数码管会熄灭,而右侧显示结果的数码管只会显示低 8 位的值。

解读一下这段话里的三个「边界条件」:
- 乘数范围 1~15:4 位计数器从 0 计到 15,超过 15 就回绕,数码管会因此熄灭或显示异常——这不是 bug,是位宽决定的天花板。
- 结果只显示低 8 位:15 × 15 = 225 还能装进 8 位,但如果哪天你把 S 改大了,乘积超过 255 时高位会被截断,显示的就是「结果 mod 256」。
- 按键计数而不是拨码开关:这个设计用按键逐次加 1 来输入乘数,验证了「输入即电路状态」的思想——乘数不是静态的端口值,而是计数器的现态。
回顾整个实验,我们用两段很短的 Verilog 完成了一件事:把「乘法」还原成「移位 + 加法」的组合,再用人手按键把它变成可玩的上板演示。下一步可以试着把 S 参数改成 8,体验位宽翻倍后综合报告里逻辑资源的变化;也可以给 M8B 加一级寄存器输出,把它改造成真正的时序乘法器。
自测
自测
- 移位相加乘法器中,被乘数 AT 和乘数 BT 分别往哪个方向移位?为什么要这样移?
- 4 位乘法器的输出为什么要定义成 8 位?最大能表示的乘积是多少?
M8B里的for循环在综合后变成了什么?它和 C 语言里按时间逐步执行的 for 循环有什么本质区别?mul_con模块里为什么用negedge mul1_key而不是posedge clk采样按键?这样写有什么潜在风险?- 上板时如果继续按键让乘数超过 15,数码管会出现什么现象?原因是什么?