Возврат значений из функций в x86-64: регистры, память и скрытые аргументы

в 7:22, , рубрики: x86-64

Наверняка многие и не задумываются: а как на самом деле происходит возврат структур и других типов значений из функций? Что происходит под капотом, какие приемы задействует компилятор? В данной статье я постараюсь дать ответы на эти вопросы и сделать это просто и понятно.

Сразу обозначу уровень: предполагается базовое понимание x86-64, знакомство с assembly и общее представление о том, как устроены системы семейства Unix. Начинающим программистам возможно будет тяжеловато.

Вступление

Итак, насколько известно, для передачи аргументов используются регистры общего назначения, а именно следующие: rdi - первый аргумент, rsi - второй аргумент, rdx - третий аргумент, rcx - четвертый аргумент, r8 - пятый аргумент, r9 - шестой аргумент (на данный момент мы рассматриваем System V ABI, которому следуют все крупные ОС из семейства Unix). Все вполне логично, учитывая что все эти регистры не callee saved, поэтому не нужно заботиться о значениях, которые там были прежде.

Как для передачи аргументов используются регистры общего назначения, так и для возврата переменных используются те же самые регистры, а конкретно rax - первое возвращаемое значение, rdx - второе возвращаемое значение (по крайней мере, для значений типа INTEGER: об этом будет написано ниже).

Возврат фундаментальных типов

Рассмотрим следующий код, который просто возвращает int, который по совместительству является фундаментальным типом:

int one_plus_one() 
{	
    int temp = 1 + 1;	
    return temp;
}

В assembly функция выглядит так (без оптимизаций, естественно):

one_plus_one():	
  ; ...	
  mov DWORD PTR [rbp-4], 2 ; Это та самая переменная `temp`	
  mov eax, DWORD PTR [rbp-4] ; Записываем переменную в первый возвратный регистр - eax (32 бита от rax).	
  ; ...	
  ret ; Возврат из функции

Как мы видим, переменная возвращается с помощью регистра rax (eax).
Возврат фундаментальных типов из функции предельно ясен, а что дальше? Дальше у нас возврат небольших структур.

Возврат маленьких структур

Рассмотрим следующую безобидную структуру:

struct nums 
{	
    std::int64_t first{};	
    std::int64_t second{};
};

В ней содержатся два поля по 8 байт (наверное, на что-то это намекает?).
Также рассмотрим следующий код, который создает структуру на стэке и возвращает ее:

nums construct() 
{	
    nums ret{10, 120};	
    return ret;
}

Достаточно незамысловатый код, который просто возвращает ту самую безобидную структуру из функции.
Теперь рассмотрим assembly, который бесспорно чуть страшнее, нежели предыдущий:

construct():	
  ; ...	
  ; Создание структуры на стэке.	
  mov QWORD PTR [rbp-16], 10 ; значение для nums::first	
  mov QWORD PTR [rbp-8], 120 ; значение для nums::second	
  ; Можно заметить, что поля как бы хранятся наоборот, но если рассматривать память снизу вверх (а не сверху вниз, как растет стэк), то все ок.

  ; Помним, что два поля по 8 байт должны были на что-то намекать. Вот оно! Каждое из полей записано в отдельный регистр.	
  mov rax, QWORD PTR [rbp-16] ; Пишем nums::first в возвратный регистр	
  mov rdx, QWORD PTR [rbp-8] ; Пишем nums::second в возвратный регистр	
  ; ...	
  ret

Как мы видим, компилятор достаточно умный - и возвращает структуру, используя все доступные данные ему ресурсы.
Интересно, а как происходит возврат крупных структур, который никак не влезут в эти два регистра по 8 байт? К этому мы и переходим.

Возврат больших структур

Рассмотрим такую достаточно устрашающую (или не очень) структуру:

struct many_nums 
{	
    std::int64_t first{};	
    std::int64_t second{};	
    std::int64_t third{};	
    std::int64_t fourth{};
};

Она точно в возвратные регистры не влезет.
Также, рассмотрим С++ код:

many_nums construct_scary() 
{	
    many_nums temp{10, 20, 30, 40};	
    return temp;
}

Опять же незамысловатый код.
А теперь посмотрим на такой же незамысловатый (или же нет) assembly код.

construct_scary():
	; ...
	mov QWORD PTR [rbp-8], rdi ; Сохраняем на стэк адрес из rdi, который хранит первый аргумент функции.
	mov rax, QWORD PTR [rbp-8] ; Переносим адрес функции из стэка в rax - тот самый первый возвратный регистр.
	
	; Начинается создание структуры
	mov QWORD PTR [rax], 10 ; Переходим по адресу в rax, где хранится many_nums::first и пишем туда.
	mov rax, QWORD PTR [rbp-8] ; Переносим адрес функции из стэка в rax
	mov QWORD PTR [rax+8], 20 ; Переходим по адресу (rax + 8), где хранится many_nums::second и пишем туда.
	mov rax, QWORD PTR [rbp-8] ; Переносим адрес функции из стэка в rax
	mov QWORD PTR [rax+16], 30 ; Переходим по адресу (rax + 16), где хранится many_nums::third и пишем туда.
	mov rax, QWORD PTR [rbp-8] ; Переносим адрес функции из стэка в rax
	mov QWORD PTR [rax+24], 40 ; Переходим по адресу (rax + 24), где хранится many_nums::fourth и пишем туда.
	mov rax, QWORD PTR [rbp-8] ; Переносим адрес функции из стэка в rax
	; ...
	ret

(Хочу отметить, что повторение mov rax, QWORD PTR [rbp-8] никакой смысловой нагрузки не несет, поскольку повторения есть только потому, что код скомпилирован под флагом -O0).
Интересно, что это вообще за адрес? Мы же ничего не передавали в качестве аргументов.

Тут уже можно рассказать про различные классы возвращаемых значений.
При возврате фундаментальных типов или маленьких структур они представлялись как класс INTEGER, поэтому их можно былов вернуть через rax и rdx.
У структур большого размера класс типа MEMORY. Вот что пишется об этом в System V ABI: If the type has class MEMORY, then the caller provides space for the return value and passes the address of this storage in %rdi as if it were the first argument to the function. In effect, this address becomes a “hidden” first argument".
Наша структура many_nums таковой и является.

Получается, что где-то кто-то выделяет память и передает ее в функцию. В данном случае это происходит в main:

int main() 
{	
    many_nums s2 = construct_scary();	
    // ...
}

Рассмотрим как это дело выглядит в assembly:

main:
	; ...
	sub rsp, 32 ; Двигаем stack pointer на 32 байта вниз (размер нашей структуры), тем самым выделяя память для переменной `s2`.
	
	lea rax, [rbp-32] ; Сохраняем адрес [rbp - 32] в rax.
	mov rdi, rax ; Передаем значение из rax как первый аргумент.
	call construct_scary()
	
	; ...
	ret

В итоге всё сводится к тому, что компилятор заранее выделяет память под результат и передаёт её адрес как скрытый аргумент.

Конечная

Подводя итог, в данной статье мы разобрали, как происходит возврат различных типов данных под капотом и какие приемы для этого использует компилятор.

Это моя первая статья, поэтому мне бы очень хотелось получить обратную связь. Буду рад любым советам, замечаниям и конструктивной критике.

Автор: klewy

Источник

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


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