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".

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,R30là thanh ghi liên kết (link register) giữ địa chỉ trả về.

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ề
- Thứ tự toán hạng Plan 9 là nguồn trước, đích sau:
MOVD src, dstnghĩa làdst = src, vàADD R1, R0, R0làR0 = R0 + R1— giống AT&T, ngược Intel, là chỗ dễ nhầm nhất khi đọc lần đầu. - 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. - Prologue kiểm tra stack qua thanh ghi
glà 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ọimorestackkhi 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.