Skip to content

Quy trình compile chương trình C

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, còn gọi là build. Bài này dùng nghĩa rộng, và gọi riêng bước 2 là "compilation".

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.

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 (mã nhị phân mà CPU hiểu được) 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

Từ relocatable là mấu chốt: file này chứa code nhưng chưa có địa chỉ cuối cùng. Nó cũng chưa chạy được.

Bên trong object file

Dùng readelf -S hello.o thấy các section chính:

Section Nội dung
.text machine code của các hàm
.data biến global/static đã khởi tạo giá trị khác 0
.bss biến global/static chưa khởi tạo (chỉ lưu kích thước, không chiếm chỗ trong file)
.rodata hằng, chuỗi literal
.symtab bảng symbol
.rela.text bảng relocation cho .text

Symbol table liệt kê những gì object file định nghĩa và những gì nó cần. Dùng nm:

$ nm hello.o
0000000000000000 T main
                 U printf
                 U square

$ nm math.o
0000000000000000 T square
  • T: symbol được định nghĩa trong section .text của file này.
  • U: symbol undefined, file này dùng nhưng chưa biết ở đâu.

Nhìn ghép lại thì thấy hello.o đang "nợ" square và printf, còn math.o có thể trả món nợ square.

Relocation: xem disassembly của hello.o bằng objdump -dr:

$ objdump -dr hello.o
0000000000000000 <main>:
   ...
   8:   bf 02 00 00 00          mov    $0x2,%edi
   d:   e8 00 00 00 00          call   12 <main+0x12>
            e: R_X86_64_PLT32   square-0x4
   ...
  3f:   e8 00 00 00 00          call   44 <main+0x44>
            40: R_X86_64_PLT32  printf-0x4

Lệnh call (opcode e8) đi kèm 4 byte địa chỉ đích đang là 00 00 00 00. Dòng R_X86_64_PLT32 square-0x4 bên dưới là một relocation entry: nó nhắn cho linker "tại offset 0xe hãy điền địa chỉ của square". Assembler không thể tự điền vì square nằm ở file khác.

4. Linking

$ gcc hello.o math.o -o hello

Linker làm ba việc chính:

  1. Symbol resolution: ghép mỗi symbol U với đúng một định nghĩa T ở đâu đó trong các object file hoặc library. Nếu không tìm thấy: lỗi undefined reference. Nếu có hai định nghĩa mạnh trùng tên: lỗi multiple definition.
  2. Section merging và gán địa chỉ: gộp các .text của từng .o thành một .text, tương tự cho .data, .bss..., rồi gán địa chỉ cho từng thứ.
  3. Relocation: quay lại các vị trí được đánh dấu trong .rela.text và điền địa chỉ thật.

Trong executable, cùng lệnh call ban đầu đã có giá trị:

$ objdump -d hello | grep "call.*square"
    1156:   e8 39 00 00 00          call   1194 <square>

$ nm hello | grep -E " (main|square)$"
0000000000001149 T main
0000000000001194 T square

4 byte 00 00 00 00 giờ là 39 00 00 00 (offset tương đối 0x39 từ lệnh kế tiếp: 0x115b + 0x39 = 0x1194, đúng địa chỉ square).

Linker còn thêm gì vào

Khi chạy ./hello, kernel không nhảy thẳng vào main. Linker đã đưa vào executable thêm các đoạn code khởi động từ crt1.o, crti.o, crtbegin.o..., trong đó có _start (là entry point thật sự) có nhiệm vụ chuẩn bị môi trường rồi mới gọi main. Khi main return, exit() được gọi với giá trị trả về.

$ readelf -h hello | grep -E "Type|Entry"
  Type:                              DYN (Position-Independent Executable file)
  Entry point address:               0x1060

Địa chỉ 0x1060 là _start, không phải main (0x1149).

Quên truyền math.c (hoặc math.o):

$ 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. Offset .text+0xe khớp với relocation e: R_X86_64_PLT32 ở trên.

