The Single-Bus Datapath
Last lesson the control unit was an abstract
finite
state machine issuing "control signals". This lesson we finally look at what those signals
steer: the datapath — the concrete tangle of registers, adders and wires through which
the bits actually flow. We build the simplest honest datapath there is, the one at the heart of the
LC-3: a pile of components all hanging off a single shared wire called the
bus.
The single bus is a deliberate design choice with a sharp consequence. Because only one value at a time
may sit on that wire, moving data around takes a sequence of clock ticks — which is exactly why a
simple processor is multi-cycle, spending several clocks on each instruction. See how the
wire forces the tempo and you understand the whole machine.
The cast of components
A datapath is just a handful of named boxes. On the LC-3 the important ones are:
| Component | What it holds | Its job in the cycle |
| PC — program counter | address of the next instruction | drives the fetch address; increments each fetch |
| IR — instruction register | the instruction just fetched | feeds the control unit (opcode) and the operands |
| Register file | 8 general registers R0–R7 | two read ports feed the ALU; one write port takes results |
| ALU | — | ADD / AND / NOT on its two inputs, result onto the bus |
| MAR — memory address register | an address | tells memory which cell to read or write |
| MDR — memory data register | a data word | the buffer between the bus and memory's data lines |
Every access to memory goes through the MAR/MDR pair: put the address in MAR, start the
read, and one or more cycles later the word appears in MDR. They are the airlock between the fast internal
bus and comparatively slow memory.
The picture: one wire to rule them all
Here is the datapath. The thick vertical line down the middle is the global bus. Watch it
build in three stages: first the components that drive the bus (PC, register file, ALU), then the
memory interface (MAR, MDR), then the instruction path (IR and the control FSM).
Two kinds of control signal steer it. A gate (tri-state driver) like
\text{GatePC} or \text{GateALU} connects one
source to the bus for one cycle — turn on more than one and you fry the wire with a short circuit, so the
control unit guarantees at most one gate is open at a time. A load enable
like \text{LD.MAR} or \text{LD.IR} tells a register to
latch whatever is on the bus at the next clock edge. A data movement is therefore always the same
two-part gesture: open one gate, assert one load.
Tri-state drivers: why sharing a wire is even legal
An ordinary logic output is always forcing its wire either high or low — connect two of them and they fight.
A tri-state driver has a third option: disconnected (high-impedance, "Hi-Z"). Give
every component a tri-state gate onto the bus and you can safely wire them all together, provided the control
unit opens exactly one gate per cycle. That is the trick that makes a single shared bus possible — and it is
why the LC-3's control signals come in matched pairs: a gate to talk, a load to listen.
- every component reaches the bus through a tri-state gate (a driver that can go
high, low, or disconnected);
- at most one gate may be open in any cycle — two would short the bus;
- a data transfer = open one source gate + assert one destination load,
latched on the clock edge;
- because only one value moves per cycle, executing an instruction takes many cycles —
the machine is multi-cycle.
Walking ADD across the datapath
Take \text{ADD R3, R1, R2} ("R3 ← R1 + R2"). After the shared FETCH sequence has
loaded it into the IR, execution is almost free because the operands are already inside the chip:
- Fetch operands + execute (1 cycle): the register file reads R1 and R2 onto the ALU's
two inputs; the ALU is told to ADD; its result is gated onto the bus with
\text{GateALU}, and \text{LD.REG} latches it into R3.
One clock.
A register-to-register ADD is cheap: the operands never leave the chip, so no MAR/MDR dance is needed. The
expensive instructions are the ones that touch memory.
Walking LDR across the datapath
Now \text{LDR R4, R6, \#5} ("R4 ← mem[R6 + 5]"). This one has to visit memory, so
it spends real cycles shuttling data through MAR and MDR:
- Evaluate address (1 cycle): the ALU computes \text{R6} + 5;
\text{GateALU} puts it on the bus; \text{LD.MAR}
latches it into MAR.
- Start the memory read (≥1 cycle): memory reads the cell addressed by MAR; the word lands
in MDR (\text{LD.MDR}). On real memory this can stall for many cycles.
- Store result (1 cycle): \text{GateMDR} drives the loaded word
onto the bus; \text{LD.REG} writes it into R4.
Count them: ADD's data phase was one cycle, LDR's was three or more. The only reason for the
difference is the single bus forcing each hop — PC→MAR, memory→MDR, MDR→register — to take its own turn on the
wire. This is the birth of a non-trivial
CPI:
different instructions cost different numbers of cycles.
Simulate it: micro-steps on the shared bus
Below is a toy single-bus datapath that executes the same two instructions, printing every clock tick
as an "open a gate, assert a load" micro-operation. Count the ticks: FETCH alone costs several, LDR costs more
than ADD — all because one wire can carry only one value per clock.
// A toy single-bus datapath. Each tick() is ONE clock: one gate drives the bus, one register loads.
type Op = "ADD" | "LDR";
interface Instr { op: Op; dr: number; sr1: number; sr2?: number; base?: number; off?: number; }
const reg = [0, 5, 3, 0, 0, 0, 0x40, 0]; // R1=5, R2=3, R6=0x40
const dmem: number[] = []; dmem[0x45] = 42; // mem[R6 + 5] = mem[0x45] = 42
const imem: Instr[] = [
{ op: "ADD", dr: 3, sr1: 1, sr2: 2 },
{ op: "LDR", dr: 4, sr1: 0, base: 6, off: 5 },
];
let PC = 0, clock = 0;
const tick = (msg: string): void => { clock += 1; console.log(` clk ${String(clock).padStart(2)}: ${msg}`); };
function fetch(): Instr {
tick("GatePC -> BUS, LD.MAR (MAR <- PC)");
tick("memory read; LD.MDR (MDR <- mem[MAR])");
tick("GateMDR -> BUS, LD.IR (IR <- MDR)");
tick("PC <- PC + 1");
const ins = imem[PC]; PC += 1; return ins;
}
function execute(ir: Instr): void {
if (ir.op === "ADD") {
const r = reg[ir.sr1] + reg[ir.sr2 as number];
tick(`ALU ADD; GateALU -> BUS, LD.REG (R${ir.dr} <- ${r})`);
reg[ir.dr] = r;
} else { // LDR
const addr = reg[ir.base as number] + (ir.off as number);
tick(`ALU ADD; GateALU -> BUS, LD.MAR (MAR <- R${ir.base}+${ir.off} = 0x${addr.toString(16)})`);
tick(`memory read; LD.MDR (MDR <- mem[0x${addr.toString(16)}] = ${dmem[addr] ?? 0})`);
tick(`GateMDR -> BUS, LD.REG (R${ir.dr} <- ${dmem[addr] ?? 0})`);
reg[ir.dr] = dmem[addr] ?? 0;
}
}
for (let i = 0; i < imem.length; i++) {
const ir = fetch();
console.log(`--- executing ${ir.op} ---`);
execute(ir);
}
console.log(`Total clocks: ${clock}. R3 = ${reg[3]}, R4 = ${reg[4]}`);
You can — and fast machines do. Give the register file two read ports and one write port on separate
buses and an ADD's operands and result move simultaneously, collapsing several cycles into one. This
is exactly the leap from the multi-cycle datapath to the
single-cycle
pipelined datapath, where dedicated wires between stages let five instructions overlap. The single
bus is the teaching machine's honest simplicity; every bus you add buys parallelism at the cost of silicon
area and wiring. Architecture is largely the art of deciding how many buses your budget can afford.
Both sit between the CPU and memory, so they blur together in students' minds. Keep them straight by their
contents: the MAR (Memory Address Register) says which cell —
it drives memory's address lines. The MDR (Memory Data Register) says what
— it carries the word going to or coming from that cell. A read fills MAR first (the address), then MDR (the
data) a cycle or more later. Put an address in the MDR by mistake and you will read the wrong cell forever.