Skip to content

Design Domains

DFHDL offers three key domain abstractions, dataflow (DF), register-transfer (RT), and event-driven (ED), all within a single HDL, as illustrated in the following figure. This unique capability allows developers to employ a cohesive syntax to seamlessly blend these abstractions: DF, RT, and ED. Each abstraction brings its own set of advantages in terms of control, synthesizability, simulation speed, and functional correctness.

The RT abstraction mirrors the capabilities found in languages like Chisel and Amaranth, while the ED abstraction aligns with the functionalities of VHDL and Verilog. Through an intelligent compilation process, the DFHDL compiler transitions from the higher-level DF abstraction through RT and ultimately to ED. The choice of compilation dialect (VHDL 93/2008 or Verilog/SystemVerilog) determines the final ED code representation.

design-domains design-domains

Dataflow (DF) Domain

The dataflow domain provides the highest level of abstraction, focusing on data dependencies rather than timing.

Key Features

  • Timing-agnostic descriptions
  • Implicit state handling
  • Token stream semantics
  • History access via .prev

Example

1
2
3
4
class Accumulator extends DFDesign:
  val input = UInt(8) <> IN
  val sum = UInt(16) <> OUT init 0
  sum := sum.prev + input  // Implicit state handling

Register Transfer (RT) Domain

The RT domain provides explicit control over registers and timing while maintaining hardware-friendly abstractions.

Domain Configuration

Clock, reset, and inter-domain relations are declared by attaching @hw.constraints.timing.* annotations to the RTDesign / RTDomain. Each annotation field is optional; unset fields are filled in from the global ElaborationOptions defaults at compile time, and from any @timing.related ancestor.

Clock Annotation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import dfhdl.hw.constraints.timing

@timing.clock(
  rate            = 50.MHz,    // Clock frequency (or period, e.g. 20.ns)
  edge            = _.rising,  // rising | falling
  portName        = "clk",     // Port name in generated code
  inclusionPolicy = _.asneeded
)
class MyDesign extends RTDesign:
  ...

Reset Annotation

1
2
3
4
5
6
7
8
@timing.reset(
  mode            = _.sync,    // async | sync
  active          = _.high,    // low | high
  portName        = "rst",     // Port name in generated code
  inclusionPolicy = _.asneeded
)
class MyDesign extends RTDesign:
  ...

Annotations may be partial: @timing.clock(rate = 100.MHz) overrides only the clock rate and inherits the rest from the elaboration defaults. The empty form @timing.clock() / @timing.reset() forces the slot to appear (e.g. on a combinational or blackbox owner) while still deriving every field from the defaults.

Inclusion Policies

  • AsNeeded: Only emits clock/reset ports when actually used.
  • AlwaysAtTop: Always emits the ports at the top level (silenced with @unused if unused).

Domain Types

Basic RT Domain

1
2
3
4
class BasicRTDesign extends RTDesign:           // uses elaboration defaults
  val x = UInt(8) <> IN
  val y = UInt(8) <> OUT.REG init 0
  y := x.reg                                    // Registered on the resolved clock edge

Multiple Clock Domains

1
2
3
4
5
6
7
8
9
@timing.clock(rate = 100.MHz, grpName = "main")
class MultiClockDesign extends RTDesign:
  @timing.clock(rate = 25.MHz, grpName = "slow")
  val slowDomain = new RTDomain:
    val slow_reg = UInt(8) <> VAR.REG init 0

  @timing.clock(rate = 200.MHz, grpName = "fast")
  val fastDomain = new RTDomain:
    val fast_reg = UInt(8) <> VAR.REG init 0

grpName distinguishes domains that should generate independent Clk_<grp> / Rst_<grp> opaque port types and ports.

A domain whose clock/reset is inherited from another domain (sibling, parent, etc.) carries @timing.related(target) instead of its own @timing.clock / @timing.reset. The compiler omits the clock/reset ports for the related domain and reuses the target's.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class RelatedDomainsDesign extends RTDesign:
  base =>
  val baseDomain = new RTDomain:
    val base_reg = UInt(8) <> VAR.REG init 0

  // Inherits clock/reset from baseDomain
  @timing.related(baseDomain)
  val relatedDomain = new RTDomain:
    val related_reg = UInt(8) <> VAR.REG init 0

  // Inherits the enclosing design's clock/reset
  @timing.related(base)
  val designRelated = new RTDomain:
    val from_top_reg = UInt(8) <> VAR.REG init 0

