Now that Lesson 1 has left you with a working compiler, this lesson covers the anatomy of a C program: how to use variables to store data, the basic data types and their format specifiers, type modifiers, constants, enums, type casting, and how to read input from and write output to the console.

1. The anatomy of a C program

Let's look again at the classic program we compiled and ran in the previous lesson:

hello.c
#include <stdio.h>

int main() {
    printf("Hello, World!\n");
    return 0;
}
  • #include <stdio.h>: a preprocessor directive telling the compiler to pull in the Standard Input/Output library, which holds the standard I/O functions (such as printf and scanf).
  • int main(): the program's main function. Every C program starts executing from the first line inside main. The int declares that the function returns an integer.
  • printf("..."): the function that writes to the screen. The \n character stands for a new line.
  • return 0;: returns the value 0 to the operating system, signalling that the program finished successfully with no errors.

2. Variables and the basic data types

A variable is a name standing for a region of memory that holds data temporarily while the program runs.

In C you must declare a variable's data type explicitly before you use it. The basic types are:

  • Integers: int (typically 4 bytes on modern 32-bit/64-bit machines).
  • Floating point: float (typically 4 bytes) and double (typically 8 bytes).
  • Characters: char (always 1 byte), holding a single character or a small integer.
⚠️ An important note about machine architecture
The actual size of C's data types (except char, which is always 1 byte) is not fixed: it depends on the CPU architecture (8-bit, 16-bit, 32-bit or 64-bit) and on the operating system and compiler. You can consult the standard specification in the C type system documentation, and the details of the data models (LP64, ILP32 and friends) under arithmetic types on cppreference.com. For example, int is 2 bytes on 8-bit/16-bit embedded microcontrollers (such as the Arduino Uno) but 4 bytes on a modern x86/ARM CPU. To write portable code, always measure with the sizeof() operator rather than hard-coding a number.

Those three types are enough to write a first program that carries real data. The snippet below declares one variable of each type and prints it β€” notice that each type comes with a different format specifier inside printf, and section 4 lists all of them.

variables.c
#include <stdio.h>

int main() {
    int age = 18;
    double score = 9.5;
    char grade = 'A';

    printf("Tuoi: %d\n", age);      // %d dung cho so nguyen (int)
    printf("Diem: %.2f\n", score);  // %.2f dung cho so thuc, lay 2 chu so sau dau phay
    printf("Xep loai: %c\n", grade); // %c dung cho ky tu (char)

    return 0;
}

The key point is that the format specifier must match the type: %d goes with int, %.2f goes with a floating point value and rounds to 2 decimal places, %c goes with a single character. Mismatch the pair and the program still compiles but prints garbage β€” the single most common beginner mistake.

3. Reading keyboard input with scanf

To take data typed by the user, we use the scanf function. Note that you have to pass the address of the variable by putting the & (address-of) operator in front of its name. Without the &, the program hits a Segmentation Fault or undefined behaviour (Undefined Behavior).

ℹ️ Three terms that just appeared
Address β€” every variable lives in a memory cell, and that cell has its own number, like a house number. &age means "the house number of the variable age". scanf needs the house number rather than the value, because its job is to write into that cell, not read from it.

Segmentation Fault β€” the operating system catches your program touching memory that does not belong to it and stops the program immediately. In the Terminal you will see Segmentation fault (core dumped). It sounds alarming, but it is actually the friendly kind of failure: it stops right at the mistake.

Undefined Behavior β€” considerably worse. The C standard says "I make no promises about this case", so the program may work today, break next week, or fail only on someone else's machine. There is no error message at all. Most of the warnings in this lesson exist to keep you away from exactly this.
scanf_demo.c
#include <stdio.h>

int main() {
    int age;
    float score;
    char letter;
    char name[50]; // Character array (string): 49 chars max + 1 terminator '\0'

    // Read an integer
    printf("Nhap age: ");
    scanf("%d", &age);   // &age = the address of the variable age

    // Read a floating point number
    printf("Nhap score: ");
    scanf("%f", &score);  // %f cho float, %lf cho double

    // WARNING: after scanf reads a number, the '\n' (Enter) stays in the buffer.
    // A following scanf("%c") would read that '\n' instead of waiting for input.
    // The fix: put a space before %c so it skips any whitespace.
    printf("Nhap mot ky tu: ");
    scanf(" %c", &letter); // The space before %c skips the leftover '\n'

    // Reading a string with %s: no & needed, an array name IS an address.
    // DANGER: bare %s has no length limit -> buffer overflow!
    // Always bound it: %49s (at most 49 chars for a 50-element array).
    printf("Nhap name (khong dau cach): ");
    scanf("%49s", name); // No & for an array, capped at 49 chars

    printf("\n--- Ket qua ---\n");
    printf("Tuoi: %d\n", age);
    printf("Diem: %.2f\n", score);
    printf("Ky tu: %c\n", letter);
    printf("Ten: %s\n", name);

    return 0;
}

