Bài SSA cho thấy compiler biến code thành mã máy. Bài này ta học đọc cái mã máy đó — ở dạng assembly. Nhưng Go dùng một cú pháp assembly riêng, kế thừa từ hệ điều hành Plan 9 của Bell Labs, khác cả cú pháp Intel lẫn AT&T mà bạn có thể quen. Lần đầu nhìn MOVD R0, main.toanCuc(SB) rất khó hiểu. Bài này giải mã ba điều lạ nhất bằng assembly thật của một hàm Go trên hai kiến trúc.

Điều lạ 1: thứ tự toán hạng nguồn trước, đích sau

Quy tắc quan trọng nhất: trong assembly Go, toán hạng nguồn đứng trước, đích đứng cuối. MOVD src, dst nghĩa là dst = src. Với phép hai toán hạng:

// c := a + b  (arm64, a trong R0, b trong R1)
ADD  R1, R0, R0     // R0 = R0 + R1 — đích là toán hạng CUỐI

Điều này giống cú pháp AT&T (nguồn trước) và ngược với cú pháp Intel (đích trước). Nếu bạn quen Intel/NASM, đây là chỗ dễ nhầm nhất: đọc MOVD R0, X là "chép R0 vào X", không phải "chép X vào R0".

Ảnh chụp đoạn mã Go nền tối minh hoạ đọc assembly Plan 9 của Go cú pháp lạ mà nhất quán, Go dùng cú pháp assembly riêng kế thừa Plan 9 trừu tượng một phần trên nhiều kiến trúc ba điều lạ cần nhớ, một thứ tự toán hạng nguồn trước đích sau c bằng a cộng b arm64 a trong R0 b trong R1 ADD R1 R0 R0 R0 bằng R0 cộng R1 đích là toán hạng cuối khác Intel đích trước giống AT và T nguồn trước quy tắc chung đọc từ trái qua phải là lấy các nguồn đặt vào đích cuối MOVD src dst nghĩa là dst bằng src, hai bốn pseudo-register trừu tượng không phải thanh ghi thật SB Static Base hàm và biến toàn cục main cong SB name SB FP Frame Pointer tham số arg0 cộng 0 FP arg1 cộng 8 FP SP Stack Pointer biến cục bộ local trừ 8 SP PC Program Counter bốn cái này không phải thanh ghi vật lý chúng là ký hiệu trừu tượng của assembler dịch sang thanh ghi offset thật tùy kiến trúc SB cho địa chỉ tĩnh FP cho tham số SP cho cục bộ, ba Go nguồn để đối chiếu var toanCuc int truy cập qua SB func cong a b int tham số qua FP hoặc thanh ghi ABIInternal c bằng a cộng b toanCuc bằng c ghi biến toàn cục qua SB return c, bốn hậu tố kích thước theo kiến trúc arm64 MOVD 8 byte MOVW 4 MOVB 1 ADD MUL amd64 MOVQ 8 byte MOVL 4 MOVB 1 ADDQ MULQ tên thanh ghi cũng khác arm64 R0 tới R30 amd64 AX BX không phải RAX

Hình 1: Ba điều lạ của assembly Plan 9. Thứ tự nguồn-trước-đích-sau; bốn pseudo-register SB/FP/SP/PC; hậu tố kích thước và tên thanh ghi khác theo kiến trúc.

Điều lạ 2: bốn pseudo-register không phải thanh ghi thật

Assembly Go dùng bốn "pseudo-register" — ký hiệu trừu tượng của assembler, không phải thanh ghi vật lý:

  • SB (Static Base): địa chỉ tĩnh — tên hàm và biến toàn cục. main.cong(SB), main.toanCuc(SB).
  • FP (Frame Pointer): tham số hàm. arg0+0(FP), arg1+8(FP).
  • SP (Stack Pointer): biến cục bộ trên stack. local-8(SP).
  • PC (Program Counter): con trỏ lệnh.

Chúng được assembler dịch sang thanh ghi/offset thật tùy kiến trúc. Khi thấy (SB), hiểu là "một địa chỉ tĩnh" (hàm hoặc biến toàn cục); (FP) là "một tham số"; (SP) là "một biến cục bộ". Đây là cách Go trừu tượng hóa việc đánh địa chỉ.

Đọc thật: một hàm Go trên arm64

