Hack lập trình game thời 8-bit: tăng tốc 70% nhờ thủ thuật điên rồ
Một lập trình viên kể lại cách anh và bạn mình tăng tốc độ vẽ nền của một game side-scroller trên PC 8-bit lên 70% bằng cách chiếm dụng cả hai thanh ghi stack, vẽ tile ngược và xử lý khéo léo vấn đề nhiễu ngắt (interrupt) gây hỏng bộ nhớ.

Khi lập trình viên phải "phá luật" để game chạy mượt
Vào thời kỳ đồ đá của đồ họa máy tính, một lập trình viên đại học đã giải quyết bài toán tốc độ của game side-scroller bằng một chuỗi thủ thuật kỳ quặc đến mức khó tin: chiếm dụng cả hai thanh ghi stack, vẽ tile theo thứ tự ngược, và biến chính lỗi bộ nhớ thành một phần của giải pháp. Câu chuyện được đăng lại trên Hacker News gần đây như một lát cắt thú vị về cách giới lập trình từng làm việc.
Bối cảnh: phần cứng chậm đến mức không tưởng
Câu chuyện diễn ra trong kỷ nguyên PC 8-bit, khi phần cứng đồ họa gần như không hỗ trợ gì. Không có texture mapper, không có blitter — muốn vẽ bất cứ thứ gì lên màn hình, lập trình viên phải tự di chuyển từng byte.
Người bạn của tác giả đã gần hoàn thành một bản port khá trung thành của một game arcade có đồ họa ấn tượng thời bấy giờ, nhét vừa vào một chiếc PC thập niên 1980. Vấn đề duy nhất: game chạy quá chậm, chuyển động giật lag phá hỏng trải nghiệm — trong khi đây lại là game side-scroller (cuộn ngang), nơi chuyển động mượt là yếu tố sống còn.
Sau khi kiểm tra, tác giả nhận ra code đã được tối ưu rất kỹ: vòng lặp đã được unroll (trải phẳng), các lệnh vẽ thừa đã bị loại bỏ. Không còn tối ưu "dễ" nào nữa. Chỉ còn lại những giải pháp "điên rồ".
Bài toán con số
Mỗi tile nền có kích thước 28x28 pixel, mỗi pixel là một trong 16 màu nên chỉ chiếm nửa byte. Mỗi tile được lưu thành 28 hàng liên tiếp, mỗi hàng 14 byte. Màn hình rộng 320 pixel, cao 240 pixel, tương ứng screen buffer gồm 240 hàng, mỗi hàng 160 byte.
Việc copy một tile đòi hỏi sao chép 28 hàng, mỗi hàng 14 byte. May mắn thay, vi xử lý 6809 có các index register 16-bit và chế độ địa chỉ auto-increment (tự động tăng con trỏ sau mỗi lần đọc/ghi, tương tự toán tử hậu tố ++ trên con trỏ trong C), cho phép copy 4 pixel mỗi lần:
LDU ,X++ ; đọc 1 word 2 byte (= 4 pixel) từ tile nguồn
STU ,Y++ ; ghi vào screen buffer
Ý tưởng điên rồ: dùng stack làm nơi trung chuyển
Ý tưởng đột phá là dùng các lệnh PULS (pop từ stack) và PSHU (push lên stack) để di chuyển dữ liệu hàng loạt. Vì mỗi lệnh có thể đọc/ghi nhiều thanh ghi cùng lúc, tốc độ copy tăng vọt.
Nhưng có một trở ngại: stack là cấu trúc LIFO (vào sau ra trước), nên thứ tự byte bị đảo ngược. Thêm vào đó, các nhóm thanh ghi được push/pop theo thứ tự byte khác với cách các septet (nhóm 7 byte liên tiếp, tức 4 hàng của tile) cần được sắp xếp.
Giải pháp: một bước tiền xử lý đơn giản — chia mỗi tile thành các septet và đảo ngược thứ tự của chúng. Nhờ vậy, khi vẽ ra, dữ liệu tự "tự sửa" lại đúng vị trí.
Kết quả là một subroutine copy tile chỉ tốn 1689 chu kỳ (cycle), so với code cũ tiết kiệm được gần 1200 chu kỳ — tức tăng tốc khoảng 70%.
Thử nghiệm: "Chúa ơi, nhanh thật!"
Khi tích hợp vào game và khởi động lại, tốc độ hiển nhiên cải thiện rõ rệt. Trên thực tế có lẽ chỉ nhanh hơn khoảng một phần ba đến một nửa, nhưng thế là đủ để vượt qua một ngưỡng cảm nhận quan trọng: game trở nên mượt mà, tự nhiên.
Rắc rối mới: nhiễu ngắt gây hỏng bộ nhớ
Vài ngày sau, khi thêm hiệu ứng âm thanh mẫu bằng cách nạp dữ liệu cho DAC vài nghìn lần mỗi giây, người bạn bật một interrupt (ngắt) được định thời bằng phần cứng. Mọi thứ hoạt động hoàn hảo — cho đến khi các tile bắt đầu bị hỏng, càng chơi càng nặng.
Nguyên nhân: khi một ngắt xảy ra, vi xử lý push trạng thái hiện tại lên system stack. Nhưng trong routine copy tile, thanh ghi S (system stack pointer) đã bị trưng dụng và đang trỏ thẳng vào vùng nhớ chứa các tile tham chiếu. Kết quả: bộ nhớ bị ghi đè hỗn loạn.
Giải pháp hai bước
Bữa tối tại quán ăn đêm, người bạn đưa ra ý tưởng đầu tiên: đừng ngăn chặn hỏng hóc — hãy để nó xảy ra, nhưng không phải trên tile tham chiếu. Chỉ cần hoán đổi vai trò của S và U trong routine: U trỏ vào tile tham chiếu, S trỏ vào screen buffer. Nếu ngắt xảy ra, hỏng hóc chỉ ảnh hưởng đến màn hình và sẽ được xóa khi vẽ lại khung hình tiếp theo.
Nhưng người chơi vẫn có thể thấy nhấp nháy trong một khung hình. Vì vậy, giải pháp hoàn chỉnh ra đời sau đó: đảo ngược thứ tự đặt tile — vẽ từ phải sang trái, từ dưới lên trên (từ địa chỉ cao xuống địa chỉ thấp). Khi ngắt xảy ra và làm hỏng vùng nhớ ngay trước tile đang vẽ, tile kế tiếp sẽ tự động sửa lại vùng đó. Và vì màn hình chỉ được hiển thị sau khi vẽ xong toàn bộ và vertical refresh đã diễn ra, người chơi không bao giờ thấy lỗi.
Góc nhìn
Câu chuyện này là lời nhắc nhở sâu sắc về bản chất của tối ưu hóa hiệu năng — một kỹ năng vẫn giữ nguyên giá trị dù phần cứng đã tiến hóa hàng nghìn lần. Ngày nay, lập trình viên hiếm khi phải đếm từng chu kỳ CPU, nhưng tư duy "hiểu rõ phần cứng bên dưới để tận dụng nó" vẫn cực kỳ quan trọng: từ việc hiểu cache locality khi xử lý dữ liệu lớn, đến việc tránh cấp phát bộ nhớ không cần thiết trong các vòng lặp nóng, hay khai thác SIMD và tối ưu memory layout.
Với lập trình viên Việt Nam làm game, hệ thống nhúng hay các ứng dụng cần hiệu năng cao trên thiết bị cấu hình thấp, bài học về "vét cạn mọi khả năng, kể cả những cách kỳ quặc" vẫn rất đáng tham khảo. Điều quan trọng là hiểu rõ trade-off: mỗi thủ thuật tăng tốc thường đi kèm rủi ro về tính đúng đắn và khả năng bảo trì — và trong trường hợp này, chính hiểu biết sâu về cách hoạt động của ngắt và stack đã biến một lỗi nghiêm trọng thành một phần của giải pháp hoàn hảo.
Nguồn: blog.moertel.com · Quy trình biên tập
Bài viết liên quan

Công nghệ
The Last Ninja (1987): Kiệt tác lập trình gói gọn trong 40 kilobyte
06 tháng 4, 2026

Công nghệ
Viết mã Z80 Assembly sau 4 thập kỷ: Hồi sinh đồ họa 3D trên ZX Spectrum
17 tháng 5, 2026

Công nghệ
Vì sao tôi trở thành lập trình viên: Hành trình từ những chiếc đĩa mềm đến thế giới lập trình
26 tháng 10, 2026