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 hiếm khi sống một mình một kiến trúc: máy chủ x86-64, điện thoại ARM, thiết bị nhúng RISC-V, mỗi nơi một tập lệnh. 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 tôi 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à vấp phải một điều mà tôi đã ngầm cho là không thể xảy ra.

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; }, hai đích cho hai bản 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 đúng 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 rồi 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 đúng ở đây — cho tới lúc tôi chạy thử.

Cùng nguồn, khác kết quả — chỗ tôi đã tin nhầm

Tôi tin chắc biên dịch chéo chỉ đổi lệnh — thanh ghi, mnemonic — còn kết quả thì phải y hệt, vì suy cho cùng đó là "cùng một chương trình". Để minh họa cho niềm tin đó, 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), rồi in kết quả trên cả hai đích. Tôi ngồ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 mới thấy 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 unsigned, nên 0xC8 = 200. Trên x86-64, char mặc định 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à chỗ đâm thẳng vào 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, 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 ra 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ề đến code hằng ngày

Điều đầu tiên: đừng giả định char có dấu, và đừng giả định kích thước kiểu. Cần một byte có dấu rõ ràng thì viết signed char; cần không dấu thì 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 cho 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 mỗi khi port phần mềm sang kiến trúc mới.

Điều 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.

Và điều cuối, cũng là nguyên tắc đo lường 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. Gói lại bằng số: 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.

Chuyện "cùng nguồn, khác kết quả tùy nền" này không riêng gì C. Java sinh ra một phần cũng để dập tắt nó: int luôn 32-bit, char luôn 16-bit unsigned, >> với >>> tách bạch dấu — JVM cố tình định nghĩa cứng mọi thứ để "write once, run anywhere" thành thật, đúng cái mà C để ngỏ. Nhưng ngay Java cũng không thoát hẳn: thứ tự byte khi bạn tự đọc ByteBuffer, hay giá trị System.lineSeparator() vẫn đổi theo nền. Go thì đi giữa: kích thước kiểu cố định (int32, int64 rõ ràng) nhưng int vẫn tùy nền 32 hay 64-bit, và cross-compile lại là điểm mạnh nhất của nó (GOOS=windows GOARCH=amd64 go build là xong). Cơ sở dữ liệu cũng có phiên bản của cái bẫy này: một câu ORDER BY trên cột text cho thứ tự khác nhau tùy collation của server — cùng dữ liệu, cùng câu SQL, khác kết quả sắp xếp. Mẫu số chung ở mọi tầng: "chạy đúng trên máy tôi" chưa bao giờ là bằng chứng cho "chạy đúng ở mọi nơi", và cách duy nhất để chắc là chạy thử trên đúng thứ mình sẽ triển khai.

Muốn tự thấy thì 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 — có trình biên dịch chéo thì dùng, 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: trên máy ARM/Apple Silicon bạn thấy 200, trên x86-64 bạn 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.