1 điểm bởi GN⁺ 3 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • Elevator dịch tĩnh toàn bộ tệp thực thi x86-64 sang AArch64 mà không cần thông tin debug, mã nguồn hay giả định về bố cục nhị phân
  • Thay vì dùng heuristic để phân biệt mã và dữ liệu, nó tạo một superset CFG chứa mọi cách diễn giải có thể của từng byte và chỉ loại bỏ các đường dẫn kết thúc chương trình
  • Nó ánh xạ trạng thái x64 sang các thanh ghi AArch64 theo kiểu một-một và xử lý nhánh gián tiếp bằng bảng tra cứu đi từ địa chỉ gốc sang mã đã dịch
  • Tile bank ngoại tuyến viết ngữ nghĩa lệnh x64 bằng mẫu C rồi biên dịch thành chuỗi byte AArch64 bằng LLVM 20
  • Kết quả là một nhị phân AArch64 tự chứa không cần dịch lúc chạy, đạt hiệu năng tương đương hoặc tốt hơn JIT chế độ người dùng của QEMU trên SPECint 2006

Mục tiêu của Elevator

  • Elevator là một trình dịch nhị phân tĩnh hoàn toàn, chuyển toàn bộ tệp thực thi x86-64 sang AArch64
  • Không sử dụng thông tin debug, mã nguồn, mẫu mã của nhị phân gốc hay các giả định về bố cục nhị phân
  • Các trình dịch tĩnh hiện có thường dựa vào heuristic hoặc fallback lúc chạy để phân biệt mã và dữ liệu, nhưng Elevator dịch trước mọi byte của tệp thực thi gốc theo tất cả các cách diễn giải có thể
  • Vì bất kỳ byte nào cũng có thể là dữ liệu, một phần opcode hoặc một phần toán hạng opcode, nó tạo một superset CFG chứa mọi luồng điều khiển có thể và chỉ loại bỏ các đường dẫn dẫn tới kết thúc chương trình ngoại lệ
  • Đầu ra gồm nhị phân AArch64 tự chứa bao gồm mã đã dịch, nhị phân x64 gốc, bảng tra cứu địa chỉ và runtime driver
  • Sau khi dịch xong, có thể chạy mà không cần JIT hay hỗ trợ dịch lúc chạy
  • Nếu dịch cùng một nhị phân đầu vào hai lần, sẽ tạo ra cùng một chuỗi bit đầu ra, và đối tượng để kiểm thử, xác minh, chứng nhận hay ký mã hóa sẽ khớp với đúng mã được triển khai thực tế
  • Chi phí chính là kích thước mã tăng lên, đổi lại khả năng xác minh trước khi triển khai cao hơn so với emulator hoặc trình biên dịch JIT
  • Đánh giá gồm toàn bộ bộ benchmark SPECint 2006 và các nhị phân viết tay, với hiệu năng tương đương hoặc tốt hơn mô phỏng chế độ người dùng QEMU có tăng tốc JIT
  • Nhóm nghiên cứu cho biết sẽ công bố toàn bộ dự án dưới dạng mã nguồn mở khi kết thúc dự án