By default a related domain also shares the target's reset. Passing includeReset = false keeps the shared clock but drops the reset: the domain's registers and memories are not driven from a reset-initialization block and instead rely on their initial values (init) alone.

1
2
3
4
5
6
7
8
class NoResetRelatedDomain extends RTDesign:
  base =>
  val base_reg = UInt(8) <> VAR.REG init 0

  // Shares base domain's clock, but not its reset
  @timing.related(base, includeReset = false)
  val relatedDomain = new RTDomain:
    val related_reg = UInt(8) <> VAR.REG init 0  // relies on its init value, no reset

Derived Clocks (Gated Clocks)

A related domain may declare its own clock port, either an input (Clk <> IN, consuming the derived clock) or an output (Clk <> OUT, sourcing it):

1
2
3
4
5
6
7
class GatedDomainDesign extends RTDesign:
  val x = UInt(8) <> IN
  @timing.related(this)
  val active = new RTDomain:
    val clk = Clk <> IN
    val r   = UInt(8) <> VAR.REG init 0
    r.din := x

This declares a derived clock: a clock that is fully synchronous with the clock of the related target (same source, same edges, phase-aligned), while the reset (subject to includeReset) is still shared through the relation. The typical use is a gated clock: an input port receives a gated version of the origin clock from outside, and an output port exports one that the design gates internally (the design scope drives it, e.g. active.clk <> gatedClk.as(active.Clk)). Because the domains are related, no clock-domain-crossing discipline applies between them, and sharing an asynchronous reset across the gated clocks is safe (a flop whose clock is gated off still sees the reset assertion).

The identity of a derived clock is its design-relative name: domain active with port clk identifies as active_clk, which is also its flattened port name. The connection rule is deliberately narrow and predictable: same-named derived clocks within the same clock group form one clock, automatically threaded across the hierarchy (through automatically added pass-through ports, also named active_clk), with an output port or an explicitly connected port as the source. A derived clock is never implicitly merged onto its origin clock:

  • Sourced somewhere: a Clk <> OUT port (the internal gating site), or any port a parent explicitly connects (e.g. child.active.clk <> gatedClk.as(child.active.Clk)), sources every same-named port in scope.
  • Sourced nowhere: the derived clock surfaces as a top-level input port instead of silently taking the origin clock, so a forgotten gated-clock connection is visible in the port list rather than a silently dead or wrongly merged clock.
  • The ungated form is an explicit choice: to run a derived clock from the origin clock (as in an FPGA build of an ASIC design that removes clock gating), connect the two explicitly, e.g. at a wrapper that declares its own root clock port: core.active.clk <> clk.as(core.active.Clk).

Derived clocks nest: a related domain with its own clock port may itself be the target of another related domain, whose clock port then derives from the outer derived clock (gating a gated clock). A related domain without its own clock port that targets a clocked related domain uses that domain's derived clock, while its reset still resolves through the full relation chain to the origin.

The most common related target is the enclosing design or domain itself, so every RT container provides two shorthand domain classes and one scoping construct. Each is exactly equivalent to a plain RTDomain with the corresponding annotations, and manifests as such (printing, compilation, and naming see no difference):

Construct Equivalent to
RTRelatedDomain @timing.related(this) new RTDomain
RTDerivedClkDomain RTRelatedDomain with a val clk = Clk <> IN declaration
RTDerivedClkDomainSrc RTRelatedDomain with a val clk = Clk <> OUT declaration
RTRegion RTRelatedDomain with @flattenMode.transparent
1
2
3
4
5
6
7
8
9
class Shorthands extends RTDesign:
  val related = new RTRelatedDomain:      // shares this design's clock and reset
    val a = UInt(8) <> VAR.REG init 0
  val gated = new RTDerivedClkDomain:     // derived clock port `clk`, shared reset
    val b = UInt(8) <> VAR.REG init 0
  val region = new RTRegion:              // shared clock/reset, no naming footprint
    val c = UInt(8) <> VAR.REG init 0     // flattens as `c`, not `region_c`
  val sub = new gated.RTRelatedDomain:    // path-prefixed: related to `gated`, not to the design
    val d = UInt(8) <> VAR.REG init 0     // clocked by gated's derived clock

All three are members of every RT container, so the related target is selected by the instantiation path: a bare new RTRelatedDomain relates to the enclosing container, while new gated.RTRelatedDomain (or new gated.RTRegion, etc.) relates to the gated domain instead, equivalent to @timing.related(gated).

The two domain shorthands create a grouping with a footprint of its own:

  • RTRelatedDomain is the general grouping tool: it scopes a piece of logic under the same clock and reset without minting a new clock group, and its members flatten with the domain-name prefix. Use the annotation form (@timing.related(this, includeReset = false)) when the domain must opt out of the reset.
  • RTDerivedClkDomain declares a derived (typically gated) clock as described in the previous section; its clk port identifies by the domain's name (domain active yields the active_clk identity and flattened port name). RTDerivedClkDomainSrc is its sourcing variant (Clk <> OUT): the internal gating site, whose design scope drives the derived clock (e.g. active.clk <> icgOut.as(active.Clk)) and exports it to every same-named derived clock in scope.

An RTRegion is deliberately the opposite: a scoping construct with no observable footprint of its own, neither a clock identity nor a naming one. It places logic under a timing context while leaving every member's own name (and therefore the generated HDL) untouched, which is what makes it useful where a design declares its domain configuration once, around its ports, and internal logic is later regrouped without renaming anything. (The variant that also opts out of the reset, e.g. to keep a memory outside the reset scope, still uses the annotation form: @timing.related(this, includeReset = false) together with @flattenMode.transparent.)

The Domain-and-Regions Pattern

Regions unfold their full value path-prefixed. The common pattern declares a timing context exactly once as a named domain, and then opens sparse regions of it wherever pieces of logic naturally live in the code, with none of them paying a naming cost:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Core extends RTDesign:
  val start = Bit <> IN
  // the gated clock context, declared once
  val active = new RTDerivedClkDomain {}

  // ... free-running logic ...
  val busy = Bit <> OUT.REG init 0
  busy.din := start || busy

  // a piece of logic in the gated context, at its natural code location
  val ctrl = new active.RTRegion:
    val state = UInt(8) <> VAR.REG init 0
    state.din := state + 1

  // ... more free-running logic ...

  // another sparse region of the same context
  val datapath = new active.RTRegion:
    val acc = UInt(8) <> VAR.REG init 0
    acc.din := acc + ctrl.state

Every region's registers are clocked by active's derived clock and reset by the design's shared reset, yet state and acc flatten under their own names, exactly as if the design had a single domain. The regions can be scattered freely between free-running logic, so the code order follows the design's dataflow rather than its clock grouping.

Register Types and Initialization

Register Declarations vs Aliases

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class RegisterPatterns extends RTDesign:
  val x = UInt(8) <> IN

  // Register Declaration - creates a new register
  val reg1 = UInt(8) <> VAR.REG init 0  // Variable register
  val out1 = UInt(8) <> OUT.REG init 0  // Output register

  // Register Alias - creates a registered version of a signal
  val delayed = x.reg      // One cycle delay of x
  val delayed2 = x.reg(2)  // Two cycle delay of x

The Register Model

A register declared with VAR.REG or OUT.REG has two sides:

  • The output is the declaration name itself (reg). Reading it yields the value the register has held since the last clock edge. That value is stable for the whole cycle, no matter what the design body assigns, and it is immutable: you cannot assign to it.
  • The input is reg.din. It is what the register will hold after the next clock edge, and it is the only assignable side.

The clock edge is what moves the input to the output. Everything the design body does within a cycle happens to the input side.

Register Access Patterns

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class RegisterAccess extends RTDesign:
  val x = UInt(8) <> IN
  val reg = UInt(8) <> VAR.REG init 0
  val out = UInt(8) <> OUT.REG init 0

  // CORRECT: Writing to register input using .din
  reg.din := x            // Updates register input
  out.din := reg         // Updates output register input

  // INCORRECT: Attempting to write to register output
  reg := x               // Error: Can't write to register output
  out := reg            // Error: Can't write to register output

  // Reading a register without `.din` reads its output
  val value = reg        // Reads register output
  val outValue = out     // Reads output register value

Reading the Register Input