Ngược lại, nếu thiếu prototype/header thì lỗi (hoặc warning implicit declaration) sẽ đến từ bước compilation.

Static vs dynamic linking

Có hai cách đưa code của library vào chương trình:

Static (libx.a) Dynamic (libx.so)
Bản chất .a là archive gồm nhiều .o; linker chép những .o cần thiết vào executable linker chỉ ghi lại tên library cần; việc nạp xảy ra lúc chạy
Kích thước executable lớn nhỏ
Phụ thuộc lúc chạy không cần đúng .so có trên máy
Cập nhật library phải build lại chương trình thay .so là đủ
Option -static mặc định

Mặc định gcc link động với libc. Kiểm tra bằng ldd:

$ ldd hello
    linux-vdso.so.1 (0x00007ffe35298000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000071d668a2f000)
    /lib64/ld-linux-x86-64.so.2 (0x000071d668c4f000)

Trong đó /lib64/ld-linux-x86-64.so.2 là dynamic linker/loader (còn gọi là interpreter, khớp với dòng interpreter /lib64/ld-linux-x86-64.so.2 trong output của file hello). Khi chạy chương trình, kernel nạp executable và loader này, loader sẽ nạp libc.so.6 vào bộ nhớ và hoàn tất việc resolve các symbol như printf. Đây là lý do trong hello.o ta thấy printf@PLT: lời gọi đi qua PLT (Procedure Linkage Table) để địa chỉ thật của printf có thể được điền lúc chạy.

Ví dụ về các option link (xem thêm hình libs-in-linux.png ở bài C introduction):

  • -L<dir>: thêm thư mục tìm library.
  • -l<name>: link với lib<name>.so (hoặc lib<name>.a). Ví dụ -lm cho libm (hàm toán học), -lpthread.
  • -static: link tĩnh toàn bộ.

Thứ tự -l quan trọng

Linker xử lý các file từ trái sang phải, và chỉ lấy từ library những symbol đang bị U tại thời điểm đó. Vì vậy gcc main.c -lm đúng, còn gcc -lm main.c có thể báo undefined reference (tuỳ cấu hình linker). Quy tắc an toàn: đặt -l... sau các file nguồn/object.

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 ở Makefile và CMake.

hello: hello.o math.o
    $(CC) $^ -o $@

%.o: %.c
    $(CC) -Wall -c $< -o $@

Cross compile

Mọi giai đoạn ở trên đều chạy trên máy host (ví dụ x86-64), nhưng với embedded Linux, executable cần chạy trên máy target (ARM, RISC-V...). Cross toolchain là bộ gcc, as, ld... được build để sinh machine code cho kiến trúc khác, đặt tên có tiền tố triplet:

$ arm-linux-gnueabihf-gcc hello.c math.c -o hello
$ file hello
hello: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, ...

Quy trình 4 bước vẫn y nguyên, chỉ khác là mọi công cụ (và cả libc, header) đều thuộc kiến trúc target. Chạy ./hello ngay trên host sẽ lỗi Exec format error.

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

Option -save-temps đặc biệt tiện để học: một lệnh duy nhất sinh ra đủ .i, .s, .o để so sánh.

Tóm tắt

  • gcc là driver, gồm 4 bước: preprocess → compile → assemble → link.
  • 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.
  • Object file chứa symbol (T đã định nghĩa, U chưa định nghĩa) và relocation; linker giải quyết chúng.
  • undefined reference là lỗi của linker, không phải của compiler.
  • Library có thể link tĩnh (.a) hoặc động (.so); link động cần dynamic loader lúc chạy.

References

  • man gcc, man ld, man nm, man objdump, man readelf
  • GCC Overall Options - cách gcc chọn các giai đoạn theo đuôi file
  • GCC Link Options
  • Randal E. Bryant, David R. O'Hallaron - Computer Systems: A Programmer's Perspective, chương 7 (Linking)