Vì sao cần dịch tĩnh và các giới hạn hiện có

  • Khi phần cứng chuyển từ ISA này sang ISA khác, phần mềm hiện có phải được đưa sang nền tảng mới, và chỉ biên dịch lại từ mã nguồn còn sót lại có thể là chưa đủ
  • Với mã legacy đã được xác minh hoặc chứng nhận, đối tượng được chứng nhận thường không phải mã nguồn mà là tệp thực thi nhị phân có thẩm quyền cụ thể đã được kiểm thử kỹ
  • Để tái tạo lại cùng một nhị phân từ mã nguồn ở mức bit trong tương lai, có thể cần đúng phiên bản compiler, linker và hệ thống build tại thời điểm đó, điều vốn rất khó khả thi
  • Nếu nhà sản xuất đã vá trực tiếp lên nhị phân mà không qua mã nguồn, việc build lại từ mã nguồn lưu trữ có thể làm sống lại các lỗi đã được sửa
  • Các cách xử lý trực tiếp nhị phân hiện nay kết hợp mô phỏng, dịch tĩnh và dịch động, nhưng các thành phần hệ thống bổ sung chạy cùng chương trình đã dịch sẽ nằm trong trusted computing base
  • Hành vi động có thể thay đổi theo thứ tự kiểm thử hoặc đầu vào, khiến việc xác nhận độ tin cậy toàn phần trở nên khó khăn
  • Horspool và Marovac đã chỉ ra từ năm 1980 rằng để đảo ngược tệp thực thi thì phải phân biệt chắc chắn giữa mã và dữ liệu, và trên hầu hết kiến trúc điều này tương đương với bài toán dừng, nên nhìn chung không thể giải được
  • Các binary lifter tĩnh hiện có xấp xỉ việc phân biệt mã và dữ liệu bằng heuristic, và vấn đề đặc biệt lớn khi dự đoán đích của các truyền luồng điều khiển gián tiếp
  • LLBT nâng lệnh ARM lên LLVM IR rồi biên dịch lại sang kiến trúc đích, nhưng dùng heuristic để phát hiện đích nhánh gián tiếp và đặt nhiều giả định lên nhị phân đầu vào
  • Ngay cả heuristic tốt cũng sẽ thất bại trên một số đầu vào, và để nâng toàn bộ nhị phân một cách đúng đắn thì mọi quyết định phân biệt mã-dữ liệu đều phải chính xác, nên nhị phân càng lớn thì khả năng thất bại càng tăng
  • Cách tiếp cận động lần theo luồng lệnh thực sự được chạy nên có thể xử lý khôi phục lệnh và luồng điều khiển gián tiếp, nhưng không thể nâng các lệnh chưa được chạm tới trong lần thực thi cụ thể
  • Với ISA có lệnh độ dài biến thiên như x64, một chuỗi lệnh có thể lồng bên trong chuỗi lệnh khác, và nếu nhảy vào giữa một lệnh nhiều byte thì các toán hạng trước đó có thể được giải mã thành các lệnh riêng biệt
  • Tấn công ROP và làm rối mã có thể khai thác đặc tính này
  • Rosetta II của Apple và Prism của Microsoft kết hợp dịch trước với các thành phần dịch động
  • WYTIWYG và Polynima nâng tĩnh theo các đường luồng điều khiển được xác định bằng profiling động, và dùng fallback động để thu thập thông tin luồng điều khiển khi gặp địa chỉ đích chưa từng thấy
  • Elevator không quyết định byte nào là mã hay dữ liệu, là word lệnh hay toán hạng, mà đưa từng byte của tệp thực thi vào các đường luồng điều khiển riêng theo mọi cách diễn giải khả dĩ
  • Cách tiếp cận này áp dụng superset disassembly vào tái biên dịch tĩnh và biên dịch chéo ISA, đánh đổi độ chính xác giải mã lấy sự gia tăng kích thước mã

