Skip to main content

Low-Level Programming in C

Home/projects/Low-Level Programming in C

[C Programming]

Low-Level Programming in C

A full progression through C fundamentals at ALX/Holberton — from compiler flags and the preprocessor to manual memory management, data structures, bit manipulation, file I/O, and search algorithms.

CGCCMakeValgrindGDBLinuxData StructuresAlgorithms
View repository

A full progression through C from first principles — starting at the compiler command line and moving through pointers, memory management, data structures, bit manipulation, file I/O, function pointers, and search algorithms. Every concept was implemented by hand: no standard library wrappers, no shortcuts. The goal was to understand exactly what happens between source code and running process.


The Compilation Pipeline

Before writing any significant C, the first week was spent understanding what gcc actually does in four distinct stages. Each stage was invoked separately:

# 1. Preprocessor — expand macros, #include directives, strip comments
gcc -E $CFILE -o c

# 2. Compiler — translate C to assembly
gcc -S $CFILE

# 3. Assembler — translate assembly to object file (machine code, not yet linked)
gcc -c $CFILE

# 4. Full compilation — produce a named executable
gcc $CFILE -o cisfun

# Generate Intel-syntax assembly instead of AT&T (easier to read)
gcc -S -masm=intel $CFILE

Understanding these stages matters because it makes error messages legible — a "preprocessor error" and a "linker error" are completely different problems with completely different fixes.


Implementing the Standard Library by Hand

Rather than calling printf and strlen freely, the entire standard library was reimplemented function by function. This forces you to understand what those functions actually do at the byte level.

_putchar — The Foundation

Every output function in the project ultimately calls this one. It uses the write syscall directly, bypassing stdio entirely:

// 0x02-functions_nested_loops/_putchar.c

int _putchar(char c)
{
    return (write(1, &c, 1)); // fd 1 = stdout
}

_strlen — Walking a Pointer to the Null Terminator

// 0x05-pointers_arrays_strings/2-strlen.c

int _strlen(char *s)
{
    int len = 0;

    while (*s != '\0')
    {
        len++;
        s++;  // advance the pointer, not an index
    }
    return (len);
}

_strcpy — Manual Byte-by-Byte Copy

// 0x05-pointers_arrays_strings/9-strcpy.c

char *_strcpy(char *dest, char *src)
{
    int l = 0, x = 0;

    while (*(src + l) != '\0')
        l++;

    for (; x < l; x++)
        dest[x] = src[x];

    dest[l] = '\0';  // must null-terminate manually
    return (dest);
}
// 0x07-pointers_arrays_strings/5-strstr.c

char *_strstr(char *haystack, char *needle)
{
    for (; *haystack != '\0'; haystack++)
    {
        char *l = haystack;
        char *p = needle;

        // walk both pointers while characters match
        while (*l == *p && *p != '\0')
        {
            l++;
            p++;
        }

        if (*p == '\0')  // exhausted needle = full match found
            return (haystack);
    }
    return (0);
}

rot13 — Character Mapping with a Lookup Table

// 0x06-pointers_arrays_strings/100-rot13.c

