Address Breakdown: Tag, Index, and Offset
Every time a processor loads or stores data, it must ask a cache one question fast: is this here? Answering that question in a single clock cycle requires splitting the memory address into precisely sized fields. This must happen the moment it arrives. This article explains how a physical (or virtual) address is carved into tag, index, and block-offset fields. It also explains why the widths of those fields are forced by the cache's geometry, not chosen freely. This decomposition is the load-bearing wall of every hardware cache design. A Direct-Mapped Cache cannot function without it. Hardware needs to know, given only wires and bit positions, which cache line to check. It also needs to know which byte within that line to return. Get the field widths wrong and the cache either aliases distinct memory locations onto the same slot too aggressively, wastes SRAM comparing bits that carry no information, or — worst of all — silently returns the wrong data. The big picture is arithmetic dressed up as circuitry. Once you fix three numbers — total cache size, block size, and number of sets — the tag/index/offset split falls out of simple log₂ counting. There is no design freedom left over: the field widths are a consequence of geometry. They are computable in seconds, and checkable by a sanity test. (The three field widths must sum to the total address width.) This article gives you that arithmetic as a repeatable procedure. It also provides a worked numeric trace, and the diagram every architecture course draws on the first day of the memory-hierarchy unit. By the end you will be able to take any triple (address width, cache size, block size, associativity). You will produce the tag/index/offset split without hesitation. This is the same skill you will reuse when generalizing to Set-Associative Caches. You will also reuse it when reasoning about cache performance.
- An address is split into three fields: tag (identifies which block of memory), index (selects the cache set/line), offset (selects the byte/word within the block).
- Field widths are determined entirely by cache geometry: `offset bits = log₂(block size)`, `index bits = log₂(number of sets)`, `tag bits = address width − index bits − offset bits`.
- Direct-mapped caches have one line per set, so `number of sets = number of lines = cache size / block size`.
- The index field must be identical for two addresses to collide in the same line; the tag field disambiguates which specific block currently occupies that line.
- The split is purely positional (low bits → offset, next bits → index, remaining high bits → tag) — no computation beyond bit-slicing is needed at access time.
- Getting field widths wrong causes either wasted tag-comparison hardware, undercounted cache capacity, or incorrect hit/miss detection.
Cache geometry alone — block size and set count — fixes the offset, index, and tag field widths via three log₂ counts that must sum to the total address width.
The idea
A cache is organized as an array of lines (also called sets in a direct-mapped cache, since each set holds exactly one line), each line holding a fixed-size block of contiguous bytes copied from main memory. When the CPU issues a memory address, hardware must answer three questions using only bit-slicing — no multiplication, no memory access, no loop — because this has to complete inside cache-access time. The address is partitioned, from least-significant to most-significant bit, into three contiguous fields:
- Block offset: the low-order bits that select a specific byte (or word) within a block. Its width is fixed by block size: a block of bytes needs offset bits, because that many bits are exactly enough to enumerate distinct byte positions.
- Index: the next bits up, which select which line (in a direct-mapped cache) or which set (in a set-associative cache) the block should live in. Its width is .
- Tag: all remaining high-order bits. The tag is stored alongside the data in the cache line and is compared against the incoming address's tag bits to confirm this is actually the block we think it is, not some other block that happens to map to the same index.
The key structural fact is that index selects a location, tag confirms identity. Two different memory blocks can share the same index (they must — there are vastly more possible blocks than there are cache lines) so the tag exists precisely to disambiguate that inevitable collision. A cache hit occurs only when the valid bit is set and the stored tag equals the incoming tag; the index merely got you to the right shelf, the tag confirms it's the right book.
How it works
Computing the split is a small, deterministic procedure driven by three geometric parameters: total address width (in bits), cache capacity (bytes of data storage), and block size (bytes per line). For a direct-mapped cache, associativity is 1, so:
``` FUNCTION AddressBreakdown(AddressWidth A, CacheSize C, BlockSize B): // Step 1: number of blocks the cache can hold (= number of lines, // since this is direct-mapped: 1 line per set) NumLines := C / B ASSERT NumLines is a power of two // required for clean bit-slicing ASSERT B is a power of two // Step 2: offset field width OffsetBits := log2(B) // Step 3: index field width IndexBits := log2(NumLines) // Step 4: tag field gets whatever bits remain TagBits := A - IndexBits - OffsetBits ASSERT TagBits >= 0 // sanity check: fields must fit RETURN (TagBits, IndexBits, OffsetBits)
FUNCTION Decompose(Address, TagBits, IndexBits, OffsetBits): Offset := Address[0 : OffsetBits-1] // low bits Index := Address[OffsetBits : OffsetBits+IndexBits-1] // middle bits Tag := Address[OffsetBits+IndexBits : A-1] // high bits RETURN (Tag, Index, Offset)
FUNCTION CacheLookup(Address, Cache[NumLines]): (Tag, Index, Offset) := Decompose(Address, ...) Line := Cache[Index] IF Line.valid AND Line.tag == Tag: RETURN HIT, Line.data[Offset] ELSE: RETURN MISS ``` Every step is a bit-slice or a `log2` of a power-of-two quantity — both O(1) operations realizable directly in wiring (address lines physically route to the tag-comparator, the set decoder, and the output multiplexer with no arithmetic unit involved). The two `ASSERT`s in `AddressBreakdown` are not defensive programming flourishes; they encode the real design constraint that block size and line count must be powers of two, precisely so that the field boundaries fall on fixed bit positions rather than requiring a division or modulo circuit at access time.
Reading the figure: the index field is decoded to select exactly one row of the cache array (a wire per possible index value, or a small decoder), the tag field feeds into a comparator wired to that row's stored tag, and the offset field drives a multiplexer that picks the right byte out of the 32-byte block once a hit is confirmed. All three happen in parallel — that parallelism, enabled entirely by the fixed positional split, is why a cache lookup fits in one cycle.
Complexity & correctness
Time complexity of the breakdown itself: computing the three field widths from is — three `log2` evaluations of quantities known at design time, done once when the cache is designed, not per-access. At run time, decomposing an actual address into its tag/index/offset values is also : it is pure bit-slicing (selecting fixed ranges of an already-available bit vector), which in hardware is just wiring — zero gate delays, unlike an ALU operation. This is precisely why powers-of-two sizing is mandatory: a non-power-of-two block size would force a division or modulo circuit into the critical path of every memory access, which is far slower than routing wires.
Space: no extra space is used by the decomposition; the fields are views into the existing address bits. The cache's own space is bits, but that is cache storage, not decomposition cost.
Correctness argument. The claim to justify is: the tag/index/offset split, computed as above, produces a decomposition where (a) every valid address maps to exactly one (tag, index, offset) triple, (b) the mapping is invertible, and (c) two addresses collide in the same cache line if and only if they share the same index.
- (a) Totality and (b) invertibility. Because offset, index, and tag are defined as three disjoint, contiguous, exhaustive bit ranges of the address — , , and the remaining high bits — every bit of the address belongs to exactly one field. Concatenating back together, in that order, reconstructs the original address bit-for-bit. This is just the standard fact that any bit-string of length can be uniquely partitioned into contiguous sub-strings of lengths summing to ; here by construction (Step 4 of the algorithm defines TagBits as exactly the remainder).
- (c) Collision iff same index. By definition, the cache places a block at row `Index` of the array. Two addresses with but are mapped to the same physical line, so loading after evicts 's block — this is the direct-mapped conflict the tag exists to detect (via the stored-tag comparison at lookup, per `CacheLookup` above). Conversely, if , the two addresses occupy different array rows and cannot conflict. This establishes the index field is both necessary and sufficient to determine placement, matching the direct-mapped invariant: "line number = index; block currently resident there is identified by its stored tag."
- Offset correctness follows from : the offset field ranges over exactly , so it can address every byte in the block and no more, with no wasted or insufficient bits.
Honest tradeoffs. This scheme is only correct/optimal under the powers-of-two assumption; a cache with a non-power-of-two number of lines or block size cannot use pure bit-slicing and loses the O(1)-in-hardware property. Increasing index bits (more lines, same total capacity ⇒ smaller blocks, or more capacity at fixed block size) reduces conflict misses but shrinks the tag field is unaffected in this trade — rather, more lines at fixed capacity means smaller blocks and worse spatial locality exploitation. Increasing offset bits (bigger blocks) improves spatial locality reuse but increases miss penalty (more bytes to fetch on a miss) and, at fixed capacity, reduces the number of lines, which increases conflict misses. These tensions are exactly what motivates moving to Set-Associative Caches, where the index selects a set of several candidate lines rather than a single line, trading some of this rigidity for fewer conflict evictions at the cost of needing several parallel tag comparators.
Worked example
Consider a direct-mapped cache with:
- Address width bits
- Cache size bytes
- Block size bytes
Step 1 — number of lines:
Step 2 — offset bits:
Step 3 — index bits:
Step 4 — tag bits:
Sanity check: ✓ — matches the figure above. Now trace a concrete address. Take the byte address `0x00001A34`, written in binary (32 bits): ``` 0000 0000 0000 0000 0001 1010 0011 0100 ``` Splitting from the low bit:
| Field | Width | Bit range | Binary value | Value |
|---|---|---|---|---|
| Offset | 5 bits | [4:0] | `10100` | 20 |
| Index | 8 bits | [12:5] | `10001001` | 137 |
| Tag | 19 bits | [31:13] | `0000000000000000000` | 0 |
So this address is checked against line 137 of the cache. If line 137's valid bit is set and its stored tag equals `0` (19 bits of zero), this is a hit, and the data returned is byte 20 of that line's 32-byte block. If the stored tag differs — say the line currently holds a block with tag `5` because some other address that also indexes to 137 was accessed more recently — this is a miss, and the cache must fetch the 32-byte block containing `0x00001A34` from the next level of the memory hierarchy, evicting whatever was in line 137. Now contrast with a second address, `0x00003A34`: ``` 0000 0000 0000 0000 0011 1010 0011 0100 ```
| Field | Bit range | Binary value | Value |
|---|---|---|---|
| Offset | [4:0] | `10100` | 20 |
| Index | [12:5] | `10001001` | 137 |
This address has the same index (137) and the same offset (20), but tag bits [31:13] now read `0000000000000000010` = value 2, differing from the first address's tag of 0. These two addresses collide in the cache: whichever was accessed most recently occupies line 137, and accessing the other forces an eviction — exactly the conflict-miss behavior the correctness argument above predicts, and exactly why the tag field must be compared on every access rather than trusted implicitly.
Common pitfalls & traps
| Pitfall | Why it happens | Fix |
|---|---|---|
| Using `log2(number of bytes in cache)` for the index instead of `log2(number of lines)` | Confusing total capacity with line count | Always divide by block size first: `NumLines = C / B`, then take log₂ |
| Off-by-one in bit ranges (e.g., writing `[OffsetBits : IndexBits]` instead of `[OffsetBits : OffsetBits+IndexBits-1]`) | Forgetting the index field starts after the offset field, not at absolute bit `IndexBits` | Always express ranges relative to the previous field's upper bound |
| Forgetting the offset entirely and indexing with the whole low-order address | Treating "block" and "byte" as the same granularity | Remember: a block holds multiple bytes/words; offset bits pick among them |
| Assuming tag width is fixed (e.g., always 20 bits) across different cache configurations | Copying a memorized number instead of recomputing | Tag width is whatever remains: `A − IndexBits − OffsetBits`; recompute every time geometry changes |
| Treating index bits as also part of the tag (double-counting) | Not recognizing the fields must be disjoint and exhaustive | Verify `TagBits + IndexBits + OffsetBits == A` as a mandatory sanity check |
| Assuming non-power-of-two block/line counts still allow bit-slicing | Overlooking that log₂ is only exact (and the slice only "clean") for powers of two | Real caches are designed with power-of-two block sizes and line counts specifically to keep this arithmetic exact |
Pioneers & history
The tag/index/offset decomposition is not attributed to a single inventor; it emerged as engineers built the first cache memories to bridge the growing speed gap between processors and main memory. The foundational conceptual leap — a small, fast associative memory automatically holding recently used data from a larger, slower store — traces to early work on associative memory and virtual memory management in the 1960s.
Origins · 1 minMaurice Wilkes and how this was discovered
Maurice Wilkes, designer of the EDSAC (one of the first stored-program computers), proposed the "slave memory" concept in 1965 — a small fast memory automatically managed by hardware to hold copies of frequently accessed main-memory locations. This is the direct conceptual ancestor of the modern cache, and with it, the necessity of a tag/index scheme to know what each fast-memory slot currently holds.
Where this matters
Every general-purpose processor built today — from smartphone application processors to server-class CPUs — decomposes physical or virtual addresses into tag/index/offset fields at every L1, L2, and L3 cache access, millions of times per second. Compiler writers exploit knowledge of index-field width when reasoning about cache-conscious data layout: choosing array strides that avoid mapping hot data structures to the same small set of index values, which would otherwise thrash a direct-mapped or low-associativity cache. Operating-system designers rely on the same bit-slicing principle when implementing page tables and TLBs (translation lookaside buffers), which are themselves small associative caches indexed by virtual page number. Hardware description languages (Verilog/VHDL) implementing real cache controllers literally instantiate wires at exactly these bit positions — the "arithmetic" described here is, in silicon, just a choice of which address pins connect to which decoder and comparator inputs. Understanding this decomposition is also the direct prerequisite for reasoning about Set-Associative Caches (where the index selects a set of candidate lines checked in parallel) and for computing Cache Performance and Average Memory Access Time, since hit rate — and therefore average latency — depends directly on how index-field collisions distribute real program addresses across cache lines.
Ask the tutor: How does the tag/index/offset split change once a cache becomes set-associative instead of direct-mapped?
Why is the offset field always the low-order bits, not the high-order bits?
Contiguous memory addresses that differ only in their low-order bits naturally fall within the same block, since a block is defined as a range of consecutive addresses. Placing the offset in the low bits means that incrementing an address by 1 (moving to the next byte) only changes the offset field, leaving index and tag unchanged — exactly matching how programs access sequential memory (arrays, instruction streams). If the offset were placed in the high bits instead, consecutive bytes would scatter across wildly different cache lines, destroying spatial locality and making the cache far less effective at exploiting the access patterns real programs exhibit.
Does the tag/index/offset split work the same way for instruction caches and data caches?
Yes — the arithmetic is identical, since it depends only on cache geometry (size, block size, associativity), not on what kind of data is stored. An instruction cache (I-cache) and data cache (D-cache) are simply two separate cache structures, each with potentially different capacities and block sizes, so each computes its own tag/index/offset split independently. Many CPUs deliberately size I-caches and D-caches differently based on their distinct access patterns, which naturally leads to different field widths for each.
What changes about this decomposition when moving to a set-associative cache?
The offset field is unaffected — it still depends only on block size. The index field now selects a set containing multiple lines (called "ways") rather than a single line, so `IndexBits = log2(number of sets)`, and `number of sets = number of lines / associativity`. The tag field is compared against every way in the selected set in parallel, and a hit occurs if any way's tag matches. This reduces conflict misses (multiple blocks with the same index can coexist, one per way) at the cost of needing several parallel comparators, foreshadowed directly by the single-comparator design shown here.
Can the index and tag fields overlap, or share any address bits?
No — by construction they are disjoint, contiguous ranges of the address that together with the offset field exactly partition all A bits with no overlap and no gap. This is essential for correctness: if the fields overlapped, the same address bit would influence both which line is selected and what tag is stored, breaking the invertibility of the decomposition and making it impossible to reconstruct the original address, which the correctness argument above depends on.
What happens if the cache size is not a power of two, say 3 KiB?
Then `NumLines = C / B` may not be a power of two, so `log2(NumLines)` is not an integer, and there is no way to select "which line" using a clean, fixed-width contiguous bit range. Real cache designs sidestep this entirely by requiring capacity, associativity, and block size to all be powers of two (or products of powers of two), which is why commercial cache sizes are almost universally quoted as 16 KiB, 32 KiB, 256 KiB, 1 MiB, and so on — never oddly-sized numbers like 3 KiB.
Is the tag stored per byte, per block, or per line?
The tag is stored once per line (equivalently, once per block currently resident in the cache), not per byte. This is efficient: a single tag comparison covers an entire block's worth of bytes (e.g., 32 or 64 bytes), so the hardware cost of tag storage and comparison is amortized over the whole block rather than paid per byte. This is also precisely why larger block sizes reduce the relative overhead of tag storage, though at the cost of fetching more unused data on a miss if spatial locality is poor.
How does this relate to virtual memory and page tables?
Virtual memory uses an almost identical decomposition: a virtual address splits into a virtual page number (analogous to tag+index combined) and a page offset (analogous to block offset), where page size plays the role of block size. The TLB, which caches virtual-to-physical page translations, is itself organized with its own index/tag structure over the virtual page number, exactly mirroring the direct-mapped or set-associative cache design described here — the same log₂ arithmetic applies, just at page granularity instead of cache-block granularity.