3. RTL设计方法论:数字电路设计流程、Verilog/VHDL基础回顾、同步设计原则

各位同学,咱们直接进入正题。RTL设计,说白了就是数字电路的“施工图纸”。你光有算法、有架构,那都是空中楼阁。怎么把它变成能在FPGA上跑、能送去流片的网表?靠的就是RTL代码。这一章,我带你捋一遍数字电路设计的完整流程,顺便把Verilog里那些最常用的东西过一遍,重点聊聊同步设计——这可是我踩坑最多的一个环节。

3.1 数字电路设计流程:从想法到芯片

一个完整的数字芯片设计,不是上来就写代码。我见过太多新手,拿到需求就开始敲Verilog,结果后面综合、时序全崩,返工返到崩溃。正确的流程应该是这样的:

  1. 需求分析与架构设计:搞清楚你要做什么,性能、功耗、面积指标是多少。画顶层框图,划分模块。
  2. RTL编码:用Verilog或VHDL描述电路行为。注意,是“描述电路”,不是“写软件”。
  3. 功能仿真:跑testbench,验证逻辑对不对。这一步叫前仿。
  4. 逻辑综合:把RTL代码映射到标准单元库,生成门级网表。
  5. 形式验证:检查综合后的网表跟RTL功能是否一致。这一步很多人会跳过,但我建议你别省。
  6. 静态时序分析(STA):检查电路能不能跑在目标频率下。嗯,这里要注意,STA是“静态”的,不需要输入激励。
  7. 布局布线:把标准单元摆到芯片上,连好线。
  8. 后仿真:带上寄生参数再仿一遍,看看实际效果。

你想想看,这每一步都有坑。我曾经在一个项目里,前仿跑得完美无缺,结果后仿直接崩了——就是因为布局布线后,一条关键路径的延迟超了。从那以后,我每次做设计都会提前做时序预算,绝不等到最后才看时序。

核心要点:RTL设计只是流程中的一环,千万别孤立地看待它。写代码的时候,脑子里就要想着综合、时序、面积。

下面这张图,是我自己总结的数字电路设计流程,你可以把它当作一个检查清单:

需求分析 架构设计 RTL编码 功能仿真 逻辑综合 形式验证 静态时序分析 布局布线 后仿真 & 流片 迭代优化

3.2 Verilog/VHDL基础回顾:够用就行

我不打算把Verilog语法从头到尾讲一遍,那太浪费时间了。咱们只挑RTL设计里最常用的几个点,快速过一遍。

3.2.1 模块结构

一个Verilog模块,说白了就是“输入→逻辑→输出”。我习惯把模块分成三部分:端口声明、内部信号、逻辑描述。

module adder #(
    parameter WIDTH = 8
)(
    input  logic [WIDTH-1:0] a,
    input  logic [WIDTH-1:0] b,
    input  logic             cin,
    output logic [WIDTH-1:0] sum,
    output logic             cout
);
    // 内部信号
    logic [WIDTH:0] temp;
    
    // 逻辑描述
    assign temp = a + b + cin;
    assign sum  = temp[WIDTH-1:0];
    assign cout = temp[WIDTH];
endmodule

你看,这个加法器用了参数化设计。我个人习惯把所有可配置的位宽都用parameter定义,这样后期改起来特别方便。我曾经在一个项目里,因为没做参数化,后来要改数据位宽,改了十几个模块,差点改出bug来。

3.2.2 组合逻辑 vs 时序逻辑

这是新手最容易搞混的地方。记住一句话:组合逻辑没有记忆,时序逻辑有记忆。

类型 always块写法 赋值方式 典型用途
组合逻辑 always_comb 或 always @(*) 阻塞赋值 = 多路选择器、译码器
时序逻辑 always_ff @(posedge clk) 非阻塞赋值 <= 寄存器、计数器、状态机

我的小技巧:写组合逻辑时,用阻塞赋值;写时序逻辑时,用非阻塞赋值。这个规则别打破,否则仿真结果会跟你预期的不一样。我见过有人混用,结果前仿和后仿结果不一致,查了三天才找到原因。

