Введение

При взятии адреса вложенной функции GCC создаёт трамплин во время выполнения программы. Такой трамплин размещается на стеке, а значит, стек должен быть исполняемым. Это проблематично, поскольку неисполняемый стек — важная функция безопасности. Некоторое время назад в GCC появилась возможность размещать трамплин в куче, что лучше, но всё ещё не идеально: выделение памяти обходится дороже, а трамплин может утечь при использовании longjmp.

В предыдущей заметке было показано, как этого можно избежать с помощью нового типа широкого указателя, а также рассказано о предварительном патче для GCC, реализующем часть базовой функциональности, необходимой для этого.

Здесь речь пойдёт о двух обновлениях.

GCC 16: вложенные функции без захвата

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


	typedef int cb_f(int);

	cb_f *foo(int x)
	{  
	  	int worker(int y)
	    	{
        		return x + y;	// capture
    		}

   		return worker;
	}

	cb_f *bar(int _x)
	{
		static int x;
		x = _x;

		int worker(int y)
		{
			return x + y;	// no capture
		}

		return worker;
	}
	

Вложенные функции, не обращающиеся к переменным родительской функции, всё же могут обращаться к статическим переменным, именованным константам или типам (если те не изменяются переменно). Это полезно для написания функций обратного вызова (пример на Godbolt).


	typedef int cb_f(void *, int y);

	int process(cb_f *cb, void *data)
	{
		return cb(data, 5);
	}

	int foo(int x)
	{
		struct { int x; } data = { .x = x };

		int worker(void *_data, int y)
		{
			typeof(data) *data = _data;
			return data->x + y;
		}

		return process(worker, &data);
	}
	

Тем не менее, истинная сила вложенных функций — в возможности напрямую обращаться к переменным родительской функции. Кроме того, приведённый выше код выглядит так, будто такие функции просто имитируются вручную добавленным шаблонным кодом — и компилятор наверняка мог бы помочь с этим. Об этом — второе обновление.

GCC 17: вложенные функции с захватом

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


	typedef int cb_f(int y);

	static int process(cb_f *cb, void *data)
	{
		return __builtin_call_with_static_chain(cb(5), data);
	}

	int main()
	{
		int x = 7; 
    
		int worker(int y)
    		{
			return x + y;
    		}

		return process(__builtin_call_code_address(worker),
			       __builtin_call_static_chain(worker));
	}
	

На самом деле, если убрать трамплины из уравнения, всё, что делает GCC под капотом — это автоматически преобразует исходный код в пример выше! Единственное отличие от вручную написанной версии заключается в том, что указатель на данные передаётся через специальный регистр. Этот регистр используется многими другими языками программирования для передачи статической цепочки и потому уже зарезервирован для этой цели в ABI.

Даже без полноценной поддержки на уровне языка, пример можно сделать немного изящнее, определив обобщённый тип широкого указателя и обернув встроенные функции макросами. (пример на Godbolt)


	#define wide(T) struct wide_##T { typeof(T) *code; void *chain; }

	typedef int cb_f(int y);

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

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

Компилятор без труда оптимизирует это до одной ассемблерной инструкции, реализующей умножение на три.


	foo:
        	lea     eax, [rdi+rdi*2]
        	ret
	

Перспективы

Хотя вложенные функции поддерживают и другие компиляторы, помимо GCC, эта возможность не вполне переносима между ними. В дальнейшем планируется рассмотреть, как использовать вложенные функции одновременно с clang и GCC, а также как с помощью небольшого трюка избежать создания трамплинов даже в более старых версиях GCC.

Литература