Reddit

Loading windows PE .dlls on linux

Linux에서 Windows PE DLL 불러오기

Rust 프로젝트 Champagne은 Linux 프로세스 안에서 Windows PE DLL을 직접 로드합니다. Wine을 별도 프로세스로 실행하던 구조를 없애고 PEB·TEB, TLS, 로더 목록과 필요한 Windows API를 구현해 HTC Vive Pro 2 렌즈 보정 라이브러리를 실행합니다.

AI 요약

HTC Vive Pro 2용 Linux 드라이버는 렌즈 보정 라이브러리인 LibLensDistortion.dll이 필요합니다. 이 라이브러리는 헤드셋에 저장된 JSON 설정을 읽고 눈과 색상 채널별 광학 변환을 계산합니다. 설정 종류가 여러 가지인 데다 HTC가 JSON 형식을 바꿀 수도 있어, 작성자는 보정 수학을 Rust로 다시 구현하기보다 공식 라이브러리를 그대로 실행하기로 했습니다. 문제는 이 라이브러리가 Windows용 PE DLL이고 드라이버는 Linux 프로세스에서 실행된다는 점입니다.

Wine 별도 실행의 문제

초기 구현은 MinGW로 만든 Windows 실행 파일을 Wine에서 실행하고, postcard IPC로 드라이버와 통신했습니다. 하지만 Wine 설치 여부와 버전, 32비트·64비트 구성, 사용자별 prefix 상태가 제각각이었습니다. Wine 업그레이드 때 prefix를 다시 초기화하는 과정이 오래 걸리면 SteamVR watchdog이 프로세스를 종료하기도 했습니다. Steam의 Proton을 찾아 쓰는 방식도 일부 사용자 환경에서 prefix 초기화와 라이브러리 복사에 실패했습니다. 결국 Wine을 별도 프로세스로 띄우는 방식은 느리고 관리할 요소가 많았습니다.

작성자는 Windows PE 파일을 현재 Linux 프로세스에 직접 매핑하는 라이브러리 Champagne을 만들었습니다. PE 헤더를 읽고 섹션을 가상 주소에 배치한 뒤 base relocation을 적용하고 페이지 보호를 설정합니다. PE 파싱에는 Rust 라이브러리 pelite를 사용했습니다. 매퍼의 기본 동작은 수백 줄 규모지만, DLL이 기대하는 Windows 실행 환경까지 마련하는 일이 더 큰 작업이었습니다.

Linux에서 실행 가능한 메모리 매핑

Linux에서는 익명 mmap으로 만든 메모리에 쓰기 권한을 적용한 뒤 실행 권한까지 더하는 방식이 커널에 막혔습니다. Champagne은 POSIX shared memory 객체를 만들고 파일 설명자를 얻은 뒤 곧바로 이름을 해제합니다. 크기를 지정하고 그 객체를 메모리에 매핑하면 파일 매핑으로 취급되므로 실행 권한을 설정할 수 있습니다. /dev/shm에는 이름이 남지 않습니다.

Windows 로더 환경을 직접 구성

Windows DLL은 PEB(프로세스 환경 블록)의 로더 목록을 참조하고, 일부 라이브러리는 그 목록에서 모듈을 찾습니다. Champagne은 실제 PEB_LDR_DATA와 LDR_DATA_TABLE_ENTRY를 만들어 로드한 DLL을 등록합니다. 다음 DLL의 import 처리와 GetModuleHandle, GetProcAddress 검색, 스택 언와인딩에도 이 목록을 사용합니다. 각 DLL의 import는 이미 로드된 모듈에서 먼저 찾고, 없으면 Rust로 작성한 내장 함수 목록에서 찾습니다. 둘 다 없으면 호출 시 함수 이름을 기록하고 중단하는 stub을 만듭니다. 실행해 누락된 함수를 확인하고 하나씩 구현하는 방식으로, OpenCV DLL이 가져오는 26개 DLL의 531개 함수 중 필요한 기능을 채웠습니다. 내장 함수는 #[winfn] 속성으로 Windows 호출 규약을 지정하고 inventory로 등록합니다.

Windows x86_64와 Linux System V는 함수 인자 전달 레지스터와 보존 규칙이 다릅니다. Champagne은 Rust의 extern "win64"를 이용해 중간 어셈블리 thunk 없이 Windows DLL과 내장 함수 사이를 호출합니다. Windows 코드가 gs 레지스터를 통해 TEB(Thread Environment Block)를 읽는 점도 맞춰야 합니다. Linux 5.9 이후 FSGSBASE를 지원하는 환경에서는 사용자 공간에서 gs 기준 주소를 바꿀 수 있습니다. Champagne은 Windows가 기대하는 오프셋에 PEB와 TEB 필드를 배치하고, DLL 호출 동안 gs가 가상 TEB를 가리키게 합니다. 호출이 끝나면 기존 값을 복원합니다.

