Why Learn Combinational Logic with assign? Every digital chip--from Intel's Core processors to NVIDIA's GPUs--relies on combinational logic blocks for arithmetic operations, data routing, and signal processing. The assign statement is the foundation for building these zero-latency circuits where outputs respond instantly to input changes. Mastering assign is essential for designing efficient ALUs, multiplexers, decoders, and data path logic that form the backbone of modern processors.

8 min read | Beginner to Intermediate

What You'll Learn

  • How to implement combinational logic using the assign statement for wire-type signals
  • Design practical circuits: adders, multiplexers, decoders, and demultiplexers using assign
  • Understand how assign statements synthesize to combinational gates with zero latency
  • Apply best practices for writing clean, maintainable combinational logic

The Verilog assign statement is used to continuously drive a signal of wire datatype and synthesizes as combinational logic. Unlike sequential logic, combinational logic has no memory elements--the output depends only on the current input values. This makes assign perfect for implementing Boolean equations, arithmetic operations, and data routing circuits.

Real-World Application

Intel's ALU Design: Modern processors like Intel's Alder Lake use thousands of combinational logic blocks implemented with continuous assignments for arithmetic operations. The ALU's adder circuits, bitwise operations, and comparison logic are all combinational blocks that execute in a single clock cycle, achieving sub-nanosecond propagation delays through optimized gate-level synthesis.

Example #1 : Simple Combinational Logic

The code shown below implements a simple digital combinational logic which has an output wire z that is driven continuously with an assign statement to realize the digital equation.


module combo ( 	input 	a, b, c, d, e,
								output 	z);

	// Continuous assignment: output changes immediately when inputs change
	// Implements: z = (a AND b) OR ((c XOR d) AND (NOT e))
	// Synthesizes to AND, OR, XOR, and NOT gates
	assign z = ((a & b) | (c ^ d) & ~e);

endmodule

The module combo gets elaborated into the following hardware schematic using synthesis tools and can be seen that the combinational logic is implemented with digital gates.

simple combinational logic with assign

The assign statement creates combinational logic, not sequential. There are no flip-flops--only gates. Output z updates immediately (within gate delays) when any input changes.

Testbench

The testbench is a platform for simulating the design to ensure that the design does behave as expected. All combinations of inputs are driven to the design module using a for loop with a delay statement of 10 time units so that the new value is applied to the inputs after some time.


module tb;
	// Declare testbench variables
	reg a, b, c, d, e; // Use 'reg' for variables you assign in procedural blocks
	wire z; // Use 'wire' for outputs from modules under test
	integer i;

	// Instantiate the design and connect design inputs/outputs with
	// testbench variables
	combo u0 ( .a(a), .b(b), .c(c), .d(d), .e(e), .z(z));

	initial begin
		// At the beginning of time, initialize all inputs of the design
		// to a known value, in this case we have chosen it to be 0.
		a = 0;
		b = 0;
		c = 0;
		d = 0;
		e = 0;

		// Use a $monitor task to print any change in the signal to
		// simulation console
		$monitor ("a=%0b b=%0b c=%0b d=%0b e=%0b z=%0b",
		 a, b, c, d, e, z);

		// Because there are 5 inputs, there can be 32 different input combinations
		// So use an iterator "i" to increment from 0 to 32 and assign the value
		// to testbench variables so that it drives the design inputs
		for (i = 0; i < 32; i = i + 1) begin
			{a, b, c, d, e} = i; // Concatenation assigns bits from i to inputs
			#10; // Wait 10 time units between test vectors
		end
	end
