gcc hello.c và bạn nhận về một file chạy được. Trông như một bước, nhưng thực ra gcc là một nhạc trưởng điều phối bốn giai đoạn riêng biệt, mỗi giai đoạn là một chương trình khác nhau, biến file nguồn của bạn qua bốn dạng trung gian trước khi thành mã máy. Mở đầu loạt bài về trình biên dịch, hãy tách rời bốn bước đó, đo kích thước và thời gian từng bước trong container — và ngay bài đầu tiên, việc đọc assembly đã lật tẩy một điều tôi tưởng là hiển nhiên.

Bốn giai đoạn dịch

Bốn bước sau một lệnh gcc

gcc mặc định làm liền tù tì bốn việc, nhưng mỗi việc có thể gọi riêng bằng một cờ:

  1. Tiền xử lý (gcc -E): xử lý các chỉ thị bắt đầu bằng # — bung #include (dán nguyên nội dung header vào), thay thế #define (macro), xóa chú thích. Kết quả (.i) vẫn là mã C thuần, chỉ là đã "phẳng" hết mọi chỉ thị.
  2. Biên dịch (gcc -S): dịch mã C đó thành assembly (.s) — ngôn ngữ gần với lệnh CPU. Đây là nơi mọi tối ưu diễn ra.
  3. Hợp dịch (gcc -c): dịch assembly thành mã máy nhị phân trong một file đối tượng (.o), nhưng chưa chạy được — các ký hiệu gọi ra ngoài (như hàm thư viện) còn để trống.
  4. Liên kết (gcc): nối các .o lại với nhau, với thư viện, và với mã khởi động của C, phân giải mọi ký hiệu còn trống, tạo ra file thực thi hoàn chỉnh.

Chuỗi đuôi file kể lại đúng hành trình: .c → .i → .s → .o → a.out. Tôi lấy một hello.c 8 dòng và chạy tách từng bước để đo.

Đo: kích thước và thời gian từng bước

Với hello.c (8 dòng, 117 byte, in một chuỗi bằng printf), đây là kích thước sau mỗi giai đoạn trên máy aarch64:

Dạng Kích thước Ghi chú
.c (nguồn) 117 B 8 dòng ta viết
.i (sau tiền xử lý) 17.959 B 750 dòng
.s (assembly) 538 B mã cho CPU
.o (mã máy) 1.592 B chưa liên kết
a.out (chạy được) 70.480 B hoàn chỉnh

Con số đầu tiên gây choáng: 8 dòng nguồn nở thành 750 dòng sau tiền xử lý. Thủ phạm là #include <stdio.h> — một dòng đó kéo theo toàn bộ khai báo của thư viện chuẩn nhập/xuất. Nghĩa là trình biên dịch ở bước 2 không phân tích 8 dòng của tôi, mà 750 dòng, gần như toàn bộ là khai báo trong header tôi chưa từng đọc. Thời gian từng bước (trung vị 5 lần, ms) cũng cho thấy công sức nằm ở đâu:

tiền xử lý  9 ms      hợp dịch   4 ms
biên dịch  13 ms      liên kết  15 ms

Biên dịch (phân tích 750 dòng) và liên kết (dựng file thực thi, nhét mã khởi động) là hai bước tốn giờ nhất; hợp dịch (dịch một-một assembly sang mã máy) nhanh nhất. Và kích thước file thực thi cuối — 70 KB cho một chương trình 117 byte — chủ yếu do bước liên kết thêm vào mã khởi động C và cấu trúc ELF. Nếu liên kết tĩnh (-static, nhét cả libc vào), nó phình lên 872 KB. Toàn bộ chuyện đó khớp với trực giác. Rồi tôi đọc assembly.

Một lần tôi đo hớ: printf biến mất khỏi assembly

Tôi định minh họa hành trình một lời gọi hàm qua bốn bước bằng cách bám theo printf: nó xuất hiện trong nguồn, phải xuất hiện trong assembly (.s) như một lệnh gọi, rồi thành một ký hiệu ngoài (undefined) trong .o, và cuối cùng được liên kết phân giải tới libc. Một câu chuyện gọn gàng. Để chụp minh chứng, tôi grep tìm printf trong hello.s:

số lần printf trong .s: 0

Không có printf nào cả. Ban đầu tôi ngờ mình grep sai, nhưng chương trình vẫn in đúng chữ. Đọc thẳng assembly thì thấy lệnh gọi thật sự là:

