VHDL · Chapter 20.5 · Capstone Projects
Capstone: Memory Controller
The fifth capstone is the most timing-aware block yet, a memory controller that sequences an external SRAM-like memory. Unlike the on-chip arrays you have inferred, an external memory has a bus protocol with timing. You must drive address, data, and control signals such as chip-enable, write-enable, and output-enable in the right order and hold them for the memory's required cycles, often inserting wait states for slow devices, and capture read data at exactly the right moment. The controller is a textbook datapath and control design. A command interface accepts read and write requests, and a control state machine steps through idle, setup, access, and capture phases to drive the memory bus. Generics set the address width, data width, and access latency. Verification covers read-after-write ordering, wait-state counts, back-to-back accesses, and boundary addresses.
Intermediate16 min readVHDLCapstoneMemory ControllerFSMTimingBus Protocol
1. Engineering intuition — a state machine that obeys the memory's timing contract
An external memory is a device with a timing contract: to write, you must present the address, present the data, and pulse write-enable, each held for a minimum time; to read, you assert the address and output-enable, wait the memory's access latency, then capture the data on the right edge. The controller's whole job is to honor that contract — drive the bus signals in the correct sequence and hold them long enough, inserting wait states when the memory is slower than your clock. So the natural structure is a control FSM stepping through the phases of each access while a small datapath routes address and data. Picture a state machine that walks setup → access → (wait) → capture for each request, driving the memory's control pins on cue — that is the controller.
2. Formal explanation — the memory controller architecture
entity mem_ctrl is
generic ( ADDR_W : positive := 16; DATA_W : positive := 16; RD_LATENCY : positive := 2 );
port ( clk, rst : in std_logic;
-- command interface (handshake)
req : in std_logic; we_cmd : in std_logic; ack : out std_logic;
addr_in : in std_logic_vector(ADDR_W-1 downto 0);
wdata : in std_logic_vector(DATA_W-1 downto 0);
rdata : out std_logic_vector(DATA_W-1 downto 0); rvalid : out std_logic;
-- external memory bus
mem_addr : out std_logic_vector(ADDR_W-1 downto 0);
mem_data_o : out std_logic_vector(DATA_W-1 downto 0);
mem_data_i : in std_logic_vector(DATA_W-1 downto 0);
mem_ce_n, mem_we_n, mem_oe_n : out std_logic );
end entity;
-- CONTROL FSM sequences each access (datapath/control, 18.5):
-- IDLE -> on req: latch addr/we/wdata, drive mem_addr + mem_ce_n='0'
-- WRITE : drive mem_data_o + pulse mem_we_n low for the required time -> ACK
-- READ : assert mem_oe_n='0', WAIT RD_LATENCY cycles (wait states), then CAPTURE mem_data_i -> rvalid
-- back to IDLE.The controller is a command interface (req/ack, we_cmd) + a control FSM (idle/write/read with
wait states) + a small datapath (address/data routing) driving the memory bus (ce_n, we_n, oe_n).
Generics set the address/data widths and the read latency. The FSM exists to honor the memory's timing.
3. Production usage — write pulse and read capture with wait states
process (clk) begin
if rising_edge(clk) then
if rst = '1' then state <= IDLE; mem_ce_n <= '1'; mem_we_n <= '1'; mem_oe_n <= '1'; ack <= '0'; rvalid <= '0';
else
ack <= '0'; rvalid <= '0';
case state is
when IDLE =>
mem_ce_n <= '1'; mem_we_n <= '1'; mem_oe_n <= '1';
if req = '1' then
mem_addr <= addr_in; mem_ce_n <= '0';
if we_cmd = '1' then mem_data_o <= wdata; state <= WRITE; else mem_oe_n <= '0'; wcnt <= 0; state <= READ; end if;
end if;
when WRITE =>
mem_we_n <= '0'; -- assert write-enable (hold per timing)
state <= WR_DONE;
when WR_DONE =>
mem_we_n <= '1'; mem_ce_n <= '1'; ack <= '1'; state <= IDLE; -- end the write pulse
when READ =>
if wcnt = RD_LATENCY-1 then -- WAIT STATES for access latency
rdata <= mem_data_i; -- CAPTURE at the right cycle
rvalid <= '1'; mem_oe_n <= '1'; mem_ce_n <= '1'; state <= IDLE;
else
wcnt <= wcnt + 1;
end if;
end case;
end if;
end if;
end process;What hardware does this become? A control FSM plus bus-driver logic: for a write, it presents address
and data and pulses we_n low for the required duration; for a read, it asserts oe_n, counts
RD_LATENCY wait states, then captures mem_data_i on the correct cycle and pulses rvalid. The datapath
is just registers routing address/data; the timing is everything — the FSM holds each signal long enough and
captures read data at exactly the right moment. Generics make it serve different memory widths and a faster or
slower device (more or fewer wait states). This is the datapath/control pattern applied to an external timing
contract.
4. Structural interpretation — the memory controller
5. Simulation interpretation — a write cycle and a read cycle with wait states
Write (we_n pulse) then read (oe_n + wait states + capture)
10 cycles6. Debugging example — capturing read data too early (and a short write pulse)
Expected: reliable reads and writes to the external memory. Observed: reads return stale or wrong data
(especially on a slower memory), and some writes do not commit, with both worsening as the memory's timing
tightens. Root cause: the controller captured read data before the memory's access latency elapsed (too
few wait states), so it latched the bus before the data was valid; and/or the write-enable pulse was too short,
not held for the memory's required write time, so the write did not commit. Fix: count the memory's read
latency as wait states and capture mem_data_i only on the cycle the data is guaranteed valid, and hold the
write-enable pulse (and address/data) for the device's required write time — both parameterized by generics
(RD_LATENCY, write hold). Engineering takeaway: an external memory controller must obey the device's timing —
insert enough wait states to capture read data when it is valid, and hold the write pulse long enough to
commit; capturing early or pulsing short corrupts reads and writes.
-- BUG: capture read data immediately (no wait states) + one-cycle we pulse -> stale reads, lost writes.
-- FIX: count RD_LATENCY wait states before capture; hold we_n low for the required write time.
-- when READ => if wcnt = RD_LATENCY-1 then rdata <= mem_data_i; rvalid <= '1'; ... else wcnt <= wcnt+1; end if;7. Common mistakes & what to watch for
- Capturing read data too early. Count the memory's access latency as wait states and capture only when the data is valid; early capture latches garbage.
- Write pulse too short. Hold write-enable (and address/data) for the device's required write time, or the write does not commit.
- Hard-coded latency. Parameterize timing with generics (read latency, hold times) so one controller serves faster and slower memories.
- Glitchy control signals. Drive
ce_n/we_n/oe_ncleanly from the FSM (registered), respecting setup/hold — glitches can corrupt accesses. - Verifying only single accesses. Test read-after-write ordering, varying wait-state counts, back-to-back accesses, and boundary addresses.
8. Engineering insight & continuity
The memory controller capstone is datapath/control built around an external timing contract: a command interface, a control FSM that sequences read/write cycles with the right signal ordering and wait states, correct read-data capture and write-enable pulses, and generics for widths and latency — verified across read-after-write, wait-state, and boundary cases. It is the model for any block that talks to a timed external device. The final capstone brings all the blocks together into a working subsystem — the closing lesson, Capstone: Integrating a Small SoC Block, which composes these components into one verified design and completes the curriculum.