Suốt sê-ri này tôi đo trên aarch64 (máy Apple Silicon chạy container). Nhưng phần mềm thật thường chạy trên nhiều kiến trúc — máy chủ x86-64, điện thoại ARM, thiết bị nhúng RISC-V. Biên dịch chéo (cross-compilation) là dịch trên một kiến trúc cho một kiến trúc khác. Bài này biên dịch cùng một file C cho cả aarch64 lẫn x86-64, đọc hai bản assembly, chạy trên cả hai — và phát hiện một điều mà tôi đã ngầm cho là không thể.
Cùng nguồn, mã máy khác
Trình biên dịch không sinh ra "mã máy" chung chung; nó sinh mã cho một tập lệnh (ISA) cụ thể. Cùng một hàm int add(int a, int b){ return a + b; } cho hai đích khác nhau:
aarch64: add w0, w0, w1 ; đối số ở x0/x1, kết quả ở x0
x86-64: leal (%rdi,%rsi), %eax ; đối số ở rdi/rsi, kết quả ở eax
Hai bản làm cùng một việc (cộng hai số) nhưng bằng lệnh khác, thanh ghi khác, và quy ước gọi khác: aarch64 truyền đối số qua x0–x7 và trả về ở x0; x86-64 truyền qua rdi, rsi, rdx, ... và trả về ở rax. Đây là điều ai cũng chờ đợi ở biên dịch chéo: cùng logic, khác mã máy. Tôi vào bài định dừng ở đây — cho đến khi tôi chạy thử.
Một lần tôi đo hớ: cùng nguồn, khác kết quả
Tôi tin chắc biên dịch chéo chỉ đổi lệnh — thanh ghi, mnemonic — còn kết quả thì y hệt, vì suy cho cùng đó là "cùng một chương trình". Để minh họa, tôi viết một hàm tầm thường: int from_char(char c){ return c; }, gán c = (char)200 (byte 0xC8, bit cao bật), và in kết quả trên cả hai đích. Tôi chờ hai con số giống nhau.
Chúng không giống nhau:
aarch64: from_char(200) = 200
x86-64: from_char(200) = -56
Cùng một dòng mã, hai kết quả khác hẳn. Đọc assembly để hiểu vì sao:
aarch64: and w0, w0, 255 ; mở rộng bằng số 0 (zero-extend) -> char là UNSIGNED
x86-64: movsbl %dil, %eax ; mở rộng theo dấu (sign-extend) -> char là SIGNED
Thủ phạm: kiểu char có dấu hay không là do kiến trúc/ABI quy định, không phải do ngôn ngữ C. Trên aarch64, char mặc định là unsigned, nên 0xC8 = 200. Trên x86-64, char mặc định là signed, nên 0xC8 = −56 (bù hai). Chuẩn C cố tình để ngỏ điều này, và mỗi nền chọn một kiểu — nên khi char giữ một giá trị có bit cao bật, cùng đoạn mã cho hai đáp án trên hai đích.
Đây là một đo hớ đúng ngay tim của cả sê-ri. Tôi đã bê nguyên trực giác từ kiến trúc mình đang ngồi (aarch64, nơi from_char(200) ra 200) và ngầm cho rằng nó phổ quát. Biên dịch chéo là công cụ tách biến "kiến trúc" ra khỏi phép đo: nó cho thấy phần nào của hành vi thật sự thuộc về chương trình, và phần nào chỉ là đặc điểm của phần cứng tôi tình cờ dùng. Mô hình "cross = cùng hành vi, chỉ khác mã" của tôi sai: cross có thể phơi bày chỗ code đang lặng lẽ dựa vào một chi tiết riêng của một đích. Và char chỉ là một ví dụ — kích thước long (8 byte trên Linux 64-bit nhưng 4 byte trên Windows 64-bit), thứ tự byte (endian), quy tắc căn lề đều thuộc nhóm "do đích định nghĩa" ấy.
Dịch dễ hơn liên kết
Có một chi tiết thực tế đáng biết về biên dịch chéo: biên dịch sang assembly của kiến trúc khác thì dễ (chỉ cần trình biên dịch biết ISA đích), nhưng liên kết thành một chương trình chạy được thì khó hơn nhiều. Trình liên kết cần thư viện chuẩn (libc), các file khởi động, và header của đích — một bộ gọi là sysroot — chứ không phải của máy host. Đây là lý do một trình biên dịch chéo đầy đủ thường đi kèm cả một toolchain (aarch64-linux-gnu-gcc, x86_64-w64-mingw32-gcc...) mang theo sysroot của đích. Cách tôi dùng ở bài này — chạy hẳn một container của kiến trúc đích qua giả lập — né được rắc rối đó, vì bên trong container mọi thư viện đều là của đích. Khi bạn thấy lỗi kiểu "cannot find -lc" lúc cross-compile, gần như luôn là thiếu sysroot của đích, không phải lỗi mã nguồn.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đừng giả định char có dấu, và đừng giả định kích thước kiểu. Nếu bạn cần một byte có dấu rõ ràng, viết signed char; cần không dấu, viết unsigned char; char trơn chỉ nên dùng cho ký tự văn bản, nơi dấu không quan trọng. Tương tự, khi cần một kiểu nguyên có độ rộng cố định, dùng int32_t, uint64_t từ <stdint.h> thay vì int/long — để mã không đổi hành vi khi chuyển từ đích 64-bit sang 32-bit, hay từ Linux sang Windows. Những giả định về kiểu là nguồn lỗi cổ điển khi port phần mềm sang kiến trúc mới.
Hệ quả thứ hai: biên dịch chéo là cách kiểm tra tính khả chuyển trước khi triển khai, và nó rẻ. Bạn không cần một máy ARM để biết mã của mình chạy đúng trên ARM — chỉ cần một trình biên dịch chéo (hoặc một container cho kiến trúc đó, như tôi dùng ở đây). Xa hơn, chạy được bản biên dịch chéo dưới trình giả lập (qemu, đúng cái container --platform này làm) cho phép chạy và test trên kiến trúc đích ngay trên máy phát triển. Kết hợp với sanitizer ở bài trước, bạn có thể bắt cả lỗi bộ nhớ và lỗi phụ thuộc kiến trúc mà không cần phần cứng thật.
Hệ quả thứ ba là bài học đo lường mang theo, và là một trong những nguyên tắc lặp lại nhiều nhất của sê-ri: đừng bê trực giác từ phần cứng này sang phần cứng khác — hãy tách biến kiến trúc. Con số mang theo: cùng một hàm from_char(200) trả về 200 trên aarch64 (char unsigned) nhưng -56 trên x86-64 (char signed), với quy ước gọi và lệnh mở rộng khác nhau — vì dấu của char, kích thước long, endian và căn lề đều do kiến trúc định nghĩa, không phải ngôn ngữ; nên biên dịch chéo rồi chạy trên đích thật là cách duy nhất để biết mã của bạn thật sự khả chuyển. Một phép đo trên một kiến trúc chỉ nói về kiến trúc đó; muốn biết hành vi của chương trình, phải đo trên nhiều đích và tách cái chung ra khỏi cái riêng. Đây cũng là lời kết tự nhiên cho cả sê-ri: trình biên dịch không phải một cái hộp đen dịch mã một-một, mà là một bộ máy đưa ra hàng loạt quyết định — tối ưu, bố cục, chọn lệnh, và cả những mặc định phụ thuộc đích như dấu của char — mỗi quyết định là một chỗ mà 'mã tôi viết' và 'mã thật sự chạy' có thể lệch nhau. Cách duy nhất để biết chắc là đo, và đo trên đúng thứ mình sẽ chạy.
Thử ba mươi giây
Viết int f(char c){ return c; } và một main in f((char)200), rồi biên dịch cho hai kiến trúc — nếu có trình biên dịch chéo thì dùng nó, còn không thì dùng Docker: docker run --rm gcc:13 (kiến trúc máy bạn) và docker run --rm --platform linux/amd64 gcc:13 (x86-64, chạy qua giả lập). Chạy cả hai và so kết quả: trên một máy ARM/Apple Silicon bạn sẽ thấy 200, trên x86-64 bạn sẽ thấy -56. Rồi thêm gcc -S và đọc assembly quanh hàm: một bên and/uxtb (mở rộng số 0), một bên movsbl (mở rộng dấu). Bạn vừa thấy tận mắt một dòng C cho hai kết quả khác nhau tùy đích — và hiểu vì sao "chạy đúng trên máy tôi" không đủ khi phần mềm phải chạy ở nhiều nơi.