Sign in or create a free account to continue.

CMPUT-229 · Lesson 4 of 12 · 31 min

Digital logic for computer architects

A processor is a digital circuit: billions of transistors acting as switches, wired into gates, and gates wired into adders, multiplexers, registers and memories. You do not need to design transistors to understand computer organization, but you do need to know what the standard building blocks do, how fast they are and how they combine, because lesson 9 builds a RISC-V processor out of exactly these blocks. ROB-200 approached digital logic from the embedded-systems side, with logic levels, chips and microcontroller pins. This lesson takes the architect's view: Boolean algebra as a design tool, combinational circuits that compute, sequential circuits that remember, the arithmetic logic unit at the heart of the datapath, and the timing rules that set a processor's clock rate.

Boolean algebra

Digital logic works with two values, 0 and 1, and three basic operations: and (written as multiplication, A · B or AB), or (written A + B) and not (written with a bar or a prime, A′). Any function of binary inputs can be described by a truth table listing its output for every combination of inputs; a function of n inputs has 2ⁿ rows. Boolean algebra supplies identities for simplifying expressions, most of which resemble ordinary algebra: A + 0 = A, A · 1 = A, A · (B + C) = AB + AC. Some do not: A + A = A, A + 1 = 1, and A + BC = (A + B)(A + C).

The most useful identities for circuit design are De Morgan's laws: the complement of an and is the or of the complements, (AB)′ = A′ + B′, and the complement of an or is the and of the complements, (A + B)′ = A′B′. They let a designer move inversions around a circuit. They also show that a nand gate (and followed by not) or a nor gate is universal: any function can be built from nands alone, which is convenient because in CMOS technology nand and nor are the cheapest gates to build.

Symbols for six logic gates with their truth tables: AND, OR, NOT, NAND, NOR and XOR. Each gate symbol has inputs A and B on the left (NOT has only A) and output Y on the right. Beneath each, a four-row truth table lists the output for inputs 00, 01, 10 and 11.
The standard logic gates and their truth tables

Any truth table can be turned into a circuit mechanically. Write one product term for each row whose output is 1, an and of every input, complemented where that input is 0, and or the terms together. The result, called the sum of products form, is often not the smallest circuit, but it always works, and it is the starting point for simplification. The program below derives the sum of products for the majority function, which is 1 when at least two of its three inputs are 1. Majority will reappear in a moment as the carry output of an adder.

From truth table to sum of products, and a check of the simplified form
from itertools import product

def majority(a, b, c):
    return int(a + b + c >= 2)

terms = []
print(' a b c | out')
for a, b, c in product((0, 1), repeat=3):
    y = majority(a, b, c)
    print(f' {a} {b} {c} |  {y}')
    if y:
        lits = [n if v else n + "'" for n, v in zip('abc', (a, b, c))]
        terms.append(''.join(lits))
print('sum of products:', ' + '.join(terms))
print('simplified     : ab + ac + bc')
# check the simplified form against the truth table
ok = all(majority(a, b, c) == ((a & b) | (a & c) | (b & c))
         for a, b, c in product((0, 1), repeat=3))
print('equivalent     :', ok)
Output
 a b c | out
 0 0 0 |  0
 0 0 1 |  0
 0 1 0 |  0
 0 1 1 |  1
 1 0 0 |  0
 1 0 1 |  1
 1 1 0 |  1
 1 1 1 |  1
sum of products: a'bc + ab'c + abc' + abc
simplified     : ab + ac + bc
equivalent     : True

The four product terms simplify to ab + ac + bc: three two-input ands and one three-input or. Designers once did such simplifications by hand with Karnaugh maps; today synthesis software performs them on circuits with millions of gates, but understanding the principle helps when reading hardware descriptions and when estimating cost.

Combinational building blocks

A combinational circuit's outputs depend only on its current inputs; it has no memory. Four combinational blocks appear again and again in processors. A decoder takes an n-bit input and turns on exactly one of its 2ⁿ outputs; it selects a register by number, or a memory row by address. A multiplexer, or mux, selects one of several inputs according to a control signal: a 2-to-1 mux outputs A when its select line is 0 and B when it is 1, computing S′A + SB. Muxes are everywhere in a datapath, choosing, for example, whether the ALU's second operand comes from a register or an immediate. An adder computes the sum of two binary numbers. And a comparator tests equality, which is just an xor of each bit pair followed by a nor of the results.

