Tập 27: Compiler lỗi, và niềm vui hơi khó giải thích

Viết compiler mang lại cho lập trình viên một loại xấu hổ vô cùng đặc biệt. Chương trình của người dùng viết sai bị sập là chuyện bình thường. Trình biên dịch do mình viết ra hiểu sai chương trình của người dùng và sinh ra mã máy lỗi thì cực kỳ khó đổ lỗi cho ai khác.

Khi đường ống biên dịch còn ngắn, lỗi thường nằm gần các vị trí dễ nhìn thấy bằng mắt thường. Parser tạo AST, emitter đi qua AST sinh mã C. Khi cú pháp ngôn ngữ tăng lên, khoảng cách giữa ý định trừu tượng của mã nguồn và mã máy nhả ra ngày càng lớn dần. Backend bắt buộc phải hiểu chi tiết về struct layout, match block, control flow và các chi tiết định kiểu.

Sprint 11 đưa vào DUY IR V0: một biểu diễn trung gian có định kiểu (typed IR), được hạ cấp (lower) từ cây typed AST, và được sử dụng làm đầu vào chính thức cho bộ sinh mã C emitter. CLI của DUY có thêm lệnh duy emit-ir để con người có thể dump trực tiếp và quan sát ngôn ngữ nội bộ này.

IR không sinh ra để trang trí cho tài liệu README.md trông giống như một compiler chuyên nghiệp. Nó giải quyết một nhu cầu thiết kế hệ thống vô cùng thiết thực: tách biệt hoàn toàn câu hỏi “chương trình này có ý nghĩa logic gì” ra khỏi câu hỏi “backend phải biểu diễn nó bằng cú pháp cụ thể nào của mã máy”. Frontend có thể chứa nhiều cú pháp tiện ích (syntax sugar) giúp lập trình viên viết nhanh. Nhưng IR bắt buộc phải có cấu trúc tối giản hơn, luồng điều khiển tường minh hơn (explicit control flow) và bảo toàn kiểu dữ liệu đủ rõ ràng để bộ verifier hoặc optimizer dễ dàng làm việc.

Mã nguồn DUY (.duy)
  -> Cây cú pháp AST
  -> Cây định kiểu Typed AST
  -> Biểu diễn trung gian DUY IR
  -> Sinh mã máy C / LLVM IR

Từ thời điểm kiến trúc này được thiết lập, một lỗi chạy đầu ra không còn chỉ có hai nghi phạm đơn giản là parser hay emitter nữa. AST có thể dựng đúng nhưng quá trình hạ cấp lowering bị sai lệch. IR có thể đúng ngữ nghĩa nhưng emitter dịch sai. Bộ tối ưu hóa optimization có thể tự ý thay đổi ngữ nghĩa logic của chương trình. Kiến trúc tốt không hề giúp triệt tiêu lỗi; nó giúp phân chia lỗi vào các khu vực rõ ràng để kỹ sư có thể cô lập và điều tra chính xác.

Lần đầu tiên gõ lệnh in mã nguồn IR của tệp control.duy mang lại một cảm giác kỳ lạ, gần giống như việc bạn đang nhìn vào tấm ảnh chụp X-quang của chính ngôn ngữ lập trình của mình. Những câu lệnh viết thân thiện ở mã nguồn biến mất, nhường chỗ cho các block logic, các biến tạm thời (temporaries), các lệnh rẽ nhánh branch tường minh và các lệnh trả về return. Vẻ đẹp cú pháp bề mặt bị tước bỏ hoàn toàn. Đổi lại, cấu trúc điều khiển thực tế hiện ra theo cách thức giúp backend có thể xử lý một cách nhất quán nhất.

And then, chính IR cũng sinh lỗi.

Đó có thể là kiểu dữ liệu của biến tạm không khớp với giá trị gán, lệnh branch nhảy tới một block logic không hề tồn tại trên đồ thị, kiểu trả về của return lệch pha với kiểu của chữ ký hàm, hay quá trình hạ cấp lowering vô tình bỏ quên một hiệu ứng phụ của biểu thức. Ở giai đoạn IR V0 sơ khai, chúng tôi chưa xây dựng được một bộ verifier mạnh mẽ làm cổng gác. Một số lỗi logic IR chỉ bị phát hiện khi emitter xuất mã C và bị trình biên dịch Clang của hệ thống kiên quyết từ chối. Cảm giác bị trình biên dịch hệ thống của OS bắt lỗi cho chính cái trình biên dịch do mình viết ra mang lại một sự khiêm tốn lớn: chúng ta vừa hùng hồn viết công cụ để đi bắt lỗi lập trình của người khác, và một công cụ lâu năm khác đang bắt lỗi chúng ta một cách chi tiết.

Nhưng khi chúng tôi viết thành công một ca kiểm thử tự động tái hiện được lỗi (reject fixture), sự xấu hổ nhanh chóng chuyển hóa thành một niềm vui kỹ thuật hơi khó giải thích cho người ngoài. Lỗi không còn là một cảm giác mơ hồ, bực dọc nữa. Nó đã được cụ thể hóa thành một file input đầu vào, một kết quả IR output lỗi thực tế, một expected behavior đúng đắn mong muốn, và một vị trí dòng code cụ thể cần được sửa chữa trong compiler. Người bên ngoài nhìn vào các thanh trạng thái test đỏ rực sẽ thấy đó là một sự thất bại; nhưng người làm compiler nhìn vào đó thấy một phần ngữ nghĩa của ngôn ngữ vừa được lôi kéo thành công ra khỏi vùng bóng tối của sự mơ hồ.

IR thiết lập một kỷ luật kiến trúc nghiêm ngặt cho dự án. Backend tuyệt đối không được phép tự ý thực hiện lại công việc type-check theo cách riêng của mình. Bộ tối ưu hóa không được phép phỏng đoán mò mẫm các hiệu ứng phụ đã bị thất lạc thông tin. Bộ hạ cấp lowering bắt buộc phải bảo toàn thông tin span vị trí mã nguồn gốc nếu muốn hệ thống diagnostics sau này hiển thị lỗi chính xác. Mỗi ràng buộc bất biến (invariant) của ngôn ngữ bắt buộc phải có một ga quản lý chịu trách nhiệm rõ ràng trong pipeline.

Từ đây, DUY bắt đầu có khả năng so sánh snapshot của IR, viết các ca test phủ nhận trên IR và đưa bộ verifier trở thành cổng gác bắt buộc ở compile time. Sprint 11 mới chỉ là phiên bản IR V0, nên tuyệt đối không được phép quảng cáo như một hệ thống formal semantics hay compiler đã được kiểm chứng toán học hoàn chỉnh. Nó đơn giản là một bước đi thực tế đúng hướng: thay vì để ngữ nghĩa trôi nổi tự do thẳng từ parser sang chuỗi text C thô, dự án thiết kế một trạm dừng chân vật lý cụ thể để con người và công cụ có thể quan sát, đo đạc và tự xác minh.

Một compiler sinh lỗi không còn là nỗi nhục nhã. Một lỗi được hiển thị rõ ràng và sớm là bằng chứng thép cho thấy đường ống biên dịch đủ trong suốt để tự phê bình và sửa chữa. Thứ đáng sợ hơn nhiều là một compiler chạy sai ngữ nghĩa nhưng toàn bộ các chương trình demo vẫn báo xanh rực vì hệ thống tests chưa từng hỏi đúng câu hỏi hiểm hóc nào.


Bình luận (0)

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