Build pipeline¶
Đây là bài đầu của series C toolchain. Bài trước (C introduction) giới thiệu ngôn ngữ C và thư viện chuẩn. Bài này đi qua một câu hỏi nền tảng: từ file hello.c đến một file thực thi chạy được, chuyện gì xảy ra ở giữa?
Khi gõ gcc hello.c -o hello, ta có cảm giác "compile" là một bước duy nhất. Thực tế gcc chỉ là một driver: nó lần lượt gọi 4 chương trình riêng biệt, mỗi chương trình làm một việc.
hello.c ──cpp──▶ hello.i ──cc1──▶ hello.s ──as──▶ hello.o ──ld──▶ hello
source preprocessed assembly object file executable
▲
libc, crt1.o ... ┘
| Giai đoạn | Công cụ | Đầu vào | Đầu ra | Option dừng lại ở bước này |
|---|---|---|---|---|
| Preprocessing | cpp |
.c |
.i |
-E |
| Compilation | cc1 |
.i |
.s |
-S |
| Assembly | as |
.s |
.o |
-c |
| Linking | ld (qua collect2) |
.o + libraries |
executable | (mặc định) |
Thuật ngữ
Theo nghĩa hẹp, "compiler" chỉ là bước .i → .s. Theo nghĩa rộng (và trong đời sống hằng ngày), "compile" là cả chuỗi từ source đến executable. Trong series này: build là cả chuỗi, compilation chỉ là bước .i → .s.
Có thể xem gcc thực sự gọi gì bằng gcc -v (hoặc -### để chỉ in lệnh, không chạy). Rút gọn từ output của gcc -v hello.c math.c -o h4 (GCC 13.3, Ubuntu 24.04):
.../13/cc1 -quiet ... hello.c ... -o /tmp/ccOPcl91.s
as -v --64 -o /tmp/ccwR438r.o /tmp/ccOPcl91.s
.../13/cc1 -quiet ... math.c ... -o /tmp/ccOPcl91.s
as -v --64 -o /tmp/ccafQ9Wf.o /tmp/ccOPcl91.s
.../13/collect2 -plugin .../liblto_plugin.so ...
Chú ý: bước preprocessing không có lệnh riêng, vì cc1 đã tích hợp sẵn preprocessor. collect2 là wrapper bọc quanh ld.
Cách học nhanh nhất: -save-temps
.c trong một lệnh, gcc đặt tên theo dạng hello-hello.i, hello-math.i...
Ví dụ xuyên suốt¶
Dùng hai file nguồn để thấy rõ vai trò của linker.
#include <stdio.h>
#define PI 3.14159
int square(int x);
int main(void)
{
printf("area = %f\n", PI * square(2));
return 0;
}
Build một lệnh và chạy:
Giờ tách từng bước.
1. Preprocessing¶
Preprocessor xử lý văn bản thuần, chưa hiểu cú pháp C. Nó chỉ thực hiện các chỉ thị bắt đầu bằng #:
#include: chép nguyên nội dung file header vào vị trí đó.#define: thay thế macro (đơn giản là thay chuỗi, không kiểm tra kiểu).#if,#ifdef,#ifndef: giữ hoặc bỏ đoạn code theo điều kiện (conditional compilation).- Xoá comment, nối các dòng có
\.
File hello.c chỉ 11 dòng nhưng hello.i có 824 dòng (do stdio.h kéo theo hàng loạt header khác). Phần cuối của hello.i:
PI đã bị thay bằng 3.14159, comment (nếu có) đã biến mất, còn phần đầu file là khai báo của printf và các kiểu từ stdio.h.
Dùng -E để debug macro
Khi một macro phức tạp (ví dụ trong Linux kernel) cho kết quả khó hiểu, chạy gcc -E rồi đọc kết quả sau khi expand là cách nhanh nhất. Thêm -dM để in danh sách toàn bộ macro đang được định nghĩa: gcc -E -dM - < /dev/null.
Một số option liên quan tới giai đoạn này:
-I<dir>: thêm thư mục tìm header cho#include "..."và#include <...>.-D<NAME>[=value]: định nghĩa macro từ command line (tương đương#define).-U<NAME>: bỏ định nghĩa macro.
2. Compilation¶
Đây là giai đoạn "thật sự" của compiler: parse code C, kiểm tra cú pháp và kiểu, tối ưu, rồi sinh ra assembly của kiến trúc đích. Hầu hết lỗi và warning mà ta gặp (thiếu ;, sai kiểu, biến chưa khai báo) đến từ bước này.
Với -O0 trên x86-64, đoạn main được sinh ra như sau (đã lược bớt các directive):
main:
endbr64
pushq %rbp
movq %rsp, %rbp
movl $2, %edi # tham số thứ nhất của square() là 2
call square@PLT
pxor %xmm1, %xmm1
cvtsi2sdl %eax, %xmm1 # int -> double
movsd .LC0(%rip), %xmm0 # nạp hằng số 3.14159
mulsd %xmm0, %xmm1
...
call printf@PLT
movl $0, %eax # return 0
popq %rbp
ret
Có thể thấy:
- Compiler chỉ gọi
squarevàprintfbằng tên, nó không biết hai hàm này nằm ở đâu. Với nó, chỉ cần có khai báo (prototype) là đủ để kiểm tra kiểu và quy ước truyền tham số. - Hằng chuỗi
"area = %f\n"và hằng số3.14159được đưa vào section.rodata(dữ liệu chỉ đọc).
Các option thường dùng:
-O0,-O1,-O2,-O3,-Os: mức tối ưu (mặc định-O0;-Ostối ưu kích thước, hay dùng trong embedded).-Wall -Wextra: bật thêm cảnh báo, nên luôn dùng.-std=c11,-std=gnu17, ...: chọn chuẩn ngôn ngữ.-g: sinh thêm thông tin debug (DWARF) để dùng vớigdb.
3. Assembly¶
Assembler dịch từng câu lệnh assembly sang machine code và đóng gói vào object file. Trên Linux, định dạng này là ELF (Executable and Linkable Format):
relocatable nghĩa là file này chứa code nhưng chưa có địa chỉ cuối cùng, và chưa chạy được. Cấu trúc bên trong (section, symbol, relocation) được phân tích ở bài 2: Object file và ELF.
4. Linking¶
Linker ghép các object file và library thành executable bằng ba việc: symbol resolution (ghép mỗi symbol chưa định nghĩa với đúng một định nghĩa), section merging và gán địa chỉ, và relocation (điền địa chỉ thật vào những chỗ assembler để trống). Chi tiết ở bài 3: Linking.
Thiếu math.c sẽ cho lỗi quen thuộc:
$ gcc hello.c -o hello
/usr/bin/ld: /tmp/ccwGhkPf.o: in function `main':
hello.c:(.text+0xe): undefined reference to `square'
collect2: error: ld returned 1 exit status
Phân biệt lỗi compile và lỗi link
Lỗi này không đến từ compiler. Nhờ có prototype int square(int x);, bước compilation hoàn toàn hài lòng. Chỉ đến khi linker đi tìm định nghĩa mới phát hiện thiếu. Dấu hiệu nhận biết: thông báo xuất phát từ ld / collect2, và có cụm undefined reference.
Ngược lại, nếu thiếu prototype/header thì lỗi (hoặc warning implicit declaration) đến từ bước compilation.
Tại sao phải tách nhiều bước¶
Tách riêng "compile từng file thành .o" và "link" mang lại lợi ích lớn cho dự án nhiều file:
- Sửa
math.cchỉ cần compile lạimath.orồi link lại, không động đếnhello.o. - Có thể build song song nhiều
.c. - Có thể đóng gói
.othành library để tái sử dụng.
Đây chính là lý do công cụ make ra đời: nó theo dõi .c nào mới hơn .o tương ứng để chỉ build lại phần cần thiết. Xem tiếp ở bài 5: Build nhiều file, Makefile và CMake.
Cross compile¶
Mọi giai đoạn ở trên chạy trên host (ví dụ x86-64). Với embedded Linux, executable cần chạy trên target (ARM, RISC-V...), nên ta dùng cross toolchain: quy trình 4 bước y nguyên, chỉ khác là mọi công cụ (và libc, header) thuộc kiến trúc target. Xem bài 6: Cross compile.
Bảng tra nhanh¶
| Mục đích | Lệnh |
|---|---|
| Xem kết quả preprocess | gcc -E file.c |
| Xem assembly | gcc -S file.c (ra file.s) |
| Chỉ compile, không link | gcc -c file.c (ra file.o) |
| Xem các lệnh gcc thực sự gọi | gcc -v file.c hoặc gcc -### file.c |
| Giữ lại toàn bộ file trung gian | gcc -save-temps file.c |
| Liệt kê symbol | nm file.o |
| Xem section, header ELF | readelf -S, readelf -h |
| Disassemble (kèm relocation) | objdump -d, objdump -dr |
| Kích thước text/data/bss | size file.o |
| Xem library động cần thiết | ldd file |
| Xác định loại file | file file |
Tóm tắt¶
gcclà driver, gồm 4 bước: preprocess → compilation → assembly → linking.- Preprocessor làm việc trên văn bản (
#include,#define); compiler biên dịch C thành assembly; assembler tạo object file (.o, ELF relocatable); linker ghép các.ovà library thành executable. undefined referencelà lỗi của linker, không phải của compiler.-save-temps,-v,-E,-S,-clà các công cụ để quan sát từng giai đoạn.
Tiếp theo: Object file và ELF.
References¶
man gcc- GCC Overall Options - cách gcc chọn các giai đoạn theo đuôi file
- Randal E. Bryant, David R. O'Hallaron - Computer Systems: A Programmer's Perspective, chương 7 (Linking)