Bảo toàn luồng điều khiển và trạng thái

  • Elevator hoạt động theo nguyên tắc bảo toàn toàn bộ trạng thái x64 bên trong mã AArch64 đã dịch
  • Nó ánh xạ các thanh ghi x64 và AArch64 theo kiểu một-một để mô phỏng trạng thái của từng thanh ghi x64 trong thanh ghi AArch64 tương ứng
  • Stack x64 được mô phỏng trực tiếp trên stack AArch64, còn việc mở rộng stack thông thường trong lúc chạy sẽ do hệ điều hành xử lý
  • Không phân tích ABI của nhị phân x64 đầu vào, mà chỉ thực hiện dịch ABI tại những điểm thực thi đi sang mã ngoài hoặc quay trở lại, theo quy tắc ABI x64 System V và AArch64 Procedure Call Standard
  • Nhờ bảo toàn trạng thái đầy đủ và ánh xạ thanh ghi một-một, mỗi lệnh x64 có thể được dịch độc lập mà không cần biết lệnh trước hay sau
  • Mỗi byte offset có thể thực thi của nhị phân gốc được diễn giải đồng thời là dữ liệu và là điểm bắt đầu tiềm năng của một chuỗi lệnh
  • Mọi đích tiềm năng không thể phân tích tĩnh, như nhảy gián tiếp, callback hay runtime dispatch, đều có điểm hạ cánh tương ứng trong nhị phân đã viết lại
  • Lúc chạy, đích sẽ được giải bằng bảng tra cứu từ địa chỉ lệnh gốc sang địa chỉ mã đã dịch được nhúng trong nhị phân cuối cùng
  • Ví dụ lệnh lồng nhau

    • Listing 1 cho thấy nếu bắt đầu giải mã tại .byte 0xB0 thì sẽ có MOV AL, 0xC3 rồi RET, còn nếu bắt đầu ở ReturnC2 lùi một byte thì chỉ có RET
    • Cả hai cách giải mã đều có thể được đạt tới từ jz trước đó, nên nếu trình dịch chỉ chọn một cách diễn giải cho hai byte này thì sẽ bỏ lỡ một đường đi
  • Ví dụ nhánh gián tiếp được tính toán

    • Listing 2 cho thấy call Label tạo ra địa chỉ cơ sở của bảng, pop rsi lấy lại địa chỉ đó, rồi cộng offset phụ thuộc đầu vào để tạo đích cho jmp rsi
    • Nhánh có thể hạ cánh vào một trong bốn lệnh inc eax được đặt cách nhau 2 byte trong luồng mã hóa
    • Một trình dịch chỉ viết lại các đích nhảy có thể phân tích tĩnh sẽ không có nơi để nhánh như vậy hạ xuống
  • Gọi, trả về và nhánh

    • Các lệnh call, return, branch không thể biểu diễn bằng tile C vì vị trí địa chỉ trả về, bộ đếm chương trình và bố cục cờ điều kiện khác nhau giữa x64 và AArch64
    • Gọi trực tiếp sẽ đẩy địa chỉ trả về x64 gốc lên stack mô phỏng rồi nhảy tới tile đã dịch của hàm được gọi
    • Gọi gián tiếp sẽ kiểm tra xem đích nằm trong nhị phân đã dịch hay thư viện ngoài; đích nội bộ sẽ được dịch qua bảng x64 offset-to-tile rồi nhảy tới tile đó
    • Với đích ngoài, nó đặt địa chỉ gadget dịch ABI ngược vào X30, thực hiện dịch ABI thoát rồi nhảy sang đích ngoài
    • Lệnh trả về lấy địa chỉ trả về 8 byte khỏi stack mô phỏng, so sánh với phạm vi nhị phân x64 nhúng, và nếu là trả về nội bộ thì dịch địa chỉ qua bảng tra cứu rồi nhảy tới tile tương ứng
    • Nhánh trực tiếp có đích được biết tại thời điểm dịch, còn nhánh có điều kiện được dịch thành nhánh có điều kiện AArch64 kiểm tra các bit cờ x64 lưu trong X14
    • Nhánh gián tiếp sẽ phát sinh bounds check giống gọi gián tiếp và trả về gián tiếp, và nếu đích là bên ngoài thì thực hiện dịch ABI thoát