char *rot13(char *s)
{
    int i, j;
    char plain[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
    char rot[]   = "NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm";

    for (i = 0; s[i] != '\0'; i++)
    {
        for (j = 0; j < 52; j++)
        {
            if (s[i] == plain[j])
            {
                s[i] = rot[j];
                break;
            }
        }
    }
    return (s);
}

_atoi — String to Integer, Handling Signs

The custom _atoi handles negative numbers by counting minus signs — an odd number of minuses means the result is negative:

// 0x05-pointers_arrays_strings/100-atoi.c

int _atoi(char *s)
{
    int i = 0, d = 0, n = 0, f = 0, digit = 0;
    int len = 0;

    while (s[len] != '\0')
        len++;

    while (i < len && f == 0)
    {
        if (s[i] == '-')
            ++d;  // count minus signs

        if (s[i] >= '0' && s[i] <= '9')
        {
            digit = s[i] - '0';
            if (d % 2)        // odd number of minuses = negative
                digit = -digit;
            n = n * 10 + digit;
            f = 1;
            if (s[i + 1] < '0' || s[i + 1] > '9')
                break;
            f = 0;
        }
        i++;
    }
    return (f == 0 ? 0 : n);
}

Building a Static Library

Once enough functions were implemented, they were archived into a static library using ar. The header declares all symbols, and any .c file that includes it gets the full implementation at link time:

// 0x09-static_libraries/main.h

#ifndef MAIN_H
#define MAIN_H

int _putchar(char c);
int _islower(int c);
int _isalpha(int c);
int _abs(int n);
int _isupper(int c);
int _isdigit(int c);
int _strlen(char *s);
void _puts(char *s);
char *_strcpy(char *dest, char *src);
int _atoi(char *s);
char *_strcat(char *dest, char *src);
char *_strncat(char *dest, char *src, int n);
char *_strncpy(char *dest, char *src, int n);
int _strcmp(char *s1, char *s2);
char *_memset(char *s, char b, unsigned int n);
char *_memcpy(char *dest, char *src, unsigned int n);
char *_strchr(char *s, char c);
unsigned int _strspn(char *s, char *accept);
char *_strpbrk(char *s, char *accept);
char *_strstr(char *haystack, char *needle);

#endif

And the dynamic library equivalent is built with gcc -fPIC and gcc -shared:

# 0x18-dynamic_libraries/1-create_dynamic_lib.sh

gcc -fPIC -c *.c           # position-independent code — required for shared libs
gcc -shared -o liball.so *.o

Recursion

Classic algorithms were implemented recursively to internalize how the call stack works. The key insight: every recursive function needs a base case that does not call itself, and every recursive case must move closer to that base case.

Factorial

// 0x08-recursion/3-factorial.c

int factorial(int n)
{
    if (n < 0)  return (-1);  // undefined
    if (n == 0) return (1);   // base case: 0! = 1
    return (n * factorial(n - 1));
}

Natural Square Root

Instead of using sqrt(), this finds the integer square root by incrementing a counter until i * i > n:

// 0x08-recursion/5-sqrt_recursion.c

int actual_sqrt_recursion(int n, int i)
{
    if (i * i > n)  return (-1);   // no integer square root
    if (i * i == n) return (i);    // found it
    return (actual_sqrt_recursion(n, i + 1));
}

int _sqrt_recursion(int n)
{
    if (n < 0) return (-1);
    return (actual_sqrt_recursion(n, 0));
}

Palindrome Check

The palindrome checker uses two helpers — one to get the string length recursively, one to compare characters from opposite ends inward:

// 0x08-recursion/100-is_palindrome.c

int check_pal(char *s, int i, int len)
{
    if (*(s + i) != *(s + len - 1))
        return (0);
    if (i >= len)
        return (1);
    return (check_pal(s, i + 1, len - 1)); // move inward from both sides
}

int is_palindrome(char *s)
{
    if (*s == 0) return (1);
    return (check_pal(s, 0, _strlen_recursion(s)));
}

Wildcard Pattern Matching

The wildcard matcher handles * by trying two possibilities recursively — either the * matches the current character, or it matches nothing:

// 0x08-recursion/101-wildcmp.c

int wildcmp(char *s1, char *s2)
{
    if (*s1 == '\0')
    {
        if (*s2 != '\0' && *s2 == '*')
            return (wildcmp(s1, s2 + 1)); // star can match empty string
        return (*s2 == '\0');
    }

    if (*s2 == '*')
        // star matches current char OR star matches nothing
        return (wildcmp(s1 + 1, s2) || wildcmp(s1, s2 + 1));

    if (*s1 == *s2)
        return (wildcmp(s1 + 1, s2 + 1));

    return (0);
}

Manual Memory Management

Dynamic allocation was covered before any higher-level data structure work, so every allocation is done with malloc/free directly.

create_array — Allocate and Initialize

// 0x0B-malloc_free/0-create_array.c

char *create_array(unsigned int size, char c)
{
    char *str;
    unsigned int i;

    str = malloc(sizeof(char) * size);
    if (size == 0 || str == NULL)
        return (NULL);

    for (i = 0; i < size; i++)
        str[i] = c;

    return (str);
}

alloc_grid — 2D Array with Cleanup on Partial Failure

The important pattern here is the cleanup loop on malloc failure — if row x fails, free all rows 0 through x-1 before returning NULL. Without this, every failed allocation is a memory leak:

// 0x0B-malloc_free/3-alloc_grid.c

int **alloc_grid(int width, int height)
{
    int **grid;
    int x, y;

    if (width <= 0 || height <= 0)
        return (NULL);

    grid = malloc(sizeof(int *) * height);
    if (grid == NULL)
        return (NULL);

    for (x = 0; x < height; x++)
    {
        grid[x] = malloc(sizeof(int) * width);

        if (grid[x] == NULL)
        {
            // clean up all previously allocated rows before returning
            for (; x >= 0; x--)
                free(grid[x]);
            free(grid);
            return (NULL);
        }
    }

    for (x = 0; x < height; x++)
        for (y = 0; y < width; y++)
            grid[x][y] = 0;

    return (grid);
}

_realloc — Implemented from Scratch

// 0x0C-more_malloc_free/100-realloc.c

void *_realloc(void *ptr, unsigned int old_size, unsigned int new_size)
{
    char *ptr1, *old_ptr;
    unsigned int i;

    if (new_size == old_size) return (ptr);
    if (new_size == 0 && ptr) { free(ptr); return (NULL); }
    if (!ptr) return (malloc(new_size));

    ptr1 = malloc(new_size);
    if (!ptr1) return (NULL);

    old_ptr = ptr;

    // copy only min(old_size, new_size) bytes
    for (i = 0; i < (new_size < old_size ? new_size : old_size); i++)
        ptr1[i] = old_ptr[i];

    free(ptr);
    return (ptr1);
}

malloc_checked — Fail Fast

A wrapper that calls exit(98) if malloc returns NULL rather than propagating a NULL return through every caller:

// 0x0C-more_malloc_free/0-malloc_checked.c

void *malloc_checked(unsigned int b)
{
    void *ptr = malloc(b);

    if (ptr == NULL)
        exit(98);  // hard fail — no silent nulls

    return (ptr);
}

Structures and Typedef

The dog project was the first introduction to grouping related data into a struct, allocating it on the heap, and writing a matched free function for every new function:

// 0x0E-structures_typedef/dog.h

struct dog
{
    char *name;
    float age;
    char *owner;
};
typedef struct dog dog_t;

new_dog allocates the struct and copies the strings into heap memory — the caller owns nothing from their stack after this call:

// 0x0E-structures_typedef/4-new_dog.c

dog_t *new_dog(char *name, float age, char *owner)
{
    dog_t *dog = malloc(sizeof(dog_t));
    if (dog == NULL) return (NULL);

    dog->name = malloc(sizeof(char) * (_strlen(name) + 1));
    if (dog->name == NULL) { free(dog); return (NULL); }

    dog->owner = malloc(sizeof(char) * (_strlen(owner) + 1));
    if (dog->owner == NULL)
    {
        free(dog);
        free(dog->name);  // free name before returning
        return (NULL);
    }

    _strcpy(dog->name, name);
    _strcpy(dog->owner, owner);
    dog->age = age;
    return (dog);
}

The matching free_dog frees in the right order — members before the struct itself:

// 0x0E-structures_typedef/5-free_dog.c

void free_dog(dog_t *d)
{
    if (d)
    {
        free(d->name);
        free(d->owner);
        free(d);   // struct last — members must outlive the struct pointer
    }
}

Function Pointers

A function pointer is just an address that points to executable code rather than data. The calculator project stores operator functions in a struct array and looks them up by the operator character — a basic dispatch table:

// 0x0F-function_pointers/3-calc.h

typedef struct op
{
    char *op;
    int (*f)(int a, int b);   // pointer to a function taking two ints, returning int
} op_t;
// 0x0F-function_pointers/3-get_op_func.c

int (*get_op_func(char *s))(int, int)
{
    op_t ops[] = {
        {"+", op_add},
        {"-", op_sub},
        {"*", op_mul},
        {"/", op_div},
        {"%", op_mod},
        {NULL, NULL},
    };

    int i = 0;
    while (ops[i].op != NULL && *(ops[i].op) != *s)
        i++;

    return (ops[i].f);  // NULL if operator not found
}

array_iterator takes a function pointer as a parameter — applies any function to every element without the caller knowing the implementation:

// 0x0F-function_pointers/1-array_iterator.c

void array_iterator(int *array, size_t size, void (*action)(int))
{
    unsigned int i;

    if (array == NULL || action == NULL)
        return;

    for (i = 0; i < size; i++)
        action(array[i]);  // call through the pointer
}

Variadic Functions

print_all accepts a format string made of type codes — 'c' for char, 'i' for int, 'f' for float, 's' for string — and dispatches to the right printf format through a switch:

// 0x10-variadic_functions/3-print_all.c

void print_all(const char * const format, ...)
{
    int i = 0;
    char *str, *sep = "";
    va_list list;

    va_start(list, format);

    if (format)
    {
        while (format[i])
        {
            switch (format[i])
            {
                case 'c': printf("%s%c", sep, va_arg(list, int));    break;
                case 'i': printf("%s%d", sep, va_arg(list, int));    break;
                case 'f': printf("%s%f", sep, va_arg(list, double)); break;
                case 's':
                    str = va_arg(list, char *);
                    printf("%s%s", sep, str ? str : "(nil)");
                    break;
                default:
                    i++;
                    continue;  // skip unknown format codes
            }
            sep = ", ";
            i++;
        }
    }

    printf("\n");
    va_end(list);
}

Data Structures

Singly Linked List

The linked list implementation covers insertion at head and tail, deletion at index, traversal, reversal, and loop detection.

add_node inserts at the head in O(1):

// 0x12-singly_linked_lists/2-add_node.c

list_t *add_node(list_t **head, const char *str)
{
    list_t *new;
    unsigned int len = 0;

    while (str[len])
        len++;

    new = malloc(sizeof(list_t));
    if (!new) return (NULL);

    new->str  = strdup(str);
    new->len  = len;
    new->next = (*head);   // point new node at old head
    (*head)   = new;       // update head pointer

    return (*head);
}

free_list walks the list, saving next before freeing each node — without the save, the next pointer is gone after free:

// 0x12-singly_linked_lists/4-free_list.c

void free_list(list_t *head)
{
    list_t *temp;

    while (head)
    {
        temp = head->next;  // save next before free
        free(head->str);    // free string first
        free(head);         // then the node
        head = temp;
    }
}

Floyd's cycle detection (fast and slow pointer) finds the entry point of a loop:

// 0x13-more_singly_linked_lists/103-find_loop.c

listint_t *find_listint_loop(listint_t *head)
{
    listint_t *slow, *fast;

    if (head == NULL || head->next == NULL)
        return (NULL);

    slow = head->next;
    fast = head->next->next;

    // phase 1: detect if a loop exists
    while (fast != NULL && fast->next != NULL)
    {
        if (slow == fast)
        {
            // phase 2: find the loop entry point
            slow = head;
            while (slow != fast)
            {
                slow = slow->next;
                fast = fast->next;
            }
            return (slow);
        }
        slow = slow->next;
        fast = fast->next->next;
    }
    return (NULL);
}

Doubly Linked List

The doubly linked list adds a prev pointer (thepreviouscount in this codebase) so traversal works in both directions. delete_dnodeint_at_index must update both prev->next and next->prev:

// 0x17-doubly_linked_lists/8-delete_dnodeint.c

int delete_dnodeint_at_index(dlistint_t **head, unsigned int index)
{
    dlistint_t *node;
    size_t idx = 0;

    if (!head || !(*head))
        return (-1);

    node = *head;

    if (index == 0)
    {
        if (node->next)
        {
            *head = node->next;
            (*head)->thepreviouscount = NULL;  // new head has no prev
        }
        else
            *head = NULL;
        free(node);
        return (1);
    }

    while (idx++ < index && node)
        node = node->next;

    if (!node) return (-1);

    if (node->thepreviouscount && node->next)
    {
        node->next->thepreviouscount = node->thepreviouscount;
        node->thepreviouscount->next = node->next;
        free(node);
    }
    else if (!node->next)
    {
        node->thepreviouscount->next = NULL;
        free(node);
    }

    return (1);
}

Hash Table

The hash table uses separate chaining for collision resolution — each bucket is a linked list of nodes. The DJB2 hash function is a standard fast hash:

// 0x1A-hash_tables/1-djb2.c

unsigned long int hash_djb2(const unsigned char *str)
{
    unsigned long int hash = 5381;
    int c;

    while ((c = *str++))
        hash = ((hash << 5) + hash) + c; /* hash * 33 + c */

    return (hash);
}

The bucket index is simply hash % table_size. hash_table_set handles collision by prepending the new node to the bucket's linked list:

// 0x1A-hash_tables/3-hash_table_set.c

int hash_table_set(hash_table_t *ht, const char *key, const char *value)
{
    unsigned long int idx;
    hash_node_t *elem, *new_node;

    if (ht == NULL || key == NULL || strcmp(key, "") == 0)
        return (0);

    idx  = key_index((unsigned char *)key, ht->size);
    elem = ht->array[idx];

    // key already exists in this bucket — update value in place
    if (elem && strcmp(key, elem->key) == 0)
    {
        free(elem->value);
        elem->value = strdup(value);
        return (1);
    }

    // new key — prepend to the bucket's chain
    new_node = malloc(sizeof(hash_node_t));
    if (new_node == NULL) return (0);

    new_node->key   = strdup(key);
    new_node->value = strdup(value);
    new_node->next  = ht->array[idx];
    ht->array[idx]  = new_node;

    return (1);
}

Bit Manipulation

All bit operations are done with shifts and masks directly — no helper functions.

Binary string to unsigned int — multiply-and-add in base 2:

// 0x14-bit_manipulation/0-binary_to_uint.c

unsigned int binary_to_uint(const char *g)
{
    int j;
    unsigned int dec_val = 0;

    if (!g) return (0);

    for (j = 0; g[j]; j++)
    {
        if (g[j] < '0' || g[j] > '1')
            return (0);
        dec_val = 2 * dec_val + (g[j] - '0'); // shift left and add bit
    }
    return (dec_val);
}

Get, set, and clear individual bits — the three fundamental bit operations, each in one line of arithmetic:

// 0x14-bit_manipulation/2-get_bit.c
int get_bit(unsigned long int n, unsigned int index)
{
    if (index > 63) return (-1);
    return ((n >> index) & 1);          // shift bit to position 0, mask it
}

// 0x14-bit_manipulation/3-set_bit.c
int set_bit(unsigned long int *n, unsigned int index)
{
    if (index > 63) return (-1);
    *n = (1UL << index) | *n;           // OR with a 1 in the target position
    return (1);
}

// 0x14-bit_manipulation/4-clear_bit.c
int clear_bit(unsigned long int *n, unsigned int index)
{
    if (index > 63) return (-1);
    *n = ~(1UL << index) & *n;          // AND with all-ones except target position
    return (1);
}

Count bits that differ — XOR produces a 1 wherever two numbers differ, then count the 1s:

// 0x14-bit_manipulation/5-flip_bits.c

unsigned int flip_bits(unsigned long int n, unsigned long int m)
{
    int i, count = 0;
    unsigned long int diff = n ^ m;     // 1 where bits differ

    for (i = 63; i >= 0; i--)
    {
        if ((diff >> i) & 1)
            count++;
    }
    return (count);
}

Endianness check — store 1 as an int, read the first byte. On a little-endian machine the least significant byte comes first, so *c == 1:

// 0x14-bit_manipulation/100-get_endianness.c

int get_endianness(void)
{
    unsigned int i = 1;
    char *c = (char *)&i;
    return (*c); // 1 = little-endian, 0 = big-endian
}

File I/O

All file operations use POSIX syscalls directly — open, read, write, close — not fopen/fread:

// 0x15-file_io/0-read_textfile.c

ssize_t read_textfile(const char *filename, size_t letters)
{
    char *buf;
    ssize_t fd, bytes_read, bytes_written;

    fd = open(filename, O_RDONLY);
    if (fd == -1) return (0);

    buf = malloc(sizeof(char) * letters);

    bytes_read    = read(fd, buf, letters);
    bytes_written = write(STDOUT_FILENO, buf, bytes_read);

    free(buf);
    close(fd);
    return (bytes_written);
}

create_file uses O_CREAT | O_TRUNC to create or overwrite, with 0600 permissions (owner read/write only):

// 0x15-file_io/1-create_file.c

int create_file(const char *filename, char *text_content)
{
    int fd, w, len = 0;

    if (filename == NULL) return (-1);

    if (text_content != NULL)
        for (len = 0; text_content[len];)
            len++;

    fd = open(filename, O_CREAT | O_RDWR | O_TRUNC, 0600);
    w  = write(fd, text_content, len);

    if (fd == -1 || w == -1) return (-1);

    close(fd);
    return (1);
}

Search Algorithms and Complexity

The search algorithms module covers multiple strategies and requires writing the time complexity of each in Big O notation as a separate file.

Linear search — O(n), prints every comparison:

// 0x1E-search_algorithms/0-linear.c

int linear_search(int *array, size_t size, int value)
{
    int i;

    if (array == NULL) return (-1);

    for (i = 0; i < (int)size; i++)
    {
        printf("Value checked array[%u] = [%d]\n", i, array[i]);
        if (value == array[i])
            return (i);
    }
    return (-1);
}

Binary search — O(log n), implemented recursively. The search space is printed at every step so the halving is visible:

// 0x1E-search_algorithms/1-binary.c

int recursive_search(int *array, size_t size, int value)
{
    size_t half = size / 2;
    size_t i;

    if (array == NULL || size == 0) return (-1);

    printf("Searching in array");
    for (i = 0; i < size; i++)
        printf("%s %d", (i == 0) ? ":" : ",", array[i]);
    printf("\n");

    if (half && size % 2 == 0)
        half--;

    if (value == array[half])
        return ((int)half);

    if (value < array[half])
        return (recursive_search(array, half, value));

    half++;
    return (recursive_search(array + half, size - half, value) + half);
}

Jump search — O(√n). Jump forward in steps of √n, then linear search backward in the last block where the value might be:

// 0x1E-search_algorithms/100-jump.c

int jump_search(int *array, size_t size, int value)
{
    int index, m, k, prev;

    if (array == NULL || size == 0) return (-1);

    m = (int)sqrt((double)size);  // step size = √n
    k = 0; prev = index = 0;

    do {
        printf("Value checked array[%d] = [%d]\n", index, array[index]);
        if (array[index] == value) return (index);
        k++;
        prev  = index;
        index = k * m;
    } while (index < (int)size && array[index] < value);

    printf("Value found between indexes [%d] and [%d]\n", prev, index);

    for (; prev <= index && prev < (int)size; prev++)
    {
        printf("Value checked array[%d] = [%d]\n", prev, array[prev]);
        if (array[prev] == value) return (prev);
    }
    return (-1);
}

Complexity answers were filed separately — one per algorithm:

2-O  → O(n)         linear search
4-O  → O(log n)     binary search
101-O → O(√n)       jump search

Makefiles

The Makefiles evolve from a single rule to a full build system with variables, pattern rules, and phony targets:

# 0x1C-makefiles/4-Makefile — the final version

CC     = gcc
SRC    = main.c school.c
OBJ    = $(SRC:.c=.o)        # pattern substitution: .c → .o
NAME   = school
RM     = rm -f
CFLAGS = -Wall -Werror -Wextra -pedantic

all: $(OBJ)
	$(CC) $(OBJ) -o $(NAME)

clean:
	$(RM) *~ $(NAME)

oclean:
	$(RM) $(OBJ)

fclean: clean oclean

re: fclean all             # full rebuild from scratch

.PHONY: all clean oclean fclean re

-Wall -Werror -Wextra -pedantic is strict mode — warnings are errors, no implicit declarations, no non-standard extensions. Every file in this repository compiles clean under these flags.


What This Repository Demonstrates

Working through this curriculum in order meant that every abstraction was built before it was used. malloc was understood before linked lists. Function pointers were understood before callbacks. The compilation pipeline was understood before makefiles. The result is a solid foundation for reading and writing systems code without mystery.