최소 .NET Hello World 이진 파일은 얼마나 작아질까?
(blog.washi.dev)-.NET Framework 4.x에서 "Hello World"만 출력하는 실행 파일을 대상으로, 일반 C# 컴파일 결과 4.6KB를 수작업 PE 구성으로 834바이트까지 줄임
- 실험은 관리 진입점, Windows 실행 가능 단일 파일, BCL 또는 일반 Windows 설치 라이브러리만 사용, 파일 끝의 0 바이트 제외라는 조건을 둠
- 크기 축소는 nullable 참조 타입 주석과 불필요한 메타데이터를 걷어내고, 64비트 PE로 전환해 imports와 base relocations를 제거하는 방식으로 진행됨
Console.WriteLine대신 P/Invoke로ucrtbase!puts를 호출해 UTF-16#US스트림을 없애고, ASCII 문자열은 PE 헤더와 첫 섹션 사이의 패딩 영역으로 옮김- 최종 실행 파일은 trailing zero를 제외해 834B(0x342), ZIP 압축 시 476B까지 줄어.NET PE와 CLR 메타데이터의 최소 구성 한계를 보여줌
실험 조건과 기준선
- 목표는 .NET 실행 파일 안에 필요한 최소 바이트만 넣어 CLR이 표준 출력에
"Hello World"를 출력하게 만드는 것임 - 실험 규칙은 다음과 같음
- 애플리케이션은 C# 또는 CIL로 구현한 관리 진입점을 실행해야 함
- 대상 런타임은 .NET Framework 4.x.x임
- 서드파티 의존성은 금지하며, BCL인
mscorlib또는 일반 Windows 설치 라이브러리만 허용함 - PE 파일과 CLR의 정렬 제약 때문에 Windows 10 이상에서 실행 가능한 이론적 최소 .NET PE 파일은 1KB보다 작을 수 없지만, 파일 끝의 trailing zero bytes는 크기 계산에서 제외함
- 일반 C# Hello World 프로그램을
net472, C# 10, nullable enabled 설정으로 빌드하면 실행 파일 크기는 4.6KB가 됨 - 최종 소스 코드는 Full Source Code에 있음
C# 컴파일 결과에서 줄일 수 있는 부분
- C# 8.0의 nullable 참조 타입은 커스텀 속성 형태로 실행 파일에 정적으로 포함되며, 이 속성들이 크기를 크게 늘림
.csproj에서<Nullable>disable</Nullable>로 nullable 참조 타입 검사를 끄면 속성은 제거되지만, PE 파일 정렬 때문에 전체 파일 크기는 여전히 4.6KB임- 디컴파일 결과 C# 컴파일러는 nullable을 꺼도 불필요한 메타데이터를 계속 생성함
- 어셈블리 파일 버전, 저작권 정보 같은 assembly attributes
Program클래스 외의 숨겨진<Module>타입
- 극단적인 축소를 위해 C# 컴파일러 설정을 더 찾는 대신, AsmResolver로 .NET 실행 파일을 직접 만들기로 함
- 직접 만든 모듈은
<Module>타입에main함수를 넣고,Ldstr "Hello World",Console.WriteLine,Ret만 포함하는 CIL 본문을 구성함 - 이 방식으로 파일 크기는 기존 대비 절반 수준으로 줄어듦
64비트 PE로 imports와 base relocations 제거
- 생성된 실행 파일에는
.text와.reloc두 섹션이 있고, Imports와 Base Relocations 데이터 디렉터리가 큰 공간을 차지함 - AnyCPU 또는 32비트 .NET 실행 파일은 비관리 진입점이
mscoree!_CorExeMain을 호출해야 하므로 imports 디렉터리가 필요함 - 기본 .NET 실행 파일은 재배치 가능(relocatable)하기 때문에, 이 imported function 호출을 위한 base relocation도 함께 필요함
- base relocation은 기본적으로 별도 섹션에 들어가며, 각 섹션은 최소
0x200바이트 단위로 정렬되어 파일이 최소 1KB 이상 부풀어 오름 - 64비트 .NET 실행 파일은 비관리 진입점이 필요 없어 다음 설정으로 두 디렉터리와
.reloc섹션을 제거할 수 있음module.PEKind = OptionalHeaderMagic.PE64module.MachineType = MachineType.Amd64
- 이 단계에서 유효한 .NET PE 파일의 이론적 최소 크기인 1KB에 도달함
메타데이터 이름, GUID, public key token 제거
- 이후 크기 측정은 Windows Explorer의 파일 크기가 아니라, 헥스 에디터에서 마지막 non-zero byte 다음 위치를 기준으로 함
- 1KB PE 파일은 이 기준으로 이미 991바이트(0x3DF) 까지 줄어든 상태임
#Strings힙은 .NET 바이너리에서 두 번째로 큰 메타데이터 스트림이며, 테이블 스트림#~가 사용하는 타입·메서드 이름을 저장함- 런타임에 중요하지 않은 이름들을
null로 설정해도 프로그램은 정상 실행되며, 이 작업으로 32바이트를 줄임 #GUID스트림은 Module 테이블의Mvid가 참조하는 GUID를 담지만, 실험에서는 버전 식별이 필요 없어 제거함Mvid16바이트를 제거함- 비어 있는
#GUID스트림 전체와 16바이트 헤더도 제거함 - 총 32바이트를 줄임
Console::WriteLine호출을 위해 참조하는mscorlib에는 일반적으로 8바이트 public key token이 붙음- public key token이 없으면 CLR은 이 assembly token의 진위를 검증하지 않으며, 실험에서는 이를 제거함
- 이 단계 이후 파일 크기는 918바이트가 됨
Console.WriteLine 제거와 ASCII 문자열 사용
"Hello World"는 .NET의#US메타데이터 스트림에 저장되며, 길이 prefix가 붙은 16비트 문자 배열과 추가 0바이트로 표현됨- 출력할 문자열의 모든 문자는 코드 포인트가
0xFF보다 작아서 1바이트 ASCII로 표현 가능하지만, CLR에#US스트림의 문자열을 ASCII로 해석하게 하는 방법은 없음 Console::WriteLine대신 비관리 함수ucrtbase!puts를 P/Invoke로 호출함- C# 선언 형태는
[DllImport("ucrtbase")] static extern int puts(nint str); puts는 zero-terminated ASCII 문자열 포인터를 인자로 받음
- C# 선언 형태는
- 컴파일 시점에 문자열의 런타임 주소를 알기 위해 PE optional header의
DllCharacteristics에서 DynamicBase 플래그를 끔- 고정 image base를 설정함
- ASCII 문자열을
.text섹션 안에 배치함 - 런타임 주소는
module_base_address + rva_ascii_string으로 계산함
- CLR이 이 플래그를 존중하도록 .NET data directory의 ILOnly 플래그도 해제함
- 이 변경으로 wide-character 문자열,
#US스트림,System.Console::WriteLine참조를 제거하고, 대신puts정의와 P/Invoke 메타데이터만 추가함 - 결과 크기는 889바이트(0x379) 가 됨
마지막 마이크로 최적화
puts의 표준 정의는 출력한 문자 수를int32로 반환하지만, 프로그램은 이 값을 사용하지 않음- 64비트 PE에서는 Microsoft의 x64 calling convention에 따라 반환값이 .NET 평가 스택이 아니라
RAX레지스터에 들어감 - P/Invoke 정의를
void반환으로 바꾸면main메서드에서 반환값을 제거하기 위한pop명령을 없앨 수 있음 - PE 헤더 크기가
0x200보다 작아 헤더와 첫 섹션 사이에 padding data가 생김 - Windows PE Loader는 PE 헤더를 읽기 가능한 메모리로 매핑하며, 이 범위에는 padding data도 포함됨
- ASCII 문자열을 이 패딩 영역으로 옮기면 파일을 13바이트 더 줄일 수 있음
Console::WriteLine을 더 이상 참조하지 않으므로mscorlib참조도 제거함- tables stream
#~의 테이블 하나가 줄어듦 #Strings스트림에서mscorlib이름도 제거됨
- tables stream
- .NET metadata directory의
VersionString은 기본적으로"v4.0.30319"이며, 4바이트 배수로 패딩되어 총 12바이트를 차지함 - 이 문자열을
v4.0.까지 줄여도 CLR 4.0이 부팅되고 프로그램이 실행되며, padding 이후 총 4바이트를 더 줄임 - trailing
.은 필요한 것으로 나타났고, 그보다 더 줄이면 프로그램이 정상 부팅되지 않음
최종 결과
- 최종 바이너리 크기는 trailing zero를 제외해 834바이트(0x342) 임
- ZIP 압축 결과는 476바이트이며, 1KB 한계 도달 이후 추가 최적화를 하지 않았을 때의 582바이트보다 작음
- 실험은 C# 컴파일러가 만든 4.6KB Hello World에서 시작해, 직접 구성한 PE 파일 834B까지 줄임
- 최종 프로그램은 여전히 정상적으로
"Hello World"를 출력함 - 이 작업은 실용성보다 .NET 모듈 파일 형식과 CLR 메타데이터의 최소 구성 한계를 탐색하는 실험에 가까움
댓글과 토론
Hacker News 의견들
-
최소 실행 가능 프로그램류의 실험은 정말 좋음
Michal Strehovský가 현재 .NET으로 알려진 프레임워크를 써서, .NET 런타임 의존성 없이 8KB 미만의 Snake 게임을 만들었음
Medium은 별로고 지금 Twitter도 유용하지 않지만, 대략 "hello world"가 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
최신 작업과 후원은 https://flattened.net에서 볼 수 있음: "bflat - C# as you know it but with Go-inspired tooling"- "Go-inspired" C# 얘기를 이어가면, .NET에는 System.Threading.Channels가 있음
https://learn.microsoft.com/en-us/dotnet/core/extensions/cha... - C# 앱이 사용자가 다운로드 시간을 즉시라고 느낄 만큼 작아질 수 있다면, C# WebAssembly를 더 현실적으로 만드는 데 쓸 수 있을지 궁금함
기억상 다운로드 크기의 상당 부분은 .NET 라이브러리 자체였던 것 같음
브라우저를 대상으로 하고 C#을 쓰면서 작은 바이너리를 만드는 2D 게임 프레임워크/엔진이 있는지도 궁금함 - Michal과 CoreRT 팀 덕분에 C#을 다시 보게 됐고, 오늘날 NativeAOT가 없었다면 특히 Go가 있는 상황에서 C#은 절대 고려하지 않았을 것 같음
- "Go-inspired" C# 얘기를 이어가면, .NET에는 System.Threading.Channels가 있음
-
이 글은 혼란을 줄 수 있음. 해당 실행 파일은 시스템에 설치된 런타임이 필요하고, 실제로 2023년에 .NET Framework 4.7.2를 씀
이런 방식과 공정하게 비교하려면 .jar나 .py 파일이 더 맞을 가능성이 큼
실제 실행 파일의 기준으로는 Windows, Linux, macOS에서 실행 가능한 Native AOT 바이너리가 더 적절함
다만 GC, ThreadPool, Console 같은 보조 코드까지 포함해야 하므로 훨씬 커지는 게 자연스러움- 꼭 그렇지는 않음. 실제 출력 exe는 main 호출 외에는 .NET Framework를 사실상 쓰지 않음
콘솔에 바이트를 출력하는 로직은 ucrt.dll이 노출하는 비관리 코드에 대한 pinvoke 호출로 처리됨
다른 댓글에 공유된 Snake 최소화 글은 Windows에 번들되지 않는 새 .NET인 .NET Core 기반으로 같은 일을 하며, 실제로 비-.NET 바이너리를 만들어냄
[0]: https://medium.com/@MStrehovsky/building-a-self-contained-ga... - 완전 정적 링크된 네이티브 바이너리와 일부 또는 전체 라이브러리를 동적으로 로드하는 바이너리를 비교하는 것도 크게 불공정함
컨테이너 이미지를 비교하는 게 좋은 출발점일 듯함. 가능한 가장 작은 기반 이미지를 쓰거나, 가능하면 "FROM scratch"를 쓰되 같은 아키텍처끼리만 비교해야 함
64비트에서 32비트를 돌리거나 포인터 압축 같은 변수를 빼려면 32비트를 선호함
그다음 압축하지 않은 export tar 크기를 비교하면 좋겠음. 완전히 공정하진 않아도 뻔한 변수를 줄일 수 있음
더 생각해보면 컨테이너 내부 압축까지 반영하려고 최대 압축된 이미지 크기를 비교하는 편이 더 공정할 수도 있음. 물론 같은 알고리즘과 파라미터를 쓰거나, 압축 벤치마크처럼 압축 해제기 크기를 최종 결과에 더해야 함 - 내 hello world는 "bash" 프레임워크가 필요하고 크기는 0바이트임
이렇게 호출하면 됨:echo "hello world" - .NET Framework는 Windows에 내장되어 있고, 적어도 Vista 이후로는 필수 구성 요소였음
구체적으로 .NET Framework 4.7.2는 지원되는 Windows 버전에 기본 포함되어 있을 것임 - 우리가 신경 써야 하는 게 "공정성"인지 "현실"인지 모르겠음
최신 Windows 설치에는 .NET이 없을 수 없고 Linux와 macOS에는 기본으로 없는데, 그걸 공정성 기준으로 삼는 건 이상해 보임
- 꼭 그렇지는 않음. 실제 출력 exe는 main 호출 외에는 .NET Framework를 사실상 쓰지 않음
-
덧붙여, 작성자 위치가 남극 McMurdo Station인 걸 본 사람이 있는지 궁금함
그 기지에 이런 기술 스택을 가진 사람이 있을 줄은 몰랐음: https://usscar.org/directory?combine=&field_usscar_nsf_progr...
꽤 흥미로운 이야기가 될 수 있음- 연구 여행으로 McMurdo에 다녀온 적이 있음
외부인이 놀라는 점은 그곳 사람들 대부분이 지원 인력이고, 과학자는 작은 비율, 아마 20% 정도뿐이라는 것임
지원 인력은 소방관, 목수, 요리사, 정비사 등 현대 도시를 굴리는 데 필요한 온갖 직종에서 옴
선발 과정 때문인지 거의 모두가 매우 뛰어나고 자기 분야 최상위권임. 폭넓은 기술을 가진 스위스 아미 나이프형 인재가 놀라울 만큼 많았음
그곳에서 생활하고 일하는 어려움은 독특하고 동기부여가 강한 사람들을 불빛에 몰려드는 벌레처럼 끌어들이고, 많은 사람이 해마다 돌아옴. 훌륭한 공동체임 - 이 경우 "토요일을 멍청하게 썼다"는 문장이 이해가 안 됨
거기서 달리 뭘 할 수 있겠음? - https://brr.fyi/를 보면 좋음
- 어떻게 찾았는지 궁금함
.NET 메타데이터나 about에서는 안 보임
- 연구 여행으로 McMurdo에 다녀온 적이 있음
-
모든 섹션을 가능한 최소 섹션 정렬값인 0x200바이트에 맞춰야 해서 파일이 적어도 그만큼 부풀어난다고 했는데, 0x200은 512바이트, 즉 0.5KB임
이 수준의 PE 크기 최적화는 오래전에 해봤지만, Windows 9x는 512가 최소였고 NT는 1까지 작은 정렬도 받을 수 있었던 것으로 기억함
예전에 최신 기법이었던 DOS MZ와 PE 헤더를 겹치는 트릭도 보이지 않았음: http://www.phreedom.org/research/tinype/
그리고 데모신에서는 이걸 1KB 바이너리 안에서 해낸다는 걸 깨닫게 됨:
https://www.pouet.net/prodlist.php?type%5B%5D=1k&platform%5B... -
이건 결코 시간을 멍청하게 쓴 게 아님
바이너리의 세부로 파고들면 만들어지는 방식에 대한 깊은 통찰을 얻고, 거기서 배운 개념은 지식을 풍부하게 해줌
흥미롭고 뭔가를 배운다면 언제나 시간과 노력을 들일 가치가 있음 -
이쪽이 훨씬 더 관련 있고 흥미로움: https://twitter.com/MStrehovsky/status/1669502394827419648
"GC 등 모든 것을 포함한 완전 자체 포함 네이티브 컴파일 C# Hello World가 약 440KB까지 작아질 수 있다"는 내용임
.NET classic framework는 Windows의 더 이상 권장되지 않는 레거시이고, 새 앱은 거기에 기반해 만들면 안 됨- 결과 exe는 비관리 pinvoke 호출로 노출된 하위 ucrt.dll 라이브러리의 구현 세부에 의존할 뿐, 프레임워크를 실제로 "사용"하지는 않음
- Office/COM API에 의존하는 앱을 만드는 경우는 예외일 수 있음
-
"진짜" 바이너리, 즉 .NET AOT 같은 네이티브 컴파일된 .NET 실행 파일 얘기이길 기대했음
https://learn.microsoft.com/en-us/dotnet/core/deploying/nati...- 지금은 몇 MB짜리 .NET AOT 예제를 만들 수 있음
가장 많이 줄인 AOT 컴파일 빌드는 곧 1MB 장벽을 깰 것처럼 보임:
https://github.com/AustinWise/SmallestDotnetHelloWorlds
- 지금은 몇 MB짜리 .NET AOT 예제를 만들 수 있음
-
축하함. 유니코드 지원을 버리고
puts에 대한 C 호출을 만든 셈임
그냥 C 컴파일러로 컴파일하는 게 더 쉽지 않았을까?
thats_just_c_with_extra_steps.jpg- 컴파일을 사실상 건너뛰는 건 좀 김이 빠짐
-
Oberon+ IDE로 방금 시도해보니 이 모듈에서 2048바이트 Hello.dll이 나옴
module Hello
begin
println("hello world")
end Hello
https://github.com/rochus-keller/Pelib 어셈블리 생성기를 사용함- Nicklaus Wirth가 옳았음
Lazarus/FreePascal을 처음 썼을 때 그렇게 느꼈고, 그의 저작을 더 읽을수록 더 맞다고 느꼈음
최근에는 이런 사고방식의 "윤회"에서 벗어나는 길이 WASM일지도 모른다고 생각하지만, 하드웨어를 그렇게 포기할 수는 없음
책상 위에서 망치로 부술 수 없는 영적인 기계를 받아들이기엔 내가 너무 원시적임
다른 사람의 하드웨어에서 수천 달러의 연산 시간을 쓰며 돌아가고 RPi보다 느린, Ethereum 같은 파괴 불가능한 전역 컴퓨터는 어떤 근본적인 방식으로 내게 금기처럼 느껴짐
아직도 Wirth의 얼굴과 함께 "Wirth was right"를 티셔츠에 넣고 싶음 - Oberon+라면 셀프 호스팅이어야 하지 않나 싶음
C++ 생태계 찌꺼기가 너무 많음
GitHub와 GitLab의 대안이라며 홍보되는 오픈소스 소프트웨어 호스팅 포지 프로젝트들이 떠오름. 막상 코드를 clone하거나 버그를 올리려 가보면 전부 GitHub에 있음 - 파일 끝이 0바이트로 채워져 있는지 궁금함
이걸 비활성화하는 옵션이 있을 것 같음
- Nicklaus Wirth가 옳았음
-
.exe를 1KB 미만까지 줄일 수 있다는 게 눈이 트이는 내용이었음
누군가 Flutter의 Hello World .exe를 1MB까지 줄일 수 있다면 정말 대단할 듯함- 첫 단계는 Microsoft가 Windows에 Flutter를 포함하도록 설득하는 것일 듯함
- Dart로 "Hello world"만 컴파일하면 인정되는지 궁금함