Back to EECS 31L Index
EECS 31L • Study Notes • Structural Modeling
Mahmoud Elfar • Spring 2026 • v0.1
v0.1: Initial version
Table of Contents
Recall the three abstraction levels:
| Level | How you describe the circuit |
|---|---|
| Dataflow | assign statements: describe what each signal equals |
| Behavioral | Procedural blocks: describe what the circuit does, step by step |
| Structural | Instantiate components and wire them together |
In structural modeling, we describe a digital circuit by its topology:
Remember: Topology = Components + Interlinks
Source: Blade Runner 2049 (2017)
Most non-trivial circuits you will write or encounter in Verilog are:
There are three categories of components you can instantiate in a structural model:
and, or, xor, etc.)i_wonder_why from past notes)In all cases, the mechanism is the same:
In the rest of this note we will cover:
generate constructSee: IEEE 1800-2017, 28.3 Gate and switch declaration syntax, pg. 876
Verilog provides a set of built-in gate primitives (or primitive gates, or primitives for short). Those primitives are modules already defined by the Verilog language. You can use them directly in your models
| Primitive | Function |
|---|---|
and |
AND |
or |
OR |
xor |
XOR |
not |
NOT (inverter) |
nand |
NAND |
nor |
NOR |
xnor |
XNOR |
buf |
Buffer |
Syntax:
<primitive> <instance_name> (<output>, <input1>, <input2>, ...);
For not and buf, which take a single input:
not <instance_name> (<output>, <input>);
buf <instance_name> (<output>, <input>);
Semantics:
and g1 (y, a, b, c) computes y = a & b & c.Example:
Here, we implement Y = AB + A'B'C using primitives.
module cl_example (input A, B, C, output Y);
wire A_NOT, B_NOT, AND1, AND2, OR1;
not g1 (A_NOT, A); // A_NOT = A'
not g2 (B_NOT, B); // B_NOT = B'
and g3 (AND1, A, B); // AND1 = AB
and g4 (AND2, A_NOT, B_NOT, C); // AND2 = A'B'C
or g5 (Y, AND1, AND2); // Y = AND1 + AND2
endmodule
module half_adder (input a, input b, output s, output c);
xor g_sum (s, a, b); // s = a ^ b
and g_carry(c, a, b); // c = a & b
endmodule
Compare this to the dataflow version from Note 2:
module half_adder (input a, input b, output s, output c);
assign s = a ^ b;
assign c = a & b;
endmodule
The logic is identical. The structural version makes the gate topology explicit – you can read off exactly which gates exist and how they are connected. The dataflow version expresses the same thing more compactly as equations. In practice, the dataflow version is easier to write and read. The structural version with primitives is closer to what a synthesis tool would produce after processing it.
See: IEEE 1800-2017, 23.3 Module instances, pg. 708
Note 1 introduced the basic syntax for module instantiation. This section covers the two port-connection styles in depth and the rules that govern them.
When you instantiate a module, you need to specify how to connect its ports to the signals in the parent module. There are two ways to do this.
Syntax:
<module_name> <instance_name> (<signal_1>, <signal_2>, ..., <signal_N>);
Semantics:
signal_1 connects to the first port in the module definition, signal_2 to the second, and so on.Example: given this module definition,
module fulladder (input x, input y, input cin, output sum, output cout);
// ...
endmodule
a positional instantiation looks like:
fulladder fa0 (a, b, c_in, s, c_out);
// ^ ^ ^ ^ ^
// x y cin sum cout (in definition order)
The risk: if the port order in the module definition ever changes, every positional instantiation silently rewires with no compile error and no warning. For small modules with two or three well-known ports, positional mapping is acceptable. For anything larger, prefer named mapping.
Syntax:
<module_name> <instance_name> (
.port_name_1 (signal_1),
.port_name_2 (signal_2),
...
.port_name_N (signal_N)
);
Semantics:
.port_name(signal) syntax..port_name() with nothing inside the parentheses. An unconnected input receives z. An unconnected output is left floating (legal, but that signal is lost).Example: the same fulladder with named mapping:
fulladder fa0 (
.x (a),
.y (b),
.cin (c_in),
.sum (s),
.cout(c_out)
);
If the port order in the definition changes, this instantiation is unaffected. Named mapping is the safe default for any module with more than a handful of ports.
always or initial block. Instantiation is always at the module level.assign. Make widths match intentionally.generate ConstructSee: IEEE 1800-2017, 27.1 Overview, pg. 751
Writing out 4 instantiations by hand is manageable. Writing out 32 is not. The generate construct lets you produce repetitive structure programmatically at elaboration time, before simulation begins.
Syntax:
genvar <i>;
generate
for (<i> = <start>; <i> < <end>; <i> = <i> + 1) begin : <label>
// instantiation or assign statements
end
endgenerate
Semantics:
genvar declares a loop variable that exists only during elaboration. It is not a signal and cannot be used inside simulation.for loop is unrolled by the tool into explicit instances before simulation. The result is identical to writing each instantiation by hand.: <label> after begin is required when the body contains instantiations. It gives each generated instance a unique hierarchical name: label[0].instance_name, label[1].instance_name, and so on.Example: generating a bitwise AND across two 8-bit vectors using assign.
input [7:0] x, y;
output [7:0] z;
genvar i;
generate
for (i = 0; i <= 7; i = i + 1) begin : and_bits
assign z[i] = x[i] & y[i];
end
endgenerate
This elaborates into exactly:
assign z[0] = x[0] & y[0];
assign z[1] = x[1] & y[1];
assign z[2] = x[2] & y[2];
// ... and so on through z[7]
Of course, for something this simple you would just write assign z = x & y. The generate construct pays off when the body contains module instantiations as demonstrated in the worked examples below.
Four fulladder instances chained together. The carry output of each stage feeds the carry input of the next.
module fa_4bit (result, carry, r1, r2, cin);
input [3:0] r1;
input [3:0] r2;
input cin;
output [3:0] result;
output carry;
wire c1, c2, c3;
fulladder u0 (r1[0], r2[0], cin, result[0], c1);
fulladder u1 (r1[1], r2[1], c1, result[1], c2);
fulladder u2 (r1[2], r2[2], c2, result[2], c3);
fulladder u3 (r1[3], r2[3], c3, result[3], carry);
endmodule
The port order at each instantiation site must match the fulladder definition exactly: x, y, cin, sum, cout. Getting that order wrong produces a silently broken design – i.e., zero errors given, wrong behavior.
The same design expressed with named mapping. The synthesized hardware is identical.
module fa_4bit (result, carry, r1, r2, cin);
input [3:0] r1;
input [3:0] r2;
input cin;
output [3:0] result;
output carry;
wire c1, c2, c3;
fulladder u0 (.x(r1[0]), .y(r2[0]), .cin(cin), .sum(result[0]), .cout(c1));
fulladder u1 (.x(r1[1]), .y(r2[1]), .cin(c1), .sum(result[1]), .cout(c2));
fulladder u2 (.x(r1[2]), .y(r2[2]), .cin(c2), .sum(result[2]), .cout(c3));
fulladder u3 (.x(r1[3]), .y(r2[3]), .cin(c3), .sum(result[3]), .cout(carry));
endmodule
More verbose, but immune to port reordering in the fulladder definition. For a four-bit adder the difference is minor. For a module with many ports, it is not.
For completeness, here is the same ripple-carry chain expressed with generate and a parameter to make the width configurable:
module fa_nbit #(parameter N = 4) (result, carry, r1, r2, cin);
input [N-1:0] r1;
input [N-1:0] r2;
input cin;
output [N-1:0] result;
output carry;
wire [N:0] c; // c[0] = cin, c[N] = carry out
assign c[0] = cin;
assign carry = c[N];
genvar i;
generate
for (i = 0; i < N; i = i + 1) begin : adder_chain
fulladder u (
.x (r1[i]),
.y (r2[i]),
.cin (c[i]),
.sum (result[i]),
.cout(c[i+1])
);
end
endgenerate
endmodule
To instantiate a 4-bit version (default):
fa_nbit fa4 (.r1(a), .r2(b), .cin(1'b0), .result(s), .carry(cout));
To instantiate a 16-bit version:
fa_nbit #(.N(16)) fa16 (.r1(a), .r2(b), .cin(1'b0), .result(s), .carry(cout));
The #(.N(16)) overrides the parameter at instantiation time. The generate loop then unrolls into 16 fulladder instances automatically with the same ripple-carry structure as 5.5.1 and 5.5.2, generalized to any width.