Direct-Mapped Caches
Every load instruction faces a hidden race. It must find the requested word before the processor stalls too long. A cache wins this race only if it locates data in a handful of cycles. It cannot search linearly through thousands of entries. Direct-mapped caches solve the search problem with arithmetic instead of search. A single modulo operation tells you exactly where a block must live, if it lives anywhere at all. This is the simplest possible answer to a deep design question in the Memory Hierarchy. Given a huge memory address space and a small, fast cache, how do you decide where each memory block is allowed to reside? The direct-mapped scheme answers with a rigid rule. Every address maps to exactly one cache location. This trades some flexibility for hardware that is small, fast, and easy to verify. This matters because cache lookup sits on the critical path of every load and store. Even a nanosecond of extra delay per access multiplies across billions of accesses per second. The big picture is a trade-off triangle familiar throughout computer architecture. Speed, capacity, and flexibility pull against each other. Direct mapping sacrifices flexibility (a block can go in only one place). In exchange, it gains speed and simplicity (no searching, no replacement policy needed). Understanding this trade-off precisely — not just qualitatively — is the goal of this article. It builds on Cache Basics: Blocks, Hits, and Misses. It also sets up the natural next question of what happens when you relax the "exactly one place" rule. By the end you will be able to derive the index and tag fields of an address from cache size and block size. You will trace a sequence of memory references through a direct-mapped cache by hand. You will explain precisely why tag comparison plus a valid bit is necessary and sufficient for correct hit detection. You will also articulate the central weakness — conflict misses — that motivates associativity.
- A direct-mapped cache assigns each memory block to exactly one cache line via `index = block_address mod (number_of_lines)`.
- An address splits into three fields: tag, index, and block offset — the index selects the line, the tag disambiguates which memory block currently sits there.
- A valid bit per line distinguishes "line has never been loaded" from "line holds real data," preventing false hits on power-up or after flush.
- A hit requires the valid bit to be set and the stored tag to match the incoming tag; either condition failing means a miss.
- Direct mapping needs no replacement policy (there is only one candidate line to evict) but suffers conflict misses when two frequently used blocks share an index.
- It is the fastest, cheapest, and least associative cache organization, sitting at one end of the spectrum that leads to Set-Associative Caches.
A direct-mapped cache uses the low-order bits of an address as a direct array index, replacing search with arithmetic — at the cost of allowing exactly one home per block.
The idea
A cache holds a subset of memory, organized into fixed-size blocks (also called lines), each block being some number of contiguous bytes copied from main memory. The fundamental question any cache design must answer is a placement policy: given a block's address, which cache location(s) may it occupy? A direct-mapped cache answers this with the strictest possible rule: each memory block maps to exactly one cache line, computed by
Because the number of lines is chosen to be a power of two, this modulo operation reduces to selecting a contiguous slice of low-order address bits — no divider circuit, no comparator array, just wires routed to a bit slice. This is the central engineering payoff: placement becomes free in hardware. Since many different blocks in main memory map to the same line (they all share the same low-order index bits but differ in higher-order bits), the cache must store, alongside the data, enough information to know which block currently occupies that line. This extra information is the tag — the remaining high-order bits of the block's address, stored in a per-line tag field. On every access, the cache extracts the index from the requested address, reads out the tag stored at that line, and compares it against the tag portion of the requested address. A valid bit per line records whether the line currently holds a real, loaded block at all (as opposed to garbage left over from before the cache was initialized, or an intentionally invalidated entry). A cache hit occurs exactly when the valid bit is set and the stored tag equals the requested tag; anything else — invalid line, or valid line with a mismatched tag — is a miss.
How it works
An address presented to the cache is conceptually sliced into three fields, ordered from low bits to high bits:
- Block offset — selects a byte (or word) within the block; width = .
- Index — selects one of the cache's lines; width = .
- Tag — the remaining upper bits; uniquely identifies which memory block, among all blocks that alias to this index, is present.
The relationship is additive: `tag bits + index bits + offset bits = total address width`. This decomposition is exactly the topic developed further in Address Breakdown: Tag, Index, and Offset; here we focus on how the index and tag drive lookup and fill.
Lookup algorithm. On a memory reference, the cache controller performs the following steps every single cycle, in hardware, in parallel where possible: ``` function DIRECT_MAPPED_ACCESS(address, is_write, write_data): offset_bits = log2(BLOCK_SIZE) index_bits = log2(NUM_LINES) offset = address[0 : offset_bits-1] index = address[offset_bits : offset_bits+index_bits-1] tag = address[offset_bits+index_bits : MSB] line = cache_lines[index] if line.valid == true and line.tag == tag: // HIT if is_write: line.data[offset] = write_data // write hit line.dirty = true // (write-back caches only) else: return line.data[offset] // read hit else: // MISS: bring the whole block in from lower-level memory block = MEMORY_READ_BLOCK(base_address_of(address)) if line.valid and line.dirty: // must write back the evicted block first (write-back policy) MEMORY_WRITE_BLOCK(line.tag, index, line.data) line.data = block line.tag = tag line.valid = true line.dirty = false if is_write: line.data[offset] = write_data line.dirty = true else: return line.data[offset] ``` Two properties of this pseudocode deserve emphasis. First, there is no search loop — `cache_lines[index]` is a single array access, computable in one hardware step because `index` came directly from address bits. Second, eviction requires no decision-making: because only one line can ever hold this block, "which line do we replace on a miss" has a trivial, forced answer — the line at `index`, whatever currently sits there. This is the deepest structural simplification direct mapping provides relative to associative designs.
Initialization. All valid bits must be cleared to 0 at reset/power-on. Without this, a line's tag field could coincidentally equal a requested tag while holding meaningless data, producing a false hit — this is precisely why the valid bit is a logical necessity, not an optimization.
Complexity & correctness
Time complexity of lookup. A direct-mapped access is : the index bits are extracted by wiring (no computation), the line array access is a single decode-and-read (as in a RAM), and the tag comparison is a single fixed-width equality comparator. No dependence on cache size shows up in the latency of comparison — only in the width of the tag comparator and the depth of the line-select decoder, both of which grow logarithmically with the number of lines and so are negligible in practice. Contrast this with a fully associative cache, where correctness requires comparing the incoming tag against every line's tag simultaneously — functionally still in parallel hardware, but at the cost of comparators instead of , and at real cost in area, power, and comparator fan-in delay that grows with associativity. Direct mapping is the design point that minimizes comparator hardware to a single unit.
Space complexity. Storage per line is `1 valid bit + tag_bits + block_size_bits (+ 1 dirty bit if write-back)`. Total cache storage is `NUM_LINES × (block_size_bits + tag_bits + O(1))`. Tag bits shrink as index bits grow (for fixed address width), so increasing the number of lines trades tag overhead for more distinct addressable lines — a real but secondary storage effect worth noting when computing effective cache overhead percentages.
Correctness argument. We must show the hit/miss decision is always correct: the cache reports a hit if and only if the requested block is genuinely present. Claim: `valid == true and tag == requested_tag` at index implies the data at line is exactly the current contents of the memory block whose address maps to index with that tag. Proof sketch by invariant. Maintain the invariant: whenever `cache_lines[i].valid` is true, `cache_lines[i].data` holds the most recently written version of the unique memory block whose (tag, index) pair equals (`cache_lines[i].tag`, i). This invariant holds initially vacuously (all valid bits false). It is preserved by the miss-handling code: on a miss, the algorithm loads the correct block — the one whose address decomposes to exactly this tag and this index, by construction of how `tag` and `index` were extracted from `address` — and only then sets `valid = true` and updates `tag`. It is preserved by write hits, since a write hit only mutates the data belonging to the block already validated at that line, and updates the coherent view of that same block. Since every memory block address decomposes to a unique (tag, index) pair (this is precisely why splitting address bits into disjoint tag/index/offset fields works — the mapping from address to (tag, index, offset) is a bijection), no two distinct blocks can ever be confused as long as the invariant is maintained: a tag match at index can only occur for the one block that legitimately owns that (tag, index) pair. Hence hit detection is sound. It is also complete — the cache reports a miss whenever the invariant does not currently hold data for the requested address, since either valid is false (never loaded) or the tag differs (a different block currently resides there), both of which correctly signal absence.
Tradeoff, stated honestly. Direct mapping is exposed to conflict misses: two blocks that are both heavily reused but happen to share the same index will repeatedly evict each other, even while most of the cache sits idle. This is a real, measurable performance pathology (classically visible when array strides align with cache size, e.g., accessing every -th element where is a power of two matching the number of lines). Associativity — explored in Set-Associative Caches — directly targets this weakness by giving each index multiple candidate lines, at the cost of needing multiple comparators and a replacement policy. Direct mapping is thus not universally superior or inferior; it is the right choice when simplicity, power, and clock speed dominate the design goal (e.g., L1 caches under tight timing budgets), while associativity is preferred when miss rate dominates.
Worked example
Consider a small direct-mapped cache with:
- Total size: 256 bytes
- Block size: 16 bytes → offset = bits
- Number of lines: → index = bits
- Address width: assume 12 bits for this example → tag = bits
We trace the following sequence of byte addresses (in hex, 12-bit): `0x034`, `0x1A2`, `0x038`, `0x234`, `0x1A5`. For each address, split into tag(4) | index(4) | offset(4):
| Address (hex) | Binary (12b) | Tag | Index | Offset | Line before | Action | Result |
|---|---|---|---|---|---|---|---|
| `0x034` | `0000 0011 0100` | `0000` | `0011` | `0100` | invalid | load block, set valid, tag=`0000` | MISS (compulsory) |
| `0x1A2` | `0001 1010 0010` | `0001` | `1010` | `0010` | invalid | load block, set valid, tag=`0001` | MISS (compulsory) |
| `0x038` | `0000 0011 1000` | `0000` | `0011` | `1000` | valid, tag=`0000` | tag matches | HIT (same block as row 1) |
| `0x234` | `0010 0011 0100` | `0010` | `0011` | `0100` | valid, tag=`0000` | tag differs → evict, reload, tag=`0010` | MISS (conflict — evicted row 1's block) |
| `0x1A5` | `0001 1010 0101` | `0001` | `1010` | `0101` | valid, tag=`0001` | tag matches | HIT |
The crucial row is the fourth: `0x038` and `0x234` share index `0011` but carry different tags (`0000` vs `0010`). Even though the line at index `0011` was just loaded and used at row 3, it must be evicted at row 4 because direct mapping permits no alternative slot — this is a conflict miss in action, and it is the single most important behavior this worked trace is meant to expose. Note also that if a sixth access to `0x034` followed, it would miss again — despite having been loaded twice already — purely because `0x234` occupies its line. A set-associative cache with 2-way associativity at this same index would have kept both blocks resident and turned that access into a hit.
Common pitfalls & traps
| Pitfall | Why it happens | Fix |
|---|---|---|
| Forgetting the valid bit | Assuming tag match alone means hit | Always require `valid AND tag_match`; uninitialized tag memory can coincidentally match |
| Off-by-one in field widths | Miscounting for non-power-of-two sizes | Always verify `tag_bits + index_bits + offset_bits == address_width` exactly |
| Confusing block address with byte address | Using the full byte address (including offset) when computing index | Compute index from bits above the offset field, never from the raw address |
| Ignoring dirty-block writeback | Overwriting a line on miss without saving modified data first | On a write-back cache, always check the dirty bit and flush before overwrite |
| Blaming "cache too small" for conflict misses | Conflating capacity misses with conflict misses | Increase associativity (not just capacity) to fix repeated same-index evictions |
Pioneers & history
The direct-mapped organization was not invented by a single named individual in the way an algorithm might be; it emerged as the natural, simplest engineering solution once cache memories themselves were proposed as an automatic layer between fast registers and slow main memory. Its intellectual lineage traces to the earliest cache hardware built for the IBM System/360 Model 85 and to the foundational analysis of memory hierarchies that followed.
Origins · 1 minMaurice Vincent Wilkes and how this was discovered
Wilkes built the EDSAC, one of the first operational stored-program computers, and later authored an influential 1965 paper analyzing "slave memories" — an early formalization of what we now call cache memory, including the tradeoffs between direct mapping, associative search, and their hardware costs.
Where this matters
Direct-mapped organization appears wherever latency is paramount and simplicity of hardware translates directly into a faster clock. Many processors use direct-mapped or low-associativity caches for their smallest, fastest L1 instruction caches, since a single-cycle hit time is often more valuable than a marginally lower miss rate. Translation Lookaside Buffers (TLBs), branch target buffers, and certain small first-level caches in embedded and mobile processors frequently adopt direct mapping for exactly this reason — the comparator and multiplexer overhead of associativity would otherwise threaten the target clock frequency. Understanding direct mapping is also the essential stepping stone to reasoning about the entire memory hierarchy: it is the simplest case from which Set-Associative Caches generalize (by allowing multiple lines per index) and fully associative caches represent the opposite extreme (any block anywhere, at the cost of full-array search). It also underlies practical performance tuning: programmers and compilers reasoning about cache-unfriendly access patterns — array strides that trigger repeated conflict misses — are implicitly reasoning about the exact modulo-indexing behavior described here.
Ask the tutor: How does adding associativity change the hit-detection hardware and the address field widths?
Why does direct mapping need no replacement policy?
Because each memory block has exactly one legal cache location, there is never a choice to make on a miss — the incoming block must overwrite whatever currently sits at that single index. A replacement policy (like LRU or random) only becomes meaningful when there are multiple candidate lines to choose among, as in set-associative or fully associative caches. Direct mapping sidesteps this decision entirely, which is part of why its hardware is simpler and its hit-time can be shorter: there is no need for age-tracking bits, priority encoders, or policy logic, just the forced overwrite of one deterministic slot.
How do I compute the number of index bits for a given cache?
Divide the total cache size by the block size to get the number of lines, then take of that count. For example, a 32 KB cache with 64-byte blocks has lines, requiring index bits. The remaining address bits above the index and offset fields form the tag. Always sanity-check that tag bits plus index bits plus offset bits equal the full address width; a mismatch signals a computational error in one of the three field widths.
Can a direct-mapped cache ever have a lower miss rate than a set-associative cache of the same size?
In principle yes, though it is atypical — miss rate depends on the specific access pattern, not just the mapping policy. For most realistic workloads with irregular reuse patterns, associativity strictly helps or ties, since a set-associative cache is a strict generalization (an -way set-associative cache with sets and a direct-mapped cache with lines only differ in how many blocks contend per index). But direct mapping compensates for a possibly higher miss rate with a shorter hit time, so the design question is about overall access latency (hit rate × hit time + miss rate × miss penalty), not miss rate alone.
What happens on a write hit versus a write miss in a direct-mapped cache?
On a write hit, the cache simply updates the data at the matched line (and marks it dirty, if using a write-back policy, so it will be flushed to memory later rather than immediately). On a write miss, most designs first fetch the full block from memory (write-allocate), then apply the write into the freshly loaded line — mirroring what happens on a read miss but finishing with a store instead of a load. Some designs instead use write-no-allocate, sending the write directly to memory without loading the block into the cache; the choice interacts with whether the cache is write-through or write-back, a design axis independent of the mapping strategy itself.
Does block size affect conflict misses independently of associativity?
Yes. Larger blocks reduce the number of distinct index values (for fixed total cache size), which can increase index collisions for widely spaced addresses, even though larger blocks also improve spatial locality exploitation. There is a genuine tension: bigger blocks amortize miss penalty better per byte fetched but reduce the number of lines, concentrating more distinct memory blocks onto each index and raising the conflict-miss rate for access patterns with poor locality. Cache design always balances block size, line count, and associativity jointly rather than optimizing any one in isolation.
Is a direct-mapped cache the same as a 1-way set-associative cache?
Yes, exactly — this is not just an analogy but a formal identity. A 1-way set-associative cache has exactly one line per set, which means each index still maps to precisely one physical line, identical in behavior to a direct-mapped design. This equivalence is why direct mapping is often introduced as the base case of the associativity spectrum, with 2-way, 4-way, and fully associative caches representing increasing degrees of the same underlying idea: multiple candidate lines per index instead of one.
Why doesn't a direct-mapped cache need to store the full address as a tag?
Because the index and offset bits are already implied by which line and byte are being accessed, storing them again would be redundant. Only the tag — the bits that vary among the multiple blocks that could occupy this particular index — carries new information needed to disambiguate. This is why tag width equals total address width minus index bits minus offset bits; storing anything less would lose disambiguating information, and storing anything more would waste bits already implicit in the line's position.