Sau khi đã cài đặt thành công môi trường lập trình C ở Bài 1, trong bài học này chúng ta sẽ cùng tìm hiểu về cấu trúc cơ bản của một chương trình C, cách sử dụng biến để lưu trữ dữ liệu, các kiểu dữ liệu và format specifiers, type modifiers, hằng số, enum, ép kiểu, và cách nhập xuất giá trị ra màn hình Console.

1. Phân tích cấu trúc một chương trình C

Hãy xem lại đoạn mã nguồn kinh điển mà chúng ta đã chạy ở bài trước:

hello.c
#include <stdio.h>

int main() {
    printf("Hello, World!\n");
    return 0;
}
  • #include <stdio.h>: Chỉ thị tiền xử lý yêu cầu trình biên dịch chèn thư viện Standard Input/Output chứa các hàm nhập xuất dữ liệu chuẩn (như printf, scanf).
  • int main(): Hàm chính của chương trình. Mọi chương trình C khi chạy đều sẽ tìm và bắt đầu thực thi từ dòng đầu tiên bên trong hàm main này. int biểu thị kiểu trả về của hàm là một số nguyên.
  • printf("..."): Hàm xuất dữ liệu ra màn hình. Ký tự \n đại diện cho việc xuống dòng (New line).
  • return 0;: Trả về giá trị 0 cho hệ điều hành, báo hiệu chương trình đã chạy thành công tốt đẹp không có lỗi xảy ra.

2. Biến & Các kiểu dữ liệu cơ bản

Biến (Variable) là tên đại diện cho một phân vùng nhớ trong máy tính dùng để lưu trữ dữ liệu tạm thời khi chương trình chạy.

Trong C, trước khi sử dụng biến, bạn bắt buộc phải khai báo rõ ràng kiểu dữ liệu của biến đó. Các kiểu dữ liệu cơ bản bao gồm:

  • Kiểu số nguyên: int (thường chiếm 4 bytes bộ nhớ trên máy tính 32-bit/64-bit hiện đại).
  • Kiểu số thực (dấu phẩy động): float (thường chiếm 4 bytes) và double (thường chiếm 8 bytes).
  • Kiểu ký tự: char (luôn chiếm 1 byte), dùng để lưu một ký tự hoặc một số nguyên nhỏ.

⚠️ Lưu ý quan trọng về kiến trúc máy tính: Kích thước thực tế của các kiểu dữ liệu trong C (ngoại trừ char luôn luôn là 1 byte) không cố định tuyệt đối mà phụ thuộc vào kiến trúc CPU (8-bit, 16-bit, 32-bit hay 64-bit) và hệ điều hành/trình biên dịch. Bạn có thể tham khảo bảng đặc tả chuẩn tại tài liệu C Type System (Hệ thống kiểu C) và chi tiết về các mô hình dữ liệu (Data models như LP64, ILP32) tại tài liệu Arithmetic types trên cppreference.com. Ví dụ, kiểu int sẽ có kích thước là 2 bytes trên dòng vi điều khiển nhúng 8-bit/16-bit (như Arduino Uno), nhưng là 4 bytes trên CPU x86/ARM hiện đại. Để viết mã nguồn tương thích cao, hãy luôn sử dụng toán tử sizeof() để đo kích thước vùng nhớ thay vì tự nhập số cố định.

Ba kiểu vừa kể trên đủ để viết chương trình đầu tiên có dữ liệu thật. Đoạn dưới đây khai báo mỗi kiểu một biến rồi in ra — hãy để ý mỗi kiểu đi với một mã định dạng khác nhau trong printf, và mục 4 ngay sau sẽ liệt kê đầy đủ các mã đó.

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;
}

Điểm mấu chốt nằm ở chỗ mã định dạng phải khớp kiểu: %d đi với int, %.2f đi với số thực và làm tròn 2 chữ số, %c đi với một ký tự. Ghép sai cặp thì chương trình vẫn biên dịch nhưng in ra số rác — đây là lỗi phổ biến nhất của người mới.

3. Nhập dữ liệu từ bàn phím bằng scanf

Để nhận dữ liệu do người dùng nhập vào từ bàn phím, ta sử dụng hàm scanf. Lưu ý: ta phải truyền địa chỉ của biến vào hàm bằng cách thêm toán tử & (address-of) trước tên biến. Nếu thiếu dấu &, chương trình sẽ gặp lỗi Segmentation Fault hoặc hành vi không xác định (Undefined Behavior).