Với hàm cong(a, b int) int { c := a+b; toanCuc = c; return c }, assembly arm64:

TEXT main.cong(SB), LEAF|NOFRAME|ABIInternal, $0-16
  // TEXT khai hàm; $0-16 = frame 0 byte, tham số 16 byte
ADD  R1, R0, R0            // c = a+b (R0=a, R1=b) — nguồn trước, đích sau
MOVD R0, main.toanCuc(SB)  // ghi biến toàn cục qua SB
RET  (R30)                 // trả về qua thanh ghi liên kết R30

Chú thích:

  • TEXT main.cong(SB), ..., $0-16: khai một hàm. $0-16 đọc là "frame 0 byte, args 16 byte" (hai tham số int = 16 byte).
  • ADD R1, R0, R0: cộng, đích cuối.
  • MOVD R0, main.toanCuc(SB): ghi biến toàn cục qua SB.
  • RET (R30): trả về. Trên arm64, R30 là thanh ghi liên kết (link register) giữ địa chỉ trả về.

Ảnh chụp bảng kết quả đọc thật nền tối assembly Plan 9 của một hàm Go go build gcflags trừ S Go 1.23 cùng nguồn hai kiến trúc, cong a b trên arm64 chú thích từng dòng TEXT main cong SB LEAF NOFRAME ABIInternal 0 trừ 16 TEXT khai hàm 0 trừ 16 bằng 0 byte frame 16 byte tham số ADD R1 R0 R0 c bằng a cộng b R0 bằng a R1 bằng b nguồn trước đích sau MOVD R0 main toanCuc SB ghi biến toàn cục qua SB RET R30 trả về qua thanh ghi liên kết R30 SB xuất hiện hai lần tên hàm main cong SB và biến toàn cục main toanCuc SB 0 trừ 16 đọc là frame 0 args 16, goiHam gọi cong prologue kiểm tra stack MOVD 16 g R16 nạp giới hạn stack từ thanh ghi g goroutine CMP R16 RSP so con trỏ stack với giới hạn BLS 44 nếu thiếu stack nhảy tới morestack MOVD 10 R1 tham số thứ 2 bằng 10 vào R1 ABIInternal CALL main cong SB gọi hàm qua SB RET R30 CALL runtime morestack_noctxt SB đường mọc thêm stack mọi hàm trừ nosplit mở đầu bằng kiểm tra stack qua thanh ghi g đây là nền tảng cho goroutine stack co giãn, cùng hàm cong trên amd64 đối chiếu ADDQ BX AX thứ tự Plan 9 y hệt nguồn BX đích AX MOVQ AX main toanCuc SB hậu tố Q quad bằng 8B tên AX không phải RAX RET cấu trúc giống arm64 thứ tự toán hạng SB chỉ khác mnemonic ADDQ MOVQ và tên thanh ghi AX BX, cốt lõi thứ tự nguồn trước đích sau MOVD src dst thành dst bằng src SB FP SP pseudo-register tĩnh tham số cục bộ N trừ M frame N byte args M byte prologue kiểm stack qua g morestack stack co giãn đa kiến trúc cùng cú pháp mnemonic thanh ghi theo CPU

Hình 2: Assembly thật của cong (arm64, chú thích từng dòng), prologue kiểm tra stack của goiHam, và cùng hàm trên amd64 để đối chiếu.

Điều lạ 3: prologue kiểm tra stack qua thanh ghi g

Mọi hàm Go (trừ hàm nosplit) mở đầu bằng một đoạn prologue lạ mắt:

MOVD 16(g), R16    // nạp giới hạn stack từ thanh ghi g (goroutine hiện tại)
CMP  R16, RSP      // so con trỏ stack với giới hạn
BLS  44            // nếu thiếu stack → nhảy tới morestack
...
CALL runtime.morestack_noctxt(SB)  // đường mọc thêm stack

g là một thanh ghi đặc biệt (R28 trên arm64) luôn trỏ tới cấu trúc goroutine hiện tại. Prologue này kiểm tra xem stack còn đủ chỗ không; nếu không, gọi morestack để nới stack. Đây chính là nền tảng của goroutine stack co giãn — thứ cho phép hàng triệu goroutine mỗi cái stack nhỏ 2KB, lớn dần khi cần (như đã bàn ở bài stack growth).

Đa kiến trúc: cùng cú pháp, chi tiết khác