endmodule
Output
ncsim> run
a=0 b=0 c=0 d=0 e=0 z=0
a=0 b=0 c=0 d=0 e=1 z=0
a=0 b=0 c=0 d=1 e=0 z=1
a=0 b=0 c=0 d=1 e=1 z=0
a=0 b=0 c=1 d=0 e=0 z=1
a=0 b=0 c=1 d=0 e=1 z=0
a=0 b=0 c=1 d=1 e=0 z=0
a=0 b=0 c=1 d=1 e=1 z=0
a=0 b=1 c=0 d=0 e=0 z=0
a=0 b=1 c=0 d=0 e=1 z=0
a=0 b=1 c=0 d=1 e=0 z=1
a=0 b=1 c=0 d=1 e=1 z=0
a=0 b=1 c=1 d=0 e=0 z=1
a=0 b=1 c=1 d=0 e=1 z=0
a=0 b=1 c=1 d=1 e=0 z=0
a=0 b=1 c=1 d=1 e=1 z=0
a=1 b=0 c=0 d=0 e=0 z=0
a=1 b=0 c=0 d=0 e=1 z=0
a=1 b=0 c=0 d=1 e=0 z=1
a=1 b=0 c=0 d=1 e=1 z=0
a=1 b=0 c=1 d=0 e=0 z=1
a=1 b=0 c=1 d=0 e=1 z=0
a=1 b=0 c=1 d=1 e=0 z=0
a=1 b=0 c=1 d=1 e=1 z=0
a=1 b=1 c=0 d=0 e=0 z=1
a=1 b=1 c=0 d=0 e=1 z=1
a=1 b=1 c=0 d=1 e=0 z=1
a=1 b=1 c=0 d=1 e=1 z=1
a=1 b=1 c=1 d=0 e=0 z=1
a=1 b=1 c=1 d=0 e=1 z=1
a=1 b=1 c=1 d=1 e=0 z=1
a=1 b=1 c=1 d=1 e=1 z=1
ncsim: *W,RNQUIE: Simulation is complete.

Example #2: Half Adder

The half adder module accepts two scalar inputs a and b and uses combinational logic to assign the outputs sum and carry bit cout. The sum is driven by an XOR between a and b while the carry bit is obtained by an AND between the two inputs.


module ha ( input 	a, b,
						output	sum, cout);

	// Sum bit: XOR gives 1 when inputs differ (1+0=1, 0+1=1)
	assign sum = a ^ b;

	// Carry bit: AND gives 1 only when both inputs are 1 (1+1=10 in binary)
	assign cout = a & b;
endmodule
half adder circuit with assign

Real-World Application

Carry-Lookahead Adders: Modern processors use half adders as building blocks in carry-lookahead adder (CLA) circuits. A 64-bit adder in the execution unit chains together sophisticated adder structures derived from basic half and full adders, enabling single-cycle integer addition at multi-GHz frequencies.

Testbench


module tb;
	// Declare testbench variables
	reg a, b; // Input drivers
	wire sum, cout; // Outputs from design under test
	integer i;

	// Instantiate the design and connect design inputs/outputs with
	// testbench variables
	ha u0 ( .a(a), .b(b), .sum(sum), .cout(cout));

	initial begin
		// At the beginning of time, initialize all inputs of the design
		// to a known value, in this case we have chosen it to be 0.
		a = 0;
		b = 0;

		// Use a $monitor task to print any change in the signal to
		// simulation console
		$monitor("a=%0b b=%0b sum=%0b cout=%0b", a, b, sum, cout);

		// Because there are only 2 inputs, there can be 4 different input combinations
		// So use an iterator "i" to increment from 0 to 4 and assign the value
		// to testbench variables so that it drives the design inputs
		for (i = 0; i < 4; i = i + 1) begin
			{a, b} = i;
			#10;
		end
	end
endmodule
Output
ncsim> run
a=0 b=0 sum=0 cout=0
a=0 b=1 sum=1 cout=0
a=1 b=0 sum=1 cout=0
a=1 b=1 sum=0 cout=1
ncsim: *W,RNQUIE: Simulation is complete.

Example #3: Full Adder

A full adder can be built using the half adder module shown above or the entire combinational logic can be applied as is with assign statements to drive the outputs sum and cout. This implementation uses the direct Boolean equations approach.


module fa (	input 	a, b, cin,
						output 	sum, cout);

	// Sum: XOR chain handles the three-input addition
	// Result is 1 when odd number of inputs are 1
	assign sum = (a ^ b) ^ cin;

	// Carry out: Generate carry when (a AND b) OR when sum of a,b generates carry with cin
	// This is the standard full adder carry equation
	assign cout = (a & b) | ((a ^ b) & cin);
endmodule
full adder circuit with assign

A full adder can also be built hierarchically by instantiating two half adders and an OR gate. The structural approach is more modular but may synthesize slightly differently than the Boolean equation approach shown here.

Testbench


module tb;
	reg a, b, cin; // Three input bits for full adder
	wire sum, cout; // Sum and carry outputs
	integer i;

	fa u0 ( .a(a), .b(b), .cin(cin), .sum(sum), .cout(cout));

	initial begin
		a = 0;
		b = 0;

		$monitor("a=%0b b=%0b cin=%0b sum=%0b cout=%0b", a, b, cin, sum, cout);

		// Test all 8 combinations (2^3 = 8)
		for (i = 0; i < 8; i = i + 1) begin
			{a, b, cin} = i;
			#10;
		end
	end
endmodule
Output
ncsim> run
a=0 b=0 cin=0 sum=0 cout=0
a=0 b=0 cin=1 sum=1 cout=0
a=0 b=1 cin=0 sum=1 cout=0
a=0 b=1 cin=1 sum=0 cout=1
a=1 b=0 cin=0 sum=1 cout=0
a=1 b=0 cin=1 sum=0 cout=1
a=1 b=1 cin=0 sum=0 cout=1
a=1 b=1 cin=1 sum=1 cout=1
ncsim: *W,RNQUIE: Simulation is complete.

Example #4: 2x1 Multiplexer

The simple 2x1 multiplexer uses a ternary operator to decide which input should be assigned to the output c. If sel is 1, output is driven by a and if sel is 0 output is driven by b. Multiplexers are fundamental building blocks in data path design.


module mux_2x1 (input 	a, b, sel,
								output 	c);

	// Ternary operator: sel ? true_value : false_value
	// When sel=1, output c gets input a
	// When sel=0, output c gets input b
	// Synthesizes to a 2:1 mux gate or equivalent logic
	assign c = sel ? a : b;
endmodule
2x1 multiplexer

The ternary operator (?:) is highly efficient for multiplexers. For larger muxes, consider using case statements within always blocks for better readability.

Testbench


module tb;
	// Declare testbench variables
	reg a, b, sel; // Two data inputs and one select
	wire c; // Multiplexer output
	integer i;

	// Instantiate the design and connect design inputs/outputs with
	// testbench variables
	mux_2x1 u0 ( .a(a), .b(b), .sel(sel), .c(c));

	initial begin
		// At the beginning of time, initialize all inputs of the design
		// to a known value, in this case we have chosen it to be 0.
		a = 0;
		b = 0;
		sel = 0;

		$monitor("a=%0b b=%0b sel=%0b c=%0b", a, b, sel, c);

		// Test all 8 input combinations
		for (i = 0; i < 8; i = i + 1) begin
			{a, b, sel} = i;
			#10;
		end
	end
endmodule
Output
ncsim> run
a=0 b=0 sel=0 c=0
a=0 b=0 sel=1 c=0
a=0 b=1 sel=0 c=1
a=0 b=1 sel=1 c=0
a=1 b=0 sel=0 c=0
a=1 b=0 sel=1 c=1
a=1 b=1 sel=0 c=1
a=1 b=1 sel=1 c=1
ncsim: *W,RNQUIE: Simulation is complete.

Example #5: 1x4 Demultiplexer

The demultiplexer routes a single input f to one of four outputs based on the 2-bit select signal sel. Each output signal is driven by a separate assign statement. Note that the same signal is generally not recommended to be driven by different assign statements.


module demux_1x4 (	input 				f,
										input [1:0]	 	sel,
										output 				a, b, c, d);

	// Output a is active when sel = 2'b00
	assign a = f & ~sel[1] & ~sel[0];

	// Output b is active when sel = 2'b10
	assign b = f & sel[1] & ~sel[0];

	// Output c is active when sel = 2'b01
	assign c = f & ~sel[1] & sel[0];

	// Output d is active when sel = 2'b11
	assign d = f & sel[1] & sel[0];

endmodule
1x4 demultiplexer

Each output signal should only be driven by one assign statement. Driving the same wire from multiple assign statements creates a multi-driver conflict and will cause synthesis errors or X propagation in simulation.

Testbench


module tb;
	// Declare testbench variables
	reg f; // Input data signal
	reg [1:0] sel; // 2-bit select signal (4 combinations)
	wire a, b, c, d; // Four output channels
	integer i;

	// Instantiate the design and connect design inputs/outputs with
	// testbench variables
	demux_1x4 u0 ( .f(f), .sel(sel), .a(a), .b(b), .c(c), .d(d));

	// At the beginning of time, initialize all inputs of the design
	// to a known value, in this case we have chosen it to be 0.
	initial begin
		f = 0;
		sel = 0;

		$monitor("f=%0b sel=%0b a=%0b b=%0b c=%0b d=%0b", f, sel, a, b, c, d);

		// Because there are 3 inputs, there can be 8 different input combinations
		// So use an iterator "i" to increment from 0 to 8 and assign the value
		// to testbench variables so that it drives the design inputs
		for (i = 0; i < 8; i = i + 1) begin
			{f, sel} = i;
			#10;
		end
	end
endmodule
Output
ncsim> run
f=0 sel=0 a=0 b=0 c=0 d=0
f=0 sel=1 a=0 b=0 c=0 d=0
f=0 sel=10 a=0 b=0 c=0 d=0
f=0 sel=11 a=0 b=0 c=0 d=0
f=1 sel=0 a=1 b=0 c=0 d=0
f=1 sel=1 a=0 b=0 c=1 d=0
f=1 sel=10 a=0 b=1 c=0 d=0
f=1 sel=11 a=0 b=0 c=0 d=1
ncsim: *W,RNQUIE: Simulation is complete.

Example #6: 4x16 Decoder

A decoder activates one of multiple output lines based on the binary value of the input. This 4-to-16 decoder uses an enable signal en and a compact left-shift operation to set the appropriate output bit.


module dec_4x16 ( 	input 					en,
									input 	[3:0] 	in,
									output [15:0] 	out);

	// When enabled, shift 1 left by 'in' positions to activate one output bit
	// Example: in=3 produces out=16'b0000_0000_0000_1000 (bit 3 set)
	// When disabled (en=0), all outputs are 0
	assign out = en ? 1 << in : 0;
endmodule
4x16 decoder

The left-shift operator (<<) provides an elegant way to implement decoders. The expression 1 << n creates a value with only bit n set to 1, which is exactly the behavior needed for address decoding circuits.

Testbench


module tb;
	reg en; // Enable signal
	reg [3:0] in; // 4-bit input (16 possible values)
	wire [15:0] out; // 16-bit output (one-hot encoded)
	integer i;

	dec_4x16 u0 ( .en(en), .in(in), .out(out));

	initial begin
		en = 0;
		in = 0;

		$monitor("en=%0b in=0x%0h out=0x%0h", en, in, out);

		// Test all 32 combinations (enable + 16 input values)
		for (i = 0; i < 32; i = i + 1) begin
			{en, in} = i;
			#10;
		end
	end
