[0] The interview that started this
Let's go back in time a bit. I got called in for a security role interview at one of Nepal's finest companies. I was really looking forward to it. I felt prepared on certain topics and had full confidence going in.
They asked me a few questions about security, then about some projects I'd done, which I explained horribly, and I was off to a rough start. Then they shifted the questions toward OS and kernel stuff. If I remember the question correctly, they asked something like "what exactly happens when you enter a URL in your browser, what does the packet flow look like from your browser to the destination," or more simply, "what happens under the hood in the kernel when you send a request to a URL."
Now, the thing is, I had limited knowledge of the sockets API, file descriptors, send(), recv(), listen(), and so on. I gave some kind of answer, but I knew it was incomplete and that they wouldn't be satisfied with it.
Then one interviewer asked a question that left me stunned and made me realize a few things. He said, "Tell me about a subject you can speak about for an hour." I couldn't answer right away, but I had to say something. At the time I had some knowledge of processes, memory, and OS internals, so I said, "I can talk about processes, memory, IPC, and that kind of thing." But deep down I knew it was only surface level.
I knew what happens when a program gets loaded into memory, about address spaces, how the CPU fetches instructions to and from memory, what context switching is and why it's necessary. That kind of thing. Nothing more.
I didn't get an offer from that company, which is understandable given how the interview went, even though it was for an associate position. So much of the lower level stuff gets abstracted away from us, and I think it's genuinely important to understand how things work underneath. This blog is my excuse to go dig into low level stuff.
[1] I see C everywhere
C is everywhere. Once you start looking, you can't unsee it. Git is C. Redis is C. nginx is C. ffmpeg is C. The Linux kernel's scheduler and memory manager are C too, even with Rust creeping into a handful of drivers these days. Even the languages people reach for specifically to avoid writing C usually have C sitting one layer down, Python's own interpreter, CPython, is itself a C program.
There's a very thin line between C code and the hardware it runs on. a = 10 compiles down to a single MOV instruction that stores the value directly into a memory address. What we write in C is close to what actually executes on the CPU.
Compare that to a language like Go, where the compiled binary is much bigger because it carries a garbage collector, a scheduler, and a network poller along with it. In C, nothing sits between your code and the hardware.
[2] Writing the first program
Let's start with the first program and see what's actually happening underneath. Here's a simple one that prints "hello, world" to standard output.
#include <stdio.h>
int main() {
printf("hello, world\n");
return 0;
}
Let's go through this line by line.
a. #include <stdio.h>
You might remember this as "the header file thing" from a uni lecture. It's a preprocessor directive that tells the C preprocessor: "search for the file stdio.h, read its contents, and paste them into this file, replacing this line." This all happens during the preprocessing phase of compilation, before a single line of your code is actually compiled.
You can catch the preprocessor in the act. Ask gcc to stop right after that phase and dump what it produced:
gcc -E hello.c -o hello.i
Open hello.i in an editor and scroll. It's mostly noise, type definitions and macro expansions pulled in from stdio.h and whatever it pulls in on its own. Search the file for the word main and you'll land on your five original lines, sitting untouched at the very bottom, exactly as you wrote them. #include didn't transform your code, it just pasted someone else's text above it.
b. int main()
Every C program needs a main() function, and it's the entry point of the program. When the OS loads the binary and runs it, main is the first thing it calls.
int means main returns an integer, and that integer goes back to the operating system as an exit code. I find this genuinely cool. The kernel looks at that return value to decide whether the program ran successfully or not. By convention, 0 means success.
Empty parentheses here mean "an unspecified number of arguments," while void would mean "takes zero arguments explicitly."
c. printf("hello, world\n")
printf prints formatted text to stdout, the program's default output stream. The \n is a newline, and without it your next shell prompt would just run right into the output, something like hello, world$.
printf isn't a keyword or something built into the language. It's a regular function, declared in stdio.h and implemented somewhere in libc, C's standard library, the shared library that provides functions like printf, malloc, and memcpy.
When you write #include <stdio.h>, you get the declaration. The compiler now knows printf exists and roughly what arguments it takes. The actual compiled code lives in a shared library on your system, and it's the linker's job to connect your call to that implementation. That step happens near the end of compilation.
d. return 0;
This sends an exit code of 0 back to the operating system, meaning the program ran successfully. Anything other than 0 signals that something went wrong.
[3] Compiling the program
Let's compile it with gcc, the GNU Compiler Collection.
gcc -o hello hello.c
Read this as "compile hello.c, and name the resulting binary hello." That naming is what the -o flag (for output) does. Under the hood, gcc runs through preprocessing, compilation, assembly, and linking, all in one command.
Run the program with:
./hello
The ./ tells the shell to look for hello in the current directory. Just typing hello won't work, because the shell searches your $PATH for it, and the current directory usually isn't part of that path.
You can check the type of file you got using the file command.
file hello
On Linux x86-64, it looks something like:
hello: ELF 64-bit LSB pie executable, x86-64
That's a native binary, built for your specific CPU architecture, not bytecode waiting on a VM, machine instructions the CPU runs directly. Here's a way to actually see that "printf lives in libc, not in your binary" claim from the last section, instead of just taking it on faith. Ask the linker what your binary depends on at runtime:
ldd hello
linux-vdso.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2
There it is: libc.so.6. Your five-line program doesn't contain printf's implementation at all, it contains a note that says "go find printf in this shared library when you actually run me." That resolving happens at load time, courtesy of the dynamic linker (ld-linux) listed right below it.
[4] Reading exit codes
If you change return 0; to return 7;, recompile, and run it, you can check the program's exit code with:
echo $?
$? is a shell variable holding the exit code of the last command run. Exit codes are how a program tells whatever launched it whether things went well or not.
./hello && echo "pass" || echo "fail"
hello, world
fail
Since the program returned a nonzero code, the shell treats it as a failure, even though it printed its output just fine. This isn't just shell trivia either, it's the same mechanism a CI pipeline uses to decide a build failed, and the same one your shell script leans on when it chains commands with &&.
[5] Peeking inside header files
We touched on this earlier: #include <stdio.h> pastes the contents of stdio.h into your source file. But what's actually inside stdio.h?
It's full of declarations. A simplified version of what printf's declaration looks like:
int printf(const char *format, ...);
This tells the compiler: there's a function called printf, it takes a const char * and a variable number of arguments, and it returns an int.
Header files give you declarations, not implementations, signatures rather than actual code. You can watch the consequence of a missing declaration with your own function instead of printf, which makes the "forward declaration" idea a lot less abstract:
#include <stdio.h>
int main() {
printf("2 + 3 = %d\n", add(2, 3));
return 0;
}
int add(int a, int b) {
return a + b;
}
add is defined lower down in the file, but called up in main, before the compiler has seen its signature. Compile it, and older compilers would just warn; modern gcc and clang will flat out refuse:
hello.c:4:31: error: call to undeclared function 'add'; ISO C99 and later
do not support implicit function declarations
The fix is a forward declaration, a prototype, up top, before it's used:
int add(int a, int b);
That one line is functionally a tiny, private header file. It tells the compiler "trust me, this function exists and looks like this," which is exactly the promise stdio.h makes about printf, just written by you instead of shipped with the compiler.
[6] Learning printf 101
printf is declared in stdio.h and defined somewhere in libc. It doesn't inspect the types of the arguments you pass it and figure out how to print them on its own. It reads a format string and trusts you completely to pass the right arguments in the right order.
To print an integer:
int count = 3;
printf("count is %d\n", count);
count is 3
%d means "interpret the next argument as a signed integer and print its decimal representation." The d stands for decimal.
%s means "interpret the next argument as a pointer to a null-terminated string, and print characters until you hit the terminator."
%f means "interpret the next argument as a double, and print it in decimal notation."
[7] Don't lie to printf
Here's the same trust falling apart in two different ways. First, a type that's the wrong size rather than the wrong kind:
double price = 19.99;
printf("price is %d\n", price);
%d reads a 4-byte integer off the argument list. A double is 8 bytes. printf doesn't know that, it just grabs 4 bytes' worth of whatever's sitting there and prints it as if it were an int. You won't get 19. You'll get some nonsense integer that has nothing to do with 19.99, because printf sliced the bytes in the wrong place.
Now the sharper version, telling it to expect a pointer when you gave it a plain number:
int code = 3;
printf("code is %s\n", code);
%s means "treat this argument as a memory address, and read a string starting there." printf takes the number 3, treats it as the address 0x3, and tries to read bytes from that location. Address 0x3 is nowhere near anything your process owns.
Segmentation fault (core dumped)
or, depending on your libc and a bit of luck, something less dramatic:
code is (null)
Two different mistakes, two different flavors of wrong, and neither one gets caught at compile time or runtime. That's the deal C makes with you: printf will do exactly what you told it, even when what you told it was a lie.
That's it for this one. Turns out the machine doesn't care how confident you sound in an interview, it only cares whether you told printf the truth. One of these two things is easier to fix with a return statement.
References
- Project Lighthouse
- Kernighan, B. W., & Ritchie, D. M., The C Programming Language (K&R)