Back to EECS 31L Index
EECS 31L • Study Notes • Dataflow Modeling
Mahmoud Elfar • Spring 2026 • v0.1
v0.1: Initial version
Table of Contents
There are three main abstraction levels for describing a digital circuit in Verilog:
| Level | Description | How it looks |
|---|---|---|
| Structural | Describe the circuit as a network of connected components (gates, modules) | Instantiate gates and modules, wire them together |
| Dataflow | Describe how data flows and transforms between signals | assign statements expressing Boolean/arithmetic relationships |
| Behavioral | Describe what the circuit should do algorithmically | always and initial blocks |
This note covers dataflow modeling. It maps most naturally to the combinational logic you already know: you have inputs, you compute some function of them, and you drive an output.
The key idea: you describe what a signal equals, not how to compute it step by step. The simulator (and synthesizer) figures out the hardware.
A Hot Take:
If dataflow modeling doesn’t sound very convincing to you, that’s because the term does not convey anything meaningful. There is no data flowing in a dataflow model — no more or less than the other two levels of abstraction.
A better name for this style would have been continuous assignment modeling. Whatever you call it, the core concept is modeling through a collection of continuous, concurrent assignments. That’s all to it. Forget about the “relationship between inputs and outputs” and all that.
assign StatementSee: IEEE 1800-2017, 10.3 Continuous assignments, pg. 233
assign <net> = <expression>;
With an optional delay:
assign #<delay> <net> = <expression>;
The assign statement creates a continuous assignment. This means:
Example:
wire y;
wire a, b, c;
assign y = a & b | c; // y is continuously driven by this expression
If a changes, y is re-evaluated. If b changes, y is re-evaluated. And so on.
Contrast this with procedural assignment (inside always blocks), which only executes when the block is triggered. That is a different modeling style covered in a future note.
assign must be a net (e.g., wire), never a variable (e.g., reg).assign statements drive the same net, it is treated as multiple drivers. Verilog resolves the conflict using the wire/tri resolution table (like the one from the previous note) from the data types note. In practice, avoid this. Also, it is out of scope for this course.assign statements are concurrent: the order you write them in the file does not matter. All assignments are always active simultaneously.// These two are equivalent regardless of order
assign y = a & b;
assign z = y | c;
assign z = y | c;
assign y = a & b;
An expression on the RHS can use:
a, b, seldata[3:0], addr[7:0]data[2]data[3:1]4'b1010, 1'b0, 8'hFFExamples:
wire [3:0] a, b, y;
wire s, cout;
assign y = a & b; // bitwise AND of two 4-bit vectors
assign s = a[0] ^ b[0]; // XOR of the LSBs
assign cout = a[0] & b[0]; // AND of the LSBs
assign y = a[3:1]; // 3-bit part-select assigned to 4-bit wire: MSB is zero-extended
Width matching: When the LHS and RHS have different widths, Verilog zero-extends if the RHS is narrower, or truncates from the MSB if the RHS is wider. Always make sure widths match intentionally.
The conditional operator ?: is the only conditional construct that can be used in a dataflow model.
It is a ternary (three-operand) operator that selects one of two values based on a condition.
Syntax:
assign <lhs> = (<condition>) ? <value_if_true> : <value_if_false>;
Semantics:
<condition> evaluates to a non-zero value (logically true), <lhs> gets <value_if_true>.<lhs> gets <value_if_false>.Example — a 2-to-1 MUX:
wire [3:0] a, b, y;
wire sel;
assign y = (sel) ? b : a; // if sel=1, y=b; if sel=0, y=a
Conditional operators can be nested to model larger MUXes. Example — a 4-to-1 MUX:
wire [3:0] d0, d1, d2, d3, y;
wire [1:0] sel;
assign y = (sel == 2'b00) ? d0 : // if sel=00, then y=d0, else ...
(sel == 2'b01) ? d1 : // if sel=01, then y=d1, else ...
(sel == 2'b10) ? d2 : // if sel=10, then y=d2, else ...
d3; // y=d3
Notice the formatting: align the ? and : vertically for readability. The last alternative has no condition — it is the default (like the final else in an if-else chain).
See: IEEE 1800-2017, 10.3.3 Delays, pg. 235
A delay can be added to a continuous assignment:
assign #5 y = a & b; // y updates 5 time units after a or b changes
The time unit is set by your `timescale directive. With `timescale 1ns/1ps, #5 means 5 ns.
There are two distinct delay behaviors to understand:
Inertial/Assign delay (default for assign):
Transport/Wire delay (default for wire):
// Inertial/Assign delay: declared on the assignment
assign #3 y = a & b; // pulses shorter than 3 units are suppressed
// Transport/Wire delay: declared on the wire itself
wire #3 y;
assign y = a & b; // every transition on (a & b) is delayed by exactly 3 units, including short pulses
For this course: you will mostly use assign delays. The inertial vs. transport distinction is important when analyzing glitches and timing diagrams — a glitch that appears on paper may not actually propagate to the output if the gate delay is longer than the glitch pulse width.
In this implementation, we model the half adder using its gate-level Boolean equations.
module half_adder (input a, input b, output s, output c);
assign s = a ^ b;
assign c = a & b;
endmodule
In this implementation, we model the half adder using its functional behavior directly. A half adder basically computes the sum of two bits, so we use an arithmetic addition operator.
module half_adder (input a, input b, output s, output c);
assign {c, s} = a + b; // treat a and b as 1-bit numbers, sum is 2 bits: {carry, sum}
endmodule
Both designs are valid, correct, and synthesizable. In a more complex design, the second style (functional modeling) can be easier to write and less prone to errors.
module full_adder (input a, input b, input cin, output s, output cout);
assign s = a ^ b ^ cin;
assign cout = (a & b) | (a & cin) | (b & cin);
endmodule
We can use intermediate wires to implement a MUX:
module mux4 (
input [3:0] d, // data inputs: d[3], d[2], d[1], d[0]
input [1:0] sel,
output y
);
wire w0, w1;
assign w0 = sel[1] ? d[2] : d[0];
assign w1 = sel[1] ? d[3] : d[1];
assign y = sel[0] ? w1 : w0;
endmodule
Intermediate wire declarations break a complex expression into readable, named pieces. The result is still purely combinational and dataflow. This is the preferred style when the single-expression version becomes hard to read.
However, if you read the design above, it is a good example of a bad design. It is logically correct but cognitively painful to comprehend.
A better design would follow the natural way by which the functionality of a MUX is defined:
00, then y should be d[0], else, if you select 01, then y should be d[1], else … etc.module mux4 (
input [3:0] d, // data inputs: d[3], d[2], d[1], d[0]
input [1:0] sel,
output y
);
assign y = (sel == 2'b00) ? d[0] :
(sel == 2'b01) ? d[1] :
(sel == 2'b10) ? d[2] :
d[3];
endmodule
That was much better, don’t you think? Now, think about a 128-to-1 MUX, which would require 127 nested conditionals. So, another way to describe the functionality of a MUX is to use indexing:
y should be the sel-th element of the vector d. sel simply encodes the index of the selected input.module mux4 (
input [3:0] d, // data inputs: d[3], d[2], d[1], d[0]
input [1:0] sel,
output y
);
assign y = d[sel]; // use sel as an index into the vector d
endmodule
Just swapping the upper and lower nibbles of an 8-bit input.
module swap_halfs (input [7:0] in, output [7:0] out);
assign out = {in[3:0], in[7:4]}; // swap upper and lower halfs
endmodule
A priority encoder takes multiple request signals and encodes the index of the highest-priority active request.
The second output indicates whether any request is active (valid bit).
module priority_enc (
input [3:0] req,
output [1:0] grant,
output valid
);
assign valid = |req; // OR reduction: valid if any request is active
assign grant = req[3] ? 2'b11 :
req[2] ? 2'b10 :
req[1] ? 2'b01 :
2'b00;
endmodule
Note the use of the OR reduction operator |req to compute valid in a single expression.