Combinational building blocks of a datapath
BlockInputs and outputsTypical use in a processor
Decodern inputs, 2ⁿ outputs, exactly one activeSelecting a register or memory row
Multiplexerk data inputs, log₂k select bits, one outputChoosing between register and immediate operands
AdderTwo n-bit numbers and a carry in; n-bit sum and carry outThe ALU, the PC + 4 incrementer, branch targets
ComparatorTwo n-bit numbers; equal or less-thanBranch decisions
ALUTwo operands and an operation code; result and statusExecuting arithmetic, logic and comparison instructions

Adders

A half adder adds two bits, producing a sum bit (the xor of the inputs) and a carry bit (their and). A full adder adds three bits, two operand bits and an incoming carry, producing a sum s = a ⊕ b ⊕ cᵢₙ and a carry out cₒᵤₜ = ab + acᵢₙ + bcᵢₙ, which is exactly the majority function. Chaining n full adders, with each carry out feeding the next carry in, gives a ripple-carry adder for n-bit numbers. It is small, but slow: the carry may have to ripple through every stage, so the delay grows linearly with the width.

A full adder built from two XOR gates, two AND gates and one OR gate, with inputs a, b and carry in, and outputs sum and carry out. To the right, four full adders are chained into a 4-bit ripple-carry adder, with the carry out of each stage connected to the carry in of the next, from bit 0 on the right to bit 3 on the left.
A full adder, and four of them chained into a ripple-carry adder
A gate-level ripple-carry adder and its worst-case delay
def full_adder(a, b, cin):
    """Return the sum bit and the carry out of one stage."""
    s = a ^ b ^ cin
    cout = (a & b) | (a & cin) | (b & cin)
    return s, cout

def ripple_add(x, y, bits=8, cin=0):
    carry, total, delay = cin, 0, 0
    for i in range(bits):
        s, carry = full_adder((x >> i) & 1, (y >> i) & 1, carry)
        total |= s << i
        delay += 2            # carry logic: one level of AND, one of OR
    return total, carry, delay

for x, y in [(23, 42), (200, 100), (255, 1)]:
    s, c, d = ripple_add(x, y)
    print(f'{x:3d} + {y:3d} = {s:3d} carry {c}  (worst-case carry path {d} gate delays)')
for bits in (8, 32, 64):
    print(f'{bits}-bit ripple-carry adder: up to {2 * bits} gate delays')
Output
 23 +  42 =  65 carry 0  (worst-case carry path 16 gate delays)
200 + 100 =  44 carry 1  (worst-case carry path 16 gate delays)
255 +   1 =   0 carry 1  (worst-case carry path 16 gate delays)
8-bit ripple-carry adder: up to 16 gate delays
32-bit ripple-carry adder: up to 64 gate delays
64-bit ripple-carry adder: up to 128 gate delays

Two gate delays per bit means 64 gate delays for a 32-bit add, far too slow for a processor whose whole cycle may allow only twenty or so. Fast adders compute the carries in parallel instead. For each bit position define generate \(g_i = a_i b_i\), meaning this position creates a carry by itself, and propagate \(p_i = a_i + b_i\), meaning it passes an incoming carry along. Then \(c_{i+1} = g_i + p_i c_i\), and by substituting repeatedly every carry can be written directly in terms of the g, p and the initial carry, in a fixed number of gate levels. Carry-lookahead and prefix adders organize this calculation as a tree, giving a delay that grows with the logarithm of the width. This is a recurring theme in hardware design: spend more gates to shorten the critical path.

\[c_{i+1} = g_i + p_i\,c_i, \qquad c_2 = g_1 + p_1 g_0 + p_1 p_0 c_0\]
Generate and propagate signals for a 4-bit carry-lookahead adder
def lookahead_carries(x, y, bits=4, c0=0):
    g = [(x >> i) & (y >> i) & 1 for i in range(bits)]
    p = [((x >> i) | (y >> i)) & 1 for i in range(bits)]
    c = [c0]
    for i in range(bits):
        c.append(g[i] | (p[i] & c[i]))     # expanded fully, each is two gate levels
    return g, p, c

