Как переменные хранятся в программном стеке и извлекаются из него?

47

Заранее извиняюсь за наивность этого вопроса. Мне 50 лет, и я впервые пытаюсь правильно понять компьютеры. Так что здесь идет.

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

Но что я не понимаю, так это то, как переменные в стеке затем считываются приложением - если я объявляю и присваиваю xцелое число, скажем x = 3, и хранилище резервируется в стеке, а затем его значение 3сохраняется там, а затем в ту же функцию, которую я объявляю и назначаю, yкак, скажем 4, а затем, после чего я использую xв другом выражении (скажем z = 5 + x), как программа может читать x, чтобы оценить, zкогда она нижеyв стеке? Я явно что-то упускаю. Дело в том, что расположение в стеке зависит только от времени жизни / области видимости переменной и что весь стек фактически доступен для программы все время? Если это так, означает ли это, что существует какой-то другой индекс, который содержит адреса только переменных в стеке, чтобы можно было получать значения? Но потом я подумал, что весь смысл стека в том, что значения хранятся в том же месте, что и адрес переменной? На мой взгляд, кажется, что если есть другой индекс, то мы говорим о чем-то более похожем на кучу? Я явно очень смущен, и я просто надеюсь, что есть простой ответ на мой упрощенный вопрос.

Спасибо за чтение этого далеко.

Селин Этвуд
источник
7
@ fade2black Я не согласен - должна быть возможность дать разумный ответ, который суммирует важные моменты.
Дэвид Ричерби
9
Вы делаете чрезвычайно распространенную ошибку, связывая тип значения с местом его хранения . Просто неверно говорить, что bool идут в стек. Bools входят в переменные , а переменные идут в стек, если известно, что их время жизни короткое , и в кучу, если неизвестно, что их время жизни короткое. Некоторые мысли о том, как это связано с C #, см. Blogs.msdn.microsoft.com/ericlippert/2010/09/30/…
Эрик Липперт,
7
Кроме того, не думайте о стеке как о стеке значений в переменных . Думайте об этом как о стеке фреймов активации для методов . Внутри метода вы можете получить доступ к любой переменной активации этого метода, но вы не можете получить доступ к переменным вызывающего, потому что они не находятся в кадре, который находится на вершине стека .
Эрик Липперт
5
Также: я приветствую вашу инициативу, чтобы выучить что-то новое и покопаться в деталях реализации языка. Здесь вы столкнулись с интересным камнем преткновения: вы понимаете, что такое стек как абстрактный тип данных , но не как деталь реализации для активации активации и продолжения . Последний не следует правилам абстрактного типа данных стека; это относится к ним больше как к руководящим принципам, чем к правилам. Весь смысл языков программирования состоит в том, чтобы вам не приходилось понимать эти отвлеченные детали для решения задач программирования.
Эрик Липперт,
4
Спасибо, Эрик, Сава, Миниатюра, эти комментарии и ссылки очень полезны. Я всегда чувствую, что люди, подобные вам, должны внутренне стонать, когда они видят вопрос, подобный моему, но, пожалуйста, знайте огромное волнение и удовлетворение от получения ответов!
Селин Этвуд

Ответы:

24

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

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

Вы можете думать, что стек очень похож на классическую структуру данных стека, с одним принципиальным отличием: вам разрешен доступ к элементам ниже вершины стека. Действительно, вы можете получить доступ к му элементу сверху. Вот как вы можете получить доступ ко всем вашим локальным переменным с помощью нажатия и выталкивания. Единственное нажатие делается при входе в функцию, и единственное нажатие при выходе из функции.k

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

Юваль Фильмус
источник
1
«Выделено в одном блоке» - это еще одна деталь реализации. Это не имеет значения, однако. Компилятор знает, как требуется память для локальных переменных, он выделяет эту память одному или нескольким блокам, а затем создает локальные переменные в этой памяти.
MSalters
Спасибо, исправлено. Действительно, некоторые из этих «блоков» являются просто регистрами.
Yuval
1
Вам действительно нужен стек для хранения адресов возврата, если это так. Вы можете легко реализовать рекурсию без стека, передав указатель на адрес возврата в куче.
Юваль
1
Стеки @MikeCaron не имеют ничего общего с рекурсией. Зачем вам «сдавать переменные» в других стратегиях реализации?
садовник
1
@gardenhead самая очевидная альтернатива (и та, которая фактически используется / использовалась) состоит в статическом размещении переменных каждой процедуры. Быстро, просто, предсказуемо ... но не допускается рекурсия или повторный вход. Это и обычный стек, конечно, не единственные альтернативы (динамическое распределение всего другого), но они обычно обсуждаются при обосновании стеков :)
hobbs
23

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

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