Pipeline dịch dựa trên tile

  • Việc dịch của Elevator được chia thành ba giai đoạn: tạo tile bank ngoại tuyến, viết lại theo từng nhị phân đầu vào, và đóng gói cuối cùng
  • Giai đoạn ngoại tuyến biểu diễn ngữ nghĩa lệnh x64 bằng các hàm C, chuyên biệt hóa theo từng tổ hợp toán hạng dưới ánh xạ thanh ghi x64-to-AArch64 cố định, rồi biên dịch bằng LLVM 20 đã chỉnh sửa để tạo các chuỗi byte AArch64 có thể tái sử dụng
  • Giai đoạn theo từng nhị phân đầu vào thực hiện superset disassembly, rồi với mỗi lệnh ứng viên được phát hiện sẽ tra tile theo tên và nối các chuỗi byte AArch64 lại
  • Những loại lệnh khó biểu diễn bằng tile C, như truyền luồng điều khiển và ranh giới ABI, được xử lý bằng các mẫu nhỏ viết tay
  • Giai đoạn đóng gói kết hợp mã đã dịch, nhị phân x64 gốc, bảng tra cứu địa chỉ và runtime driver để tạo ra nhị phân AArch64 độc lập có thể chạy được
  • Tile bank ngoại tuyến

    • Việc viết tay chuỗi lệnh AArch64 tương đương cho từng lệnh x64 là không thực tế
    • Chỉ riêng một mẫu như ADD Reg8, Reg8 cũng mở rộng thành 256 tổ hợp thanh ghi cụ thể, và toàn bộ tập lệnh x64 còn có nhiều biến thể địa chỉ hóa cho thanh ghi, toán hạng bộ nhớ và immediate
    • Elevator viết ngữ nghĩa của từng lệnh x64 dưới dạng hàm C nhỏ, chuyên biệt hóa theo từng tổ hợp toán hạng cụ thể, rồi để LLVM biên dịch sang AArch64
    • Trong ví dụ ADD Reg8, Reg8, mẫu sẽ cập nhật 8 bit thấp của thanh ghi đích bằng tổng 8 bit và giữ nguyên 56 bit trên để khớp với ngữ nghĩa ghi thanh ghi từng phần của x64
    • Lệnh x64 ADD Reg8, Reg8 cũng thay đổi các cờ Carry, Parity, Auxiliary Carry, Zero, Sign và Overflow của RFLAGS, nên do giới hạn hàm C chỉ có một giá trị trả về, phần cập nhật cờ được tách ra thành flag tile riêng
    • Một lệnh x64 có thể tương ứng với một hoặc nhiều tile, và khi phát ra, chúng sẽ được ghép liên tiếp để khôi phục đầy đủ ngữ nghĩa
    • Thuộc tính aarch64_custom_reg khai báo cho LLVM biết giá trị trả về và từng đối số sẽ được đặt vào thanh ghi AArch64 nào
    • Ánh xạ cố định này được chọn để khớp tính chất callee-saved và caller-saved của x64 System V với AAPCS64, giảm việc hoán đổi vị trí thanh ghi chứa đối số nguyên, và giữ lại các thanh ghi callee-saved AArch64 còn trống cho trạng thái bóng trong tương lai
    • Các bit RFLAGS và file thanh ghi XMM của x64 cũng được lưu trong các thanh ghi AArch64 chuyên dụng theo cùng nguyên tắc một-một
    • LLVM 20 đã chỉnh sửa xử lý thuộc tính aarch64_custom_reg theo từng hàm và phân loại lại các thanh ghi AArch64 chứa trạng thái x64 được mô phỏng thành callee-saved trong allocator
    • TileGen duyệt qua các mẫu C để tạo bản sao chuyên biệt cho mọi tổ hợp toán hạng hợp lệ, rồi tự động tổng hợp thuộc tính từ vị trí tham số của mẫu và ánh xạ thanh ghi
  • Viết lại theo từng nhị phân đầu vào

    • Khi nhận nhị phân x64 đầu vào, giai đoạn per-binary sẽ thực hiện superset disassembly rồi duyệt CFG kết quả
    • Tại mỗi node, formatter tạo tên tile từ opcode và toán hạng của lệnh đã giải mã; với lệnh cần nhiều tile thì ghép nhiều tên lại
    • x64 không có ràng buộc căn chỉnh stack pointer, nhưng AArch64 yêu cầu căn chỉnh 16 byte khi dùng stack pointer trong toán hạng bộ nhớ
    • Nếu ánh xạ trực tiếp RSP sang SP, các mẫu mã x64 phổ biến như nhiều PUSH liên tiếp trong function prologue có thể gây lỗi căn chỉnh trên AArch64
    • Elevator cho tile truy cập stack qua thanh ghi riêng X25, và chỉ hiện thực hóa SP vào đó khi tile thực sự cần
    • Các tile biên dịch bằng LLVM kỳ vọng SP đã căn chỉnh 16 byte khi vào, nên trước khi chạy tile được phát hiện có cấp phát spill space, hệ thống sẽ căn SP xuống và khôi phục lại sau đó
    • Vì tile tính cờ tương đối tốn kém, nếu cờ bị ghi đè trước khi được đọc ở lệnh post-dominating phía sau, phần tính cờ của node hiện tại sẽ bị loại bỏ
    • Các lệnh hiện chưa được hỗ trợ chủ yếu là AVX2 và các phần mở rộng vector rộng hơn về sau của x64; ở những vị trí đó sẽ chèn lệnh interrupt thay cho tile
    • Trong đánh giá đầy đủ SPECint 2006, toàn bộ ISA số nguyên x86-64 cùng tập con SSE mà SPECint sử dụng đã đủ để chạy tất cả benchmark
    • Có thể mở rộng hỗ trợ thêm lệnh bằng cách bổ sung tile mới, nhưng nhóm tác giả cho rằng thêm kỹ thuật triển khai sẽ ít mang lại hiểu biết khoa học mới

