Wine — это свободное программное обеспечение для запуска Windows-приложений на нескольких POSIX-совместимых операционных системах, включая Linux, macOS и BSD.
Если вы любите Linux, то наверняка когда-то запускали Wine. Возможно, для какой-то «важной» программы Windows, у которой нет аналога под Линуксом, или поиграться. Забавный факт: даже Steam Deck от Valve запускает игры через оболочку на основе Wine (она называется Proton).
За последний год я намучился с отладчиком, который позволяет одновременно дебажить и Wine, и Windows-приложение в нём. Разобраться во кишочках Wine оказалось очень интересно! Я-то раньше много им пользовался, но никогда не понимал механику целиком. Можно взять файл Windows — и просто запустить его в Linux без каких-либо изменений. Если вы хотите знать, как это сделано, добро пожаловать под кат.
Дисклеймер. В статье реальность сильно упрощается, а многие детали игнорируются. Текст даёт общее представление, как работает Wine.
© «Время приключений» (1 сезон, 18 серия) — прим. пер.
Wine — не эмулятор!
Прежде чем разбираться в работе Wine, нужно сказать, чем он НЕ является. Вообще, W.I.N.E. — это рекурсивный акроним, который расшифровывается как "Wine Is Not an Emulator". Почему? Потому что есть куча отличных эмуляторов и для старых архитектур, и для современных консолей, а Wine принципиально реализован по-другому. Давайте вкратце рассмотрим, как вообще работают эмуляторы.
Представьте простую игровую приставку, которая понимает две инструкции:
push <value>
— пушит заданное значение в стек
setpxl
— достаёт три значения из стека и рисует пиксель с цветомarg1
в точке(arg2, arg3)
(вполне достаточно для визуализации классных демок, верно?)
> dump-instructions game.rom
...
# рисуем красную точку по координатам (10,10)
push 10
push 10
push 0xFF0000
setpxl
# рисуем зелёную точку по координатам (15,15)
push 15
push 15
push 0x00FF00
setpxl
Бинарный файл игры (или картридж ROM) представляет собой последовательность таких инструкций, которые аппаратное обеспечение может загрузить в память и выполнить. Нативное железо выполняет их в натуральном режиме, но как запустить старый картридж на современном ноуте? Для этого делаем эмулятор — программу, которая загружает ROM из картриджа в оперативную память и выполняет его инструкции. Это интерпретатор или виртуальная машина, если хотите. Реализация эмулятора для нашей приставки с двумя инструкциями будет довольно простой:
enum Opcode {
Push(i32),
SetPixel,
};
let program: Vec<Opcode> = read_program("game.rom");
let mut window = create_new_window(160, 144); // Виртуальный дисплей 160x144 пикселей
let mut stack = Vec::new(); // Стек для передачи аргументов
for opcode in program {
match opcode {
Opcode::Push(value) => {
stack.push(value);
}
Opcode::SetPixel => {
let color = stack.pop();
let x = stack.pop();
let y = stack.pop();
window.set_pixel(x, y, color);
}
}
}
Настоящие эмуляторы намного сложнее, но основная идея та же: поддерживать некоторый контекст (память, регистры и т.д.), обрабатывать ввод (клавиатура/мышь) и вывод (например, рисование в каком-то окне), разбирать входные данные (ROM) и выполнять инструкции одну за другой.
Разработчики Wine могли пойти по этому пути. Но есть две причины, почему они так не поступили. Во-первых, эмуляторы и виртуальные машины тормозные по своей сути — там огромный оверхед на программное выполнение каждой инструкции. Это нормально для старого железа, но не для современных программ (тем более видеоигр, которые требовательны к производительности). Во-вторых, в этом нет необходимости! Linux/macOS вполне способны запускать двоичные файлы Windows нативно, их нужно только немного подтолкнуть…
Давайте скомпилируем простую программу для Linux и Windows и сравним результат:
int foo(int x) {
return x * x;
}
int main(int argc) {
int code = foo(argc);
return code;
}
Слева — Linux, справа — Windows
Результаты заметно отличаются, но набор инструкций фактически один и тот же:
push
, pop
, mov
, add
, sub
, imul
, ret
. Если бы у нас был «эмулятор», который понимает эти инструкции, то смог бы выполнить обе программы. И такой «эмулятор» существует — это наш CPU.
Как Linux запускает бинарники
Прежде чем запускать чужеродный двоичный файл, давайте разберёмся, как запускается под Linux родной бинарник.
❯ cat app.cc
#include <stdio.h>
int main() {
printf("Hello!\n");
return 0;
}
❯ clang app.cc -o app
❯ ./app
Hello! # работает!
Довольно просто, но давайте копнём глубже. Если сделать
.app
?❯ ldd app
linux-vdso.so.1 (0x00007ffddc586000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f743fcdc000)
/lib64/ld-linux-x86-64.so.2 (0x00007f743fed3000)
❯ readelf -l app
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x1050
There are 13 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000002d8 0x00000000000002d8 R 0x8
INTERP 0x0000000000000318 0x0000000000000318 0x0000000000000318
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
...
Самое главное, что
.app
— это динамически исполняемый файл. Он зависит от некоторых динамических библиотек и требует их присутствия в рантайме. Иначе не запустится. Другой интересный момент — запрос интерпретатора (requesting program interpreter
в последней строке листинга). Какой ещё интерпретатор? Я думал, что C++ — компилируемый язык, в отличие от Python…В данном контексте интерпретатор — это «динамический загрузчик». Специальный инструмент, которая запускает исходную программу: разрешает и загружает её зависимости, а затем передаёт ей управление.
❯ ./app
Hello! # Работает!
❯ /lib64/ld-linux-x86-64.so.2 ./app
Hello! # Тоже работает!
# Домашнее задание: запустите это и попробуйте понять смысл выдачи.
❯ LD_DEBUG=all /lib64/ld-linux-x86-64.so.2 ./app
При запуске исполняемого файла ядро Linux определяет, что файл динамический и требует загрузчика. Затем оно запускает загрузчик, который выполняет всю работу. Это можно проверить, если запустить программу под отладчиком:
❯ lldb ./app
(lldb) target create "./app"
Current executable set to '/home/werat/src/cpp/app' (x86_64).
(lldb) process launch --stop-at-entry
Process 351228 stopped
* thread #1, name = 'app', stop reason = signal SIGSTOP
frame #0: 0x00007ffff7fcd050 ld-2.33.so`_start
ld-2.33.so`_start:
0x7ffff7fcd050 <+0>: movq %rsp, %rdi
0x7ffff7fcd053 <+3>: callq 0x7ffff7fcdd70 ; _dl_start at rtld.c:503:1
ld-2.33.so`_dl_start_user:
0x7ffff7fcd058 <+0>: movq %rax, %r12
0x7ffff7fcd05b <+3>: movl 0x2ec57(%rip), %eax ; _dl_skip_args
Process 351228 launched: '/home/werat/src/cpp/app' (x86_64)
Мы видим, что первая выполненная инструкция находится в библиотеке
ld-2.33.so
, а не в бинарнике .app
.Подводя итог, запуска динамически связанного исполняемого файла в Linux выглядит примерно так:
- Ядро загружает образ (≈ двоичный файл) и видит, что это динамический исполняемый файл
- Ядро загружает динамический загрузчик (
ld.so
) и передаёт ему управление
- Динамический загрузчик разрешает зависимости и загружает их
- Динамический загрузчик возвращает управление исходному двоичному файлу
- Оригинальный двоичный файл начинает выполнение в
_start()
и в конечном итоге доходит доmain()
Понятно, почему исполняемый файл Windows не запускается в Linux — у него другой формат. Ядро просто не знает, что с ним делать:
❯ ./HalfLife4.exe
-bash: HalfLife4.exe: cannot execute binary file: Exec format error
Однако если пропустить шаги с первого по четвёртый и каким-то образом перескочить на пятый, то теоретически должно сработать, верно? Ведь с точки зрения операционной системы что значит «запустить» бинарный файл?
В каждом исполняемом файле есть раздел
.text
со списком сериализованных инструкций CPU:❯ objdump -drS app
app: file format elf64-x86-64
...
Disassembly of section .text:
0000000000001050 <_start>:
1050: 31 ed xor %ebp,%ebp
1052: 49 89 d1 mov %rdx,%r9
1055: 5e pop %rsi
1056: 48 89 e2 mov %rsp,%rdx
1059: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp
105d: 50 push %rax
105e: 54 push %rsp
105f: 4c 8d 05 6a 01 00 00 lea 0x16a(%rip),%r8 # 11d0 <__libc_csu_fini>
1066: 48 8d 0d 03 01 00 00 lea 0x103(%rip),%rcx # 1170 <__libc_csu_init>
106d: 48 8d 3d cc 00 00 00 lea 0xcc(%rip),%rdi # 1140 <main>
1074: ff 15 4e 2f 00 00 call *0x2f4e(%rip) # 3fc8 <__libc_start_main@GLIBC_2.2.5>
107a: f4 hlt
107b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
...
Чтобы «запустить» бинарный файл, ОС загружает его в память (в частности, раздел
.text
), устанавливает указатель текущей инструкции на адрес, где находится код, и всё — исполняемый файл типа «запущен». Как сделать это для исполняемых файлов Windows?Легко! Просто возьмём код из исполняемого файла Windows, загрузим в память, направим
%rip
в нужное место — и CPU с радостью выполнит этот код! Если архитектура процессора одинаковая, то процессору вообще без разницы, откуда выполнять ассемблерные инструкции.Hello, Wine!
По сути, Wine — это «динамический загрузчик» для исполняемых файлов Windows. Это родной двоичный файл Linux, поэтому может нормально запускаться, и он знает, как работать с EXE и DLL. То есть своего рода эквивалент
ld-linux-x86-64.so.2
:# запуск бинарника ELF
❯ /lib64/ld-linux-x86-64.so.2 ./app
# запуск бинарника PE
❯ wine64 HalfLife4.exe
Здесь
wine64
загружает исполняемый файл Windows в память, анализирует его, выясняет зависимости, определяет, где находится исполняемый код (т. е. раздел .text
), и переходит в этот код.Примечание. В действительности он переходит к чему-то вродеntdll.dll!RtlUserThreadStart()
, это точка входа в «пространство пользователя» в мире Windows. Потом вmainCRTStartup()
(эквивалент_start
), и в самmain()
.
На данный момент наша Linux-система выполняет код, изначально скомпилированный для Windows, и всё вроде бы работает. За исключением системных вызовов.
Системные вызовы
Системные вызовы (syscall) — вот где основные сложности. Это вызовы к функциям, которая реализованы не в бинарнике или динамических библиотеках, а в родной ОС. Набор системных вызовов представляет системный API операционной системы. В нашем случае это Windows API.
Примеры системных вызовов в Linux:
read
, write
, open
, brk
, getpid
Примеры в Windows:
NtReadFile
, NtCreateProcess
, NtCreateMutant
?Системные вызовы не являются обычными вызовами функций в коде. Открытие файла, например, должно выполняться самим ядром, поскольку именно оно следит за файловыми дескрипторами. Поэтому приложению нужен способ как бы «прервать своё выполнение» и передать управление ядру (эта операция обычно называется переключением контекста).
Набор системных функций и способы их вызова в каждой ОС разные. Например, в Linux для вызова функции
read()
наш бинарник записывает в регистр %rdi
дескриптор файла, в регистр %rsi
— указатель буфера, а в %rdx
— количество байт для чтения. Однако в ядре Windows нет функции read()
! Ни один из аргументов не имеет там смысла. Бинарник Windows использует свой способ выполнения системных вызовов, который не сработает в Linux. Не будем здесь углубляться детали системных вызовов, например, вот отличная статья о реализации в Linux.Скомпилируем ещё одну небольшую программу и сравним сгенерированный код в Linux и Windows:
#include <stdio.h>
int main() {
printf("Hello!\n");
return 0;
}
Слева — Linux, справа — Windows
На этот раз мы вызываем функцию из стандартной библиотеки, которая в конечном итоге выполняет системный вызов. На скриншоте выше версия Linux вызывает
puts
, а версия Windows — printf
. Эти функции из стандартных библиотек (libc.so
в Linux, ucrtbase.dll
в Windows) для упрощения взаимодействия с ядром. Под Linux сейчас частенько собирают статически связанные бинарники, не зависимые от динамических библиотек. В этом случае реализация puts
встроена в двоичный файл, так что libc.so
не задействуется в рантайме.Под Windows до недавнего времени «системные вызовы bcgjkmpjdfkb только вредоносные программы»[нет источника] (вероятно, это шутка автора — прим. пер.). Обычные приложения всегда зависят от
kernel32.dll/kernelbase.dll/ntdll.dll
, где скрывается низкоуровневая магия тайного общения с ядром. Приложение просто вызывает функцию, а библиотеки заботятся об остальном:источник
В этом месте вы наверное поняли, что будет дальше. ?
Трансляция системных вызовов в рантайме
А что, если «перехватывать» системные вызовы во время выполнения программы? Например, когда приложение вызывает
NtWriteFile()
, мы берём управление на себя, вызываем write()
, а потом возвращаем результат в ожидаемом формате — и возвращаем управление. Должно сработать. Быстрое решение в лоб для примера выше:// HelloWorld.exe
lea rcx, OFFSET FLAT:`string'
call printf
↓↓
// «Фальшивый» ucrtbase.dll
mov edi, rcx // Преобразование аргументов в Linux ABI
call puts@PLT // Вызов реальной реализации Linux
↓↓
// Real libc.so
mov rdi, <stdout> // запись в STDOUT
mov rsi, edi // указатель на "Hello"
mov rdx, 5 // сколько символов писать
syscall
По идее, можно сделать собственную версию
ucrtbase.dll
со специальной реализацией printf
. Вместо обращения к ядру Windows она будет следовать формату интерфейсов Linux ABI и вызывать функцию write
из библиотеки libc.so
. Однако на практике мы не можем изменять код этой библиотеки по ряду причин — это муторно и сложно, нарушает DRM, приложение может статически ссылаться на ucrtbase.dll
и т. д.Поэтому вместо редактирования бинарника мы внедримся в промежуток между исполняемым файлом и ядром, а именно в
ntdll.dll
. Это «ворота» в ядро, и Wine действительно предоставляет собственную реализацию. В последних версиях Wine решение состоит из двух частей: ntdll.dll
(библиотека PE) и ntdll.so
(библиотека ELF). Первая часть — это тоненькая прокладка, которая просто перенаправляет вызовы в ELF-аналог. А уже он содержит специальную функцию __wine_syscall_dispatcher
, которая выполняет магию преобразования текущего стека из Windows в Linux и обратно.Поэтому в Wine системный вызов выглядит следующим образом:
Диспетчер системных вызовов — это мост между мирами Windows и Linux. Он заботится о соглашениях и стандартах для системных вызовов: выделяет пространство стека, перемещает регистры и т. д. Когда выполнение переходит к библиотеке Linux (
ntdll.so
), мы можем свободно использовать любые нормальные интерфейсы Linux (например, libc
или syscall
), реально читать/записывать файлы, занимать/отпускать мьютексы и так далее.И это всё?
Звучит почти слишком просто. Но так и есть. Во-первых, под Windows много разных API. Они плохо документированы и имеют известные (и неизвестные, ха-ха) ошибки, которые следует воспроизвести в точности. (Вспомните, как при разработке Windows 95 туда скопировали утечку памяти из SimCity, чтобы популярная игра не крашилась в новой ОС. Возможно, такие специфические вещи приходится воспроизводить под Linux для корректной работы конкретных программ — прим. пер.). Большая часть исходного кода Wine — это реализация различных Windows DLL.
Во-вторых, системные вызовы можно выполнять по-разному. Технически ничто не мешает Windows-приложению выполнить прямой системный вызов через
syscall
, и в идеале это тоже должно работать (как мы уже говорили, Windows-игры делают всякие безумные вещи). В ядре Linux специальный механизм для обработки таких ситуаций, который, конечно, добавляет сложности.В-третьих, весь этот бардак 32 vs 64 бит. Есть много старых 32-битных игр, которые никогда не перепишут на 64 бита. В Wine есть поддержка обеих платформ. И это тоже плюс к общей сложности.
В-четвертых, мы даже не упомянули
wine-server
— отдельный процесс Wine, который поддерживает «состояние» ядра (открытые дескрипторы файлов, мьютексы и т. д.).И последнее… о, так вы хотите запустить игру? А не просто hello world? Ну так это совсем другое дело! Тогда нужно разобраться с DirectX, со звуком (привет, PulseAudio, старый друг), устройствами ввода (геймпады, джойстики) и т. д. Куча работы!
Wine разрабатывался в течение многих лет и прошёл долгий путь. Сегодня вы без проблем запускаете под Linux самые последние игры, такие как Cyberpunk 2077 или Elden Ring. Чёрт возьми, иногда производительность Wine даже выше, чем у Windows! В какое замечательное время мы живём…
P. S. На всякий случай повторим дисклеймер: статья даёт только базовое представление о работе Wine. Многие детали упрощены или опущены. Так что не судите очень строго, пожалуйста.