Cùng hàm cong trên amd64:

ADDQ BX, AX             // thứ tự Plan 9 y hệt: nguồn BX, đích AX
MOVQ AX, main.toanCuc(SB)  // hậu tố Q (quad=8 byte), tên AX không phải RAX
RET

Cấu trúc giống hệt arm64 (thứ tự toán hạng, SB), chỉ khác: mnemonic có hậu tố kích thước (ADDQ/MOVQ — Q là quad 8 byte, so với MOVL 4 byte, MOVB 1 byte), và tên thanh ghi là AX/BX (không phải RAX/RBX dù là 64-bit). Đây là điểm hay của Go asm: nó trừu tượng một phần — cấu trúc và quy ước giống nhau trên mọi kiến trúc, chỉ mnemonic và tên thanh ghi theo CPU.

Ứng dụng thực tế

Đọc assembly để xác minh tối ưu. Khi -gcflags=-m nói một hàm được inline hay benchmark cho số lạ, đọc assembly (-gcflags=-S) là cách chắc chắn nhất xem compiler thực sự sinh ra gì — có CALL không (inline được chưa), có MUL không (strength reduction chưa), có tra itab không (devirtualize chưa).

Hiểu pprof ở mức lệnh. go tool pprof với chế độ disassembly (disasm) hiển thị thời gian CPU trên từng dòng assembly. Biết đọc Plan 9 giúp bạn thấy đúng lệnh nào là điểm nóng — một MOVD từ bộ nhớ (cache miss) hay một CALL lặp.

Nền tảng để viết assembly Go. Nếu sau này bạn cần viết một hàm assembly thủ công (crypto, SIMD, atomic đặc biệt), phải hiểu cú pháp này — đó là chủ đề bài sau. Biết đọc trước khi biết viết.

Đánh đổi cần cân nhắc

Assembly Go trừu tượng, không phải assembly "thật" của CPU. Go asm là một lớp trung gian: một MOVD có thể sinh ra nhiều lệnh máy thật, và assembler tự chèn prologue/epilogue. Đừng nhầm nó với assembly nguyên bản của kiến trúc — nó gần hơn nhưng vẫn có mức trừu tượng. Muốn thấy lệnh máy tuyệt đối, dùng go tool objdump trên binary.

Đọc assembly tốn thời gian, chỉ đáng cho điểm nóng thật. Với 99% code, -gcflags=-m và benchmark đã đủ. Chỉ xuống assembly khi bạn cần xác minh một tối ưu cụ thể hoặc gỡ một điểm nóng mà profiler chỉ ra. Đừng đọc assembly của mọi hàm.

Cú pháp Plan 9 dễ nhầm với người quen Intel. Thứ tự toán hạng ngược và tên thanh ghi khác (AX không phải RAX) là bẫy thường trực. Khi chuyển từ đọc assembly gdb/objdump (thường cú pháp AT&T hoặc Intel) sang Go asm, luôn nhắc mình quy tắc nguồn-trước-đích-sau.

Ba ý mang về

  1. Thứ tự toán hạng Plan 9 là nguồn trước, đích sau: MOVD src, dst nghĩa là dst = src, và ADD R1, R0, R0 là R0 = R0 + R1 — giống AT&T, ngược Intel, là chỗ dễ nhầm nhất khi đọc lần đầu.
  2. Bốn pseudo-register SB/FP/SP/PC là ký hiệu trừu tượng, không phải thanh ghi thật: (SB) cho địa chỉ tĩnh (hàm/biến toàn cục), (FP) cho tham số, (SP) cho biến cục bộ — assembler dịch sang thanh ghi/offset thật tùy kiến trúc.
  3. Prologue kiểm tra stack qua thanh ghi g là nền tảng goroutine stack co giãn: mọi hàm (trừ nosplit) mở đầu bằng so con trỏ stack với giới hạn rồi gọi morestack khi thiếu — và cú pháp Go asm trừu tượng một phần, cùng cấu trúc trên mọi CPU, chỉ khác mnemonic (MOVD/MOVQ) và tên thanh ghi.

Phần sau ta chuyển từ đọc sang viết: Phần sau hướng dẫn viết một hàm assembly Plan 9 đơn giản từ đầu — khai báo trong file .s, khớp với khai báo Go, nhận tham số, trả kết quả, và khi nào thực sự đáng viết assembly thủ công.