Learn › Learn C › C Foundations
gcc - a hands-on Linux lab on a real virtual machine.
Write, compile, and run your first C programs. main is the entry point, printf speaks to the screen, gcc -Wall builds the binary, and ./hello runs it. Receive command line words through argc and argv, hand a number back to the shell with return, and read it with echo $?. Includes your first real compiler error, a missing semicolon, how to read the gcc message like a sentence, and the trap of a program that compiles with a warning and still prints garbage.
The last lesson left two loose ends. A missing semicolon made gcc refuse an entire program. And that program ended with an odd return 7 you were promised an explanation for. Both threads finish here. Meet the failure again, boiled down to its bones: a broken.c whose only job is to end and hand the number 7 back to the shell. Ask gcc, the compiler from the last lesson, to build it, and it answers with this:
broken.c: In function 'main':
broken.c:5:13: error: expected ';' before '}' token
5 | return 7
| ^
| ;
6 | }
| ~
You already know what the refusal means: no binary was produced, and nothing ran. But look closer at the message itself. gcc names an exact line and column, aims a caret, and prints three more lines under it that most beginners skip right past. There is a repair suggestion hiding in there.
By the end of this lesson you will read that whole message like a sentence, and the number 7 will finally have a job. Along the way you will pull apart the hello.c you compiled last lesson, line by line, then write two new programs: one that reads what you typed after its name, and one that speaks to the shell in numbers.
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 actual lab machine: Debian 12 with gcc 12.2.0, the same image the module lab boots for you. The source files shown in the code panels are already saved in the sandbox home directory, so you can compile them straight away.
A C program is a collection of functions. A function is a named block of code that does one job. You will write many functions eventually. But one of them is special, and its name is not negotiable: main.
main is the entry point: the one place where every C program begins. When you run a compiled program, the operating system loads it into memory, jumps to main, and executes its statements from top to bottom. A statement is one complete instruction, and every statement ends with a semicolon, the way a sentence ends with a period.
Think of a building with exactly one front door. However large the building grows, whatever rooms it gains, visitors always enter through that one door. main is the front door. And when main finishes, the program is over: the number it returns walks out the door with it.
Why care about one function's name? Because every command you used in Linux Foundations is a C program with a main. ls has one. cat has one. grep, mkdir, bash itself: each begins at its own main.
That fact connects three things you already do daily to what you are about to write. When you typed ls -l /etc, the words -l and /etc arrived inside ls through the arguments of its main. When you chained commands with &&, the shell was reading the number each program's main returned. Printing, arguments, exit codes: this lesson is the other side of conversations you have been having with C programs all along.
Here is hello.c, the program you compiled in the last lesson, with one cosmetic change: the opening brace now sits on its own line. C does not mind; the compiler reads symbols, not layout, and both styles are common. Last time each line got one sentence on faith. Now read it top to bottom; every line is explained below the panel.
#include <stdio.h>
int main(void) { printf("hello, machine\n"); return 0; }
Line 1: #include <stdio.h> pulls in the standard input and output library, the collection of ready-made code where printf lives. Without it, the compiler does not know what printf is.
Line 3: int main(void) declares the function. int says main hands back a whole number when it finishes. void says it takes no inputs, for now. The braces { and } fence off the body: the statements that actually run.
Line 5: printf prints text to the terminal. It sends exactly what sits between the double quotes. The two characters \n are not printed as letters; together they mean newline, the invisible character that ends a line. Line 6: return 0; ends main and reports success.
>>> printf prints only what is between the quotes, so no code appears on screen. And \n is not two letters: the backslash marks a special character, and \n together means start a new line. The screen shows hello, machine, then the cursor moves down one line.
Now build it. The command has two halves chained with && from Foundations: compile, and if that succeeds, run.
prompt: operator@camp:~$ answer: gcc -Wall hello.c -o hello && ./hello output: hello, machine hint: The full line is gcc -Wall hello.c -o hello && ./hello
Break the command apart; every piece is a callback. gcc is the compiler. -Wall turns on all common warnings, the habit you started last lesson: warnings are the compiler flagging mistakes it can survive but you cannot. hello.c is the input. -o hello names the output binary; without -o, gcc picks the default name a.out. Then &&, straight from Foundations, runs ./hello only if the compile succeeded, the same chained shape you built in the last lesson's challenge. The ./ is the usual $PATH rule: the shell does not search your current directory. One more detail: gcc printed nothing before your program ran. Silence from a compiler means success.
One piece of that command deserves the full treatment, because every beginner eventually tries to skip it. The binary is named hello and it is sitting right there in your current directory. ls runs without any ./ in front. So the tempting shortcut is obvious: just type the name.
>>> Tempting to expect it to run, because ls works exactly that way. But when you type a bare name, the shell searches only the directories listed in its $PATH variable, and your current directory is not on that list. ls is found because it lives in /usr/bin, which is on the list; your hello is not in any of those places. And the shell never asks for confirmation: it searches, fails, and reports command not found.
prompt: operator@camp:~$ answer: hello output: bash: hello: command not found hint: Type just hello, with no ./ in front. The failure is the point.
command not found, for a file you can see with ls. This is the moment that confuses most beginners, and the fix is remembering what a bare name means to the shell. Remember $PATH from Foundations: it is the list of directories the shell searches when you type a bare command name, and the current directory is deliberately not on it, so a stray file in whatever directory you happen to stand in can never hijack a command name. Writing ./hello skips the search entirely: . means the current directory, so ./hello is a path, and the shell follows paths instead of searching for them. The rule in one sentence: a bare word asks the shell to search $PATH, while anything with a / in it names the exact file to run.
The tradition of making a first program print a short greeting comes from the 1978 book The C Programming Language, written by the language's creators, Brian Kernighan and Dennis Ritchie.
You have passed arguments to programs your whole Foundations life: ls -l, cat notes.txt, grep root /etc/passwd. Now stand on the receiving side. How does a program see what you typed after its name?
Through a second form of main. The sandbox has this new version of hello.c saved and ready.
#include <stdio.h>
int main(int argc, char *argv[]) { printf("argc = %d, argv[0] = %s\n", argc, argv[0]); return 0; }
Two new inputs appear between the parentheses. argc is the argument count: how many words were on the command line. argv is the argument vector: the list of those words, stored as text. Vector is an old word for list, and the square brackets mark it as one. How such lists really work under the hood is the pointers lesson, coming soon.
printf grew too. %d is a placeholder for a whole number and %s is a placeholder for text. printf fills the placeholders, in order, from the extra values listed after the quotes: argc first, then argv[0], the first entry in the list. Counting starts at zero in C, a habit you will meet everywhere.
>>> The program's own name counts. Entry 0 of the list is ./hello, exactly as you typed it. Entry 1 is linux and entry 2 is camp. Three words on the line means argc is 3, and argv[0] is the name, never the first extra word.
prompt: operator@camp:~$ answer: gcc -Wall hello.c -o hello && ./hello linux camp output: argc = 3, argv[0] = ./hello hint: Same compile as before, then run ./hello with the two words linux camp after it.
argc = 3 because the command line held three words, and the program's own name is word zero. This trips up most engineers at first: a program run with two arguments sees a count of three. Meanwhile argv[1] holds linux and argv[2] holds camp; the program simply did not print them. One more thing to notice: everything in argv arrives as text. If you type 42 on a command line, the program receives the characters 4 and 2, not the number forty-two. Converting argument text into real numbers is exactly what the module lab will ask of you.
A program has two voices. printf speaks to the human watching the screen. The return value of main speaks to the shell, silently. You have used that silent voice from the listening side already: && runs the next command only when the previous one reported success, and $? holds the last reported number. Now speak it yourself.
Here is exit7.c. It prints nothing. It only returns:
#include <stdio.h>
int main(void) { return 7; }
>>> return is not printf. It hands the number 7 to the shell and ends the program without drawing a single character. To see the number, you have to ask the shell for it afterward.
prompt: operator@camp:~$ answer: gcc -Wall exit7.c -o exit7 && ./exit7 output: hint: gcc -Wall exit7.c -o exit7 && ./exit7, and expect no output at all.
prompt: operator@camp:~$ answer: echo $? output: 7 hint: The shell variable holding the last exit code is $?, printed with echo.
There it is. The 7 from return 7; traveled out of main, through the operating system, and into the shell's $? variable. This number is the exit code, and it follows a convention the whole system relies on: 0 means success, anything else signals a problem. That is why return 0; ended your first two programs. Two gotchas to lock in. First, $? is overwritten by every command you run, so read it immediately. Second, an exit code fits in the range 0 to 255; the shell keeps only the low 8 bits of whatever main returns, so keep your codes small.
Time to resolve the cold open. broken.c is exit7.c with exactly one character removed: the semicolon at the end of line 5. Compare them; the highlighted line is the only difference.
#include <stdio.h>
int main(void) { return 7 }
Ask gcc to build it, and watch what one missing character does.
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: Compile it the usual way: gcc -Wall broken.c -o broken. The error IS the lesson.
Now read the message like a sentence, piece by piece. broken.c: In function 'main': names where gcc was working. broken.c:5:13: is the address of the problem: file, line 5, column 13. error: expected ';' before '}' token is the complaint: gcc finished reading return 7, went looking for the semicolon that must end the statement, and ran into the closing brace } instead. The next two lines reprint the source line and aim a caret ^ at the exact column. Then comes the part beginners skip: the line under the caret shows a ; sitting exactly where one would fix the problem. That is a fix-it hint, and gcc prints one whenever it is confident about the repair. The last two lines show line 6, the brace where the hunt actually ended, marked with a tilde ~. One habit to build now: the compiler reports where it NOTICED the problem, which is at or just after where you made it. Read the reported line first, then the line above it. And note the consequence: an error, unlike a warning, means no binary was produced at all.
The missing semicolon was an error: the build stopped and no binary appeared. gcc has a second, softer voice: the warning. A warning does not stop the build. The binary appears, it runs, and that is exactly what makes it dangerous, because most beginners conclude that compiled means correct. Time to break that idea before it settles in. The sandbox has lucky.c saved; look closely at its printf.
#include <stdio.h>
int main(void) { printf("the answer is %d\n"); return 0; }
You know %d from the argc program: a placeholder for a whole number, filled from the values listed after the quotes. Now look after the quotes here. There is no list. The placeholder has nothing to fill it.
prompt: operator@camp:~$ answer: gcc -Wall lucky.c -o lucky output: lucky.c: In function 'main': lucky.c:5:28: warning: format '%d' expects a matching 'int' argument [-Wformat=] 5 | printf("the answer is %d\n"); | ~^ | | | int hint: The usual compile: gcc -Wall lucky.c -o lucky. Read what comes back carefully.
Read the first word after the location: warning, not error. gcc pointed at the %d, said it expects a matching whole number, and even noted the type it wanted, int. Then it built the binary anyway. Run ls and a fresh lucky is sitting there. It compiled. So it works?
>>> There is no rule that an empty placeholder becomes 0; C does not fill in polite defaults. And it does not crash here either, which is its own kind of bad luck, because a crash would at least get your attention. printf trusts the %d and reaches for a value you never supplied, so it grabs whatever leftover number happens to be lying where that value should have been, and prints it. Garbage, wearing a nice suit.
prompt: operator@camp:~$ answer: ./lucky && ./lucky output: the answer is 1451477480 the answer is -493137384 hint: Run it twice in one line with the && chain: ./lucky && ./lucky
Two runs, two different numbers, neither one supplied by you. Your own runs will print different garbage again; the leftover value changes from run to run. That pins down what compiling actually proves. An error means gcc could not translate your file, so there is no binary. A warning means the translation succeeded but the meaning looks wrong: the binary exists AND misbehaves. That is the whole case against the most expensive sentence in C, it compiled so it works. Compiling proves your grammar, not your meaning, and the warning was gcc telling you the meaning was broken. This is why -Wall goes on every compile in this camp, and why you fix every warning before you run anything.
Three messages will dominate your first weeks of C. You have met the first already.
error: expected ';' before '}' token
What it means: a statement is missing its ending semicolon, and gcc hit something else while hunting for it. What to check first: the line number in the message, then the line directly above it. The missing semicolon is almost always on one of those two lines.
bash: ./hello: No such file or directory
What it means: this one comes from the shell, not gcc; you met it in the last lesson's error list too, because it never stops being common. You asked to run a binary named hello in the current directory and there is no such file. Usually the compile failed and never produced it, or your -o named it something else. What to check first: scroll up for compile errors, then run ls to see which binaries actually exist.
warning: implicit declaration of function 'printf' [-Wimplicit-function-declaration]
What it means: you called printf but the compiler never saw its declaration, which almost always means the #include <stdio.h> line is missing from the top of your file. Note the word warning: gcc still produces a binary, and on this machine the program even happens to run. Do not trust that luck; the C language makes no promise about calling a function the compiler was never told about. gcc even prints the fix in a note directly underneath: include '<stdio.h>' or provide a declaration of 'printf'. What to check first: the top of the file. Every function you call must have its #include.
gcc separates warnings from errors. An error stops the build: no binary. A warning still builds the binary but is telling you something looks wrong. With -Wall on, treat every warning as a bug report addressed to you, and fix it before moving on.
Scaffolding off. No command is shown in this step.
The semicolon is back: broken.c in the sandbox is now fixed, identical to exit7.c right down to its return 7;. Your task, in one line: compile it with all warnings on, name the binary broken, and run it only if the compile succeeds. You know the compiler, the warnings flag, the output-name flag, and the chaining operator.
prompt: operator@camp:~$ answer: gcc -Wall broken.c -o broken && ./broken output: hint: The shape is gcc -Wall broken.c -o broken && ./broken
It ran in silence, which you now know proves nothing is wrong. Prove what it handed back to the shell:
prompt: operator@camp:~$ answer: echo $? output: 7 hint: echo $? prints the exit code of the last command.
You compiled a C source file, produced a binary, ran it, and read its exit code,
Practice Hello, machine in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.