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:

ComponentWhat it holdsIts job in the cycle
PC — program counteraddress of the next instructiondrives the fetch address; increments each fetch
IR — instruction registerthe instruction just fetchedfeeds the control unit (opcode) and the operands
Register file8 general registers R0–R7two read ports feed the ALU; one write port takes results
ALUADD / AND / NOT on its two inputs, result onto the bus
MAR — memory address registeran addresstells memory which cell to read or write
MDR — memory data registera data wordthe 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.

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:

  1. 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:

  1. Evaluate address (1 cycle): the ALU computes \text{R6} + 5; \text{GateALU} puts it on the bus; \text{LD.MAR} latches it into MAR.
  2. 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.
  3. 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.