Дело в том, что расположение в стеке зависит только от времени жизни / области видимости переменной и что весь стек фактически доступен для программы все время?

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

aebabis
источник
1
Это кажется самым чистым ответом на данный момент с точки зрения того, чтобы не говорить за пределами базовых знаний, которые OP привносит в таблицу. +1 за реальный таргетинг ОП!
Бен И.
1
Я тоже согласен! Хотя все ответы чрезвычайно полезны, и я очень благодарен, моя оригинальная публикация была мотивирована, потому что я чувствую (d), что весь этот стек / куча абсолютно необходим для понимания того, как возникает различие между типом значение / ссылка, но я не мог не вижу, как если бы вы могли только когда-либо просматривать вершину "стека". Так что ваш ответ освобождает меня от этого. (У меня такое же чувство, как и у меня, когда я впервые осознал, что все физики обратных квадратов в физике просто выпадают из геометрии излучения, выходящего из сферы, и вы можете нарисовать простую диаграмму, чтобы увидеть это.)
Селин Этвуд
Я люблю это, потому что это всегда чрезвычайно полезно, когда вы можете видеть, как и почему какое-то явление на более высоком уровне (например, в языке) действительно связано с более фундаментальным явлением, немного ниже по дереву абстракции. Даже если это будет довольно просто.
Селин Этвуд
1
@CelineAtwood Обратите внимание, что попытка доступа к переменным «принудительно» после их удаления из стека приведет к непредсказуемому / неопределенному поведению и не должна выполняться. Обратите внимание, что я не сказал «не могу», потому что некоторые языки позволят вам попробовать. Тем не менее, это будет ошибка программирования, и ее следует избегать.
code_dredd
12

Чтобы представить конкретный пример того, как компилятор управляет стеком и как осуществляется доступ к значениям в стеке, мы можем взглянуть на визуальные изображения, а также код, сгенерированный GCCв среде Linux с целевой архитектурой i386.

1. Стек кадров

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

Кадр стека CSAPP

2. Управление кадрами стека и переменное местоположение

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

Базовый указатель, ebpпо соглашению, содержит адрес памяти дна, или основания, стека. Позиции всех значений в кадре стека могут быть рассчитаны с использованием адреса в базовом указателе в качестве ссылки. Это изображено на рисунке выше: %ebp + 4адрес памяти хранится в базовом указателе плюс 4, например.

3. Сгенерированный компилятором код

Но что я не понимаю, так это то, как переменные в стеке затем считываются приложением - если я объявляю и присваиваю x как целое число, скажем, x = 3, и хранилище резервируется в стеке, а затем сохраняется его значение 3 там, а затем в той же функции я объявляю и присваиваю y как, скажем, 4, а затем после этого я затем использую x в другом выражении (скажем, z = 5 + x), как программа может прочитать x, чтобы оценить z, когда это ниже у в стеке?

Давайте используем простой пример программы, написанной на C, чтобы увидеть, как это работает:

int main(void)
{
        int x = 3;
        int y = 4;
        int z = 5 + x;

        return 0;
}

Давайте рассмотрим текст на ассемблере, созданный GCC, для этого исходного текста на языке C (для ясности я немного его очистил):

main:
    pushl   %ebp              # save previous frame's base address on stack
    movl    %esp, %ebp        # use current address of stack pointer as new frame base address
    subl    $16, %esp         # allocate 16 bytes of space on stack for function data
    movl    $3, -12(%ebp)     # variable x at address %ebp - 12
    movl    $4, -8(%ebp)      # variable y at address %ebp - 8
    movl    -12(%ebp), %eax   # write x to register %eax
    addl    $5, %eax          # x + 5 = 9
    movl    %eax, -4(%ebp)    # write 9 to address %ebp - 4 - this is z
    movl    $0, %eax
    leave

То , что мы наблюдаем , что переменные х, у и г расположены по адресам %ebp - 12, %ebp -8и %ebp - 4, соответственно. Другими словами, расположение переменных в кадре стека main()рассчитывается с использованием адреса памяти, сохраненного в регистре ЦП %ebp.

4. Данные в памяти за указателем стека находятся вне области видимости

Я явно что-то упускаю. Дело в том, что расположение в стеке зависит только от времени жизни / области видимости переменной и что весь стек фактически доступен для программы все время? Если это так, означает ли это, что существует какой-то другой индекс, который содержит адреса только переменных в стеке, чтобы можно было получать значения? Но потом я подумал, что весь смысл стека в том, что значения хранятся в том же месте, что и адрес переменной?

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

Когда функции вызываются и возвращаются, указатель стека уменьшается и увеличивается. Данные, записанные в стек, не исчезают после того, как они выходят за пределы области видимости, но компилятор не генерирует инструкции, ссылающиеся на эти данные, поскольку у компилятора нет способа вычислить адреса этих данных с использованием %ebpили %esp.

5. Резюме

Код, который может быть непосредственно выполнен процессором, генерируется компилятором. Компилятор управляет стеком, кадрами стека для функций и регистрами ЦП. Одна из стратегий, используемая GCC для отслеживания расположения переменных в кадрах стека в коде, предназначенном для выполнения на архитектуре i386, заключается в использовании адреса памяти в базовом указателе кадра стека %ebpв качестве ссылки и записи значений переменных в расположения в кадрах стека по смещению по адресу в %ebp.

юлианский
источник
Мой, если я спрошу, откуда это изображение? Это выглядит подозрительно знакомым ... :-) Возможно, это было в прошлом учебнике.
Великая утка
1
nvmd. Я только что видел ссылку. Это было то, что я думал. +1 за то, что поделился этой книгой.
Великая утка
1
+1 за демонстрацию сборки gcc :)
flow2k
9

Существует два специальных регистра: ESP (указатель стека) и EBP (базовый указатель). Когда процедура вызывается, первые две операции обычно

push        ebp  
mov         ebp,esp 

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

Ассемблер переводит имена переменных в смещения EBP. Например, если у вас есть две локальные переменные x,y, и у вас есть что-то вроде

  x = 1;
  y = 2;
  return x + y;

тогда это может быть переведено в нечто вроде

   push        ebp  
   mov         ebp,esp
   mov  DWORD PTR [ ebp + 6],  1   ;x = 1
   mov  DWORD PTR [ ebp + 14], 2   ;y = 2
   mov  eax, [ ebp + 6 ]
   add  [ ebp + 14 ], eax          ; x + y 
   mov  eax, [ ebp + 14 ] 
   ...  

Значения смещения 6 и 14 вычисляются во время компиляции.

Это примерно как это работает. Обратитесь к книге компилятора для деталей.

fade2black
источник
14
Это специфично для Intel x86. В ARM используется регистр SP (R13), а также FP (R11). А на x86 отсутствие регистров означает, что агрессивные компиляторы не будут использовать EBP, поскольку он может быть получен из ESP. Это ясно в последнем примере, где вся относительная EBP-адресация может быть преобразована в ESP-относительную, без каких-либо других изменений.
MSalters
Разве вы не пропустили SUB на ESP, чтобы освободить место для x, y?
Хаген фон
@HagenvonEitzen, наверное. Я просто хотел выразить идею, как к переменным, расположенным в стеке, обращаться с помощью аппаратных регистров.
fade2black
Downvoters, комментарии, пожалуйста!
fade2black
8

Вы сбиты с толку, потому что к локальным переменным, хранящимся в стеке, нельзя обратиться с помощью правила доступа к стеку: First In Last Out или просто FILO .

Дело в том, что правило FILO применяется к последовательностям вызовов функций и стековым фреймам , а не к локальным переменным.

Что такое кадр стека?

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

То, как локальные переменные хранятся в кадре стека, не имеет ничего общего с FILO. (Даже порядок появления ваших локальных переменных в вашем исходном коде не гарантирует, что локальные переменные будут храниться в этом порядке.) Как вы правильно поняли в своем вопросе, «существует некоторый другой индекс, который содержит адреса только переменных в стеке, чтобы разрешить получение значений ". Адреса локальных переменных обычно рассчитываются с использованием базового адреса , такого как граничный адрес кадра стека, и значений смещения, характерных для каждой локальной переменной.

Итак, когда появляется это поведение FILO?

Что произойдет, если вы вызовете другую функцию? Функция вызываемого должна иметь свой собственный кадр стека, и именно этот кадр стека помещается в стек . То есть кадр стека функции вызывающей стороны размещается поверх стека кадра функции вызывающей стороны. И если эта вызываемая функция вызывает другую функцию, то ее кадр стека снова будет помещен на вершину стека.

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

Итак, из вашего вопроса:

Дело в том, что расположение в стеке зависит только от времени жизни / области видимости переменной и что весь стек фактически доступен для программы все время?

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

Тогда что отличает стек от кучи?

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

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

nglee
источник
4

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

