JVM viết bằng Rust
(andreabergia.com)- Dự án phụ
rjvmđể học Rust là một JVM đồ chơi phục vụ học tập, nhưng đã được triển khai đến mức có thể đọc các tệp.classvàjarthật, cũng như thực thi bytecode Java - Các tính năng chưa hỗ trợ gồm thread, reflection, annotation, I/O, trình biên dịch JIT và string interning, nhưng đã bao gồm luồng điều khiển, tạo đối tượng, gọi phương thức, ngoại lệ và garbage collection
- Ví dụ dùng
rt.jarthật của OpenJDK 7, nên các lớp nhưjava.lang.StackTraceElementđược tải từ JDK thật - Mã được chia thành ba Rust crate:
reader,vm,vm_cli, bao quát từ phân tích cú pháp.classđến call stack, native method, mô hình hóa giá trị và đối tượng - Cột mốc cuối cùng là stop-the-world semispace copying collector; sau khi đạt mục tiêu, dự án đã dừng lại và cũng không có kế hoạch sửa các lỗi đã biết
JVM Rust phục vụ học tập rjvm
rjvmlà một Java Virtual Machine đồ chơi được tạo ra để học Rust- Mã nguồn được công khai trên GitHub
- Đây là một bản triển khai nhằm mục đích học tập hơn là một JVM nghiêm túc, nên không hỗ trợ các tính năng sau
- Thread
- Reflection
- Annotation
- I/O
- Trình biên dịch JIT
- String interning
- Generic ban đầu nằm trong danh sách chưa hỗ trợ, nhưng thực tế lại hoạt động
Đã triển khai đến đâu
rjvmkhông chỉ là một parser đơn giản mà còn tự triển khai nhiều hoạt động của JVM- Luồng điều khiển như
if,for - Tạo kiểu nguyên thủy và đối tượng
- Gọi phương thức ảo và phương thức tĩnh
- Xử lý ngoại lệ
- Garbage collection
- Phân giải lớp từ tệp
jar
- Luồng điều khiển như
- Mã kiểm thử có các ví dụ dùng
Throwable,Exception,StackTraceElement - Vì không có I/O thật, thay vì
System.out.printlnnó dùng một native method tên làtempPrint - Ví dụ dùng
rt.jarthật của OpenJDK 7, vàjava.lang.StackTraceElementcũng được lấy từ JDK thật
Ba Rust crate
- Dự án là một dự án Rust tiêu chuẩn, gồm ba crate
reader: chứa các kiểu dùng để đọc tệp.classvà mô hình hóa nội dung của chúngvm: cung cấp máy ảo có thể thực thi mã dưới dạng thư việnvm_cli: chứa một launcher dòng lệnh đơn giản, tương tự tệp thực thijava
- Đang cân nhắc tách crate
readerthành kho riêng và phát hành lên crates.io
Phân tích cú pháp tệp .class
- Mã Java được biên dịch bằng
javacthành tệp.class, và thường được phân phối dưới dạng tệp.jar, vốn là định dạngzip - Để chạy mã Java, trước tiên cần tải tệp
.classchứa bytecode do trình biên dịch tạo ra - Tệp class chứa cả những thông tin cần thiết cho thực thi và phân giải kiểu
- Metadata như tên lớp và tên tệp nguồn
- Tên superclass
- Các interface đã implement
- Field, kiểu field và annotation
- Descriptor của phương thức, mệnh đề
throws, annotation, thông tin generic - Bytecode, bảng exception handler, bảng số dòng
- Crate
readerphân tích cú pháp tệp class và trả về một Rust struct mô hình hóa lớp cùng nội dung của nó
Thực thi phương thức và call stack
- API chính của crate
vmlàVm::invoke, dùng để thực thi phương thức - Mỗi phương thức đang chạy có một
CallFrametrongCallStack - Khi chạy
main, call stack ban đầu rỗng, và một frame mới được tạo để thực thi - Mỗi lần gọi hàm, một frame mới được thêm vào call stack; khi phương thức chạy xong, frame đó được gỡ bỏ
- Phần lớn phương thức được triển khai bằng bytecode Java, nhưng
rjvmcũng hỗ trợ native method- Native method là phương thức do chính JVM trực tiếp triển khai, không phải bytecode Java
- Ví dụ gồm
System::currentTimeMillis,System::arraycopy,Throwable::fillInStackTrace - Trong
rjvm, chúng được triển khai bằng hàm Rust
- JVM là máy ảo dựa trên stack, nên các lệnh bytecode chủ yếu hoạt động trên stack giá trị
- Mỗi call frame gắn với một stack giá trị và một tập biến cục bộ được định danh bằng chỉ số
Mô hình hóa giá trị và đối tượng
Valuemô hình hóa các giá trị có thể được lưu trong biến cục bộ, phần tử stack và field của đối tượngValueđược triển khai bằngenumcủa Rust và gồm các trạng thái sauUninitializedInt(i32)Long(i64)Float(f32)Double(f64)Object(AbstractObject<'a>)Null
- Sum type như
enumcủa Rust rất phù hợp để biểu diễn việc một giá trị có thể thuộc một trong nhiều kiểu - Việc lưu trữ đối tượng ban đầu khởi đầu bằng một struct
Objectđơn giản dựa trênVec<Value>, chứa tham chiếu lớp và các giá trị field - Sau khi triển khai garbage collector, nó được đổi sang một triển khai cấp thấp hơn, dùng nhiều con trỏ và phép cast
- Hiện tại
AbstractObjectmô hình hóa đối tượng hoặc mảng thật, là con trỏ tới một mảng byte chứa vài header word và các giá trị field
Thực thi lệnh bytecode
- Thực thi phương thức là quá trình xử lý từng lệnh bytecode một
- JVM có hơn 200 lệnh, được mã hóa bằng 1 byte trong bytecode
- Nhiều lệnh có đối số đi kèm, và một số lệnh có độ dài biến đổi
rjvmmô hình hóa các lệnh bytecode Java bằng kiểuInstruction- Khi thực thi phương thức, nó duy trì stack giá trị và mảng biến cục bộ, đồng thời khởi tạo program counter — địa chỉ lệnh sẽ chạy tiếp theo — bằng 0
- Thông thường sau khi chạy một lệnh, program counter chuyển sang lệnh kế tiếp, nhưng lệnh nhảy có thể chuyển nó đến vị trí khác
- Lệnh nhảy được dùng để triển khai các cấu trúc điều khiển luồng như
if,for,while - Các lệnh gọi phương thức khác tồn tại như một nhóm riêng
- Cách xác định phương thức nào sẽ được gọi gồm tra cứu ảo, tra cứu tĩnh, v.v.
- Sau khi phân giải phương thức đích, một frame mới được thêm vào call stack và bắt đầu thực thi
- Nếu giá trị trả về không phải
void, giá trị trả về được push lên stack rồi việc thực thi tiếp tục
Xử lý ngoại lệ
- Ngoại lệ phức tạp để triển khai vì chúng phá vỡ luồng điều khiển thông thường, khiến phương thức trả về sớm và có thể lan truyền dọc theo call stack
- Mỗi khối
catchtương ứng với một mục trong bảng ngoại lệ của phương thức - Mục trong bảng ngoại lệ chứa thông tin cần thiết để tìm handler
- Phạm vi program counter áp dụng
- Địa chỉ lệnh đầu tiên của khối
catch - Tên lớp ngoại lệ mà khối đó bắt
CallFrame::execute_instructiondùngResultcủa Rust để biểu diễn kết quả thực thi lệnh- Kết quả thực thi lệnh được chia thành bốn trạng thái
- Thành công và tiếp tục thực thi phương thức hiện tại
- Thành công, và vì là lệnh
returnnên phương thức hiện tại kết thúc cùng giá trị trả về - Thất bại do lỗi VM nội bộ
- Thất bại do một ngoại lệ Java được ném ra
- Vòng lặp thực thi phương thức phân tích cú pháp lệnh, di chuyển program counter đến địa chỉ kế tiếp, rồi thực thi lệnh
- Khi phát sinh ngoại lệ, nó tìm exception handler phù hợp với vị trí lệnh hiện tại
- Nếu không có handler, ngoại lệ được truyền cho caller
- Nếu có handler, đối tượng ngoại lệ được push lại lên stack và việc thực thi tiếp tục tại vị trí handler của
catch
Resultvà pattern matching của Rust rất phù hợp để biểu diễn hành vi này trong cấu trúc mã
Garbage collection
- Cột mốc lớn cuối cùng của
rjvmlà triển khai garbage collector - Thuật toán được chọn là stop-the-world semispace copying collector
- Vì không có thread, cách stop-the-world tự nhiên được thỏa mãn
- Triển khai là một biến thể đơn giản hơn của Cheney's algorithm, mã nằm trong gc.rs
- Cách này chia bộ nhớ khả dụng thành hai semispace
- Một vùng là vùng hoạt động, dùng để cấp phát đối tượng
- Vùng còn lại để không sử dụng
- Khi vùng hoạt động đầy, các đối tượng còn sống được sao chép sang semispace kia
- Tất cả tham chiếu đối tượng được cập nhật để trỏ tới bản sao mới
- Vai trò của hai semispace được hoán đổi
- Quy trình này được ví như một cách tương tự blue-green deployment
- Ưu và nhược điểm của thuật toán rất rõ ràng
- Không thể dùng một nửa bộ nhớ tối đa, nên lãng phí bộ nhớ lớn
- Cấp phát rất nhanh vì chỉ tăng con trỏ
- Vì sao chép và nén đối tượng, không cần xử lý phân mảnh bộ nhớ
- Nén đối tượng có thể cải thiện hiệu năng nhờ tận dụng cache line tốt hơn
- Các Java VM thực tế thường dùng garbage collector theo thế hệ tinh vi hơn, như G1 hoặc parallel GC
Điểm kết thúc của dự án
- Khi tạo
rjvm, tác giả đã học được rất nhiều về Rust và triển khai máy ảo, đặc biệt hài lòng với việc triển khai được một garbage collector thật sự hoạt động - Garbage collector chưa đạt mức hoàn thiện cao, nhưng thực sự chạy được
- Vì mục tiêu ban đầu đã đạt được, dự án dừng lại tại đây
- Có các lỗi đã biết, nhưng không có kế hoạch sửa chúng
- Rust là một ngôn ngữ đem lại trải nghiệm thú vị khi dùng để triển khai JVM, và trong các bài viết sau tác giả dự định trình bày chi tiết hơn về triển khai
rjvmcũng như cách JVM hoạt động
1 bình luận
Các ý kiến trên Hacker News
Phần khó trong việc triển khai garbage collection là đảm bảo mọi tham chiếu đều được bắt làm root đúng cách, đặc biệt là với collector kiểu di chuyển
Phương thức
do_garbage_collectionđược đánh dấu làunsafe[1], nhưng không có mô tả phía gọi phải bảo đảm điều gì để có thể gọi nó một cách an toànTôi thắc mắc làm sao bảo đảm rằng mọi tham chiếu tới heap đều được bắt làm root, và đây không phải là vấn đề nhỏ[2][3][4]
Ngoài ra, khi clone repository rồi chạy
cargo test, tất cả test đều thất bại với lỗishould be able to add entries to the classpath: InvalidEntry(".../vm/rt.jar"):vm/tests/integration/real_code_tests.rs:15:10[1] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
[2] https://manishearth.github.io/blog/2021/04/05/a-tour-of-safe...
[3] https://without.boats/blog/shifgrethor-iii/
[4] https://coredumped.dev/2022/04/11/implementing-a-safe-garbag...
Vì vậy có thể duyệt ngăn xếp đó để tìm tham số và biến cục bộ rồi dùng chúng làm root
Cách này có chi phí hiệu năng, nhưng việc tracing cho garbage collection đơn giản hơn nhiều, và cũng dễ triển khai các primitive về đồng thời và luồng điều khiển như coroutine hay continuation hơn
Dự án tốt, xin chúc mừng
Tuy nhiên phần “không hỗ trợ: generic” khiến tôi thấy hơi lạ
Tôi tò mò không rõ JVM cần hỗ trợ generic theo nghĩa nào
Ở mức bytecode, do type erasure, chẳng phải có thể đơn giản xem tất cả là
Object, tức kiểu tham chiếu, hay sao? Hay là đang nói tới parser định nghĩa class? Ngay cả vậy thì ngoài cú pháp cơ bản, dường như cũng không có logic kiểm tra xem class file có hợp lệ hay khôngViệc thực sự cần làm chỉ là đọc thuộc tính Signature chứa thông tin generic của class, method và field (https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.ht...)
Thực tế tôi vừa thử và đoạn code dưới đây cũng chạy :-)
public class Generic { public static void main(String[] args) { List strings = new ArrayList(10); strings.add("hey"); strings.add("hackernews"); for (String s : strings) { tempPrint(s); } } private static native void tempPrint(String value); }checkcast: https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.ht...Ví dụ, nó sẽ được sinh ra nếu bạn viết code như
final Main value = list.get(0);http://henrikeichenhardt.blogspot.com/2013/05/how-are-java-g...
Ngược lại, việc không có string interning thì rất lạ. Triển khai cũng khá đơn giản, và nếu thiếu thứ này thì khó xem là JVM được
Việc các chuỗi trở nên bằng nhau theo tham chiếu là quan trọng và là một phần của JLS
Việc không có thread khiến toàn bộ nỗ lực này giống một dự án đồ chơi
Thật sự rất tuyệt. Khi tôi tham gia làm Java vào năm 1992, nó có tên là Oak, và nhóm của tôi đang hướng tới việc viết toàn bộ hệ điều hành bằng Java
Ý tưởng là có thể giảm bề mặt tấn công của hệ điều hành nhúng nếu chỉ để lại phần tối thiểu cần thiết dưới dạng “mã máy”, tức các native method
Ban đầu Java được nhắm tới để chạy ở những nơi như TV hay thiết bị gia dụng, và khi đó native method được làm bằng C chứ không phải Rust
Một JVM viết bằng Rust bổ sung an toàn bộ nhớ khá mạnh cho toàn bộ quá trình này
Rất nhiều logic hệ điều hành được viết bằng Java hoặc Kotlin, nhưng đồng thời cũng trộn lẫn nhiều system service bằng native code, và chúng được kết nối với nhau bằng Binder IPC nổi tiếng, hoặc tai tiếng
[0] - https://en.wikipedia.org/wiki/Singularity_(operating_system)
[1] - https://en.wikipedia.org/wiki/Midori_(operating_system)
Cũng đáng xem https://jacobin.org/, một triển khai JVM 17 viết bằng Go
Jacobins là một câu lạc bộ chính trị cách mạng trong thời Cách mạng Pháp thập niên 1790, và cũng là tên của tạp chí tại https://jacobin.com
Tôi tò mò liệu có bị vấp giới hạn vì tham số lifetime trong chữ ký này không
fn execute_instruction(&mut self, vm: &mut Vm<'a>, call_stack: &mut CallStack<'a>, instruction: Instruction) -> Result, MethodCallFailed<'a>>Nếu thêm lifetime vào biến thể
ErrcủaResultvà lifetime đó là bất biến, thì như ở đây dovmvàcall_stack, thường sẽ không thể dùng toán tử?hay trả về sớm[1]Khi đó xử lý lỗi sẽ dài dòng hơn và khó đọc hơn, nên tôi tò mò thực tế có đúng là trải nghiệm như vậy không
[1] https://users.rust-lang.org/t/nll-and-early-return-not-allow...
'adường như không được dùng cho tham chiếu mutable củavmhaycall_stack, nên có lẽ không phải vấn đề nàyVấn đề không hẳn là bản thân tính bất biến, mà là cách Rust suy luận lifetime của tham chiếu mutable, và đoạn code này đang tránh điều đó
Vậy thì tôi không rõ
'agắn vớiVMvàCallStackcó ý nghĩa gìNếu có thể tạo ra bằng bất kỳ lifetime không bị ràng buộc nào, thậm chí là
'static[1][2][3], thì coi như không đặt ràng buộc nàoTôi tò mò tham số lifetime này đang làm gì ở đây, và vì sao không loại bỏ nó
[1] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
[2] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
[3] https://github.com/andreabergia/rjvm/blob/be9c54066c64a82879...
Đây là một dự án học tập tuyệt vời, và thật tốt khi tác giả đang làm nó một cách vui vẻ
Tự triển khai VM từ con số không thật sự rất thú vị, và trước đây tôi đã học được rất nhiều khi làm những việc như vậy
Nếu muốn thử gắn garbage collection vào, bạn cũng có thể xem MMTk (https://www.mmtk.io/)
Nó có các thuật toán thu gom chất lượng cao được thiết kế để cắm vào nhiều VM, và được viết bằng Rust
Tôi từng định dùng cho một dự án đồ chơi nhưng vì dùng Mac nên đã bỏ cuộc
Làm rất tốt
Tạo VM lúc nào cũng thú vị, và kết hợp với hệ thống kiểu của Rust hẳn là một trải nghiệm học tập hấp dẫn
Nếu đang tìm việc, có thể liên hệ với tôi qua Twitter, Mastodon hoặc email công ty. Nhìn user ID của tôi ở đây là sẽ tìm được
Khi thấy những dự án tuyệt vời như thế này, tôi có cảm giác choáng ngợp
Sẽ rất hay nếu tác giả gốc giải thích nên bắt đầu với Rust như thế nào và nắm vững nền tảng đến mức nào thì mới có thể thử làm thứ như thế này
Không muốn đi lạc đề quá nhiều, nhưng cá nhân tôi gần đây đang vật lộn khá nhiều với cảm xúc này
Tôi đã làm lập trình viên phần mềm chuyên nghiệp gần 10 năm, và nhìn vào vị trí hiện tại cũng như khả năng thực sự đưa sản phẩm ra thị trường, tôi biết mình có năng lực và cũng biết mình không phải là một lập trình viên giả mạo
Thế nhưng gần đây khi đọc blog của các lập trình viên, tôi lại bị choáng ngợp như thể mình không biết đủ nhiều và không phải là lập trình viên “thực thụ”
Tôi nghĩ cảm xúc này nảy sinh vì tôi tự dựng lên trong đầu hình mẫu lập trình viên lý tưởng rồi so sánh bản thân với tiêu chuẩn tưởng tượng đó
Tôi vừa ngưỡng mộ những người có kiến thức sâu rộng, diễn đạt rõ ràng và súc tích, vừa tự hỏi tại sao mình không được như họ
Sau khi tan làm rồi chăm sóc gia đình, tôi hầu như không còn năng lượng để làm thêm gì nữa, và tôi cũng biết lập trình không phải là tất cả, nhưng tôi vẫn muốn học hỏi và phát triển hơn
Tôi biết điều đó không lành mạnh cũng không hợp lý, nhưng gần đây đó là cảm xúc khó gạt bỏ
Trước đây tôi từng có chút kinh nghiệm với VM, và vài năm trước cũng từng viết một loạt bài ngắn trên blog
Ở công ty trước, để giải quyết một vấn đề rất đặc thù của khách hàng, tôi cũng từng động chạm một chút đến bytecode JVM
Vài năm trước tôi cũng đã đọc cuốn https://craftinginterpreters.com/ xuất sắc, và lấy ý tưởng từ đó
Nhưng dự án này rõ ràng là lớn và phức tạp
Nó tốn rất nhiều thời gian, và như nhiều dự án phụ của tôi, cũng đã bị bỏ xó vài lần, nhưng tôi rất vui vì đã hoàn thành :-)
Gần đây tôi đào sâu vào socket, nhưng chỉ 2 tuần trước tôi vẫn chỉ có hiểu biết ở mức cao, thu được từ việc đọc lướt các trang manual, tài liệu, bài blog, v.v.
Vì muốn hiểu các nền tảng mạng, tôi quyết định đọc càng nhiều càng tốt, và một tuần sau đã học đủ để viết mã socket bằng Python và C
Vì tôi khá rành Python, nên sau khi đào sâu rồi nhìn vào thư viện
sockets, tôi hiểu nó tốt hơnNếu muốn giỏi hơn công nghệ A trong ngôn ngữ X, tôi khuyên bạn nên đọc hoặc xem càng nhiều càng tốt về công nghệ A, rồi thử xây thứ gì đó bằng ngôn ngữ Y
Sau đó khi quay lại ngôn ngữ X, bạn đã học được khá nhiều khái niệm xung quanh công nghệ A rồi
Một VM cho ngôn ngữ đơn giản sẽ có biểu diễn đối tượng trong bộ nhớ, trình thông dịch bytecode, một bộ thu gom rác đơn giản và bộ nạp
Có thể xem trình thông dịch bytecode như một stack, cách biểu diễn hàm trên stack đó, và một vòng lặp diễn giải từng bytecode rồi di chuyển program counter
Nếu là 0 giờ thì tất nhiên không có gì đáng trách, và cũng có nhiều việc khác cần tập trung, nhưng không có gì ngạc nhiên khi một người làm dự án phụ trung bình 10–20 giờ mỗi tuần trong nhiều năm lại tạo ra kết quả ấn tượng
Tranh thủ quảng bá một dự án tương tự một cách trơ trẽn: https://github.com/tenaf0/rust-jvm3
Tôi đang làm một hệ điều hành miễn phí cho các máy clone 386(486) AT. Chỉ là sở thích thôi, không lớn và chuyên nghiệp như gnu :-)
osdev.org, sandpile.org, RBIL, freevga cũng đáng tham khảo
Vấn đề đau đầu nhất là hỗ trợ phần cứng
Cũng có nhiều sách giấy vintage hay chứa các công thức như port I/O đáng tin cậy hoặc các mẹo phần cứng không được tài liệu hóa
Intel® 64 and IA-32 Architectures Software Developer’s Manual Combined Volumes: 1, 2A, 2B, 2C, 2D, 3A, 3B, 3C, 3D, and 4
Microsoft MS-DOS Programmer's Reference, bao gồm cả các lời gọi BIOS real-mode
PC Interrupts
Undocumented PC
PC Intern
Programmer's Guide To The EGA, VGA, And Super VGA Cards
Graphics Programming Black Book Special Edition
Ngoài ra, cũng đáng thử nghiệm những tiến bộ trong phát triển hệ điều hành sau thời đại kernel nguyên khối, microkernel và hybrid
Các cấu trúc dựa trên capability như seL4 có nhiều lợi thế bản chất về hiệu năng và bảo mật, bao gồm capability và IPC xuất sắc
Lớp tương thích POSIX cũng quan trọng. Ngay cả hệ điều hành nhúng không có khái niệm thread hay process cũng có thể triển khai POSIX
Hypervisor sẽ dễ thêm hơn nhiều nếu có Intel VT-[xd], còn nếu không thì có thể lùi về emulation. Emulation kiểu dịch chuyển đổi có hiệu năng rất tốt
Cần thành thạo việc tổng quát hóa và làm nhanh các interrupt handler, tránh race condition, và dùng các mẫu lock-free
Cũng cần viết lại hoặc trap các lệnh không được hỗ trợ, bao gồm x87 và MMX
Lý do microkernel thuần túy thất bại là độ phức tạp trong việc sắp xếp và quản lý nhiều tài nguyên theo kiểu giao dịch đã tăng lên
Kiến trúc microkernel về lý thuyết có lợi thế lớn về bảo mật và vận hành, nhưng ở dạng thuần túy thì chưa được chấp nhận rộng rãi