Tệp nhị phân .NET Hello World tối thiểu có thể nhỏ đến mức nào?
(blog.washi.dev)- Với một tệp thực thi trên .NET Framework 4.x chỉ in
"Hello World", kết quả biên dịch C# thông thường 4,6KB được giảm xuống còn 834 byte bằng cách tự dựng PE thủ công - Thử nghiệm đặt ra các điều kiện: entry point được quản lý, một tệp duy nhất có thể chạy trên Windows, chỉ dùng BCL hoặc các thư viện có sẵn trong bản cài Windows thông thường, và không tính các byte 0 ở cuối tệp
- Việc giảm kích thước được thực hiện bằng cách loại bỏ chú thích nullable reference type và metadata không cần thiết, chuyển sang PE 64-bit để loại bỏ imports và base relocations
- Thay vì
Console.WriteLine, gọiucrtbase!putsbằng P/Invoke để loại bỏ stream#USUTF-16, và chuyển chuỗi ASCII vào vùng padding giữa PE header và section đầu tiên - Tệp thực thi cuối cùng, không tính trailing zero, còn 834B(0x342); khi nén ZIP còn 476B, cho thấy giới hạn cấu hình tối thiểu của .NET PE và CLR metadata
Điều kiện thử nghiệm và mốc cơ sở
- Mục tiêu là chỉ đưa vào tệp thực thi .NET số byte tối thiểu cần thiết để CLR in
"Hello World"ra standard output - Quy tắc thử nghiệm như sau
- Ứng dụng phải chạy một entry point được quản lý được triển khai bằng C# hoặc CIL
- Runtime mục tiêu là .NET Framework 4.x.x
- Cấm phụ thuộc bên thứ ba; chỉ cho phép BCL là
mscorlibhoặc các thư viện có sẵn trong bản cài Windows thông thường - Do các ràng buộc căn chỉnh của PE file và CLR, tệp .NET PE tối thiểu về mặt lý thuyết có thể chạy trên Windows 10 trở lên không thể nhỏ hơn 1KB, nhưng trailing zero bytes ở cuối tệp không được tính vào kích thước
- Khi build chương trình C# Hello World thông thường với
net472, C# 10 và bật nullable, kích thước tệp thực thi là 4,6KB - Mã nguồn cuối cùng nằm tại Full Source Code
Những phần có thể giảm từ kết quả biên dịch C#
- Nullable reference type của C# 8.0 được nhúng tĩnh vào tệp thực thi dưới dạng custom attribute, và các attribute này làm tăng kích thước đáng kể
- Nếu tắt kiểm tra nullable reference type trong
.csprojbằng<Nullable>disable</Nullable>, các attribute sẽ bị loại bỏ, nhưng do căn chỉnh PE file, kích thước toàn bộ tệp vẫn là 4,6KB - Kết quả decompile cho thấy trình biên dịch C# vẫn tạo metadata không cần thiết ngay cả khi đã tắt nullable
- assembly attributes như phiên bản tệp assembly, thông tin bản quyền
- Kiểu
<Module>ẩn ngoài lớpProgram
- Để thu nhỏ cực đoan, thay vì tìm thêm thiết lập trình biên dịch C#, tác giả quyết định trực tiếp tạo tệp thực thi .NET bằng AsmResolver
- Module tự tạo đặt hàm
maintrong kiểu<Module>và cấu thành thân CIL chỉ gồmLdstr "Hello World",Console.WriteLine,Ret - Cách này giúp kích thước tệp giảm còn khoảng một nửa so với trước
Loại bỏ imports và base relocations bằng PE 64-bit
- Tệp thực thi được tạo có hai section là
.textvà.reloc, trong đó các data directory Imports và Base Relocations chiếm nhiều không gian - Tệp thực thi .NET AnyCPU hoặc 32-bit cần imports directory vì entry point không quản lý phải gọi
mscoree!_CorExeMain - Tệp thực thi .NET mặc định có thể relocate, nên cũng cần base relocation cho lời gọi imported function này
- Base relocation mặc định nằm trong section riêng, và mỗi section được căn chỉnh tối thiểu theo đơn vị
0x200byte, khiến tệp phình lên tối thiểu trên 1KB - Tệp thực thi .NET 64-bit không cần entry point không quản lý, nên có thể loại bỏ hai directory này và section
.relocbằng các thiết lập saumodule.PEKind = OptionalHeaderMagic.PE64module.MachineType = MachineType.Amd64
- Ở bước này, tệp đạt đến kích thước tối thiểu về mặt lý thuyết của một .NET PE hợp lệ là 1KB
Loại bỏ tên metadata, GUID, public key token
- Từ đây, kích thước được đo không phải theo kích thước tệp trong Windows Explorer, mà theo vị trí ngay sau byte non-zero cuối cùng trong hex editor
- Theo tiêu chí này, tệp PE 1KB đã giảm xuống còn 991 byte(0x3DF)
- Heap
#Stringslà stream metadata lớn thứ hai trong binary .NET, lưu tên kiểu và phương thức mà stream bảng#~sử dụng - Ngay cả khi đặt các tên không quan trọng với runtime thành
null, chương trình vẫn chạy bình thường; thao tác này giảm được 32 byte - Stream
#GUIDchứa GUID màMvidcủa bảng Module tham chiếu, nhưng trong thử nghiệm không cần định danh phiên bản nên bị loại bỏ- Loại bỏ 16 byte của
Mvid - Loại bỏ toàn bộ stream
#GUIDrỗng cùng header 16 byte - Tổng cộng giảm 32 byte
- Loại bỏ 16 byte của
mscorlibđược tham chiếu để gọiConsole::WriteLinethường có public key token 8 byte đi kèm- Nếu không có public key token, CLR không xác minh tính xác thực của assembly token này; trong thử nghiệm, nó được loại bỏ
- Sau bước này, kích thước tệp là 918 byte
Loại bỏ Console.WriteLine và dùng chuỗi ASCII
"Hello World"được lưu trong stream metadata#UScủa .NET, được biểu diễn dưới dạng mảng ký tự 16-bit có length prefix và một byte 0 bổ sung- Tất cả ký tự của chuỗi cần in đều có code point nhỏ hơn
0xFF, nên có thể biểu diễn bằng ASCII 1 byte, nhưng không có cách nào khiến CLR diễn giải chuỗi trong stream#USlà ASCII - Thay vì
Console::WriteLine, gọi hàm không quản lýucrtbase!putsbằng P/Invoke- Dạng khai báo C# là
[DllImport("ucrtbase")] static extern int puts(nint str); putsnhận con trỏ tới chuỗi ASCII kết thúc bằng zero làm đối số
- Dạng khai báo C# là
- Để biết địa chỉ runtime của chuỗi tại thời điểm biên dịch, tắt cờ DynamicBase trong
DllCharacteristicscủa PE optional header- Thiết lập image base cố định
- Đặt chuỗi ASCII trong section
.text - Địa chỉ runtime được tính bằng
module_base_address + rva_ascii_string
- Cũng bỏ cờ ILOnly trong .NET data directory để CLR tôn trọng cờ này
- Thay đổi này loại bỏ chuỗi wide-character, stream
#US, tham chiếuSystem.Console::WriteLine, và chỉ thêm định nghĩaputscùng metadata P/Invoke - Kích thước kết quả là 889 byte(0x379)
Các tối ưu vi mô cuối cùng
- Định nghĩa chuẩn của
putstrả về số ký tự đã in dưới dạngint32, nhưng chương trình không dùng giá trị này - Trong PE 64-bit, theo x64 calling convention của Microsoft, giá trị trả về nằm trong thanh ghi
RAX, không nằm trên evaluation stack của .NET - Khi đổi định nghĩa P/Invoke sang trả về
void, có thể loại bỏ lệnhpoptrong phương thứcmainvốn dùng để bỏ giá trị trả về - Kích thước PE header nhỏ hơn
0x200, nên có padding data giữa header và section đầu tiên - Windows PE Loader ánh xạ PE header vào vùng nhớ có thể đọc, và phạm vi này cũng bao gồm cả padding data
- Chuyển chuỗi ASCII vào vùng padding này giúp giảm thêm 13 byte cho tệp
- Vì không còn tham chiếu
Console::WriteLine, cũng loại bỏ tham chiếumscorlib- Giảm một bảng trong tables stream
#~ - Tên
mscorlibcũng bị loại khỏi stream#Strings
- Giảm một bảng trong tables stream
VersionStringcủa .NET metadata directory mặc định là"v4.0.30319", và sau khi padding theo bội số 4 byte thì chiếm tổng cộng 12 byte- Ngay cả khi rút chuỗi này xuống
v4.0., CLR 4.0 vẫn boot và chương trình vẫn chạy; sau padding, giảm thêm tổng cộng 4 byte - Dấu
.trailing dường như là bắt buộc; nếu giảm thêm nữa, chương trình không boot bình thường
Kết quả cuối cùng
- Kích thước binary cuối cùng, không tính trailing zero, là 834 byte(0x342)
- Kết quả nén ZIP là 476 byte, nhỏ hơn 582 byte khi không tối ưu thêm sau khi đã đạt giới hạn 1KB
- Thử nghiệm bắt đầu từ Hello World 4,6KB do trình biên dịch C# tạo ra và giảm xuống tệp PE tự dựng 834B
- Chương trình cuối cùng vẫn in
"Hello World"bình thường - Công việc này thiên về một thử nghiệm khám phá giới hạn cấu hình tối thiểu của định dạng tệp module .NET và CLR metadata hơn là tính thực dụng
1 bình luận
Ý kiến Hacker News
Mình rất thích các thử nghiệm kiểu chương trình tối thiểu có thể chạy được
Michal Strehovský đã dùng framework hiện nay được biết đến là .NET để tạo ra một game Snake dưới 8KB mà không phụ thuộc vào .NET runtime
Medium không hay lắm và Twitter giờ cũng không hữu ích, nhưng mình nhớ mang máng là "hello world" đại khái còn hỗ trợ cả Windows 3.11
https://medium.com/@MStrehovsky/building-a-self-contained-ga... [https://web.archive.org/web/20200103110836/https://medium.co... | https://archive.ph/b6qXE]
https://news.ycombinator.com/item?id=22010159 (2020)
https://news.ycombinator.com/item?id=22104734
Có thể xem các công việc mới nhất và tài trợ tại https://flattened.net: "bflat - C# as you know it but with Go-inspired tooling"
https://learn.microsoft.com/en-us/dotnet/core/extensions/cha...
Mình nhớ phần lớn dung lượng tải xuống là do chính thư viện .NET
Cũng tò mò không biết có framework/engine game 2D nào nhắm tới trình duyệt, dùng C#, mà tạo được binary nhỏ hay không
Bài này có thể gây hiểu nhầm. File thực thi đó cần runtime được cài trên hệ thống, và thực tế là dùng .NET Framework 4.7.2 vào năm 2023
Để so sánh công bằng với cách này thì có lẽ .jar hoặc file .py sẽ phù hợp hơn
Nếu lấy file thực thi thực sự làm tiêu chuẩn thì binary Native AOT chạy được trên Windows, Linux, macOS sẽ thích hợp hơn
Tuy nhiên vì phải bao gồm cả mã phụ trợ như GC, ThreadPool, Console nên việc nó lớn hơn nhiều là điều tự nhiên
Logic ghi byte ra console được xử lý bằng lời gọi pinvoke tới mã unmanaged do ucrt.dll cung cấp
Bài viết về tối giản Snake được chia sẻ ở bình luận khác cũng làm điều tương tự trên .NET Core, tức .NET mới không được bundle cùng Windows, và thực sự tạo ra binary không phải .NET
[0]: https://medium.com/@MStrehovsky/building-a-self-contained-ga...
Có lẽ so sánh container image sẽ là điểm khởi đầu tốt. Hãy dùng image nền nhỏ nhất có thể, hoặc nếu được thì dùng "FROM scratch", nhưng chỉ so sánh trong cùng kiến trúc
Để loại bỏ các biến số như chạy 32-bit trên 64-bit hoặc nén con trỏ, mình nghiêng về 32-bit
Sau đó có thể so sánh kích thước export tar chưa nén. Dù không hoàn toàn công bằng, nó vẫn giúp giảm bớt các biến số hiển nhiên
Nghĩ kỹ hơn thì có lẽ so sánh kích thước image sau khi nén tối đa còn công bằng hơn để phản ánh cả việc nén bên trong container. Tất nhiên phải dùng cùng thuật toán và tham số, hoặc giống benchmark nén thì phải cộng cả kích thước bộ giải nén vào kết quả cuối cùng
Chỉ cần gọi thế này:
echo "hello world"Cụ thể thì .NET Framework 4.7.2 có lẽ sẽ được bao gồm mặc định trong các phiên bản Windows được hỗ trợ
Một bản cài Windows hiện đại thì gần như không thể không có .NET, còn Linux và macOS thì mặc định không có, nên lấy đó làm tiêu chí công bằng có vẻ hơi kỳ lạ
Nhân tiện, không biết có ai để ý tác giả ghi địa điểm là McMurdo Station ở Nam Cực không
Tôi không ngờ ở căn cứ đó lại có người dùng kiểu tech stack này: https://usscar.org/directory?combine=&field_usscar_nsf_progr...
Chuyện này có thể là một câu chuyện khá thú vị
Điều khiến người ngoài ngạc nhiên là phần lớn người ở đó là nhân sự hỗ trợ; các nhà khoa học chỉ chiếm một tỷ lệ nhỏ, chắc khoảng 20%
Nhân sự hỗ trợ đến từ đủ mọi nghề cần thiết để vận hành một thành phố hiện đại: lính cứu hỏa, thợ mộc, đầu bếp, thợ máy, v.v.
Có lẽ do quy trình tuyển chọn, gần như ai cũng cực kỳ giỏi và thuộc nhóm hàng đầu trong lĩnh vực của mình. Có rất nhiều nhân tài kiểu dao đa năng Thụy Sĩ với bộ kỹ năng rộng đến mức đáng kinh ngạc
Những khó khăn rất riêng của việc sống và làm việc ở đó thu hút những con người độc đáo và có động lực mạnh mẽ như côn trùng lao vào ánh đèn, và nhiều người quay lại năm này qua năm khác. Đó là một cộng đồng tuyệt vời
Ở đó thì còn có thể làm gì khác được?
Trong metadata của .NET hay about cũng không thấy
Người ta nói file bị đội lên ít nhất chừng đó vì phải căn tất cả các section theo giá trị alignment tối thiểu có thể là 0x200 byte, nhưng 0x200 là 512 byte, tức 0,5KB
Tôi từng làm kiểu tối ưu kích thước PE này từ lâu rồi; nếu nhớ không nhầm thì Windows 9x lấy tối thiểu là 512, còn NT chấp nhận alignment nhỏ tới 1
Tôi cũng không thấy mẹo chồng DOS MZ và PE header từng là kỹ thuật tối tân ngày xưa: http://www.phreedom.org/research/tinype/
Và rồi người ta nhận ra rằng trong demoscene, chuyện này còn được làm gọn trong binary 1KB:
https://www.pouet.net/prodlist.php?type%5B%5D=1k&platform%5B...
Đây tuyệt đối không phải là phí thời gian một cách ngớ ngẩn
Khi đào sâu vào chi tiết của binary, bạn sẽ có được hiểu biết rất sâu về cách chúng được tạo ra, và những khái niệm học được từ đó sẽ làm giàu thêm kiến thức của bạn
Nếu thấy thú vị và học được điều gì đó thì lúc nào nó cũng đáng để bỏ thời gian và công sức
Cái này liên quan và thú vị hơn nhiều: https://twitter.com/MStrehovsky/status/1669502394827419648
Nội dung là: "một chương trình C# Hello World biên dịch native, hoàn toàn self-contained, bao gồm cả GC và mọi thứ, có thể được thu nhỏ xuống khoảng 440KB"
.NET classic framework là di sản cũ của Windows không còn được khuyến nghị nữa, và ứng dụng mới không nên xây trên đó
Tôi đã hy vọng đây là nói về binary "thật", tức file thực thi .NET đã được biên dịch native như .NET AOT
https://learn.microsoft.com/en-us/dotnet/core/deploying/nati...
Bản build biên dịch AOT được cắt giảm nhiều nhất có vẻ sắp phá mốc 1MB:
https://github.com/AustinWise/SmallestDotnetHelloWorlds
Xin chúc mừng. Vậy là bạn đã bỏ hỗ trợ Unicode và tạo một lời gọi C tới
putsChẳng phải cứ biên dịch bằng trình biên dịch C sẽ dễ hơn sao?
thats_just_c_with_extra_steps.jpgTôi vừa thử bằng Oberon+ IDE, và nó tạo ra Hello.dll 2048 byte từ module này
module Hellobeginprintln("hello world")end HelloDùng trình tạo assembly https://github.com/rochus-keller/Pelib
Tôi đã có cảm giác đó khi lần đầu dùng Lazarus/FreePascal, và càng đọc các tác phẩm của ông thì tôi càng thấy điều đó đúng hơn
Gần đây tôi nghĩ có lẽ WASM là con đường để thoát khỏi kiểu "luân hồi" của lối tư duy này, nhưng tôi không thể từ bỏ phần cứng theo cách đó
Tôi quá nguyên thủy để chấp nhận một cỗ máy mang tính tinh thần mà tôi không thể lấy búa trên bàn đập vỡ
Một máy tính toàn cục không thể phá hủy như Ethereum, chạy bằng cách tiêu tốn hàng nghìn đô thời gian tính toán trên phần cứng của người khác mà còn chậm hơn RPi, đem lại cho tôi cảm giác gần như cấm kỵ theo một cách rất căn bản
Tôi vẫn muốn in câu "Wirth was right" lên áo phông cùng khuôn mặt của Wirth
Có quá nhiều tàn dư của hệ sinh thái C++
Nó làm tôi nhớ tới các dự án forge lưu trữ phần mềm mã nguồn mở tự quảng bá là lựa chọn thay thế GitHub và GitLab. Nhưng khi bạn thực sự tới để clone code hay báo lỗi thì tất cả đều nằm trên GitHub
Có vẻ phải có tùy chọn để tắt việc này chứ
Việc có thể giảm
.exexuống dưới 1KB thật sự rất mở mang tầm mắtNếu ai đó có thể giảm file
.exeHello World của Flutter xuống 1MB thì đúng là quá ấn tượng