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

FlagPurpose
-WallEnable most warnings
-WextraEnable even more warnings
-std=c17Use the C17 standard
-pedanticReject non-standard extensions
-o nameName 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 -Wextra now saves pain later.

Try It: Change the return 0; to return 42;. Recompile, run, then check echo $?. 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 #include directives. Modules, use statements, 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.c and open hello.s. Find the call instruction that invokes printf. On x86-64 Linux it will look something like call 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 commandPurpose
cargo new nameCreate a new project
cargo buildCompile (debug mode)
cargo build --releaseCompile with optimizations
cargo runBuild and run
cargo checkType-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 printf is undefined behavior. For example, printf("%d\n", 3.14) will print garbage. The compiler cannot always catch this because printf is 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 #include and header guards is not optional.

Quick Knowledge Check

  1. What does return 0; in C's main tell the operating system?
  2. Why does println! have an exclamation mark?
  3. What gcc flag enables most compiler warnings?

Common Pitfalls

  • Forgetting \n in printf. 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". Write int main(void) to mean "no parameters".
  • Using rustc for multi-file projects. Use cargo instead. rustc works for single files only.
  • Ignoring compiler warnings. Both gcc -Wall and rustc produce warnings for a reason. Treat warnings as errors during learning (-Werror in gcc, #![deny(warnings)] in Rust).
  • Mixing up printf format specifiers. %d for int, %ld for long, %zu for size_t. Getting them wrong is undefined behavior in C.