Loading learning content...
While bit vectors dedicate one bit per disk block regardless of whether it's free or allocated, linked list free space management takes a fundamentally different approach: it only tracks free blocks, linking them together into a chain. This conceptual shift has profound implications for both performance characteristics and implementation complexity.
The linked list approach is historically significant—it was used in early file systems and forms the conceptual basis for the File Allocation Table (FAT) file system that powered DOS, early Windows, and remains in widespread use today on flash drives and memory cards. Understanding linked list free space management illuminates both file system history and the design trade-offs that led to modern alternatives.
This technique exemplifies a classic computer science theme: trading spatial overhead for operational efficiency in specific scenarios. Where bitmaps provide uniform cost for all operations, linked lists excel at some operations while struggling with others.
By the end of this page, you will understand how linked lists represent free space, the algorithms for allocation and deallocation, the critical performance characteristics (especially the poor random access), variants like free block caching and FAT optimization, and when linked list approaches are appropriate versus when alternatives are preferable.
In linked list free space management, free blocks are connected by pointers stored within the free blocks themselves. Each free block contains a pointer to the next free block, forming a chain:
┌─────────────────────────────────────────────────────────────────┐
│ DISK │
├────────┬────────┬────────┬────────┬────────┬────────┬────────┬──┤
│Block 0 │Block 1 │Block 2 │Block 3 │Block 4 │Block 5 │Block 6 │..│
│(alloc) │(FREE) │(alloc) │(FREE) │(alloc) │(FREE) │(FREE) │ │
│data... │next→3 │data... │next→5 │data... │next→6 │next→NIL│ │
└────────┴────────┴────────┴────────┴────────┴────────┴────────┴──┘
↑ │ ↗ ↗
free_head────────→─────────┘──────────┘─────────┘
Free List: 1 → 3 → 5 → 6 → NIL
The file system maintains a free list head pointer that points to the first free block. To find a free block, simply follow this pointer. The allocated blocks are not part of the free list—they're tracked by file metadata structures.
Key Insight: Since free blocks aren't being used for data, they can store metadata about the free list within themselves. There's no external data structure needed beyond the head pointer—the free blocks are the data structure.
Terminology:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
#include <stdint.h>#include <stdbool.h> /** * Linked List Free Space Manager * * The free list is threaded through the free blocks themselves. * Each free block contains the block number of the next free block. */ #define NULL_BLOCK UINT64_MAX // Sentinel for end of list typedef struct { uint64_t head; // First free block (NULL_BLOCK if empty) uint64_t free_count; // Number of free blocks uint64_t block_size; // Size of each block in bytes // Disk I/O functions (abstracted) void *disk_handle;} LinkedListManager; /** * Read the "next" pointer from a free block. * The pointer is stored at the start of the block. */uint64_t read_next_pointer(LinkedListManager *llm, uint64_t block_num) { uint64_t next; // disk_read(llm->disk_handle, block_num * llm->block_size, &next, sizeof(next)); return next;} /** * Write the "next" pointer into a free block. */void write_next_pointer(LinkedListManager *llm, uint64_t block_num, uint64_t next) { // disk_write(llm->disk_handle, block_num * llm->block_size, &next, sizeof(next));} /** * Allocate a single block from the free list. * Time complexity: O(1) - just take from head * I/O operations: 1 read, 1 write (to persist new head) */uint64_t ll_allocate(LinkedListManager *llm) { if (llm->head == NULL_BLOCK) { return NULL_BLOCK; // No free blocks } // Remove from head of list uint64_t allocated_block = llm->head; // Read next pointer from the block we're allocating uint64_t next = read_next_pointer(llm, allocated_block); // Update head to next block llm->head = next; llm->free_count--; // Note: Must persist new head to superblock for crash consistency // persist_superblock(llm); return allocated_block;} /** * Free a block by adding it to the free list. * Time complexity: O(1) - just add to head */bool ll_free(LinkedListManager *llm, uint64_t block_num) { // Write current head as this block's next write_next_pointer(llm, block_num, llm->head); // Update head to this block llm->head = block_num; llm->free_count++; // persist_superblock(llm); return true;} /** * Check if list is empty. * Time complexity: O(1) */bool ll_is_empty(LinkedListManager *llm) { return llm->head == NULL_BLOCK;}The linked list approach has distinctive performance characteristics that make it suitable for some scenarios and problematic for others. Let's analyze each operation in detail.
Single Block Allocation: O(1) Time, 1 I/O
Allocating a single block is exceptionally fast—just read the head pointer, read the next pointer from that block, and update the head. This is constant time and requires only a single block read.
Single Block Deallocation: O(1) Time, 1 I/O
Freeing a block is equally fast—write the current head as the block's next pointer, then update the head. Constant time, one block write.
Finding N Contiguous Blocks: O(total_free) Time, O(total_free) I/O ⚠️
This is where linked lists struggle catastrophically. To find contiguous blocks, you must:
Random Access to Free Block N: O(N) Time, O(N) I/O ⚠️
Unlike a bitmap where you can compute the bit position for any block in O(1), finding whether a specific block is free requires traversing the list from the head.
| Operation | Linked List | Bitmap | Winner |
|---|---|---|---|
| Allocate 1 block (any) | O(1), 1 I/O | O(N/64) scan, 0-1 I/O | Linked List |
| Free 1 block | O(1), 1 I/O | O(1), 0-1 I/O | Tie |
| Check if block B is free | O(F) traverse | O(1) bit test | Bitmap |
| Find N contiguous blocks | O(F) scan + sort | O(N) scan | Bitmap |
| Count free blocks | O(F) or cached O(1) | O(1) cached | Tie |
| Space overhead | 1 pointer/free block | 1 bit/all blocks | Depends* |
The inability to efficiently find contiguous blocks is the linked list approach's fatal flaw for modern file systems. Sequential file access—reading and writing files as contiguous streams—is the dominant access pattern. File systems that can't allocate contiguous blocks suffer from fragmentation and poor performance. This is why almost no modern general-purpose file system uses pure linked lists for free space.
Space Overhead Analysis:
The space trade-off is nuanced and depends on disk utilization:
Linked List overhead = (free_blocks × pointer_size)
Bitmap overhead = (total_blocks / 8) bytes
Breakeven point:
free_blocks × pointer_size = total_blocks / 8
free_blocks = total_blocks / (8 × pointer_size)
For 8-byte pointers:
free_blocks = total_blocks / 64
Implication: Linked lists use less space than bitmaps only when less than ~1.5% of blocks are free! On a nearly-full disk (>98.5% utilized), linked lists win on space. On most real systems (20-80% utilized), bitmaps are far more space-efficient.
| Disk Utilization | Free % | Bitmap Overhead | Linked List Overhead (8B ptr) |
|---|---|---|---|
| 20% | 80% | 0.003% | 0.2% |
| 50% | 50% | 0.003% | 0.1% |
| 80% | 20% | 0.003% | 0.04% |
| 95% | 5% | 0.003% | 0.01% |
| 99% | 1% | 0.003% | 0.002% |
Implementing linked list free space management requires careful attention to several practical concerns: where to store the head pointer, how to handle pointer size vs. block size ratios, maintaining list integrity during crashes, and optimizing for real-world access patterns.
Head Pointer Storage:
The free list head must be stored in a well-known, persistent location. Common choices:
Pointer Representation:
Several options exist for how block numbers are encoded:
Absolute block number: Simple but requires log2(total_blocks) bits
Example: 1TB disk, 4KB blocks → 28 bits needed → use 32-bit integer
Relative offset: Pointer gives offset from current block
Example: next = current + 5 means next block is 5 blocks away
Pro: Smaller numbers for nearby blocks
Con: More complex, rarely used in practice
Utilizing Free Block Space:
Since free blocks aren't storing user data, we can store more than just a next pointer. Some implementations store:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
/** * Enhanced Linked List Free Space Manager * * Stores additional metadata in free blocks: * - Next pointer * - Previous pointer (for doubly-linked) * - Adjacent count (rudimentary extent tracking) * - Magic number (for crash recovery verification) */ #define FREE_BLOCK_MAGIC 0xDEADBEEFCAFEBABE typedef struct __attribute__((packed)) { uint64_t magic; // Verification that this is indeed a free block uint64_t next; // Next free block uint64_t prev; // Previous free block (for doubly-linked) uint32_t adjacent_count; // Number of contiguous free blocks starting here uint32_t checksum; // CRC32 of this header} FreeBlockHeader; typedef struct { uint64_t head; uint64_t tail; // For O(1) append operations uint64_t free_count; uint64_t block_size; void *disk_handle;} EnhancedLLManager; /** * Read free block header */FreeBlockHeader read_free_header(EnhancedLLManager *ellm, uint64_t block) { FreeBlockHeader hdr; // disk_read(ellm->disk_handle, block * ellm->block_size, &hdr, sizeof(hdr)); return hdr;} /** * Write free block header */void write_free_header(EnhancedLLManager *ellm, uint64_t block, FreeBlockHeader *hdr) { hdr->magic = FREE_BLOCK_MAGIC; hdr->checksum = compute_crc32(hdr, sizeof(*hdr) - sizeof(hdr->checksum)); // disk_write(ellm->disk_handle, block * ellm->block_size, hdr, sizeof(*hdr));} /** * Verify block is actually free (crash recovery) */bool verify_free_block(EnhancedLLManager *ellm, uint64_t block) { FreeBlockHeader hdr = read_free_header(ellm, block); if (hdr.magic != FREE_BLOCK_MAGIC) { return false; // Not a free block (or corrupted) } uint32_t expected = compute_crc32(&hdr, sizeof(hdr) - sizeof(hdr.checksum)); if (hdr.checksum != expected) { return false; // Corrupted header } return true;} /** * Find contiguous blocks using adjacent_count optimization */uint64_t find_contiguous(EnhancedLLManager *ellm, uint32_t needed) { uint64_t current = ellm->head; while (current != NULL_BLOCK) { FreeBlockHeader hdr = read_free_header(ellm, current); if (!verify_free_block(ellm, current)) { // Corrupted block - skip and potentially repair current = hdr.next; // Hope next pointer is still valid continue; } if (hdr.adjacent_count >= needed) { // Found a run of sufficient size return current; } current = hdr.next; } return NULL_BLOCK; // No contiguous region found} /** * Remove block from middle of doubly-linked list * Time complexity: O(1) assuming we have the block number */void remove_from_list(EnhancedLLManager *ellm, uint64_t block) { FreeBlockHeader hdr = read_free_header(ellm, block); // Update previous block's next pointer if (hdr.prev != NULL_BLOCK) { FreeBlockHeader prev_hdr = read_free_header(ellm, hdr.prev); prev_hdr.next = hdr.next; write_free_header(ellm, hdr.prev, &prev_hdr); } else { ellm->head = hdr.next; } // Update next block's previous pointer if (hdr.next != NULL_BLOCK) { FreeBlockHeader next_hdr = read_free_header(ellm, hdr.next); next_hdr.prev = hdr.prev; write_free_header(ellm, hdr.next, &next_hdr); } else { ellm->tail = hdr.prev; } ellm->free_count--;}The linked list approach's primary weakness—requiring I/O for each allocation—can be mitigated through caching. Instead of reading from disk for each allocation, the file system maintains a cache of free block numbers in memory.
Cache Strategy:
This optimization transforms the allocation cost from O(1) with 1 I/O to amortized O(1) with 1/cache_size I/O. With a cache of 1000 free blocks, you reduce I/O by 1000×.
Cache Size Trade-offs:
| Cache Size | Memory Used | I/O Reduction | Risk at Crash |
|---|---|---|---|
| 100 blocks | 800 bytes | 100× | Lose up to 100 block records |
| 1000 blocks | 8 KB | 1000× | Lose up to 1000 block records |
| 10000 blocks | 80 KB | 10000× | Lose up to 10000 block records |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
/** * Cached Free List Manager * * Maintains an in-memory cache of free block numbers to minimize I/O. * Cache is periodically synchronized with on-disk linked list. */ #define CACHE_SIZE 1024 typedef struct { LinkedListManager *ll; // Underlying linked list uint64_t cache[CACHE_SIZE]; // In-memory cache of free blocks size_t cache_count; // Number of cached blocks size_t cache_low_water; // Refill when below this size_t cache_high_water; // Flush when above this bool dirty; // True if cache has unflushed changes} CachedFreeList; /** * Initialize cached free list */CachedFreeList* cached_init(LinkedListManager *ll) { CachedFreeList *cfl = malloc(sizeof(CachedFreeList)); cfl->ll = ll; cfl->cache_count = 0; cfl->cache_low_water = CACHE_SIZE / 4; cfl->cache_high_water = CACHE_SIZE * 3 / 4; cfl->dirty = false; // Pre-fill cache cached_refill(cfl); return cfl;} /** * Refill cache from linked list */void cached_refill(CachedFreeList *cfl) { while (cfl->cache_count < CACHE_SIZE && !ll_is_empty(cfl->ll)) { uint64_t block = ll_allocate(cfl->ll); if (block != NULL_BLOCK) { cfl->cache[cfl->cache_count++] = block; } }} /** * Flush cache to linked list */void cached_flush_some(CachedFreeList *cfl, size_t count) { while (count > 0 && cfl->cache_count > 0) { cfl->cache_count--; ll_free(cfl->ll, cfl->cache[cfl->cache_count]); count--; } cfl->dirty = (cfl->cache_count > 0);} /** * Allocate from cache (fast path) * Amortized O(1), minimal I/O */uint64_t cached_allocate(CachedFreeList *cfl) { // Check if we need to refill if (cfl->cache_count <= cfl->cache_low_water) { cached_refill(cfl); } if (cfl->cache_count == 0) { return NULL_BLOCK; // Truly out of space } // Pop from cache (LIFO - last one added is first out) cfl->cache_count--; cfl->dirty = true; return cfl->cache[cfl->cache_count];} /** * Free to cache (fast path) * O(1), no I/O unless cache is full */bool cached_free(CachedFreeList *cfl, uint64_t block) { // Check if we need to flush if (cfl->cache_count >= cfl->cache_high_water) { cached_flush_some(cfl, CACHE_SIZE / 4); } // Add to cache cfl->cache[cfl->cache_count++] = block; cfl->dirty = true; return true;} /** * Sync all cached state to disk * Called on unmount and periodically for safety */void cached_sync(CachedFreeList *cfl) { if (!cfl->dirty) return; // Flush all cached blocks back to linked list cached_flush_some(cfl, cfl->cache_count); // Persist superblock with updated head // persist_superblock(cfl->ll); cfl->dirty = false;}The cache introduces a crash consistency risk: blocks in the cache are not persistently recorded anywhere. If the system crashes, those blocks become 'leaked'—neither in the free list nor allocated to any file. Solutions include periodic syncing, journaling cache changes, or accepting some space leakage in exchange for performance.
The File Allocation Table (FAT) file system is the most successful commercial use of linked list concepts for storage management. Created by Microsoft in 1977 and still widely used today, FAT uses a unified table that serves both as a file block map and a free block tracker.
The FAT Concept:
Instead of storing the "next" pointer in each disk block, FAT centralizes all pointers in a single table stored at the beginning of the disk:
┌─────────────────────────────────────────────────────────────────┐
│ FILE ALLOCATION TABLE (in memory/disk start) │
├─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────┤
│Entry 0 │Entry 1 │Entry 2 │Entry 3 │Entry 4 │Entry 5 │... │
│RESERVED │RESERVED │ →5 │ FREE │ 0xFFF │ 0xFFF │ │
└─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────┘
│ │
│ └── Entry 4: EOF marker
│ (file ends here)
└── Entry 2: next cluster is 5
File starting at cluster 2: follows chain 2 → 5 → END
Free clusters: all entries marked FREE (0x0000)
FAT Entry Values:
| Value | Meaning (FAT32) |
|---|---|
| 0x00000000 | Free cluster |
| 0x00000001 | Reserved |
| 0x00000002 - 0x0FFFFFEF | Next cluster number |
| 0x0FFFFFF0 - 0x0FFFFFF6 | Reserved |
| 0x0FFFFFF7 | Bad cluster |
| 0x0FFFFFF8 - 0x0FFFFFFF | End of chain (EOF) |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
/** * FAT File System Simulation * * Demonstrates how FAT uses a centralized table for both * file block chains and free space tracking. */ #define FAT_FREE 0x00000000#define FAT_BAD 0x0FFFFFF7#define FAT_EOF 0x0FFFFFF8#define FAT_RESERVED 0x0FFFFFF0 typedef struct { uint32_t *fat; // The File Allocation Table uint32_t total_clusters; // Total data clusters uint32_t free_count; // Cached free count uint32_t next_free_hint; // Hint for faster allocation} FATManager; /** * Initialize FAT - mark all clusters as free */FATManager* fat_create(uint32_t cluster_count) { FATManager *fm = malloc(sizeof(FATManager)); fm->total_clusters = cluster_count; fm->fat = malloc(cluster_count * sizeof(uint32_t)); // First two entries are reserved fm->fat[0] = FAT_RESERVED; fm->fat[1] = FAT_RESERVED; // Rest start as free for (uint32_t i = 2; i < cluster_count; i++) { fm->fat[i] = FAT_FREE; } fm->free_count = cluster_count - 2; fm->next_free_hint = 2; return fm;} /** * Find a free cluster using next-fit strategy */uint32_t fat_find_free(FATManager *fm) { if (fm->free_count == 0) return 0; // 0 indicates failure uint32_t start = fm->next_free_hint; uint32_t current = start; do { if (fm->fat[current] == FAT_FREE) { fm->next_free_hint = current + 1; return current; } current++; if (current >= fm->total_clusters) current = 2; // Wrap } while (current != start); return 0; // No free cluster found} /** * Allocate a chain of clusters for a new file */uint32_t fat_allocate_chain(FATManager *fm, uint32_t count) { if (count == 0 || count > fm->free_count) return 0; uint32_t first = 0; uint32_t prev = 0; for (uint32_t i = 0; i < count; i++) { uint32_t cluster = fat_find_free(fm); if (cluster == 0) { // Out of space - free what we allocated if (first != 0) fat_free_chain(fm, first); return 0; } if (first == 0) { first = cluster; } else { // Link previous cluster to this one fm->fat[prev] = cluster; } fm->fat[cluster] = FAT_EOF; // Mark as end (for now) fm->free_count--; prev = cluster; } return first; // Return first cluster of chain} /** * Free an entire cluster chain */void fat_free_chain(FATManager *fm, uint32_t start) { uint32_t current = start; while (current >= 2 && current < fm->total_clusters) { uint32_t next = fm->fat[current]; fm->fat[current] = FAT_FREE; fm->free_count++; if (next >= FAT_EOF) { break; // End of chain } current = next; }} /** * Extend an existing chain by adding more clusters */bool fat_extend_chain(FATManager *fm, uint32_t end_cluster, uint32_t add_count) { // Verify end_cluster is actually end of a chain if (fm->fat[end_cluster] < FAT_EOF) { return false; // Not end of chain } for (uint32_t i = 0; i < add_count; i++) { uint32_t new_cluster = fat_find_free(fm); if (new_cluster == 0) return false; fm->fat[end_cluster] = new_cluster; fm->fat[new_cluster] = FAT_EOF; fm->free_count--; end_cluster = new_cluster; } return true;} /** * Walk a cluster chain (for reading a file) */void fat_walk_chain(FATManager *fm, uint32_t start, void (*callback)(uint32_t cluster, void *ctx), void *ctx) { uint32_t current = start; while (current >= 2 && current < FAT_RESERVED) { callback(current, ctx); current = fm->fat[current]; }}FAT's insight is that the same table serves two purposes: it tracks the order of clusters for each file (following the chain) AND identifies free clusters (entries with value 0). This is elegant but means the entire FAT must be accessible to find free space—a challenge for large disks where FAT32's table can be hundreds of megabytes.
The basic linked list implementation adds freed blocks to the head of the list, resulting in an unordered list where blocks appear in LIFO (last-in-first-out) deallocation order. An alternative is maintaining an ordered list where blocks are sorted by block number.
Unordered List (LIFO):
Ordered List (Sorted by Block Number):
Coalescing is merging adjacent free blocks into a single larger free region. With an ordered list, when freeing block N, you check if blocks N-1 and N+1 are also free and merge them:
Before freeing block 5:
Free list: [3] → [4] → [7] → [8] → NIL
After freeing block 5 with coalescing:
Free list: [3] → [4,5] → [7] → [8] → NIL
or: [3-5] → [7-8] → NIL (extent representation)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
/** * Ordered Free List with Coalescing * * Maintains blocks in sorted order by block number. * Automatically merges adjacent free blocks. */ typedef struct FreeNode { uint64_t start_block; // First block in this extent uint64_t block_count; // Number of contiguous blocks struct FreeNode *next; // Next extent in sorted order} FreeNode; typedef struct { FreeNode *head; uint64_t total_free;} OrderedFreeList; /** * Free a block with automatic coalescing */void ordered_free(OrderedFreeList *ofl, uint64_t block) { FreeNode *prev = NULL; FreeNode *current = ofl->head; // Find insertion point (sorted by start_block) while (current && current->start_block < block) { prev = current; current = current->next; } // Check if we can coalesce with previous extent bool merged_prev = false; if (prev && prev->start_block + prev->block_count == block) { prev->block_count++; merged_prev = true; ofl->total_free++; } // Check if we can coalesce with next extent if (current && block + 1 == current->start_block) { if (merged_prev) { // Merge all three: prev absorbs block and current prev->block_count += current->block_count; prev->next = current->next; free(current); } else { // Extend current backward current->start_block = block; current->block_count++; ofl->total_free++; } return; } // If merged with prev, we're done if (merged_prev) return; // Create new node for this single block FreeNode *new_node = malloc(sizeof(FreeNode)); new_node->start_block = block; new_node->block_count = 1; new_node->next = current; if (prev) { prev->next = new_node; } else { ofl->head = new_node; } ofl->total_free++;} /** * Allocate contiguous blocks - efficient due to extent tracking */uint64_t ordered_alloc_contiguous(OrderedFreeList *ofl, uint64_t count) { FreeNode *prev = NULL; FreeNode *current = ofl->head; // First-fit search for extent of sufficient size while (current) { if (current->block_count >= count) { uint64_t result = current->start_block; if (current->block_count == count) { // Exact fit - remove node entirely if (prev) { prev->next = current->next; } else { ofl->head = current->next; } free(current); } else { // Partial allocation - shrink extent current->start_block += count; current->block_count -= count; } ofl->total_free -= count; return result; } prev = current; current = current->next; } return NULL_BLOCK; // No sufficient contiguous region}Linked list free space management represents a distinct set of trade-offs compared to bitmap approaches. Understanding when each approach is preferable requires careful analysis of workload characteristics, device properties, and system constraints.
Linked lists are appropriate when: (1) Storage is nearly full (>99%) and overhead minimization matters, (2) Allocations are predominantly single blocks, (3) Sequential file access is not a priority, (4) Memory is too constrained to cache a bitmap, or (5) Simplicity is valued over performance. In practice, these conditions rarely align in modern general-purpose file systems.
Linked list free space management is a foundational technique that trades operation uniformity for optimized single-block allocation. While largely superseded by bitmaps in modern general-purpose file systems, the approach remains relevant in specific contexts and forms the conceptual basis for FAT, one of the most ubiquitous file systems in portable storage.
What's Next:
We've now explored both bitmaps and linked lists. The next page examines Grouping, a technique that combines aspects of both approaches—storing multiple free block addresses together to reduce traversal overhead while maintaining some of the linked list's advantages.
You now understand linked list free space management—from basic concepts through FAT's practical implementation to advanced variants with coalescing. This historical perspective illuminates why modern file systems evolved toward more sophisticated approaches.