Xử lý ranh giới ABI

  • Elevator chỉ hỗ trợ các nhị phân liên kết động
  • Nhị phân liên kết tĩnh có thể chứa trực tiếp các lệnh đặc thù kiến trúc như CPUID, còn nhị phân liên kết động sẽ ủy quyền việc đó cho libc, nên nhu cầu dịch giảm đi
  • Khi tương tác với thư viện liên kết động, hệ thống hỗ trợ chuyển đổi giữa ABI Linux x64 và ABI Linux AArch64 để đi qua lại giữa môi trường x64 được mô phỏng và mã thư viện AArch64 native
  • Các yếu tố cốt lõi cần dịch ABI là cách bố trí đối số và vị trí địa chỉ trả về
  • ABI System V x64 dùng sáu thanh ghi RDI, RSI, RDX, RCX, R8, R9 làm thanh ghi đối số, còn các đối số bổ sung được truyền trên stack từ [RSP+8]
  • x64 CALL lưu địa chỉ trả về tại [RSP]
  • AArch64 Procedure Call Standard dùng tám thanh ghi đối số X0-X7, đặt các đối số còn lại trên stack tại [SP], và lưu địa chỉ trả về trong X30
  • Gọi thư viện ngoài

    • Khi một lời gọi x64 đã dịch nhắm tới thư viện ngoài, nó phải đổi bố cục đối số cho phù hợp với calling convention của AArch64
    • Trước tiên, hệ thống trừ 8 khỏi SP để căn lại về biên 16 byte, và đặt địa chỉ trả về x64 vốn đã có trên stack vào [SP+0x8]
    • Các giá trị tại [SP+0x10], [SP+0x18] được nạp vào X6, X7 để thư viện AArch64 có thể nhìn thấy đối số thứ 7 và 8 tiềm năng mà mã x64 đã đặt trên stack
    • Các đối số stack tiềm năng còn lại vẫn nằm từ [SP+0x20], không khớp với vị trí mà AArch64 mong đợi
    • Việc loại bỏ khỏi stack địa chỉ trả về x64 cùng các giá trị đã chuyển sang X6, X7 là không an toàn vì chúng có thể không phải đối số thực mà là caller spill space hoặc một phần struct nằm trên stack của caller
    • Elevator không đụng vào bố cục stack của caller mà cấp phát thêm n×8 byte stack, rồi sao chép n đối số 8 byte tiềm năng từ vị trí hiện tại
    • Giá trị mặc định của n là 10, và có thể tăng bằng cấu hình nếu nhị phân đầu vào truyền tổng cộng hơn 16 đối số cho hàm thư viện ngoài
    • Cuối cùng, hệ thống lưu địa chỉ gadget mà thư viện ngoài sẽ quay về vào X30
  • Quay về từ thư viện ngoài

    • Khi điều khiển quay lại gadget đã lưu trong X30 trước khi gọi thư viện ngoài, hệ thống cộng n×8 vào stack pointer để dọn các đối số stack đã sao chép trước đó
    • Giá trị trả về của thư viện ngoài được chuyển từ X0 sang X9, là vị trí RAX mà mã x64 mô phỏng kỳ vọng
    • Sau đó lấy địa chỉ trả về x64 gốc cùng phần đệm liên quan khỏi stack, dịch địa chỉ và nhảy tới đó để tiếp tục thực thi sau CALL ban đầu
  • Callback đi vào mã đã dịch

    • Nếu mã AArch64 native gọi vào nhị phân đã dịch, phải chuyển calling convention AArch64 sang calling convention x64
    • Mã x64 mô phỏng kỳ vọng đối số thứ 7 và 8 nằm trên stack chứ không phải trong X6, X7, nên hệ thống sẽ push X7 trước rồi push X6 sau để đặt chúng đúng vị trí trên stack mà x64 chờ đợi
    • Nếu callee thực ra không cần đối số thứ 7 và 8 thì các giá trị được push này sẽ không ảnh hưởng gì
    • Địa chỉ trả về mà lệnh branch-and-link của thư viện ngoài AArch64 đặt trong X30 sẽ được push vào vị trí trên stack mà lệnh trả về của x64 mong đợi
  • Trả từ callback về thư viện ngoài

    • Khi mã đã dịch quay về thư viện ngoài từ callback, hệ thống thực hiện ngược lại quá trình lúc vào
    • Địa chỉ trả về được lấy khỏi stack, còn X6X7 được push ra, và phần stack đã cấp phát sẽ được dọn bằng cách cộng 0x10 vào stack pointer

