序
这是北京航空航天大学计算机学院 2025 年计算机组成原理预习部分的 Verilog 部分。
IDE
本文使用 ISE 开发与仿真。
Verilog 语法
模块的定义方法
模块(module)是 Verilog HDL 的基本功能单元,它实际上代表了具有一定功能的电路实体。通俗来讲,其代表了电路中被导线连接的各个功能模块(子电路)。
以一个与门为例:
方法一:
1 | module AndGate( |
方法二:
1 | module AndGate(i1,i2,o); // 模块名定义及端口定义 |
两种方法没有实质上的区别,只是形式上有所不同:方法 1 对方法 2 中的端口定义及 IO 说明进行了合并。
模块以 module 开始,endmodule 结束,中间包括模块名、端口定义、I/O 说明等部分。模块中的语句除了顺序执行的语句块以外都是并行的;输入输出端口若不特别说明类型及位宽,默认为 1 位 wire 型。
常用数据类型
Wire 型
wire 型数据属于线网 nets 型数据,通常用于表示组合逻辑信号,可以将它类比为电路中的导线。它本身并不能存储数据,需要有输入才有输出(这里输入的专业术语叫驱动器),且输出随着输入的改变而即时改变。一般使用 assign 语句对 wire 型数据进行驱动(assign 语句将在下一节中进行讲解)。
wire 型的数据分为标量(1 位)和向量(多位)两种。可以在声明过程中使用范围指示器指明位数,如 wire [31:0] a;。冒号两侧分别代表最高有效位(MSB, Most Significant Bit)和最低有效位(LSB, Least Significant Bit)。在访问时,可以使用形如 a[7:4] 的方式取出 a 的第 7-4 位数据。
声明位宽时,如果写作 wire [0:31] a 也是可以的。此时高位被指定为第 0 位,低位被指定为第 31 位。为其赋值时 a = 32'h1234_5678 1 会实际存储在首位,而 8 会存储在末位,与正常相反。
信号定义好之后,不仅决定了位宽还决定了方向,例如定义为 [4:7] 的 b 信号,四个管脚分别为 4,5,6,7,在使用中只能正向接,不能反向接。因此接 b[4:7] 是合法的,而 b[7:4] 是不合法的;同理接 c[8:11] 是合法的,接 c[11:8] 是不合法的。(“接”指赋值操作)
在 Verilog 里,信号的位宽可以通过 in[a:b] 这样的语法来取一部分:
1 | assign out = in[7:4];//取 in 的第 7-4 位 |
这里的 a 和 b 都必须是常量,不能包含变量。比如写成 assign out = in[m * 4 + 3:m * 4] 就会报错,因为 m 是一个变量。
但是,Verilog-2001/SystemVerilog 提供了一种叫 part-select with variable index 的写法:
1 | assign out = in[start +: width];//从 start 位开始往高位取 width 位 |
这里的 start 则可以是变量,而 width 必须是常量。
Reg 型
reg 型是寄存器数据类型,具有存储功能。它也分为标量和向量,类似 wire 型,可以类比前面的教程。一般在 always 块内使用 reg 型变量(always 块将在本章后面提到),通过赋值语句来改变寄存器中的值。为了确定何时进行赋值,我们经常需要用到各种控制结构,包括 while、for、switch 等,这与 C 语言中的做法十分相似。
需要注意的是,reg 型变量不能使用 assign 赋值。而且,reg 型并不一定被综合成寄存器,它也可和 always 关键字配合(下一节会讲到),建模组合逻辑。
我们可以通过对 reg 型变量建立数组来对存储器建模,例如 reg [31:0] mem [0:1023];,其中前面的中括号内为位宽,后面的中括号内为存储器数量。这种写法在我们开始搭建CPU后会用到。
我们可以通过引用操作访问存储器型数据元素,类似于位选择操作,例如 mem[2] 就是访问 mem 中的第 3 个元素。
Verilog HDL 中没有多维数组。
数字字面量
Verilog 中的数字字面量可以按二进制(b 或 B)、八进制(o 或 O)、十六进制(h 或 H)、十进制(d 或 D)表示。
数字的完整表达为 <位宽>'<进制><值>,如 10'd100。省略位宽时采用默认位宽(与机器有关,一般为 32 位),省略进制时默认为十进制,值部分可以用下划线分开提高可读性,如 16'b1010_1011_1111_1010。
Verilog 中除了普通的数字以外,还有两个特殊的值:x 和 z。x 为不定值,当某一二进制位的值不能确定时出现,变量的默认初始值为 x。z 为高阻态,代表没有连接到有效输入上。对于位宽大于 1 的数据类型,x 与 z 均可只在部分位上出现。
注意数字的位宽决定了数字的最大值。比如 3'd101 就是一个非法的数字,因为 3 位宽的数字最大值为 7。
Verilog 数字本身并不能添加负号,但写作类似于 -8'd5 这样的形式可以看作是对数字的运算,是合法的。
Integer 型
integer 数据类型一般为 32 位,与 C 语言中的 int 类似,默认为有符号数,在我们的实验中主要用于 for 循环(将在本章后面提到)。
Parameter 型
parameter 类型用于在编译时确认值的常量,通过形如 parameter 标识符 = 表达式; 的语句进行定义,如:parameter width = 8;。在实例化模块时,可通过参数传递改变在被引用模块实例中已定义的参数(模块的实例化将在后面的章节进行介绍)。parameter 虽然看起来可变,但它属于常量,在编译时会有一个确定的值。
parameter 可以用于在模块实例化时指定数据位宽等参数,便于在结构相似、位宽不同的模块之间实现代码复用。
组合逻辑建模常用语法
Assign 语句
assign 语句是连续赋值语句,是组合逻辑的建模利器,其作用是用一个信号来驱动另一个信号。如 assign a = b;,其中 a 为 wire 型(也可由位拼接得到,见运算符部分),b 是由数据和运算符组成的表达式。
assign 语句与 C 语言的赋值语句有所不同,这里“驱动”的含义类似于电路的连接,也就是说,a 的值时刻等于 b。这也解释了 assign a = a + 1; 这样的语句为什么是不合法的。由于这样的特性,assign 语句不能在 always 和 initial 块中使用。
assign 语句经常与三目运算符配合使用建模组合逻辑。一般来说,assign 语句综合出来的电路是右侧表达式化简后所对应的逻辑门组合。
reg类型不能被assign赋值。未被
assign赋值(驱动)过的wire类型数据不能被赋给其他的wire类型数据。未被assign过的wire类型就好比什么都没连接的导线,它和其它导线连接是没有意义的。1 位的变量,不可以被两次
assign,多位的变量,每一位只能被一次assign。比如wire [3:0] output; assign output[1:0] = 2'b01; assign output[3:2] = 2'b10;这是合法的。而assign output = 4'b0; assign output[1:0] = 2'b01;是不合法的,因为 0 位和 1 位被两次赋值。
运算符
这里只介绍和 C 语言有差异的运算符。
-
Verilog 中没有自增和自减运算符。
-
操作数中有不定值
x和高阻态z时,结果中也可能出现。 -
逻辑右移
>>和算术右移>>>- 它们的区别主要在于前者在最高位补 0,而后者在最高位补符号位。
-
相等比较运算符
==和===、不等比较运算符!=和!====和!=可能由于不定值x和高阻值z的出现导致结果为不定值x,而===和!==的结果一定是确定的 0 或 1(x与z也参与比较)。
-
阻塞赋值
=和非阻塞赋值<=- 不同于
assign语句,这两种赋值方式被称为过程赋值,通常出现在initial和always块中,为reg型变量赋值。这种赋值类似 C 语言中的赋值,不同于assign语句,赋值仅会在一个时刻执行。由于 Verilog 描述硬件的特性,Verilog 程序内会有大量的并行,因而产生了这两种赋值方式。这两种赋值方式的详细区别会在之后的小节内介绍,这里暂时只需记住一点:为了写出正确、可综合的程序,在描述时序逻辑时要使用非阻塞式赋值<=。
- 不同于
-
位拼接运算符
{}- 这个运算符可以将几个信号的某些位拼接起来,例如
{a, b[3:0], w, 3'b101};;可以简化重复的表达式,如{4{w}}等价于{w,w,w,w};还可以嵌套,{b, {3{a, b}}}等价于{b, {a, b, a, b, a, b}},也就等价于{b, a, b, a, b, a, b}。
- 这个运算符可以将几个信号的某些位拼接起来,例如
-
缩减运算符
- 运算符
&(与)、|(或)、^(异或)等作为单目运算符是对操作数的每一位汇总运算,如对于reg[31:0] B; 中的B来说,&B代表将B的每一位与起来得到的结果。
- 运算符
时序逻辑建模常用语法
Always 块
always 块有如下两种用法:
-
若
always之后紧跟@(...),其中括号内是敏感条件列表,表示当括号中的条件满足时,将会执行always之后紧跟的语句或顺序语句块(和 C 语言中的语句块类似,只是将大括号用begin和end替换了)。这种用法主要用于建模时序逻辑。
例如:
1 | always @(posedge clk) // 表示在 clk 上升沿触发后面的语句块 |
-
若
always之后紧跟@ *或@(*),则表示对其后紧跟的语句或语句块内所有信号的变化敏感。这种用法主要用于与 reg 型数据和阻塞赋值配合,建模组合逻辑。 -
若
always紧跟语句,则表示在该语句执行完毕之后立刻再次执行。这种用法主要配合后面提到的时间控制语句使用,来产生一些周期性的信号。
always 的敏感条件列表中,条件使用变量名称表示,例如 always @(a) 表示当变量 a 发生变化时执行之后的语句;若条件前加上 posedge 关键字,如 always @(posedge a),表示当 a 达到上升沿,即从 0 变为 1 时触发条件,下降沿不触发;加上 negedge 则是下降沿触发条件,上升沿不触发。每个条件使用逗号 , 或 or 隔开,只要有其中一个条件被触发,always 之后的语句都会被执行。
敏感条件是变量时,该变量只要变化就会触发执行,没有对高低电平的要求。
多个 always 块中对同一个变量进行赋值会导致无法综合。
Initial 块
initial 块后面紧跟的语句或顺序语句块在硬件仿真开始时就会运行,且仅会运行一次,一般用于对 reg 型变量的取值进行初始化。initial 块通常仅用于仿真,是不可综合的。下面的代码用于给寄存器 a 赋初始值 0:
1 | reg a; |
wire 型数据不能在 always 和 initial 块中赋值。wire 类型本质上模拟硬件电路中的物理导线,它本身不具备存储功能,仅用于传递信号(从驱动源到接收端)。导线的特性是 “即时响应驱动源”—— 驱动源的信号变化会立即通过导线传递,没有时间延迟或状态保持。
而 always 块描述的是时序逻辑或组合逻辑的 “计算过程”,通常包含条件判断、状态跳转等逻辑,其赋值对象需要具备 “根据逻辑计算结果更新状态” 的能力。wire 作为导线,无法承载这种 “计算后更新” 的语义,因此不能作为 always 块的赋值目标。
语句块
块语句的作用是将多条语句合并成一组,使它们像一条语句那样。在使用上一节提到的各种控制语句或者要使用always/initial过程块时,如果要执行多条语句,就可以使用块语句,这就类似于 C 语言中大括号里的语句。块语句有两种:顺序块和并行块。顺序块的关键字是begin-end,并行块的关键字是fork-join,关键字位于块语句的起始位置和结束位置,相当于 C 语言中的左大括号和右大括号。块语句也可以嵌套。
- 顺序块中的语句是一条接一条按顺序执行的,只有前面的语句执行完成之后才能执行后面的语句,除非是带有内嵌延迟控制的非阻塞赋值语句。
- 如果语句包括延迟,那么延迟总是相对于前面那条语句执行完成的仿真时间的。
If 语句
Verilog 中 if 语句的语法和 C 语言基本相同,也有 else if、else 这样的用法。但是,if 语句只能出现在顺序块中,其后的分支也只能是语句或顺序块。举例如下(下面的例子也使用了 always 建模组合逻辑):
1 | always @ * begin |
Case 语句
Verilog 中的 case 语句与 C 语言的写法略有区别,详见下方的示例。case 语句同样只能出现在顺序块中,其中的分支也只能是语句或顺序块。与 C 语言不同,case 语句在分支执行结束后不会落入下一个分支,而会自动退出。举例如下:
1 | always @(posedge clk) begin |
Verilog 中的 case 语句默认做的是全等比较,即所有位都相等(包括 x 和 z)。上例中 data === 0 时 out 才会赋值为 4。
For 语句
for 语句和 C 语言中的类似。
循环变量
integer 类型和 reg 类型的变量均可以作为循环变量,但 reg 型需要注意位宽的设置以免造成死循环,譬如:
以下代码会造成 Isim 崩溃。
1 | reg [1:0] tmp; |
这是因为 tmp 位宽为 2,最大只能到 2’h3,当 tmp 等于 2’h3 时,下一轮循环 tmp 溢出,回到 2’h0,如此往复导致死循环。
While 语句
while 语句和 C 语言中的类似。
在 Verilog 中所有的循环语句只能在 always 或 initial 块中使用。
模块实例化
对于一个已经存在的模块 Sample,以及其定义好的接口 input a, input b, output c,我们可以通过以下方法进行实例化:
1 | wire x; |
非阻塞赋值和阻塞赋值
考察以下代码:
1 | module blocked_and_non_blocked( |
非阻塞赋值
clk 上升沿到来的时候,可以认为仿真器为 <= 右侧的变量做了一次“快照”,即存储了它们的值。然后将“快照”值赋给了 <= 左侧的变量。在上述代码中,b_non_blocked 值变为 a 的值,而 c_non_blocked 值变为原来的 b_non_blocked 值。
处在一个 always 块中的非阻塞赋值是在块结束时同时并发执行的。
阻塞赋值
阻塞赋值是顺序执行的。在 begin - end 顺序块中,前一句阻塞赋值完成后,后一句阻塞赋值才会开始。在上述代码中,上升沿到来时,b_blocked 值变为 a 的值,然后 c_blocked 值才变为新的 b_blocked 值,即 a 的值。
在时序逻辑中的阻塞赋值可能是不可综合的。
有符号数的处理
wire, reg 等类型的数据默认是无符号的。
若要声明该数据是有符号的,需要使用 $signed(),例如 $signed(a)。
一个简单的例子
1 | module comparator( |
我们编写一个 Testbench 来测试,令 a 和 b 的初始值都为 1,100 ns 后令 b 为 -1。
我们期望看到 res 的值恒为 1,但是实际观测到 100 ns 后 res 的值变为 0。
这正是因为我们没有声明 b 是有符号数。Verilog 默认其为无符号数,当 b = -1 时,其补码为 4’b1111,会被认为是 15。
将比较代码修改为 assign res = $signed(a) > $signed(b);,程序即可达到预期结果。
值得一提的是,假如将比较代码修改为 assign res = a > $signed(b);,得到的结果也达不到预期效果。
在对无符号数和符号数同时操作时,Verilog 会自动地做数据类型匹配,将符号数向无符号数转化。因为在执行 a > $signed(b) 时,a 是无符号数,$signed(b) 是符号数,Verilog 默认向无符号类型转化,得到的结果仍是无符号数的比较结果。
关于符号数和无符号数的原理,您可参考这里。简单地概括,一个表达式,只要其子表达式中有任一表达式是无符号,则该表达式就是无符号的。
一些注意事项
-
对于移位运算符,其右侧的操作数总是被视为无符号数,并且不会对运算结果的符号性产生任何影响。结果的符号由运算符左侧的操作数和表达式的其余部分共同决定。
-
对于三目运算符,其
?前的布尔表达式是自决定的表达式,不会对最外层表达式的符号造成影响。 -
算术右移在左操作数无符号时高位仍然补 0,与逻辑右移效果相同。
-
未指定位宽和进制的 0 的有无符号性是根据上下文决定的。而指定了位宽和进制的 0(比如 4’b0000)的符号是确定的。
宏定义
宏定义格式如下:
1 |
|
定义时,需要以反引号(`)开头。使用时,也需要加上反引号。
Verilog 例题
电梯调度
简介
一栋大楼有一部运行的电梯,你需要根据乘客请求和电梯状态来输出。
电梯的具体信息
-
在最开始或者每次 reset 后,电梯默认初始楼层为一楼。保证在输入到来前先进行 reset。
-
初始运行方向:每次 reset 后,电梯默认向上运行。
-
运行范围:1 至 7 层。
-
调度规则:
- 如果当前周期没有乘客请求 (乘客请求详细信息见下文),则电梯将会按照当前的运行方向运动一层,并在下一周期更新楼层;如果位于边界楼层且运行方向越界,则反转运行方向并运动一层。
- 如果当前周期有乘客请求,且乘客请求楼层与当前电梯所在楼层不相同,则电梯将会向乘客请求楼层方向移动一层,并在下一周期更新楼层,电梯运行方向也调整为向乘客请求楼层运行的方向 (若与原来方向一致则不用调整) 。
- 如果当前周期有乘客请求,且乘客请求楼层与当前电梯所在楼层相同,则电梯将保持不动,并在下一周期维持楼层,电梯运行方向不改变。
乘客请求
-
存在乘客请求的条件:
- 若 from = 0 并且不存在未完成的乘客请求,则视为当前周期没有乘客请求。反之,如果 from != 0 或者+存在未完成的乘客请求++,则视为当前周期有乘客请求。
- 若 from != 0,则视为当前周期有新到来的乘客请求。该乘客请求将会一直存在直到满足取消条件 ( 即使后续周期 from = 0 ) ,在此期间内视为:存在一个未完成的乘客请求。
- 若 from = 0 并且存在一个未完成的乘客请求,则该乘客请求继续存在直至满足取消条件。
-
取消乘客请求的条件:
- 若当前周期的乘客请求楼层和电梯的当前所在楼层相同,则视为满足该请求的取消条件,在下一个周期开始时取消该请求。
- 若当前周期有 reset 信号,则在下一周期取消该乘客请求。
-
其他规则:输入数据保证在上一个乘客请求满足取消条件之前,不会输入下一个乘客请求。 也就是当 from != 0 时,当前一定不存在未完成的乘客请求。
输出要求
当乘客请求楼层和电梯的当前所在楼层相同时,输出 1 ;否则输出 0 。
样例

如图,在 reset 后电梯默认从一楼向上运行。
在电梯运行到三楼时,输入了一楼的乘客请求,于是下个周期电梯向乘客请求楼层 (一楼) 方向运行,回到了二楼。然后再下个周期电梯到了一楼,此时电梯楼层和乘客请求楼层相同,因此输出 out 置为 1 ,同时下个周期电梯保留楼层和运行方向。再下个周期输入为 0,电梯掉头向上运行,同时取消上一个乘客请求。
当电梯继续运行到五楼时,输入了同层五楼的乘客请求,输出 out 立刻置为 1 ,同时下一周期电梯保留原楼层和运行方向,同时取消乘客请求。再下个周期输入为 0,电梯正常向上运行至六楼。
请认真阅读波形图,一切逻辑以波形图所示为准!
分析
本题适合用时序逻辑来解决。按照题意,在没有乘客请求的时候,电梯全自动运行,故首先我们需要解决这个问题。我们应当维护两个变量,它们分别是当前的楼层和电梯运行方向,记作 cur_floor 和 direction。简单起见,由于楼层只有七层,可以用一个 3 位二进制数表示(001 至 111),方向则用 0 和 1 表示上与下,简略代码如下:
1 | initial begin//初始化在一楼,向上 |
如果有乘客请求,其实有两种情况需要考虑:
-
当
from信号出现的时候,电梯尚未到该楼层。 -
当
from信号出现的时候,电梯已经到该楼层。
如果电梯还没到,则需要想办法保存下这个乘客请求的楼层号,然后及时调整电梯运行方向。当某个上升沿确认 cur_floor 等于 from 的楼层(当然这个 from 是一个副本,因为 from 输入可能只持续一个周期就归零了,接下来将把这个副本叫做 request)时,让下一个周期保持运行方向和楼层不变。这是样例中 30ns 时发出 1 楼请求的情况。可以看到,45ns 的上升沿电梯刚好到 1 楼,同时 request 等于 cur_floor,out 置为 1,下一个周期(55ns - 65ns)电梯保持在 1 楼。
如果发出乘客请求时,电梯正好处于该楼层,则情况稍微会复杂些。按照样例的解释(一切逻辑以样例为准!),100ns 发出 5 楼的请求,此时时钟信号正处于下降沿,而 out 被立刻置为 1。很明显 out 的输出采用的组合逻辑的 assign 语句,一检测到 from 或 request 与 cur_floor 相匹配就立刻置为 1(对于第一种情况,当然是检测 request 的信号,第二种则是 from)。再看电梯停留在 5 楼的时间。105ns 时电梯本准备往 6 楼去,而实际上停留在 5 楼,这就说明 105ns - 115ns 是电梯停留的一周期。也就是说,95ns - 105ns 发生了 cur_floor 和 from 的判定,即在第二种情况下判定并不是发生在上升沿的,而是立即判定!
我们如果想要电梯停留在某一层一个周期,可以这样写:
1 | if (condition) begin |
无论怎么说,使用非阻塞赋值就决定了这一逻辑一定会在某个上升沿激活。对于第一种情况,我们可以大胆地让 cur_floor == request 作为条件,根据“乘客请求-其他规则”,request 尚未解决时不会有新的 from 出现。可以预见的是一定会在某个上升沿满足条件,然后下一个上升沿执行上述的等待逻辑。对于第二种情况,则大有不同了,接受 from 信号后将其赋给 request 必定也使用非阻塞赋值,那么 request 的赋值要等到 from 信号来临的下一个上升沿,即二者会出现一定的时间差。这对于 out 的输出,以及电梯的等待,都是不可接受的。具体到样例中,如果接到 from 信号后立刻赋给 request,那么 105ns 时 request 才会被置为 1。如果还用 cur_floor == request 作为条件,5 楼的等待时间将会延长到 115ns - 125ns 周期,这就错了。而且 out 与 from 和 request 挂钩,如果在 request 已经被赋值(105ns),再用条件判断将其归零,也许要等到下一个上升沿(115ns),这样, out 就会从 100ns 一直激活到 115ns。
总的来说,面对第二种情况,等待逻辑的判定条件必须是 cur_floor == from,而且如果满足该条件,就要立刻发出 request 归零的命令,让 request 赶在下一个上升沿(105ns)就归零,相当于“反悔给 request 赋值”(这样做从波形上来看,request 不会被赋值,因为赋值和归零都是在上升沿以外的地方进行的),避免 out 激活时间过长。
Verilog 工程的设计开发调试
编写可综合代码
以下规则不适用于 Testbench。
-
勿使用 Initial 块、勿为 Reg 型(寄存器)赋初值。
-
一个寄存器只能在一个
always块中赋值一次。
以下代码不可综合:
1 | reg a; |
实际上,Reg 型一般会被综合为 D 触发器,只有一个时钟输入,而上述代码让该触发器处于两个时钟域中。
何谓赋值一次?如果使用 if / else / case 语句进行条件判断,在不同且互斥的情况下对同一个寄存器进行赋值,是完全合法的。而其他情况是不可被综合的。
-
尽量避免综合后的奇怪故障
- 在时序逻辑中,永远使用非阻塞赋值(
<=);在组合逻辑中,永远使用阻塞赋值(=); - 每个组合逻辑运算结果仅在一个
always @(*)中修改; - 在
always @(*)中,为每个运算结果赋初值,避免 latch[1] 的产生。 - 使用位运算代替乘除法。
- 在时序逻辑中,永远使用非阻塞赋值(
Verilog 代码规范
命名
-
信号名采用
snake_case,PascalCase或者camelCase。全工程采用统一命名方式。snake_case:变量名全小写,单词间以下划线连接。PascalCase:首字母全大写。camelCase:第一个字母小写,后续首字母大写。
-
低电平有效信号用
_n后缀。 -
多路选择器标明规格。例如 4 选 1 的 32 位 MUX可记作
MUX4_1_32。 -
对于状态机,各状态一定要命名,避免在代码中出现不知所云的数字。
组合逻辑的编写
-
一个信号只在一个
always块中赋值。 -
组合逻辑用
always @(*)块或者assign。 -
组合逻辑的
always块只用阻塞赋值。 -
确保所有分支都赋值,否则出现锁存器[1:1]。
时序逻辑
-
时序逻辑用
always @(posedge clock)。 -
时序逻辑的
always块只用非阻塞赋值。 -
通常情况下,不要用下降沿触发。
-
除了
always敏感列表外,不要用时钟信号。 -
使用同步复位而非异步复位[2]。
代码风格
-
单目运算符与变量间不添加空格。
-
同一逻辑,但表达式复杂的语句,使用换行进行切割:
1 | // GOOD |
-
显式声明数字位宽。