Как сборка выполняет передачу параметров: по значению, ссылке, указателю для разных типов/массивов?
В попытке взглянуть на это, я написал этот простой код, где я просто создал переменные разных типов и передал их в функцию по значению, по ссылке и по указателю:
int i = 1;
char c = 'a';
int* p = &i;
float f = 1.1;
TestClass tc; // has 2 private data members: int i = 1 and int j = 2
тела функций остались пустыми, потому что я просто смотрю, как передаются параметры.
passByValue(i, c, p, f, tc);
passByReference(i, c, p, f, tc);
passByPointer(&i, &c, &p, &f, &tc);
хотел посмотреть, как это отличается для массива, а также как параметры доступа к ним.
int numbers[] = {1, 2, 3};
passArray(numbers);
монтаж:
passByValue(i, c, p, f, tc)
mov EAX, DWORD PTR [EBP - 16]
mov DL, BYTE PTR [EBP - 17]
mov ECX, DWORD PTR [EBP - 24]
movss XMM0, DWORD PTR [EBP - 28]
mov ESI, DWORD PTR [EBP - 40]
mov DWORD PTR [EBP - 48], ESI
mov ESI, DWORD PTR [EBP - 36]
mov DWORD PTR [EBP - 44], ESI
lea ESI, DWORD PTR [EBP - 48]
mov DWORD PTR [ESP], EAX
movsx EAX, DL
mov DWORD PTR [ESP + 4], EAX
mov DWORD PTR [ESP + 8], ECX
movss DWORD PTR [ESP + 12], XMM0
mov EAX, DWORD PTR [ESI]
mov DWORD PTR [ESP + 16], EAX
mov EAX, DWORD PTR [ESI + 4]
mov DWORD PTR [ESP + 20], EAX
call _Z11passByValueicPif9TestClass
passByReference(i, c, p, f, tc)
lea EAX, DWORD PTR [EBP - 16]
lea ECX, DWORD PTR [EBP - 17]
lea ESI, DWORD PTR [EBP - 24]
lea EDI, DWORD PTR [EBP - 28]
lea EBX, DWORD PTR [EBP - 40]
mov DWORD PTR [ESP], EAX
mov DWORD PTR [ESP + 4], ECX
mov DWORD PTR [ESP + 8], ESI
mov DWORD PTR [ESP + 12], EDI
mov DWORD PTR [ESP + 16], EBX
call _Z15passByReferenceRiRcRPiRfR9TestClass
passByPointer(&i, &c, &p, &f, &tc)
lea EAX, DWORD PTR [EBP - 16]
lea ECX, DWORD PTR [EBP - 17]
lea ESI, DWORD PTR [EBP - 24]
lea EDI, DWORD PTR [EBP - 28]
lea EBX, DWORD PTR [EBP - 40]
mov DWORD PTR [ESP], EAX
mov DWORD PTR [ESP + 4], ECX
mov DWORD PTR [ESP + 8], ESI
mov DWORD PTR [ESP + 12], EDI
mov DWORD PTR [ESP + 16], EBX
call _Z13passByPointerPiPcPS_PfP9TestClass
passArray(numbers)
mov EAX, .L_ZZ4mainE7numbers
mov DWORD PTR [EBP - 60], EAX
mov EAX, .L_ZZ4mainE7numbers+4
mov DWORD PTR [EBP - 56], EAX
mov EAX, .L_ZZ4mainE7numbers+8
mov DWORD PTR [EBP - 52], EAX
lea EAX, DWORD PTR [EBP - 60]
mov DWORD PTR [ESP], EAX
call _Z9passArrayPi
// parameter access
push EAX
mov EAX, DWORD PTR [ESP + 8]
mov DWORD PTR [ESP], EAX
pop EAX
Я предполагаю, что я смотрю на правильную сборку, связанную с передачей параметра, потому что в конце каждого из них есть вызовы!
Но из-за моих очень ограниченных знаний об собраниях я не могу сказать, что происходит здесь. Я узнал о конвенции ccall, поэтому я предполагаю, что что-то происходит, что связано с сохранением регистров, сохраненных вызывающим абонентом, а затем путем нажатия параметров в стек. Из-за этого я ожидаю, что все вещи загрузятся в регистры и "нажимают" всюду, но не имеют представления о том, что происходит с mov
и lea
s. Кроме того, я не знаю, что такое DWORD PTR
.
Я только узнал о регистрах: eax, ebx, ecx, edx, esi, edi, esp
и ebp
, поэтому просмотр чего-то вроде XMM0
или DL
просто смущает меня. Я думаю, имеет смысл видеть lea
когда дело доходит до передачи по ссылке/указателю, потому что они используют адреса памяти, но я не могу сказать, что происходит. Когда дело доходит до передачи по значению, кажется, что существует множество инструкций, поэтому это может быть связано с копированием значения в регистры. Не знаю, когда дело доходит до того, как массивы передаются и доступны как параметры.
Если бы кто-то мог объяснить общее представление о том, что происходит с каждым блоком сборки, я бы очень признателен.
Ответы
Ответ 1
Использование регистров CPU для передачи аргументов выполняется быстрее, чем использование памяти, то есть стека. Однако в ЦПУ имеется ограниченное количество регистров (особенно в x86-совместимых ЦП), поэтому, когда функция имеет много параметров, тогда стек используется вместо регистров процессора. В вашем случае есть 5 аргументов функции, поэтому компилятор использует стек для аргументов вместо регистров.
В принципе, компиляторы могут использовать push
команды для толкания аргументов для стека до фактического call
функции, но многие компиляторы (включая gnu c++) используют mov
для толкания аргументов для стека. Этот способ удобен, так как он не изменяет регистр ESP (верхняя часть стека) в части кода, вызывающей функцию.
В случае, если значения passByValue(i, c, p, f, tc)
помещаются в стек. Вы можете увидеть много команд mov
из ячейки памяти в регистр и из регистра в соответствующее место стека. Причиной этого является то, что сборка x86 запрещает прямое перемещение из одного места памяти в другое (исключение - movs
который перемещает значения из одного массива (или строки по вашему желанию) в другой).
В случае passByReference(i, c, p, f, tc)
вы можете увидеть много инструкций 5 lea, которые копируют адреса аргументов в регистры CPU, и эти значения регистров переносятся в стек.
Случай passByPointer(&i, &c, &p, &f, &tc)
аналогичен passByValue(i, c, p, f, tc)
. Внутри, на уровне сборки, по ссылке используются указатели, а на более высоком уровне c++, программисту не нужно явно использовать операторы &
и *
для ссылок.
После того, как параметры перемещаются в стек, выдается call
, который подталкивает указатель инструкции EIP
к стеку перед передачей выполнения программы в подпрограмму. Все moves
параметров к учетной записи стека для следующего EIP
в стеке после команды call
.
Ответ 2
Там слишком много в вашем примере выше, чтобы вскрыть все из них. Вместо этого я просто passByValue
поскольку это кажется самым интересным. Впоследствии вы должны быть в состоянии выяснить остальное.
Прежде всего, некоторые важные моменты, которые следует учитывать при изучении разборки, чтобы вы не потерялись полностью в море кода:
- Нет никаких инструкций по прямой копированию данных из одного места памяти в другое место памяти. например.
mov [ebp - 44], [ebp - 36]
не является юридической инструкцией. Для хранения данных сначала требуется промежуточный регистр, а затем копируется в память. - Оператор скобки
[]
в сочетании с средством mov
для доступа к данным из вычисленного адреса памяти. Это аналогично дешифрованию указателя в C/C++. - Когда вы видите
lea x, [y]
который обычно означает вычисляемый адрес y и сохраняет в x. Это аналогично адресу адреса переменной в C/C++. - Данные и объекты, которые необходимо скопировать, но слишком велики, чтобы вписаться в регистр, копируются в стек в куклу. IOW, он скопирует собственное машинное слово за раз, пока не будут скопированы все байты, представляющие объект/данные. Обычно это означает, что 4 или 8 байтов на современных процессорах.
- Компилятор обычно будет чередовать инструкции вместе, чтобы поддерживать занятость в процессоре и минимизировать киоски. Хорошо для эффективности кода, но плохо, если вы пытаетесь понять разборку.
С учетом вышеизложенного вызов функции passByValue
перестраивается немного, чтобы сделать его более понятным:
.define arg1 esp
.define arg2 esp + 4
.define arg3 esp + 8
.define arg4 esp + 12
.define arg5.1 esp + 16
.define arg5.2 esp + 20
; copy first parameter
mov EAX, [EBP - 16]
mov [arg1], EAX
; copy second parameter
mov DL, [EBP - 17]
movsx EAX, DL
mov [arg2], EAX
; copy third
mov ECX, [EBP - 24]
mov [arg3], ECX
; copy fourth
movss XMM0, DWORD PTR [EBP - 28]
movss DWORD PTR [arg4], XMM0
; intermediate copy of TestClass?
mov ESI, [EBP - 40]
mov [EBP - 48], ESI
mov ESI, [EBP - 36]
mov [EBP - 44], ESI
;copy fifth
lea ESI, [EBP - 48]
mov EAX, [ESI]
mov [arg5.1], EAX
mov EAX, [ESI + 4]
mov [arg5.2], EAX
call passByValue(int, char, int*, float, TestClass)
Вышеприведенный код не имеет отношения, а смещение команд отменено, чтобы дать понять, что на самом деле происходит, но некоторые все еще требуют объяснения. Во-первых, символ signed
и имеет размер по одному байту. Инструкции здесь:
; copy second parameter
mov DL, [EBP - 17]
movsx EAX, DL
mov [arg2], EAX
читает байт из [ebp - 17]
(где-то в стеке) и сохраняет его в младший первый байт edx
. Этот байт затем копируется в eax
с помощью расширенного перемещения. Полное 32-битное значение в eax
, наконец, копируется в стек, доступ к passByValue
может получить passByValue
. Если вам нужна более подробная информация, см. Схему реестров.
Четвертый аргумент:
movss XMM0, DWORD PTR [EBP - 28]
movss DWORD PTR [arg4], XMM0
Использует команду SSE movss
для копирования значения с плавающей точкой из стека в регистр xmm0
. Вкратце, инструкции SSE позволяют выполнять одну и ту же операцию на нескольких фрагментах данных одновременно, но здесь компилятор использует ее как промежуточное хранилище для копирования значений с плавающей запятой в стеке.
Последний аргумент:
; copy intermediate copy of TestClass?
mov ESI, [EBP - 40]
mov [EBP - 48], ESI
mov ESI, [EBP - 36]
mov [EBP - 44], ESI
соответствует TestClass
. По-видимому, этот класс имеет размер 8 байтов, расположенный в стеке от [ebp - 40]
до [ebp - 33]
. Класс здесь копируется по 4 байта за раз, так как объект не может вписаться в один регистр.
Вот что выглядит примерно так: перед call passByValue
:
lower addr esp => int:arg1 <--.
esp + 4 char:arg2 |
esp + 8 int*:arg3 | copies passed
esp + 12 float:arg4 | to 'passByValue'
esp + 16 TestClass:arg5.1 |
esp + 20 TestClass:arg5.2 <--.
...
...
ebp - 48 TestClass:arg5.1 <-- intermediate copy of
ebp - 44 TestClass:arg5.2 <-- TestClass?
ebp - 40 original TestClass:arg5.1
ebp - 36 original TestClass:arg5.2
...
ebp - 28 original arg4 <--.
ebp - 24 original arg3 | original (local?) variables
ebp - 20 original arg2 | from calling function
ebp - 16 original arg1 <--.
...
higher addr ebp prev frame
Ответ 3
То, что вы ищете, - это соглашения ABI. Различные платформы имеют разные соглашения. например, Windows на x86-64 имеет разные соглашения, чем Unix/Linux на x86-64.
http://www.agner.org/optimize/ имеет документ-соглашения о вызовах, описывающий различные для x86/amd64.
Вы можете писать код в ASM, который делает все, что угодно, но если вы хотите вызвать другие функции и вызываться ими, тогда передайте параметры/возвращаемые значения в соответствии с ABI.
Полезно было бы использовать вспомогательную функцию внутреннего использования, которая не использует стандартный ABI, но вместо этого использует значения в регистрах, которые вызывающая функция выделяет в них. Это esp. вероятно, если вы пишете основную программу в чем-то, отличном от ASM, с небольшой частью в ASM. Тогда часть asm должна заботиться только о том, чтобы быть переносимой в системы с различными ABI для вызова из основной программы, а не для собственных внутренних компонентов.