这一篇在干嘛?

上一节讲了综合器”认什么”,这一篇讲”怎么写得好”:每种操作符综合成什么电路(原书第 5 章),组合/时序逻辑的安全写法,RAM/DSP/状态机等典型模块的推荐代码,跨时钟域同步化,以及面积、时序、功耗三方面优化(第 6 章)。语法支持和综合指令见第 1 篇

表达式与操作符会综合成什么

组合逻辑:别造出 latch 和逻辑环

时序逻辑:DFF、复位与初值

存储器与硬核:RAM、移位寄存器、DSP

状态机:三种风格与两种编码

跨时钟域同步与三态总线

面积优化技巧

时序优化:从级数评估到流水线

功耗优化

表达式与操作符会综合成什么

表达式出现在赋值右侧或条件括号里,不同操作符综合出完全不同的电路,了解对应关系才能预估资源:

  • 合并 {} / 重复 {{}}{a, b} 位宽是两者之和;{2{a}} 位宽是被重复位宽乘次数。零成本,纯连线。
  • 移位<</>> 空位补 0;>>> 算术右移补最高位。移位常数基本不耗逻辑。
  • 按位逻辑 ~ & | ^ ~^逻辑 ! && ||:输出 1bit,多 bit 操作数做逻辑运算会隐式缩位或——把多 bit 信号当 if 条件时同样触发隐式缩位,注意是否是你想要的。
  • 条件选择 ?::综合成 2 选 1 MUX。
  • 加减 + -:短的用 LUT,长的用 SLICE 进位链(可用 use_ripple 指定)。
  • 比较 < <= > >= == !=:与加法器类似,短的 LUT、长的进位链。注意 =====!==!= 在综合中无区别,写短的即可。
  • *:小的用 SLICE,大的用 DSP 硬核。
  • /、模 %、乘方 **:占用面积大、级数多,尽量避免;确有需要用等价逻辑改写(如除以常数换成乘法+移位)。

操作数位宽不同时,窄的一侧高位补零对齐。位运算优先级很低,建议加括号避免错误:wire high_zero = (a & mask) == 0;

常见坑: assign a = a + 1; 写在 wire 上不是计数器,是一个组合逻辑环(输出反馈回输入),综合必然出问题。计数器必须用时钟沿触发的 reg 来写。

组合逻辑:别造出 latch 和逻辑环

组合逻辑的输出只取决于当前输入。两种建模方式:assign 连续赋值(不能出现在过程块中),以及 always 块中的过程赋值——敏感表必须列出所有依赖信号,或直接用 @(*)。过程赋值多次赋同一信号时按最后一次生效;阻塞赋值上文会影响下文。

规则一:组合 always 里每个分支都必须给所有输出赋值,且不能赋自身。 漏了 if 的 else、case 的 default,或写了 o = o;,输出就对当前值产生依赖,综合成 latch:

// 反例:else 分支保持 o 原值 → latch
always @(*)
    if (a)     o = b;
    else if(c) o = o;   // 保持原值 = 锁存
    else       o = o;

TD 不支持 latch,遇到会报 warning,需要改成所有分支无条件赋新值。

规则二:禁止组合逻辑环。 组合逻辑的输出不经过任何寄存器直接反馈回输入就是逻辑环,危害极大:

// 反例 1:两个反相器互相反馈
assign a = ~b;
assign b = ~a;
 
// 反例 2:输出依赖自身
assign a = a + 1;

时序逻辑下的逻辑环示例:上门级描述把输出反馈到输入形成环

如果设计确实需要”由当前值生成新值”,改成时序逻辑写法——用 FPGA 的 DFF 资源实现,效果更好。不稳定组合逻辑环(如奇数个反相器成环)还会在特定状态下振荡,造成大功耗、毛刺和时序违规,TD 会检测并报 warning,看到务必修改。

时序逻辑:DFF、复位与初值

DFF 用时钟沿触发的 always 描述,posedge/negedge 都可以,但不能双沿触发、不能多时钟沿触发

always @(posedge clk)  q <= d;   // 正确
always @(clk)          q <= d;   // 错:电平不是沿
always @(posedge clk1, posedge clk2) q <= d;  // 错:多时钟

DFF 可以设初值,定义时写或 initial 里写都行;数组可用 initial 里的 for 循环或 $readmemb/$readmemh 批量初始化:

reg q = 1'b0;                 // 定义时赋初值
initial q = 1'b0;             // 或者 initial 赋
reg [7:0] bytes[3:0];
initial begin
    for (i=0; i<4; i=i+1) bytes[i] = i;
end