TLS, 스레드, 예외 처리

Windows의 정적 TLS는 DLL의 TLS 디렉터리에 담긴 초기값과 크기 정보를 바탕으로 스레드별 저장 공간을 준비합니다. Champagne은 템플릿을 보관하고 새 스레드가 시작되기 전에 각 스레드의 복사본을 만듭니다. TLS 콜백도 DLL 초기화와 스레드 생성·종료 때 실행합니다. 동적 TLS인 TlsAlloc과 TlsGetValue는 TEB의 64개 슬롯과 PEB의 할당 비트맵으로 처리합니다.

CreateThread는 Rust 스레드를 생성하는 것만으로 끝나지 않습니다. 각 스레드에 TEB와 TLS 복사본을 준비하고 DLL_THREAD_ATTACH 알림을 보낸 뒤 Windows 함수 호출을 시작합니다. 종료 때는 DLL_THREAD_DETACH 알림을 보내고, 핸들 테이블을 통해 WaitForSingleObject와 GetExitCodeThread도 지원합니다. DllMain은 로더 잠금 아래에서 실행되므로 잠금은 재진입 가능해야 합니다. 다른 라이브러리를 로드하는 DllMain에서 일반 mutex를 다시 잡으면 교착 상태에 빠질 수 있습니다.

x86_64 Windows는 프레임 포인터 대신 .pdata의 RUNTIME_FUNCTION과 UNWIND_INFO로 호출 스택을 복원합니다. Champagne은 모듈의 .pdata를 검색하고 언와인드 코드를 해석하는 RtlLookupFunctionEntry와 RtlVirtualUnwind 경로를 구현했습니다. 그 결과 C++ 예외 코드 0xe06d7363이 기록된 상황에서 ucrtbase.dll, OpenCV, LibLensDistortion 등의 프레임을 출력하는 백트레이스를 만들었습니다.

결과와 남은 범위

드라이버는 Wine·Proton 검색 코드와 lens-server, lens-protocol, lens-client를 제거했습니다. 이제 렌즈 계산은 SteamVR의 vrserver 프로세스 안에서 실행되며, 함수 호출마다 IPC를 거치지 않고 사용자가 Wine prefix를 설치하거나 관리할 필요도 없습니다. 현재 Champagne은 Windows 전체를 구현하지 않습니다. 라이브러리 실행 중 호출되는 기능을 stub 메시지로 찾아 약 110개의 Windows 함수로 채웠으며, 필요한 기능을 추가하는 방식으로 범위를 넓힙니다.

Reddit 반응

  • @u/qthree — 멋집니다! 몇 년 전에 딱 이런 걸 찾아봤지만 찾지 못했습니다. 언젠가 써보겠습니다.
  • @u/zzzthelastuser — 고맙습니다. 제가 꼭 필요했던 게 바로 이겁니다!
  • @u/anxxa — 좋은 글입니다! Tavis의 LoadLibrary는 배울 점이 많은 코드라 이 코드도 읽어보고 싶네요. TLS 콜백은 정말 골칫거리입니다. TLS 관련 문제를 하나씩 잡아내셨는지 궁금합니다. DllMain은 PEB+0x110의 임계 구역인 로더 잠금 아래에서 실행됩니다. DllMain에서 LoadLibrary를 호출할 수 있으니 재진입해야 한다고 하셨는데, Windows 동작과 맞지 않는다고 생각합니다. DllMain에서 LoadLibrary를 호출하면 교착 상태가 생길 수 있다는 Raymond Chen의 글은 지금도 유효하다고 봅니다.
    • @u/0lach — 제가 라이브러리에 넣어본 경우에는 TLS가 놀랄 만큼 잘 작동했습니다. 예전에 수동 매퍼를 작성해본 덕일 수도 있겠네요. mpengine.dll 지원을 추가할 때 TLS 코드는 손대지 않았습니다. LoadLibrary 동작은 말씀하신 내용과 같습니다. 글에도 간접적으로 나옵니다. “로더 잠금에 들어가고 잠금이 유지된 상태에서 DLL_THREAD_DETACH 메시지를 보낸다”고 했습니다. DllMain이 로더 잠금 아래에서 실행되니 문제가 생길 수 있습니다. 다만 Windows C++ 런타임과 다른 라이브러리도 이 동작을 기대합니다. 멀티스레딩과 관련해 다른 일을 하지 않는다면 보통은 작동하지만, 교착 상태를 만들 수는 있습니다. 로더 잠금은 재진입 가능합니다.

원문: delta.rocks / 번역·요약: Trawling