Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 1.320 phút đọc

Quy trình biên dịch

Sau bài này bạn sẽ làm được

  • Kể tên bốn giai đoạn, công cụ và đuôi file tương ứng
  • Chạy được từng giai đoạn riêng bằng gcc -E, -S, -c
  • Đọc được bảng ký hiệu và bảng tái định vị trong file object
  • Phân biệt được lỗi undefined reference và multiple definition

Khi bạn gõ gcc main.c, thực ra bốn chương trình khác nhau lần lượt chạy. Hiểu rõ bốn bước này là thứ giúp bạn tự chẩn đoán được phần lớn lỗi khó hiểu về sau, đặc biệt là lỗi liên kết. Đây là bài quan trọng nhất của Phần 0.

#Bốn giai đoạn

Mỗi mũi tên là một chương trình riêng biệt. Lệnh gcc chỉ là người điều phối gọi lần lượt cả bốn.
Giai đoạnCông cụĐầu vàoĐầu ra
Tiền xử lýcppmain.cmain.i
Biên dịchcc1main.imain.s
Hợp dịchasmain.smain.o
Liên kếtldmain.o và các thư việnmain

#1. Tiền xử lý

Bước đầu tiên do cpp đảm nhiệm. Điều quan trọng nhất cần nhớ: preprocessor không hiểu C. Nó chỉ thao tác trên văn bản, giống như thao tác tìm và thay thế trong trình soạn thảo.

Nó làm đúng bốn việc:

  • Dán nguyên nội dung file được #include vào vị trí đó
  • Thay thế mọi macro do #define tạo ra
  • Xử lý biên dịch có điều kiện: #if, #ifdef, #ifndef
  • Xóa toàn bộ chú thích
gcc -E main.c -o main.i

Hãy thử với một chương trình Hello World. Kết quả có thể làm bạn bất ngờ:

terminal
gcc -E hello.c | wc -l
812
# 812 dòng cho một chương trình 5 dòng

Toàn bộ 800 dòng thừa đến từ #include <stdio.h>. Preprocessor đã dán vào đó mọi khai báo hàm của thư viện nhập xuất chuẩn. Chương trình của bạn nằm ở cuối cùng.

#2. Biên dịch

Đây là bước phức tạp nhất. Trình biên dịch nhận file C thuần và sinh ra mã assembly cho kiến trúc đích. Bên trong nó là một dây chuyền nhiều tầng:

  1. Phân tích từ vựng

    Cắt mã nguồn thành các token: int, main, (, void, ) và tiếp tục.

  2. Phân tích cú pháp

    Dựng cây cú pháp trừu tượng. Lỗi thiếu dấu chấm phẩy hay thiếu ngoặc bị bắt ở đây.

  3. Phân tích ngữ nghĩa

    Kiểm tra kiểu, phân giải tên biến và hàm. Phần lớn cảnh báo mà bạn thấy sinh ra ở bước này.

  4. Sinh mã trung gian và tối ưu

    Gấp hằng số, loại bỏ mã chết, nội tuyến hàm, mở vòng lặp, vector hóa. Đây là nơi cờ -O2 phát huy tác dụng.

  5. Sinh mã đích

    Chuyển mã trung gian thành assembly của CPU cụ thể.

gcc -S main.c -o main.s
gcc -S -masm=intel main.c -o main.s    # cú pháp Intel dễ đọc hơn

#3. Hợp dịch

Trình hợp dịch as dịch từng dòng assembly thành mã máy nhị phân, tạo ra file object.

File object
File chứa mã máy đã dịch xong, nhưng địa chỉ chưa được chốt. Nó còn kèm theo bảng ký hiệu (danh sách những gì file này định nghĩa và những gì nó cần) và bảng tái định vị (danh sách các chỗ trống chờ điền địa chỉ).
gcc -c main.c -o main.o

Hãy nhìn vào bên trong file object bằng ba lệnh sau:

terminal
# Bảng ký hiệu: file này có gì và cần gì
nm main.o
0000000000000000 T main
                 U printf
# T nghĩa là định nghĩa ở đây, U nghĩa là chưa biết ở đâu
readelf -r main.o
Relocation section '.rela.text':
  Offset  Type            Symbol
  00001a  R_X86_64_PLT32  printf - 4
# Mã máy, lệnh call chưa có địa chỉ đích
objdump -d -M intel main.o