ℹ️ Ba thuật ngữ vừa xuất hiện
Địa chỉ — mỗi biến nằm ở một ô nhớ, và ô đó có số thứ tự riêng, giống số nhà. &age nghĩa là "số nhà của biến age". scanf cần số nhà chứ không cần giá trị, vì việc của nó là ghi vào đó chứ không phải đọc ra.

Segmentation Fault — hệ điều hành thấy chương trình chạm vào vùng nhớ không phải của nó và dừng chương trình ngay. Trên Terminal bạn sẽ thấy dòng Segmentation fault (core dumped). Nghe đáng sợ, nhưng đây thực ra là loại lỗi dễ chịu: nó dừng ngay tại chỗ sai.

Undefined Behavior — tệ hơn nhiều. Chuẩn C nói "trường hợp này tôi không quy định gì cả", nên chương trình có thể chạy đúng hôm nay, sai vào tuần sau, hoặc chỉ hỏng trên máy người khác. Không có thông báo lỗi nào cả. Phần lớn cảnh báo trong bài này tồn tại là để bạn tránh đúng loại lỗi đó.
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;
}

Các lỗi thường gặp với scanf:

  • Quên dấu &: Với các kiểu int, float, double, char, bạn bắt buộc phải truyền địa chỉ bằng &. Riêng mảng (ví dụ char ten[50]) thì không cần vì tên mảng đã tự động chuyển thành con trỏ tới phần tử đầu tiên.
  • Tràn bộ đệm (Buffer Overflow) với %s: Nếu người dùng nhập chuỗi dài hơn kích thước mảng, dữ liệu sẽ ghi đè vùng nhớ không thuộc về mảng, gây ra lỗi bảo mật nghiêm trọng. Luôn giới hạn bằng %49s (cho mảng kích thước 50).
  • Ký tự \n sót trong buffer sau %d / %f: Khi bạn nhấn Enter sau khi nhập số, ký tự xuống dòng \n vẫn nằm trong bộ đệm stdin. Nếu lệnh scanf("%c") tiếp theo không có khoảng trắng phía trước, nó sẽ đọc ngay ký tự \n đó thay vì đợi người dùng nhập ký tự mới. Giải pháp: dùng scanf(" %c", &c) (thêm dấu cách trước %c).

4. Bảng Format Specifiers đầy đủ

Các format specifier (mã định dạng) dùng trong printfscanf để xác định kiểu dữ liệu cần xuất/nhập. Tham khảo đầy đủ tại cppreference.com - fprintf.

Specifier Kiểu dữ liệu Mô tả Ví dụ
%d int Số nguyên có dấu (decimal) printf("%d", -42);
%u unsigned int Số nguyên không dấu printf("%u", 42u);
%ld long Số nguyên dài có dấu printf("%ld", 100000L);
%lld long long Số nguyên rất dài (64-bit) printf("%lld", 9000000000LL);
%f float / double Số thực dấu phẩy động (printf dùng cho cả float lẫn double) printf("%.2f", 3.14);
%lf double Dùng trong scanf để đọc double (printf thì %f là đủ) scanf("%lf", &d);
%e float / double Ký pháp khoa học (scientific notation) printf("%e", 0.00123);1.230000e-03
%c char Một ký tự đơn printf("%c", 'A');
%s char* / char[] Chuỗi ký tự (kết thúc bằng \0) printf("%s", "Hello");
%p void* Địa chỉ con trỏ (dạng hex) printf("%p", (void*)&x);
%x unsigned int Số nguyên dạng thập lục phân (hexadecimal) printf("%x", 255);ff
%o unsigned int Số nguyên dạng bát phân (octal) printf("%o", 8);10
%% In ra ký tự phần trăm % printf("100%%");100%

5. Type Modifiers & sizeof

C cung cấp các type modifier (từ khóa bổ sung kiểu) để thay đổi kích thước và phạm vi giá trị của các kiểu số nguyên cơ bản. Tham khảo chi tiết tại cppreference.com - Arithmetic types.

  • short — Số nguyên ngắn, thường 2 bytes (tối thiểu 16-bit theo chuẩn C).
  • long — Số nguyên dài, thường 4 bytes (32-bit) trên Windows, 8 bytes (64-bit) trên Linux/macOS 64-bit.
  • long long — Số nguyên rất dài, luôn tối thiểu 8 bytes (64-bit) theo chuẩn C99.
  • unsigned — Chỉ lưu số không âm (0 trở lên), phạm vi dương gấp đôi so với signed.
  • signed — Lưu cả số âm và dương (mặc định cho int).

