TRT
Sign in

Foundations · Module 2 · Lesson 2 of 10

Anatomy of a C Program and Formatted I/O

Explore program entry points from crt0 and _start to main, inspect process exit codes, and master buffered standard I/O streams.

FreeAbout 16 minutes

By the end you can

  • Trace execution flow from the OS loader through crt0 to main
  • Differentiate between standard streams and their buffering mechanics
  • Format stream data safely with printf and scanf specifiers
  • Handle process termination states and return codes correctly

Builds on The C Compilation Pipeline.

01 · A question first

Textbooks state that execution begins at main(), but who allocates argc and argv before main runs? Why does printf sometimes hold text in memory until a newline appears, while fprintf to stderr prints immediately? Understanding process startup and stream buffering explains these hidden runtime behaviors.

Make a guess, then step through the trace below to check it.

02 · Watch it run

Execution flow from _start to main and stream flushingstep 1 of 6
#include <stdio.h> int main(int argc, char *argv[]) {    printf("Count: %d\n", argc);    fprintf(stderr, "Alert\n");    return 0;}
  1. buffer 0uninitialized(looked at in this step)
  2. buffer 1stdout: empty
  3. buffer 2stderr: direct
  4. buffer 3exit: none

Kernel loads ELF image into virtual memory; entry point jumps to runtime _start.

Machine work: execve() sets stack pointer; jump to _start

03 · The idea

Start with the problem

Almost every introduction to C presents the same premise: execution starts on the first line of main() and ends when main() returns. For trivial scripts, this abstraction holds. In production systems programming, treating main() as the true beginning and end leads directly to puzzling debugging failures.

Consider a developer debugging an application that crashes with a segmentation fault. To narrow down the failure, they sprinkle print statements across their code:

c
#include <stdio.h>
 
int main(void) {
    printf("Starting worker task...");
    
    int *ptr = NULL;
    *ptr = 42; /* Fatal crash: writing to a null pointer */
    
    printf("Worker task complete.\n");
    return 0;
}

When run in the terminal, the program outputs nothing at all before printing Segmentation fault (core dumped).

The developer concludes that the program crashed before reaching the first print statement. They waste hours inspecting library loading and environment configurations, only to discover later that the crash happened on line 7—after the first printf() had already executed.

Why did the terminal show nothing even though printf() ran successfully?

Furthermore, who allocates and populates argc and argv before main() begins? What happens to the integer returned by main()? And why does writing to stderr behave differently from writing to stdout?

To write reliable systems code, you must look behind the high-level syntax and understand the runtime environment: the bootstrap process that prepares memory before main(), the mechanics of user-space stream buffering, and the contract between the process and the operating system.


How the idea works

A running C program is an operating system process managed by a collaboration between the kernel, the C runtime library (crt), and your compiled instructions.

text
Kernel: execve()
       │
       ▼  [Set up stack with argc, argv, envp]
Runtime: _start (crt0.o / crt1.o)
       │
       ▼  [Initialize libc, TLS, stdio streams]
__libc_start_main()
       │
       ▼  [Invoke application entry]
main(argc, argv)
       │
       ▼  [Return status code]
exit() / sys_exit

1. The True Entry Point: _start

When a shell executes a compiled binary, the kernel issues the execve() system call. The kernel maps the binary sections into virtual memory, sets up the initial process stack, and sets the instruction pointer to the entry address specified in the ELF header.

That address does not point to main(). It points to an assembly routine named _start, provided by a runtime object file called crt0.o (or crt1.o in modern dynamically linked binaries).

The _start routine performs critical initialization:

  1. It reads argc, argv, and the environment pointer (envp) from the initial stack layout prepared by the kernel.
  2. It initializes thread-local storage (TLS) and runtime data structures.
  3. It sets up the standard I/O streams (stdin, stdout, stderr).
  4. It calls __libc_start_main(), which in turn executes any global constructor functions before finally invoking your main() function.

2. Signatures and Parameters of main()

The C standard recognizes two canonical signatures for the host environment entry point:

c
int main(void);
int main(int argc, char *argv[]);

In the two-parameter signature:

  • argc (argument count): A non-negative integer indicating the number of command-line arguments passed to the program. By convention, argc is at least 1.
  • argv (argument vector): An array of pointers to null-terminated character strings. argv[0] represents the command name or path used to invoke the binary. Subsequent elements (argv[1] through argv[argc - 1]) hold the arguments. The C standard guarantees that argv[argc] is a null pointer (NULL).

