Skip to content

gen-verilog: 26 of 32 modules reference struct fields under a prefix declared nowhere — no single prefix can be correct #2325

Description

@gHashTag

26 of 32 emitted modules reference struct fields under a prefix declared nowhere

fpga-conformance has been red on every master run since fd245d2aa (#2242) turned
the iverilog step from ::warning into ::error + exit 1. The red is correct and the
gate is correct. What is broken is the Verilog emitter: it writes function/task bodies
that reference flattened struct-field identifiers never declared in any enclosing
scope
, and marks the very structs it is about to mis-emit with its own
UNSUPPORTED_ICARUS comment before emitting them anyway.

This is not #2241 (nothing executes the vectors — no vvp anywhere). This is that
28 of 32 modules will not even compile, so #2241's lane has nothing to run.


Measurement

Source: the fpga-verilog artifact of FPGA E2E Build run #1037
(id 32464326319, head bba06449, branch master, 80,443 bytes, 32 .v files).

Locally under Icarus Verilog 13.0 (stable) (v13_0) with the workflow's exact flags:
pass=4, fail=28 — byte-for-byte the count CI reports in its own annotation on that
run: 28/32 modules failed iverilog compilation.

The 4 that compile: hw_types.v, spi.v, top_level.v, uart.v.

Error families across the 28:

342  error: Unable to bind wire/reg/memory       (r-value reads)
178  error: Unable to elaborate condition expression.   (downstream of the above)
 28  error: Could not find variable              (l-value writes)
  5  error: Unknown module type                  (all 5 in zerodsp_top.v)
  3  error: Enable of unknown task               (all 3 in bridge.v)
  2  error: No function named                    (both in bridge.v)

Unable to bind and Could not find variable are the same defect seen from two sides:
iverilog reports an unresolvable read as the former and an unresolvable write as
the latter.

Scope split — 26 emitter, 2 harness

class count files
flattened struct-field prefix (this issue) 26 all failing except the two below
cross-file references under a per-file compile 2 bridge.v (calls mac_*/spi_* tasks defined in other files), zerodsp_top.v (instantiates 5 modules defined in other files)
compile clean 4 hw_types.v, spi.v, top_level.v, uart.v

The 2 in the middle row are a different problem — the gate compiles each .v in
isolation, so a genuine cross-module reference cannot resolve. Do not fold them in here.

The correlation is exact. All 26 flatten-class failures contain at least one
// UNSUPPORTED_ICARUS: struct <Name> contains non-lowerable fields line. All 4 passing
files contain zero. The emitter already knows which structs it cannot lower.

Per-file error counts (28 failing):

axi4 57   ternary_isa 51   hir 41   fifo 36   gf16_accel 35   formal 32   memory 30
placement 26   apb_bridge 24   mac 22   assembler 22   stdlib 18   dft 18
vcd_trace 16   linker 15   timing 14   testbench 12   e2e_demo 12   clock_domain 11
simulator 10   bootrom 10   router 9   cts 9   partition 8   bridge 6
zerodsp_top 5   crossopt 5   power 4

Verbatim samples:

mac.v:212: error: Could not find variable ``mac_units_status'' in ``ZeroDSP_MAC.mac_cycle.mac_cycle_body''
fifo.v:174: error: Unable to bind wire/reg/memory `cfg_depth' in `Fifo.addr_width.addr_width_body'
gf16_accel.v:292: error: Unable to bind wire/reg/memory `cfg_has_fft' in `Gf16Accel.bram_count.bram_count_body'
memory.v:206: error: Unable to bind wire/reg/memory `result_port_count' in `Memory.add_read_port.add_read_port_body'

Reproduction

No build required. Needs gh (authenticated) and iverilog >= 11.

mkdir -p /tmp/t27vlog && cd /tmp/t27vlog
gh run download 32464326319 --repo gHashTag/t27 --name fpga-verilog --dir .

pass=0; fail=0
for v in ./*.v; do
  name=$(sed -n 's/^module \([A-Za-z0-9_]*\).*/\1/p' "$v" | head -1)
  if iverilog -o "/tmp/t27vlog/${name}_tb.vvp" -g2012 -DSIMULATION "$v" 2>"/tmp/t27vlog/$(basename "$v" .v).log"; then
    pass=$((pass+1))
  else
    fail=$((fail+1))
  fi
done
echo "pass=$pass fail=$fail total=$((pass+fail))"     # => pass=4 fail=28 total=32

The loop body is copied from .github/workflows/fpga-build.yml:716-732 (step
"Compile conformance testbenches (iverilog)"); only the output paths differ.

Error-family histogram:

cat /tmp/t27vlog/*.log | grep -o "error: [A-Za-z][^\`']*" | sed 's/ *$//' | sort | uniq -c | sort -rn

Confirm the UNSUPPORTED_ICARUS correlation:

grep -c UNSUPPORTED_ICARUS /tmp/t27vlog/*.v | grep -v ':0$' | wc -l   # => 26
grep -c UNSUPPORTED_ICARUS /tmp/t27vlog/{hw_types,spi,top_level,uart}.v  # => all 0

Artifact retention is 7 days from 2026-08-21. To regenerate from source instead:

cargo build --release -p t27c
t27c fpga-build --smoke     # writes build/fpga/generated/*.v

then run the same loop over build/fpga/generated/*.v.


Mechanism, in one file

fifo.v is the whole defect in 40 lines. Module-scope registers, emitted by
gen_verilog_struct (artifact fifo.v:55-67):

    // struct FifoConfig
    // UNSUPPORTED_ICARUS: struct FifoConfig contains non-lowerable fields
    reg [31:0] fifoconfig_name;                 // FifoConfig.name
    reg signed [7:0] fifoconfig_kind;           // FifoConfig.kind
    reg [31:0] fifoconfig_depth;                // FifoConfig.depth
    reg [31:0] fifoconfig_data_width;           // FifoConfig.data_width
    ...
    // struct FifoState
    // struct FifoState lowered as packed vector (100 bits)

FifoConfig took the flatten branch, prefixed with the lowercased type name.
FifoState took the packed branch and gets no registers at all.

Now the use site (fifo.v:286-306):

    // function: push
    function [99:0] push; // -> FifoState
        input [99:0] state;
        input [31:0] cfg;
        begin : push_body
            reg [31:0] result;
            reg __t27_ret;
            __t27_ret = 1'b0;
            result = state;
            if (state[97 +: 1]) begin
                push = result;
                __t27_ret = 1'b1;
            end else begin
                result_tail_ptr = ((state[64 +: 32] + 1) % cfg_depth);
                result_fill_count = (state[0 +: 32] + 1);
                result_flags_empty = 1'b0;
                if ((result_fill_count == cfg_depth)) begin
                    result_flags_full = 1'b1;
                end
                push = result;

Three prefixes for two struct types, in one function:

  • stateFifoState on the packed path. state[64 +: 32] resolves. Correct.
  • result — the same FifoState, on the flatten path. result_tail_ptr is
    declared nowhere. reg [31:0] result exists but is a different, unrelated name.
  • cfgFifoConfig on the flatten path, so cfg_depth; the registers were emitted
    as fifoconfig_depth.

The unbound identifiers in fifo.v: cfg_almost_empty_threshold,
cfg_almost_full_threshold, cfg_data_width, cfg_depth, cfg_has_almost_empty,
cfg_has_almost_full, cfg_kind, cfg_name, result_fill_count, plus the l-value
class result_tail_ptr, result_head_ptr, result_flags_empty, result_flags_full.

Note the second-order damage even if the names were fixed: result = state assigns a
100-bit value into reg [31:0] result, and the writes to result_tail_ptr are to a
name unrelated to result, so push = result returns the truncated, unmodified copy.
Renaming alone does not restore the dataflow.


Why no correct prefix exists

gen_verilog_struct emits one register set per struct type, at module scope, under
one prefix. But a struct type can be bound under several names simultaneously in a
single scope — push above holds FifoState as both state and result at the same
time — and each binding needs its own storage.

So the prefix decision is unsatisfiable as posed:

  • Prefix by type name (fifoconfig_) — what the fallback does — matches no use site,
    because bodies emit <binding>_<field>.
  • Prefix by binding name — what struct_var tries — can only pick one binding per
    type. In push it must be state and result.
  • Prefix by binding, emitted per binding, would put function-local storage at module
    scope, aliasing every call site and destroying re-entrancy.

There is no third option that keeps per-type module-scope registers. The flatten path
is wrong by construction, not by an off-by-one in prefix selection
, which is why the
existing struct_var map cannot be patched into correctness.

Proposal, not a decision

The packed path already works — state[64 +: 32] compiles and carries correct
semantics. The plausible direction is to make it the only path: declare every struct
binding as input [W-1:0] base (or a local reg [W-1:0]) and lower every base.field
to a base[off +: w] part-select, deleting the flatten branch and the
UNSUPPORTED_ICARUS marker with it. That requires is_lowerable_scalar_struct to
accept everything it currently rejects, which is a real piece of work and interacts with
#2319 (extents the width function cannot size). I am not asserting this is the right
design
— it is the direction the working half of the emitter suggests. The decision is
open.


Compiler references, checked against origin/master (fead099c2)

bootstrap/src/compiler.rs is 37,141 lines on master. Reading it from a stale checkout
produces wrong line numbers, so these were re-derived from git show origin/master: today.

Holds — the fallback, compiler.rs:11892-11895:

            for s in &structs {
                let prefix = struct_var
                    .get(&s.name)
                    .cloned()
                    .unwrap_or_else(|| s.name.to_lowercase());
                self.gen_verilog_struct(s, &prefix);
            }

Holds — the comment that condemns that exact fallback, compiler.rs:11877-11881:

            // Declare struct field regs under the module-level `var` name that
            // holds the struct (e.g. `uart_state_status`), matching exactly what
            // gen_verilog_expr emits for field access. Falling back to the struct
            // type name (`uartstate_status`) leaves every field reference an
            // undeclared identifier and breaks iverilog compilation.

It is at :11877-11881, i.e. eleven lines above the let prefix, not four — the ten
lines between it build the struct_var map. The comment documents the failure mode the
code immediately below it still takes, and fifo.v's fifoconfig_depth is that comment's
uartstate_status example realized.

Holds — the two branches, gen_verilog_struct at compiler.rs:12946, dispatching on
is_lowerable_scalar_struct at :12949, UNSUPPORTED_ICARUS emitted at :12962-12965,
per-field reg {prefix}_{field} at :12988-12996.

Does not hold as a scope figure for this defect — the repo's 489 of 618 /
79.1% at compiler.rs:13026-13028. That comment is real, but it belongs to a
different cause (W657/T103): param_types was cleared in gen_verilog_fn and never
repopulated, so struct-typed function parameters fell to the flatten fallback. That
repair has landed:13033-13035 now populates the map:

        for (pname, ptype) in &node.params {
            self.param_types.insert(pname.clone(), ptype.clone());
        }

Citing 489/618 as this issue's scope would be wrong on two counts: wrong defect, and a
figure whose fix is already in the tree. The scope measured today is 26 of 32 emitted
modules (81.3%)
on the artifact above.


Do not soften the gate

Before fd245d2aa (2026-08-19, #2242) this step ran bare -g2005, compiled 0 of 32,
and reported ::warning — a permanent green that measured nothing. That commit changed
it to -g2012 -DSIMULATION with exit 1, which is what surfaced this. Reverting to a
warning restores the false green. The red should stay until the emitter is fixed. If the
lane must move meanwhile, exclude by explicit named list with a link back here — never by
downgrading the annotation level.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions