← Lập trình C cơ bản

Bài 4 · Cơ bản · 20 phút· Cập nhật 11/06/2026

C biên dịch như thế nào?

Biên soạn bởi Nguyễn Anh Tuấn

Đi từng bước từ file .c qua preprocessor, compiler, assembler, linker tới executable; hiểu header, object file và lỗi linker.

Bài 3, bạn đã chạy thành công chương trình hello.c đầu tiên. Bây giờ chúng ta tò mò hơn một chút, cùng tìm hiểu máy tính biến một file C thành chương trình chạy được như thế nào?

Quá trình biên dịch chương trình C qua 4 bước như sau:

BướcCông cụĐầu vàoĐầu raNhiệm vụ
1. Tiền xử lýBộ tiền xử lý (Preprocessor)giai-phau.cgiai-phau.iKhai triển macro và chèn nội dung các tệp header.
2. Biên dịchBộ biên dịch (Compiler)giai-phau.igiai-phau.sDịch mã nguồn C sang ngôn ngữ hợp ngữ `.s`
3. Lắp rápBộ lắp ráp (Assembler)giai-phau.sgiai-phau.oChuyển mã assembly thành object file chứa machine code
4. Liên kếtBộ liên kết (Linker).o + thư việngiai-phauHợp nhất các tệp đối tượng và các thư viện (libraries) lại với nhau.

Ta dùng một file nhỏ có đủ thứ cần theo dõi: header, prototype, hàm main, biến cục bộ, lời gọi hàm và một hàm tự viết. Build một dòng như sau:

giai-phau.c

#include <stdio.h>

double thue(double gia);

int main(void) {
    double gia = 200000.0;
    printf("Thue: %.0f dong\n", thue(gia));
    return 0;
}

double thue(double gia) {
    return gia * 0.1;
}

Build và chạy

$ cc -std=c17 -Wall -Wextra -Wpedantic -g giai-phau.c -o giai-phau
$ ./giai-phau

Kết quả khi chạy

Thue: 20000 dong
  • `-std=c17` chọn chuẩn C17 cho khoá này.
  • `-Wall -Wextra -Wpedantic` bật nhiều cảnh báo hữu ích.
  • `-g` giữ thông tin debug để sau này dùng debugger.

File giai-phau.c chưa được đưa thẳng vào compiler. Trước đó, bộ tiền xử lý (preprocessor) duyệt qua file và xử lý các chỉ thị bắt đầu bằng #. Trong chương trình mẫu, dòng quan trọng nhất là #include <stdio.h>.

Chạy riêng bước tiền xử lý

$ cc -std=c17 -E giai-phau.c -o giai-phau.i

Lệnh -E bảo công cụ dừng sau bước tiền xử lý. Đầu ra là giai-phau.i: một translation unit đã được mở rộng. File này thường dài hơn rất nhiều so với file gốc, vì nội dung của header hệ thống đã được chèn vào.

Ý tưởng của `giai-phau.i` - đã có khai báo từ header

/* rất nhiều dòng từ stdio.h ... */
int printf(const char * restrict, ...);

double thue(double gia);

int main(void) {
    double gia = 200000.0;
    printf("Thue: %.0f dong\n", thue(gia));
    return 0;
}

double thue(double gia) {
    return gia * 0.1;
}

Điểm cần nắm rõ: #include không kéo code đã biên dịch của printf vào chương trình. Nó chủ yếu mang khai báo của printf vào để compiler biết hàm này tên gì, nhận tham số kiểu gì, trả về kiểu gì.

  • Đầu vào: `giai-phau.c`.
  • Công cụ: preprocessor.
  • Đầu ra: `giai-phau.i`, tức translation unit sau khi xử lý `#include`, macro và các chỉ thị tiền xử lý.
  • Ở bước này chưa kiểm tra sâu logic C, chưa sinh assembly, chưa tạo chương trình chạy được.

Cẩn thận

Đừng hiểu header là thư viện đã được ghép. Header giúp compiler kiểm tra lời gọi; phần code đã biên dịch của thư viện C sẽ được xử lý ở bước liên kết.

Sau tiền xử lý, bộ biên dịch (compiler) nhận giai-phau.i và bắt đầu đọc C theo cú pháp của ngôn ngữ. Đây là bước compiler kiểm tra những thứ như thiếu dấu ;, gọi hàm chưa khai báo, kiểu tham số không khớp prototype, hoặc dùng tên chưa tồn tại trong phạm vi hiện tại.

Dừng sau bước biên dịch để xem assembly

$ cc -std=c17 -Wall -Wextra -Wpedantic -g -S giai-phau.i -o giai-phau.s

Tuỳ hệ điều hành và compiler, file giai-phau.s nhìn sẽ khác nhau. Nhưng về bản chất nó là assembly: dạng chữ chỉ dẫn gần với lệnh máy hơn C. Ví dụ, trong file assembly thường sẽ có nhãn cho main, thue, và một lời gọi tới symbol printf.

