1

Origin Story — Shedding the C++ Armor

Chapter 1: Every hero starts by losing something.

Welcome to the C Tutorial! You already know C++ — so instead of starting from zero, we’ll focus on what’s different and what’s missing.

Think of C++ as a suit of high-tech armor: classes, std::string, templates — layers of protection built over decades. C is what’s underneath: raw, exposed, powerful. Learning C means voluntarily removing the armor to understand what it was protecting you from. That’s not a downgrade — it’s an origin story. Every systems programming superhero (Linux kernel devs, embedded engineers, OS hackers) started right here.

C is not a “simpler C++.” It’s an older, smaller language that C++ grew out of. Many features you rely on in C++ simply don’t exist:

C++ Feature C Equivalent
cout << x printf("%d", x)
new / delete malloc() / free()
class struct (no methods, no access control)
string char[] arrays + string functions
References (&) Pointers only
bool #include <stdbool.h> or use int
Namespaces None — everything is global
Function overloading Not supported
Templates Not supported

Task: Compile and run your first C program

A file hello.c has been created. Look at it in the editor, then compile and run it:

cd c_project
gcc -Wall -std=c11 hello.c -o hello
./hello

Important: We use gcc, not g++. Using g++ would compile as C++ and mask the differences we’re here to learn.

Before you start editing code, study the program first. You’ll learn more by reading code before writing it. Read hello.c carefully and identify all the differences from C++ you can spot.

Notice:

Starter files
c_project/hello.c
#include <stdio.h>

int main(void) {
    printf("=== Welcome to the Danger Zone ===\n");
    printf("No classes. No RAII. No safety net.\n");
    printf("Just you, raw memory, and a compiler.\n");
    printf("Let's go.\n");
    return 0;
}
2

Power #1 — printf: Speak to the Machine

Power Unlocked: Formatted Output

Your first superpower: talking directly to the terminal. printf is C’s Swiss Army knife for output. It takes a format string containing ordinary text and conversion specifiers that start with %:

Specifier Type Example
%d int printf("%d", 42)42
%f double printf("%f", 3.14)3.140000
%c char printf("%c", 'A')A
%s char* (string) printf("%s", "hi")hi
%p pointer printf("%p", ptr)0x7fff...
%x hex int printf("%x", 255)ff
%% literal % printf("100%%")100%

Width and Precision

You can control formatting with width and precision modifiers:

Predict Before You Run (PRIMM)

Before compiling, predict what each line in format_lab.c will print. Write down your predictions on paper, then compile and check. This predict-then-verify cycle is called PRIMM (Predict, Run, Investigate, Modify, Make) — and it’s one of the most effective ways to learn a new language’s quirks.

gcc -Wall -std=c11 format_lab.c -o format_lab
./format_lab

How many did you get right?

Investigate and Modify

Now try these modifications to deepen your understanding:

  1. Investigate: Change %.2f to %.5f. How many decimal places appear now?
  2. Investigate: What does %+d do? Try printf("%+d", 42) and printf("%+d", -7).
  3. Modify: Add a new line that prints: Score in hex: 0x2a (Hint: use %x and the 0x prefix).
Starter files
c_project/format_lab.c
#include <stdio.h>

int main(void) {
    int xp = 42;
    double hp = 97.5;
    char rank = 'S';
    char player[] = "xX_SlayerKing_Xx";

    // Basic specifiers
    printf("Player: %s\n", player);
    printf("XP: %d\n", xp);
    printf("HP: %f\n", hp);
    printf("Rank: %c\n", rank);

    // Width and precision
    printf("HP (1 decimal):   %.1f\n", hp);
    printf("HP (no decimals): %.0f\n", hp);
    printf("XP (zero-padded): [%05d]\n", xp);
    printf("Player (right-20):[%20s]\n", player);
    printf("Player (left-20): [%-20s]\n", player);

    // Multiple values in one call
    int xp_needed = 100;
    printf("%s: %d/%d XP (%.1f%% to next level)\n",
           player, xp, xp_needed, (xp * 100.0) / xp_needed);

    return 0;
}
3

