2. Verilog基础与RTL设计:模块化设计、组合逻辑、时序逻辑、阻塞与非阻塞赋值

好,咱们今天聊聊Verilog基础里最核心的几个概念。说实话,面试的时候,这块内容我几乎必问。为什么?因为这是区分「会写代码」和「会做设计」的分水岭。

很多新手觉得Verilog就是写代码,跟C语言差不多。嗯,这个想法很危险。Verilog本质上是描述硬件,不是写软件。你写的每一行代码,最终都会变成实实在在的门电路、触发器和连线。脑子里没有硬件电路图,代码写得再花哨也没用。

2.1 模块化设计:把大问题拆成小积木

我个人习惯,拿到一个设计需求,第一件事不是写代码,而是画模块框图。模块化设计说白了就是「分而治之」——把复杂系统拆成功能独立的小模块,每个模块只干一件事。

举个例子,一个UART收发器,你可以拆成:波特率发生器、发送模块、接收模块、控制寄存器模块。每个模块单独设计、单独验证,最后再拼起来。我在项目中遇到过,有人把所有逻辑写在一个模块里,结果调试的时候,一个信号改了,整个模块都得重新综合,痛苦得很。

模块化设计的好处:

  • 可复用:写好的模块,下个项目直接拿来用
  • 可维护:改一个功能,只动一个模块
  • 可验证:每个模块单独仿真,定位问题快
小技巧:模块的接口要尽量简单。我一般遵循「一个模块只做一件事,接口信号不超过20个」的原则。接口太多,说明这个模块该拆了。

2.2 组合逻辑:assign和always@(*)

组合逻辑,就是输出只取决于当前输入,跟历史状态无关。说白了,就是一堆门电路连在一起,输入一变,输出立刻跟着变。

Verilog里描述组合逻辑有两种方式:

2.2.1 assign语句

适合描述简单的逻辑关系,比如数据选择、位运算、加法器。用起来很直接:

// 2选1多路选择器
assign out = sel ? a : b;

// 位拼接
assign data_out = {byte0, byte1, byte2, byte3};

// 三态门
assign data_bus = enable ? data_in : 1'bz;

你想想看,assign左边的信号必须是wire类型,右边可以是wire或者reg。这个细节面试常考。

2.2.2 always@(*) 语句

当逻辑比较复杂,需要if-else或case语句时,就用always@(*)。这里的星号表示敏感列表自动推导,所有输入信号变化都会触发这个块。

// 4选1多路选择器
always @(*) begin
    case(sel)
        2'b00: out = a;
        2'b01: out = b;
        2'b10: out = c;
        2'b11: out = d;
        default: out = 1'b0;
    endcase
end
我曾经踩过的坑:在always@(*)里漏掉敏感信号。比如你写了一个组合逻辑,但敏感列表里只写了a和b,忘了写c。综合工具会警告,但仿真结果可能不对。所以现在我都用always@(*),让工具自动推导,省心。

组合逻辑设计要点:

  • 所有赋值必须用阻塞赋值(=)
  • 所有分支必须覆盖完整,否则会生成锁存器
  • 避免组合逻辑反馈环路

2.3 时序逻辑:always@(posedge clk)

时序逻辑就不一样了,它依赖时钟边沿来更新状态。说白了,就是触发器在干活。时钟上升沿到来时,采样输入,更新输出。

最基本的写法:

// 带同步复位的D触发器
always @(posedge clk) begin
    if (rst)
        q <= 1'b0;
    else
        q <= d;
end

// 带异步复位的D触发器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        q <= 1'b0;
    else
        q <= d;
end

这里有个关键点:时序逻辑里必须用非阻塞赋值(<=)。为什么?因为非阻塞赋值是「并行更新」的,所有赋值在时钟边沿同时生效,这正好符合触发器的行为。

面试高频题:同步复位和异步复位的区别?我的回答是:同步复位只在时钟沿有效,抗毛刺能力强;异步复位响应快,但容易受毛刺影响。实际项目中,我常用异步复位、同步释放的方式,兼顾两者优点。

2.4 阻塞赋值与非阻塞赋值:最容易翻车的地方

这块内容,我建议你反复看,直到形成肌肉记忆。面试时十个人里有八个会在这上面栽跟头。

核心规则就两条:

  • 组合逻辑用阻塞赋值(=):描述纯组合电路
  • 时序逻辑用非阻塞赋值(<=):描述寄存器电路

为什么会这样?咱们用代码说话:

// 错误示范:时序逻辑里用阻塞赋值
always @(posedge clk) begin
    a = b;
    c = a;  // 这里c得到的是a更新后的值,不是b的原始值
end

// 正确示范:时序逻辑里用非阻塞赋值
always @(posedge clk) begin
    a <= b;
    c <= a;  // 这里c得到的是a的旧值,即b的原始值
end

你想想看,阻塞赋值是「顺序执行」的,先更新a,再更新c。而非阻塞赋值是「并行执行」的,所有赋值同时采样、同时更新。这正好对应了硬件的行为——所有触发器在同一个时钟沿同时采样。

我曾经犯过的错:在一个状态机里,我混用了阻塞和非阻塞赋值。结果仿真通过,但上板后状态跳转完全乱套。查了两天才发现,是某个always块里用了阻塞赋值,导致状态更新顺序错了。从那以后,我写代码前都会先确认:这个always块是组合逻辑还是时序逻辑?

2.5 实战建议:写代码前先画电路

说了这么多,最后给个实用建议。每次写RTL代码前,先在脑子里或者纸上画出对应的电路图。组合逻辑就是一堆门,时序逻辑就是触发器加组合逻辑。电路图画清楚了,代码自然就写对了。

我面试时经常问候选人:「你写这段代码,综合出来是什么电路?」能答上来的,基本是靠谱的工程师。答不上来的,回去还得再练练。

嗯,今天就聊到这儿。下一章咱们讲讲状态机的设计,那可是数字电路设计的核心技能之一。