Есть ли разница между перемещением регистров до создания кадра стека или после?
Предположим, у меня есть функция с именем func:
PROC func:
;Bla bla
ret
ENDP func
Теперь предположим, что я использую регистр ax
а также bx
например, чтобы сохранить их начальное значение, я помещаю их в стек внутри функции.
Теперь к вопросу: есть ли большая разница между нажатием регистров перед созданием кадра стека:
PROC func:
push bp
push ax
push bx
mov bp, sp
;Bla bla
ret
ENDP func
Или после?
PROC func:
push bp
mov bp, sp
push ax
push bx
;Bla bla
ret
ENDP func
А что мне использовать в своих программах? Один метод лучше или "правильнее", чем другой? Потому что сейчас я использую первый метод.
3 ответа
Второй способ, push bp
; movbp, sp
перед добавлением дополнительных регистров означает, что ваш первый аргумент стека всегда находится в[bp+4]
независимо от того, сколько еще толчков вы сделаете1. Это не имеет значения, если вы передали все аргументы в регистрах, а не в стеке, что в большинстве случаев проще и эффективнее, если у вас есть только пара.
Это хорошо для удобства обслуживания людьми; вы можете изменить, сколько регистров вы сохраняете / восстанавливаете, не меняя способа доступа к аргументам. Но вам все равно следует избегать пространства прямо под BP; сохранение большего количества регистров означает, что вы можете поместить самую высокую локальную переменную в[bp-6]
вместо того [bp-4]
.
Сноска: "Дальний процесс" имеет 32-битный адрес возврата CS:IP, поэтому аргументы начинаются с [bp+6]
в этом случае. См. Комментарии @MichaelPetch о том, чтобы позволить таким инструментам, как MASM, разбираться с этим за вас с помощью символических имен для аргументов и локальных переменных.
Кроме того, для обратного отслеживания стека вызовов это означает, что ваш вызывающийbp
value указывает сохраненное значение BP в кадре стека вызывающей стороны, формируя связанный список значений BP / ret-addr, за которым может следовать отладчик. Делал больше толчков передmovbp,sp
оставил бы BP указывать в другом месте. См. Также Когда мы создаем базовый указатель в функции - до или после локальных переменных? для более подробной информации об этом, по очень похожему вопросу для 32-битного режима. (Обратите внимание, что 32- и 64-битный код может использовать[esp +- x]
режимы адресации, но 16-битный код не может. 16-битный код в основном вынужден устанавливать BP как указатель кадра для доступа к собственному кадру стека.)
Трассировка стека - одна из основных причин movbp,sp
сразу после push bp
стандартное соглашение. В отличие от какого-либо другого не менее действенного соглашения, такого как выполнение всех ваших толчков, а затем movbp,sp
.
если ты push bp
наконец, вы можете использоватьleave
инструкция перед pop/pop/ret в эпилоге. (Это зависит от BP, указывающего на сохраненное значение BP).
В leave
инструкция может сохранить размер кода как компактную версиюmov sp,bp
; pop bp
. (Это не магия, это все, что она делает. Совершенно нормально не использовать ее. Иenter
очень медленный на современной x86, никогда не используйте его.) Вы действительно не можете использовать leave
если у вас есть другие дела, которые нужно сделать в первую очередь. Послеadd sp, whatever
чтобы указать SP на сохраненное значение BX, вы делаете pop bx
и тогда вы можете просто использовать pop bp
вместо того leave
. Такleave
полезен только в функции, которая создает кадр стека, но не помещает после него никакие другие регистры. Но резервирует дополнительное место сsub sp, 20
например, так sp
все еще не указывает на то, что вы хотите pop
.
Или вы можете использовать что-то вроде этого, чтобы смещения для стека аргументов и локальных переменных не зависели от того, сколько регистров вы нажимаете / выталкиваете, кроме BP. Я не вижу в этом явных недостатков, но, возможно, я по какой-то причине пропустил, почему это не обычное соглашение.
func:
push bp
mov bp,sp
sub sp, 16 ; space for locals from [bp-16] to [bp-1]
push bx ; save some call-preserved regs *below* that
push si
... function body
pop si
pop bx
leave ; mov sp, bp; pop bp
ret
Современный GCC имеет тенденцию сохранять все регистры с сохранением вызовов до sub esp, imm
. например
void ext(int); // non-inline function call to give GCC a reason to save/restore a reg
void foo(int arg1) {
volatile int x = arg1;
ext(1);
ext(arg1);
x = 2;
// return x;
}
gcc9.2-m32-O3 -fno-omit-frame-pointer-fverbose-asm на Godbolt
foo(int):
push ebp #
mov ebp, esp #,
push ebx # save a call-preserved reg
sub esp, 32 #,
mov ebx, DWORD PTR [ebp+8] # arg1, arg1 # load stack arg
push 1 #
mov DWORD PTR [ebp-12], ebx # x = arg1
call ext(int) #
mov DWORD PTR [esp], ebx #, arg1
call ext(int) #
mov DWORD PTR [ebp-12], 2 # x,
mov ebx, DWORD PTR [ebp-4] #, ## restore EBX with mov instead of pop
add esp, 16 #, ## missed optimization, let leave do this
leave
ret
Восстановление регистров с сохранением вызовов с помощью mov
вместо того pop
позволяет GCC по-прежнему использовать leave
. Если вы настроите функцию так, чтобы она возвращала значение, GCC избегает потерьadd esp,16
.
Кстати, вы можете сократить свой код, позволив функциям уничтожить хотя бы AX без сохранения / восстановления. т.е. обращаться с ними как с заторможенными вызовами, иначе изменчивыми. Обычные 32-битные соглашения о вызовах имеют EAX, ECX и EDX volatile (например, то, что GCC компилирует в приведенном выше примере: Linux i386 System V), но существует множество различных 16-битных соглашений, которые отличаются.
Наличие одного из энергозависимых SI, DI или BX позволит функциям получать доступ к памяти без необходимости выталкивать / выталкивать ее копию вызывающего абонента.
Руководство Agner Fog по соглашениям о вызовах включает некоторые стандартные 16-битные соглашения о вызовах, см. Таблицу в начале главы 7, где указаны16-битные соглашения, используемые существующими компиляторами C/C++. @MichaelPetch предлагает соглашение Watcom: AX и ES всегда закрываются при вызове, но аргументы передаются в AX, BX, CX, DX. Любой регистр, используемый для передачи аргументов, также закрывается при вызове. И то же самое, когда SI используется для передачи указателя на то место, где функция должна хранить большое возвращаемое значение.
Или, в крайнем случае, выберите настраиваемое соглашение о вызовах для каждой функции, в зависимости от того, что наиболее эффективно для этой функции и для ее вызывающих. Но это быстро превратилось в кошмар обслуживания; если вам нужна такая оптимизация, просто используйте компилятор и позвольте ему встроить короткие функции и оптимизировать их в вызывающую программу, или выполните межпроцедурную оптимизацию на основе того, какие регистры фактически используются функцией.
В своих программах я обычно использую второй метод, то есть сначала создаю фрейм стека. Это делается с помощьюpush bp
\ mov bp, sp
а затем по желанию push ax
один или два раза или lea sp, [bp - x]
чтобы зарезервировать место для неинициализированных переменных. (Я позволяю макросам фрейма стека создавать эти инструкции.) Затем вы можете дополнительно добавить в стек, чтобы зарезервировать место для и в то же время инициализировать другие переменные. После переменных могут быть переданы регистры, которые необходимо сохранить во время выполнения функции.
Есть третий способ, который вы не указали в качестве примера в своем вопросе. Выглядит это так:
PROC func:
push ax
push bx
push bp
mov bp, sp
;Bla bla
ret
ENDP func
Для меня легко возможны второй и третий способы. Я мог бы использовать третий способ, если бы я сначала нажимал что-то, а затем для создания кадра стека укажите, что я называю "насколько велик адрес возврата и другие вещи между bp и последним параметром" в моемlframe
вызов макроса.
Но проще всегда нажимать регистры после настройки фрейма (второй способ). В этом случае я всегда могу указать "тип кадра" какnear
, что почти полностью эквивалентно 2
; это потому, что ближайший 16-битный адрес возврата занимает 2 байта.
Вот пример кадра стека с регистрами, сохраненными путем их нажатия:
lframe near, nested
lpar word, inp_index_out_segment
lpar word, out_offset
lpar_return
lenter
lvar dword, start_pointer
push word [sym_storage.str.start + 2]
push word [sym_storage.str.start]
lvar word, orig_cx
push cx
mov cx, SYMSTR_index_size
ldup
lleave ctx
lleave ctx
; INP: ?inp_index_out_segment = index
; ?start_pointer = start far pointer of this area
; ?orig_cx = what to return cx to
; cx = index size
.common:
push es
push di
push dx
push bx
push ax
%if _BUFFER_86MM_SLICE
push si
push ds
%endif
Здесь есть небольшое преимущество использования второго способа: начальный кадр стека фактически создается несколько раз разными точками входа в функцию. Они легко разделяют сохранение, помещая регистры в.common
обработка. Этого нельзя было бы достичь так же легко, если бы различное вступление для каждой точки входа следовало бы после нажатия регистров для сохранения их значений.
В остальном большой разницы нет, нет. Однако, сохраняя предыдущее значение bp наword [bp]
(второй или третий способ) может быть полезным или даже необходимым для отладчиков или другого программного обеспечения для отслеживания цепочки кадров стека. Точно так же может быть полезен второй способ, поскольку он сохраняет обратный адрес вword [bp + 2]
.
Чаще всего сначала настраивают фрейм стека. Это потому, что параметры вашей функции обычно находятся в стеке. Вы можете получить к ним доступ с фиксированными (положительными) смещениями от bp. Если вы сначала вставите другие регистры, положение параметров в кадре стека изменится.
Если вам нужно выделить локальное хранилище в стеке, вы можете вычесть константу из sp, чтобы создать пустое пространство, а затем протолкнуть другие регистры. Таким образом, ваше локальное хранилище имеет (отрицательное) смещение от bp, которое не меняется, если вы помещаете больше или меньше регистров в стек.