Common mistakes with scanf:

  • Forgetting the & β€” for int, float, double and char you must pass the address with &. Arrays (for example char name[50]) are the exception, because an array name already decays into a pointer to its first element.
  • Buffer overflow with %s β€” if the user types a string longer than the array, the data overwrites memory that does not belong to it, which is a serious security bug. Always bound it with %49s (for an array of size 50).
  • A leftover \n in the buffer after %d / %f β€” when you press Enter after typing a number, the newline character stays in the stdin buffer. If the next scanf("%c") has no leading space, it reads that \n instead of waiting for a new character. The fix: use scanf(" %c", &c) with a space before %c.

4. The complete format specifier table

Format specifiers tell printf and scanf which data type is being written or read. The full reference lives at cppreference.com β€” fprintf.

Specifier Data type Description Example
%d int Signed integer (decimal) printf("%d", -42);
%u unsigned int Unsigned integer printf("%u", 42u);
%ld long Signed long integer printf("%ld", 100000L);
%lld long long Very long integer (64-bit) printf("%lld", 9000000000LL);
%f float / double Floating point (printf accepts both float and double) printf("%.2f", 3.14);
%lf double Used in scanf to read a double (for printf, %f is enough) scanf("%lf", &d);
%e float / double Scientific notation printf("%e", 0.00123); β†’ 1.230000e-03
%c char A single character printf("%c", 'A');
%s char* / char[] A string (terminated by \0) printf("%s", "Hello");
%p void* A pointer address (in hex) printf("%p", (void*)&x);
%x unsigned int Integer in hexadecimal printf("%x", 255); β†’ ff
%o unsigned int Integer in octal printf("%o", 8); β†’ 10
%% β€” Prints a literal percent sign printf("100%%"); β†’ 100%

5. Type modifiers and sizeof

C provides type modifiers β€” extra keywords that change the size and value range of the basic integer types. Full details at cppreference.com β€” Arithmetic types.

  • short β€” a short integer, typically 2 bytes (at least 16-bit per the C standard).
  • long β€” a long integer, typically 4 bytes (32-bit) on Windows and 8 bytes (64-bit) on 64-bit Linux/macOS.
  • long long β€” a very long integer, always at least 8 bytes (64-bit) per the C99 standard.
  • unsigned β€” stores non-negative values only (0 and up), doubling the positive range compared to signed.
  • signed β€” stores both negative and positive values (the default for int).

Since the sizes depend on the platform, as we just said, the only way to know for certain is to ask the machine you are running on. The program below prints the real size of each type on your own machine:

sizeof_demo.c
#include <stdio.h>
#include <stdint.h>  // Fixed-width integer types

int main() {
    printf("=== Kich thuoc cac kieu du lieu co ban ===\n");
    printf("char:        %zu bytes\n", sizeof(char));
    printf("short:       %zu bytes\n", sizeof(short));
    printf("int:         %zu bytes\n", sizeof(int));
    printf("long:        %zu bytes\n", sizeof(long));
    printf("long long:   %zu bytes\n", sizeof(long long));
    printf("float:       %zu bytes\n", sizeof(float));
    printf("double:      %zu bytes\n", sizeof(double));
    printf("long double: %zu bytes\n", sizeof(long double));

    printf("\n=== Unsigned variants ===\n");
    printf("unsigned int:       %zu bytes\n", sizeof(unsigned int));
    printf("unsigned long long: %zu bytes\n", sizeof(unsigned long long));

    printf("\n=== Kieu co dinh tu stdint.h (portable) ===\n");
    printf("int8_t:   %zu bytes\n", sizeof(int8_t));
    printf("int16_t:  %zu bytes\n", sizeof(int16_t));
    printf("int32_t:  %zu bytes\n", sizeof(int32_t));
    printf("int64_t:  %zu bytes\n", sizeof(int64_t));
    printf("uint8_t:  %zu bytes\n", sizeof(uint8_t));
    printf("uint16_t: %zu bytes\n", sizeof(uint16_t));
    printf("uint32_t: %zu bytes\n", sizeof(uint32_t));
    printf("uint64_t: %zu bytes\n", sizeof(uint64_t));

    return 0;
}

Run it on a different machine and some of those numbers will change. That is exactly why you should not hard-code a figure (such as "int is 4 bytes") into your code: sizeof() always tells you the truth about the platform you are on, whereas the number you memorised does not.

πŸ’‘ When should you use stdint.h?
When you need code that behaves identically on every platform (portable code), especially in embedded programming, network protocols or binary file handling. Types such as int32_t and uint8_t guarantee an exact size regardless of the CPU architecture. See cppreference.com β€” Fixed width integer types.

6. Constants, enums and type casting

A. Constants: const vs #define

C has two common ways to define a constant β€” a value that never changes:

  • const β€” creates a constant variable with an explicit data type, checked by the compiler. Safer, and easier to debug.
  • #define β€” a preprocessor macro that substitutes text before compilation. It has no data type and occupies no memory, but it can cause subtle bugs if you are not careful.

The difference between the two only shows up when you get it wrong. The snippet below puts them side by side, and the last part deliberately walks into a classic #define trap:

