Khi bạn gọi f(1, 2, 3), làm sao hàm f biết tìm các đối số ở đâu, và bạn nhận giá trị trả về từ chỗ nào? Câu trả lời là một bản hợp đồng gọi là quy ước gọi hàm (calling convention), một phần của ABI — thứ mà cả người gọi và người bị gọi phải tuân theo dù chúng được biên dịch riêng rẽ. Bài này đo bản hợp đồng đó trên aarch64: đối số nào đi vào thanh ghi nào, và tôi vấp một quy tắc mà trực giác "đếm theo vị trí" của tôi đã sai hoàn toàn.

Quy ước gọi hàm

Người gọi và người bị gọi phải thỏa thuận trước

Vì mỗi hàm được biên dịch độc lập (như bài đơn vị dịch đã đo), người gọi và người bị gọi không thấy mã của nhau. Chúng chỉ hợp tác được nếu thống nhất trước một bộ quy tắc: đối số đặt ở đâu, giá trị trả về để ở đâu, ai giữ gìn thanh ghi nào. Trên aarch64 (ABI AAPCS), quy tắc cốt lõi là:

  • Đối số kiểu số nguyên / con trỏ: tám cái đầu đi vào các thanh ghi x0x7; từ cái thứ chín trở đi tràn ra stack.
  • Đối số kiểu float / double: đi vào một dải thanh ghi riêng d0d7 (v0–v7).
  • Giá trị trả về: số nguyên ở x0, số thực ở d0.

Tôi kiểm từng quy tắc bằng một mẹo: viết hàm chỉ trả về đúng một đối số, rồi đọc assembly xem nó đọc từ thanh ghi nào.

Đo: đối số nằm ở đâu

Hàm int arg5(int a,int b,int c,int d,int e){ return e; } biên dịch ở -O2 thành:

mov  w0, w4      ; e nằm ở w4 (tức x4)
ret

Đối số thứ 5 nằm ở x4 — khớp quy tắc "đối số int thứ n vào x(n-1)" cho tám cái đầu. Còn đối số thứ 9? Hàm trả về nó cho ra:

ldr  x0, [sp]    ; nạp từ STACK

Đúng như dự đoán: quá tám thanh ghi, đối số thứ chín được người gọi đẩy lên stack, và người bị gọi đọc lại từ [sp]. Giá trị trả về thì luôn ở x0 (số nguyên). Cũng nhờ quy ước cố định này mà một hàm viết bằng assembly tay có thể được gọi từ C: nó chỉ cần đọc đối số từ đúng các thanh ghi đã thỏa thuận và đặt kết quả vào x0. Mọi thứ khớp trực giác — cho tới khi tôi trộn int với float.

Một lần tôi đo hớ: đối số thứ 3 lại ở x1

Tôi tin chắc một quy tắc gọn: đối số thứ N nằm ở thanh ghi x(N-1). Nên với f(int a, double b, int c), tôi đoán ax0, bx1, cx2. Để kiểm, tôi viết hàm trả về c và đọc assembly:

mov  w0, w1      ; c nằm ở x1, KHÔNG phải x2!
ret

c — đối số thứ ba — lại nằm ở x1. Và hàm trả về b (double) chỉ có đúng một lệnh ret, nghĩa là b đã sẵn ở d0 (thanh ghi trả về của double) nên chẳng cần di chuyển gì. Trực giác "đếm theo vị trí chung" của tôi sai.

Lý do: int và float dùng hai dải thanh ghi hoàn toàn riêng, mỗi dải có một bộ đếm độc lập. Số nguyên và con trỏ lấy lần lượt x0, x1, x2...; số thực lấy lần lượt d0, d1, d2.... Trong f(int a, double b, int c), a là int đầu tiên → x0, c là int thứ haix1, còn b là float đầu tiên → d0. Đối số b không hề chiếm chỗ x1 — nó ở một dải khác. Nên thanh ghi mà một đối số rơi vào phụ thuộc vào số đối số cùng loại trước nó, chứ không phải vị trí chung của nó trong danh sách.