endmodule
Output
ncsim> run
en=0 in=0x0 out=0x0
en=0 in=0x1 out=0x0
en=0 in=0x2 out=0x0
en=0 in=0x3 out=0x0
en=0 in=0x4 out=0x0
en=0 in=0x5 out=0x0
en=0 in=0x6 out=0x0
en=0 in=0x7 out=0x0
en=0 in=0x8 out=0x0
en=0 in=0x9 out=0x0
en=0 in=0xa out=0x0
en=0 in=0xb out=0x0
en=0 in=0xc out=0x0
en=0 in=0xd out=0x0
en=0 in=0xe out=0x0
en=0 in=0xf out=0x0
en=1 in=0x0 out=0x1
en=1 in=0x1 out=0x2
en=1 in=0x2 out=0x4
en=1 in=0x3 out=0x8
en=1 in=0x4 out=0x10
en=1 in=0x5 out=0x20
en=1 in=0x6 out=0x40
en=1 in=0x7 out=0x80
en=1 in=0x8 out=0x100
en=1 in=0x9 out=0x200
en=1 in=0xa out=0x400
en=1 in=0xb out=0x800
en=1 in=0xc out=0x1000
en=1 in=0xd out=0x2000
en=1 in=0xe out=0x4000
en=1 in=0xf out=0x8000
ncsim: *W,RNQUIE: Simulation is complete.

Common Beginner Mistakes

Mistake #1: Using assign with reg data types

Why it's problematic: The assign statement can only drive wire types. Attempting to use assign with a reg causes a compilation error because reg variables are driven by procedural assignments inside always or initial blocks, not continuous assignments.

Best Practice: Use wire for all signals driven by assign. Use reg only for signals assigned in procedural blocks (always, initial). For combinational logic, choose between assign (for simple equations) or always @(*) (for complex logic with if-else or case).

Mistake #2: Multiple assign statements driving the same wire

Why it's problematic: If two or more assign statements attempt to drive the same wire, you create a multi-driver conflict. In simulation, this typically produces 'X' (unknown) values. In synthesis, this either causes an error or creates unintended tri-state logic with potential bus contention.

Best Practice: Ensure each wire is driven by exactly one assign statement or one module output port. If you need conditional assignment, use the ternary operator (?:) or combine conditions with Boolean logic in a single assign statement.

Mistake #3: Forgetting operator precedence in complex expressions

Why it's problematic: Verilog has specific operator precedence rules. For example, & (AND) has higher precedence than | (OR), so a | b & c evaluates as a | (b & c), not (a | b) & c. This can lead to incorrect logic that's difficult to debug.

Best Practice: Use parentheses liberally to make your intent explicit, even when not strictly required. Write (a | b) & c instead of relying on precedence rules. This improves readability and prevents errors. See the operator precedence table for reference.

Mistake #4: Mixing blocking and non-blocking assignments with assign

Why it's problematic: Beginners sometimes confuse continuous assignments (assign) with procedural assignments (= or <= inside always blocks). The assign keyword creates hardware that continuously updates, while procedural assignments execute sequentially when triggered by sensitivity lists or events.

Best Practice: Use assign for combinational logic on wires outside procedural blocks. Use = (blocking) for combinational logic inside always @(*) blocks. Use <= (non-blocking) for sequential logic inside clocked always @(posedge clk) blocks. Keep these three paradigms separate.

For more complex combinational logic with conditional statements, explore always blocks for combinational logic. To add memory elements and state, see sequential logic with always. Understanding how these circuits synthesize is covered in the digital design flow tutorial.

Summary

In this tutorial, you learned:

  • The assign statement creates continuous assignments for wire types, synthesizing to zero-latency combinational logic
  • Common combinational circuits like adders, multiplexers, decoders, and demultiplexers can be elegantly implemented using assign statements
  • The ternary operator (?:) and shift operators (<<) provide compact syntax for multiplexers and decoders
  • Each wire must be driven by exactly one assign statement to avoid multi-driver conflicts
  • Using explicit parentheses and proper operator precedence prevents logic errors in complex Boolean equations
Did you find what you were looking for?

Quiz

Test Your Knowledge

1 of 5 Score: 0 / 5