g, p, c = lookahead_carries(0b1011, 0b0110)
print('generate  g3..g0:', g[::-1])
print('propagate p3..p0:', p[::-1])
print('carries   c4..c0:', c[::-1])
print('check: 11 + 6 =', 0b1011 + 0b0110, '= carry', c[4], 'and sum', (0b1011 + 0b0110) & 0xF)
Output
generate  g3..g0: [0, 0, 1, 0]
propagate p3..p0: [1, 1, 1, 1]
carries   c4..c0: [1, 1, 1, 0, 0]
check: 11 + 6 = 17 = carry 1 and sum 1

The arithmetic logic unit

The ALU combines an adder with logic operations and selects among them with a multiplexer. A simple 1-bit ALU slice computes a and b, a or b and a full-adder sum for one bit position, and a mux chooses which result to output according to an operation code. Thirty-two slices side by side, with the carries chained, make a 32-bit ALU. Three tricks extend it. Subtraction comes free from two's complement: a − b = a + b′ + 1, so the ALU inverts b and sets the first carry in to 1. Set-less-than, the slt instruction, uses the subtractor: a < b exactly when a − b is negative, taking overflow into account, so the ALU outputs that sign as bit 0 and zeros elsewhere. And a zero detector, a nor of all 32 result bits, tells the branch logic whether two values were equal.

An 8-bit ALU with and, or, add, subtract and signed set-less-than
MASK = 0xFF                          # an 8-bit ALU keeps the output short

def alu(op, a, b):
    """8-bit ALU. ops: and, or, add, sub, slt (signed). Returns result and zero flag."""
    if op == 'and': r = a & b
    elif op == 'or': r = a | b
    elif op in ('add', 'sub', 'slt'):
        bb = (~b & MASK) if op != 'add' else b      # invert b to subtract
        cin = 1 if op != 'add' else 0               # ... and add 1
        r = (a + bb + cin) & MASK
        if op == 'slt':
            sa, sb, sr = a >> 7, b >> 7, r >> 7
            overflow = sa != sb and sr != sa
            r = sr ^ int(overflow)                  # true sign of a - b
    return r, int(r == 0)

def s8(x): return x - 256 if x & 0x80 else x
tests = [('and', 0b1100, 0b1010), ('or', 0b1100, 0b1010), ('add', 100, 27),
         ('sub', 27, 100), ('slt', 0x80, 0x01), ('slt', 0x01, 0x80), ('sub', 55, 55)]
for op, a, b in tests:
    r, z = alu(op, a, b)
    print(f'{op:>3} {s8(a):5d} {s8(b):5d} -> {s8(r):5d}  zero={z}')
Output
and    12    10 ->     8  zero=0
 or    12    10 ->    14  zero=0
add   100    27 ->   127  zero=0
sub    27   100 ->   -73  zero=0
slt  -128     1 ->     1  zero=0
slt     1  -128 ->     0  zero=1
sub    55    55 ->     0  zero=1

Look at the two slt rows. As signed bytes, 0x80 is −128 and 0x01 is 1, so −128 < 1 gives 1 and 1 < −128 gives 0. Computing 0x80 − 0x01 overflows: the raw difference 0x7F has sign bit 0, which would wrongly say not less than. Correcting the sign with the overflow bit fixes it. The last row shows the zero output that a beq instruction would use.

Sequential circuits: storing bits

Combinational circuits cannot remember anything. To store a value, a circuit needs feedback: outputs fed back to inputs. The simplest storage element is a latch built from two cross-coupled nor or nand gates, which settles into one of two stable states and stays there. A D latch adds a data input D and an enable: while enabled, the output follows D; when disabled, it holds its last value. Processors mostly use D flip-flops, which change only at a clock edge, typically the moment the clock rises from 0 to 1. Between edges the output is stable, however the input changes. A register is a row of flip-flops sharing a clock, usually with a write-enable signal that decides whether the register loads a new value on the next edge.

The register file is the processor's fast local storage. RV32I has 32 registers, x0 to x31, each 32 bits wide. The register file is built so that two registers can be read and one written in every cycle, because most instructions read two source registers and write one destination. Reading uses two 32-input multiplexers controlled by the source register numbers; writing uses a 5-to-32 decoder to enable the one destination register. Register x0 is special: it is wired to always read as zero, and writes to it are ignored, which lets the ISA express many operations without extra instructions. For example, mv a0, a1 is really addi a0, a1, 0, and nop is addi x0, x0, 0.

