Как работает стек на языке ассемблера?


84

В настоящее время я пытаюсь понять, как работает стек, поэтому я решил научиться некоторому языку ассемблера , я использую эту книгу:

http://savannah.nongnu.org/projects/pgubook/

Я использую Gas и занимаюсь разработкой на Linux Mint .

Меня что-то немного смущает:

Насколько мне известно, стек - это просто структура данных. Поэтому я предположил, что если бы я писал код на сборке, мне пришлось бы реализовать стек самостоятельно. Однако, похоже, это не так, поскольку есть такие команды, как

pushl
popl

Поэтому при кодировании в сборке для x86 архитектуры и использовании синтаксиса Gas: является ли стек уже реализованной структурой данных? Или это реально реализовано на аппаратном уровне? Или что-то еще? Также будет ли стек для большинства языков ассемблера для других наборов микросхем уже реализован?

Я знаю, что это немного глупый вопрос, но на самом деле меня это смущает.


2
В большинстве этих ответов говорится о стеке, поскольку он используется языками, в частности, о передаче аргументов в стек. Во многих процессорах это просто упрощает реализацию языков - если вы вручную кодируете сборку, вы обычно передаете параметры функциям в регистрах (по крайней мере, до того, как ЦП был оптимизирован для операций со стеком, потому что языки так интенсивно его использовали). Стек в основном предназначен для упорядочения вызовов / возвратов, также прерывание (которое должно сохранять состояние ЦП) будет помещать существующие значения в регистры, которые он будет использовать, и выталкивать их перед возвратом.
Bill K

Ответы:


82

Я думаю, что в первую очередь вы путаете между a program's stackи any old stack.

Стек

Это абстрактная структура данных, которая состоит из информации в системе «последним вошел - первым ушел». Вы кладете произвольные объекты в стопку, а затем снова их снимаете, как лоток для ввода / вывода: верхний элемент всегда тот, который снимается, и вы всегда кладете его наверх.

Стек программ

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

Стек программ обычно не является аппаратным (хотя он хранится в памяти, поэтому можно утверждать, что это так), но указатель стека, который указывает на текущую область стека, обычно является регистром ЦП. Это делает его немного более гибким, чем стек LIFO, поскольку вы можете изменить точку, в которой стек обращается.

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

Существует также этот учебник, в котором объясняется стек с точки зрения старых 16-битных регистров, но он может быть полезен и еще один, посвященный стеку.

От Нильса Пипенбринка:

Стоит отметить, что некоторые процессоры не реализуют все инструкции для доступа к стеку и управления им (push, pop, указатель стека и т. Д.), Но x86 делает это из-за частоты его использования. В этих ситуациях, если вам нужен стек, вам придется реализовать его самостоятельно (некоторые процессоры MIPS и некоторые ARM создаются без стеков).

Например, в MIP инструкция push будет реализована следующим образом:

addi $sp, $sp, -4  # Decrement stack pointer by 4  
sw   $t0, ($sp)   # Save $t0 to stack  

и инструкция Pop будет выглядеть так:

lw   $t0, ($sp)   # Copy from stack to $t0  
addi $sp, $sp, 4   # Increment stack pointer by 4  

2
Кстати, у x86 есть эти специальные инструкции стека, потому что выталкивание и извлечение информации из стека случается так часто, что было хорошей идеей использовать для них короткий код операции (меньше места для кода). Такие архитектуры, как MIPS и ARM, не имеют их, поэтому вам придется реализовать стек самостоятельно.
Нильс Пипенбринк

4
Имейте в виду, что ваш горячий новый процессор в некоторой степени двоично совместим с 8086, и он был совместим с исходным кодом с 8080, разработкой 8008, первого микропроцессора. Некоторые из этих решений были приняты давно.
Дэвид Торнли

4
В ARM есть отдельные инструкции для управления стеком, они просто не так очевидны, потому что называются STMDB SP! (для PUSH) и LDMIA SP! (для POP).
Адам Гуд,

1
Боже мой, этот ответ нуждается в +500 ... Я не нашел ничего, что бы хорошо объясняло это с тех пор. Рассматривая возможность создания новых учетных записей для +1 к этому на данный момент ...
Габриэль

1
@bplus Вы также можете обратиться к cs.umd.edu/class/sum2003/cmsc311/Notes/Mips/stack.html
Suraj Jain

36

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

Я когда-либо делал только самые простые вещи в asm во время моего курса CS101 в 2003 году. И я никогда не понимал, как работают asm и стек, пока не понял, что все это в основном как программирование на C или C ++ ... но без локальных переменных, параметров и функций. Наверное, пока непросто :) Позвольте мне показать вам (для x86 asm с синтаксисом Intel ).


1. Что такое стек

Стек обычно представляет собой непрерывный кусок памяти, выделяемый для каждого потока перед его запуском. Вы можете хранить там все, что захотите. В терминах C ++ ( фрагмент кода # 1 ):

const int STACK_CAPACITY = 1000;
thread_local int stack[STACK_CAPACITY];

2. Верх и низ стека

В принципе, вы можете хранить значения в случайных ячейках stackмассива ( фрагмент 2.1 ):

stack[333] = 123;
stack[517] = 456;
stack[555] = stack[333] + stack[517];

Но представьте, как трудно запомнить, какие ячейки stackуже используются, а какие «свободны». Вот почему мы храним новые значения в стеке рядом друг с другом.

Одна странность в стеке asm (x86) заключается в том, что вы добавляете туда вещи, начиная с последнего индекса, и переходите к более низким индексам: стек [999], затем stack [998] и так далее ( фрагмент 2.2 ):

stack[999] = 123;
stack[998] = 456;
stack[997] = stack[999] + stack[998];

И все же (осторожно, вы сейчас запутаетесь) "официальное" название stack[999]- это нижняя часть стека .
Последняя использованная ячейка ( stack[997]в приведенном выше примере) называется вершиной стека (см. Где находится вершина стека на x86 ).


3. Указатель стека (SP)

Для целей этого обсуждения предположим, что регистры ЦП представлены как глобальные переменные (см. Регистры общего назначения ).

int AX, BX, SP, BP, ...;
int main(){...}

Есть специальный регистр ЦП (SP), который отслеживает вершину стека. SP - указатель (содержит адрес памяти, например 0xAAAABBCC). Но для целей этого поста я буду использовать его как индекс массива (0, 1, 2, ...).

Когда поток запускается, SP == STACK_CAPACITYа затем программа и ОС изменяют его по мере необходимости. Правило состоит в том, что вы не можете писать в ячейки стека за пределами вершины стека, и любой индекс меньше SP недействителен и небезопасен (из-за системных прерываний ), поэтому вы сначала уменьшаете SP, а затем записываете значение в новую выделенную ячейку.

Если вы хотите поместить несколько значений в стек подряд, вы можете заранее зарезервировать место для всех ( фрагмент 3 ):

SP -= 3;
stack[999] = 12;
stack[998] = 34;
stack[997] = stack[999] + stack[998];

Запись. Теперь вы можете понять, почему распределение в стеке происходит так быстро - это всего лишь декремент на один регистр.


4. Локальные переменные

Давайте посмотрим на эту упрощенную функцию ( фрагмент 4.1 ):

int triple(int a) {
    int result = a * 3;
    return result;
}

и перепишем без использования локальной переменной ( фрагмент 4.2 ):

int triple_noLocals(int a) {
    SP -= 1; // move pointer to unused cell, where we can store what we need
    stack[SP] = a * 3;
    return stack[SP];
}

и посмотрите, как он вызывается ( фрагмент 4.3 ):

// SP == 1000
someVar = triple_noLocals(11);
// now SP == 999, but we don't need the value at stack[999] anymore
// and we will move the stack index back, so we can reuse this cell later
SP += 1; // SP == 1000 again

5. Толкать / хлопать

Добавление нового элемента на вершину стека - настолько частая операция, что у ЦП есть специальная инструкция для этого push,. Мы реализуем это так ( фрагмент 5.1 ):

void push(int value) {
    --SP;
    stack[SP] = value;
}

Аналогичным образом, взяв верхний элемент стека ( фрагмент 5.2 ):

void pop(int& result) {
    result = stack[SP];
    ++SP; // note that `pop` decreases stack's size
}

Обычный шаблон использования push / pop временно сохраняет некоторую ценность. Скажем, у нас есть что-то полезное в переменной myVarи по какой-то причине нам нужно произвести вычисления, которые перезапишут ее ( фрагмент 5.3 ):

int myVar = ...;
push(myVar); // SP == 999
myVar += 10;
... // do something with new value in myVar
pop(myVar); // restore original value, SP == 1000

6. Параметры функции

Теперь передадим параметры с помощью стека ( фрагмент 6 ):

int triple_noL_noParams() { // `a` is at index 999, SP == 999
    SP -= 1; // SP == 998, stack[SP + 1] == a
    stack[SP] = stack[SP + 1] * 3;
    return stack[SP];
}

int main(){
    push(11); // SP == 999
    assert(triple(11) == triple_noL_noParams());
    SP += 2; // cleanup 1 local and 1 parameter
}

7. returnзаявление

Вернем значение в регистре AX ( фрагмент 7 ):

void triple_noL_noP_noReturn() { // `a` at 998, SP == 998
    SP -= 1; // SP == 997

    stack[SP] = stack[SP + 1] * 3;
    AX = stack[SP];

    SP += 1; // finally we can cleanup locals right in the function body, SP == 998
}

void main(){
    ... // some code
    push(AX); // save AX in case there is something useful there, SP == 999
    push(11); // SP == 998
    triple_noL_noP_noReturn();
    assert(triple(11) == AX);
    SP += 1; // cleanup param
             // locals were cleaned up in the function body, so we don't need to do it here
    pop(AX); // restore AX
    ...
}

8. Базовый указатель стека (BP) (также известный как указатель кадра ) и кадр стека.

Давайте возьмем более "продвинутую" функцию и перепишем ее на нашем asm-подобном C ++ ( фрагмент № 8.1 ):

int myAlgo(int a, int b) {
    int t1 = a * 3;
    int t2 = b * 3;
    return t1 - t2;
}

void myAlgo_noLPR() { // `a` at 997, `b` at 998, old AX at 999, SP == 997
    SP -= 2; // SP == 995

    stack[SP + 1] = stack[SP + 2] * 3; 
    stack[SP]     = stack[SP + 3] * 3;
    AX = stack[SP + 1] - stack[SP];

    SP += 2; // cleanup locals, SP == 997
}

int main(){
    push(AX); // SP == 999
    push(22); // SP == 998
    push(11); // SP == 997
    myAlgo_noLPR();
    assert(myAlgo(11, 22) == AX);
    SP += 2;
    pop(AX);
}

Теперь представьте, что мы решили ввести новую локальную переменную для сохранения результата перед возвратом, как мы это сделали в tripple(фрагмент № 4.1). Тело функции будет ( фрагмент # 8.2 ):

SP -= 3; // SP == 994
stack[SP + 2] = stack[SP + 3] * 3; 
stack[SP + 1] = stack[SP + 4] * 3;
stack[SP]     = stack[SP + 2] - stack[SP + 1];
AX = stack[SP];
SP += 3;

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

Мы создадим якорь сразу после входа в функцию (перед тем, как выделить место для локальных), сохранив текущую вершину (значение SP) в регистре BP. Фрагмент № 8.3 :

void myAlgo_noLPR_withAnchor() { // `a` at 997, `b` at 998, SP == 997
    push(BP);   // save old BP, SP == 996
    BP = SP;    // create anchor, stack[BP] == old value of BP, now BP == 996
    SP -= 2;    // SP == 994

    stack[BP - 1] = stack[BP + 1] * 3;
    stack[BP - 2] = stack[BP + 2] * 3;
    AX = stack[BP - 1] - stack[BP - 2];

    SP = BP;    // cleanup locals, SP == 996
    pop(BP);    // SP == 997
}

Срез стека, который принадлежит функции и полностью контролирует ее, называется кадром стека функции . Например myAlgo_noLPR_withAnchor, кадр стека равен stack[996 .. 994](включая оба идентификатора).
Кадр начинается с BP функции (после того, как мы обновили его внутри функции) и длится до следующего кадра стека. Таким образом, параметры в стеке являются частью кадра стека вызывающей стороны (см. Примечание 8a).

Примечания:
8а. Википедия говорит иначе о параметрах, но здесь я придерживаюсь руководства разработчика программного обеспечения Intel , см. Т. 1, раздел 6.2.4.1 Указатель базы стека-кадра, и рисунок 6-2 в разделе 6.3.2 Операции удаленного вызова и возврата . Параметры функции и фрейм стека являются частью записи активации функции (см. «Проблемы генерации функции» ).
8b. положительные смещения от точки BP к параметрам функции и отрицательные смещения указывают на локальные переменные. Это очень удобно для отладки
8c. stack[BP]хранит адрес предыдущего кадра стека,stack[stack[BP]]сохраняет предыдущий кадр стека и так далее. Следуя этой цепочке, вы можете обнаружить фреймы всех функций в программе, которые еще не вернулись. Вот как отладчики показывают стек вызовов
8d. первые 3 инструкции myAlgo_noLPR_withAnchor, в которых мы настраиваем фрейм (сохранить старый BP, обновить BP, зарезервировать место для локальных), называются прологом функции


9. Соглашения о вызовах

Во фрагменте 8.1 мы поместили параметры для myAlgoсправа налево и вернули результат в формате AX. Мы могли бы также передать параметры слева направо и вернуться BX. Или передайте параметры в BX и CX и вернитесь в AX. Очевидно, что caller ( main()) и вызываемая функция должны согласовать, где и в каком порядке хранятся все эти данные.

Соглашение о вызове - это набор правил о том, как передаются параметры и возвращается результат.

В приведенном выше коде мы использовали соглашение о вызовах cdecl :

  • Параметры передаются в стек, причем первый аргумент находится по наименьшему адресу в стеке во время вызова (помещается последним <...>). Вызывающий отвечает за извлечение параметров из стека после вызова.
  • возвращаемое значение помещается в AX
  • EBP и ESP должны быть сохранены вызываемым пользователем ( myAlgo_noLPR_withAnchorфункцией в нашем случае), чтобы вызывающий объект ( mainфункция) мог полагаться на то, что эти регистры не были изменены вызовом.
  • Все остальные регистры (EAX, <...>) могут быть изменены вызываемым пользователем; если вызывающий желает сохранить значение до и после вызова функции, он должен сохранить значение в другом месте (мы делаем это с помощью AX)

(Источник: пример «32-bit cdecl» из документации по переполнению стека; авторское право 2016 г. принадлежит icktoofay и Peter Cordes ; под лицензией CC BY-SA 3.0. Архив полного содержания документации по переполнению стека можно найти на сайте archive.org, в котором этот пример проиндексирован по ID темы 3261 и ID примера 11196.)


10. Вызов функций

Теперь самое интересное. Как и данные, исполняемый код также хранится в памяти (совершенно не связанной с памятью для стека), и каждая инструкция имеет адрес.
Если не указано иное, ЦП выполняет инструкции одну за другой в том порядке, в котором они хранятся в памяти. Но мы можем приказать процессору «перейти» в другое место в памяти и выполнить инструкции оттуда. В asm это может быть любой адрес, а в более высокоуровневых языках, таких как C ++, вы можете переходить только к адресам, отмеченным метками ( есть обходные пути, но они, мягко говоря, не очень хороши).

Возьмем эту функцию ( фрагмент 10.1 ):

int myAlgo_withCalls(int a, int b) {
    int t1 = triple(a);
    int t2 = triple(b);
    return t1 - t2;
}

И вместо вызова trippleC ++ сделайте следующее:

  1. скопировать trippleкод в начало myAlgoтела
  2. при myAlgoвходе перепрыгивайте через trippleкод с помощьюgoto
  3. когда нам нужно выполнить trippleкод, сохраните в стеке адрес строки кода сразу после trippleвызова, чтобы мы могли вернуться сюда позже и продолжить выполнение ( PUSH_ADDRESSмакрос ниже)
  4. перейти к адресу 1-й строки ( trippleфункции) и выполнить ее до конца (3. и 4. вместе составляютCALL макрос)
  5. в конце tripple(после того, как мы очистили локальных), возьмите адрес возврата из вершины стека и перейдите туда ( RETмакрос)

Поскольку в C ++ нет простого способа перейти к конкретному адресу кода, мы будем использовать метки, чтобы отмечать места переходов. Я не буду вдаваться в подробности того, как работают макросы ниже, просто поверьте мне, они делают то, что я говорю ( фрагмент 10.2 ):

// pushes the address of the code at label's location on the stack
// NOTE1: this gonna work only with 32-bit compiler (so that pointer is 32-bit and fits in int)
// NOTE2: __asm block is specific for Visual C++. In GCC use https://gcc.gnu.org/onlinedocs/gcc/Labels-as-Values.html
#define PUSH_ADDRESS(labelName) {               \
    void* tmpPointer;                           \
    __asm{ mov [tmpPointer], offset labelName } \
    push(reinterpret_cast<int>(tmpPointer));    \
}

// why we need indirection, read https://stackoverflow.com/a/13301627/264047
#define TOKENPASTE(x, y) x ## y
#define TOKENPASTE2(x, y) TOKENPASTE(x, y)

// generates token (not a string) we will use as label name. 
// Example: LABEL_NAME(155) will generate token `lbl_155`
#define LABEL_NAME(num) TOKENPASTE2(lbl_, num)

#define CALL_IMPL(funcLabelName, callId)    \
    PUSH_ADDRESS(LABEL_NAME(callId));       \
    goto funcLabelName;                     \
    LABEL_NAME(callId) :

// saves return address on the stack and jumps to label `funcLabelName`
#define CALL(funcLabelName) CALL_IMPL(funcLabelName, __LINE__)

// takes address at the top of stack and jump there
#define RET() {                                         \
    int tmpInt;                                         \
    pop(tmpInt);                                        \
    void* tmpPointer = reinterpret_cast<void*>(tmpInt); \
    __asm{ jmp tmpPointer }                             \
}

void myAlgo_asm() {
    goto my_algo_start;

triple_label:
    push(BP);
    BP = SP;
    SP -= 1;

    // stack[BP] == old BP, stack[BP + 1] == return address
    stack[BP - 1] = stack[BP + 2] * 3;
    AX = stack[BP - 1];

    SP = BP;     
    pop(BP);
    RET();

my_algo_start:
    push(BP);   // SP == 995
    BP = SP;    // BP == 995; stack[BP] == old BP, 
                // stack[BP + 1] == dummy return address, 
                // `a` at [BP + 2], `b` at [BP + 3]
    SP -= 2;    // SP == 993

    push(AX);
    push(stack[BP + 2]);
    CALL(triple_label);
    stack[BP - 1] = AX;
    SP -= 1;
    pop(AX);

    push(AX);
    push(stack[BP + 3]);
    CALL(triple_label);
    stack[BP - 2] = AX;
    SP -= 1;
    pop(AX);

    AX = stack[BP - 1] - stack[BP - 2];

    SP = BP; // cleanup locals, SP == 997
    pop(BP);
}

int main() {
    push(AX);
    push(22);
    push(11);
    push(7777); // dummy value, so that offsets inside function are like we've pushed return address
    myAlgo_asm();
    assert(myAlgo_withCalls(11, 22) == AX);
    SP += 1; // pop dummy "return address"
    SP += 2;
    pop(AX);
}

Примечания:
10а. поскольку адрес возврата хранится в стеке, в принципе мы можем его изменить. Вот как работает атака с разбиванием стека
10b. последние 3 инструкции в "конце" triple_label(очистка локальных переменных, восстановление старого BP, возврат) называются эпилогом функции


11. Сборка

Теперь посмотрим на настоящий asm для myAlgo_withCalls. Для этого в Visual Studio:

  • установите платформу сборки на x86 ( не x86_64)
  • тип сборки: отладка
  • установить точку останова где-нибудь внутри myAlgo_withCalls
  • запустить, а когда выполнение остановится в точке останова, нажмите Ctrl + Alt + D

Одно отличие от нашего asm-подобного C ++ состоит в том, что стек asm работает с байтами, а не с целыми числами. Таким образом, чтобы зарезервировать место для одного int, SP будет уменьшено на 4 байта.
Вот и все ( фрагмент 11.1 , номера строк в комментариях взяты из сути ):

;   114: int myAlgo_withCalls(int a, int b) {
 push        ebp        ; create stack frame 
 mov         ebp,esp  
; return address at (ebp + 4), `a` at (ebp + 8), `b` at (ebp + 12)
 
 sub         esp,0D8h   ; reserve space for locals. Compiler can reserve more bytes then needed. 0D8h is hexadecimal == 216 decimal 
 
 push        ebx        ; cdecl requires to save all these registers
 push        esi  
 push        edi  
 
 ; fill all the space for local variables (from (ebp-0D8h) to (ebp)) with value 0CCCCCCCCh repeated 36h times (36h * 4 == 0D8h)
 ; see https://stackoverflow.com/q/3818856/264047
 ; I guess that's for ease of debugging, so that stack is filled with recognizable values
 ; 0CCCCCCCCh in binary is 110011001100...
 lea         edi,[ebp-0D8h]     
 mov         ecx,36h    
 mov         eax,0CCCCCCCCh  
 rep stos    dword ptr es:[edi]  
 
;   115:    int t1 = triple(a);
 mov         eax,dword ptr [ebp+8]   ; push parameter `a` on the stack
 push        eax  
 
 call        triple (01A13E8h)  
 add         esp,4                   ; clean up param 
 mov         dword ptr [ebp-8],eax   ; copy result from eax to `t1`
 
;   116:    int t2 = triple(b);
 mov         eax,dword ptr [ebp+0Ch] ; push `b` (0Ch == 12)
 push        eax  
 
 call        triple (01A13E8h)  
 add         esp,4  
 mov         dword ptr [ebp-14h],eax ; t2 = eax
 
 mov         eax,dword ptr [ebp-8]   ; calculate and store result in eax
 sub         eax,dword ptr [ebp-14h]  

 pop         edi  ; restore registers
 pop         esi  
 pop         ebx  
 
 add         esp,0D8h  ; check we didn't mess up esp or ebp. this is only for debug builds
 cmp         ebp,esp  
 call        __RTC_CheckEsp (01A116Dh)  
 
 mov         esp,ebp  ; destroy frame
 pop         ebp  
 ret  

И asm для tripple( фрагмент 11.2 ):

 push        ebp  
 mov         ebp,esp  
 sub         esp,0CCh  
 push        ebx  
 push        esi  
 push        edi  
 lea         edi,[ebp-0CCh]  
 mov         ecx,33h  
 mov         eax,0CCCCCCCCh  
 rep stos    dword ptr es:[edi]  
 imul        eax,dword ptr [ebp+8],3  
 mov         dword ptr [ebp-8],eax  
 mov         eax,dword ptr [ebp-8]  
 pop         edi  
 pop         esi  
 pop         ebx  
 mov         esp,ebp  
 pop         ebp  
 ret  

Надеюсь, после прочтения этого поста сборка не выглядит такой загадочной, как раньше :)


Вот ссылки из тела сообщения и некоторые материалы для дальнейшего чтения:


Я спросил это давным-давно, это действительно отличный подробный ответ. Благодарю.
bplus 05

Почему вы используете 16-битные имена регистров в начале своего ответа? Если вы говорили о реальном 16-битном коде, [SP]это не является допустимым 16-битным режимом адресации. Наверное, лучше всего использовать ESP. Кроме того, если вы объявляете SPкак объект int, вы должны изменять его на 4 для каждого элемента, а не на 1. (Если вы объявили long *SP, тогда SP += 2будет увеличиваться на 2 * sizeof(int)и, таким образом, удалить 2 элемента. Но с intSP это должно быть SP += 8, как add esp, 8. В 32 -bit asm.
Питер Кордес

Очаровательно! Думаю, интересно, что вы пытаетесь объяснить сборку с использованием C. Я этого раньше не видел. Аккуратно. Я мог бы предложить переименовать «Нет локальных переменных» в «Как работают локальные переменные» или просто «Локальные переменные».
Дэйв Допсон

@PeterCordes причина появления 16-битных имен (SP, BP) заключается в ясности - SP легко переводится как «указатель стека». Если я использую правильные 32-битные имена, мне нужно будет либо объяснить разницу между режимами 16/32/64 бит, либо оставить это без объяснения. Я хотел, чтобы тот, кто знает только Java или Python, мог следить за публикацией, не сильно ломая голову. И я думаю, что адресация памяти только отвлечет читателя. К тому же, для любопытных я поместил ссылку на викибук и сказал пару слов о ESP в конце сообщения.
Александр Малахов

1
Чтобы этого избежать, нам нужен индекс привязки, который не меняется при увеличении стека. Потребность - неправильное слово; -fomit-frame-pointerуже много лет используется в gcc и clang по умолчанию. Людям, интересующимся реальным asm, нужно знать, что EBP / RBP обычно не используется в качестве указателя кадра. Я бы сказал, что «традиционно людям нужен якорь, который не меняется с push / pop, но компиляторы могут отслеживать изменение смещения». Затем вы можете обновить раздел об обратных трассировках, указав, что это устаревший метод, который не используется по умолчанию, когда доступны .eh_frameметаданные DWARF или метаданные Windows x86-64.
Питер Кордес

7

Относительно того, реализован ли стек на оборудовании, эта статья в Википедии может помочь.

Некоторые семейства процессоров, такие как x86, имеют специальные инструкции для управления стеком выполняемого в данный момент потока. Другие семейства процессоров, включая PowerPC и MIPS, не имеют явной поддержки стека, а вместо этого полагаются на соглашения и делегируют управление стеком двоичному интерфейсу приложений (ABI) операционной системы.

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


4

Концепт

Сначала подумайте обо всем, как если бы вы были человеком, который это изобрел. Как это:

Сначала подумайте о массиве и о том, как он реализован на низком уровне -> это в основном просто набор смежных ячеек памяти (ячеек памяти, которые находятся рядом друг с другом). Теперь, когда у вас есть этот мысленный образ в вашей голове, подумайте о том, что вы можете получить доступ к ЛЮБОЙ из этих областей памяти и удалить ее по своему желанию, когда вы удаляете или добавляете данные в свой массив. Теперь подумайте об этом же массиве, но вместо возможности удалить любое местоположение вы решите удалить только ПОСЛЕДНЕЕ местоположение при удалении или добавлении данных в свой массив. Теперь ваша новая идея по манипулированию данными в этом массиве таким образом называется LIFO, что означает «последний пришел - первым ушел». Ваша идея очень хороша, потому что она упрощает отслеживание содержимого этого массива без необходимости использования алгоритма сортировки каждый раз, когда вы что-то удаляете из него. Также, чтобы всегда знать, каков адрес последнего объекта в массиве, вы выделяете один регистр в ЦП, чтобы отслеживать его. Теперь, способ, которым регистр отслеживает это, состоит в том, что каждый раз, когда вы удаляете или добавляете что-либо в свой массив, вы также уменьшаете или увеличиваете значение адреса в своем регистре на количество объектов, которые вы удалили или добавили из массива (на объем занимаемого ими адресного пространства). Вы также хотите убедиться, что количество, на которое вы уменьшаете или увеличиваете этот регистр, фиксировано на одно количество (например, 4 ячейки памяти, т.е. 4 байта) на объект, опять же, чтобы упростить отслеживание, а также сделать это возможным чтобы использовать этот регистр с некоторыми конструкциями цикла, потому что в циклах используется фиксированное увеличение на итерацию (например, чтобы выполнить цикл через ваш массив с помощью цикла, вы создаете цикл для увеличения вашего регистра на 4 за каждую итерацию, что было бы невозможно, если бы в вашем массиве были объекты разных размеров). Наконец, вы решили назвать эту новую структуру данных «Стек», потому что она напоминает вам стопку тарелок в ресторане, где они всегда удаляют или добавляют тарелку наверху этой стопки.

Реализация

Как видите, стек - это не что иное, как массив смежных участков памяти, в которых вы решили, как им управлять. Из-за этого вы можете видеть, что вам даже не нужно использовать специальные инструкции и регистры для управления стеком. Вы можете реализовать это самостоятельно с помощью базовых инструкций mov, add и sub и использования регистров общего назначения вместо ESP и EBP следующим образом:

mov edx, 0FFFFFFFFh

; -> это будет начальный адрес вашего стека, наиболее удаленный от вашего кода и данных, он также будет служить тем регистром, который отслеживает последний объект в стеке, который я объяснил ранее. Вы называете его «указателем стека», поэтому вы выбираете регистр EDX в качестве того, для чего обычно используется ESP.

sub edx, 4

mov [edx], dword ptr [someVar]

; -> эти две инструкции уменьшат указатель стека на 4 ячейки памяти и скопируют 4 байта, начиная с ячейки памяти [someVar], в ячейку памяти, на которую теперь указывает EDX, точно так же, как инструкция PUSH уменьшает ESP, только здесь вы сделали это вручную, и вы использовали EDX. Таким образом, инструкция PUSH - это, по сути, просто более короткий код операции, который фактически делает это с помощью ESP.

mov eax, dword ptr [edx]

добавить edx, 4

; -> и здесь мы делаем наоборот, мы сначала копируем 4 байта, начиная с места в памяти, на которое теперь указывает EDX, в регистр EAX (произвольно выбранный здесь, мы могли бы скопировать его куда угодно). Затем мы увеличиваем указатель стека EDX на 4 ячейки памяти. Это то, что делает инструкция POP.

Теперь вы можете видеть, что инструкции PUSH и POP, а также регистры ESP и EBP были добавлены Intel только для того, чтобы упростить запись и чтение вышеуказанной концепции «стековой» структуры данных. Все еще есть некоторые процессоры RISC (сокращенный набор инструкций), которые не имеют инструкций PUSH и POP и выделенных регистров для управления стеком, и при написании программ сборки для этих процессоров вы должны реализовать стек самостоятельно, как и Я показал тебе.


3

Вы путаете абстрактный стек и стек, реализованный на оборудовании. Последний уже реализован.


3

Я думаю, что на главный ответ, который вы ищете, уже намекнули.

Когда загружается компьютер x86, стек не настраивается. Программист должен явно настроить его во время загрузки. Однако, если вы уже используете операционную систему, об этом позаботились. Ниже приведен пример кода простой программы начальной загрузки.

Сначала устанавливаются регистры сегмента данных и стека, а затем указатель стека устанавливается на 0x4000 сверх этого.


    movw    $BOOT_SEGMENT, %ax
    movw    %ax, %ds
    movw    %ax, %ss
    movw    $0x4000, %ax
    movw    %ax, %sp

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


3

Стек - это просто способ использования памяти программами и функциями.

Стек меня всегда смущал, поэтому я сделал иллюстрацию:

Стек похож на сталактиты

( версия svg здесь )

Не уверен, помогает ли это кому-то или просто сбивает с толку. Я считаю, что это правильно. Не стесняйтесь использовать изображение SVG. Всеобщее достояние.


1

Стек уже существует, поэтому вы можете предположить, что при написании кода. Стек содержит адреса возврата функций, локальные переменные и переменные, которые передаются между функциями. Существуют также встроенные регистры стека, такие как BP, SP (указатель стека), которые вы можете использовать, отсюда и упомянутые вами встроенные команды. Если стек еще не был реализован, функции не могли работать и поток кода не мог работать.


1

Стек «реализуется» с помощью указателя стека, который (в предположении архитектуры x86) указывает на сегмент стека . Каждый раз, когда что-то помещается в стек (с помощью pushl, call или аналогичного кода операции стека), оно записывается по адресу, на который указывает указатель стека, и указатель стека уменьшается (стек растет вниз , т. Е. Меньшие адреса) . Когда вы извлекаете что-то из стека (popl, ret), указатель стека увеличивается и значение считывается из стека.

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


1

Я специально не видел ассемблера Gas, но в целом стек «реализуется» путем сохранения ссылки на место в памяти, где находится вершина стека. Ячейка памяти хранится в регистре, который имеет разные имена для разных архитектур, но может рассматриваться как регистр указателя стека.

Команды pop и push реализованы в большинстве архитектур для вас на основе микрокоманд. Однако некоторые «образовательные архитектуры» требуют, чтобы вы реализовали их самостоятельно. Функционально push будет реализован примерно так:

   load the address in the stack pointer register to a gen. purpose register x
   store data y at the location x
   increment stack pointer register by size of y

Кроме того, в некоторых архитектурах последний использованный адрес памяти сохраняется в качестве указателя стека. Некоторые сохраняют следующий доступный адрес.


1

Что такое стек? Стек - это тип структуры данных - средство хранения информации в компьютере. Когда новый объект помещается в стек, он помещается поверх всех ранее введенных объектов. Другими словами, структура данных стека похожа на стопку карточек, бумаг, почтовых отправлений по кредитным картам или любых других реальных объектов, о которых вы можете подумать. При удалении объекта из стека первым удаляется тот, который находится сверху. Этот метод называется LIFO (последний пришел, первый ушел).

Термин «стек» также может быть сокращенным для обозначения стека сетевых протоколов. В сети соединения между компьютерами устанавливаются через серию более мелких соединений. Эти соединения или уровни действуют как структура данных стека в том смысле, что они строятся и удаляются одинаково.


0

Вы правы, что стек - это структура данных. Часто структуры данных (включая стеки), с которыми вы работаете, являются абстрактными и существуют как представление в памяти.

Стек, с которым вы работаете в этом случае, имеет более материальное существование - он отображается непосредственно на реальные физические регистры в процессоре. Как структура данных, стеки представляют собой структуры FILO (first in, last out), которые обеспечивают удаление данных в порядке, обратном их введению. См. Логотип StackOverflow для визуализации! ;)

Вы работаете со стеком инструкций . Это стек фактических инструкций, которые вы скармливаете процессору.


неправильно. это не «стек инструкций» (существует ли такая вещь?), это просто память, доступ к которой осуществляется через регистр стека. используется для временного хранения, параметров процедуры и (что наиболее важно) адреса возврата для вызовов функций
Хавьер

0

Стек вызовов реализуется набором инструкций x86 и операционной системой.

Такие инструкции, как push и pop, регулируют указатель стека, в то время как операционная система заботится о выделении памяти по мере увеличения стека для каждого потока.

Тот факт, что стек x86 «растет вниз» от более высоких адресов к более низким, делает эту архитектуру более уязвимой для атаки переполнения буфера.


1
Почему тот факт, что стек x86 растет вниз, делает его более восприимчивым к переполнению буфера? Не могли бы вы получить такое же переполнение при расширении сегмента вверх?
Натан Феллман

@nathan: только если вы можете заставить приложение выделять отрицательный объем памяти в стеке.
Хавьер

1
Атаки переполнения буфера записывают данные за конец массива на основе стека - char userName [256], это записывает память от меньшего к большему, что позволяет вам перезаписывать такие вещи, как адрес возврата. Если бы стек увеличивался в том же направлении, вы могли бы перезаписать только нераспределенный стек.
Морис Флэнаган

0

Вы правы, что стек - это «просто» структура данных. Здесь, однако, имеется в виду аппаратно реализованный стек, используемый для специального назначения - «Стек».

Многие люди комментировали аппаратно реализованный стек по сравнению со структурой данных (программного) стека. Я хотел бы добавить, что существует три основных типа структуры стека:

  1. Стек вызовов - о котором вы спрашиваете! В нем хранятся параметры функций, адрес возврата и т. Д. Прочтите главу 4 (Все о 4-й странице, т.е. страница 53) в этой книге. Есть хорошее объяснение.
  2. Общий стек, который вы можете использовать в своей программе для чего-то особенного ...
  3. Общий аппаратный стек
    Я не уверен в этом, но я помню, что где-то читал, что есть аппаратный стек общего назначения, доступный в некоторых архитектурах. Если кто-нибудь знает, правильно ли это, прокомментируйте.

Первое, что нужно знать, это архитектура, для которой вы программируете, что объясняется в книге (я только что посмотрел - ссылка). Чтобы по-настоящему понять вещи, я предлагаю вам узнать о памяти, адресации, регистрах и архитектуре x86 (я предполагаю, что это то, что вы изучаете - из книги).


0

Вызов функций, который требует сохранения и восстановления локального состояния в стиле LIFO (в отличие от обобщенного подхода совместной подпрограммы), оказывается такой невероятно распространенной потребностью, что языки сборки и архитектуры ЦП в основном встраивают эту функцию. вероятно, можно сказать о понятиях потоковой передачи, защиты памяти, уровней безопасности и т. д. Теоретически вы можете реализовать свой собственный стек, соглашения о вызовах и т. д., но я предполагаю, что некоторые коды операций и большинство существующих сред выполнения полагаются на эту родную концепцию «стека». .