异步 set/reset 的规则(面试和实战都常考):

  1. 异步信号的边沿必须写进敏感表,且必须在 always 块最开始判断并赋常数
  2. 异步 set 置位值必须是 1,复位赋 0(初始值与置/复位值极性要一致:初值 1 对应 set 赋 1,初值 0 对应 rst 赋 0)。
  3. 不能同时有异步 set 和异步 reset(敏感表里两个异步沿是非法的)。
  4. 复位值不能是可变信号——q <= value; 里的 value 不是常数,综合不了。
// 标准异步低有效复位
always @(posedge clk, negedge rstn)
    if (!rstn) q <= 1'b0;
    else       q <= d;

敏感表里没列出的信号若出现在赋值表达式中,综合器会为它综合一个 mux 做选择——这不是错误,但通常不是你的本意。

常见坑:异步复位信号跳变很快时,直接改成同步复位可能漏采样。可以先用一个寄存器异步采样复位信号,再用采样的结果同步复位本时钟域(async2sync 写法,见原书 6.1.6)。

存储器与硬核:RAM、移位寄存器、DSP

RAM 三种端口模式,写法决定综合结果:

// SP 单口:同一组地址读写
always @(posedge clk) begin
    dout <= mem[addr];
    if (we) mem[addr] <= din;
end
 
// SDP 简单双口:一组只写、一组只读(可异时钟)
always @(posedge rclk) dout <= mem[raddr];
always @(posedge wclk) if (we) mem[waddr] <= din;
 
// TDP 真双口:两组地址各自时钟下读写
always @(posedge clka) begin
    doa <= mem[addra];
    if (wea) mem[addra] <= dia;
end

同步读是综合成块 RAM 的关键dout <= mem[addr];(读数据打拍)或先 addr_sync <= addr; 再组合读(读地址打拍)都算同步读;always @(*) dout = mem[addr]; 是异步读,只能映射到 DRAM/SLICE。RAM 输出后再打一拍(Out-reg)对时序有利,IP Catalog 生成 RAM 默认带 Out-reg,推荐保留。读写同一地址的顺序有三种:先读后写(read before write)、先写后读(write through)、写时不读(normal),按需选择。RAM 复位值可为任意常数,但有 OUTREG 时两级寄存器的 rst 和复位值必须一致。

RAM 选型条件速查(EG4S20 属 PH1 系列,思路一致):

目标资源关键条件
ERAM/BRAM同步读;写口 ≤2;单写口时深度 ≥64(指定 ram_style="bram" 后不再限)
DRAM写口 ≤1(用 LUT 实现)
URAM(144K)指定 bram_144k;无初值;复位值全 0;双口同时钟、必须 OUTREG、只支持 NORMAL 模式
SLICE以上条件不满足,或指定 ram_style="none"(多写口不同时钟会报 CRITICAL WARNING)

移位寄存器:静态的用一个大 reg 循环移位,动态深度(运行时可变)用数组+for+SEL 索引读出。深度大的移位链建议让它映射成 SRL 而非一串 DFF(shifter_style 控制);从时序角度不建议路径终点是 SRL,高速设计在 SRL 尾部保留一个寄存器。

DSPa * b 小乘法用 LUT、大乘法自动用 DSP,可用 dsp_style 强制。PH 系列 DSP 内部有 5 级寄存器(输入到输出最多 5 拍),推荐打开输入输出寄存器帮助时序;DSP 内部各级寄存器可用不同的 enable 和 reset 信号。加乘组合(product = presum * c; tsum = product + d;)按顺序写就能被 DSP 吸收。

常见坑:中间寄存器位宽不够会丢进位。3 个 16bit 数相加需要 18bit 才存得下,声明成 16bit 结果就错了。

状态机:三种风格与两种编码

按输出依赖关系分两类:米利(Mealy)输出取决于当前状态和当前输入;**摩尔(Moore)**输出只取决于当前状态。结构都由下一状态逻辑、状态寄存器和输出逻辑构成。

米利(Mealy)状态机结构:输出组合逻辑同时看状态和输入

摩尔(Moore)状态机结构:输出只由当前状态决定

推荐用三段式:状态寄存器、下一状态译码、输出各占一个 always 块,结构清晰且输出可打拍:

// 第一段:状态寄存器(时序)
always @(posedge clk, posedge rst)
    if (rst) state <= STATE_IDLE;
    else     state <= state_next;
 
// 第二段:下一状态译码(组合)
always @(cd, state) begin
    case (state)
    STATE_IDLE: if (cd) state_next <= STATE_STOP;
                else    state_next <= STATE_IDLE;
    STATE_STOP: if (cd) state_next <= STATE_IDLE;
                else    state_next <= STATE_STOP;
    default:    state_next <= STATE_IDLE;   // 必须 default
    endcase
end
 