reg.din can also be read. It yields the register's pending value: whatever has been assigned to it so far in the current cycle, or the register's output when nothing has been assigned yet. This lets a register be built up in steps:

1
2
3
4
5
6
7
class SteppedCounter extends RTDesign:
  val r = UInt(8) <> VAR.REG init 0
  val y = UInt(8) <> OUT
  // each statement builds on the pending value, so `r` advances by 2 every cycle
  r.din := r.din + 1
  r.din := r.din + 1
  y := r

The compiler gives the register a shadow variable holding its pending value, seeds it from the register at the top of the cycle, applies the assignments in order, and registers the result on the clock edge:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
module SteppedCounter(
  input  wire logic clk,
  input  wire logic rst,
  output logic [7:0] y
);
  `include "dfhdl_defs.svh"
  logic [7:0] r;
  logic [7:0] r_din;
  always_comb
  begin
    r_din = r;
    r_din = r_din + 8'd1;
    r_din = r_din + 8'd1;
  end
  always_ff @(posedge clk)
  begin
    if (rst == 1'b1) r <= 8'd0;
    else r <= r_din;
  end
  assign y = r;
endmodule
The shadow r_din is a module-level logic assigned with blocking assignments inside always_comb, so each statement sees the value left by the previous one.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
entity SteppedCounter is
port (
  clk : in std_logic;
  rst : in std_logic;
  y : out unsigned(7 downto 0)
);
end SteppedCounter;

architecture SteppedCounter_arch of SteppedCounter is
  signal r : unsigned(7 downto 0);
  signal r_din : unsigned(7 downto 0);
begin
  process (all)
    variable r_din_v : unsigned(7 downto 0);
  begin
    r_din_v := r;
    r_din_v := r_din_v + 8d"1";
    r_din_v := r_din_v + 8d"1";
    r_din <= r_din_v;
  end process;
  process (clk)
  begin
    if rising_edge(clk) then
      if rst = '1' then r <= 8d"0";
      else r <= r_din;
      end if;
    end if;
  end process;
  y <= r;
end SteppedCounter_arch;
VHDL signal assignment evaluates every right-hand side against the value the signal had when the process started, so a shadow signal would increment only once here. The shadow is therefore a process variable (r_din_v), which does carry the value forward between statements, and it is published to the r_din signal at the end of the process for the clocked process to register.

A .din read reflects the assignments above it, not the register's final input value. Reading it before any assignment yields the register output, which is why the model above stays consistent:

1
2
3
4
5
6
7
8
class DinOrdering extends RTDesign:
  val x = UInt(8) <> IN
  val r = UInt(8) <> VAR.REG init 0
  val a = UInt(8) <> OUT
  val b = UInt(8) <> OUT
  a     := r.din   // nothing assigned yet, so this reads the register output
  r.din := x
  b     := r.din   // reads x

As with assignment, a partial selection comes before .din, not after:

1
2
3
4
5
class DinPartial extends RTDesign:
  val r = UInt(8) <> VAR.REG init 0
  val y = UInt(4) <> OUT
  r(3, 0).din := 5
  y := r(3, 0).din   // the pending value of the lower nibble

A .din read cannot be given a name

Binding a .din read to a Scala val is rejected at elaboration:

1
val d = r.din   // error: Cannot name a register DIN read.

The reason is that such a binding looks like a snapshot but behaves as a live view: d would keep reporting the pending value at whatever point it is later read, so r.din := 5 between the binding and its use would change what d yields. Apply .din directly where it is read instead, which reads the same and leaves no room for the confusion:

1
y := r.din + 1

Register Composition

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class RegisterComposition extends RTDesign:
  val x = UInt(8) <> IN

  // Using register declarations
  val reg1 = UInt(8) <> VAR.REG init 0
  val reg2 = UInt(8) <> VAR.REG init 0
  reg1.din := x
  reg2.din := reg1      // Chaining registers

  // Using register aliases
  val stage1 = x.reg    // Same as reg1
  val stage2 = x.reg(2) // Same as reg2, but more concise

  // Mixing declarations and aliases
  val reg3 = UInt(8) <> VAR.REG init 0
  reg3.din := stage2    // Can mix both styles

Advanced Register Patterns

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class AdvancedRegisters extends RTDesign:
  val x = UInt(8) <> IN
  val y = UInt(8) <> IN

  // Conditional registration
  val reg = UInt(8) <> VAR.REG init 0
  if (x > 10)
    reg.din := y     // Register y when x > 10
  else
    reg.din := x     // Register x otherwise

  // Register with enable
  val enReg = UInt(8) <> VAR.REG init 0
  val en = Bit <> IN
  if (en)
    enReg.din := x   // Only update when enabled

  // Multiple cycle delays with processing
  val processed = (x + 1).reg(2)  // Add 1 and delay 2 cycles

Common Patterns and Pitfalls

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class RegisterPitfalls extends RTDesign:
  val x = UInt(8) <> IN
  val reg = UInt(8) <> VAR.REG init 0

  // GOOD: Explicit input/output separation
  reg.din := x + 1        // Write to input
  val result = reg + 2    // Read from output

  // BAD: Attempting to write to output
  reg := x + 1           // Error: Writing to output

  // GOOD: Register alias for simple delays
  val delayed = x.reg    // Clean and clear intent

  // BAD: Unnecessary register declaration for simple delay
  val regDelay = UInt(8) <> VAR.REG init 0
  regDelay.din := x      // More verbose than needed

Event-Driven (ED) Domain

The ED domain provides the lowest level of abstraction, with explicit process blocks and event sensitivity.

Process Types

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class EDExample extends EDDesign:
  val clk = Bit <> IN
  val rst = Bit <> IN
  val x = UInt(8) <> IN
  val y = UInt(8) <> OUT

  // Combinational process
  process(all):
    y := x + 1

  // Clock-sensitive process
  process(clk.rising):
    y := x

  // Clock and reset process
  process(clk, rst):
    if (rst)
      y := 0
    else if (clk.rising)
      y := x

Assignment Types

  • Blocking (:=): Immediate effect
  • Non-blocking (:==): Scheduled update
    1
    2
    3
    process(clk.rising):
      val temp = x  // Blocking read
      y :== temp    // Non-blocking write
    

Domain Interaction

Cross-Domain Communication

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@timing.clock(rate = 100.MHz, grpName = "main")
class CrossDomainExample extends RTDesign:
  val x = UInt(8) <> IN

  @timing.clock(rate = 50.MHz, grpName = "a")
  val domainA = new RTDomain:
    val reg_a = UInt(8) <> VAR.REG init 0
    reg_a := x.reg

  @timing.clock(rate = 25.MHz, grpName = "b")
  val domainB = new RTDomain:
    val reg_b = UInt(8) <> VAR.REG init 0
    reg_b := domainA.reg_a.reg  // Cross-domain registration

Domain Flattening

During compilation, nested domains are flattened while preserving clock and reset relationships:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Original nested domains
@timing.clock(rate = 25.MHz, grpName = "inner")
val innerDomain = new RTDomain:
  val reg = UInt(8) <> VAR.REG init 0

// After flattening
val innerDomain_reg = UInt(8) <> VAR.REG init 0
process(innerDomain_clk.rising):
  if (innerDomain_rst) 
    innerDomain_reg := 0
  else 
    innerDomain_reg := next_value

Compilation Flow

  1. Domain Resolution:
  2. Flattens nested domains
  3. Resolves clock and reset configurations
  4. Establishes domain hierarchies

  5. State Management:

  6. Converts DF .prev to explicit registers
  7. Handles RT register declarations
  8. Manages ED process state variables

  9. Process Generation:

  10. Converts DF and RT to ED processes
  11. Optimizes sensitivity lists
  12. Handles blocking/non-blocking assignments

  13. Backend Generation:

  14. Generates VHDL or Verilog code
  15. Preserves timing relationships
  16. Maintains design hierarchy

Best Practices

  1. Domain Selection:
  2. Use DF for algorithmic descriptions
  3. Use RT for timing-critical paths
  4. Use ED for low-level control

  5. Clock Domain Crossing:

  6. Use explicit synchronization
  7. Maintain clear domain boundaries
  8. Document clock relationships

  9. State Management:

  10. Initialize all registers
  11. Use appropriate reset strategies
  12. Consider reset domains

  13. Performance Optimization:

  14. Balance domain abstractions
  15. Use appropriate clock domains
  16. Consider resource utilization