Hello from C, Hello from Rust
Every systems programmer's journey starts the same way: make the machine say something. In this chapter you will write, compile, and run your first program in both C and Rust, and you will see how the two languages differ before a single line of logic appears.
Your First C Program
Create a file called hello.c:
/* hello.c -- the smallest useful C program */
#include <stdio.h>
int main(void)
{
printf("Hello from C!\n");
return 0;
}
Compile and run it:
$ gcc -Wall -o hello hello.c
$ ./hello
Hello from C!
Let us walk through every piece.
#include <stdio.h> -- This is a preprocessor directive. Before the compiler ever
sees your code, a separate tool (the C preprocessor) pastes the entire contents of
stdio.h into your file. That header declares printf and hundreds of other I/O
functions. Without it, the compiler does not know what printf is.
int main(void) -- The entry point. The operating system's C runtime calls main
after setting up the process. It returns int because the OS expects an exit code.
void in the parameter list means "no arguments" (in C, empty parentheses mean
"unspecified arguments", which is different).
printf("Hello from C!\n") -- Writes a string to standard output. The \n is a
newline character. printf is a variadic function; it accepts a format string followed
by zero or more arguments. We will use it heavily.
return 0; -- Exit code 0 means success. Any non-zero value signals an error.
The shell stores this value in $?.
$ ./hello
Hello from C!
$ echo $?
0
The gcc flags you should always use
| Flag | Purpose |
|---|---|
-Wall | Enable most warnings |
-Wextra | Enable even more warnings |
-std=c17 | Use the C17 standard |
-pedantic | Reject non-standard extensions |
-o name | Name the output binary |
A solid default:
$ gcc -Wall -Wextra -std=c17 -pedantic -o hello hello.c
Driver Prep: Kernel modules are compiled with an even stricter set of warnings. Getting comfortable with
-Wall -Wextranow saves pain later.
Try It: Change the
return 0;toreturn 42;. Recompile, run, then checkecho $?. What do you see?
Your First Rust Program
Create a file called hello.rs:
// hello.rs -- the smallest useful Rust program fn main() { println!("Hello from Rust!"); }
Compile and run it:
$ rustc hello.rs
$ ./hello
Hello from Rust!
fn main() -- Rust's entry point. No return type is written because main
implicitly returns () (the unit type, similar to void). No header includes, no
preprocessor. The compiler already knows about println!.
println!("Hello from Rust!") -- The ! marks this as a macro, not a function.
Macros in Rust are expanded at compile time. println! handles formatting, type
checking of arguments, and writes to stdout with an appended newline.
There is no explicit return 0. Rust's main returns exit code 0 on success
automatically. If you want to return a custom exit code:
// hello_exit.rs -- returning a custom exit code use std::process::ExitCode; fn main() -> ExitCode { println!("Hello from Rust!"); ExitCode::from(0) }
Rust Note: Rust does not have a preprocessor. There are no
#includedirectives. Modules,usestatements, and the compiler's built-in knowledge of the standard library replace that entire mechanism.
The Compilation Model
C and Rust compile your source code down to native machine code, but the journey is different.
C compilation pipeline
+-------------+
hello.c ----->| Preprocessor|----> hello.i (expanded source)
+-------------+
|
+-------------+
| Compiler |----> hello.s (assembly)
+-------------+
|
+-------------+
| Assembler |----> hello.o (object file)
+-------------+
|
+-------------+
| Linker |----> hello (executable)
+-------------+
You can see each stage:
$ gcc -E hello.c -o hello.i # preprocess only
$ gcc -S hello.c -o hello.s # compile to assembly
$ gcc -c hello.c -o hello.o # assemble to object file
$ gcc hello.o -o hello # link
Rust compilation pipeline
+-----------+
hello.rs ----->| rustc |----> hello (executable)
| (frontend |
| + LLVM |
| backend) |
+-----------+
rustc handles everything in one invocation. Internally it parses, type-checks,
performs borrow checking, generates LLVM IR, and invokes LLVM to produce machine code.
There is no separate preprocessor or linker step visible to the user (though a linker
is invoked behind the scenes).
Try It: Run
gcc -S hello.cand openhello.s. Find thecallinstruction that invokesprintf. On x86-64 Linux it will look something likecall printf@PLT.
Cargo: Rust's Build System
For anything beyond a single file, Rust programmers use Cargo.
$ cargo new hello_project
Created binary (application) `hello_project` package
$ cd hello_project
$ tree .
.
├── Cargo.toml
└── src
└── main.rs
src/main.rs already contains:
fn main() { println!("Hello, world!"); }
Build and run:
$ cargo build
Compiling hello_project v0.1.0
Finished dev [unoptimized + debuginfo] target(s)
$ cargo run
Hello, world!
| Cargo command | Purpose |
|---|---|
cargo new name | Create a new project |
cargo build | Compile (debug mode) |
cargo build --release | Compile with optimizations |
cargo run | Build and run |
cargo check | Type-check without producing a binary |
C has no official build system. Projects use Makefiles, CMake, Meson, or plain shell scripts. Here is a minimal Makefile for our hello program:
# Makefile
CC = gcc
CFLAGS = -Wall -Wextra -std=c17 -pedantic
hello: hello.c
$(CC) $(CFLAGS) -o hello hello.c
clean:
rm -f hello
$ make
gcc -Wall -Wextra -std=c17 -pedantic -o hello hello.c
$ make clean
rm -f hello
Driver Prep: The Linux kernel uses its own Kbuild Makefile system. Understanding basic Make targets (
all,clean,modules) is essential for kernel module work.
printf vs println!
The two are deceptively similar but work very differently under the hood.
C: printf
/* printf_demo.c */
#include <stdio.h>
int main(void)
{
int x = 42;
double pi = 3.14159;
char ch = 'A';
printf("integer: %d\n", x);
printf("float: %.2f\n", pi);
printf("char: %c\n", ch);
printf("hex: 0x%08x\n", x);
return 0;
}
$ gcc -Wall -o printf_demo printf_demo.c && ./printf_demo
integer: 42
float: 3.14
char: A
hex: 0x0000002a
printf format specifiers: %d (int), %f (double), %c (char), %s (string),
%x (hex), %p (pointer), %zu (size_t). Use the wrong one and you get undefined
behavior -- the compiler may warn you, but it is not required to.
Caution: Passing the wrong type to
printfis undefined behavior. For example,printf("%d\n", 3.14)will print garbage. The compiler cannot always catch this becauseprintfis a variadic function with no type information in its signature.
Rust: println!
// println_demo.rs fn main() { let x: i32 = 42; let pi: f64 = 3.14159; let ch: char = 'A'; println!("integer: {}", x); println!("float: {:.2}", pi); println!("char: {}", ch); println!("hex: {:#010x}", x); }
$ rustc println_demo.rs && ./println_demo
integer: 42
float: 3.14
char: A
hex: 0x0000002a
println! uses {} as the default placeholder. Formatting traits (Display, Debug)
determine how a type is printed. The compiler checks at compile time that every argument
matches a placeholder and implements the required trait.
Rust Note: You cannot pass the wrong type to
println!. It is a compile-time error, not undefined behavior. The macro expands into code that the type checker validates before any binary is produced.
Return Codes and Error Signaling
Both languages use the process exit code to signal success or failure to the OS.
/* exit_codes.c */
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
if (argc < 2) {
fprintf(stderr, "Usage: %s <name>\n", argv[0]);
return EXIT_FAILURE; /* defined as 1 in stdlib.h */
}
printf("Hello, %s!\n", argv[1]);
return EXIT_SUCCESS; /* defined as 0 */
}
$ gcc -Wall -o exit_codes exit_codes.c
$ ./exit_codes
Usage: ./exit_codes <name>
$ echo $?
1
$ ./exit_codes Alice
Hello, Alice!
$ echo $?
0
The Rust equivalent:
// exit_codes.rs use std::env; use std::process; fn main() { let args: Vec<String> = env::args().collect(); if args.len() < 2 { eprintln!("Usage: {} <name>", args[0]); process::exit(1); } println!("Hello, {}!", args[1]); }
$ rustc exit_codes.rs
$ ./exit_codes
Usage: ./exit_codes <name>
$ echo $?
1
$ ./exit_codes Alice
Hello, Alice!
$ echo $?
0
eprintln! writes to stderr, just like fprintf(stderr, ...) in C.
The Edit-Compile-Run Cycle
Both languages follow the same workflow:
+------+ +---------+ +-----+
| Edit | ---> | Compile | ---> | Run |
+------+ +---------+ +-----+
^ |
| (fix bugs) |
+------------------------------+
In C the cycle is: edit hello.c, run gcc, run ./hello.
In Rust the cycle is: edit src/main.rs, run cargo run (which compiles and runs).
Rust's cargo check lets you skip code generation entirely when you only want to see
if your code type-checks. This is faster than a full build and useful during
development.
$ cargo check
Checking hello_project v0.1.0
Finished dev [unoptimized + debuginfo] target(s)
Multiple Source Files
A real C project splits code across files. Here is a minimal two-file example.
/* greet.h */
#ifndef GREET_H
#define GREET_H
void greet(const char *name);
#endif
/* greet.c */
#include <stdio.h>
#include "greet.h"
void greet(const char *name)
{
printf("Hello, %s!\n", name);
}
/* main.c */
#include "greet.h"
int main(void)
{
greet("world");
return 0;
}
$ gcc -Wall -c greet.c -o greet.o
$ gcc -Wall -c main.c -o main.o
$ gcc greet.o main.o -o hello
$ ./hello
Hello, world!
In Rust, you create a module:
#![allow(unused)] fn main() { // src/greet.rs pub fn greet(name: &str) { println!("Hello, {}!", name); } }
// src/main.rs mod greet; fn main() { greet::greet("world"); }
$ cargo run
Hello, world!
No header files. No include guards. No separate compilation step. Cargo handles it.
Driver Prep: Kernel modules in C use header files extensively. The kernel headers (
linux/module.h,linux/kernel.h, etc.) declare the interfaces you will call. Understanding#includeand header guards is not optional.
Quick Knowledge Check
- What does
return 0;in C'smaintell the operating system? - Why does
println!have an exclamation mark? - What gcc flag enables most compiler warnings?
Common Pitfalls
- Forgetting
\ninprintf. Output may not appear until the buffer flushes.println!adds the newline automatically. - Empty parentheses in C.
int main()means "unspecified parameters", not "no parameters". Writeint main(void)to mean "no parameters". - Using
rustcfor multi-file projects. Usecargoinstead.rustcworks for single files only. - Ignoring compiler warnings. Both
gcc -Wallandrustcproduce warnings for a reason. Treat warnings as errors during learning (-Werrorin gcc,#![deny(warnings)]in Rust). - Mixing up
printfformat specifiers.%dforint,%ldforlong,%zuforsize_t. Getting them wrong is undefined behavior in C.