When main() finishes, it returns an integer to the runtime. The runtime immediately passes this value to exit(), which flushes open streams, executes functions registered with atexit(), and invokes the kernel termination system call (sys_exit or exit_group).

c
/* These two statements are functionally equivalent inside main(): */
return 0;
exit(0);

3. The Standard I/O Model: Streams and Descriptors

At the operating system level, I/O is handled via low-level integer file descriptors:

  • File Descriptor 0: Standard Input
  • File Descriptor 1: Standard Output
  • File Descriptor 2: Standard Error

Rather than forcing developers to make raw read() and write() system calls for every character, the C standard library (<stdio.h>) wraps these descriptors in structured objects known as streams, represented by pointers to the opaque type FILE:

  • stdin: Buffered input stream wrapping file descriptor 0.
  • stdout: Buffered output stream wrapping file descriptor 1.
  • stderr: Unbuffered or line-buffered output stream wrapping file descriptor 2.

4. Buffering Strategies

The mystery of the invisible print statement comes down to buffering. Standard I/O streams operate under three distinct buffering modes:

  1. Unbuffered (_IONBF): Bytes written to the stream are transmitted immediately to the kernel via a write() system call. stderr is unbuffered by default so error diagnostics reach the console immediately during a failure.
  2. Line-Buffered (_IOLBF): Bytes are stored in a user-space memory buffer inside the standard library. The buffer is flushed (written to the kernel) only when a newline character (\n) is encountered, when the buffer fills completely, or when input is requested from stdin. stdout defaults to line-buffered mode when attached to an interactive terminal.
  3. Fully Buffered (_IOFBF): Bytes accumulate in a large fixed-size block buffer (typically 4096 or 8192 bytes). A system call occurs only when the block is completely full or when explicitly flushed. When stdout is redirected to a file or a pipe (./app > out.txt), the C runtime automatically switches its mode from line-buffered to fully buffered.

In the trace above, step 3 shows printf("Count: %d\n", argc); loading characters into the stdout buffer. Because the string concludes with \n, step 4 shows libc flushing the buffer to the terminal using write(1, ...). In step 5, fprintf(stderr, ...) sends bytes immediately without waiting for a newline.

To force an immediate flush on a line-buffered or fully buffered stream at any time, use the fflush() function:

c
printf("Processing data...");
fflush(stdout); /* Forces user-space buffer to flush to kernel immediately */

5. Formatted Output: printf and Specifiers

The printf() family formats raw binary data into human-readable text using conversion specifications beginning with %:

SpecifierExpected TypeOutput FormatExample
%d or %iintSigned decimal integer-42
%uunsigned intUnsigned decimal integer3000000000
%fdoubleFloating-point decimal notation3.141593
%schar *Null-terminated string"hello"
%cint (promoted char)Single character'A'
%pvoid *Implementation-defined pointer address0x7ffeefbff560
%zusize_tUnsigned integer size type1024
%ldlongSigned long decimal integer9223372036854775807

6. Formatted Input: scanf and Address Passing

While printf reads variable values, scanf() parses text from stdin and writes results into memory. Because C uses pass-by-value semantics for function arguments, passing a variable directly to scanf would provide only a copy of its current value.

To allow scanf to modify a variable, you must pass the variable's memory address using the address-of operator (&):

c
int age = 0;
if (scanf("%d", &age) == 1) {
    printf("Recorded age: %d\n", age);
} else {
    fprintf(stderr, "Failed to parse integer from input.\n");
}

The return value of scanf() is critical: it returns the number of input items successfully matched and assigned, or EOF if input ends or a read failure occurs before any conversion.


Why it is correct

Understanding the design choices behind process startup and stream buffering reveals why C operates this way.

Why Runtime Initialization Exists

A compiler cannot emit machine code for main() and hand it directly to the OS kernel. Modern operating systems enforce complex conventions:

  • The kernel passes command-line arguments and environment variables as a raw block of memory on top of the stack. Code must parse those pointers into the convenient argv array structure.
  • Standard C features—such as static variable initializers, memory allocators (malloc), and I/O streams—rely on internal control blocks that must be configured before user code touches them.

Having _start orchestrate initialization ensures that main() runs in a fully initialized, predictable environment.

Why Streams Use User-Space Buffering

Direct system calls are computationally expensive. A single write() system call requires the CPU to switch from user mode (Ring 3) to kernel mode (Ring 0), validate arguments, modify kernel file tables, and return.

If an application writes 10,000 characters one by one without buffering:

  • Without user-space buffering: 10,000 separate write() system calls.
  • With standard library buffering: The library batches the 10,000 characters into two or three 4096-byte blocks, issuing only 3 system calls.

This design reduces context switching overhead by orders of magnitude while keeping individual character I/O calls simple for the developer.


What it costs

Using the runtime and standard I/O streams introduces specific performance and resource overhead:

OperationPrimary CostTypical LatencyKey Consideration
User-space buffer write (fputc)Memory copy in user RAM2–8 nanosecondsNegligible overhead
Buffer flush (write syscall)CPU privilege switch (Ring 3 to Ring 0)100–300 nanosecondsHundreds of times slower than memory write
Formatted string parsing (printf)Format parsing and integer-to-text conversionWorkload and implementation dependentSpecifier parsing adds CPU work
Default Stream AllocationHeap memory per open FILE handle4 KB to 8 KB (BUFSIZ)Multiplied across hundreds of open files

Process Exit Code Truncation

Under POSIX systems, the value returned by main() or passed to exit() is not returned as a full 32-bit signed integer to the parent shell. The kernel preserves only the least significant 8 bits:

text
Status passed to exit:  256  (binary: 00000001 00000000)
Status visible to shell: 0    (binary:          00000000)

If your application indicates an error by executing return 256;, the shell checks the exit status and sees 0—falsely interpreting the run as a complete success. Always restrict process exit codes to values between 0 and 255, reserving 0 for success (EXIT_SUCCESS) and non-zero values for errors (EXIT_FAILURE).


Pitfalls and variations

Pitfall 1: Missing Newlines Before Abrupt Termination

As demonstrated in the opening problem, calling printf("Status") without a newline keeps the text in the user-space stdout buffer. If the program crashes immediately afterward via a segmentation fault, illegal instruction, or abort(), the process terminates without running libc cleanup routines. The buffered text is discarded with the process memory, misleading developers during debugging.

To avoid this pitfall:

  • Always end terminal status messages with \n.
  • Call fflush(stdout) explicitly when writing progress indicators that do not end with a newline.
  • Direct diagnostic logging to stderr, which writes unbuffered data immediately.

Pitfall 2: Forgetting Address Operator with scanf

Passing an unadorned variable name to scanf remains one of the most common C bugs:

c
int count = 100;
scanf("%d", count); /* BUG: passing value 100 instead of &count */

Here, scanf receives the integer 100 and interprets it as a memory pointer (0x00000064). When it attempts to store the user input at that address, the operating system raises a segmentation fault due to an invalid memory write. Enable -Wall -Wextra in your compiler options to have the compiler verify format string types at compile time.

Pitfall 3: Unbounded String Input via %s

Using scanf("%s", buffer) is inherently unsafe:

c
char name[16];
scanf("%s", name); /* BUG: Vulnerable to buffer overflow */

If a user enters twenty characters, scanf writes past the end of name, corrupting the surrounding stack memory. Always supply an explicit maximum field width matching the buffer size minus one byte for the null terminator:

c
char name[16];
scanf("%15s", name); /* Safe: reads at most 15 characters */

Pitfall 4: Ignoring scanf Return Values

If a user enters characters when an integer is expected, scanf("%d", &val) fails to parse the token, leaves the input unconsumed in stdin, and returns 0. If your code does not check the return value, val retains its previous or uninitialized value, and subsequent reads will loop indefinitely trying to parse the same invalid token.


Where it is used

The runtime initialization and standard stream abstractions underpin real-world systems across computing:

  • UNIX Pipelines: Shell redirection (cmd1 | cmd2 > out.log 2> err.log) relies entirely on the separation of file descriptors 0, 1, and 2. Because stdout is separated from stderr, diagnostic errors do not pollute downstream data pipelines.
  • Embedded Firmware and Free-Standing Environments: Embedded platforms often run without a full operating system. In these "freestanding" environments, there is no _start or libc. Developers write their own custom reset vectors in assembly to set up the stack pointer before jumping directly to an embedded loop.
  • High-Performance Loggers: Production servers (such as Nginx or Redis) avoid unbuffered fprintf(stderr) in hot loops. Instead, they implement custom ring buffers or write directly to non-blocking file descriptors via the write() system call to avoid blocking the main event thread.

In the next lesson, we will move inside the memory model of C variables: inspecting how primitive data types are physically represented as bytes in memory, how two's complement integer encodings work, and what happens during integer promotion and overflow.

04 · Prove it

Question 1 of 3 · answer from memory

What function actually serves as the raw binary entry point defined in the ELF header for a C program on modern Linux systems?

05 · Practice

Something wrong with this lesson? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save