Tập 28: Dùng C làm cầu tạm

Có một nghịch lý vô cùng đẹp đẽ và thực tế trong những ngày đầu tiên xây dựng DUY: dự án được sinh ra từ mong muốn vượt qua các giới hạn cũ kỹ và lỗi bộ nhớ của C, nhưng để tạo ra được tệp nhị phân thực thi đầu tiên, nó bắt buộc phải nhờ cậy chính C đưa qua sông.

Một người theo đuổi sự thuần khiết lý thuyết tuyệt đối có thể thấy giải pháp dịch bắc cầu qua C này là một sự thỏa hiệp khó chấp nhận. Nhưng một kỹ sư hệ thống từng có kinh nghiệm đưa sản phẩm ra thực tế sẽ hỏi một câu hỏi khác: "Cây cầu tạm ấy có giúp ta nhanh chóng kiểm chứng được giả thuyết thiết kế ngôn ngữ sớm hơn không?"

Viết một backend sinh mã máy thô (machine-code backend) từ con số không đòi hỏi lập trình viên phải hiểu rõ thuật toán chọn chỉ thị (instruction selection), phân phối thanh ghi vật lý (register allocation), các quy ước gọi hàm (calling conventions), định dạng file object (.o), relocation và cơ chế hoạt động của linker. Viết một backend LLVM hoàn chỉnh vẫn đòi hỏi kiến thức sâu về cách tổ chức layout dữ liệu, luồng điều khiển và kiểm chứng IR hợp lệ. Trong khi đó, việc sinh mã C trung gian cho phép DUY lập tức tận dụng được toàn bộ hạ tầng công nghệ toolchain cực kỳ trưởng thành của nhân loại để kiểm chứng lexer, parser, type checker và phần lớn ngữ nghĩa của ngôn ngữ trước.

Lộ trình biên dịch ở giai đoạn sơ khai hoạt động như sau:

Mã nguồn DUY (.duy)
  -> Cây định kiểu AST / DUY IR
  -> Sinh mã nguồn C tương ứng (.c)
  -> Spawn Clang biên dịch file C
  -> Tệp nhị phân thực thi gốc (native binary)

Bộ sinh mã C emitter chuyển đổi các kiểu dữ liệu của DUY sang kiểu tương đương của C, biến hàm thành hàm C, ánh xạ struct thành typedef struct, hạ control flow thành các lệnh ifwhile quen thuộc của C, và chuyển các lệnh print thành các lời gọi hàm I/O thích hợp. Với chương trình hello tối giản, mã nguồn C sinh ra bắt buộc phải chứa định nghĩa int32_t main(void) cùng phép toán cộng tương ứng.

Đây là một chiến lược tự dựng (bootstrap) cực kỳ sáng suốt bởi vì ngôn ngữ C vừa đóng vai trò là một ngôn ngữ lập trình cao cấp, vừa là một dạng assembly di động cấp cao (portable assembly). Nó cung cấp cho linker, thư viện libc và ABI của hệ điều hành một điểm giao dịch quen thuộc và ổn định. Khi chương trình sinh ra chạy lỗi, ta hoàn toàn có thể mở trực tiếp file .c trung gian ra đọc, chạy biên dịch Clang độc lập bằng tay để tận dụng hệ thống diagnostic khổng lồ của cả một hệ sinh thái C lâu năm.

Nhưng cây cầu tạm ấy hoàn toàn không trong suốt tuyệt đối.

Ngữ nghĩa của C chứa đầy rẫy các hành vi không xác định (Undefined Behavior), các quy tắc ép kiểu số nguyên ẩn và các chi tiết tối ưu hóa riêng biệt. Một kiểu dữ liệu của DUY nếu bị ánh xạ cẩu thả sang C có thể bị thay đổi thuộc tính có dấu/không dấu (signedness). Layout của struct và mảng có thể bị thay đổi tùy thuộc vào ABI của từng hệ điều hành. Dữ liệu vị trí mã nguồn span của DUY rất dễ bị thất lạc hoàn toàn khi clang chỉ nhìn thấy tệp nguồn C sinh ra. Một tính năng được hứa hẹn an toàn bộ nhớ ở frontend của DUY có thể bị emitter hạ cấp vụng về thành các thao tác con trỏ C thô sơ đầy hiểm họa. C backend chạy thành công không đồng nghĩa ngữ nghĩa an toàn của DUY đã được chứng minh tĩnh thực sự.

