Project 3: Memory Allocator (v1.0)

Starter repository on GitHub: https://classroom.github.com/a/OmDVtmHV

For our third project, we will develop a custom memory allocator. As we discussed in class, malloc isn’t provided by the OS, nor is it a system call; it is a library function that uses system calls to allocate and deallocate memory. In most implementations of malloc, these system calls involve at least one of:

sbrk is the simplest of the two – simply give it a size and it will increase the program break (a.k.a. bound). On the other hand, mmap gives us more control but requires more work to use. The malloc implementation we’ve been using on your Linux installations will use sbrk for small allocations and mmap for larger ones.

To simplify our allocator, we will only be using mmap, and we’ll allocate entire regions of memory at a time. The size of each region should be a multiple of the system page size; this means that if a program executes malloc(1) for a single byte of memory and our machine has a page size of 4096, we’ll allocate a region of 4096 bytes instead. Consequently, if the user requests 4097 bytes then we will allocate two regions' worth of memory.

“Wait,” you’re thinking, “doesn’t that waste a whole lot of memory?” And the answer is a resounding yes. Herein lies the challenge: you will not only support allocating memory, but also use the free space management algorithms we discussed in class to split up and reuse empty regions:

You should be able to configure the active free space management algorithm via environment variables. To determine which algorithm to use:

char *algo = getenv("ALLOCATOR_ALGORITHM");
if (algo == NULL) {
    algo = "first_fit";
}

if (strcmp(algo, "first_fit") == 0) {
    ...
} else if (strcmp(algo, "best_fit") == 0) {
    ...
} else if (strcmp(algo, "worst_fit") == 0) {
    ...
}

If $ALLOCATOR_ALGORITHM isn’t set, you’ll use first fit as the default.

Managing Memory

mmap will give us regions of memory… But how do we distinguish between different allocations (called blocks) stored inside the regions? One good approach is to prefix each allocation with some metadata; this is how many malloc implementations work. Simply embed a struct at the start of each memory block that describes how large it is, whether it has been freed or not, and – most importantly – include a pointer to the next block in the chain. Looks like studying linked lists paid off after all! Here’s how this looks logically in memory, with each allocation prefixed with a metadata struct:



Struct prefix for each memory allocation

This means that allocating a single byte of memory actually takes more space: the single byte, plus the size of the metadata.

Tracking Memory

We have to have a way to keep track of this information:

Looking at the big picture, we can also see how separate regions play a role in this. Here’s a visualization of several allocations:


Regions of memory and with links

Note that both the block size as well as the user-requested allocation size (shown in parenthesis) are provided, along with block usage, memory addresses and ‘free’ status. Here is a metadata struct that contains this information:

/**
 * Defines metadata structure for memory blocks. This structure is prefixed
 * before each allocation's data payload.
 */
struct mem_block {
    /**
     * Region this block is a part of. This should point to the first block in
     * the region.
     */
    struct mem_block *region;

    /**
     * The name of this memory block. If the user doesn't specify a name for the
     * block, it should be left empty (a single null byte).
     */
    char name[32];

    /** Size of the block */
    size_t size;

    /** Links for our doubly-linked list of blocks: */
    struct mem_block *next_block;
    struct mem_block *prev_block;
} __attribute__((packed));

You may wonder where you should store the block’s free state, but as you will soon find out we are aligning allocations to 16 bytes. That means the first 3 bits of the block’s size will be unused, so we can use the 0th bit to store this information. Why care about header size when we have a 32-byte character array for storing the block’s name (seems a bit wasteful…)? Well, the name could be easily removed without much fuss if you wanted to use this allocator in production; we include it here to help us debug.

In general you should not modify this struct (nor should you need to), but you can do so if you obtain permission first.

Allocating Memory

Request new memory regions from the kernel via mmap. To perform the allocation, place a metadata struct at the start of a free memory address and then return a pointer to the ‘data’ portion of the memory shown in the first figure. Don’t return a pointer to the struct itself, because it will be overwritten by the user!

Memory allocations must be aligned to 16 bytes; in other words, the size of the memory blocks should be evenly divisible by 16. This should take the size of the block headers into account.

Once basic allocation works, you can start splitting blocks that are not 100% used. For instance, if a block is 4096 bytes in size but only 96 bytes are actually used, split the block in two: one 96-byte block, and one 4000-byte block.

When implementing your free space management algorithms, ties (i.e., blocks that satisfy the algorithm and are the same size) should be broken by choosing the first allocation you found based on the linked list order.