Ý tưởng trong file assembly, đã lược bỏ chi tiết phụ thuộc máy

main:
    gọi thue
    gọi printf
    trả 0

thue:
    nhân gia với 0.1
    trả kết quả
  • Đầu vào: `giai-phau.i`.
  • Công cụ: compiler.
  • Đầu ra: `giai-phau.s`, tức assembly.
  • Compiler kiểm tra cú pháp và kiểu ở bước này. Lỗi kiểu `expected ;` hoặc `call to undeclared function` thường dừng tại đây.

Assembly vẫn là dạng chữ để con người đọc được. Máy chưa chạy trực tiếp file .s. Bước lắp ráp dùng assembler để chuyển assembly thành object file .o.

Chuyển assembly thành object file

$ cc -c giai-phau.s -o giai-phau.o

File giai-phau.o đã chứa machine code cho phần code trong file của bạn, ví dụ mainthue. Nhưng nó vẫn chưa phải chương trình hoàn chỉnh. Lý do: nó còn để lại một số tham chiếu cần linker giải quyết, chẳng hạn lời gọi printf.

Có thể soi symbol trong object file

$ nm giai-phau.o
... T _main
... T _thue
... U _printf
  • Đầu vào: `giai-phau.s`.
  • Công cụ: assembler.
  • Đầu ra: `giai-phau.o`, tức object file.
  • `T _main` hoặc `T _thue` nghĩa là object file này có định nghĩa symbol đó. `U _printf` nghĩa là symbol này còn chưa được giải quyết.

Vì sao `.o` chưa chạy được?

Object file chỉ là một mảnh của chương trình. Nó có code máy cho file hiện tại, nhưng chưa có đủ thông tin khởi động chương trình, thư viện C, và các symbol bên ngoài. Những phần đó thuộc trách nhiệm của linker.

Linker nhận một hoặc nhiều object file, cộng với các thư viện cần thiết, rồi ghép chúng thành executable. Với chương trình mẫu, linker phải nối lời gọi printf với implementation đã biên dịch trong thư viện C, đồng thời chuẩn bị phần để hệ điều hành có thể nạp chương trình và gọi main.

Link object file thành executable

$ cc giai-phau.o -o giai-phau
$ ./giai-phau

Kết quả khi chạy

Thue: 20000 dong

Đây là lý do lỗi linker thường xuất hiện muộn hơn lỗi compiler. Compiler có thể đã hiểu câu lệnh chao(); nhờ một prototype, nhưng linker vẫn cần tìm thân hàm chao ở đâu đó. Nếu không có định nghĩa, linker sẽ báo undefined reference hoặc undefined symbols.

  • Đầu vào: một hoặc nhiều file `.o`, cộng với thư viện.
  • Công cụ: linker.
  • Đầu ra: executable, ví dụ `giai-phau`.
  • Linker giải quyết symbol: chỗ nào gọi hàm hoặc dùng biến ngoài file thì phải tìm được định nghĩa tương ứng.

Khi đã đi qua đủ bốn bước, ta quay lại file C ban đầu sẽ dễ hiểu hơn. Mỗi loại dòng trong chương trình phục vụ một phần của pipeline:

Loại mảnhVí dụDùng để làm gì?
Chỉ thị (directive)#include <stdio.h>preprocessor xử lý trước compiler
Khai báo (declaration)double thue(double);giới thiệu tên + kiểu để compiler soát lời gọi
Định nghĩa (definition)double thue(...) { ... }cung cấp thân hàm hoặc storage của biến
Câu lệnh (statement)return 0;mệnh lệnh thực thi khi chương trình chạy
  • Khai báo thuần chỉ giới thiệu tên + kiểu, không có thân hàm và không tạo storage mới.
  • Định nghĩa cũng là một khai báo, nhưng có thêm phần để chương trình dùng: thân hàm hoặc storage.
  • Câu lệnh chỉ sống trong thân hàm; ngoài hàm không có lệnh chạy trực tiếp.
  • Header và prototype giúp compiler kiểm tra lời gọi; object file và thư viện là việc của assembler/linker.

Giờ hãy thử xếp loại từng dòng. Đừng đoán theo cảm giác, hãy hỏi dòng đó phục vụ bước nào:

#include <stdio.h>

Dòng này thuộc loại nào?

Đoán trước rồi mới xem đáp án - chốt là không đổi được.

Đã xếp đúng 0/9 dòng · còn 9 dòng chưa xếp

Từ giờ, khi build lỗi, câu hỏi đầu tiên nên là: pipeline hỏng ở bước nào? Mỗi bước có kiểu lỗi riêng:

Lỗi tiền xử lý: include một header không tồn tại

#include "khong-co.h"

int main(void) {
    return 0;
}

$ cc -std=c17 -Wall -Wextra -Wpedantic -g loi-preprocess.c
loi-preprocess.c:1:10: fatal error: 'khong-co.h' file not found

Lỗi biên dịch: gọi hàm chưa khai báo