Vì vậy, mã nguồn C sinh ra bắt buộc phải được xem là một sản phẩm phụ trung gian phục vụ kiểm tra, tuyệt đối không được phép coi nó là nguồn chân lý của ngôn ngữ. Hệ thống tests bắt buộc phải kiểm tra nghiêm ngặt cả kết quả thực thi runtime lẫn các chi tiết ánh xạ kiểu dữ liệu quan trọng ở file trung gian. Biểu diễn trung gian DUY IR phải hoàn tất việc kiểm tra toàn bộ các ràng buộc bất biến ngữ nghĩa trước khi chạm xuống C emitter. Về dài hạn, đường dẫn sinh mã qua LLVM và sinh file object trực tiếp bắt buộc phải trở thành đường đi chính thức của ngôn ngữ, còn C backend chỉ giữ vai trò làm tham chiếu đối chiếu (reference backend), hỗ trợ debug và phục vụ giao tiếp chéo (interoperability).

Tôi từng có những lúc vô cùng sốt ruột muốn đập bỏ cây cầu tạm C này thật nhanh. Nhưng kinh nghiệm làm hệ thống dạy tôi rằng một sự phụ thuộc công nghệ được nhìn thấy rõ ràng và được đo lường hiệu năng cẩn thận ít nguy hiểm hơn nhiều so với một backend native viết vội vã mà không có hệ thống oracle nào để đối chiếu. C emitter cung cấp cho DUY một đường cơ sở (baseline) vững chắc để so sánh hiệu năng và tính đúng đắn. Khi LLVM path xuất hiện về sau, cùng một bộ mã nguồn thử nghiệm có thể chạy song song qua cả hai backend dịch. Mọi kết quả đầu ra không trùng khớp (mismatch) sẽ ngay lập tức kích hoạt tín hiệu cảnh báo để đội ngũ điều tra lỗi.

Bài học xương máu này lặp đi lặp lại nhiều lần trong suốt dự án DUY: tính độc lập thực sự của một ngôn ngữ không đến từ việc vội vàng xóa bỏ các dependencies từ trước, mà đến từ việc kiên nhẫn xây dựng các hệ thống thay thế (replacements) có đầy đủ bằng chứng kiểm chứng tính tương đương ngữ nghĩa. Xóa bỏ Node.js khỏi lệnh cài đặt không làm cho compiler tự động tự biên dịch chính mình. Xóa bỏ tệp nguồn C trung gian không làm cho LLVM lowering tự động chạy đúng. Một kiến trúc trông có vẻ độc lập trên giấy vẫn có thể phụ thuộc sâu sắc vào các giả định (assumptions) chưa từng được kiểm chứng thực tế.

Cây cầu tạm qua C còn mang lại một giá trị tâm lý vô cùng lớn. Nó giúp cho mục tiêu dịch thành công source -> binary khả thi và chạm tới được đủ sớm để giữ cho đội ngũ dự án còn đầy đủ năng lượng đi tiếp hành trình khó khăn phía sau. Một lộ trình phát triển chỉ toàn các công việc nền móng kéo dài ròng rã nửa năm trời rất dễ bào mòn năng lượng và làm người xây dựng mất đi cảm giác tiến bộ. File thực thi gốc sinh ra qua cầu tạm C không phải là đích đến cuối cùng của DUY, nhưng nó là một cột mốc vật lý cụ thể mà con người có thể sờ thấy và chạy thử được.

DUY không cần phải xấu hổ vì đã đi nhờ chiếc xe của tiền nhân C. Chúng tôi chỉ cần ghi vé rõ ràng, biết chính xác mình đang đứng ở ga nào trên hành trình, và tuyệt đối không quảng cáo thổi phồng với người dùng rằng mình đã tự thiết kế và lái toàn bộ đoàn tàu.


Bình luận (0)

Đang tải bình luận...