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.

Key takeaways
  • 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.
The core insight

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

index=(block address)  (number of cache lines)\text{index} = (\text{block address}) \bmod (\text{number of cache lines})

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 = log⁡2(block size in bytes)\log_2(\text{block size in bytes}).
  • Index — selects one of the cache's lines; width = log⁡2(number of lines)\log_2(\text{number of lines}).
  • 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.

Figure
Direct-mapped cache: address split, index lookup, and tag comparison
Memory Address (32 bits) Tag Index Offset compare select line select byte Cache Line Array V Tag Data Block 0 1 0x37A [ ...block bytes... ] ← index selects this line ⋮ (remaining lines) =? tag comparator AND with valid bit → HIT Hit ⇔ valid == 1 AND stored tag == incoming tag Index chosen by arithmetic (mod), never by search.

Complexity & correctness

Time complexity of lookup. A direct-mapped access is O(1)O(1): 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 O(1)O(1) in parallel hardware, but at the cost of nn comparators instead of 11, 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 ii implies the data at line ii is exactly the current contents of the memory block whose address maps to index ii 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 ii 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. ■\blacksquare

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 kk-th element where kk 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 = log⁡216=4\log_2 16 = 4 bits
  • Number of lines: 256/16=16256 / 16 = 16 → index = log⁡216=4\log_2 16 = 4 bits
  • Address width: assume 12 bits for this example → tag = 12−4−4=412 - 4 - 4 = 4 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)TagIndexOffsetLine beforeActionResult
`0x034``0000 0011 0100``0000``0011``0100`invalidload block, set valid, tag=`0000`MISS (compulsory)
`0x1A2``0001 1010 0010``0001``1010``0010`invalidload block, set valid, tag=`0001`MISS (compulsory)
`0x038``0000 0011 1000``0000``0011``1000`valid, tag=`0000`tag matchesHIT (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 matchesHIT

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

PitfallWhy it happensFix
Forgetting the valid bitAssuming tag match alone means hitAlways require `valid AND tag_match`; uninitialized tag memory can coincidentally match
Off-by-one in field widthsMiscounting log⁡2\log_2 for non-power-of-two sizesAlways verify `tag_bits + index_bits + offset_bits == address_width` exactly
Confusing block address with byte addressUsing the full byte address (including offset) when computing indexCompute index from bits above the offset field, never from the raw address
Ignoring dirty-block writebackOverwriting a line on miss without saving modified data firstOn a write-back cache, always check the dirty bit and flush before overwrite
Blaming "cache too small" for conflict missesConflating capacity misses with conflict missesIncrease 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
MV
Maurice Vincent Wilkes
1913–2010 · Pioneer of stored-program computing and cache memory concepts

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.

History
1965
— Maurice Wilkes formally analyzes "slave memory," an early cache concept, in print.
1968
— IBM System/360 Model 85 ships with one of the first commercial cache memories.
1971
— Early set-associative and direct-mapped designs are compared analytically in IBM research, quantifying conflict-miss tradeoffs.
1990
— Hennessy and Patterson's quantitative approach formalizes miss-rate categories (compulsory, capacity, conflict) still used to teach direct-mapped weaknesses today.
2000
s — Direct-mapped organization remains standard for very low-latency structures like TLBs and small L1 instruction caches, even as L2/L3 caches move to higher associativity.

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 log⁡2\log_2 of that count. For example, a 32 KB cache with 64-byte blocks has 32768/64=51232768 / 64 = 512 lines, requiring log⁡2512=9\log_2 512 = 9 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 nn-way set-associative cache with SS sets and a direct-mapped cache with SS 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.

Before this

Where this leads