bl	puts

Và ký hiệu ngoài trong .oU puts, không phải U printf. Trình biên dịch đã lặng lẽ đổi printf("%s\n", s) thành puts(s) — một phép biến đổi builtin mà gcc làm ngay ở mức -O0, không cần bật tối ưu. Lý do: khi chuỗi định dạng chỉ là "%s\n" với một tham số chuỗi, puts cho kết quả y hệt mà rẻ hơn, nên trình dịch thay luôn.

Để chắc đây là quy luật chứ không phải may rủi, tôi thử ngược: printf("x=%d y=%d\n", 1, 2) — chuỗi định dạng có %d, không rút gọn thành puts được. Lần này assembly giữ printf (printf=1, puts=0), đúng như dự đoán khi đọc mã. Vậy quy luật rõ ràng: bước biên dịch không chỉ dịch trung thành mã của tôi, nó biến đổi mã. Bài học đo lường mở đầu cả sê-ri: đừng đoán trình biên dịch làm gì với mã của bạn — đọc assembly, để mã máy tự nói. Nếu tôi chỉ đếm dòng và tin vào giả định "printf trong nguồn thì printf trong .o", tôi đã ghi vào bài một ký hiệu sai. Chính việc đọc .s — thứ kỷ luật mà loạt bài này đặt lên hàng đầu — mới bắt được sự thật.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là biết dừng ở đúng giai đoạn để gỡ lỗi. Khi gặp lỗi khó hiểu, tách bốn bước ra là cách khoanh vùng nhanh nhất. Lỗi macro bung sai? Xem .i bằng gcc -E. Nghi trình dịch sinh mã lạ? Xem .s bằng gcc -S. Lỗi "undefined reference"? Đó là bước liên kết, không phải biên dịch — file .o của bạn dịch tốt rồi, chỉ là thiếu định nghĩa lúc nối. Phân biệt "lỗi biên dịch" với "lỗi liên kết" cắt một nửa thời gian mò mẫm, vì chúng ở hai giai đoạn khác nhau với cách sửa khác nhau.

Hệ quả thứ hai là hiểu vì sao thời gian dịch phần lớn không phải vì code của bạn. Một file 8 dòng mất công phân tích 750 dòng vì header. Trong dự án thật, mỗi .c include hàng chục header, mỗi header lại include header khác, và trình dịch phân tích lại toàn bộ đống đó cho mỗi đơn vị dịch — đây là gốc rễ của thời gian build lâu, và là lý do có precompiled headers, #pragma once, và module hóa. Con số 8→750 nhỏ xíu này phóng lên quy mô nghìn file là hàng triệu dòng bị phân tích lặp đi lặp lại.

Hệ quả thứ ba là bài học xuyên suốt sê-ri: trình biên dịch là một tầng chủ động biến đổi, không phải một cỗ máy dịch trung thành. Nó đổi printf thành puts, tính sẵn hằng số, xóa mã chết, đảo thứ tự lệnh — tất cả trong khi vẫn giữ kết quả chương trình đúng. Con số mang theo: một lệnh gcc là bốn giai đoạn (.c → .i → .s → .o → a.out); mỗi bước có cờ riêng để dừng lại và soi; và bước biên dịch có toàn quyền viết lại mã của bạn miễn là kết quả không đổi — nên muốn biết CPU thật sự chạy gì, phải đọc assembly chứ không đọc mã nguồn. Đó là tinh thần của mọi bài tiếp theo: giữa mã bạn viết và mã máy chạy là một khoảng cách, và chỉ phép đo mới lấp được nó.

Thử ba mươi giây

Lấy một file C bất kỳ có printf và chạy gcc -S -O0 file.c -o file.s, rồi mở file.s tìm dòng call (trên x86) hoặc bl (trên ARM). Nếu bạn printf một chuỗi kết thúc bằng \n và không có định dạng nào khác, nhiều khả năng bạn sẽ thấy puts thay vì printf — trình dịch đã đổi hộ. Thử đổi chuỗi thành có %d rồi dịch lại: printf quay về. Chỉ hai lần dịch và hai lần đọc .s là bạn tận mắt thấy trình biên dịch viết lại mã mình — bài học đầu tiên và quan trọng nhất khi làm việc với nó.