A register file model: reads are immediate, writes happen at the clock edge, x0 stays zero
class RegisterFile:
    """32 x 32-bit registers, two read ports, one write port, x0 hardwired to 0."""
    def __init__(self):
        self.r = [0] * 32
        self.pending = None
    def read(self, rs1, rs2):                  # combinational: available immediately
        return self.r[rs1], self.r[rs2]
    def write(self, rd, value, enable=True):    # takes effect at the clock edge
        self.pending = (rd, value & 0xFFFFFFFF) if enable else None
    def clock_edge(self):
        if self.pending and self.pending[0] != 0:
            rd, v = self.pending
            self.r[rd] = v
        self.pending = None

rf = RegisterFile()
rf.write(5, 1234)
print('before edge: x5 =', rf.read(5, 0)[0])
rf.clock_edge()
print('after edge : x5 =', rf.read(5, 0)[0])
rf.write(0, 99); rf.clock_edge()
print('after writing 99 to x0: x0 =', rf.read(0, 5)[0])
Output
before edge: x5 = 0
after edge : x5 = 1234
after writing 99 to x0: x0 = 0

Timing and the clock

Every gate takes time to switch, a few picoseconds in a modern chip, and signals take time to travel along wires. In a synchronous circuit, values leave flip-flops at a clock edge, pass through combinational logic, and must arrive at the next flip-flops early enough before the following edge. That margin is the setup time; the flip-flop also needs the input held steady for a short hold time after the edge. The longest delay through any combinational path between flip-flops is the critical path, and it sets the minimum clock period: the period must be at least the flip-flop's own output delay plus the critical path delay plus the setup time.

\[T_{\text{clock}} \ge t_{\text{clk-to-q}} + t_{\text{critical path}} + t_{\text{setup}}\]

Worked problem

Finding the maximum clock rate

In a datapath, flip-flops have a clock-to-output delay of 30 ps and a setup time of 20 ps. The slowest path goes through a register file read (150 ps), a mux (25 ps), the ALU (200 ps) and another mux (25 ps). What is the maximum clock rate?

Show solution
  1. Critical path = 150 + 25 + 200 + 25 = 400 ps.
  2. Minimum period = 30 + 400 + 20 = 450 ps.
  3. Maximum clock rate = 1 / 450 ps ≈ 2.22 GHz.

Answer: About 2.2 GHz. To clock faster, the designer must shorten the critical path, for example with a faster adder, or split it across two cycles, which is what pipelining does in lesson 10.

This is the link between logic design and the performance equation of lesson 1. The clock period is set by the slowest path in the whole processor, so a single slow unit drags everything down. Designers therefore balance work so that no stage is much slower than the others, and they use the fast structures, such as carry-lookahead adders, on critical paths while saving area elsewhere.

Finite state machines

A sequential circuit with a register holding its current state and combinational logic computing the next state from the current state and inputs is a finite state machine, or FSM. FSMs control traffic lights, network interfaces, cache controllers and, in a multicycle processor, the sequence of steps that executes each instruction. In a Moore machine the outputs depend only on the state; in a Mealy machine they depend on the state and the inputs. The example below is a Moore machine that watches a serial bit stream and raises its output whenever the last three bits received were 1, 0, 1, a pattern detector of the kind used to find frame markers in communication links.

A Moore finite state machine that detects the bit pattern 101, including overlaps
# States record how much of the pattern 1-0-1 has been seen so far.
NEXT = {            # (state, input bit) -> next state
    ('none', 0): 'none', ('none', 1): 'saw1',
    ('saw1', 0): 'saw10', ('saw1', 1): 'saw1',
    ('saw10', 0): 'none', ('saw10', 1): 'saw101',
    ('saw101', 0): 'saw10', ('saw101', 1): 'saw1',
}
OUTPUT = {'none': 0, 'saw1': 0, 'saw10': 0, 'saw101': 1}   # Moore outputs

state = 'none'
stream = [1, 0, 1, 0, 1, 1, 0, 0, 1, 0, 1]
outs = []
for bit in stream:                 # one clock edge per input bit
    state = NEXT[(state, bit)]
    outs.append(OUTPUT[state])
print('input :', ''.join(map(str, stream)))
print('output:', ''.join(map(str, outs)))
print('pattern found ending at positions', [i for i, o in enumerate(outs) if o])
Output
input : 10101100101
output: 00101000001
pattern found ending at positions [2, 4, 10]