Dòng U printf chính là điểm mấu chốt. Trình hợp dịch biết bạn gọi printf, nhưng hoàn toàn không biết hàm đó nằm ở địa chỉ nào. Nó để lại một chỗ trống và ghi vào bảng tái định vị rằng chỗ này cần địa chỉ của printf. Việc điền vào là của bước sau.

#4. Liên kết

Trình liên kết làm đúng ba việc:

  • Gộp các phần. Ghép mọi vùng mã của các file object thành một vùng mã duy nhất.
  • Phân giải ký hiệu. Mỗi ký hiệu U phải tìm được đúng một định nghĩa, không nhiều hơn và không ít hơn.
  • Tái định vị. Điền địa chỉ thật vào mọi chỗ trống đã đánh dấu.

Nó cũng nối thêm mã khởi động của thư viện chuẩn, thứ sẽ gọi hàm main của bạn. Chi tiết về mã khởi động này nằm ở bài tiếp theo.

gcc main.o -o main
ldd main         # xem chương trình phụ thuộc thư viện động nào

#Hai lỗi kinh điển

Gần như mọi lập trình viên C đều gặp hai lỗi này trong tuần đầu tiên. Chúng trông giống nhau nhưng nguyên nhân ngược nhau, và phân biệt được chúng sẽ tiết kiệm cho bạn rất nhiều giờ.

undefined reference

Trình liên kết không tìm thấy định nghĩa. Nguyên nhân thường gặp:

  • Quên biên dịch kèm file .c chứa hàm đó
  • Quên thư viện: dùng sqrt() mà thiếu -lm
  • Gõ sai tên hàm
  • Sai thứ tự: gcc -lm main.c thất bại, phải viết gcc main.c -lm
multiple definition

Trình liên kết tìm thấy nhiều hơn một định nghĩa. Nguyên nhân thường gặp:

  • Định nghĩa biến trong file .h rồi include ở nhiều nơi
  • Quên extern khi khai báo biến toàn cục trong header
  • Hai file cùng định nghĩa một hàm trùng tên

#Chạy tay từng bước

Bài thực hành này nên làm ngay, không nên chỉ đọc. Nó biến bốn giai đoạn từ lý thuyết thành thứ bạn nhìn thấy tận mắt.

demo.c
#include <stdio.h>
#define TEN "The gioi"

int main(void)
{
    printf("Xin chao, %s!\n", TEN);
    return 0;
}
terminal
# Bước 1: tiền xử lý, xem macro đã bị thay thế chưa
gcc -E demo.c -o demo.i
wc -l demo.i
812 demo.i
tail -5 demo.i
# Bước 2: sinh assembly
gcc -S demo.i -o demo.s
grep -A 12 'main:' demo.s
# Bước 3: hợp dịch, xem bảng ký hiệu
gcc -c demo.s -o demo.o
nm demo.o
0000000000000000 T main
                 U printf
# Bước 4: liên kết rồi chạy
gcc demo.o -o demo && ./demo
Xin chao, The gioi!

Tự làm thử

  1. Tạo hai file: a.c chứa main, và b.c chứa hàm int nhan_doi(int). Biên dịch riêng thành hai file object rồi liên kết. Sau đó cố tình quên b.o khi liên kết và đọc kỹ thông báo lỗi.
  2. Viết chương trình dùng sqrt() từ <math.h>. Biên dịch không có -lm, đọc lỗi. Thêm -lm vào sai vị trí (trước tên file), đọc lỗi. Rồi đặt đúng vị trí.
  3. So sánh kích thước của gcc demo.c -o d1 gcc -static demo.c -o d2. Chạy ldd cho cả hai. Giải thích vì sao chênh lệch lớn tới vậy.
  4. Dùng gcc -E trên một file có #define#ifdef, quan sát preprocessor xử lý chúng ra sao.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Bốn giai đoạn: tiền xử lý (cpp), biên dịch (cc1), hợp dịch (as), liên kết (ld).
  • Chạy riêng từng bước bằng gcc -E, -S, -c, và giữ tất cả bằng -save-temps.
  • Preprocessor không hiểu C, nó chỉ thao tác trên văn bản.
  • File object có mã máy nhưng địa chỉ chưa chốt. Ký hiệu U nghĩa là chưa biết nằm ở đâu.
  • undefined reference nghĩa là thiếu định nghĩa, multiple definition nghĩa là thừa định nghĩa.
  • Thư viện luôn đặt sau các file cần nó trên dòng lệnh.