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ước | Công cụ | Đầu vào | Đầu ra | Nhiệm vụ |
|---|---|---|---|---|
| 1. Tiền xử lý | Bộ tiền xử lý (Preprocessor) | giai-phau.c | giai-phau.i | Khai triển macro và chèn nội dung các tệp header. |
| 2. Biên dịch | Bộ biên dịch (Compiler) | giai-phau.i | giai-phau.s | Dịch mã nguồn C sang ngôn ngữ hợp ngữ `.s` |
| 3. Lắp ráp | Bộ lắp ráp (Assembler) | giai-phau.s | giai-phau.o | Chuyển mã assembly thành object file chứa machine code |
| 4. Liên kết | Bộ liên kết (Linker) | .o + thư viện | giai-phau | Hợ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
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ụ main và thue. 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?
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ảnh | Ví 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.
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
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 đó.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Trong compilation process, #include <stdio.h> được xử lý ở bước nào?
- 1
Build từng bước
Tạo file
giai-phau.ctheo 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ạygiai-phau, và chương trình inThue: 20000 dong. - 2
Soi file sau tiền xử lý
Mở
giai-phau.ibằng editor, tìmprintf, 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;
printfxuất hiện dưới dạng khai báo/prototype. - 3
Tự tạo lỗi compiler
Xoá dấu
;sau dòngdouble 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
Tự tạo lỗi linker
Xoá phần thân hàm
thue, chỉ giữ prototypedouble 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
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.