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.

Bài tập làm thử

Bài 1 (đọc hiểu). Đọc dòng assembly arm64 sau và cho biết ý nghĩa chính xác của nó, chú ý thứ tự toán hạng kiểu Plan 9.

ADD  R1, R0, R0
Đáp án

Trong assembly Plan 9 của Go, toán hạng nguồn đứng trước, đích đứng cuối. Dòng này nghĩa là R0 = R0 + R1 (cộng giá trị hiện có trong R0 với R1, ghi kết quả trở lại R0). Đây là điều ngược với cú pháp Intel (nơi đích thường đứng trước) — dễ nhầm nhất với người quen đọc assembly Intel/NASM.

Bài 2 (sửa lỗi/nhận diện lỗi). Một người mới đọc dòng MOVD R0, main.toanCuc(SB) và diễn giải là "chép giá trị của biến toàn cục toanCuc vào thanh ghi R0". Cách hiểu này sai ở đâu? Nêu cách hiểu đúng.

Đáp án

Sai vì đã đọc ngược thứ tự toán hạng. Theo quy tắc nguồn-trước-đích-sau (MOVD src, dst nghĩa là dst = src), dòng này thực chất là: chép giá trị đang có trong thanh ghi R0 vào biến toàn cục main.toanCuc (được định vị qua pseudo-register SB). Tức là ghi từ thanh ghi ra biến toàn cục, không phải đọc biến toàn cục vào thanh ghi.

Bài 3 (đọc hiểu — pseudo-register). Giải thích sự khác biệt giữa SB, FP, và SP trong assembly Go — mỗi cái dùng để đánh địa chỉ loại gì, và chúng có phải thanh ghi vật lý thật không?

Đáp án

Cả ba đều là pseudo-register — ký hiệu trừu tượng của assembler, không phải thanh ghi vật lý thật; assembler dịch chúng sang thanh ghi/offset thật tùy kiến trúc. SB (Static Base) dùng cho địa chỉ tĩnh: tên hàm và biến toàn cục (ví dụ main.cong(SB)). FP (Frame Pointer) dùng để truy cập tham số của hàm (ví dụ arg0+0(FP)). SP (Stack Pointer) dùng để truy cập biến cục bộ trên stack (ví dụ local-8(SP)).

Bài 4 (vận dụng thực tế — đa kiến trúc). Cùng một hàm cong biên dịch trên arm64 cho ra ADD R1, R0, R0 còn trên amd64 cho ra ADDQ BX, AX. Nêu hai điểm giống nhau và hai điểm khác nhau giữa hai đoạn assembly này, thể hiện ý "Go asm trừu tượng một phần".

Đáp án

Giống nhau: (1) cùng tuân theo quy tắc thứ tự toán hạng nguồn-trước-đích-sau của Plan 9 (cả hai đều ghi kết quả vào toán hạng cuối). (2) cùng dùng pseudo-register SB để truy cập biến toàn cục/hàm. Khác nhau: (1) tên mnemonic khác — ADD (arm64) so với ADDQ (amd64, hậu tố Q nghĩa là quad/8 byte). (2) tên thanh ghi khác — R0, R1 (arm64) so với AX, BX (amd64, không phải RAX/RBX dù xử lý 64-bit). Cấu trúc và quy ước chung của Go asm giống nhau trên mọi kiến trúc, chỉ mnemonic cụ thể và tên thanh ghi phụ thuộc CPU.

Bài 5 (bẫy/đánh đổi — hiểu sâu prologue). Bài viết mô tả đoạn prologue sau ở đầu hầu hết các hàm Go:

MOVD 16(g), R16
CMP  R16, RSP
BLS  44
...
CALL runtime.morestack_noctxt(SB)

Đoạn này làm gì, tại sao nó lại cần thiết cho mô hình goroutine của Go, và loại hàm nào không có đoạn này?

Đáp án

Đoạn này kiểm tra xem stack của goroutine hiện tại (được trỏ tới qua thanh ghi đặc biệt g) còn đủ chỗ để chạy hàm hay không, bằng cách so sánh con trỏ stack hiện tại (RSP) với giới hạn stack đã lưu trong cấu trúc goroutine. Nếu không đủ (BLS nhảy), chương trình gọi runtime.morestack_noctxt để cấp phát thêm stack. Đây chính là nền tảng của goroutine stack co giãn — cho phép mỗi goroutine bắt đầu với stack rất nhỏ (khoảng 2KB) và tự lớn dần khi cần, nhờ đó Go chạy được hàng triệu goroutine mà không tốn quá nhiều bộ nhớ ngay từ đầu. Loại hàm không có prologue này là hàm được đánh dấu nosplit (ví dụ dùng //go:nosplit hoặc cờ NOSPLIT trong TEXT) — dành cho các hàm cực nhỏ, không gọi hàm khác, đảm bảo không cần thêm stack.

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.