Real processors are written not as gate diagrams but in hardware description languages such as Verilog, SystemVerilog or VHDL, or in newer languages such as Chisel, in which much of the open-source RISC-V hardware is written. A designer describes registers, combinational logic and state machines at roughly the level of this lesson, and synthesis tools turn the description into gates and then into a physical layout. Many free RISC-V cores can be read on the web and run on inexpensive field-programmable gate array (FPGA) boards, a good next step if this lesson interests you.

Common mistakes

  • MistakeThinking a ripple-carry adder is fast enough for a 32-bit processor.

    InsteadIts delay grows linearly with width; real processors use carry-lookahead or prefix adders with logarithmic delay.

  • MistakeForgetting that subtraction needs both an inverted operand and a carry in of 1.

    Insteada − b = a + b′ + 1; inverting b alone computes a − b − 1.

  • MistakeComputing slt from the sign of a − b alone.

    InsteadIf the subtraction overflows, the sign is wrong; use the sign xor the overflow bit.

  • MistakeExpecting a register to change as soon as its input changes.

    InsteadFlip-flop-based registers load only at the clock edge, and only when write-enabled.

  • MistakeAssuming the clock period depends on the average path delay.

    InsteadIt is set by the critical path, the slowest path between flip-flops, plus flip-flop overheads.

Summary and key terms

  • Any Boolean function can be written as a sum of products from its truth table; De Morgan's laws move inversions, and nand alone is universal.
  • Decoders, multiplexers, adders and comparators are the combinational blocks of a datapath.
  • A full adder computes s = a ⊕ b ⊕ c and carry = majority(a, b, c); chained, they form a ripple-carry adder with linear delay.
  • Carry-lookahead adders compute carries in parallel from generate and propagate signals, with logarithmic delay.
  • An ALU selects among logic and arithmetic results; subtraction adds the inverted operand plus 1, and slt uses the corrected sign of a − b.
  • Flip-flops store bits and change only at clock edges; the register file provides two reads and one write per cycle, with x0 fixed at zero.
  • The clock period must cover clock-to-output delay, the critical path and setup time; finite state machines combine a state register with next-state logic.
Truth table
A table listing a Boolean function's output for every combination of inputs.
Sum of products
An or of and terms, one for each truth-table row whose output is 1.
Multiplexer
A circuit that selects one of several inputs according to select signals.
Full adder
A circuit adding two bits and a carry in to produce a sum bit and a carry out.
Carry lookahead
Computing carries in parallel from generate and propagate signals instead of letting them ripple.
ALU
Arithmetic logic unit: the combinational circuit that performs arithmetic, logic and comparison operations.
D flip-flop
A storage element that captures its input at a clock edge and holds it until the next edge.
Register file
The set of processor registers, with read and write ports.
Critical path
The longest combinational delay between storage elements, which limits the clock rate.
Finite state machine
A sequential circuit whose next state depends on its current state and inputs.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Write the sum of products for the xor of a and b.

    Show answer

    a'b + ab'.

  2. Use De Morgan's law to rewrite (A + B + C)′.

    Show answer

    A′B′C′.

  3. How many select bits does an 8-to-1 multiplexer need?

    Show answer

    Three, since 2³ = 8.

  4. Compute the sum and carry out of a full adder with a = 1, b = 1, cin = 1.

    Show answer

    Sum 1 (1 ⊕ 1 ⊕ 1) and carry 1 (majority).

  5. With 2 gate delays per bit, what is the worst-case delay of a 16-bit ripple-carry adder?

    Show answer

    32 gate delays.

  6. For bits a = 1, b = 0, what are generate and propagate?

    Show answer

    g = 0 (not both 1) and p = 1 (at least one 1), so an incoming carry is passed on.

  7. How does an ALU compute 7 − 9 with an adder?

    Show answer

    It adds 7 to the inverted bits of 9 with carry in 1, which is 7 + (−9), giving −2.

  8. Why does RISC-V hardwire x0 to zero?

    Show answer

    A permanent zero lets many operations, such as mv, nop, neg and comparisons with zero, use existing instructions.

  9. A path has 300 ps of logic, flip-flops need 25 ps clock-to-output and 15 ps setup. What is the maximum clock rate?

    Show answer

    1 / 340 ps ≈ 2.94 GHz.

  10. What distinguishes a Moore machine from a Mealy machine?

    Show answer

    Moore outputs depend only on the state; Mealy outputs depend on the state and the current inputs.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz