Learn › Learn C › C Foundations
gcc - a hands-on Linux lab on a real virtual machine.
What a compiler is, from zero. An interpreter like bash re-translates a script at every run; a compiler like gcc translates C source into a binary once, and the binary runs on its own. You compile and run your first program, hand C source to bash and read the real syntax error it dies with, watch gcc refuse a broken file before anything runs, catch the classic stale-binary trap where an edited source lies behind an old executable, and delete the source to prove the binary stands alone. The toolchain proof runs on the real lab image: gcc 12.2.0 and GNU Make 4.3.
You spent all of Linux Foundations typing commands. ls, cat, grep, hundreds of times each. Here is a question you probably never asked: what IS ls?
It is a file. It sits on disk at /bin/ls, the same way your notes sit in a text file. But try to read it with cat /bin/ls and the screen fills with unreadable characters. It is not text. It is not a script. No amount of scrolling will show you instructions a person could read.
That file is machine code: raw instructions for the processor, packed in a format only the machine reads comfortably. It started life as readable text written by a person, in a language called C. A tool called a compiler translated that text into the file you cannot read. The Linux kernel was built this way. So was bash itself. So was almost every command you met in Foundations.
This lesson is about that translation step. What a compiler is, how it differs from the way bash runs your scripts, and the small set of tools you will use to do the translating yourself. By the end you will have compiled and run a real program, and you will know exactly what happened when you did.
The black boxes below are a practice terminal: a safe sandbox that checks the one command each step teaches. Every output shown is real, captured from the same Debian 12 machine your labs run on, with gcc 12.2.0 installed. Type the commands yourself. The typing is the learning.
A program is a set of instructions the computer follows. Source code is those instructions written as readable text by a person. The processor cannot run text. Something has to translate. There are two ways to do it, and the split between them shapes everything in this track.
The first way is interpretation. An interpreter is a program that reads your text and performs it, one line at a time, while you wait. This is what bash does with a script: read line one, do it, read line two, do it. The translation happens live, at every single run. Think of a live interpreter at a summit, translating a speech phrase by phrase. Flexible, but the interpreter must be present at every meeting, because nothing is ever written down.
The second way is compilation. A compiler is a program that reads ALL of your text, translates it into machine code once, and writes the result to a new file. That new file is called a binary, or an executable: a program the processor runs directly, with no translator in the room. Think of translating a book once. The translation took effort up front, but now every reader moves at full speed, forever, without the translator.
C is a compiled language. You write text in a file ending in .c, hand it to the compiler, and get back a binary. The text file is the recipe. The binary is the cake. They are two separate files, and mixing them up is the classic beginner trap. You will fall into it later in this lesson, on purpose.
C was created in the early 1970s to build an operating system, Unix, without hand-writing raw processor instructions. That is still exactly what it is for. When a program must be fast, must start instantly, or must talk to memory and hardware directly, engineers reach for C. The Linux kernel is C. So is bash: the interpreter that ran every script you wrote in Foundations is itself a compiled C binary that someone built once, long ago.
Why not just use bash for everything? A bash script pays the interpreter tax on every run: every line is re-translated, every time, and bash cannot touch memory or hardware directly. That is fine for gluing commands together. It is far too slow for a kernel scheduling thousands of tasks a second.
The tools that take you from text to binary are called the toolchain. You need two names today. gcc is the compiler: the GNU Compiler Collection, the standard C compiler on Linux. make is the build runner: a tool that remembers how to compile your project and redoes only what changed. make gets its own lesson next. First, prove both tools are installed.
gcc --version prints a few lines of version and license text. Only the first line matters, so trim it with head, the Foundations tool for taking the top of any output:
prompt: operator@camp:~$ answer: gcc --version | head -1 output: gcc (Debian 12.2.0-14+deb12u1) 12.2.0 hint: Ask gcc for its version and keep the first line: gcc --version | head -1
The compiler answers, so it is installed. The version is 12.2.0, printed twice: once inside the parentheses as part of Debian's exact package name, once at the end as the plain version. On a machine where this command fails, there is no C compiler, and nothing else in this track would work there.
prompt: operator@camp:~$ answer: make --version | head -1 output: GNU Make 4.3 hint: Same trick, different tool: make --version | head -1
GNU Make 4.3 is standing by. You will not need it today, because today's programs are one file each. It earns its keep the moment a project has many files and you stop being able to remember which ones changed. Forward reference: the next lesson puts it to work.
Time for a head-to-head. The same tiny job, done both ways. Your practice directory already holds this two-line script:
#!/bin/bash echo "hello, machine"
Line one names the interpreter this text is meant for. Line two is echo, straight from Foundations. Nothing here is new. The question is what actually does the work when it runs.
>>> hello.sh is only text, and text cannot execute. The processor never sees it. bash, the interpreter, opens the file, reads line one, performs it, reads line two, performs it. The work happens inside bash. The script is a to-do list, and bash is the one doing.
prompt: operator@camp:~$ answer: bash hello.sh output: hello, machine hint: Hand the script to the interpreter by name: bash hello.sh
The output arrived, but look at what it cost. bash translated both lines at the moment you pressed Enter. Run the script a thousand times and bash translates it a thousand times. And hello.sh is still exactly what it was: a text file. Open it tomorrow and it reads the same. Interpreted means the translation never sticks. It is redone, live, at every run.
Now the same job in C. Your practice directory also holds this file:
#include <stdio.h>
int main(void) { printf("hello, machine\n"); return 0; }
Six lines, four new ideas, each getting one sentence now and a full lesson soon. #include <stdio.h> pulls in the standard input and output toolbox, which is where printf lives. int main(void) marks the starting point: every C program begins running at main. printf prints text, and the \n at the end is a newline character. return 0; hands the shell an exit code of 0, the number you read with $? in Foundations. Notice that each instruction ends with a semicolon. C insists on them, and forgetting one is about to matter.
Before the compiler enters the picture, try the tempting move. bash just ran hello.sh by reading it line by line. If C were only bash with more speed, the same trick should work here: hand hello.c to bash the same way.
>>> bash speaks exactly one language: bash. It does not translate C, slowly or otherwise, and it pays no attention to file names, so the .c ending does not stop it from trying. It opens the file, skips line 1 because # starts a comment in bash, and hits int main(void) { on line 3. That is not a bash sentence. It stops there with a syntax error, and no program ever runs.
prompt: operator@camp:~$ answer: bash hello.c output: hello.c: line 3: syntax error near unexpected token (' hello.c: line 3: int main(void) {' hint: Hand the C file to bash exactly like the script: bash hello.c
There is the answer to a question every beginner quietly asks: why can't I just run the .c file? Because C is not a faster bash. It is a different language, and no interpreter on this machine speaks it. bash read your C text with bash eyes, found a ( where its own grammar allows none, printed the offending line back at you, and quit. C source is text that NOTHING can run directly: not bash, not the processor. It has to be translated first, and that is the compiler's entire job.
>>> A compiler is a translator, not a runner. gcc read hello.c, translated it into machine code, and wrote the result to a file named hello, because -o names the output file. It never ran the program. And like most Unix tools, it says nothing when all is well. Silence is success.
prompt: operator@camp:~$ answer: gcc -Wall hello.c -o hello output: hint: The compiler, warnings on, the source file, then -o and the output name: gcc -Wall hello.c -o hello
Silence, which from a compiler means success. The -Wall flag asks gcc to warn about suspicious code, and you should type it every single time, starting today. There was nothing to warn about here. The real product is now sitting in your directory: a new file named hello. It is not text.
Every Linux binary opens with the same four-byte signature, called the ELF magic. Bytes two through four spell the letters E, L, F: Executable and Linkable Format, the container every Linux binary ships in. When a file starts with these bytes, you are looking at machine code, not text. Here are the first four bytes of your new hello:
{ "kind": "memory", "cells": [ { "addr": "0x00", "bytes": "7f", "label": "marker byte" }, { "addr": "0x01", "bytes": "45", "label": "the letter E" }, { "addr": "0x02", "bytes": "4c", "label": "the letter L" }, { "addr": "0x03", "bytes": "46", "label": "the letter F" } ] }
prompt: operator@camp:~$ answer: ./hello output: hello, machine hint: Run a program in the current directory with the dot slash prefix: ./hello
Same one line of output as the script, but the path it took was completely different. No interpreter woke up. The shell asked the kernel to load hello, and the processor ran its instructions directly. gcc was not involved and does not even need to exist on the machine anymore. The ./ prefix is the Foundations rule about $PATH: the shell does not search the current directory for commands, so you point at the file explicitly.
Interpreted and compiled differ in one more way, and for working engineers it is the most important one: WHEN mistakes get caught. Your practice directory holds one more file. It has a deliberate flaw on the highlighted line: the return lost its semicolon.
#include <stdio.h>
int main(void) { printf("hello, machine\n"); return 7 }
One honest side note: this file returns 7 instead of 0. An exit code of 7 is legal, just unusual, and the next lesson covers exit codes in full. Today the semicolon is the story.
>>> The compiler reads the whole file before it produces anything. It cannot translate an instruction with no ending, so it stops, names the file, the line, and the column, and writes no output file. There is nothing to run. The mistake never got the chance to happen.
prompt: operator@camp:~$ answer: gcc -Wall broken.c -o broken output: broken.c: In function 'main': broken.c:5:13: error: expected ';' before '}' token 5 | return 7 | ^ | ; 6 | } | ~ hint: Same compile shape as before, new file names: gcc -Wall broken.c -o broken
Read the error the way gcc wrote it. broken.c:5:13 is the file, the line, and the column where the compiler got stuck. expected ';' before '}' token says what it wanted and what it found instead. The caret points at the exact spot, just past return 7, and directly under the caret gcc prints the ; it wanted you to type there. The last two lines show line 6, where it actually ran into the }. Now compare this to bash. Put a typo on line 90 of a bash script and bash happily runs lines 1 through 89, doing real work, before dying at the flaw. The compiler proofreads the entire letter before it lets you send anything. For code that manages disks, memory, or money, catching mistakes before the program even exists is not a luxury.
Here is the trap promised at the start. hello.c has been edited: one word changed on the highlighted line. Nothing else was touched, and nobody ran gcc.
#include <stdio.h>
int main(void) { printf("hello, camp\n"); return 0; }
>>> The binary named hello is a finished translation, frozen at the moment gcc wrote it. It carries no link back to hello.c and never checks it. Editing the recipe does not change the cake already on the plate.
prompt: operator@camp:~$ answer: ./hello output: hello, machine hint: Run the existing binary in the current directory: ./hello
The old text. Your edit is sitting in hello.c, and hello.c is not what runs. This stale-binary moment catches every C beginner and plenty of veterans: you fix the code, run the program, and the fix is not there, because you skipped the translation step. An edit reaches the binary through exactly one door: gcc.
prompt: operator@camp:~$ answer: gcc -Wall hello.c -o hello && ./hello output: hello, camp hint: Chain compile and run with the Foundations && operator: gcc -Wall hello.c -o hello && ./hello
There is the edit. && is the Foundations chain: run the second command only if the first one succeeded, which here means run the program only if it compiled. This compile-and-run line is the heartbeat of C work, and you will type it hundreds of times in this track. When projects grow past one file, remembering what needs recompiling becomes a job of its own. That job has a tool, make, and this trap is a big part of why make exists.
One more push on this idea, because it settles which file is the program. The binary now prints hello, camp, and hello.c also says hello, camp, so right now they agree and you cannot tell them apart by output. Break the tie the brutal way: delete the source.
>>> The binary is the finished translation, complete in itself. It holds machine code, not a link back to hello.c, and it never reads the source at run time. So nothing is missing when the source disappears, and there is no old version to fall back to. Deleting the recipe does not un-bake the cake.
prompt: operator@camp:~$ answer: rm hello.c && ./hello output: hello, camp hint: Remove the source file, then run the binary: rm hello.c && ./hello
The source is gone and the program does not care. This answers the cold open. /bin/ls runs on every Linux machine on earth, and almost none of them have the C source for ls installed anywhere. The .c file is not the program. It is the text the program was made FROM, and once gcc has done its work, the binary stands alone. One warning to carry out of this: the door swings both ways. gcc can always rebuild the binary from the source, but nothing can give you back readable source from a binary, so in real work the source file is the one you guard.
Three messages will greet you constantly in your first weeks of C. Learn to read them calmly.
error: expected ';' before '}' token is gcc telling you an instruction has no ending. The subtle part: gcc points at where it NOTICED the problem, which can be past the spot where the semicolon belongs. What to check first: the end of the line gcc names, then the end of the line above it. In broken.c the missing semicolon belonged right after return 7, and the caret sat one column past it.
bash: ./hello: No such file or directory means the shell found no file named hello to run. Nearly always the compile failed and you missed the error, or -o wrote the binary under a different name. What to check first: scroll up to the gcc output, then ls to see what actually got created.
bash: hello: command not found means you dropped the ./. The shell searched $PATH for a command named hello, and your current directory is not on $PATH, exactly as you learned in Foundations. What to check first: nothing is broken. Run it as ./hello.
Depending on how you logged in, the shell may print -bash instead of bash in front of the last two messages. The part after the first colon is the part that matters.
Scaffolding off. No command is shown in this step.
The sandbox has been reset. hello.c is back to printing hello, machine, and there is no binary in the directory. Take the source all the way to running output in a single chained line. Compile it with warnings on, name the binary hello, and run it, with the run happening only if the compile succeeds.
prompt: operator@camp:~$ answer: gcc -Wall hello.c -o hello && ./hello output: hello, machine hint: The compiler, -Wall, the source file, -o and the output name, then && and ./hello
gcc -Wall hello.c -o hello && ./hello is the whole compiled world in one line. Translate once, and if the translation succeeded, run the result. Every C lesson from here on assumes this line is in your fingers.
You earned this table. Every row is something you just did or watched happen:
Practice What C is in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.