第一次接触Verilog时,很多人会把它当成普通编程语言来学,结果发现代码烧写到FPGA后行为完全不符合预期。我当年就犯过这个错误——用C语言的思维写了三天三夜流水灯,最后板子上的LED全乱套了。直到看见导师在示波器上抓取的信号波形,才恍然大悟:Verilog是硬件描述语言,每一行代码都对应着真实的物理电路。
以最简单的LED控制为例,当你写下assign led = key_press时,实际上是在PCB板上焊接了一根连接按键和LED的导线;而always @(posedge clk) cnt <= cnt +1则会在芯片内部实例化一个带时钟端的寄存器。这种代码与电路的映射关系,正是FPGA开发最迷人的地方。下面我们就用硬件工程师的视角,拆解always、case、assign这些核心语法背后的电路秘密。
提示:本文所有代码示例都附带对应的RTL视图截图,建议配合Xilinx Vivado或Intel Quartus的综合结果对照阅读
当你在代码中看到always @(posedge clk)时,请立即联想到D触发器构成的寄存器。这是我调试过的经典计数器代码:
verilog复制always @(posedge clk or negedge rst_n) begin
if(!rst_n)
counter <= 32'd0;
else
counter <= counter + 1'b1;
end
综合后的电路非常直观:一个32位宽的寄存器,时钟端接系统时钟clk,复位端接rst_n,数据输入端连接加法器输出。**非阻塞赋值<=**在这里至关重要,它保证了所有寄存器在时钟边沿同步更新,就像阅兵式里士兵们整齐划一的步伐。
实际项目中我曾遇到过诡异的现象:一个本该每秒计数一次的模块,实测却跳变了多次。后来用逻辑分析仪抓取信号发现,原来是误用了阻塞赋值导致寄存器行为异常。这个坑让我深刻理解了:时序逻辑必须用非阻塞赋值,这是铁律!
不带时钟的always块则对应着纯粹的组合电路,比如这个三态门控制逻辑:
verilog复制always @(*) begin
if(en)
data_out = mem[addr];
else
data_out = 8'bz;
end
注意这里敏感列表用了@(*),这是Verilog-2001的新语法,编译器会自动推断所有输入信号。我曾手动列出过@(en or addr or mem),结果漏掉了mem的某个位导致仿真与综合不一致。组合逻辑一定要用阻塞赋值=,这样才能实现信号传播的即时性。
case语句在硬件中通常被实现为多路选择器(MUX)。但根据写法不同,综合结果可能有天壤之别。来看这个七段数码管译码器:
verilog复制always @(*) begin
case(num)
4'h0: seg = 8'b1100_0000;
4'h1: seg = 8'b1111_1001;
//...其他数字
default: seg = 8'b1111_1111;
endcase
end
综合工具会生成一个16选1的MUX,所有输入路径完全并行。但若改用if-else嵌套:
verilog复制if(num == 4'h0) seg = 8'b1100_0000;
else if(num == 4'h1) seg = 8'b1111_1001;
//...
就会变成带优先级的级联结构。我在一个高速ADC项目中就吃过亏——if-else导致的较长组合路径成了时序瓶颈。对速度敏感的设计要优先使用case语句,它能保证各路径延迟均衡。
有限状态机(FSM)是case语句的经典应用场景。这是我为电机控制器写的三段式状态机:
verilog复制// 状态寄存器
always @(posedge clk)
current_state <= next_state;
// 状态转移逻辑
always @(*) begin
case(current_state)
IDLE: next_state = (start) ? RUN : IDLE;
RUN: next_state = (done) ? STOP : RUN;
STOP: next_state = (reset) ? IDLE : STOP;
endcase
end
// 输出逻辑
always @(*) begin
case(current_state)
IDLE: {en, dir} = 2'b00;
RUN: {en, dir} = {1'b1, cw};
STOP: {en, dir} = 2'b10;
endcase
end
这种写法生成的电路清晰可预测:第一个always块对应状态寄存器,后两个生成组合逻辑。对比单always块写法,虽然代码量多些,但避免了输出信号被不必要地寄存器化。
assign语句描述的是永久的连接关系,就像用焊锡直接连通两个焊点。这个PWM调制器的例子特别能说明问题:
verilog复制module pwm_gen(
input [7:0] duty,
input clk,
output pwm_out
);
reg [7:0] counter;
always @(posedge clk)
counter <= counter + 1;
assign pwm_out = (counter < duty);
endmodule
assign语句在这里实例化了一个比较器,其输出直接驱动pwm_out引脚。如果误用always块实现:
verilog复制always @(*)
pwm_out = (counter < duty);
虽然功能相同,但前者更准确地反映了设计意图——一个持续比较的电路,不需要任何触发条件。
assign特别适合做总线拼接和位选择。比如这个串口发送模块:
verilog复制wire [9:0] tx_frame;
assign tx_frame = {1'b1, data[7:0], 1'b0}; // 停止位+数据+起始位
花括号{}表示位拼接,综合后就是简单的连线重组。我在实现摄像头数据接口时,曾用assign完成RGB565到RGB888的转换:
verilog复制assign rgb888 = {r5[4:0], 3'b0, g6[5:0], 2'b0, b5[4:0], 3'b0};
这种写法比用always块简洁得多,而且综合工具能生成更优化的电路。
新手最常混淆的就是阻塞(=)与非阻塞(<=)赋值。这个例子能直观展示区别:
verilog复制// 阻塞赋值 - 组合逻辑
always @(*) begin
a = b;
c = a; // c立即得到b的值
end
// 非阻塞赋值 - 时序逻辑
always @(posedge clk) begin
a <= b;
c <= a; // c得到的是b的旧值
end
第一个always综合成两条直接连线,第二个则生成两个级联的寄存器。有个项目需要实现移位寄存器,我最初误用阻塞赋值导致整个链式结构坍缩成一个寄存器,教训深刻。
reg和wire的选择也直接影响电路结构:
这个呼吸灯设计展示了典型用法:
verilog复制module breath_led(
input clk,
output led
);
reg [15:0] counter; // 需要累加,必须声明为reg
wire [15:0] pwm; // 中间连线
always @(posedge clk)
counter <= counter + 1;
assign pwm = counter[15] ? ~counter : counter;
assign led = (pwm > duty_cycle);
endmodule
counter虽然声明为reg,但因为用在时序always块中,实际综合为16位寄存器;而pwm作为中间结果,用wire类型更合适。