[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.
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);
}
_strstr — Two-Pointer Substring Search
// 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.