3.2.3 状态机写法

状态机是数字电路的核心。我推荐三段式写法:

// 第一段:状态转移
always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        state <= IDLE;
    else
        state <= next_state;
end

// 第二段:次态逻辑(组合逻辑)
always_comb begin
    next_state = state;
    case (state)
        IDLE:   if (start) next_state = CALC;
        CALC:   if (done)  next_state = DONE;
        DONE:   next_state = IDLE;
        default: next_state = IDLE;
    endcase
end

// 第三段:输出逻辑(组合逻辑)
always_comb begin
    calc_en = 1'b0;
    done_flag = 1'b0;
    case (state)
        CALC: calc_en = 1'b1;
        DONE: done_flag = 1'b1;
    endcase
end

为什么用三段式?因为可读性强,综合出来的电路也清晰。我曾经接手过一个项目,里面全是用一段式写的状态机,代码又长又乱,改一个状态要翻半天。从那以后,我强制团队所有成员都用三段式。

3.3 同步设计原则:别让你的电路“异步”

同步设计,说白了就是所有时序逻辑都受同一个时钟沿控制。这是数字电路设计的黄金法则。你想想看,如果电路里既有上升沿触发的寄存器,又有下降沿触发的,还有异步复位的,那时序分析会变得多复杂?

3.3.1 单时钟域设计

我建议,只要条件允许,尽量用单时钟域。一个时钟,一个复位,简单明了。

always_ff @(posedge clk) begin
    if (rst_n) begin
        reg_a <= 1'b0;
        reg_b <= 1'b0;
    end else begin
        reg_a <= data_in;
        reg_b <= reg_a;
    end
end

你看这个例子,reg_a和reg_b都在同一个时钟沿更新。这样综合工具很容易做时序分析,STA也简单。

3.3.2 避免组合逻辑环路

组合逻辑环路是数字电路里的“死锁”。一旦出现,仿真可能跑不出结果,综合也会报错。

警告:千万不要写出这样的代码:

assign a = b & c;
assign b = a | d;  // 这是组合环路!

我曾经在代码评审时发现一个同事写了类似的环路,综合工具直接报“combinational loop detected”。最后花了半天才把逻辑理清楚。

3.3.3 同步复位 vs 异步复位

这是个老生常谈的话题。我个人偏好同步复位,因为综合出来的电路更干净,时序分析也更容易。

复位方式 优点 缺点
同步复位 时序分析简单,抗毛刺 需要时钟才能复位
异步复位 复位信号不依赖时钟,响应快 容易产生亚稳态,需要做复位同步

我的建议:如果你用异步复位,一定要做“异步复位同步释放”处理。否则,复位信号撤除时如果刚好在时钟沿附近,寄存器会进入亚稳态。我在一个高速设计项目里吃过这个亏,后来老老实实加了同步释放电路。

3.3.4 跨时钟域处理

如果实在避免不了多时钟域,那跨时钟域信号必须做同步处理。最常用的方法就是两级寄存器同步。

// 跨时钟域同步器
always_ff @(posedge clk_b) begin
    sync_1 <= data_from_clk_a;
    sync_2 <= sync_1;
end

assign data_sync = sync_2;

两级寄存器可以把亚稳态的概率降到极低。但注意,这只适用于单比特信号。如果是多比特数据总线,需要用异步FIFO或者握手协议。

嗯,同步设计的原则其实就这些。但真正做项目的时候,你会发现处处都是细节。比如时钟树怎么插、复位怎么分配、扫描链怎么插入——这些都是在RTL阶段就要考虑好的。我个人的习惯是,写RTL之前先画时序图,把每个信号的波形画清楚,再动手写代码。这样能避免很多后期的问题。

好了,这一章的内容就到这里。记住,RTL设计不是写软件,你写的每一行代码都会变成实实在在的硬件。多想想综合器会怎么理解你的代码,多想想时序能不能收敛。养成好习惯,后面会省很多事。

可可精品资料库