Когда я взялся полноценно портировать API Windows с C на Lua, одна из самых интересных и занимательных задач заключалась в том, как делать обратные вызовы к коду C из функций Lua. Без них значительная часть API — например, WNDPROC – осталась бы бесполезной.
Краткое знакомство с замыканиями в Lua
Для тех, кто не знаком с Lua 5.4, скажу так: семантически этот язык, пожалуй, на 90% схож с JavaScript. В нашем случае важно, что это касается замыканий и лексического связывания (lexical scoping). Таким образом, внешние переменные «охватываются» при создании новой функции, которая их использует. В Lua такие значения называются «верхними» (up), поскольку поступают из более высокой лексической области видимости.
local a = 1
local f = function(b) return a + b end
print(f(2)) -- выводит 3 Краткое знакомство с замыканиями в C
Язык С брутален, но прекрасен в своём минимализме, поэтому такой концепции как замыкания в нём нет вообще. Однако в нём есть функции и указатели на сырую память — и этого достаточно, можно работать. Поэтому в C принято вручную передавать данные функциям обратного вызова через указатель, так, чтобы они попадали в нужную область памяти.
Следующий тривиальный пример в общем виде демонстрирует, как это работает. Внешняя вызывающая сторона и внутренний обратный вызов передают друг другу указатель, причём, обоим известен его истинный тип. Но функции между ними этот тип не известен, и она передаёт указатель просто как void *ctx.
int cb(void *ctx, int b) {
int a = *(int*)ctx;
return a + b;
}
int add(void *a, int b, int (*f)(void *ctx)) {
return f(a, b);
}
int main() {
int a = 1;
add(&a, 2, &cb);
}Примечание: два похожих выражения на C — cb и &cb — дают на выходе адрес (более того, один и тот же адрес), но я намеренно воспользовался &cb, чтобы было понятнее, что перед нами нормальный адрес на C. Так получится нагляднее показать, что будет происходить далее.
Идеальный стыковочный API
Вот API Lua, на котором я остановился:
local a = 1
local lpfnWndProc = WNDPROC(function(hwnd, umsg, wparam, lparam)
if umsg == WM_KEYDOWN then
print(a)
end
return DefWindowProc(hwnd, umsg, wparam, lparam)
end)Таким образом, вы передаёте функцию на Lua другой функции на Lua, которая представляет тип обратного вызова на C и возвращает void*, указывающий на динамически создаваемую функцию C.
Элементарная реализация
Доведём на�� первый пример до работоспособного состояния при помощи одной маленькой коррекции:
local a = 1
local f = CALLBACK(function(b) return a + b end)
print(Add(f, 2)) -- выводит 3Здесь мы добавили две новые функции:
Функцию
CALLBACK, которая принимает функцию Lua и возвращает функцию CФункцию
Add, принимающую обратный вызов C и целое число (int), а затем выполняющую этот код из C
Вполне достаточно для реализации упрощённого варианта того паттерна, который используется в WNDPROC, WNDCLASS и CreateWindow.
Можно написать для этого такую тривиальную реализацию:
static int findex;
static int REAL_CALLBACK(lua_State *L, int b) {
// заносим функцию и аргумент в стек
lua_rawgeti(L, LUA_REGISTRYINDEX, findex);
lua_pushinteger(L, b);
// вызываем функцию Lua с 1 аргументом, получаем 1 результат
lua_call(L, 1, 1);
// возвращаем её результат, интерпретированный как целое число на C
return lua_tointeger(L, -1);
}
static int CALLBACK(lua_State *L) {
// сохраняем функцию Lua в реестре Lua,
// сохраняем её индекс как глобальный индекс C
findex = luaL_ref(L, LUA_REGISTRYINDEX);
// записываем функцию C для тяжеловесной работы как
// лёгкие пользовательские данные (обычный указатель на C)
lua_pushlightuserdata(L, &REAL_CALLBACK);
return 1;
}
static int Add(lua_State *L) {
int (*f)(lua_State *L, int b) = lua_touserdata(L, 1);
int b = lua_tointeger(L, 2);
// передаём серьёзную работу функции C
int c = f(L, b);
lua_pushinteger(L, c);
return 1;
}
int main() {
lua_State *L = luaL_newstate();
lua_pushcfunction(L, CALLBACK);
lua_setglobal(L, "CALLBACK");
lua_pushcfunction(L, Add);
lua_setglobal(L, "Add");
luaL_dostring(L, the_above_code);
}В основном здесь всё должно быть понятно без объяснения, даже если вы не знакомы со связующим API между Lua и C, поскольку этот код хорошо сам себя документирует. Если коротко, API Lua для C использует неявный стек объектов, и все функции оперируют этим стеком, где индекс 1 указывает на самый нижний объект, а индекс -1 — на самый верхний. Мы преобразуем типы C в типы Lua при помощи функций, работающих с этим стеком и выполняющих с ним три операции: записать/вытолкнуть/интерпретировать. (В данном случае мы ограничиваемся записью/интерпретацией, так как в выбранном нами практическом случае ничего не приходится выталкивать из стека). Lua и сохраняет, и выдаёт нам указатели C в виде пользовательских данных.
Что здесь ещё стоит коротко объяснить — так это LUA_REGISTRYINDEX. Это индекс на глобальную таблицу, которая доступна только из C. Функция luaL_ref выталкивает из стека последний объект и сохраняет его в этой таблице под уникальным индексом. Для нашего случая это подходит идеально, поскольку по данному индексу мы можем извлечь из таблицы эту информацию в рамках обратного вызова. Всё, что нам требуется — это сохранить индекс функции в глобальной переменной findex.
Наиболее очевидная проблема с данным решением заключается в том, что при такой реализации обратный вызов может запомнить всего одну функцию. Иными словами, следующий код работать не будет, так как findex затирается при каждом вызове к CALLBACK. Из-за этого первая функция «теряется», а запоминается только вторая.
local a1, a2 = 1, 2
local f1 = CALLBACK(function(b) return a1 + b end)
local f2 = CALLBACK(function(b) return a2 + b end)
print(Add(f1, 2)) -- выводит 4 -- неверно!
print(Add(f2, 2)) -- выводит 4, верноВопрос в том, как создавать новый и уникальный указатель C на каждую функцию Lua, передаваемую в код C — такой указатель, который использовал бы корректную функцию Lua? Конечно же, это делается на динамически генерируемом ассемблере!
Динамическое создание замыканий C во время выполнения
Наконец-то начинается что-то интересное! Мы собираемся изменить CALLBACK так, чтобы она динамически генерировала функцию «C» во время выполнения. Это будет функция, правильно устанавливающая в C глобальную переменную findex непосредственно перед вызовом REAL_CALLBACK.
Естественно, в таком случае потребовалось бы писать отдельную реализацию для каждой платформы, но это не составляет труда сделать при помощи ограничивающих #if. Но пока нас интересует лишь 64-разрядная версия Windows. В данном случае полезным справочником послужит соглашение о вызовах для x64, а также, пожалуй, обзор соглашений x64 ABI, но ниже я объясню основы, знать которые необходимо.
Эквивалентный код на C, который мы собираемся сгенерировать, в сущности, таков:
/* не указано */ generated_function(/* не указано */) {
findex = _closed_over_findex;
goto REAL_CALLBACK(/* передаёт аргументы в виде «как есть» */);
/* возвращаемым значением будет что угодно, возвращённое REAL_CALLBACK */
}Ины��и словами:
Создаём временное значение
closedover_findexДля глобальной переменной C
findexустанавливаем наше новое значение, соответствующееclosedover_findexПереходим к первой инструкции в
&REAL_CALLBACK
Изучаем ассемблер при помощи отладчика Visual Studio
Для начала можно посмотреть, какой ассемблер MSVC сгенерирует для кода, функционально очень похожего на интересующий нас. Давайте создадим минималистическую функцию main, которая просто устанавливает несколько глобальных переменных, а затем переходит к адресу. Затем установим точку отладки в первой строке main (foo1 = 111), выполним её, а потом нажмём с клавиатуры Ctrl-Alt-D, чтобы отобразить ассемблер.
static int foo1;
static int foo2;
static int foo3;
int main() {
foo1 = 111;
goto later;
foo2 = 222;
foo3 = 333;
later:
return 0;
}Скомпилировав это в режиме Release, получим:
nop ;;
xor eax, eax ;; установить ret в 0
ret ;; выйти из функции mainДело в том, что в ходе оптимизации значения отбрасываются. В данном случае это не слишком полезно. Но это можно исправить, добавив к переменным модификатор volatile. Таким образом мы сообщаем компилятору, что наши переменные запрещено оптимизировать, пусть даже при этом будут возникать побочные эффекты. Зато мы сможем просмотреть вывод в таком виде, каков он есть.
- static int foo1;
- static int foo2;
- static int foo3;
+ static volatile int foo1;
+ static volatile int foo2;
+ static volatile int foo3;Теперь получаем:
nop ;;
mov dword ptr [foo1 (07FF7838E0670h)], 6Fh
xor eax, eax ;; установить ret в 0
ret ;; выйти из функции mainВы получите не 07FF7838E0670h, а другое значение, но это значение всегда будет тем же, что и в &foo1. Можете это проверить при помощи printf("%lld\n", &foo1); Нам придётся воспользоваться lld, так как в 64-разрядной версии Windows указатели тоже 64-разрядные, сделанные по принципу long long int.
Но, всё-таки, компилятор здесь чрезмерно умничает, и нам придётся компилировать в отладочной конфигурации (Debug). Только так мы вообще сможем увидеть jmp. Теперь получаем следующее:
;int main() {
push rbp
push rdi
sub rsp,0E8h
lea rbp,[rsp+20h]
lea rcx,[__29E14FF1_ConsoleApplication1@c (07FF717A41008h)]
call __CheckForDebuggerJustMyCode (07FF717A31352h)
nop
; foo1 = 111;
mov dword ptr [foo1 (07FF717A3C200h)],6Fh
; goto later;
jmp $later (07FF717A317ACh)
; foo2 = 222;
mov dword ptr [foo2 (07FF717A3C204h)],0DEh
; foo3 = 333;
mov dword ptr [foo3 (07FF717A3C208h)],14Dh
;later:
; return 0;
xor eax,eax
;}
lea rsp,[rbp+0C8h]
pop rdi
pop rbp
ret Теперь начинает просматриваться конфигурация стека и отладочные оболочки, но мы можем всё это игнорировать. Единственное, что здесь интересно — вот:
mov dword ptr [foo1 (07FF717A3C200h)],6Fh
jmp $later (07FF717A317ACh) Теперь, связывая это с тем кодом, который был у нас изначально, хотим сгенерировать такой ассемблер:
mov dword ptr [findex], _closed_over_findex
jmp REAL_CALLBACKУчимся генерировать ассемблер
В этом нам помогут два следующих сайта:
https://defuse.ca/online-x86-assembler.htm, где используется GCC (обязательно щёлкните вариант x64)
https://asmjit.com/parser.html, где используется AsmJit
Если воспользоваться обоими и сравнить вывод, то мы сможем дважды проверить наше решение.
Для начала введём ассемблер «как есть», но в таком случае заметим ошибки и попытаемся их исправить. Для начала отметим, что от переменных никакого проку, поэтому можно заменить их на числа-заглушки, которые мы позже сможем заменить нашими собственными значениями. В данном случае давайте будем продолжать первый пример.
mov dword ptr [07FF717A3C200h],6Fh
jmp 07FF717A317AChВот первая проблема, с которой приходится столкнуться: первый же сайт (использующий GCC) не приемлет h-синтаксис для работы с шестнадцатеричными значениями. Поэтому воспользуемся синтаксисом 0x:
mov dword ptr [0x07FF717A3C200],0x6F
jmp 0x07FF717A317ACТеперь на стороне GCC видим ряд «неподдерживаемых инструкций» (Unsupported instructions), а на стороне asmjit — «переносы» (Relocations). Давайте упростим это, детально разобрав каждую инструкцию.
Изучаем инструкцию jmp
Для начала рассмотрим инструкцию jmp как таковую. Ниже в виде таблицы приведены значения, которые я попытался запрограммировать, с результатами со стороны GCC и со стороны asmjit:
;; input ;; gcc ;; asmjit
jmp 0x07FF717A317AC ;; error ;; 40E900000000
jmp 0xffffffff ;; error ;; 40E900000000
jmp 0xfffffff ;; e900000000 ;; 40E900000000Интересно, что мы, наконец-то, добились ассемблера от GCC, и 0xE9 соответствует тому, что выдаёт нам asmjit — за исключением 0x40 от asmjit, которое в данном случае отсутствует.
На справочной странице по jmp указано, что E9 означает относительный адрес. Вот почему оно допускает значения тол��ко чуть ниже 32-разрядных. Если бы нам требовался абсолютный адрес, в особенности 64-разрядный, то можно было бы воспользоваться FF и ссылаться на память через регистр. Именно это подтверждается и в следующем ответе на Stackoverflow.
Теперь нам требуется найти неиспользуемый регистр. Посмотрев страницу о соглашениях для x64, узнаём, что нам понадобится регистр для временных данных (scratch register), в частности, такой, который не используется для параметров, так как мы создаём оболочку в самом начале функции. Согласно этой странице, rax вполне подойдёт, поскольку одновременно и является "volatile" (то есть, предназначен для временных данных), и не используется под параметры.
Итак, давайте изменим наш ассемблер так, чтобы этот регистр в нём использовался:
;; input ;; gcc ;; asmjit
jmp rax ;; FFE0 ;; FFE0Вуаля! Подошло идеально. И, поскольку rax — это 64-разрядная версия eax, у нас автоматически поддерживаются 64-разрядные значения. Прекрасно.
Итак, наш переход умещается всего в два байта: 0xFF и 0xE0. Отметим это на потом и перейдём к mov.
Изучаем инструкцию mov
Возвращаясь к нашему примеру:
mov dword ptr [0x07FF717A3C200],0x6FСо стороны asmjit это работает, а со стороны GCC — нет. А в версии для asmjit снова имеем дело с переносами.
Давайте посмотрим, что произойдёт, если вновь воспользоваться фокусом с rax. Сохраним значение литерала в rax, ведь нам всё равно потребовалось бы сделать это для инструкции jmp, чтобы она знала, куда выполнять переход (goto).
mov dword ptr [rax], 0x6F
;; gcc: C7006F000000
;; asmjit: C7006F000000Вновь идеальное соответствие! И никаких переносов! Инструкция генерирует непосредственное значение (0x6F).
Осталась всего одна проблема, и она связана с двойными словами. Размер двойного слова — всего 32 байта. Что же делать, если наше значение содержит 64 байта? Нам понадобится четверное слово:
mov qword ptr [rax], 0x6F
;; gcc: 48C7006F000000
;; asmjit: 48C7006F000000Это работает, но в результате генерируются другие инструкции. Но, как только мы попробуем 64-разрядное значение, например, 0xffffffffff мы увидим, что этот код отказывает в обеих точках. Дело в том, что это просто некорректный ассемблер.
Синтаксис qword ptr [rax] имел смысл в случае, когда операнд «dest» представлял собой адрес в памяти, а операнд «src» был непосредственным (литеральным) значением. Но теперь, когда мы имеем дело с регистром, такой синтаксис неправильный. Если повторно попытаемся проделать то же самое с применением обычного rax, то получим следующее:
mov rax, 0x6F ;; 48C7C06F000000
mov rax, 0xfffffff ;; 48C7C0FFFFFF0F
mov rax, 0xffffffff ;; 48B8FFFFFFFF00000000
mov rax, 0xfffffffff ;; 48B8FFFFFFFF0F000000
mov rax, 0x7f7f7f7f7f7f7f7f ;; 48B87F7F7F7F7F7F7F7FТаким образом, наша функция movToRax примет следующий вид:
Для 32-разрядных значений запишем
48,C7,C0,(4 byte val)Для 64-разрядных значений запишем
48,B8,(8 byte val)
static BYTE* movToRax(BYTE* exe, UINT64 val) {
if (val > 0xffffffff) {
*exe++ = 0x48;
*exe++ = 0xb8;
*((UINT64*)exe) = val;
exe += sizeof(UINT64);
}
else {
*exe++ = 0x48;
*exe++ = 0xc7;
*exe++ = 0xc0;
*((UINT32*)exe) = val;
exe += sizeof(UINT32);
}
return exe;
}Вероятно, есть способ сделать это и ещё лаконичнее, но приведённое решение даёт нужный результат и возвращает указатель к следующему байту, после того, как произойдёт запись в память. Этим мы воспользуемся, чтобы связать всё вместе.
Нам понадобится обратная функция movRaxTo, так как наш общий генератор ассемблера будет должен:
1. Перенести closedover_findex в RAX
2. Перенести RAX в &findex
3. Перенести &REAL_CALLBACK в RAX
4. Перейти к RAX
Мы сможем воспользоваться movToRax для 1 и 3, а в 4 будет ровно та же логика, что и в movToRax, с той оговоркой, что она просто выдаёт 0xFF и 0xE0, и ей не требуется проверять размер.
Реализацию movRaxTo оставлю читателям в качестве домашнего задания. Это означает, что теперь мы переходим к самому-самому интересному!
Генерация ассемблера во время выполнения
Мы почти готовы генерировать ассемблер! Для начала изменим CALLBACK так, чтобы он использовал нужную нам функцию, которой у нас пока нет:
static int CALLBACK(lua_State *L) {
- findex = luaL_ref(L, LUA_REGISTRYINDEX);
- lua_pushlightuserdata(L, &REAL_CALLBACK);
+ int index = luaL_ref(L, LUA_REGISTRYINDEX);
+ void *_generated_function = generate_function(index);
+ lua_pushlightuserdata(L, _generated_function);
return 1;
}Несколько изменений:
1. Вместо того, чтобы сохранять индекс непосредственно в findex, мы будем передавать целое число функции generate_function, которая станет работать с findex по-новому.
2. У нас есть новая функция generate_function, которая возвращает void*, указывающий на новую функцию, которую мы будем динамически создавать во время выполнения. Здесь мы назвали её generatedfunction.
3. Будем возвращать не &REAL_CALLBACK, а указатель на generatedfunction.
Теперь нам понадобится реализовать новую generate_function, которая производит ассемблер, соответствующий generated_function, так, как было показано выше.
static void *generate_function(int _closed_over_findex) {
BYTE *exe = VirtualAlloc(
NULL,
0x10000,
MEM_RESERVE | MEM_COMMIT,
PAGE_READWRITE | PAGE_EXECUTE
);
BYTE *fn = exe;
exe = movToRax(exe, _closed_over_findex);
exe = movRaxTo(exe, &findex);
exe = movToRax(exe, &REAL_CALLBACK);
exe = jmpToRax(exe);
return fn;
}Вот и всё! Теперь у нас есть исполняемая память, в которую мы записывали ассемблер прямо во время выполнения. Ассемблер создаёт функции, которые устанавливают findex, а затем переходят прямо к &REAL_CALLBACK. Мы возвращаем память, и во время выполнения она неотличима от функции, которую мы могли бы написать на C, за исключением в основном лишь того, что в ней отсутствует отладочная информация.
Предупреждения о том, что здесь сделано не лучшим образом
Обратите внимание: здесь мы не предусмотрели никакой обработки ошибок. На практике lowkPRO делает гораздо больше, но это довольно скучно и раздражает.
Также обратите внимание, что мы крайне неэффективно используем VirtualAlloc. Эта функция выделяет память фрагментами по 64 кб. Конечно же, не хочется применять её так. На практике lowkPRO выделяет каждый фрагмент памяти по мере необходимости и делит эту память на узлы, объединённые в большой связный список, так, чтобы затем эта память могла использоваться при создании каждого обратного вызова. Теперь эта проблема решена благодаря функции winapi.freecallback, предоставляемой lowkPRO.
Наконец, не следует работать с такой исполняемой памятью, которая одновременно и является исполняемой, и доступна для записи. На практике lowkPRO использует AllocProtect, чтобы открывать память для записи только при создании обратного вызова, а во всех прочих случаях оставляет её исполняемой.
Также здесь делается ещё кое-какая работа при помощи thread_local, а ещё предусматривается критическая секция, гарантирующая потокобезопасность всех этих операций: создания, высвобождения и выполнения обратных вызовов.
Приёмы, описанные в этой статье, позволяют состыковать все обратные вызовы к Windows API с Lua, от WNDPROC до pD3DCompile. Это делается при помощи написанного на C# скрипта, который генерирует код на C++. На рекомпиляцию самого этого кода уходит примерно 30 минут, за это время портируется около полумиллиона записей Windows API.
