Введение

Ранее уже разбиралось, как в GCC 17 и Clang можно использовать вложенные функции для колбэков без необходимости в исполняемом стеке. Но что делать, если нужно поддерживать более старые версии GCC? Можно, конечно, просто смириться с исполняемым стеком (это не так страшно, как некоторые утверждают), но есть способ обойтись и без него — с помощью небольшого хака.

GCC: вложенные функции и трамплины

Сначала стоит разобраться, как GCC вообще реализует взятие адреса вложенной функции. Ниже — упрощённый пример без использования новых макросов (пример на Godbolt).


	typedef int cb_f(int y);

	int baz(cb_f p, int x)
	{
		return p(x);
	}

	int foo(int k)
	{
		int bar(int x) { return k + x; }
    		return baz(bar, 2 * k);
	}
	

На x86_64 генерируется следующий ассемблерный код.


bar.0:
        movl    %edi, %eax
        addl    (%r10), %eax
        ret
foo:
        subq    $56, %rsp
        leaq    64(%rsp), %rax
        movq    %rax, 32(%rsp)
        movl    %edi, (%rsp)
        leaq    4(%rsp), %rax
        movw    $-17591, 4(%rsp)
        movabsq $bar.0, %rcx
        movq    %rcx, 6(%rsp)
        movw    $-17847, 14(%rsp)
        movq    %rsp, 16(%rsp)
        movl    $-1864106167, 24(%rsp)
        addl    %edi, %edi
        call    *%rax
        addq    $56, %rsp
        ret
	

Этот код размещает трамплин на стеке и сразу же вызывает его через инлайненную функцию baz. Трамплин — это короткая последовательность инструкций, которая загружает статический регистр фрейма в структуру на стеке, содержащую захваченные переменные родительской функции, а затем передаёт управление локальной функции. Если перевести константы -17591, -17847 и -1864106167 обратно в ассемблерные инструкции, получится следующий код x86_64.


        movq	$bar.0, %r11
        movq    $frame, %r10
	jump	*%r11
	

И статическая цепочка (static chain), и адрес кода — это непосредственные константы, используемые инструкциями пересылки внутри тела трамплина. Вместо того чтобы вызывать функцию через адрес трамплина, можно извлечь из него адрес кода и статическую цепочку, а затем использовать их со встроенной функцией __builtin_call_with_static_chain для прямого вызова локальной функции. Например, с помощью макросов peek и array_slice из библиотеки noplate это можно сделать так — для x86_64 (и модели памяти large).


	unsigned char (*tramp)[24] = (void*)bar;
	void *code = peek(uint64_t, &array_slice(tramp, 2, 10));
	void *chain = peek(uint64_t, &array_slice(tramp, 12, 20));
	

Эти указатели — ровно та же информация, которую можно будет получить в ещё не выпущенном GCC 17 с помощью новых встроенных функций __builtin_call_static_chain и __builtin_call_code_adress, и её можно использовать для вызова локальной функции так же, как обсуждалось ранее.


	__builtin_call_with_static_chain(((typeof(bar)*)code)(arg), chain);
	

Таким образом, чтение этих двух значений указателей из трамплина можно использовать как запасной механизм для старых версий GCC. Минусы в том, что трамплин всё равно создаётся, компилятор по-прежнему не может девиртуализировать косвенный вызов, а стек всё равно помечается как исполняемый. Так в чём же выгода? Поскольку трамплин фактически никогда не вызывается, стек можно снова сделать неисполняемым следующей командой, что хотя бы снимает вопросы безопасности, связанные с этой возможностью.


	patchelf --clear-execstack program
	

Эта идея реализована в экспериментальной библиотеке noplate, где из адреса кода и статической цепочки конструируется широкий указатель.

Трамплины как дескрипторы функций

Есть ещё одна идея, которую стоит изучить подробнее: сам трамплин можно использовать как дескриптор функции. Вместо того чтобы извлекать статическую цепочку и указатель кода в момент создания трамплина, можно как обычно передавать дальше адрес самого трамплина. Но везде, где предполагается вызов трамплина, сначала проверяется, действительно ли указатель ведёт на трамплин, и уже затем извлекаются адрес кода и статическая цепочка для прямого вызова вложенной функции через __builtin_call_with_static_chain. В каком-то смысле вместо непосредственного вызова трамплина происходит его интерпретация прямо в точке вызова — с помощью крайне простого интерпретатора, способного разобрать только эту конкретную последовательность кода и настолько компактного, что его можно инлайнить (пример на Godbolt).

Литература