Bài trước ta học đọc assembly Plan 9. Bài này ta viết một hàm assembly từ đầu — khai báo, cài đặt, chạy thật. Nhưng câu hỏi quan trọng hơn "làm sao viết" là "khi nào nên viết". Và câu trả lời trung thực, có đo đạc, khác với kỳ vọng của nhiều người: với hầu hết code, viết assembly thủ công lỗ, không lãi. Bài này chỉ cả hai — cách viết đúng, và vì sao thường không đáng.
Cấu trúc: khai báo Go + cài đặt .s
Một hàm assembly gồm hai nửa. Nửa Go: khai báo hàm không có thân trong file .go — chỉ chữ ký, báo compiler biết cài đặt nằm nơi khác:
// lib.go
package main
func Cong(a, b int64) int64 // không thân → cài đặt ở lib_arm64.s
Nửa assembly: cài đặt trong file .s cùng package, tên file có hậu tố kiến trúc (_arm64.s, _amd64.s) để build đúng CPU:
// lib_arm64.s
#include "textflag.h"
// func Cong(a, b int64) int64
TEXT ·Cong(SB), NOSPLIT, $0-24
MOVD a+0(FP), R0 // R0 = a (tham số 1 tại offset 0)
MOVD b+8(FP), R1 // R1 = b (tham số 2 tại offset 8)
ADD R1, R0, R0 // R0 = a + b
MOVD R0, ret+16(FP) // ghi kết quả tại offset 16
RET

Hình 1: Cấu trúc một hàm assembly. ·Cong là tên package hiện tại; $0-24 là frame 0 byte, tham số 24 byte; tham số/kết quả đọc-ghi qua FP theo offset khớp khai báo Go.
Ba quy tắc phải nhớ
·Name (dấu chấm giữa) = tên package hiện tại. Ký tự · (middle dot, U+00B7) trước tên hàm nghĩa là "hàm này thuộc package đang biên dịch". TEXT ·Cong(SB) khai hàm Cong của package hiện tại.
$frame-args khai kích thước. $0-24 nghĩa là frame cục bộ 0 byte, vùng tham số 24 byte. Với Cong(a, b int64) int64: a (8) + b (8) + ret (8) = 24.
Offset FP phải khớp khai báo Go. a+0(FP), b+8(FP), ret+16(FP) — mỗi int64 là 8 byte, xếp liên tiếp, kết quả nằm sau các tham số. Sai offset là đọc nhầm ô nhớ → kết quả rác hoặc panic. Chạy go vet để bắt lỗi lệch offset (nó có bộ kiểm asmdecl đối chiếu .s với khai báo Go).
Chạy thật: nó hoạt động
Đo thật, gọi từ Go như hàm thường:
Cong(3, 4) -> 7 // 3 + 4
NhanDoiCong1(10) -> 21 // 10*2 + 1
Hàm assembly biên dịch và chạy đúng. Nhưng câu hỏi thật là: có đáng không?
Đo trung thực: hàm nhỏ thường lỗ

Hình 2: Hàm asm chạy đúng (7, 21). Benchmark CongAsm 0,956 ns vs CongGo 1,323 ns — số sát nhau nhưng KHÔNG so cùng điều kiện: bản Go inline, hàm asm không bao giờ inline.
Benchmark Cong (asm) với CongGo (Go thuần a+b): CongAsm 0,956 ns, CongGo 1,323 ns. Nhìn thì asm nhanh hơn — nhưng đây là so sánh không cùng điều kiện và không được kết luận "asm nhanh hơn cho a+b":
- Bản Go
CongGođược inline — nó biến mất vào vòng lặp benchmark, compiler tối ưu cả khối. - Hàm asm
Congkhông bao giờ inline được — mỗi lần gọi là mộtCALLthật.
Sự thật cốt lõi: hàm assembly không inline được. Với một hàm số học nhỏ, đây thường là lỗ ròng — bạn mất inline (thứ đã đo ~2,7x lợi ở bài trước) để đổi lấy... không gì cả, vì compiler Go đã sinh mã tối ưu cho a+b. Con số benchmark sát nhau ở đây là do đặc thù vòng lặp, không phản ánh lợi ích thật.
Khi nào thực sự đáng viết assembly
Assembly chỉ đáng khi bạn cần thứ compiler không diễn đạt được:
Lệnh SIMD (vector). Xử lý nhiều phần tử một lúc (AVX2, NEON) — compiler Go không tự động vector hóa mạnh, nên crypto/nén/xử lý ảnh dùng asm SIMD để nhanh gấp nhiều lần. Đây là lý do phổ biến nhất trong thư viện chuẩn (crypto/*, hash/*).
Crypto hằng thời gian. Code mật mã cần không rẽ nhánh theo dữ liệu bí mật để chống tấn công thời gian. Assembly cho kiểm soát chính xác từng lệnh, đảm bảo hằng thời gian mà code cấp cao khó bảo đảm.
Lệnh CPU đặc biệt. Đọc bộ đếm chu kỳ (RDTSC), lệnh mã hóa phần cứng (AES-NI), popcount, CRC — những lệnh mà Go không expose qua hàm chuẩn.
Đánh đổi cần cân nhắc
Assembly làm code không portable và khó bảo trì. Mỗi kiến trúc cần một file .s riêng (_amd64.s, _arm64.s...). Quên một kiến trúc là build lỗi ở đó. Code asm khó đọc, khó sửa, dễ sai — mỗi dòng phải tự lo đúng đắn mà compiler không kiểm hộ. Chỉ trả cái giá này khi lợi ích thật sự lớn.
Compiler ngày càng giỏi, xói mòn lợi thế asm. Nhiều tối ưu từng phải viết asm giờ compiler tự làm (PGO, autovectorization đang cải thiện). Code asm viết hôm nay có thể chậm hơn code Go sau vài phiên bản khi compiler bắt kịp — mà asm thì không tự hưởng cải tiến compiler.
Luôn đo, và luôn có bản Go dự phòng. Nếu viết asm, giữ một bản Go thuần (qua build tag) làm chuẩn đối chiếu và dự phòng cho kiến trúc chưa có asm. Đo trên phần cứng thật, trên khối lượng thật — nhiều lần "tối ưu asm" hóa ra chậm hơn bản Go khi đo đúng cách.
Ba ý mang về
- Viết hàm assembly = khai báo Go không thân + cài đặt trong
.scùng package: dùng·Name(SB), đọc-ghi tham số qua FP theo offset khớp khai báo (a+0,b+8,ret+16), và chạygo vetđể bắt lỗi lệch offset (asmdecl) — đo thật hàm asm chạy đúng, gọi được như hàm Go thường. - Hàm assembly không bao giờ inline được, nên với hàm nhỏ thường lỗ ròng: benchmark asm vs Go sát nhau nhưng không cùng điều kiện (Go inline, asm không) — compiler đã sinh mã tối ưu cho số học đơn giản, viết asm chỉ mất inline.
- Assembly chỉ đáng cho thứ compiler không diễn đạt được: lệnh SIMD, crypto hằng thời gian, lệnh CPU đặc biệt — không phải để "tối ưu" số học thường; luôn giữ bản Go dự phòng và đo trên phần cứng/khối lượng thật.
Phần sau ta quay lại tầng cao hơn, tập hợp các "công tắc" điều khiển compiler đã gặp rải rác: Phần sau tổng hợp các compiler directive //go: — noinline, nosplit, noescape, linkname, embed và bạn bè — mỗi cái làm gì, khi nào dùng, và cái nào nguy hiểm.