Đôi khi bạn cần một lệnh mà C không có cách nào diễn đạt: đọc một thanh ghi hệ thống, dựng một hàng rào bộ nhớ, dùng một lệnh phần cứng đặc biệt. Assembly nội tuyến (inline assembly) là cửa để chèn thẳng lệnh máy vào giữa mã C, với một cú pháp khai báo cho trình biên dịch biết lệnh của bạn đọc gì, ghi gì, phá gì. Bài này đo nó trong container — và một lần nữa, cái từ khóa volatile quen thuộc là ranh giới giữa "chạy đúng" và "sụp thành hằng số".
Cú pháp: lệnh, ràng buộc, clobber
Một khối asm mở rộng có bốn phần, ngăn bằng dấu hai chấm: __asm__("lệnh" : ràng_buộc_xuất : ràng_buộc_nhập : danh_sách_clobber). Ràng buộc (constraint) là cách bạn nối biến C với lệnh máy mà không phải tự chọn thanh ghi. "=r"(y) nói "xuất kết quả ra một thanh ghi bất kỳ, gán vào biến y"; "r"(x) nói "nạp x vào một thanh ghi rồi đưa cho lệnh". Trình biên dịch tự chọn thanh ghi nào và điền vào %0, %1 trong chuỗi lệnh — bạn không cần biết nó chọn x3 hay x7.
Phần thứ tư — clobber — là nơi bạn khai những thanh ghi hoặc vùng bộ nhớ mà lệnh của bạn đụng tới ngoài các toán hạng đã liệt kê. Nếu lệnh của bạn ghi đè x9 mà bạn không khai x9 vào clobber, trình biên dịch tưởng x9 còn nguyên giá trị cũ và có thể đã để một biến sống ở đó — kết quả là mã sai, rất khó lần. Khai "memory" báo rằng asm đọc/ghi bộ nhớ tùy ý, buộc trình biên dịch không giữ giá trị trong thanh ghi bắc qua khối asm. Ràng buộc và clobber chính là hợp đồng giữa bạn và trình biên dịch; điền sai hợp đồng là nguồn của những lỗi asm nội tuyến kinh điển.
Đo: chèn lệnh, và cái bẫy volatile
Trước hết, một khối asm đúng đắn. Tôi viết hàm đảo byte dùng lệnh rev của ARM:
uint32_t bswap(uint32_t x){ uint32_t y; __asm__("rev %w0, %w1" : "=r"(y) : "r"(x)); return y; }
Đọc objdump: đúng một lệnh rev w0, w0 được chèn vào, và chạy ra bswap(0x12345678) = 0x78563412 — chính xác. Ràng buộc "=r"/"r" đã nối x và y vào lệnh mà không cần tôi động tới tên thanh ghi.
Giờ đến chỗ nguy hiểm. Tôi dùng asm để đọc bộ đếm ảo cntvct_el0 (một thanh ghi đếm chu kỳ) nhằm đo thời gian, và so hai cách khai: có và không có volatile. Đọc objdump đếm số lệnh mrs (lệnh đọc thanh ghi hệ thống):
| Tình huống | non-volatile | volatile |
|---|---|---|
| asm đọc bộ đếm, output không dùng | XÓA (0 lệnh) | giữ (1 lệnh) |
| đọc bộ đếm 2 lần kề nhau, tính hiệu | gộp → return 0 |
2 lệnh, hiệu đúng |
Cả hai dòng đều dữ dội. Khi output của asm không được dùng, bản non-volatile bị xóa sạch (0 lệnh mrs) — trình biên dịch coi nó là mã chết. Khi đọc bộ đếm hai lần kề nhau để tính khoảng thời gian, bản non-volatile không chỉ gộp hai lần đọc làm một, mà còn gấp luôn b - a thành hằng 0: cả hàm rút gọn thành mov x0, #0; ret — luôn trả về 0. Một routine đo thời gian viết bằng asm không volatile báo cáo thời gian trôi qua là không, mà chẳng có lỗi biên dịch nào.
Một lần tôi đo hớ: rào chắn giấu mất cái bẫy
Tôi vào bài với kịch bản định sẵn: đọc bộ đếm hai lần với một vòng lặp volatile bận rộn ở giữa (giả lập công việc cần đo), rồi kỳ vọng bản non-volatile sẽ gộp hai lần đọc và cho hiệu 0. Tôi viết đúng thế, chạy objdump, và... không gộp — cả hai bản, có và không volatile, đều giữ đủ 2 lệnh mrs.
Phải dừng lại nghĩ. Lý do: cái vòng lặp volatile tôi đặt giữa hai lần đọc là một rào chắn — mọi truy cập volatile phải giữ đúng thứ tự, và trình biên dịch không dám di chuyển hay gộp asm bắc qua nó. Chính cái tôi thêm vào để "giả lập công việc" đã vô tình bảo vệ asm non-volatile khỏi bị tối ưu, che mất cái bẫy tôi định phơi bày. Kỳ vọng "non-volatile thì luôn bị gộp" của tôi quá mạnh: nó chỉ bị gộp khi không có gì cản.
Bỏ rào chắn đi — đặt hai lệnh đọc kề nhau, không có gì ở giữa — cái bẫy mới hiện ra, và còn dữ hơn tôi tưởng: trình biên dịch gộp hai lần đọc, rồi gấp cả phép trừ thành return 0. Bài học đo lường kép: thứ nhất, một biến ẩn (rào chắn volatile) có thể giấu mất hành vi tôi đang cố đo — tôi suýt kết luận sai "non-volatile cũng an toàn" chỉ vì cách dựng thí nghiệm tình cờ chặn tối ưu. Thứ hai, và là cốt lõi: asm không volatile là một hàm thuần dưới mắt trình biên dịch — nó có thể gộp (như CSE), xóa (như mã chết), kéo ra khỏi vòng (như biểu thức bất biến), y hệt một hàm pure/const ở bài hàm thuần. Cái volatile ở đây mang đúng nghĩa nó mang ở bài volatile: "khối này có tác dụng phụ, chạy mỗi lần, đừng đụng vào".
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: asm có tác dụng phụ thì phải volatile. Bất cứ khối asm nào đọc trạng thái phần cứng thay đổi (bộ đếm chu kỳ, thanh ghi thiết bị), gây tác dụng phụ (ghi thanh ghi hệ thống, phát tín hiệu), hay dựng hàng rào bộ nhớ đều cần volatile — nếu không, trình biên dịch sẽ gộp, xóa, hoặc di chuyển nó dựa trên giả định sai rằng nó thuần. Ngược lại, một khối asm thật sự thuần (như bswap chỉ biến đổi toán hạng) thì không nên volatile, để trình biên dịch được tự do tối ưu quanh nó — giống hệt cân nhắc pure với hàm C.
Hệ quả thứ hai: khai đủ ràng buộc và clobber, coi chúng như hợp đồng. Mọi thanh ghi lệnh của bạn ghi phải là output hoặc nằm trong clobber; mọi bộ nhớ nó chạm phải khai "memory". Thiếu một khai báo, trình biên dịch sinh mã đúng theo hợp đồng bạn viết, không theo lệnh bạn thực sự chạy — và hai cái lệch nhau cho ra lỗi chạy lúc được lúc không, đổi theo mức -O và theo cách trình biên dịch phân bổ thanh ghi. Đây là lý do asm nội tuyến nổi tiếng khó: phần khó không phải viết lệnh, mà là mô tả đúng cho trình biên dịch lệnh đó làm gì.
Hệ quả thứ ba, và là bài học mang theo: đừng đoán asm nội tuyến còn sống hay đã bị tối ưu — đọc assembly mà kiểm. Con số mang theo: một khối asm không volatile bị trình biên dịch đối xử như hàm thuần — xóa khi output không dùng (0 lệnh so với 1), gộp khi lặp lại (hai lần đọc bộ đếm sụp thành return 0) — nên lệnh có tác dụng phụ phải volatile, phải khai đủ clobber, và phải kiểm bằng objdump rằng nó thật sự nằm trong mã cuối. Cửa xuống tận assembly là công cụ mạnh nhất bạn có, nhưng nó cũng là chỗ dễ tự bắn vào chân nhất — và cách duy nhất để chắc là nhìn thẳng vào mã máy trình biên dịch sinh ra.
Thử ba mươi giây
Viết hai hàm đọc bộ đếm chu kỳ của máy bạn hai lần rồi trả về hiệu — một bản dùng __asm__(...), một bản dùng __asm__ volatile(...) (trên x86 là rdtsc, trên ARM là mrs ..., cntvct_el0). Biên dịch gcc -O2 -S và đọc assembly: bản không volatile rất có thể chỉ còn một lần đọc, hoặc thậm chí return 0 nếu trình biên dịch gộp và gấp được; bản volatile giữ đủ hai lần đọc. Đó là toàn bộ sự khác biệt giữa một bộ đo thời gian hoạt động và một bộ đo luôn báo số không — nằm gọn trong một từ khóa, và chỉ lộ ra khi bạn đọc mã máy chứ không đọc mã nguồn.