This article describes how I prefer to write synthesizable SystemVerilog RTL. It is not a language tutorial, and it is not an attempt to define the only correct style. It is a record of the choices that make RTL easier for me to review, simulate, synthesize, integrate, and debug in an ASIC-oriented flow.

The central idea is simple:

RTL should make the intended hardware structure obvious and leave as little room as practical for tools or reviewers to infer the wrong thing.

That means caring about more than whether a block compiles. Priority should be visible, parallel operations should look parallel, widths and signedness should be deliberate, and signal ownership should survive hierarchy. The code should behave predictably across simulation, lint, CDC, formal, synthesis, DFT, STA, place-and-route, gate simulation, and ECO work.

An important inspiration for these ideas was James W. Hanlon’s article Writing synthesizable Verilog. Many of the rules below are the same as, or directly inspired by, his guidance: using logic, always_comb, and always_ff; making widths and carry bits explicit; keeping naming and generate hierarchy disciplined; qualifying package symbols; and minimizing the preprocessor. I have adapted that foundation to my own ASIC-oriented workflow, added rules around hierarchy boundaries and state machines, and made a smaller number of different choices. I call out those differences where they matter.

The article comes with a reusable Synthesizable RTL Style Agent Skill for applying the same preferences to AI-assisted RTL writing and review. The style is the subject; the skill is its reusable form.

Scope and design mindset

These rules apply to synthesizable design RTL. They do not automatically apply to testbenches, UVM, verification frameworks, checkers, classes, or other non-synthesizable code. Verification code has different goals and should not be forced into a design-RTL subset.

When reading a block, I ask:

  • Which values are state?
  • What logic exists between registers?
  • Where is priority encoded?
  • Which operations are independent and parallel?
  • What are the width and signedness of intermediate results?
  • Which module owns each signal?
  • Which assumptions am I giving to synthesis?

If these answers are hard to recover from the source, the code may be compact but the hardware intent is not explicit.

Types and declarations

Prefer logic

I use logic for ordinary point-to-point signals, ports, and procedural variables:

logic        valid;
logic [31:0] data;

I use wire only when net semantics are genuinely required, such as multiple drivers, tri-state behavior, or an external IP interface that requires it.

Do not confuse initialization with connectivity

A declaration assignment on a variable is an initialization, not a continuous connection:

// Avoid: initializes data rather than continuously driving it.
logic [31:0] data = 32'b0;

For a continuous relationship, separate the declaration and assignment:

logic [31:0] data;

assign data = 32'b0;

Pack structs and unions used in RTL

When a struct or union represents hardware data, I prefer it to be fully packed so that layout and width are explicit:

typedef struct packed {
    logic        valid;
    logic [15:0] data;
    logic        parity;
} payload_t;

Disable implicit nets

RTL files should use:

`default_nettype none

This turns a misspelled signal or port name into an error instead of silently creating an implicit wire.

Combinational logic

Use always_comb

I use always_comb rather than always @(*). It states the intent directly and gives tools additional checks for combinational behavior and conflicting drivers.

Fully assign every output

Every signal driven by an always_comb block must receive a value on every path. When there is a natural hold or fallback value, I assign it first:

always_comb begin
    next_value = value_q;

    if (i_update) begin
        next_value = i_data;
    end
end

Incomplete assignment implies storage and can infer a latch:

// Avoid.
always_comb begin
    if (i_update) begin
        next_value = i_data;
    end
end

Do not read and write the same signal in one block

I avoid self-dependence inside always_comb:

// Avoid.
always_comb begin
    foo = foo << 1;
end

Use a separate next-value signal:

always_comb begin
    foo_nxt = foo << 1;
end

Reading and writing the same variable can interact badly with sensitivity and tool interpretation. A distinct signal also makes the data transformation easier to inspect.

Prefer assign for simple relationships

For simple dataflow, a continuous assignment is clearer than a procedural block:

assign hit = i_valid && i_ready;

I use always_comb when the logic genuinely needs procedural control flow, priority, or several dependent operations.

Expose parallelism

Independent conditions should not look sequential:

assign cond_a = a && b;
assign cond_b = c || d;
assign set    = cond_a || cond_b || e;

This is preferable to repeatedly assigning set in a block when no statement actually depends on a previous statement.

Keep blocks small when it helps

Small combinational blocks often make signal ownership and dependency analysis clearer. They can also avoid false combinational-loop or UNOPTFLAT warnings in simulators such as Verilator.

This is not a rule to drive exactly one signal per block. When several outputs are controlled by one tightly coupled decision, keeping them together may communicate the hardware better than splitting them mechanically.

Separate Boolean computation from control flow when useful

If a condition is complex, reused, or useful in waveforms, I name it:

assign transfer = i_valid && i_ready && !i_flush;

always_comb begin
    ...

    if (transfer) begin
        ...
    end
end

If the condition is short and used once, it can remain inline. The goal is to keep the control structure easy to understand, not to manufacture signals for every expression.

Sequential logic and reset

Use always_ff

I use always_ff rather than a generic always block for registers. State updates use non-blocking assignments.

Local update logic is allowed

I do not require every register to have an external *_d signal. Local clear, enable, and increment behavior can remain inside always_ff:

always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        count_q <= '0;
    end
    else if (i_clear) begin
        count_q <= '0;
    end
    else if (i_enable) begin
        count_q <= count_q + 1'b1;
    end
end

I split out next-value logic when it adds something useful: a large combinational cone, shared arithmetic, a long priority tree, complex state transitions, or an observation point that matters during debug.

This is less strict than Hanlon’s preference for keeping non-blocking assignments very simple. I value that separation, but I do not want it to create intermediate signals mechanically when the local update rule is already obvious.

Reset follows the architecture

I do not reset every register by default.

Control-oriented state often needs an explicit initial value:

  • FSM state;
  • valid and pending bits;
  • protocol control;
  • interrupt status.

Datapath registers, pipeline data, temporary operands, and large arrays may not need reset if control logic guarantees that their contents are ignored until valid.

This decision involves X methodology, DFT, reset-tree area, routing, power, timing, recovery and removal, and reset-domain crossings. Hanlon’s guidance generally treats reset as the normal choice and non-reset registers as deliberate exceptions. My rule is more directly architecture-driven: reset the state that must be known, and prove that unreset data cannot be observed before it becomes valid.

Priority and case statements

Use if / else if for real priority

When priority is architectural, I write it in the most recognizable form:

if (req0) begin
    grant = 2'd0;
end
else if (req1) begin
    grant = 2'd1;
end
else if (req2) begin
    grant = 2'd2;
end

The order req0 > req1 > req2 is visible immediately. It is also worth remembering that an if condition is X-optimistic in simulation: X is normally treated as false.

Use plain case by default

My default selection construct is an ordinary case. I do not add unique or priority as decoration.

A unique case is an architectural contract. I use it only when:

  1. legal cases are mutually exclusive;
  2. the complete legal state space is covered;
  3. unmatched encodings have no defined functional behavior;
  4. synthesis may rely on these assumptions for optimization.

This differs from Hanlon’s recommendation to qualify case statements to expose parallel or priority intent. I prefer to make fewer promises to synthesis by default and add unique only when the promise is proven.

I avoid unique0 unless the actual tool flow has been verified and zero-or-one-match semantics are required. I also do not use full_case or parallel_case pragmas, and I do not mechanically replace them with unique or priority.

One-hot selection needs a proven one-hot property

This pattern can be useful:

unique case (1'b1)
    state[0]: ...
    state[1]: ...
    state[2]: ...
endcase

But I use it only when one-hot behavior and complete legal coverage are real architectural assumptions. The syntax should not create an assumption that the design does not guarantee.

Put the natural default at the block level

For next-state logic, I prefer:

always_comb begin
    state_nxt = state;

    case (state)
        IDLE: begin
            if (i_start) begin
                state_nxt = RUN;
            end
        end

        RUN: begin
            if (i_done) begin
                state_nxt = IDLE;
            end
        end
    endcase
end

The block establishes the normal path first; the case describes overrides. I use a case default only when an unmatched encoding has distinct functional meaning, such as explicit recovery behavior.

If an unreachable branch creates a coverage hole, I prefer a justified waiver or constraint over changing correct RTL merely to make the coverage report green.

Keep large logic out of case branches

Deeply nested case logic makes the main selection structure difficult to scan. When a branch contains substantial calculation or another independent selection problem, I factor it into separate combinational logic and use the result in the main case.

Avoid wildcard case matching

I avoid casex, casez, wildcard case inside, and ?, x, or z bits used as wildcards in case items. Hanlon recommends case inside when don’t-care matching is required. I instead prefer named Boolean mask conditions:

assign status_is_00 = (status[2:1] == 2'b00);
assign status_is_01 = (status[2:1] == 2'b01);
assign status_is_10 = (status[2] == 1'b1) && (status[0] == 1'b0);

always_comb begin
    ...

    if (status_is_00) begin
        ...
    end
    else if (status_is_01) begin
        ...
    end
    else if (status_is_10) begin
        ...
    end
end

This is more verbose, but every ignored bit is visible and input X/Z values do not become accidental matches.

Expressions and arithmetic

Make associativity obvious

I use parentheses in complex Boolean and arithmetic expressions:

assign hit =
       (a && b)
    || (c && d);

Nested ternaries should be aligned so the choice structure is visible:

assign result =
    cond_a              ? value_a :
    (cond_b && !cond_c) ? value_b :
                          value_c;

Reviewers should not have to reconstruct operator precedence mentally.

Make intermediate widths explicit

Expression sizing affects the circuit. For example:

assign result = (a + b) >> 1;

If a and b are 16-bit unsigned values and the intention is to compute their average, the carry must survive before the shift:

logic [16:0] sum;

assign sum    = {1'b0, a} + {1'b0, b};
assign result = sum >> 1;

Capture discarded carry intentionally

When a carry is unused, I still prefer to capture it:

logic unused_co;

assign {unused_co, result} = a + b;

The unused_ name tells reviewers and lint tools that truncation is deliberate.

Use signed types for signed arithmetic

Signed values should be declared signed:

logic signed [15:0] a;
logic signed [15:0] b;

Mixing signed and unsigned operands can change the signedness of a whole expression. I use explicit casts when the result matters instead of relying on implicit propagation.

Do not split one arithmetic cone without a reason

If the intended operation is:

assign sum = a + b + c;

I do not divide it into temporary operations or separate modules merely for style. Keeping one combinational cone visible gives synthesis more freedom to optimize adders and carry structures. A real pipeline register, timing boundary, or reuse requirement is a valid reason to split it.

Constants, enums, and X values

Name meaningful constants

I avoid unexplained magic numbers:

localparam logic [5:0] p_count_max = 6'd37;

Literal width should be explicit where it affects behavior. '0 means all zeros at the destination width. '1 means every destination bit is one, not the numeric value one:

logic [15:0] data;

assign data = '1; // 16'hffff

Preserve enum type safety

If a port or signal uses an enum type, I connect a value of the same enum type when possible:

.i_state (core_pkg::IDLE)

This is preferable to passing an untyped bit pattern and depending on implicit conversion.

Do not use X as a routine synthesis don’t-care

I avoid assigning 'x merely to give synthesis optimization freedom. RTL simulation sees X, while silicon implements zero or one. X-based optimization belongs only in a methodology that explicitly supports it and where the benefit has been demonstrated.

Naming and hierarchy

Naming should help with waveform search, hierarchy tracing, flattened-netlist analysis, gate-level debug, and ECO work.

Basic naming conventions

ObjectConvention
Current-module inputi_<semantic>
Current-module outputo_<semantic>
Clockclk or clk_<domain>
Resetrst_n or rst_<domain>_n
Instanceu_<name>
Parameter/localparamp_<name>
Registered value<name>_q
Active-low signal<name>_n
Type<name>_t
Generate scopeg_<name>
Intentionally unused valueunused_<name>

Clocks and resets are exceptions to the ordinary input prefix. I use clk and rst_n, not i_clk and i_rst_n.

Pipeline stages may use names such as p1_valid or p2_data_q. I avoid encoding every property into one name; searchability and semantic clarity matter more than maximum information density.

Name signals from the instance’s point of view

For an instance named u_filter, its boundary signals are:

logic [23:0] filter_i_data;
logic        filter_i_valid;
logic [23:0] filter_o_data;
logic        filter_o_valid;

The filter_i_* signals enter u_filter; the filter_o_* signals leave it.

Preserve the parent/instance boundary

I prefer explicit signals even when a parent port could connect directly to a child port:

assign filter_i_data = i_data;
assign o_data        = filter_o_data;

filter u_filter (
    .clk     (clk),
    .rst_n   (rst_n),
    .i_data  (filter_i_data),
    .o_data  (filter_o_data)
);

Preserve both sides between instances

For a connection from an HPF to an EQ, I keep both ownership views:

logic [23:0] hpf_o_data;
logic [23:0] eq_i_data;

assign eq_i_data = hpf_o_data;

Synthesis may collapse these nets, but the source still says that one belongs to the output of u_hpf and the other belongs to the input of u_eq.

I use assign both for simple dataflow and for explicit hierarchy connectivity. A group of boundary assignments should make the major datapath readable before the instance declarations are inspected.

Keep expressions out of instance connections

I avoid:

foo u_foo (
    .i_enable (a && b && !c),
    .i_data   (x + y)
);

Instead, I name the logic:

logic        foo_i_enable;
logic [31:0] foo_i_data;

assign foo_i_enable = a && b && !c;
assign foo_i_data   = x + y;

The instance then expresses connectivity rather than hiding logic. I also avoid .* in ordinary synthesizable RTL and use explicit named port connections.

This instance-boundary convention is one of the strongest project-specific additions beyond the style that inspired me.

Generate blocks, packages, and verification structure

Name every generate scope

Every generate for, if, and else scope should have a stable name:

for (genvar i = 0; i < 4; i++) begin : g_channel
    ...
end

This produces meaningful hierarchy such as g_channel[0] instead of tool-generated names such as genblk1.

Minimize the preprocessor

I prefer parameters, localparams, and generate blocks over define and ifdef. Macros do not have ordinary module scope and can make behavior depend on compilation order.

If a local macro is unavoidable, I remove it when its use ends:

`define LOCAL_FEATURE
...
`undef LOCAL_FEATURE

Qualify package symbols

Packages are useful for shared types, constants, functions, and tasks. I prefer explicit qualification:

core_pkg::state_t
core_pkg::fifo_width
core_pkg::gray_code(...)

over:

import core_pkg::*;

Qualification keeps symbol origin visible and avoids namespace pollution.

Keep assertions separate when practical

Large verification-only assertion sets should live in separate files and be attached through bind when practical. This makes it easier to use different assertion sets in RTL simulation, formal verification, and gate simulation without mixing them into the implementation.

Unused or dangling signals should also be explicit. A name such as unused_co distinguishes intentional omission from an integration mistake and makes lint waivers easier to review.

Module organization and formatting

A predictable module structure reduces navigation cost. I normally use this order:

  1. module ports;
  2. parameters and localparams;
  3. typedefs and enums;
  4. instance-boundary signals;
  5. local combinational signals;
  6. registers and state;
  7. boundary and simple assignments;
  8. always_comb blocks;
  9. always_ff blocks;
  10. submodule instances;
  11. assertions, or external binds.

The exact order can change across a project, but the project should use one predictable structure.

Formatting follows the same idea: spaces instead of tabs, controlled line length, parentheses around complex expressions, begin/end for control blocks, and consistent alignment. Formatting is not the hardware, but inconsistent formatting makes hardware structure harder to see.

Finite state machines

My default FSM has three distinct parts:

  1. a state-register always_ff block;
  2. a next-state always_comb block;
  3. separate logic that reads the current state.

I prefer an enum with an explicit base type. I normally use state and state_nxt, but the names are not mandatory:

typedef enum logic [1:0] {
    IDLE,
    RUN,
    DONE
} state_t;

state_t state;
state_t state_nxt;

The state-register block stores the state and does nothing else apart from reset:

always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        state <= IDLE;
    end
    else begin
        state <= state_nxt;
    end
end

The next-state block begins by holding the current state and then describes only transitions:

always_comb begin
    state_nxt = state;

    case (state)
        IDLE: begin
            if (i_start) begin
                state_nxt = RUN;
            end
        end

        RUN: begin
            if (i_done) begin
                state_nxt = DONE;
            end
        end

        DONE: begin
            if (i_ack) begin
                state_nxt = IDLE;
            end
        end
    endcase
end

Outputs and other behavior read the current state elsewhere:

assign o_busy = (state == RUN);
assign o_done = (state == DONE);

This makes state storage, transitions, and state-dependent behavior independently visible. If one FSM begins coordinating several independent responsibilities, I consider splitting it into smaller machines with explicit interaction signals.

The three-part form is a preference, not a ritual. When transitions and complex registered operations are tightly coupled, forcing three parts can introduce too many intermediate signals, repeated conditions, or scattered priority logic. In that situation, a one-process FSM may be clearer. The decision must preserve cycle latency, output registration, and priority semantics.

A compact structural example

The following wrapper illustrates the hierarchy and naming rules without trying to show every rule in one module:

`default_nettype none