Bài học đo lường: một quy tắc "gần đúng" (đối số thứ N ở x(N-1)) che một cấu trúc thật sự phức tạp hơn — hai dải thanh ghi song song. Và hệ quả không chỉ là chuyện học thuật: nếu khai sai nguyên mẫu một hàm (một file nghĩ tham số là int, file kia định nghĩa là double), đối số sẽ được đọc nhầm dải thanh ghi — bên gọi đặt vào x, bên nhận đọc từ d, ra giá trị rác. Đây chính là mặt phần cứng của cái bug "linker khớp tên không khớp kiểu" đã đo ở bài đơn vị dịch: quy ước gọi hàm là nơi sự bất đồng kiểu biến thành dữ liệu sai. (Tôi cũng đo thêm: trả về một struct lớn 24 byte không dùng thanh ghi trả về, mà người gọi cấp sẵn chỗ và truyền một con trỏ ẩn trong x8 cho hàm ghi kết quả vào — nên "trả về struct" thực chất là nhận thêm một tham số ẩn.)

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

Hệ quả đầu tiên là nguyên mẫu hàm phải khớp tuyệt đối giữa các file — đúng kiểu, đúng thứ tự, đúng số lượng. Sai một kiểu (int ↔ double, hay quên một tham số) không gây lỗi biên dịch nếu hai file không cùng thấy nguyên mẫu, mà gây đọc nhầm thanh ghi lúc chạy — một bug âm thầm cực khó lần. Đây là lý do sâu xa vì sao ta luôn khai hàm trong header dùng chung: để trình biên dịch bắt được bất đồng trong cùng một đơn vị dịch.

Hệ quả thứ hai là hiểu vì sao truyền ít đối số lại nhanh hơn, và vì sao struct lớn tốn kém. Tám đối số đầu miễn phí (chỉ là thanh ghi); từ cái thứ chín phải ghi/đọc stack, chậm hơn. Trả về hay truyền struct lớn theo giá trị kéo theo sao chép qua bộ nhớ và một con trỏ ẩn. Biết điều này giúp bạn thiết kế API nóng gọn gàng: gom tham số, truyền con trỏ tới struct lớn thay vì bản sao. Ngược lại, đừng tối ưu quá sớm chuyện này: với đa số hàm, chi phí truyền vài đối số là không đáng kể so với công việc thật bên trong.

Hệ quả thứ ba là quy ước gọi hàm là chỗ các ngôn ngữ gặp nhau. Khi C gọi assembly, khi Go gọi C qua cgo, khi bạn viết một hàm gọi được từ ngôn ngữ khác — tất cả đều phải nói cùng một ABI. Hiểu "đối số ở thanh ghi nào, trả về ở đâu, ai giữ thanh ghi nào" là điều kiện để ghép các mảnh viết bằng ngôn ngữ khác nhau. Con số mang theo: trên aarch64, đối số int/con trỏ đi vào x0–x7 rồi tràn stack, float/double đi vào dải RIÊNG d0–d7 với bộ đếm độc lập (nên đối số thứ 3 trong f(int,double,int) ở x1 chứ không x2), giá trị trả về ở x0/d0, và struct lớn trả về qua con trỏ ẩn x8. Quy ước này là bản hợp đồng vô hình khiến các hàm biên dịch riêng rẽ ghép lại chạy được — và đọc assembly là cách duy nhất thấy nó tận mắt.

Thử ba mươi giây

Viết long pick(long a, long b, long c){ return b; }, dịch gcc -O2 -S file.c -o file.s, và xem hàm: bạn sẽ thấy mov x0, x1bx1. Giờ đổi thành long pick(long a, double b, long c){ return c; } và dịch lại: c giờ ở x1 (mov x0, x1), không phải x2, vì b là double đã nhảy sang dải d. Chỉ hai lần đọc .s là bạn thấy tận mắt hai dải thanh ghi song song — và hiểu vì sao khai đúng kiểu tham số không phải chuyện hình thức mà là chuyện đúng/sai của dữ liệu.