C++ / Thunk: без ассемблера и машинного кода

в 17:24, , рубрики: gcc, метки:

Есть одна мощная, но малоизвестная техника — thunking. В двух словах, суть ее в следующем: во время работы программы динамически создается новая функция на основе существующей — thunk. Новая функция может иметь другой набор параметров или выполнять какие-то вычисления с параметрами, прежде чем передать их исходной функции.
Эта техника используется в оконном фреймворке ATL. Как известно, функционирование UI в Windows построено на обмене сообщениями между окнами. Окно хранит указатель на функцию обработки сообщений, которая соответствует следующему прототипу:
LRESULT WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam);

ATL объявляет базовый класс для представления окна:
class Window
{
HWND hwnd;
virtual LRESULT proc(UINT uMsg, WPARAM wParam, LPARAM lParam) = 0;
};

К сожалению, напрямую использовать Window::proc() в качестве оконной функции нельзя — она не соответствует прототипу WindowProc(). Чтобы устранить несоответствие, ATL генерирует thunk. Отмечу, что в методы класса неявно передается параметр this — указатель на экземпляр класса. Оконная функция не получает никаких дополнительных указателей, поэтому значение this фиксируется при генерации thunk. Для каждого нового экземпляра класса требуется отдельный thunk.

Устройство thunk в ATL

На x86, thunk содержит следующий код:
mov dword ptr [esp+0x4], pThis
jmp proc

Первая команда записывает в стек константу pThis. На вершине стека по адресу [esp] лежит адрес возврата из функции, а ниже (по адресу [esp+0x4]) лежит первый параметр функции. Код подменяет первый параметр функции: было , стало .
Следующим шагом происходит переход на тело оригинальной функции.
Thunk генерируется динамически во время выполнения программы, непосредственно в машинных кодах. Для удобства, объявлена следующая структура:
#pragma pack(push,1)
struct _CallBackProcThunk
{
DWORD m_mov; // mov dword ptr [esp+0x4],
DWORD m_this; // ... pThis
BYTE m_jmp; // jmp
DWORD m_relproc; // ... proc
};
#pragma pack(pop)

Код инициализации приведен ниже. Загадочное выражение для инициализации m_relproc связано с особенностями относительной адресации на x86.
void _CallBackProcThunk::Init(DWORD_PTR proc, void* pThis)
{
m_mov = 0x042444C7; // C7 44 24 04
m_this = PtrToUlong(pThis);
m_jmp = 0xE9;
m_relproc = DWORD((INT_PTR)proc - ((INT_PTR)this + sizeof(_CallBackProcThunk)));

FlushInstructionCache(GetCurrentProcess(), this, sizeof(_CallBackProcThunk));
}

Thunk в ATL: обсуждение

Рассмотренная техника обладает некоторыми недостатками. Главный из них — зависимость от конкретной аппаратной платформы. Для x86_64 потребуется отдельная реализация. Кроме x86 и x86_64 сейчас актуален Arm.
Я привел ATL исключительно в качестве примера. На самом деле, я далек от программирования под Windows. Мне интересно программирование под POSIX системы, а процессор в моем понимании может быть любой, лишь бы gcc умел под него генерировать код. А это значит, что потребуется не один десяток реализаций thunk под разные процессоры.
Хорошо бы реализовать thunk максимально переносимым образом, а генерацией машинного кода пусть занимается компилятор!
Условно переносимая реализация thunk через расширения GCC

Дальше я изложу свою идею, как можно реализовать (условно) кроссплатформенный thunk. Условность связана с тем, что реализация полагается на специфические фичи GCC, недоступные в других компиляторах. Если тема вызовет интерес, я напишу код для демонстрации того, что идея работает.
GCC предлагает интересное нам расширение языка Си: вложенные функции. При этом вложенная функция имеет доступ ко всем переменным, объявленным в объемлющей функции — фактически мы имеем честное замыкание (closure).
typedef int (*unary_fn)(int); /* указатель на функцию int->int */
typedef int (*binary_fn)(int,int); /* указатель на функцию int,int->int */

unary_fn bind_first(binary_fn fn, int val)
{
int helper(int param) { return fn(val, param); }
return &helper;
}

В этом примере, GCC сам создает thunk для нас. У вложенной функции можно взять адрес и вызывать ее через указатель как любую другую.
К сожалению, это работать не будет.
Дело в том, что thunk создается на стеке. Когда bind_first() завершится, возвращаемое значение станет недействительным.
Запустим bind_first() в отдельном потоке, а получать параметры и отправлять результат будем через IPC. После завершения обработки запроса, функция рекурсивно вызывает сама себя. Таким образом, bind_first() никогда не завершается, поэтому возвращаемый thunk остается валидным.
void bind_first()
{
binary_fn fn;
int val;

int helper(int param) { return fn(val, param); }

receive_reply_request(&fn, &val, &helper); /* получаем очередной запрос и отдаем ответ */

bind_first();
}

Наконец, избавимся от отдельного потока и IPC.
Для этого воспользуемся библиотекой для организации вытесняющей многопоточности в userspace (см. например этот хабратопик).
Когда нам нужно сгенерировать очередной thunk, мы переключаемся с контекста текущей задачи на контекст bind_first(). Функция генерирует thunk, и вызывает сама себя рекурсивно. В точке receive_reply_request происходит переключение на констекст исходной задачи.
Мы выделяем отдельный блок памяти, которую задача bind_first() использует под стек. Память расходуется для хранения сгенерированных thunk-ов, плюс некоторый оверхед на работу функции.
Заключение

Предложенный метод позволяет генерировать thunk-и переносимым образом на любой POSIX системе в случае использования компилятора GCC. Эффективность рассмотренного метода (предположительно) уступает методу с генерацией машинного кода вручную как в плане производительности, так и по потреблению памяти. Взамен достигается полная независимость от процессорной архитектуры.

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js