Power #2 — scanf: Listen (But Watch Your Back)

Power Unlocked: Reading Input (with great danger)

Every superpower has a dark side. scanf lets you hear the user — but it’s also how most C programs get hacked.

scanf reads formatted input from the user. It uses the same % specifiers as printf, but with a critical difference: scanf needs pointers because it must store the input somewhere.

int age;
scanf("%d", &age);   // & gives the ADDRESS of age

The & (address-of operator) is required for basic types. Without it, scanf would receive the value of age (garbage, since it’s uninitialized), interpret it as a memory address, and write to a random location — a classic undefined behavior bug.

The Buffer Overflow Danger

Reading strings with scanf is notoriously dangerous:

char name[10];
scanf("%s", name);   // DANGER: no length limit!

If the user types more than 9 characters, scanf writes past the end of the array — a buffer overflow. This is the exact vulnerability class that has caused thousands of real-world security exploits.

The safe alternative: Use fgets() to read a line with a length limit:

fgets(name, sizeof(name), stdin);  // reads at most 9 chars + '\0'

Why fflush(stdout) Matters

Notice the template code has fflush(stdout) after each printf prompt. Why? When your program writes to stdout, C doesn’t send the text to the screen immediately — it buffers it for efficiency. A newline \n usually flushes the buffer, but our prompts ("Enter server name: ") don’t end with \n. Without fflush(stdout), the prompt might never appear before scanf/fgets blocks waiting for input — the user sees a blank screen. fflush(stdout) forces the buffer to the screen immediately.

Task: Fix the vulnerable program

The file input_lab.c has a buffer overflow bug. This is a Bug Hunt — you’ll learn more from finding and fixing broken code than from writing it yourself. Let’s go.

  1. Replace the dangerous scanf("%s", ...) with fgets().
  2. Compile with gcc -Wall -std=c11 input_lab.c -o input_lab.
  3. Run ./input_lab and test it.

Hint: fgets includes the newline character \n in the buffer. The provided strip_newline helper removes it.

Starter files
c_project/input_lab.c
#include <stdio.h>
#include <string.h>

// Helper: remove trailing newline from fgets input
void strip_newline(char *str) {
    size_t len = strlen(str);
    if (len > 0 && str[len - 1] == '\n') {
        str[len - 1] = '\0';
    }
}

int main(void) {
    char server[20];
    int players;

    printf("Enter server name: ");
    fflush(stdout);
    // BUG: this scanf has no length limit — buffer overflow!
    scanf("%s", server);

    printf("Enter player count: ");
    fflush(stdout);
    scanf("%d", &players);

    printf("Server %s: %d players online.\n", server, players);
    return 0;
}
4

Power #3 — malloc/free: Control Over Memory Itself

Power Unlocked: Manual Memory Management

This is the big one. The power that separates C programmers from everyone else: you control memory directly. No garbage collector. No smart pointers. Just you and the heap. With great power comes great responsibility — and great bugs. When researchers tracked real CS2 students, they found that 74% created memory leaks, 70% dereferenced dead pointers, and 57% dereferenced null pointers — and most of these errors were completely silent. No crash, no warning, just corrupted memory that manifests as mysterious bugs way later. Spooky.

This step teaches you the discipline that prevents these errors.

In C++, you allocate heap memory with new and release it with delete. C uses lower-level functions from <stdlib.h>:

C++ C
int *p = new int; int *p = malloc(sizeof(int));
int *a = new int[10]; int *a = malloc(10 * sizeof(int));
delete p; free(p);
delete[] a; free(a);

Stack vs. Heap: Where Does Memory Live?

Before diving into malloc, you need to know where your variables live:

