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:
#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 asprintfandscanf). -
int main(): the program's main function. Every C program starts executing from the first line insidemain. Theintdeclares that the function returns an integer. -
printf("..."): the function that writes to the screen. The\ncharacter 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) anddouble(typically 8 bytes). -
Characters:
char(always 1 byte), holding a single character or a small integer.
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.
#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).
&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.
#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
&β forint,float,doubleandcharyou must pass the address with&. Arrays (for examplechar 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
\nin the buffer after%d/%fβ when you press Enter after typing a number, the newline character stays in thestdinbuffer. If the nextscanf("%c")has no leading space, it reads that\ninstead of waiting for a new character. The fix: usescanf(" %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 tosigned. -
signedβ stores both negative and positive values (the default forint).
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:
#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.
stdint.h?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:
#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:
#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)valuesyntax.
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:
#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.
int a = 7; int b = 2; printf("%f", (double)a / b); β what does it print?
Comments