Есть ли разница между перемещением регистров до создания кадра стека или после?

Предположим, у меня есть функция с именем 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, разбираться с этим за вас с помощью символических имен для аргументов и локальных переменных.


Кроме того, для обратного отслеживания стека вызовов это означает, что ваш вызывающийbpvalue указывает сохраненное значение 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, которое не меняется, если вы помещаете больше или меньше регистров в стек.

Другие вопросы по тегам