C Intermediate
A Complete 7-Chapter Programming Course
Table of Contents
- Structs & Unions
- Dynamic Memory
- Memory Bugs
- The Preprocessor
- Multi-File Projects & Makefiles
- File I/O
- Function Pointers
Structs & Unions
Course 1 covered scalars, arrays, and the pointers connecting them. Course 2 opens with C's way of grouping several values into one โ and, in the same chapter, its way of letting several values share one.
Declaring & Using Structs
typedef
Referring to struct Point everywhere gets verbose fast. typedef gives the type a shorter name โ an idiom so common in real C that it's effectively the default style.
Rust never needed this ceremony โ a struct's own name is always the type, directly, with no separate aliasing step required.
Structs Are Pure Data
A C struct has no methods attached to it โ no impl block equivalent from Rust. Any function operating on a struct is written separately, typically taking a pointer to it as a parameter. Course 2's own Function Pointers chapter (Ch.7) later shows one way C approximates method-like dispatch without a language feature for it at all.
Unions
A union looks like a struct syntactically, but its members share the same memory โ only one is genuinely "active" at a time. A union's sizeof equals its largest member's size, not the sum of all members, unlike a struct.
The Danger of Type Punning via Unions
Reading a union member that wasn't the one last written is, in most cases, undefined behavior โ the union itself carries no record of which member is currently valid. Compare this directly to Rust's own data-carrying enums (rust1-6): a Rust enum stores a hidden discriminant tracking which variant is actually active, so accessing the wrong one is caught as a compile-time type error through match, not silently allowed at runtime. A C union tracks nothing at all โ the programmer is expected to remember.
| Concept | Rust enum | C union |
|---|---|---|
| Tracks which variant/member is active | yes โ a hidden discriminant, checked by match | no โ nothing tracks it at all |
| Reading the "wrong" one | not possible โ match forces handling every case | undefined behavior in most cases |
| Size | largest variant + discriminant tag | exactly the largest member โ no tag |
typedef in one statement is extremely common in real C code โ expect to see it constantly when reading existing codebases.
Coding Challenges
Define a Point struct with x and y int fields using typedef struct, create an instance with values 5 and 10, and print both fields.
๐ View solutionDefine a union with an int member and a float member. Write 100 into the int member, then print both the int member and the float member. Explain why they don't show a related value.
๐ View solutionExplain how Rust's enum discriminant makes reading the "wrong" variant impossible at compile time, while a C union has no equivalent safeguard at all โ and why this makes the union version genuinely undefined behavior rather than just an inconvenience.
๐ View solutionChapter 1 Quick Reference
structgroups multiple values; access members with.typedef struct { ... } Name;โ the idiomatic default, avoiding repeatedstructkeywords- C structs carry no methods โ functions operating on them are always separate, taking a pointer
unionmembers share memory โsizeofis the largest member's size, not the sum- Reading the wrong union member is undefined behavior โ no discriminant tracks which one is active, unlike Rust's enums
- Next chapter: dynamic memory โ malloc/calloc/realloc/free, and C's own version of Rust's ownership model
Dynamic Memory
c1-7 named dangling pointers as the exact bug class Rust's borrow checker exists to prevent. This chapter shows where those dangling pointers actually come from โ and hands you the same responsibility Rust's ownership system otherwise carries for you.
Stack vs. Heap
The stack is automatic: a local variable's memory exists for exactly as long as its function is running, freed the instant that function returns โ this is precisely what made returning &local_var in c1-7 a dangling pointer. The heap is manual: memory allocated there exists until something explicitly releases it, with no connection to any particular function's lifetime, and effectively unlimited size.
malloc
Allocates raw, uninitialized memory of a given byte size, returning void * โ cast or assigned to a typed pointer. On failure, it returns NULL, which must always be checked; Rust, by contrast, typically aborts automatically on allocation failure rather than returning a value the caller might forget to verify.
calloc
Like malloc, but zero-initializes the memory, and takes element count and element size as two separate arguments. The zero-init distinction genuinely matters โ malloc's memory is garbage until written, and reading it before initializing is its own class of bug.
realloc
Resizes a previous allocation โ and may move it to an entirely new address. The returned pointer can genuinely differ from the one passed in, and the old pointer becomes invalid the instant realloc succeeds.
free
Releases heap memory back to the system. Immediately afterward, the pointer that referenced it becomes a genuine dangling pointer โ this is the exact mechanism c1-7 warned about, closing the loop directly: freeing is how dangling pointers are most commonly created in real code.
Manual Memory Management vs. Rust's Ownership
This is the chapter's real subject. In C, you decide when to call free, and nothing enforces it. Forget it โ a leak. Call it twice on the same pointer โ a double-free, undefined behavior. Use the pointer after freeing โ use-after-free, also undefined behavior. Rust's ownership model (rust1-3) tracks, at compile time, exactly when a value's owner goes out of scope, and automatically inserts the equivalent of free at precisely that point โ no leaks in safe code, no double-frees, no use-after-free, guaranteed by the compiler rather than the programmer's own discipline. Every rule this chapter just described by hand is what Rust's ownership system enforces silently, every time, without exception.
| Concept | Rust | C |
|---|---|---|
| When memory is freed | automatically, when the owner goes out of scope | only when you explicitly call free() |
| Forgetting to free | not possible in safe code | a memory leak |
| Freeing twice | not possible โ ownership moves, can't double-drop | a double-free โ undefined behavior |
| Using memory after it's freed | compile error โ the borrow checker rejects it | a use-after-free โ undefined behavior |
free immediately after an allocation, before writing the code that uses the memory in between โ then move the free to its real, correct location. It's much harder to forget a free you already wrote once than one you were planning to add "later."
ptr = realloc(ptr, new_size); is a real, common bug: if realloc fails and returns NULL, that NULL overwrites ptr โ permanently losing the only reference to the still-valid original allocation, which now leaks with no way to free it. Always assign to a temporary variable first.
Coding Challenges
Allocate an array of 5 ints with malloc, fill it with values 1 through 5, print them, then free the memory.
๐ View solutionAllocate an array of 3 ints with calloc, print all three values before writing anything to them, then explain what they show and why, contrasted with what malloc's uninitialized memory would show instead.
๐ View solutionExplain, precisely, why Rust's ownership model makes a double-free structurally impossible in safe code, rather than merely unlikely or discouraged.
๐ View solutionChapter 2 Quick Reference
- Stack โ automatic, freed on function return; heap โ manual, freed only by explicit action
mallocโ uninitialized memory, must check forNULLcallocโ zero-initialized memory, count and size given separatelyreallocโ may move the allocation; always assign to a temp variable first, never the original pointerfreeโ releases memory; the pointer becomes dangling immediately afterward- Leaks, double-frees, use-after-free โ all manual failure modes in C; all structurally prevented by Rust's ownership model at compile time
- Next chapter: Memory Bugs โ the tooling (valgrind) that catches exactly these mistakes
Memory Bugs
Every bug this chapter names has already appeared somewhere in this course โ c1-6's buffer overflow, c1-7's dangling pointer, c2-2's leaks and double-frees. This chapter gathers them into one catalog, and introduces the tools real C developers actually use to catch them.
The Bug Catalog, Named Precisely
- Use-after-free โ dereferencing a pointer after its memory was freed (
c1-7,c2-2) - Double-free โ calling
freetwice on the same allocation (c2-2) - Memory leak โ allocated memory that's never freed, with no remaining pointer to it at all (
c2-2) - Buffer overflow โ reading or writing past an array or allocation's actual bounds, stack or heap (
c1-6,c1-8) - Uninitialized read โ reading memory (from
malloc, or a declared-but-unset variable) before anything was written to it
A Genuine Leak Example
Each call leaks a small, fixed amount โ individually harmless-looking, but accumulating without bound. Rust makes this specific pattern impossible: a value's Drop runs automatically the moment its owner goes out of scope, with no equivalent "forgot to call it" failure mode in safe code.
valgrind
A dynamic analysis tool โ run an already-compiled program under it, no recompilation needed. It reports leaks, invalid reads/writes, and use-after-free, each with a stack trace showing exactly where the bad allocation, access, or free occurred.
AddressSanitizer (ASan)
A compile-time instrumentation approach โ add -fsanitize=address and recompile. It catches much of the same bug class, but faster than valgrind, and crashes immediately with a detailed report at the exact moment the bad access happens, rather than only summarizing at program exit.
| Tool | Requires recompiling | Speed |
|---|---|---|
| valgrind | no | slower โ full emulation |
| AddressSanitizer | yes โ a compiler flag | much faster, precise timing |
Why This Chapter Exists
This is the honest cost of the tradeoff Chapter 1 named: C's speed and control come with these exact bug classes as a genuine, permanent possibility โ not a temporary tooling gap that will eventually be fixed. Rust's compiler catches most of this entire category at compile time, before the program ever runs. C requires catching it at runtime, with tools like these, ideally before shipping โ a real and lasting asymmetry between the two languages, not a difference in maturity or polish.
| Bug class | Rust | C |
|---|---|---|
| Use-after-free | compile-time โ borrow checker | runtime โ valgrind/ASan, if you run them |
| Double-free | compile-time โ single ownership | runtime โ valgrind/ASan, if you run them |
| Memory leak | not possible in safe, non-cyclic code | runtime โ valgrind's leak checker, if you run it |
| Buffer overflow | runtime panic โ always checked | runtime โ ASan, if you run it; otherwise silent UB |
valgrind or an ASan-instrumented build regularly during development โ not only when chasing a specific crash โ catches problems long before they become a mystery in production.
Coding Challenges
Write a small program with a deliberate memory leak (malloc without a matching free), compile it, and run it under valgrind --leak-check=full. Report what the leak summary shows.
๐ View solutionWrite a small program with a deliberate use-after-free (free a pointer, then dereference it), compile it with -fsanitize=address, and run it. Report what AddressSanitizer's error output shows.
๐ View solutionExplain why "the program didn't crash" is not proof a C program is free of memory bugs, and why the same reasoning doesn't apply to a Rust program that compiles successfully.
๐ View solutionChapter 3 Quick Reference
- Bug catalog: use-after-free, double-free, memory leak, buffer overflow, uninitialized read
valgrind --leak-check=fullโ no recompile needed, slower, reports leaks/invalid access with stack traces-fsanitize=addressโ requires recompiling, much faster, crashes precisely at the moment of the bad access- Rust catches most of this category at compile time; C requires catching it at runtime, with tools
- Undefined behavior is not guaranteed to crash โ a program can "work" for a long time despite a real bug
- Next chapter: the preprocessor โ #define, macros, and conditional compilation
The Preprocessor
#include <stdio.h> has appeared in nearly every example since Chapter 1. This chapter finally explains what it โ and every other line starting with # โ actually does.
The Preprocessor Runs Before Compilation
The preprocessor is a genuinely separate pass: pure text substitution over the source file, completed entirely before parsing or type-checking ever begins. Rust has no preprocessor at all โ its macro system (rust3-4) operates on the actual parsed syntax tree, with real awareness of types and structure, not raw text. This is a fundamental difference in kind, not just syntax.
#define โ Object-like Macros
Every occurrence of MAX_SIZE in the file is replaced, textually, with 100 โ before the compiler ever sees it as a number. No type checking happens at this stage at all.
#define โ Function-like Macros
Looks like a function; is pure text substitution. Without full parenthesization, this breaks badly:
Even with correct parentheses, macros carry a trap no real function has: arguments can be evaluated more than once.
#include
Now the honest explanation: #include pastes the entire contents of the named file, textually, in place of the #include line itself โ nothing more sophisticated than that. It's not an "import" in Rust's sense at all; there's no module system underneath, just literal text insertion before compilation.
Header Guards
Including the same header twice โ common once a project has several source files each including several headers โ pastes its contents twice, causing duplicate definitions and a real compile error. The classic fix:
The modern, widely-supported alternative is a single line:
Rust's module system has no equivalent problem at all โ use never textually pastes anything, so there's nothing to accidentally duplicate.
Conditional Compilation
#ifdef/#ifndef/#if/#else/#endif compile different code depending on which macros are defined โ commonly used for platform-specific code, or separating debug and release builds.
| Concept | Rust | C |
|---|---|---|
| Preprocessor | none โ macros operate on the syntax tree | a genuine separate text-substitution pass |
| Module inclusion | use โ a real module reference, no text pasting | #include โ literal text pasting |
| Double-inclusion risk | not possible โ no text pasting involved | real โ requires header guards or #pragma once |
#ifndef/#define/#endif pattern, and supported by every major compiler โ though technically a compiler extension, not part of the C standard itself.
i++) can be silently evaluated multiple times, a class of bug that simply cannot occur with a real function call.
Coding Challenges
Define a macro SQUARE(x) without any parentheses around x or the whole expression. Call it as SQUARE(2 + 3) and show what it actually expands to and evaluates as, compared to the mathematically correct answer (25).
๐ View solutionWrite a header file with a struct definition, protected by a header guard using #ifndef/#define/#endif. Explain what compile error would occur if the guard were removed and the header were included twice from the same source file.
๐ View solutionExplain why SQUARE(i++), even with SQUARE fully and correctly parenthesized as ((x) * (x)), is still a bug โ and why the equivalent call to a real function square(i++) would never have this problem.
๐ View solutionChapter 4 Quick Reference
- The preprocessor is a text-substitution pass, complete before compilation begins โ unlike Rust, which has none
#define NAME valueโ object-like macro, pure text substitution- Function-like macros need full parenthesization โ and can still evaluate arguments multiple times
#includeliterally pastes a file's contents โ not a real module import- Header guards (
#ifndef/#define/#endifor#pragma once) prevent duplicate-inclusion errors #ifdef/#if/#else/#endifโ conditional compilation for platform/debug-vs-release code- Next chapter: Multi-File Projects & Makefiles โ separate compilation and linking, for real
Multi-File Projects & Makefiles
c1-1 named the gap directly โ no official build tool. c1-5 introduced header files in passing. c2-4 explained exactly what #include does. This chapter puts all three together, for real, on a genuine multi-file project.
Why Split a Project Across Files
Organization at real scale, and something more practical: faster rebuilds. Recompiling one changed file is far cheaper than recompiling an entire project from scratch every time โ but only if the build process actually takes advantage of that.
Header Files as Contracts
A header declares what a source file provides, without exposing how. Per c2-4's literal explanation, those declarations get pasted, verbatim, into every file that includes the header โ giving each one enough information to call the functions without ever seeing their implementation.
Separate Compilation
This is c1-1's compile-then-link model, made concrete across multiple files. Each .c file compiles independently into its own object file (.o); only afterward are all the object files linked into one executable.
A Worked Example
The Linking Step, In Detail
main.o contains a call to add() with no body โ an unresolved external symbol. math_utils.o contains add()'s actual definition. The linker's job is matching them up. Forget to link math_utils.o in at all, and the result is one of C's most recognizable real-world errors:
A Real Makefile
make is the unofficial-but-standard answer to c1-1's own open question โ targets, dependencies, and rules, rebuilding only what actually changed.
Run make, and it rebuilds only the files whose dependencies (listed after the colon) are newer than their target โ editing only math_utils.c recompiles just that file and re-links, leaving main.o untouched.
| Concept | Rust / cargo | C / make |
|---|---|---|
| Build tool | official, built in | unofficial โ make/CMake fill the gap |
| Incremental rebuilds | automatic | explicit โ dependency rules must be written correctly |
| Unresolved symbol | compile error โ caught before linking exists as a concept | a distinct linker error โ "undefined reference" |
make will report success while quietly building against outdated object files. Always list every header a .c file includes as part of its rule's dependencies.
Coding Challenges
Create the three files from this chapter's worked example (math_utils.h, math_utils.c, main.c), compile and link them manually with separate gcc -c and gcc linking commands, and run the result.
๐ View solutionDeliberately compile only main.c into an executable, omitting math_utils.o from the final link command. Report the exact error and explain what it means.
๐ View solutionWrite a Makefile for the three-file project from Challenge 1, following the chapter's own pattern. Explain what happens, in terms of which files actually get recompiled, if you run make twice in a row with no changes in between.
๐ View solutionChapter 5 Quick Reference
- A header declares what a source file provides โ a contract, not an implementation
gcc -c file.c -o file.oโ compiles independently into an object file- Linking matches unresolved calls (in one
.o) against real definitions (in another) - "undefined reference" โ a linker error, distinct from a compile error; the definition was never linked in
makeโ the unofficial standard build tool, rebuilding only what its dependency rules say changed- Every header a source file includes must be listed as a dependency, or changes to it won't trigger a rebuild
- Next chapter: File I/O โ fopen/fread/fwrite/fclose, text vs. binary mode
File I/O
Every program so far has lived entirely in memory. This chapter opens the door to the filesystem โ and introduces a resource-management discipline that should already feel familiar from c2-2's malloc/free pairing.
Opening a File
File Modes
The mode string controls both direction and format: "r" read, "w" write (truncating), "a" append โ each with a b variant ("rb", "wb", "ab") for binary.
Text Mode vs. Binary Mode
Text mode may translate line endings depending on the platform โ on Windows, \n can be translated to/from \r\n on read/write. Binary mode transfers bytes exactly as-is, with no translation at all. This is genuinely platform-dependent behavior, worth knowing explicitly rather than discovering by accident. Rust's std::fs has no equivalent mode distinction at the language level โ it's byte-oriented by default, with no automatic translation to opt out of.
Reading & Writing Text
fgets takes a maximum buffer size, exactly the bounds-checking discipline c1-8 covered. Its predecessor, gets(), took no size argument at all โ it was so fundamentally unfixable that C11 removed it from the standard entirely.
Reading & Writing Binary Data
fread/fwrite operate on raw bytes: a pointer, the size of each element, and how many elements. Both return the actual number of items transferred โ which can genuinely be less than requested, and isn't automatically an error condition to ignore.
Closing a File
Flushes any buffered writes and releases the underlying OS file handle. Forgetting it is the same class of mistake as c2-2's forgotten free โ a resource acquired manually that must be released manually, just a file descriptor instead of heap memory.
| Concept | Rust | C |
|---|---|---|
| Text/binary mode distinction | none โ byte-oriented by default | explicit โ "r" vs "rb", genuinely platform-dependent |
| Closing a file | automatic โ Drop closes it when the handle goes out of scope | manual โ fclose must be called explicitly |
| Unbounded line reading | not available โ read_line always takes a growable buffer | gets() existed, then was removed entirely in C11 |
fopen returns NULL โ the same discipline c2-2 established for malloc. A missing file, wrong permissions, or a full disk are all real, common causes; never assume the file opened successfully.
gets() read a line with no way to specify a maximum length at all โ genuinely, unfixably unsafe, no matter how carefully called. C11 didn't just discourage it; it removed it from the standard outright. Use fgets, always.
Coding Challenges
Write a program that opens a file for writing, writes three lines of text to it with fprintf, closes it, then reopens the same file for reading and prints each line back out with fgets.
๐ View solutionWrite a program that writes an array of 5 ints to a file in binary mode with fwrite, then reads them back into a different array with fread, printing both arrays to confirm they match.
๐ View solutionExplain specifically why gets() could never be made safe with better documentation or careful usage alone, and why fgets's extra parameter is what actually fixes the underlying problem.
๐ View solutionChapter 6 Quick Reference
fopen(name, mode)โ returnsNULLon failure, must always be checked"r"/"w"/"a"plus abvariant for binary โ text mode may translate line endings, platform-dependentlyfgetsโ bounded, safe;gets()โ unbounded, removed entirely from C11fread/fwriteโ return the actual items transferred, which can be less than requestedfcloseโ manual, exactly likefree; forgetting it leaks a file descriptor- Next chapter: Function Pointers โ syntax, callbacks, and a vtable-style dispatch pattern
Function Pointers
c2-1 named this chapter directly: "one way C approximates method-like dispatch without a language feature for it at all." Course 2 closes by actually building that mechanism โ the same one every object-oriented language's runtime dispatch is quietly built from underneath.
Function Pointer Syntax
A pointer to a function taking two ints and returning int. The parentheses around *operation are mandatory โ without them, this parses as something else entirely, a genuine syntax trap worth memorizing rather than re-deriving each time.
Assigning & Calling Through a Function Pointer
A function name decaying to a pointer to itself is genuinely the same phenomenon as array-to-pointer decay from c1-6, just applied to functions instead of arrays.
Callbacks
Passing a function pointer so another function can call it later โ a real, common C idiom.
A Simple vtable-Style Dispatch Pattern
The real payoff: store a function pointer alongside data, and let different instances point at different implementations.
A second shape type populates its own struct's function pointer with a different implementation entirely โ calling shape->area(shape) dispatches to the correct one, purely because the pointer was set to point there.
What C++ and Rust Formalize
This is not just an analogy โ it's the literal mechanism. A C++ class with virtual methods, and Rust's own dyn Trait, both work by storing a table of function pointers alongside the data, dispatched through one level of indirection โ exactly what was just built by hand above. The difference is who manages the table: here, the programmer wires it up manually and must get it right; in C++ and Rust, the compiler generates the table automatically and guarantees it's always fully and correctly populated.
| Concept | Rust dyn Trait | C function pointer struct |
|---|---|---|
| Who builds the dispatch table | the compiler, automatically | the programmer, by hand |
| Guaranteed fully populated? | yes โ enforced at compile time | no โ nothing checks it at all |
| Calling an unset entry | not possible โ compile error | undefined behavior โ a NULL or garbage call |
NULL call, or a call through a mismatched signature, with no compiler check whatsoever. This is precisely what Rust's dyn Trait and C++'s virtual mechanism guarantee away entirely.
Coding Challenges
Write two functions, add(int, int) and subtract(int, int), and a single function pointer variable that can be pointed at either one. Call it once through each, printing both results.
๐ View solutionWrite a function apply_to_all(int *arr, int n, void (*fn)(int)) that calls fn on every element of an array, and a print_doubled(int) function to pass as the callback that prints each value doubled.
๐ View solutionExtend the chapter's Circle vtable-style example with a second shape (e.g. a Rectangle) that has its own area function, both sharing a common calling pattern. Then explain what specifically would happen if a third shape's function pointer were left unset (NULL) and its area were called.
๐ View solutionChapter 7 Quick Reference โ Course 2 Complete
return_type (*name)(param_types);โ the parentheses around*nameare mandatory- A function name decays to a pointer to itself โ the same phenomenon as
c1-6's array decay - Callbacks โ passing a function pointer so another function can call it later
- A struct of data plus a function pointer is a hand-rolled vtable โ real dispatch, written manually
- C++'s
virtualand Rust'sdyn Traitare the exact same mechanism, compiler-managed and guaranteed correct โ this chapter's version is neither - Course 2 complete. Course 3 covers bit manipulation, hand-built data structures, concurrency, undefined behavior in full depth, debugging tooling, and a real capstone CLI project