+---------------------+ High address
|       Stack         | <-- local variables (int x, char buf[20])
|  (grows downward)   |     automatic: created on function entry,
|         v           |     destroyed on function return
+---------------------+
|                     |
|    (free space)     |
|                     |
+---------------------+
|         ^           |
|   (grows upward)    |
|        Heap         | <-- malloc'd memory
|                     |     manual: you create it, you destroy it
+---------------------+
|   Global/Static     | <-- global variables, string literals
+---------------------+
|    Code (Text)      | <-- your compiled functions
+---------------------+ Low address

Key insight: Stack memory is free and automatic — but it dies when the function returns. Heap memory survives function calls — but you must free() it yourself. Returning a pointer to a local stack variable is a classic bug: the memory is gone by the time the caller uses the pointer.

Key Differences from C++

  1. malloc returns void* — in C, this implicitly converts to any pointer type (no cast needed). Don’t add a cast; it hides bugs.
  2. malloc does NOT initialize memory — the bytes are garbage. Use calloc() if you need zeroed memory.
  3. malloc can fail — it returns NULL if there’s no memory. Always check.
  4. No constructorsmalloc just gives you raw bytes. You must initialize fields yourself.

The Pointer Lifecycle: A Mental Model

Here’s a mental model that will save you hours of debugging. Every pointer variable is in one of four states:

                  malloc()
[Uninitialized] -----------> [Alive]
                                |
                                |
                     free()     |     = NULL
                        |       |       |
                        v       |       v
                     [Dead]           [Null]
State Meaning Safe Operations
Uninitialized Declared but not assigned None — using it is undefined behavior
Alive Points to valid, allocated memory Dereference (*p), member access (p->x), free
Null Explicitly set to NULL Compare (p == NULL), reassign
Dead Was freed — memory returned to OS Nothing! Accessing a dead pointer is use-after-free

The most dangerous transition is Alive → Dead (via free()), because the pointer variable still holds the old address — it just doesn’t point to valid memory anymore. The pointer looks fine, but the memory behind it is gone. This is why 70% of students hit use-after-free bugs. Pro tip: set pointers to NULL immediately after freeing them.

Task: Build a dynamic array

Complete the program in memory_lab.c:

  1. Allocate an array of count integers using malloc.
  2. Check if malloc returned NULL.
  3. Fill the array with squares: arr[i] = i * i.
  4. Print the array.
  5. Free the memory when done.
gcc -Wall -std=c11 memory_lab.c -o memory_lab
./memory_lab
Starter files
c_project/memory_lab.c
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int count = 5;

    // Sub-goal 1: Allocate heap memory
    // Use malloc(count * sizeof(int)) to request space for 'count' ints
    int *squares = NULL;  // Replace NULL with your malloc call

    // Sub-goal 2: Validate allocation
    // Check if malloc returned NULL (out of memory). If so, print error and exit.

    // Sub-goal 3: Initialize data
    // Fill array with squares: squares[i] = i * i

    // Print the array
    printf("Squares:");
    for (int i = 0; i < count; i++) {
        printf(" %d", squares[i]);
    }
    printf("\n");

    // Sub-goal 4: Release memory
    // Every malloc must have a matching free

    return 0;
}
5

Power #4 — Strings: Bare-Knuckle Text Wrangling

Power Unlocked: Raw String Manipulation

In C++, std::string does the heavy lifting — memory, length tracking, concatenation, all automatic. In C, you are the string class. Every byte, every null terminator, every bounds check — that’s on you. A “string” is just an array of char terminated by a null byte '\0':

char name[] = "Alice";
// Memory layout: ['A']['l']['i']['c']['e']['\0']
//                  [0]  [1]  [2]  [3]  [4]  [5]

The null terminator '\0' marks where the string ends. Every string function (strlen, printf %s, etc.) scans forward until it hits '\0'. If you forget the null terminator, functions will read past the end of your array — undefined behavior.

String Functions (from <string.h>)

Function Purpose Gotcha
strlen(s) Returns length (not counting '\0') O(n) — scans for '\0' every time
strcpy(dst, src) Copies src into dst No bounds checking! Use strncpy
strcat(dst, src) Appends src to dst No bounds checking!
strcmp(a, b) Compares: returns 0 if equal You CANNOT use == to compare strings
strncpy(dst, src, n) Copies at most n chars May NOT null-terminate if src >= n