0

stackэто часть памяти. он используется для inputи outputизfunctions . также он используется для запоминания возврата функции.

esp Регистр запоминает адрес стека.

stack и esp реализованы аппаратно. также вы можете реализовать это самостоятельно. это сделает вашу программу очень медленной.

пример:

nop // esp= 0012ffc4

push 0 // esp= 0012ffc0, Dword [0012ffc0] = 00000000

вызов proc01 // esp= 0012ffbc, Dword [0012ffbc] = eip, eip= adrr [proc01]

pop eax// eax= Dword [ esp], esp= esp+ 4


0

Я искал, как работает стек с точки зрения функций, и нашел этот блог его потрясающим и объясняющим концепцию стека с нуля и то, как стек сохраняет значение в стеке.

Теперь по вашему ответу. Я объясню на Python, но вы получите хорошее представление о том, как работает стек на любом языке.

введите описание изображения здесь

Это программа:

def hello(x):
    if x==1:
        return "op"
    else:
        u=1
        e=12
        s=hello(x-1)
        e+=1
        print(s)
        print(x)
        u+=1
    return e

hello(3)

введите описание изображения здесь

введите описание изображения здесь

Источник: Cryptroix

некоторые из его тем, которые освещаются в блоге:

How Function work ?
Calling a Function
 Functions In a Stack
 What is Return Address
 Stack
Stack Frame
Call Stack
Frame Pointer (FP) or Base Pointer (BP)
Stack Pointer (SP)
Allocation stack and deallocation of stack
StackoverFlow
What is Heap?

Но это объясняется языком Python, поэтому, если хотите, можете взглянуть.


Сайт Criptoix мертв, и его копии на web.archive.org нет
Александр Малахов

1
@AlexanderMalakhov Cryptroix не работал из-за проблем с хостингом. Cryptroix запущен и работает.
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.