module audio_top #(
    parameter int p_data_w = 24
) (
    input  logic                clk,
    input  logic                rst_n,
    input  logic [p_data_w-1:0] i_data,
    input  logic                i_valid,
    output logic [p_data_w-1:0] o_data,
    output logic                o_valid
);

logic [p_data_w-1:0] hpf_i_data;
logic                hpf_i_valid;
logic [p_data_w-1:0] hpf_o_data;
logic                hpf_o_valid;

logic [p_data_w-1:0] eq_i_data;
logic                eq_i_valid;
logic [p_data_w-1:0] eq_o_data;
logic                eq_o_valid;

assign hpf_i_data  = i_data;
assign hpf_i_valid = i_valid;

assign eq_i_data   = hpf_o_data;
assign eq_i_valid  = hpf_o_valid;

assign o_data      = eq_o_data;
assign o_valid     = eq_o_valid;

hpf #(
    .p_data_w (p_data_w)
) u_hpf (
    .clk      (clk),
    .rst_n    (rst_n),
    .i_data   (hpf_i_data),
    .i_valid  (hpf_i_valid),
    .o_data   (hpf_o_data),
    .o_valid  (hpf_o_valid)
);

eq #(
    .p_data_w (p_data_w)
) u_eq (
    .clk      (clk),
    .rst_n    (rst_n),
    .i_data   (eq_i_data),
    .i_valid  (eq_i_valid),
    .o_data   (eq_o_data),
    .o_valid  (eq_o_valid)
);

endmodule

The major datapath is visible in the assignments, and every instance boundary has a source-level ownership name.

A companion Agent Skill

The complete rules are also packaged as an Agent Skill so that an AI coding agent can apply the same preferences when writing, reviewing, or refactoring RTL:

Synthesizable RTL Style on GitHub

For Codex, the installable directory is:

skills/synthesizable-rtl-style

It can be copied manually to:

~/.codex/skills/synthesizable-rtl-style

The useful part is not that the rules live in a SKILL.md file. The useful part is that the preferences are explicit enough to discuss, revise, and apply consistently.

Final thought

No RTL style should replace engineering judgment. A rule is valuable when it exposes hardware intent, prevents a real ambiguity, or makes downstream work easier. When following the rule makes the circuit harder to understand, the rule deserves an exception or revision.

The result I want is RTL where a reviewer can see the architecture before mentally simulating the syntax: state looks like state, priority looks like priority, parallel logic looks parallel, widths are deliberate, hierarchy has ownership, and every important tool assumption is intentional.