“False Friends” from C++

Some C syntax looks like C++ but does something completely different. These traps will get you if you’re on autopilot:

The #1 Mistake: Using == to Compare Strings

if (name == "Alice")  // WRONG! Compares pointer addresses, not contents
if (strcmp(name, "Alice") == 0)  // CORRECT! Compares character-by-character

Task: Fix the string bugs

The file strings_lab.c has three bugs related to C strings. Find and fix all of them:

  1. A string comparison using == instead of strcmp
  2. An unsafe strcpy that should use strncpy
  3. A missing null terminator after strncpy
gcc -Wall -std=c11 strings_lab.c -o strings_lab
./strings_lab
Starter files
c_project/strings_lab.c
#include <stdio.h>
#include <string.h>

int main(void) {
    // Bug 1: comparing strings with ==
    char lang[] = "C";
    if (lang == "C") {
        printf("Language is C\n");
    } else {
        printf("Language is not C\n");
    }

    // Bug 2: strcpy with no size limit
    char dest[8];
    char src[] = "A very long string that overflows the buffer";
    strcpy(dest, src);
    printf("Copied: %s\n", dest);

    // Bug 3: strncpy may not null-terminate
    char abbrev[4];
    strncpy(abbrev, "Pittsburgh", sizeof(abbrev));
    printf("Abbreviation: %s\n", abbrev);

    return 0;
}
6

Power #5 — Structs: Build Your Own Data Types

Power Unlocked: Custom Data Structures

Time to level up from primitive types. With structs, you can bundle related data together and build the foundations of any system — game engines, operating systems, databases. C has no classes, but structs + functions give you everything you need.

In C++, class and struct are nearly identical (differing only in default access). In C, struct is all you have, and it’s much more limited:

Declaring and Using Structs

struct Point {
    double x;
    double y;
};

// Without typedef, you must write 'struct Point' everywhere:
struct Point p1;
p1.x = 3.0;
p1.y = 4.0;

typedef Saves Typing

typedef struct {
    double x;
    double y;
} Point;

// Now you can just write 'Point':
Point p1 = {3.0, 4.0};

The Arrow Operator (->)

When you have a pointer to a struct, use -> instead of .:

Point *pp = &p1;
pp->x = 5.0;       // same as (*pp).x = 5.0

Task: Build an RPG Character Sheet

Complete structs_lab.c to create a Character struct (think RPG character sheet) and functions that operate on it. This is how you do “OOP” in C — structs hold data, standalone functions provide behavior.

We’ve provided the main() function — your job is to build the struct and its functions. Filling in a working skeleton is a faster path to understanding than staring at a blank file.

  1. Define the Character struct using typedef (fields: name[50], level, hp).
  2. Implement character_init to populate a character.
  3. Implement character_print to display a character’s stats.
gcc -Wall -std=c11 structs_lab.c -o structs_lab
./structs_lab
Starter files
c_project/structs_lab.c
#include <stdio.h>
#include <string.h>

// TODO: Define a Character struct using typedef with fields:
//   - char name[50]
//   - int level
//   - double hp


// TODO: Implement character_init
// Takes a POINTER to Character, plus name, level, hp as parameters
// Copies name into c->name using strncpy (safely!)
// Sets c->level and c->hp


// TODO: Implement character_print
// Takes a POINTER to Character (use const for safety)
// Prints: "<name> [Lv.<level>] HP: <hp>"


int main(void) {
    Character hero;
    character_init(&hero, "LinkSlayer99", 42, 97.5);
    character_print(&hero);

    Character boss;
    character_init(&boss, "DarkLord_X", 99, 1000.0);
    character_print(&boss);

    return 0;
}
7

Power #6 — Unions: Shape-Shifting Memory

Power Unlocked: One Memory Location, Many Forms