Vì kích thước phụ thuộc nền tảng như vừa nói, cách duy nhất để biết chắc là hỏi thẳng máy đang chạy. Chương trình dưới đây in kích thước thật của từng kiểu trên chính máy bạn:

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;
}

Chạy thử trên máy khác, bạn sẽ thấy vài con số đổi. Đó chính là lý do không nên viết số cứng (kiểu "int là 4 byte") vào code: sizeof() luôn trả về sự thật của nền tảng đang chạy, còn con số bạn nhớ trong đầu thì không.

Khi nào dùng stdint.h? Khi bạn cần mã nguồn chạy đúng trên mọi nền tảng (portable code), đặc biệt trong lập trình nhúng (embedded), giao thức mạng, hoặc xử lý tệp nhị phân. Các kiểu như int32_t, uint8_t đảm bảo kích thước chính xác bất kể kiến trúc CPU. Tham khảo: cppreference.com - Fixed width integer types.

6. Hằng số, Enum & Ép kiểu

A. Hằng số: const vs #define

C có hai cách phổ biến để định nghĩa hằng số (giá trị không thay đổi):

  • const — Tạo biến hằng có kiểu dữ liệu rõ ràng, được kiểm tra bởi trình biên dịch. An toàn hơn, dễ debug hơn.
  • #define — Chỉ thị tiền xử lý (preprocessor macro), thay thế văn bản trước khi biên dịch. Không có kiểu dữ liệu, không chiếm bộ nhớ, nhưng dễ gây lỗi tinh vi nếu không cẩn thận.

Khác biệt giữa hai cách chỉ lộ ra khi bạn dùng sai. Đoạn dưới đặt chúng cạnh nhau, và phần cuối cố tình để lại một cái bẫy kinh điển của #define:

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;
}

Dòng đáng nhớ nhất là SQUARE(3+1) in ra 7 chứ không phải 16. Vì #define chỉ thay thế văn bản chứ không hiểu toán học, nó bung thành 3+1 * 3+1, và phép nhân được ưu tiên trước. Đó là lý do quy tắc chung là: dùng const cho hằng số, chỉ dùng #define khi thật sự cần.

B. Enum (Kiểu liệt kê)

enum cho phép định nghĩa một tập hợp các hằng số nguyên có tên, giúp mã nguồn dễ đọc và bảo trì hơn. Tham khảo: cppreference.com - Enumeration.

Ví dụ dưới dùng hai kiểu enum: một cái để giá trị mặc định, một cái tự đánh số — rồi dùng nó trong switch, nơi enum phát huy tác dụng rõ nhất:

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;
}

Cái lợi thấy rõ ở khối switch: viết case MON: đọc ra nghĩa ngay, còn case 1: thì sáu tháng sau chính bạn cũng phải đi tra 1 là thứ mấy. Trình biên dịch cũng cảnh báo được nếu bạn quên xử lý một giá trị trong enum.

C. Ép kiểu (Type Casting)

Ép kiểu là việc chuyển đổi giá trị từ kiểu dữ liệu này sang kiểu khác. C hỗ trợ hai hình thức:

  • Ép kiểu ngầm định (Implicit Casting) — Trình biên dịch tự động chuyển đổi kiểu khi cần, theo quy tắc "kiểu nhỏ → kiểu lớn" (integer promotion). Ví dụ: intdouble.
  • Ép kiểu tường minh (Explicit Casting) — Lập trình viên chỉ định rõ bằng cú pháp (kiểu_mới)giá_trị.

Đoạn dưới đây gom cả bốn tình huống ép kiểu vào một chương trình chạy được: ngầm định, tường minh, mất dữ liệu khi thu nhỏ, và quy tắc thăng cấp số nguyên. Mỗi khối đều in kết quả ra để bạn đối chiếu với con số ghi trong 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;
}

Hai dòng đáng nhớ nhất trong đoạn trên nằm cạnh nhau: 7/2 cho 3, còn (double)7/2 cho 3.500000. Cùng hai con số, khác nhau đúng một phép ép kiểu — vì khi cả hai vế đều là int, C làm phép chia nguyên và vứt phần dư đi. Đây chính là câu hỏi trong phần kiểm tra ngay dưới.

📝 Kiểm tra kiến thức bài 2
Cho đoạn mã: int a = 7; int b = 2; printf("%f", (double)a / b);. Kết quả in ra màn hình là gì?

Bài viết liên quan trong series

Bài 3: Toán tử, Thứ tự ưu tiên & Phép toán Bitwise trong C Bài 1: Hướng dẫn cài đặt môi trường lập trình C trên macOS & Linux Quay lại Lộ trình C Series