How Does C Become Assembly?
From preprocessing and linking to x86-64 calling conventions and the complete stdio.h flow
How Does C Become Assembly?
The articles on this blog are written for teaching. Compiler versions, operating systems, ABIs, and optimization options can all change the generated code, so verify important details in your own environment.
Running gcc -S file.c is easy. Reading the resulting .s file is harder. The #include <stdio.h> line has disappeared, the full implementation of printf has been reduced to a call, and examples for Linux and Windows use different registers.
The best way to make sense of this is to follow the whole build rather than memorizing individual instructions. This article covers preprocessing, compilation, assembly, linking, x86-64 calling conventions, optimization, and the complete set of 45 core C standard I/O functions.
Examples use x86-64 Linux, the System V ABI, and Intel syntax unless stated otherwise. C itself does not prescribe a CPU or assembly language.
The five stages from source to a running program
- The preprocessor expands directives such as
#include,#define, and#if. - The compiler analyzes the preprocessed C program and generates target-specific assembly.
- The assembler encodes instructions and metadata into an object file.
- The linker resolves symbols and combines objects with libraries into an executable.
- The operating-system loader maps the executable and shared libraries into memory.
When people say that they want to convert C to assembly, they usually mean stopping after the second stage and keeping the .s or .asm listing.
A small example
#include <stdio.h>
static int add(int left, int right)
{
return left + right;
}
int main(void)
{
int value = add(20, 22);
printf("value = %d\n", value);
return 0;
}
This is small enough to inspect but still contains a function call, local data, a string literal, and the variadic function printf.
Stop GCC at each stage
gcc -std=c17 -E stdio_trace.c -o stdio_trace.i
gcc -std=c17 -O0 -fno-omit-frame-pointer -S -masm=intel -fverbose-asm stdio_trace.c -o stdio_trace.s
gcc -std=c17 -c stdio_trace.c -o stdio_trace.o
gcc stdio_trace.o -o stdio_trace
-E keeps preprocessor output, -S keeps assembly, and -c stops after producing an object file. -masm=intel selects Intel syntax in GCC's textual output. -O0 makes source structure easier to recognize, while -O2 is better for studying realistic optimized code.
To ask GCC to save several intermediate files at once, use:
gcc -std=c17 -O2 -save-temps=obj stdio_trace.c -o stdio_trace
Clang and MSVC
Clang accepts a similar command:
clang -std=c17 -O0 -S -masm=intel stdio_trace.c -o stdio_trace.s
clang -std=c17 -O2 -S -emit-llvm stdio_trace.c -o stdio_trace.ll
The LLVM IR file is not CPU assembly. It is a compiler intermediate representation that is useful for seeing transformations before final instruction selection.
MSVC can emit an assembly listing with /FA options:
cl /nologo /std:c17 /Od /FAs /Fa:stdio_trace.asm stdio_trace.c
cl /nologo /std:c17 /O2 /FAs /Fa:stdio_trace_opt.asm stdio_trace.c
Run these commands from a Developer Command Prompt so that the compiler, SDK headers, and libraries are configured.
Compiler assembly and disassembly are different views
A compiler listing still contains labels, directives, sections, and sometimes source comments. Disassembly reads already encoded instructions from an object or executable.
objdump -d -Mintel stdio_trace.o
objdump -d -Mintel stdio_trace
The final executable may contain linker-generated stubs and relocated addresses that did not appear in the compiler's .s file. Debug symbols make names easier to recognize but do not restore the original C program.
Minimal Intel-syntax reading
Intel syntax generally writes the destination first.
mov eax, 42
add eax, 8
cmp eax, 50
je .equal
Common concepts are:
movcopies data.leacomputes an address expression and is also used for arithmetic.cmpsets flags as if it had subtracted its operands.- conditional jumps inspect those flags.
callstores a return address and transfers control.retreturns to the caller.
Memory operands use brackets in Intel syntax. DWORD PTR [rbp-4] means a 32-bit value in memory at an address derived from rbp.
Reading add without optimization
A typical debug-oriented System V build may contain code like this:
add:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov DWORD PTR [rbp-8], esi
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-8]
add eax, edx
pop rbp
ret
The first two integer arguments arrive in edi and esi. The return value is placed in eax. Storing arguments on the stack is useful for debugging but is not required by the C abstract machine. Optimized code can reduce the body to an lea and ret, or inline it completely.
Calling printf from main
A System V build often prepares a printf call like this:
mov esi, 42
lea rdi, .LC0[rip]
xor eax, eax
call printf@PLT
rdi receives the format-string address and esi receives the integer argument. For a variadic call under this ABI, al also communicates how many vector registers carry floating-point arguments; zeroing eax sets it to zero here. printf@PLT is a dynamic-linking entry, not the whole implementation of printf.
System V x86-64 calling convention
For integer and pointer arguments, the first six registers are normally rdi, rsi, rdx, rcx, r8, and r9. Integer return values use rax. Floating-point values use XMM registers. The stack must meet the ABI alignment rules at a call boundary.
Some registers are caller-saved and others are callee-saved. A function that changes a callee-saved register must restore it before returning. This contract lets separately compiled object files call one another.
Windows x64 uses different argument registers
Windows x64 normally uses rcx, rdx, r8, and r9 for the first four integer or pointer arguments. The caller reserves 32 bytes of shadow space. Additional arguments go on the stack. Integer results still usually use rax.
lea rcx, format_string
mov edx, 42
call printf
Never interpret register roles without first identifying the target ABI.
Optimization removes the shape of the source
With optimization enabled, the compiler may:
- inline
add; - evaluate
add(20, 22)at compile time; - keep no stack slot for
value; - remove unreachable code;
- combine or reorder expressions where language rules allow;
- replace a library call with an equivalent built-in transformation.
For learning, compare -O0 and -O2. The first helps map source constructs to instructions. The second shows the program the optimizer actually wants to execute.
printf may become puts
A compiler can recognize a constant call such as:
printf("hello\n");
and emit a call to puts instead. This is legal when the observable behavior remains valid under the compilation assumptions. Options such as -fno-builtin can help experiments, but production code should not rely on a particular call surviving optimization.
What <stdio.h> provides
<stdio.h> declares the standard stream I/O interface. Including it does not paste the complete library implementation into your source. Preprocessing exposes types, macros, and function declarations. The actual implementation usually remains in the C runtime library and is resolved during linking or loading.
The central abstraction is a stream represented by FILE *. A FILE object can hold buffering state, file position, end-of-file and error flags, orientation, and implementation-specific synchronization data. Its members are implementation details; user code works through standard functions.
Three streams are available at program start:
stdinfor standard input;stdoutfor standard output;stderrfor diagnostics.
Types and macros in <stdio.h>
Important types include FILE, fpos_t, and size_t. Important macros include EOF, NULL, BUFSIZ, FOPEN_MAX, FILENAME_MAX, L_tmpnam, TMP_MAX, _IOFBF, _IOLBF, _IONBF, SEEK_SET, SEEK_CUR, and SEEK_END.
Macro values are implementation-defined where the standard permits it. Do not serialize or compare them against guessed numeric constants.
All 45 core functions
The following groups cover the 45 core functions in the C standard stream I/O interface. Later language editions and optional annexes can add more names, and implementations may provide extensions.
File operations, 4
| Function | Purpose |
|---|---|
remove | remove a file by name |
rename | rename or move a file within implementation rules |
tmpfile | create a temporary binary stream |
tmpnam | generate a temporary filename; difficult to use safely |
tmpnam has race-condition problems in real programs. Prefer a secure platform API that creates and opens a unique file atomically.
Open, close, and buffering, 6
| Function | Purpose |
|---|---|
fclose | flush and close a stream |
fflush | flush pending output |
fopen | open a named file |
freopen | reopen or redirect an existing stream |
setbuf | select simple buffering behavior |
setvbuf | configure buffer mode and size |
Common fopen modes are r, w, a, r+, w+, and a+, optionally with b. Opening with w truncates an existing file. Always check the returned pointer.
fflush is defined for output streams and update streams whose last operation was not input. Portable C does not use fflush(stdin) to discard keyboard input.
Formatted output, 8
| Function | Destination and argument form |
|---|---|
fprintf | stream, variadic arguments |
printf | standard output, variadic arguments |
sprintf | character array, variadic arguments |
snprintf | bounded character array, variadic arguments |
vfprintf | stream, va_list |
vprintf | standard output, va_list |
vsprintf | character array, va_list |
vsnprintf | bounded character array, va_list |
A conversion follows the general form %[flags][width][.precision][length]specifier. The format string and actual argument types must agree. Passing user input directly as the format string creates a format-string vulnerability.
Variadic arguments undergo default promotions: float becomes double, and narrow integer types usually become int. This affects both C source reasoning and the ABI-level call.
Formatted input, 6
| Function | Source and argument form |
|---|---|
fscanf | stream, variadic pointers |
scanf | standard input, variadic pointers |
sscanf | character string, variadic pointers |
vfscanf | stream, va_list |
vscanf | standard input, va_list |
vsscanf | character string, va_list |
Except for special cases such as %n, input conversions generally require pointers to writable objects. Use field widths for %s and check the return value, which reports successful assignments. For interactive input, reading a whole line with fgets and parsing it separately is often easier to validate.
Character and line I/O, 10
| Function | Purpose |
|---|---|
fgetc | read one character from a stream |
fgets | read a bounded line or character sequence |
fputc | write one character to a stream |
fputs | write a string to a stream |
getc | read one character, possibly implemented as a macro |
getchar | read one character from stdin |
putc | write one character, possibly implemented as a macro |
putchar | write one character to stdout |
puts | write a string followed by a newline |
ungetc | push one character back onto an input stream |
Store character input in an int, not a char, so every unsigned-char value remains distinguishable from EOF.
Block I/O, 2
| Function | Purpose |
|---|---|
fread | read an array of objects |
fwrite | write an array of objects |
Their return value is the number of complete elements transferred, not the number of bytes unless the element size is 1. Writing a C struct directly does not create a portable file format because padding, byte order, type sizes, and floating-point representation can differ.
File position, 5
| Function | Purpose |
|---|---|
fgetpos | save a stream position in fpos_t |
fseek | reposition using a long offset |
fsetpos | restore a saved fpos_t position |
ftell | obtain the current position as long |
rewind | return to the beginning and clear status flags |
Text-stream offsets have stricter portability rules than arbitrary byte indexes. For very large files, platforms often provide separate 64-bit extensions.
Status and diagnostics, 4
| Function | Purpose |
|---|---|
clearerr | clear EOF and error indicators |
feof | test the EOF indicator |
ferror | test the error indicator |
perror | print a message associated with errno |
EOF is known only after a read attempts to pass the end. This is why while (!feof(file)) is usually wrong. Drive the loop with the read operation itself.
Annex K and implementation extensions
Some implementations expose bounds-checking names such as fopen_s, fprintf_s, or scanf_s. These are not a portable replacement for the core functions across all C implementations. Microsoft also assigns specific parameter rules to its _s functions. Treat them as a separate API and document the targeted runtime.
POSIX and Windows provide many additional functions that are useful but not part of the ISO C core. Keep the standard interface and platform extensions distinct in explanations and build rules.
Standard I/O is not one system call per function
printf commonly formats data into a user-space stream buffer. A later flush may call an operating-system API such as write on a POSIX system or a Windows runtime and kernel path. Buffering reduces expensive transitions and lets the C library provide one portable stream interface.
Consequently, seeing call printf in assembly explains only the ABI boundary at that call site. To study the library internals, obtain debug symbols or build the relevant C runtime separately and disassemble that implementation.
getc may not look like a function call
The standard permits getc and putc to be implemented as macros. A library can inline a fast buffer check and call a slower refill routine only when necessary. Modern compilers and link-time optimization can also inline ordinary functions. The absence of a call getc instruction does not mean that the C operation disappeared.
Binary file copy example
#include <errno.h>
#include <stdio.h>
int copy_file(const char *source_path, const char *destination_path)
{
unsigned char buffer[64 * 1024];
FILE *source = fopen(source_path, "rb");
if (source == NULL)
{
perror("fopen source");
return -1;
}
FILE *destination = fopen(destination_path, "wb");
if (destination == NULL)
{
perror("fopen destination");
fclose(source);
return -1;
}
int result = 0;
for (;;)
{
size_t read_count = fread(buffer, 1, sizeof buffer, source);
if (read_count > 0)
{
size_t written = 0;
while (written < read_count)
{
size_t count = fwrite(buffer + written, 1,
read_count - written, destination);
if (count == 0)
{
perror("fwrite");
result = -1;
break;
}
written += count;
}
}
if (result != 0 || read_count < sizeof buffer)
{
if (ferror(source))
{
perror("fread");
result = -1;
}
break;
}
}
if (fclose(destination) == EOF)
{
perror("fclose destination");
result = -1;
}
if (fclose(source) == EOF)
{
perror("fclose source");
result = -1;
}
return result;
}
This example checks partial transfers, distinguishes EOF from an input error, and checks close-time output failure. It also shows why stdio code should be understood at both the C interface and the lower ABI level.
A useful study sequence
- Start with a function containing integer parameters and a return value.
- Compare
-O0and-O2. - Add a local array and inspect stack addressing.
- Add a normal call and identify argument and return registers.
- Add
printfand inspect the format-string address and variadic ABI rule. - Inspect the object file, then the linked executable.
- Repeat for a different ABI such as Windows x64.
Change one thing at a time. Small diffs teach more than a large production disassembly with no baseline.
Common mistakes
- There is no single assembly translation for a C program. Target, compiler, version, options, and surrounding code matter.
-O0is still generated code, not a literal line-by-line definition of C semantics.- Mismatched format specifiers and arguments cause undefined behavior.
- Never use untrusted text as a
printfformat string. - Bound
%sinput or prefer line-based input and explicit parsing. - Do not write loops driven by
!feof(stream). - Do not depend on
fflush(stdin)in portable C. - Check
fclosewhen output errors matter. - Raw struct bytes are not a portable serialization format.
- Undefined behavior cannot be used to infer a general assembly rule.
From one printf line to visible output
The source declaration comes from <stdio.h>. The compiler checks and lowers the call according to the target ABI. The assembler encodes it with a symbol reference. The linker or dynamic loader connects that reference to the C runtime. At run time, the library formats the values, updates a FILE buffer, and eventually asks the operating system to write bytes to an underlying handle.
Each layer answers a different question. Assembly tells you how control and values cross one machine-level boundary; it does not replace the language, ABI, library, and operating-system contracts around it.
References
- ISO C working draft N1570
- GCC options for code generation
- Clang command-line reference
- MSVC 0 assembly listings
- System V AMD64 ABI
- Microsoft x64 calling convention
- GNU objdump
Summary
C becomes executable code through preprocessing, compilation, assembly, linking, and loading. The assembly you see depends on the target ABI and optimization decisions. <stdio.h> contributes declarations and macros, while the runtime library supplies the actual stream implementation. Once these layers are kept separate, instructions, calls, buffers, and system APIs form one understandable path instead of a wall of unfamiliar text.