1 bình luận

 
Ý kiến trên Hacker News
  • Tôi không rõ chính xác chế độ người dùng JIT của QEMU làm gì, nhưng có vẻ vẫn còn khá nhiều chỗ để cải thiện
    Năm 2013, tôi đã làm một động cơ JIT chuyển từ x86-64 sang aarch64, và khi đó có thể chạy các binary Fedora beta aarch64 cũng như build lại phần lớn bản port aarch64 của Fedora trên Linux x86_64
    Tôi cũng làm JIT theo chiều ngược lại là aarch64 → x86-64, và cho vui còn trình diễn việc hai JIT chạy loopback lẫn nhau trong cùng một process theo kiểu x86-64 → aarch64 → x86_64
    JIT tôi làm ánh xạ lệnh và trạng thái CPU theo kiểu 1-nhiều, và chậm hơn khoảng 2~5 lần so với mã tái biên dịch native
    Về sau khi so với QEMU JIT, QEMU có vẻ nằm trong khoảng chậm hơn 10~50 lần
    Đáng tiếc là do vấn đề cấp phép mã nguồn mở nên tôi không thể công khai mã để chứng minh

    • Đúng vậy, QEMU JIT gần như là mục tiêu khá dễ để vượt qua
      Đặc biệt nếu có thể chuyên biệt thiết kế cho “chỉ x86 sang aarch64” và “chỉ chế độ người dùng” thì có rất nhiều lợi thế về hiệu năng
      Hỗ trợ chế độ người dùng của QEMU gần giống một phần phụ “vô tình chạy được” gắn lên trên hỗ trợ giả lập hệ thống, và cấu trúc JIT tổng thể cũng theo kiểu “guest → biểu diễn trung gian → host”, nên rất phù hợp để hỗ trợ nhiều kiến trúc guest và nhiều kiến trúc host, nhưng lại khó tận dụng các đặc tính của từng cặp guest/host cụ thể như “x86 có ít thanh ghi số nguyên nên có thể hard-allocate” hay “nếu đặt CPU aarch64 vào chế độ phù hợp thì ngữ nghĩa dấu phẩy động phức tạp sẽ luôn khớp”
      Hơn nữa, trong quá trình phát triển QEMU, thời gian dành cho việc “giả lập tính năng kiến trúc mới X” thường nhiều hơn tìm cơ hội tối ưu hiệu năng, vì bên chi tiền phát triển coi đó là điều quan trọng hơn
    • QEMU không hẳn là một trình dịch mà là TCG, và vì được thiết kế để chạy trên n kiến trúc nên nó có những giới hạn nhất định
  • Việc .text section phình lên 50 lần là rất lớn, nhưng như cái giá phải trả để có được chuyển đổi hoàn toàn tất định thì có vẻ vẫn chấp nhận được
    Trong nhiều trường hợp, chênh lệch hiệu năng so với giả lập sẽ lớn hơn nhiều so với bất tiện do tăng kích thước
    Cũng thú vị ở chỗ đa luồng và xử lý ngoại lệ không phải là bất khả thi, mà chỉ nằm ngoài phạm vi của dự án này
    Tôi tự hỏi bước tiếp theo có phải là dùng heuristic để cắt bớt không gian khả năng nhằm giảm kích thước binary hay không
    Khi đó bảo đảm chuyển đổi sẽ bị phá vỡ, nhưng tính thực tế về khả năng di chuyển binary có thể sẽ tốt hơn

    • Không hẳn chênh lệch hiệu năng so với giả lập sẽ lớn hơn
      Trình dịch này chậm hơn Box64 hay FEX rất nhiều, và nếu không phải ở tình huống nào đó không thể dùng JIT thì đây đơn giản là lựa chọn tệ hơn
  • Tôi luôn tò mò trình dịch xử lý nhảy gián tiếp như thế nào
    Khi phân tích binary, bạn chỉ có thể phát hiện các đoạn mã được nối bằng nhảy trực tiếp nơi địa chỉ đích đã biết
    Vậy thì điều đó có nghĩa là mỗi khi xảy ra nhảy gián tiếp, phải tìm hàm đích, dịch nếu cần, rồi quay lại mã đã dịch, như vậy có chậm không?
    Tôi muốn biết liệu có cách nhanh hơn không, có thể khớp địa chỉ hàm đã dịch với địa chỉ hàm gốc hay không, hoặc là chèn một lệnh nhảy từ địa chỉ gốc sang mã đã dịch

    • Trình dịch tôi làm chỉ ở mức hobby, nhưng nó dùng một bảng lớn kiểu “nếu jmp gián tiếp tới địa chỉ X thì block tương ứng ở vị trí Y”
      Cách này chậm hơn jmp trực tiếp vốn không cần bảng, nhưng ngay trong chương trình gốc thì nhảy gián tiếp vốn dĩ cũng chậm hơn, và thường không xuất hiện nhiều trong các vòng lặp quan trọng về hiệu năng
  • Tôi thực sự thích ý tưởng đồ thị luồng điều khiển siêu tập, nhưng nếu ai định đọc bài này thì nên biết vài điểm sau
    Thời gian chạy nhanh hơn khoảng 4,75 lần (nhanh hơn QEMU nhưng vẫn chậm hơn Box64 khá nhiều), số lệnh thực thi tăng 7 lần, kích thước binary tăng 50 lần
    Nó mô phỏng ABI x86 cho tới trước khi gọi ra bên ngoài
    Nó phải mô phỏng phần lớn trạng thái CPU x86 như EFLAGS, và cả những mov phức tạp cũng phải được tính riêng lẻ
    Chỉ hỗ trợ binary đơn luồng
    Không có xử lý ngoại lệ và stack unwinding
    Không hỗ trợ toàn bộ tập lệnh

  • Đây là công việc thú vị
    Tôi chưa xem kỹ, nhưng offset tương đối có vẻ vẫn có thể là vấn đề
    Dù sao thì kích thước kết quả sinh mã chắc chắn sẽ khác đi, nên có lẽ cần một lớp dịch nào đó hoặc MMU, và chủ yếu sẽ ảnh hưởng đến bảng nhảy và các nhánh nội bộ
    Tôi chủ yếu làm việc với đồ từ thập niên 90, và disassembler thường đưa ra rất nhiều giả định về điểm bắt đầu và kết thúc của mã
    Nhưng đôi khi cũng có trường hợp không thể tìm ra các khối binary nếu không có tri thức sẵn như con trỏ entry point ở vị trí cố định
    Có lẽ sau vài lượt pass có thể tinh lọc binary thành các “vùng chắc chắn là mã”

  • Nếu “Elevator xét mọi cách diễn giải có thể có của mọi byte, tạo trước bản dịch riêng cho từng khả năng [...] và chỉ cắt tỉa những trường hợp dẫn tới crash” thì mọi chương trình thực tế có khả năng va chạm đều sẽ bị cắt tỉa hết sao?

    • Có lẽ nó sẽ đặt thành một đường va chạm được chuẩn hóa trong bảng tra cứu địa chỉ → mã
      Khi đó vẫn sẽ bị crash, nhưng sẽ không giống với kiểu crash do thực thi trực tiếp mã sai
  • Phần thú vị nhất với tôi là góc nhìn chứng nhận
    Trong các ngành bị quản lý như hàng không hay thiết bị y tế, thường có trường hợp không thể dùng JIT vì mã đang chạy phải là mã đã được chứng nhận, chính xác vì lý do này
    Một phép chuyển đổi tĩnh có thể tạo ra binary có thể ký, dù phải chấp nhận phình mã, vẫn có thể là một bước đột phá thực sự

    • Tôi tò mò quy mô của mảng này trong ngành phần mềm lớn đến mức nào
      Có lẽ đây cũng là lĩnh vực mà LLM không có cách áp dụng ở quy mô lớn, nhưng trong các đại tự sự lớn về “AI trong công việc” thì gần như không thấy nhắc đến phần này
  • 50 lần là không hợp lý và sẽ là thảm họa cache
    Toàn bộ lợi ích hiệu năng thu được nhờ tránh JIT có thể bị nuốt sạch

    • Chỉ đúng nếu toàn bộ số mã đó thực sự được dùng trong lúc chạy, còn phần lớn các điểm bắt đầu giải mã khả dĩ có lẽ sẽ không được dùng đến
    • Đây là trường hợp cực kỳ phù hợp cho sắp xếp lại mã ở thời điểm liên kết
      Nếu gom mã nóng vào một chỗ thì có thể khiến phần mã không dùng tới không bao giờ phải nạp
    • Tôi sẽ không vội kết luận
      Dù sao lệnh cũng không lớn đến vậy, và CPU còn tự tối ưu trong lúc chạy nữa
  • Nó có xử lý được mã tự sửa đổi không?
    Tôi cũng thắc mắc vì sao lại chỉ là x86_64
    Có vẻ việc chuyển các chương trình 32-bit như game cũ sẽ ý nghĩa hơn

    • Nếu đọc bài được liên kết thì phần này được nói rất rõ
      “Mã tự sửa đổi và mã biên dịch JIT. Elevator, cũng như mọi trình viết lại binary hoàn toàn tĩnh khác, không hỗ trợ mã tự sửa đổi hay mã biên dịch JIT”
    • Tôi cho rằng mã tự sửa đổi ngoài runtime JIT ngày nay khá hiếm so với thời thập niên 80~90
      Giờ .text section phần lớn là chỉ đọc, và các yêu cầu bảo mật cũng khó mà giảm đi
    • Nếu xử lý mã tự sửa đổi thì nó sẽ không còn là “hoàn toàn tĩnh” nữa
      Về bản chất là mâu thuẫn
    • Nếu nhìn từ phía người phát triển x86 mới, mã tự sửa đổi dù làm được thì thường cũng rất tệ
      Vì nó phá hỏng hiệu năng của cache line và dự đoán nhánh trong pipeline
      Ngoài ra nó còn vi phạm W^X, nên thường chỉ nên dùng trên các trang nhớ tương thích với JIT
      Vì vậy gần như luôn nên tránh
      Thời 486 hay P5 thì người ta có dùng ở mức nào đó, kiểu dùng immediate value như biến vòng lặp nội bộ, nhưng giờ thì không còn mấy
      Để đạt được mô phỏng hay chuyển đổi gần như hoàn hảo thì còn rất nhiều trường hợp ngoại lệ lằng nhằng của x86 phải xử lý
  • Mã nguồn ở đâu?