Skip to the content.

Стек и указатели


Стек - непрерывная область памяти, для которой в специальном регистре (ESP - указатель стека) хранится адрес вершины стека, память выше вершины (в области с адресами, меньшимы адреса вершины) считается свободной, а память от вершины до конца области (включая вершину) занятой.

stack_1

Стек выделяется одним из первых при запуске программы, обычно небольшой по размеру (4 Мб, но можно изменить при помощи настроек компилятора).

Операция добавления в стек некоторого значения уменьшает адрес вершины, сдвигая тем самым вершину вверх (то есть в направлении меньших адресов) и в новую вершину записывает значение.

Операция извлечения считывает значение с вершины стека и сдвигает вершину вниз, увеличивая её адрес.

Направление роста стека зависит от конкретного процессора, в основном на большинстве архитектур (х86) стек “растёт” вниз, то есть в направлении убывания адресов.

Стековый фрейм

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

Самое главное: стек используется при вызовах подпрограмм для хранения их адресов возврата, передаваемых параметров и локальных переменных. Именно использование стека позволяет реализовать механизм рекурсии.

Стековый фрейм (Стек вызовов) - область стековой памяти, содержащая связанные одним вызовом значения параметров, адрес возврата и локальные переменные.

Низкоуровневая информация

Адрес возврата хранится в регистре ESP, но что если этот регистр понадобится нам для еще одного вызова (прямо из текущего стекового фрейма), для этого значение ESP сохраняют в регистр EBP, а перед возвратом из подпрограммы значение ESP восстанавливают обратно. Назрел ещё один вопрос, а что если другие подпрограммы используют EBP для тех же целей? Для этого каждая подпрограмма сама сохраняет в стеке старое значение EBP и восстанавливает перед возвратом управления., таким образом старое значение EBP помещается в стек непосредственно после адреса возврата из подпрограммы. EBP в итоге используется для доступа к другим ячейкам.

Вот так выглядит стековый фрейм при вызове функции f() с тремя параметрами и тремя локальными переменными (соглашение stdcall):

base pointer (EBP) и frame pointer это одно и тоже!

Абстрактный пример

void bar()
{
    int c;
}

void foo()
{
    int b = 3;
    bar();
}

int main()
{
    int a = 3;
    foo();
    bar();
    
    return 0;
}

Стоит обратить внимание, на второй вызов функции bar(), она занимает то же место в памяти, которое раньше занимала foo(), и мы можем предположить что переменная c лежит точно там же, где раньше лежала b, и возможно взяла её тогдашнее значение (3), вот почему так важно инициализировать переменные при объявлении.

Более подробный пример

int foo(int a, int b, bool c)
{
    double d = a * b * 2.71;
    int h = c ? d : d / 2;
    return h;
}

int main()
{
    int x = 1;
    int y = 2;
    x = foo (x, y, false);
    cout << x;
    return 0;
}

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

Не обязательно, что сложная арифметическая операция будет записана в стек, возможно что всё будет посчитано только с использованием регистров.

При вызове функции frame pointer перемещается:

Процесс вызова подпрограммы может отличатся в зависимости от используемых соглашений (cdecl, stdcall, fastcall, thiscall).

Указатели


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

int i = 3; // переменная типа int

Указатель - переменная, которая хранит адрес некоторой ячейки памяти.

Указатели являются типизированными (тип указателя должен соответствовать типу переменной на которую он указывает), это так важно поскольку при разыменовании указатель должен знать как интерпретировать свое содержимое.

Нулевому указателю (которому присвоено значение 0) не соответствует никакая ячейка памяти.

int *p = 0; // нулевой указатель на переменную типа int
int *a, b; // a - указатель, b - переменная типа int

не допустимо:
double *ptr = 7; // 7 это литерал, у него нет адреса
double *ptr = 0x0012FF7C; // это не адрес, а литерал

Оператор взятия адреса переменной & - позволяет узнать, какой адрес памяти присвоен конкретной переменной (Стоит отметить, что & не возвращает адрес, вместо этого он возвращает указатель, содержащий адрес операнда):

int a = 7;
std::cout << a; // значение переменной a: 7
std::cout << &a; // адрес переменной a: 0x7fdd6a6537

__Оператор разыменования __ - позволяет получить значение по указанному адресу (не стоит путать оператор разыменования (p) и модификатор типа (int * p))

int a = 7;
int *p = &a; // указатель на переменную i, содержит 0x7fdd6a6537
std::cout << p; // 0x7fdd6a6537
std::cout << *p; // значение по адресу p: 7
*p = 10; // изменить значение по адресу р на 10
std::cout << *p; // 10

Может сложится впечатление что указатели совсем ненужные, но:

Передача параметров по указателю

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

void swap (int a, int b)
{
    int t = a;
    a = b;
    b = t;
}

swap(k, m);

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

void swap (int *a, int *b) // указатели на тип int
{
    int t = *a;
    *a = *b;
    *b = t;
}

swap(&k, &m); // передадим адреса, вместо значений

Арифметика указателей

Указатели позволяют передвигаться по массивам. Для этого используется арифметика указателей:

// Можно проинициализировать лишь часть массива, 
// остальная часть будет заполнена нулями
int m[10] = {1, 2, 3, 4, 5}; // 1 2 3 4 5 0 0 0 0 0
int *p = &m[0]; // адрес на первый элемент массива
int *q = &m[9]; // адрес на последний элемент массива

m тоже самое что и &m[0] (точнее m неявно преобразуется в указатель на первый элемент массива &m[0])

Заполнение массива при помощи указателей:

int m[10] = {}; // изначально заполнен нулями
for (int *p = m; p <= m + 9; ++p) *p = (p - m) + 1; 
// заполняем числами от 1 до 10

Передача массива в функцию

Вместо int *m можно передать int m[], для параметров функции это одно и тоже. Более того, если передать int m[3], размер (3) проигнорируется (т.е. можно будет передать масив размера 2 и программа успешно скомпилируется).

int max (int *m, int size) // передаем указатель на начало массива
{
    int max = *m;
    for (int i = 1; i < size; ++i)
        if (m[i] > max) max = m[i];
    return max;
}