Rebuilding printf and Reviewing It with AI Assistance
Lesson 2 of 3
2Rebuilding printf and Reviewing It with AI Assistance
Two connected projects: first building a simplified version of printf from scratch, then using an AI tool as an additional code reviewer (not a replacement) to improve that version.
What the picture shows: printf("x=%d!", 42) copies plain characters, and at %d takes 42 and prints it, giving x=42!.
Two ways into the same idea
printf is like a letter template with blanks: "Hello ___, your balance is ___". You rebuild it yourself to understand how blanks are filled with different types. Then you show your work to AI as a second reviewer who notices what you missed, without writing the work for you.
printf is a variadic function built with stdarg.h (va_list, va_start, va_arg). You read the format string character by character, handle %c, %s, %d and %%, and return the number of characters printed. Then you ask for a review focused on edge cases like INT_MIN and NULL.
Before You Start
🧠Variadic functions
You need to understand va_list/va_start/va_arg from Part Seven of the Foundations track, printf itself is a variadic function.
The Core Idea: How Does printf "Read" Format Specifiers?
When you write printf("Name: %s, Age: %d\n", name, age), the function doesn't know in advance how many arguments it was called with or their types, it relies entirely on the format string itself to interpret the rest:
The mechanism here starts as a purely sequential scan: the function advances through the format string character by character from the start, and any ordinary, non-format character is printed immediately as-is, with no further processing. The moment it meets a % character, that specific character is the only signal in the entire string telling the function "the next character isn't text to print, it's an instruction": the character right after it must be read to learn the upcoming argument's type (d for an integer, s for a string, another % for a literal % sign). The point that's easy to miss is that this scan isn't just a sequential display step, it's actually the engine that pulls arguments, one at a time, off the open-ended list the function was called with. Each time a complete format specifier is recognized (a d right after a %, say), va_arg(args, matching type) is called exactly once, no more, no less, to pull the next value off the argument list, after which the scanning index advances to keep reading right after that specifier. If the string contains three specifiers, va_arg gets called exactly three times, in precisely the order the specifiers appeared, never a different order, never a different count, because there is no other way at all for the function to know how many arguments it has or what types they are.
The reason va_arg needs an explicit type on every call (rather than simply "give me the next value") is that type information in C is completely erased by the time the called function runs, the function sees no trace of whether the argument that was actually passed was an int or a char *; all it has is a memory location holding raw bytes. va_arg needs to know, from the type you hand it, exactly how many bytes to read and under what representation (a direct integer value versus a pointer address), and it then advances its own internal cursor by precisely that amount so it's positioned for the next argument. On common architectures today like x86-64 (which is what a typical Linux environment runs on), this is more involved than "just a pointer walking along the stack," as you might picture it: the first several arguments to any function call are actually passed in CPU registers, not on the stack at all, so when a variadic function starts, the compiler automatically generates code, triggered by va_start, that first copies those register values into a reserved area inside that function's own stack frame; va_arg then reads from that saved area first, before it ever "overflows" into reading the genuinely stack-passed remaining arguments. This is precisely why va_arg isn't a trivial "macro that just adds the type's size to a pointer" the way it might be on simpler architectures, it's internal bookkeeping the compiler builds specifically around the type you asked for.
This directly explains why the responsibility falls on you as the programmer, not the compiler: if you write %d in the format string but a char * is actually passed in that position, nothing stops you at compile time, va_arg(args, int) will read from that saved bookkeeping area assuming it holds an int-sized integer, regardless of what was actually passed. The result isn't just "reading the wrong bytes at the same address" in some simple sense, it can be a complete misreading of part of that internal bookkeeping structure itself, giving you a meaningless number, or treating a pointer as if it were an integer (or the reverse: print_str trying to read memory at an address built from a random integer that was never a valid pointer at all, which can crash the program outright). Some modern compilers (GCC and Clang) do offer an optional warning (-Wformat) when your own function is annotated with __attribute__((format(printf, ...))), telling the compiler to treat it like printf and check that the types written in the string match the actual arguments, but that's an extra compiler feature layered on top of the language, not a guarantee the C language itself provides by default for any variadic function you write.
Figure 1, the scanning loop itself is what drives argument consumption: every completed format specifier calls va_arg exactly once before the loop continues scanning the rest of the string.
How It Works, Step by Step: A Simplified ft_printf Structure
#include <stdarg.h>
#include <unistd.h>
int ft_printf(const char *format, ...)
{
va_list args;
int printed = 0;
int i = 0;
va_start(args, format);
while (format[i])
{
if (format[i] == '%' && format[i + 1])
{
i++;
if (format[i] == 'd')
printed += print_int(va_arg(args, int));
else if (format[i] == 's')
printed += print_str(va_arg(args, char *));
else if (format[i] == '%')
printed += write(1, "%", 1);
}
else
printed += write(1, &format[i], 1);
i++;
}
va_end(args);
return (printed);
}
Figure 2, The core idea: one loop scans the string, and every format specifier consumes exactly one argument from args with its matching type, the same logic as sum_all from the Foundations track, but with more branching by specifier type.
Let's trace this code literally on one specific call: ft_printf("Age: %d\n", 7). From i = 0 through i = 4, the characters A, g, e, :, space are all non-%, so the else branch runs each time: write prints the character straight from &format[i], then i++. At i = 5, format[5] == '%' and there's actually a character after it (format[6] exists), so it enters the first branch: i++ brings it to i = 6, which reads format[6] == 'd', so va_arg(args, int) gets called exactly once, this is the single moment in this entire call where the argument 7 is actually pulled off the argument list, and its value is handed to print_int, which prints "7" and returns the character count. After that inner block, the loop's trailing i++ (which always runs, regardless of which branch executed) advances i to 7, the character right after d in the original string, which is \n, and it's treated as an ordinary character and printed directly via write. At i = 8, format[8] is the null terminator \0 that actually ends the string, so the while (format[i]) condition fails and the loop stops. Notice that va_arg was called exactly once across this entire scan, precisely as many times as there are format specifiers in the string, no more.
Worked Example: AI-Assisted Review as an Extra Layer
Once ft_printf actually works, the second project adds a review step: running an AI tool over the code you actually wrote (not generating new code) to catch issues you might have missed:
| What you ask the AI reviewer | Why it helps |
|---|---|
| "Is there a format specifier case I haven't handled?" | Catches coverage gaps (like %x or %u) you didn't think about |
| "Is my use of va_arg consistent with the expected types?" | Checks that the format specifier's expected type matches the actual va_arg type |
| "Is there a memory leak or a forgotten va_end?" | A quick review of common bugs a fast read of the code might miss |
Deep Dive: Variadics, printf Design and Critiquing an AI Reviewer
This part covers two projects: building printf and then reviewing its code with AI assistance. First we understand variadic functions and what happens inside printf, then how to read AI critique with a critical eye.
1. Variadic functions
A function like _printf(const char *format, ...) receives an unknown number of arguments after the fixed one. The function carries no information about their count or types; the format string is the only contract: each %d promises that the next argument is an integer. If you lie (say %d and pass a string) the result is undefined behavior. The four stdarg tools work like this: va_start begins reading after the last fixed argument, va_arg takes the next argument as the type you specify, va_copy duplicates the reading state, and va_end cleans up at the end.
Figure 3, The core printf cycle: walk the string, route each character or specifier to its handler, and count what was printed.
2. Design decisions
🔢The return value
The exact number of characters printed; each handler returns how many it wrote and the dispatcher sums them.
🗂️A dispatch table
Mapping each specifier to its handler makes adding a new one a small change rather than editing a giant conditional.
🧯Edge cases
Unknown specifier, a % at the end of the string, an empty string, and a NULL string: when unspecified, do what the real printf does.
🚰Buffering and performance
Each write costs a trip into the kernel; gathering characters into a buffer and writing once is far faster, even though the project does not require imitating the library’s buffer.
3. Flags, width and precision (conceptual view)
| Part | Position in the specifier | What it does |
|---|---|---|
| Flags | Right after % | Change the look: show the sign, zero padding, prefix for bases |
| Width | A number after flags | The minimum number of columns the output occupies |
| Precision | A dot then a number | Number of digits or maximum length of a string |
| Length modifier | Before the final letter | Specifies the type size such as long or short |
4. Teamwork and flowcharts
A flowchart turns the algorithm into a language the whole team understands before anyone types a line: a rectangle for a step, a diamond for a decision, an oval for start or end, and arrows for flow. Draw it before the code, split the work by the functions agreed in the header file, and test together on a shared set of tests.
5. AI as a fallible reviewer
In the AI code-review project the goal is not improving the code but evaluating the tool’s ability to reason about low-level C. The tool is quick at stylistic remarks but weak on memory semantics (who owns this pointer? how long does it live?), on undefined behavior, and on constraints imposed by the project itself; it may suggest changes that violate rules such as no global variables or a restricted list of allowed functions.
| The tool claims | How you check it |
|---|---|
| "There is a memory leak here" | Trace every malloc to its free yourself, then confirm with a tool such as Valgrind |
| "Use function X instead of write" | Is it among the project’s allowed functions? |
| "This line is undefined behavior" | Check the standard or manual page; do not accept the term without evidence |
| "The code is excellent" | Remember models lean toward flattery; ask for edge cases and assumptions |
Figure 4, AI code review: the tool proposes, the human verifies and judges.
Try it yourself
How does printf know how many arguments there are and their types?
The function carries no information about its arguments beyond what the format specifiers promise. If you lie (%d with a string) the behaviour is undefined.
Why must you pass the type to va_arg on every call?
The function only sees raw bytes in memory. The type you give says how much to read and whether it is a direct value or an address.
A call to ft_printf has three specifiers. How many times is va_arg called in the scan?
Each complete specifier pulls exactly one argument of the matching type. Ordinary characters are printed straight away and pull nothing.
Quick Review Flashcards
Check Your Understanding
Why check format[i+1] when seeing % in the code above, instead of just checking format[i]?
To avoid reading past the string's bounds if % is its last character, without this check, format[i+1] could read memory outside the actually allocated array.
The tool suggests a function not allowed in your project; what do you write in your critique?
That the suggestion ignores project constraints; quote the constraint and classify it as a limitation in understanding context.
Resources for this part
Sorted by kind: start with the official docs when you want the precise reference, or with an article or video when you want the explanation first.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Official docs3
Done with this lesson?
Sign in to save your progress.