Skip to content

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

$ gcc -save-temps -c hello.c
$ ls
hello.c  hello.i  hello.o  hello.s  math.c
Một lệnh duy nhất giữ lại toàn bộ file trung gian của mọi giai đoạn, để mở ra đọc và so sánh. Khi truyền nhiều file .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.

hello.c
#include <stdio.h>

#define PI 3.14159

int square(int x);

int main(void)
{
    printf("area = %f\n", PI * square(2));
    return 0;
}
math.c
int square(int x)
{
    return x * x;
}

Build một lệnh và chạy:

$ gcc hello.c math.c -o hello
$ ./hello
area = 12.566360

Giờ tách từng bước.

1. Preprocessing

$ gcc -E hello.c -o hello.i

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:

int square(int x);

int main(void)
{
    printf("area = %f\n", 3.14159 * square(2));
    return 0;
}

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

$ gcc -S hello.i -o hello.s

Đâ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 square và printf bằ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; -Os tố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ới gdb.

3. Assembly

$ gcc -c hello.s -o hello.o
$ gcc -c math.c  -o math.o

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):

$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

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

$ gcc hello.o math.o -o hello

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.c chỉ cần compile lại math.o rồi link lại, không động đến hello.o.
  • Có thể build song song nhiều .c.
  • Có thể đóng gói .o thà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

  • gcc là 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 .o và library thành executable.
  • undefined reference là lỗi của linker, không phải của compiler.
  • -save-temps, -v, -E, -S, -c là 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)