const_vs_define.c
#include <stdio.h>

// Option 1: #define - pure text replacement, NO data type
#define PI 3.14159265
#define MAX_SIZE 100

// Option 2: const - a typed constant, checked by the compiler
const double E = 2.71828182;
const int MAX_STUDENTS = 50;

int main() {
    // PI has no type -> the compiler cannot warn you if you misuse it
    printf("PI = %f\n", PI);

    // E is a double -> the compiler warns you if you pass the wrong type
    printf("E = %f\n", E);

    // The classic #define trap:
    #define SQUARE(x) x * x
    printf("SQUARE(3+1) = %d\n", SQUARE(3+1)); // Prints 7, NOT 16!
    // Because the macro expands to: 3+1 * 3+1 = 3+3+1 = 7
    // The fix: #define SQUARE(x) ((x) * (x))

    return 0;
}

The line worth remembering is that SQUARE(3+1) prints 7, not 16. Because #define only substitutes text and understands no arithmetic, it expands to 3+1 * 3+1, and multiplication binds tighter. That is why the general rule is: use const for constants, and reach for #define only when you really need it.

B. Enums

An enum lets you define a set of named integer constants, which makes code easier to read and maintain. See cppreference.com β€” Enumeration.

The example below uses two enums: one that keeps the default values and one that numbers itself β€” and then uses it inside a switch, where enums pay off most clearly:

enum_demo.c
#include <stdio.h>

// Default values: RED=0, GREEN=1, BLUE=2
enum Color { RED, GREEN, BLUE };

// Custom values: MON=1, TUE=2, ..., SUN=7
enum Weekday { MON = 1, TUE, WED, THU, FRI, SAT, SUN };

int main() {
    enum Color favColor = GREEN;
    enum Weekday today = FRI;

    printf("Mau yeu thich: %d\n", favColor);  // In ra: 1
    printf("Hom nay la thu: %d\n", today);     // In ra: 5

    // Using an enum in a switch statement
    switch (favColor) {
        case RED:   printf("Do\n"); break;
        case GREEN: printf("Xanh la\n"); break;
        case BLUE:  printf("Xanh duong\n"); break;
    }

    return 0;
}

The benefit is obvious in the switch block: case MON: reads as meaning straight away, whereas with case 1: even you will be looking up which day 1 is six months from now. The compiler can also warn you if you forget to handle one of the enum's values.

C. Type casting

Casting means converting a value from one data type to another. C supports two forms:

  • Implicit casting β€” the compiler converts automatically when it needs to, following the "smaller type β†’ larger type" rule (integer promotion). For example int β†’ double.
  • Explicit casting β€” the programmer states it directly with the (new_type)value syntax.

The snippet below gathers all four casting situations into one runnable program: implicit, explicit, data loss when narrowing, and the integer promotion rule. Each block prints its result so you can compare it against the number written in the comment:

type_casting.c
#include <stdio.h>

int main() {
    // === Implicit casting ===
    int a = 5;
    double b = a;  // int -> double: nothing is lost
    printf("a = %d, b = %f\n", a, b);  // b = 5.000000

    // Integer promotion: in a mixed expression, int is promoted automatically
    int x = 5;
    double y = 2.0;
    double result = x + y;  // x is promoted to double before the addition
    printf("x + y = %f\n", result);  // 7.000000

    // === Explicit casting ===
    int numerator = 7;
    int denominator = 2;
    // Without a cast: integer division, the fraction is thrown away!
    printf("7/2 = %d\n", numerator / denominator);           // 3
    // With a cast: real division, the fraction is kept
    printf("7/2 = %f\n", (double)numerator / denominator);   // 3.500000

    // WARNING: casting from a larger type to a smaller one CAN LOSE DATA
    double big = 123456.789;
    int truncated = (int)big;  // The fractional part is gone!
    printf("big = %f, truncated = %d\n", big, truncated);  // 123456

    long long huge = 5000000000LL;  // Beyond int range (max ~2.1 billion)
    int overflow = (int)huge;       // Overflow! The result is unpredictable.
    printf("huge = %lld, overflow = %d\n", huge, overflow);

    // === The integer promotion rule ===
    // char and short are always promoted to int in arithmetic expressions.
    char c1 = 100, c2 = 100;
    // c1 * c2 = 10000, far beyond char range (max 127),
    // but it does NOT overflow: both are promoted to int before multiplying.
    int product = c1 * c2;
    printf("c1 * c2 = %d\n", product);  // 10000

    return 0;
}

The two most memorable lines sit next to each other: 7/2 gives 3, while (double)7/2 gives 3.500000. Same two numbers, separated by exactly one cast β€” because when both sides are int, C performs integer division and throws the remainder away. That is precisely the question in the check below.

πŸ“ Check your understanding β€” Lesson 2
Given the code int a = 7; int b = 2; printf("%f", (double)a / b); β€” what does it print?

Related lessons in this series

Lesson 3: Operators, precedence & bitwise arithmetic Lesson 1: Setting up a C development environment Back to the C series roadmap (Vietnamese)