// 第三段:输出(时序,Moore)
always @(posedge clk)
    case (state)
    STATE_IDLE: out <= 1'b0;
    STATE_STOP: out <= 1'b1;
    endcase

一段式把三件事塞进一个 always;二段式合并后两段。输出是组合逻辑时也可以用 assign out = state ? d0 : d1; 代替第三段。

编码方式:二进制编码最省触发器(N 状态只需 log2(N) 位),但翻转多、功耗大、易毛刺、状态译码组合逻辑复杂;one-hot 独热码每个状态一位、译码简单、速度快、可靠性高,代价是触发器最多且会生成不可达的冗余状态。两者可用 fsm_encoding 指令互相转换——代码按二进制写,加 (*fsm_encoding = "one_hot"*) 让综合器换成独热码,反之亦然,无需改代码。

跨时钟域同步与三态总线

异步信号直接进另一个时钟域会产生亚稳态,按信号特征选同步方案:

  • 两级 DFF 同步器:快→慢时钟域的单 bit 电平信号,最基础写法 asy_in_d <= asy_in; asy_in_2d <= asy_in_d;
  • 信号展宽(翻转法):单脉冲跨域,在源域把脉冲转成电平翻转 if(pulse_a) pulse_inv <= ~pulse_inv;,目的域打三拍后用 pulse_inv_2d ^ pulse_inv_3d 还原成脉冲。
  • 异步 FIFO:多 bit 数据跨域的标准方案,读写地址转格雷码后互相同步(格雷码相邻只变 1 位,多 bit 同步不撕裂),用同步后的地址比较产生 full/empty。
  • 握手:req/ack 双向打拍确认,适合低速率多 bit 传输。

三态总线:只有管脚支持三态。顶层端口设为 inout,用三态逻辑驱动:assign io1 = tx_en ? dout : 1'bz;,或原语 bufif1 u_io3(io3, dout, tx_en);。开漏(open drain,如 I2C)写法是高电平表达为高阻:assign sda = da ? 1'bz : 1'b0;。多个模块可挂同一总线,也可跨模块描述。内部三态总线禁止:FPGA 内部无法实现三态,高阻空闲按 0 综合、0/1 冲突按 1 综合,开漏逻辑在内部无法综合。

面积优化技巧

  • 去掉 keep:加了 keep 的信号不再被优化,面积增大;量产版把调试用的 keep 删掉。
  • 慎用 keep_hierarchy:保留层次会阻止跨模块优化(常数传播、信号合并等都做不了),面积敏感时去掉该指令让 TD 打平优化。
  • Memory 尺寸对齐器件:避免位宽/深度刚好比 ERAM 支持的最大值多一点的尴尬尺寸,用 ram_style 指定实现方式。
  • 模块出口统一打拍:输入输出都打拍会白白增加 latency,统一只在模块出口寄存器打拍。
  • 复用复位信号:相同复位逻辑(同线网、同边沿、同同步/异步类型)的 DFF 才能相邻摆放;混用同步/异步复位造成控制逻辑不兼容、面积损失。功能允许时统一改成同步复位——异步复位必须用 DFF 控制逻辑实现,同步复位还可以用 LUT 实现,给综合器更多自由度。
  • 近似数值计算:精度不敏感时截掉低位。比较器只比高 20 位:timeout <= counter[31:12] > 20'h0001_B;;20x20 乘法截成 18x18:area <= {width[19:2] * height[19:2], 4'b0}; 避开 DSP 级联。
  • 常数乘法换移位加a*9 等价 (a<<3) + a,DSP 紧张时省下硬核。
  • 资源共享:下游 mux 二选一、永不同时使用的两个运算单元可以共享一份。result <= sel ? a*b : c*d; 改成 result <= (sel ? a : c) * (sel ? b : d); 两个乘法器变一个。也可用 resource_sharing 指令让综合器自动做。

时序优化:从级数评估到流水线

先学会估算级数。TD 对 PH2A 器件推荐每级组合逻辑约 500ps,“一级”可理解为一个 6 输入 LUT。常见电路的评估级数:

组合逻辑电路评估级数
2 输入 AND(LUT)0.4 级
6 输入 OR(LUT)1 级
一个 if-else 分支0.6 级
4 选 1 MUX1 级
32bit 加法(进位链)2 级
27x18 乘法(DSP)2 级
带 outreg 的 ERAM0.5 级
不带 outreg 的 ERAM2 级

例如 500MHz(2ns 周期)额度是 4 级:y <= a+b+c 是 2 个加法 4 级,勉强合格;y <= a+b+c+d 会被优化成 (a+b)+(c+d) 树状仍在额度内;y <= a+b+c+d+e 超额,运行时间暴涨难以收敛——该加流水线了

Pipeline 四种插拍位置,以 (a+b+c)*(x+y+z)(6 级)为例:

  1. 插 Out-reg(出口打拍):always @(posedge clk) result <= (a+b+c)*(x+y+z); 最常用。
  2. 插 In-reg(入口打拍):上游关系复杂、不敢动上游时在下游入口打拍;注意输入打拍对时序无直接好处,是无奈之选。
  3. 插中间 Reg:拆成两拍——第一拍算 sum_abc <= a+b+c;(18bit 中间寄存器!),第二拍算乘法。理想目标是每一拍都小于 4 级。
  4. 插 IP Reg:DSP 的 IREG/MREG/OREG、ERAM 的 OUTREG 都是现成的打拍选项,生成 IP 时勾上即可。

替代方案:Multi-cycle 约束——不插寄存器,用 SDC 告诉 STA 该路径允许 2 个周期,省 DFF 但要求会读 timing report:

create_clock -name CLK -period 1 [get_ports clk]
set_multicycle_path -from [get_regs ra*] -to [get_regs result*] 2

还有反沿借半拍:上游松弛下游紧张时,把中间打拍寄存器改成 negedge 采样,上游变 0.5 周期、下游变 1.5 周期(需配 set_multicycle_path 修正)。

树状还是链状?功能等价、资源相同,但时序路径不同:

树状加法:a、b、c、d 到输出的路径都经过 2 次加法,适合到达时间相近的场景

链状加法:四条路径分别经过 3、3、2、1 次加法,适合信号到达时间先后分明的场景

到达时间相近用树状,早到的信号让它多走几级。TD 通常会按到达时间自动转换,但进位特殊处理等操作会阻碍等价变换,此时需人为改写。

等价变换省资源:常数运算在综合期就能完成,把常数移到同一侧——a + C1 >= C2 改写为 a >= C2 - C1,一次加法+一次比较变一次比较;信号与信号比较比信号与常数比较贵,把变量聚到一侧(a + C1 >= b 改写为 a >= b - C1,大变小)。

if 长链转 case:一串 if-else if 综合成 MUX 优先级长链,改成 case 综合成平衡的 8 选 1 MUX,每条路径级数一致。但到达时间严重不平衡时保留 if 长链反而更好——让晚到的信号放进更高优先级的 if,落在长链下游 MUX 的输入端。

大 MUX 插拍:6bit 地址 64 选 1 MUX 就是 3 级 LUT,上游再有逻辑就是 4 级起。插拍时地址也要同步打拍,数据按低 2bit 预选成 16 份打拍、高 4bit 地址打拍,下一拍再从 16 选 1:

reg [5:2] addr_r;
always @(posedge clk) addr_r[5:2] <= addr[5:2];
reg [7:0] data_r[15:0];
always @(posedge clk)
    for (i=0; i<16; i=i+1)
        data_r[i] <= data[{i, addr[1:0]}];
always @(posedge clk)
    dout <= data_r[addr_r[5:2]];

功耗优化

Clock gating:长时间不工作的子模块(不使能、输入不跳变、输出不被选)把时钟关断,省时钟网络和 DFF 翻转的动态功耗。注意两点:TD 不会根据 if (clock_enable) q <= d; 自动推导 clock gate;时钟资源有限(全局+局部),时钟缓冲调多了会导致布局合法化和布线困难。

数据路径 Gating:使用频率极低但功耗极大的运算逻辑(典型是大乘法器),用使能把它的输入 gate 住,让输入不翻转:

ALU 中的乘法器:即使很少被选中,输入 a、b 一直翻转造成动态功耗

插入 gating 逻辑后:乘法器不被选中时输入被拉低,翻转停止

wire multi_en = enable && (operator == 3);
wire [15:0] multi_a = a & {16{multi_en}};   // 不选中时输入全 0
wire [15:0] multi_b = b & {16{multi_en}};
always @(posedge clk)
    if (enable)
    case (operator)
    0: result <= a & b;
    1: result <= a + b;
    2: result <= a - b;
    3: result <= multi_a * multi_b;
    default: result <= result;
    endcase

最后一条还是老朋友:避免不稳定组合逻辑环(奇数个反相器环等),它们在特定状态下持续振荡,是动态功耗黑洞。TD 检测到组合逻辑环会报 warning,看到就修。

通关标准:

  1. 能判断一段组合 always 会不会生成 latch,并当场改写成不生成的版本。
  2. 默写 SP/SDP/TDP 三种 RAM 的代码骨架,说出综合成 ERAM 的三个条件。
  3. 给定一个 5 级以上的关键路径,用”出口打拍 + 中间寄存器”拆成每拍 ≤4 级的流水线,并说明中间寄存器位宽怎么算。