int main(void) {
    chao();
    return 0;
}

$ cc -std=c17 -Wall -Wextra -Wpedantic -g loi-compile.c
loi-compile.c:2:5: error: call to undeclared function 'chao';
            ISO C99 and later do not support implicit function declarations

Lỗi liên kết: có khai báo nhưng thiếu định nghĩa

void chao(void);

int main(void) {
    chao();
    return 0;
}

$ cc -std=c17 -Wall -Wextra -Wpedantic -g loi-link.c
Undefined symbols for architecture arm64:
  "_chao", referenced from:
      _main in loi-link-...o
clang: error: linker command failed with exit code 1
  • Header không tìm thấy hoặc macro viết sai: thường hỏng ở bước tiền xử lý.
  • Sai cú pháp, sai kiểu, gọi hàm chưa khai báo: thường hỏng ở bước biên dịch.
  • Assembly không hợp lệ: hỏng ở bước lắp ráp, ít gặp khi bạn viết C thuần.
  • Biên dịch qua nhưng thiếu thân hàm, thiếu file `.o`, hoặc thiếu thư viện: hỏng ở bước liên kết.

Bài tiếp theo

Đến đây, mèo con đã biết một dòng như double gia = 200000.0; vừa là định nghĩa biến, vừa tạo storage cho giá trị. Storage đó rộng bao nhiêu byte, và mỗi kiểu chứa được gì - Biến & kiểu dữ liệu sẽ trả lời tiếp.

Câu hỏi thường gặp

Vì thuật ngữ chỉ dễ hiểu khi bạn biết nó phục vụ bước nào trong quá trình build. Trước hết hãy nhìn đường đi .c -> tiền xử lý -> compiler -> object file -> linker -> executable. Sau đó "khai báo", "định nghĩa", "câu lệnh" sẽ có chỗ đứng rõ hơn.

Không giống hẳn. Trong C, #include là việc của preprocessor: nó dán nội dung header vào file trước khi compiler phân tích C. Header thường chứa khai báo để compiler biết printf có kiểu gì.

File .o là object file: code máy và thông tin symbol cho một file .c sau khi biên dịch. Nó chưa phải chương trình hoàn chỉnh, vì còn cần linker ghép với thư viện C và các object file khác.

Compiler đọc từ trên xuống trong từng translation unit. Khi main gọi thue, compiler cần biết trước tên hàm, kiểu tham số và kiểu trả về để soát lời gọi. Prototype cung cấp đúng thông tin đó.

Có. Lệnh đó gọi cả tiền xử lý, compile, assemble và link. Trong bài này ta tách bước bằng -E-c để thấy từng tầng, không phải vì ngày nào cũng phải build thủ công như vậy.

Không. Bên ngoài các hàm chỉ có chỉ thị, khai báo và định nghĩa; mọi câu lệnh như gán, gọi hàm, return, if, for phải nằm trong thân một hàm.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Trong compilation process, #include <stdio.h> được xử lý ở bước nào?

Bài tập về nhà

  1. 1

    Build từng bước

    Tạo file giai-phau.c theo bài. Chạy lần lượt: cc -std=c17 -E giai-phau.c -o giai-phau.i, cc -std=c17 -Wall -Wextra -Wpedantic -g -S giai-phau.i -o giai-phau.s, cc -c giai-phau.s -o giai-phau.o, cc giai-phau.o -o giai-phau, rồi ./giai-phau.

    ✅ Hoàn thành khi: Có đủ giai-phau.i, giai-phau.s, giai-phau.o, file chạy giai-phau, và chương trình in Thue: 20000 dong.

  2. 2

    Soi file sau tiền xử lý

    Mở giai-phau.i bằng editor, tìm printf, rồi so với dòng #include <stdio.h> trong file gốc.

    ✅ Hoàn thành khi: Nói được: header được dán vào trước khi compiler phân tích C; printf xuất hiện dưới dạng khai báo/prototype.

  3. 3

    Tự tạo lỗi compiler

    Xoá dấu ; sau dòng double gia = 200000.0, rồi build lại bằng bộ cờ của khoá.

    ✅ Hoàn thành khi: Compiler báo lỗi ngữ pháp trước khi tạo file assembly hoặc object file.

  4. 4

    Tự tạo lỗi linker

    Xoá phần thân hàm thue, chỉ giữ prototype double thue(double);, rồi build lại.

    ✅ Hoàn thành khi: Compiler có thể đọc được lời gọi, nhưng linker báo thiếu định nghĩa của thue.

  5. 5

    Xếp loại sau khi hiểu pipeline

    Quay lại công cụ xếp loại ở cuối bài: phân loại đủ 9 dòng. Mỗi dòng hãy tự hỏi: dòng này phục vụ preprocessor, compiler, linker hay lúc chương trình chạy?

    ✅ Hoàn thành khi: Xếp đúng 9/9 và giải thích lại được ít nhất 3 dòng bằng ngôn ngữ của mèo con.