This power is subtle but deadly useful. A union lets a single block of memory shape-shift between different types — like a superhero with multiple identities sharing the same body. It’s normal to wonder “when would I ever use this?” The answer: unions show up in parsers, network protocols, and any system that handles multiple data types through the same interface. If this step feels harder than previous ones, that’s expected — you’re building a more sophisticated mental model.

A union looks like a struct, but with a critical difference: all members share the same memory. The size of a union equals the size of its largest member.

union Value {
    int    i;    // 4 bytes
    double d;    // 8 bytes
    char   s[8]; // 8 bytes
};
// sizeof(union Value) == 8 (size of largest member)

At any moment, only one member is valid. Writing to val.d overwrites whatever was in val.i. Reading a member you didn’t last write to is undefined behavior.

Tagged Unions: The C Pattern for “Variant Types”

Since the union doesn’t know which member is active, you need to track it yourself. The standard pattern is a struct with a tag (enum) and a union:

typedef enum { TYPE_INT, TYPE_DOUBLE, TYPE_STRING } ValueType;

typedef struct {
    ValueType type;     // tag: which union member is valid
    union {
        int    i;
        double d;
        char   s[32];
    };                  // anonymous union (C11)
} TaggedValue;

This is C’s version of std::variant from C++17.

Task: Build a tagged value system

Complete unions_lab.c to implement a TaggedValue that can hold an int, double, or string. Implement the print_value function that uses a switch on the tag.

gcc -Wall -std=c11 unions_lab.c -o unions_lab
./unions_lab
Starter files
c_project/unions_lab.c
#include <stdio.h>
#include <string.h>

typedef enum { TYPE_INT, TYPE_DOUBLE, TYPE_STRING } ValueType;

typedef struct {
    ValueType type;
    union {
        int    i;
        double d;
        char   s[32];
    };
} TaggedValue;

// TODO: Implement print_value
// Use a switch on val->type to print the correct member:
//   TYPE_INT:    printf("int: %d\n", ...)
//   TYPE_DOUBLE: printf("double: %.2f\n", ...)
//   TYPE_STRING: printf("string: %s\n", ...)
void print_value(const TaggedValue *val) {

}

int main(void) {
    TaggedValue v1 = { .type = TYPE_INT, .i = 42 };
    TaggedValue v2 = { .type = TYPE_DOUBLE, .d = 3.14 };
    TaggedValue v3 = { .type = TYPE_STRING };
    strncpy(v3.s, "hello", sizeof(v3.s) - 1);
    v3.s[sizeof(v3.s) - 1] = '\0';

    print_value(&v1);
    print_value(&v2);
    print_value(&v3);

    return 0;
}
8

Power #7 — Function Pointers: Code That Rewires Itself

Power Unlocked: Functions as Values

This is arguably C’s most mind-bending power: functions are just addresses in memory, and you can store, pass, and swap them at runtime. This is how C programs achieve polymorphism without classes — and it’s the secret behind qsort, callback systems, and plugin architectures.

In C, a function name (without parentheses) evaluates to the function’s memory address. You can store this address in a function pointer and call the function through it.

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }

// Declare a function pointer
int (*operation)(int, int);

operation = add;          // point to 'add'
int result = operation(3, 4);  // calls add(3, 4) → 7

operation = sub;          // repoint to 'sub'
result = operation(3, 4);      // calls sub(3, 4) → -1

Reading the Syntax (Pair Up!)

Function pointer syntax is notoriously confusing — even experienced C programmers have to pause and think about it. If you’re working alongside a classmate, this is an excellent moment for pair programming. Two brains parsing int (*fp)(const void*, const void*) is genuinely better than one.

The syntax int (*operation)(int, int) reads as:

Warning: Without the inner parentheses, int *operation(int, int) means “a function returning int*” — completely different!

qsort: The Classic Callback Example

The C standard library’s qsort sorts any array using a comparison function you provide:

void qsort(void *base, size_t nmemb, size_t size,
            int (*compar)(const void*, const void*));

The comparison function receives void* pointers (generic pointers — C’s limited version of templates). You must cast them to the correct type inside.