Freeing Memory

First, set the 0th bit of size to 1. Next, use the data payload portion of the block to store a pointer to the next free block. That’s it! This approach is why you sometimes can read ‘old’ values from memory that have been freed. After freeing a block, you should also check neighboring blocks to determine whether you can merge with them or not. Merge with any free neighboring blocks.

If an entire memory region has been freed (i.e., all of the blocks within it are free), then you should free the region with munmap. You will be able to tell when a region is free because all its blocks have been merged into a single, large block (use the region pointer in the header to distinguish where a region begins and ends in the linked list).

Reallocating Memory

If the user wants to realloc a pointer, first check to see if the block can be resized in place. Ways this could happen:

If none of the situations above are possible (e.g., the block is too large to resize in place), simply malloc a new, appropriately sized block, copy the data there, and then free the old block.

Edge Cases: If the pointer passed into realloc is NULL, then it should behave like malloc instead since there is nothing to resize. Additionally, if the size passed into realloc is 0, then the block should be freed.

Extra Features

Since we’re writing our own version of malloc, we might as well add some features while we’re at it.

Named Blocks: to help with debugging, you can optionally provide a name for each allocation. These names will be shown when state information is printed.

Memory State Information: your allocator should be able to print out the current state of the regions and blocks with the print_memory() function. See the format below.

-- Current Memory State --
[REGION 0x7f0d774e7000]
  [BLOCK 0x7f0d774e7000-0x7f0d774e70a8] 168     [USED]  'First Allocation'
  [BLOCK 0x7f0d774b0000-0x7f0d774b0050] 80      [USED]  'Second Allocation'
  [BLOCK 0x7f0d774af000-0x7f0d774af0a8] 168     [USED]  'Third Allocation'
     ...
  (list continues)

-- Free List --
[0x7f0d774e70a8] -> [0x7f0d774b0050] -> [0x7f0d774af0a8] -> (...) -> NULL

Each element is printed out in order, so there is an implied link between element 1 and element 2, and so on.

Leak Check: You can leverage the metadata we are tracking to find memory leaks. If ALLOCATOR_LEAK_CHECK is set to 1, then you should execute the leak_check() function when the program quits (use a C destructor to call it). leak_check() will print leaks, a summary, and return true if leaks were found:

-- Leak Check --
[BLOCK 0x7f0d774e7000] 168     'First Allocation'
[BLOCK 0x7f0d774b0000] 80      'Second Allocation'
     ...
  (list continues)

-- Summary --
542 blocks lost (892412 bytes)

To implement the destructor, declare void __attribute__((destructor)) shutdown(); – in this case the destructor is called shutdown. NOTE: not all programs will work with this (utilities like ls tend to close stdout and exit before we can print anything), so use the programs included with the test cases (or write your own) to verify this behavior.

Scribbling: C beginners often get tripped up by a seemingly strange behavior exhibited by malloc: sometimes they get a nice, clean chunk of memory to work with, and other times it will have ‘residual’ values that crash their program (usually when it’s being graded!). One solution to this, of course, is to use calloc() to clear the newly-allocated block. Since you are implementing your own memory allocator, you now understand why this happens: free() leaves old values in memory without cleaning them up.

To help find these memory errors, you will provide scribbling functionality: when the ALLOCATOR_SCRIBBLE environment variable is set to 1, you will fill any new allocation with 0xAA (10101010 in binary). This means that if a program assumes memory allocated by malloc is zeroed out, it will be in for a rude awakening – for instance, what might’ve been assumed to be 0 in a single byte will now be 170 (10101010 instead of 00000000).

You should scribble new allocations (malloc()), reused blocks, and any new space in a realloc.

Danger Ahead

Some C library functions call malloc, calloc, realloc, etc. This means that if your implementation isn’t correct, other functions may fail in strange and unpredictable ways. Finish implementing a simple (wasteful) allocator first with a single block per region before moving on to the other functionality. You may also want to use a simple stub implementation of free that only sets usage = 0 during testing (i.e., no munmap).

Grading

Check your code against the provided test cases. You should make sure your code runs on your Arch Linux VM.

Submission: submit via GitHub by checking in your code before the project deadline.

Your grade is based on:

Restrictions: you may only use the standard C libraries. Other external libraries are not allowed unless permission is granted in advance. Your code must compile and run on your Arch Linux VM. Failure to follow these guidelines will will result in a grade of 0.

Changelog