Интуитивно понятно, что указатель стека spхранится во время выполнения (по фиксированному адресу или в регистре - это действительно имеет значение). Предположим, что каждое «нажатие» увеличивает указатель стека.

Во время компиляции компилятор определяет адрес каждой переменной как sp - Kгде Kконстанта, которая зависит только от области действия переменной (следовательно, может быть вычислена во время компиляции).

Обратите внимание, что мы используем слово «стек» в широком смысле. Доступ к этому стеку осуществляется не только с помощью операций push / pop / top, но и с помощью sp - K.

Например, рассмотрим этот псевдокод:

procedure f(int x, int y) {
  print(x,y);    // (1)
  if (...) {
    int z=x+y; // (2)
    print(x,y,z);  // (3)
  }
  print(x,y); // (4)
  return;
}

Когда процедура вызывается, аргументы x,yмогут быть переданы в стек. Для простоты предположим, что условием является то, что вызывающий абонент xсначала нажимает y.

Тогда компилятор в точке (1) может найти xв sp - 2и yв sp - 1.

В точке (2) новая переменная вводится в область видимости. Компилятор генерирует код, который суммирует x+y, то есть то, на что указывает sp - 2и sp - 1, и помещает результат суммы в стек.

В точке (3) zпечатается. Компилятор знает, что это последняя переменная в области видимости, поэтому на нее указывает sp - 1. Это больше не так y, как spизменилось. Тем не менее, для печати yкомпилятор знает, что он может найти его в этой области в sp - 2. Точно так же xсейчас находится на sp - 3.

В точке (4) мы выходим из прицела. zвыскочил, и yснова находится по адресу sp - 1, и xнаходится в sp - 2.

Когда мы вернемся, либо fлибо вызывающая сторона выскакивает x,yиз стека.

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

Обратите внимание, что в некоторых языках программирования вещи могут стать еще более сложными, если необходимо обрабатывать более сложные функции. Например, вложенные процедуры требуют очень тщательного анализа, поскольку Kтеперь приходится «пропускать» многие адреса возврата, особенно если вложенная процедура является рекурсивной. Закрывающие / лямбда-функции / анонимные функции также требуют некоторой осторожности для обработки «захваченных» переменных. Тем не менее, приведенный выше пример должен проиллюстрировать основную идею.

чи
источник
3

Самая простая идея - думать о переменных как об именах фиксированных адресов в памяти. Действительно, некоторые ассемблеры отображают машинный код таким образом («сохранить значение 5 в адресе i», где iимя переменной).

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

Питер - Восстановить Монику
источник
2

Элементы данных, которые могут помещаться в стек, помещаются в стек - да! Это премиум пространство. Кроме того, после того, как мы поместили xв стек, а затем yв стек, в идеале мы не можем получить доступ, xпока yон не будет. Нам нужно, чтобы yполучить доступ к x. Вы правильно поняли.

Стек не переменных, а frames

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

Допустим, наши элементы данных объединены в два стековых фрейма frame-xи frame-y. Мы толкали их один за другим. Теперь, пока вы находитесь frame-yна вершине frame-x, вы не можете идеально получить доступ к любому элементу данных внутри frame-x. Только frame-yвидно. НО, учитывая, что frame-yэто видно, вы можете получить доступ ко всем элементам данных, связанных в нем. Весь кадр виден, обнажая все элементы данных, содержащиеся в нем.

Конец ответа. Больше (разглагольствовать) на этих кадрах

Во время компиляции составляется список всех функций в программе. Затем для каждой функции составляется список стековых элементов данных. Затем для каждой функции stack-frame-templateпроизводится. Этот шаблон представляет собой структуру данных, которая содержит все выбранные переменные, пространство для входных данных функции, выходных данных и т. Д. Теперь во время выполнения, всякий раз, когда вызывается функция, ее копия templateпомещается в стек вместе со всеми входными и промежуточными переменными. , Когда эта функция вызывает какую-то другую функцию, то свежая копия этой функции stack-frameпомещается в стек. Теперь, пока эта функция работает, элементы данных этой функции сохраняются. Как только эта функция заканчивается, ее стековый фрейм выталкивается. В настоящее времяэтот стековый фрейм активен, и эта функция может получить доступ ко всем его переменным.

Обратите внимание, что структура и состав стекового фрейма варьируется от языка программирования к языку программирования. Даже внутри языка могут быть тонкие различия в разных реализациях.


Спасибо за рассмотрение CS. Я сейчас программист, беру уроки игры на фортепиано :)

любознательный
источник