Worked Example: A Complete Comparator

Before you write your own, study this fully worked comparator for sorting doubles:

// Sub-goal: Cast void* to the actual type
int compare_doubles(const void *a, const void *b) {
    double da = *(const double *)a;   // cast void* → double*, then dereference
    double db = *(const double *)b;

    // Sub-goal: Return comparison result
    if (da < db) return -1;
    if (da > db) return 1;
    return 0;
}

Notice the pattern: (1) cast void* to the real type, (2) dereference to get the value, (3) compare. Your task below follows the same pattern but for int.

Task: Sort an array with qsort

Complete funcptr_lab.c:

  1. Implement compare_ascending for qsort (return negative if *a < *b, zero if equal, positive if *a > *b).
  2. Implement compare_descending (reverse order).
  3. Use qsort with each comparator.
gcc -Wall -std=c11 funcptr_lab.c -o funcptr_lab
./funcptr_lab
Starter files
c_project/funcptr_lab.c
#include <stdio.h>
#include <stdlib.h>

void print_array(const int *arr, int n) {
    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

// TODO: Implement compare_ascending for qsort
// Parameters are const void* pointers — cast to const int*
// Return: negative if *a < *b, zero if equal, positive if *a > *b
int compare_ascending(const void *a, const void *b) {
    return 0; // Replace this
}

// TODO: Implement compare_descending (reverse of ascending)
int compare_descending(const void *a, const void *b) {
    return 0; // Replace this
}

int main(void) {
    int data[] = {42, 17, 93, 8, 56, 31, 74};
    int n = sizeof(data) / sizeof(data[0]);

    printf("Original: ");
    print_array(data, n);

    qsort(data, n, sizeof(int), compare_ascending);
    printf("Ascending: ");
    print_array(data, n);

    qsort(data, n, sizeof(int), compare_descending);
    printf("Descending: ");
    print_array(data, n);

    return 0;
}
9

Trial by Fire — Arrays, Pointers, and the Decay Trap

Every Hero Has a Weakness. This Is Yours.

Array decay and pass-by-value are the kryptonite of C programmers. More bugs come from misunderstanding these two concepts than from almost anything else in the language. This step is a trial — survive it, and you’ll have the mental model that separates beginners from real systems programmers.

Scaffolding pause: You’ve been writing code from scratch in the last few steps. Now we’re deliberately giving you back some scaffolding — pre-written buggy code to debug — because this concept is a notorious trap even for experienced programmers. Finding bugs is the right exercise type here: it forces you to reason about why code breaks, which is exactly the skill you need for array/pointer issues.

In C++, arrays and pointers are related but distinct. In C, they are so intertwined that students routinely confuse them — this is the most treacherous “false friend” between C and C++.

The Decay Rule: When you pass an array to a function, it silently decays into a pointer to its first element. The function receives just a pointer — all size information is lost.

void print_size(int arr[]) {
    // SURPRISE: sizeof(arr) is 8 (pointer size), NOT the array size!
    printf("sizeof = %zu\n", sizeof(arr));  // prints 8
}

int main(void) {
    int data[100];
    printf("sizeof = %zu\n", sizeof(data));  // prints 400
    print_size(data);                         // prints 8!
}

This is the #1 source of bugs in C array code. The function signature int arr[] is identical to int *arr — it’s just syntactic sugar.

Quick Refresh: The Pointer Lifecycle (from Step 4)

Remember the four pointer states? You’ll need them for Bug 3:

Bug 3 involves a pointer that should transition from Null to Alive — but doesn’t, because of how C passes arguments.

C Is Strictly Pass-by-Value

C++ has references (int &x). C does not. Everything in C is passed by value — including pointers. When you pass a pointer, the function gets a copy of the pointer (the address), not a reference to the original pointer variable.

This means:

To modify a pointer from inside a function, you need a pointer to a pointer (int **pp).

Task: Find and fix the array/pointer bugs

The file arrays_lab.c has three bugs, ordered by difficulty:

Start with Bugs 1-2. Once those compile and run, tackle Bug 3 — it’s conceptually different (pass-by-value for pointers).

gcc -Wall -std=c11 arrays_lab.c -o arrays_lab
./arrays_lab
Starter files
c_project/arrays_lab.c
#include <stdio.h>
#include <stdlib.h>

// Bug 1: This function tries to compute array length
// but sizeof(arr) gives POINTER size, not array size!
int array_length(int arr[]) {
    return sizeof(arr) / sizeof(arr[0]);
}

// Bug 2: This function tries to zero-fill an array
// but uses the wrong size
void zero_fill(int arr[]) {
    int len = sizeof(arr) / sizeof(arr[0]);  // BUG: decay!
    for (int i = 0; i < len; i++) {
        arr[i] = 0;
    }
}

// Bug 3: This function tries to allocate memory for the caller
// but the caller's pointer never changes (pass-by-value!)
void allocate(int *ptr, int n) {
    ptr = malloc(n * sizeof(int));  // BUG: modifies local copy only
    if (ptr != NULL) {
        for (int i = 0; i < n; i++) {
            ptr[i] = i * 10;
        }
    }
}

int main(void) {
    // Test Bug 1 & 2
    int data[5] = {1, 2, 3, 4, 5};
    printf("Array length: %d (expected 5)\n", array_length(data));

    zero_fill(data);
    printf("After zero_fill: %d %d %d %d %d (expected all 0s)\n",
           data[0], data[1], data[2], data[3], data[4]);

    // Test Bug 3
    int *heap_data = NULL;
    allocate(heap_data, 5);
    if (heap_data == NULL) {
        printf("heap_data is still NULL! allocate() didn't work.\n");
    }

    // After fixing: uncomment these lines
    // printf("heap_data[0] = %d (expected 0)\n", heap_data[0]);
    // free(heap_data);

    return 0;
}
10

Power #8 — File I/O: Read and Write the World

Power Unlocked: Persistent Storage

Up until now, everything you’ve built vanishes when the program exits. This power changes that — you can read from and write to files on disk, making your programs interact with the real world. Config files, save games, log files, databases — it all starts here.

Files in C: Open, Use, Close

File I/O in C follows a simple pattern that mirrors how you use files in real life:

  1. Open the file with fopen() → get a FILE* handle
  2. Read or write using the handle
  3. Close the file with fclose()
FILE *fp = fopen("data.txt", "r");  // "r" = read mode
if (fp == NULL) {
    perror("fopen failed");          // prints reason (e.g., file not found)
    return 1;
}
// ... use fp ...
fclose(fp);

File Modes

Mode Meaning If file doesn’t exist
"r" Read only Returns NULL (error)
"w" Write (truncates existing content!) Creates new file
"a" Append (adds to end) Creates new file
"r+" Read and write Returns NULL (error)

Warning: "w" destroys existing file contents. Use "a" to append.

Predict: What happens here?

Before reading further, predict what this code does:

FILE *fp = fopen("important_data.txt", "w");
fclose(fp);

Does important_data.txt still have its original contents? (Answer: No — "w" truncated it to zero bytes. This two-line program just erased the file’s contents.)

Reading and Writing Functions

Function Purpose Like printf/scanf but to files
fprintf(fp, fmt, ...) Write formatted text to file printf → stdout; fprintf → file
fscanf(fp, fmt, ...) Read formatted input from file scanf → stdin; fscanf → file
fgets(buf, n, fp) Read a line (safe, with limit) Same as stdin version, but from file
feof(fp) Check if end-of-file reached Returns non-zero at EOF

Notice the pattern: printf, scanf, and fgets all have file-based counterparts — just add f and pass the FILE* as the first (or last) argument.

The Resource Management Pattern

C has no RAII (like C++ destructors) and no with statement (like Python). You must manually close every file you open. Forgetting fclose() can cause:

Task: Save and load a playlist

Complete fileio_lab.c to:

  1. Write a playlist of songs to a file using fprintf.
  2. Read the file back line by line using fgets.
  3. Count the total number of tracks and print the result.
  4. Properly close all files.
gcc -Wall -std=c11 fileio_lab.c -o fileio_lab
./fileio_lab
Starter files
c_project/fileio_lab.c
#include <stdio.h>
#include <string.h>

int main(void) {
    // === PART 1: Save the playlist ===
    // TODO: Open "playlist.txt" for writing ("w" mode)
    // TODO: Check if fopen returned NULL (use perror for error message)

    const char *songs[] = {"Bohemian Rhapsody", "Blinding Lights", "Levitating",
                           "Anti-Hero", "Bad Guy", "Cruel Summer"};
    int num_songs = sizeof(songs) / sizeof(songs[0]);

    // TODO: Write each song on its own line using fprintf

    // TODO: Close the file
    printf("Saved %d tracks to playlist.txt\n", num_songs);

    // === PART 2: Load the playlist back ===
    // TODO: Open "playlist.txt" for reading ("r" mode)
    // TODO: Check if fopen returned NULL

    char line[100];
    int track_count = 0;

    // TODO: Read lines with fgets until it returns NULL (EOF)
    // TODO: Increment track_count for each line

    // TODO: Close the file

    printf("Loaded %d tracks from playlist.txt\n", track_count);
    return 0;
}
11

Final Boss — A Linked List in C

The Final Boss Fight

Every origin story ends with a boss battle. This is yours.

You’ll combine every power you’ve unlocked — structs, pointers, malloc, free, printf, and scanf — to build a singly linked list from scratch. No scaffolding. No hints. No TODO comments telling you what to write. Just you and the compiler.

This is supposed to be hard. If you get stuck, that doesn’t mean you’re not cut out for C — it means you’re fighting the boss, not the tutorial. Go back and re-read the specific step that covers the concept you’re struggling with. Every power you need is already in your toolkit. The challenge is wielding them all at once.

Why linked lists are the ultimate pointer test: When researchers tracked real student code, three categories of pointer errors accounted for nearly all bugs:

Error Category % of Students Who Make It
Memory leak (pointer leaves scope without free) 74%
Dereferencing a dead pointer (use-after-free) 70%
Dereferencing a null pointer 57%

Building a linked list exercises all three. Pay special attention to freeing nodes and checking for NULL.

Requirements

Your program should:

  1. Read an integer n from stdin (how many values to insert).
  2. Read n integers and insert each into a linked list.
  3. Print the list (space-separated values, then a newline).
  4. Free all memory — every node must be deallocated.

The Node Struct

typedef struct Node {
    int value;
    struct Node *next;
} Node;

Note: For recursive (self-referencing) structs, you must name the struct (struct Node) and use struct Node *next inside — because Node (the typedef) isn’t defined yet at that point.

Example Run

Enter count: 4
Enter value: 10
Enter value: 20
Enter value: 30
Enter value: 40
List: 10 20 30 40

Hints

gcc -Wall -std=c11 linked_list.c -o linked_list
echo "4 10 20 30 40" | ./linked_list
Starter files
c_project/linked_list.c
#include <stdio.h>
#include <stdlib.h>

typedef struct Node {
    int value;
    struct Node *next;
} Node;

Node *node_create(int value) {
    return NULL;
}

void list_print(const Node *head) {
}

void list_free(Node *head) {
}

int main(void) {
    int n;
    printf("Enter count: ");
    scanf("%d", &n);

    Node *head = NULL;
    Node *tail = NULL;

    for (int i = 0; i < n; i++) {
        int val;
        printf("Enter value: ");
        scanf("%d", &val);

        Node *new_node = node_create(val);
        if (new_node == NULL) {
            fprintf(stderr, "malloc failed\n");
            list_free(head);
            return 1;
        }

        if (head == NULL) {
            head = new_node;
            tail = new_node;
        } else {
            tail->next = new_node;
            tail = new_node;
        }
    }

    printf("List: ");
    list_print(head);
    list_free(head);

    return 0;
}