What C is and how a program becomes a binary

C is a small compiled language that sits close to the machine: no garbage collector, no classes, no runtime. You write a text file, GCC turns it into machine code, and the operating system runs the result as a process. This lesson builds the smallest possible program and looks inside every step, on Linux in a Docker container.

From source to program

A C program starts as plain text with a .c suffix. Save the lines below as hello.c, then compile the file and run the binary the compiler writes. The -o hello option names that binary; without it GCC writes a.out.

#include <stdio.h> int main(void) { printf("Hello, C!\n"); return 0; }

main is the entry point: the C runtime calls it and its return value becomes the exit status of the process. printf writes to standard output. Compile with warnings enabled, then run the program from the same directory:

gcc -Wall -Wextra hello.c -o hello
./hello

Hello, C!

-Wall -Wextra asks for the warnings that catch most beginner mistakes. They cost nothing at run time, so keep them in every command of this course.

The four build stages

GCC is a driver, not one program. It runs the preprocessor, which handles #include and macros; the compiler, which turns C into assembly; the assembler, which turns assembly into an object file; and the linker, which joins object files and libraries into an executable. Each stage can be stopped and inspected with its own option.

#include <stdio.h> int main(void) { int a = 6; int b = 7; printf("%d * %d = %d\n", a, b, a * b); return 0; }

-E stops after preprocessing, -S after compilation to assembly, -c after assembly. The last command links the object file on its own:

gcc -E stages.c -o stages.i
wc -l stages.i
gcc -S stages.c -o stages.s
wc -l stages.s
gcc -c stages.c -o stages.o
gcc stages.o -o stages
./stages

182 stages.i
37 stages.s
6 * 7 = 42

The preprocessed file is long because stdio.h pulls in declarations from other headers. The exact line counts depend on the library and compiler version, but the order of the stages never changes.

The default dialect is C23

GCC 15 compiles for C23 unless told otherwise, so __STDC_VERSION__ is 202311L. This course uses that default, and switches to -std=c17 only in a lesson about the older standard. The program below prints what the compiler selected.

#include <stdio.h> int main(void) { printf("__STDC_VERSION__ = %ld\n", __STDC_VERSION__); printf("__GNUC__ = %d\n", __GNUC__); printf("hosted = %d\n", __STDC_HOSTED__); return 0; }

Build it twice, once with the default and once with -std=c17, and compare the first line of each run:

gcc --version | head -1
gcc -Wall -Wextra version.c -o version
./version
gcc -std=c17 -Wall -Wextra version.c -o version17
./version17

gcc (Alpine 15.2.0) 15.2.0
__STDC_VERSION__ = 202311
__GNUC__ = 15
hosted = 1
__STDC_VERSION__ = 201710
__GNUC__ = 15
hosted = 1

hosted = 1 means the full standard library is available, as opposed to a freestanding embedded build. The __GNUC__ value does not change with the dialect: it reports the compiler, not the language level.

Exit status

The value returned by main is handed to the parent process. The shell stores it in $?: zero means success, anything else is a failure code that a script can test. The program below returns 3.

#include <stdio.h> int main(void) { printf("about to leave\n"); return 3; }

Run the program, then ask the shell for the status of the last command:

gcc -Wall -Wextra status.c -o status
./status
echo "exit status: $?"

about to leave
exit status: 3

The message goes to standard output, the status travels separately, so a program can print a report and still fail. This is how the test scripts in lesson 63 decide whether a test passed.

Two mistakes that stop the build

The most common first failure is a misspelled entry point. The compiler accepts the function, the assembler accepts the object file, and the linker is the stage that reports the missing symbol:

#include <stdio.h> int mian(void) { printf("this never runs\n"); return 0; }

gcc -Wall -Wextra typo.c -o typo

/usr/lib/gcc/x86_64-alpine-linux-musl/15.2.0/../../../../x86_64-alpine-linux-musl/bin/ld: /usr/lib/gcc/x86_64-alpine-linux-musl/15.2.0/../../../../lib/Scrt1.o: in function `_start_c':
/home/buildozer/aports/main/musl/src/musl-1.2.6/crt/crt1.c:18:(.text._start_c+0x1a): undefined reference to `main'
collect2: error: ld returned 1 exit status

The paths differ on other systems; the last line is the one to read. The second mistake is writing void main. GCC still builds the program but warns, because the standard requires int main:

#include <stdio.h> void main(void) { printf("hello\n"); }

gcc -Wall -Wextra voidmain.c -o voidmain
./voidmain

voidmain.c:3:6: warning: return type of 'main' is not 'int' [-Wmain]
3 | void main(void)
| ^~~~
hello

Since C99, reaching the closing brace of main without a return is the same as returning 0, so the explicit return 0; is a habit rather than a requirement. Return the value anyway: it makes the exit status visible in the source.

Exercises

  1. Change the first program to print your name on a second line, then run it and check the exit status with echo $?.
  2. Compile stages.c with -O2, keep the assembly with -S, and find the line of assembly that loads the constant 6.
  3. Write a program that returns 0 when it prints one line and 1 when it prints two, then run both paths from the shell and compare $?.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. Which build stage joins object files and libraries into an executable?

    Answer

    The linker

    The linker is the last stage: it resolves symbols such as main and pulls in the libraries the program uses.

  2. Which GCC option stops after producing assembly?

    Answer

    -S

    -S stops after compilation and writes the assembly file; -c goes one stage further and writes an object file.

  3. Which C standard does GCC 15 use when no -std option is given?

    Answer

    C23

    GCC 15 defaults to C23, so __STDC_VERSION__ is 202311L unless another dialect is requested.

  4. What does the shell keep in $??

    Answer

    The exit status of the last command

    $? is the exit status of the last command: zero for success and a non-zero value for failure.

  5. What does this program print? #include <stdio.h> int main(void) { printf("%d\n", 6 * 7); return 0; }

    Answer

    42

    6 * 7 is 42, and %d prints the value as a decimal integer.

  6. What does printf("%ld\n", __STDC_VERSION__); print in a default GCC 15 build?

    Answer

    202311

    The default dialect is C23 and its __STDC_VERSION__ value is 202311L.

  7. This program is built with gcc -std=c17 -Wall -Wextra version.c -o version. What does it print? #include <stdio.h> int main(void) { printf("%ld\n", __STDC_VERSION__); return 0; }

    Answer

    201710

    -std=c17 selects C17, whose __STDC_VERSION__ value is 201710L.

  8. This program is built as status and run with ./status; echo "exit status: $?". What does the shell print? #include <stdio.h> int main(void) { printf("about to leave\n"); return 3; }

    Answer

    about to leave / exit status: 3

    The message goes to standard output, and the value returned by main becomes the exit status 3.

  9. A source file defines int mian(void) instead of int main(void). Which build stage reports the missing entry point?

    Answer

    The linker

    The function is valid C, so the earlier stages succeed; the linker looks for main and reports an undefined reference.

  10. This program is built with gcc -std=c17 -Wall -Wextra voidmain.c -o voidmain. What does GCC report? #include <stdio.h> void main(void) { printf("hello\n"); }

    Answer

    warning: return type of 'main' is not 'int' [-Wmain]

    GCC still builds the program but warns that the return type of main is not int.

The code on this page was run with gcc (Alpine 15.2.0) 15.2.0 on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev