В стеке вызовов не говорится "откуда вы пришли", но "где вы собираетесь дальше"?

В предыдущем вопросе (Получить иерархию вызовов объектов), я получил этот интересный ответ:

Стол вызовов не существует, чтобы сообщить вам, откуда вы пришли. Он должен сказать вам, куда вы собираетесь идти дальше.

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

  • В вызов кода:

    • сохранить адрес возврата (в стеке вызовов)
    • сохранять состояния регистров (в стеке вызовов)
    • записать параметры, которые будут переданы функции (в стеке вызовов или в регистрах)
    • перейти к целевой функции

  • В называется целевой код:

    • Получить хранимые переменные (при необходимости)

  • Процесс возврата. Отмените то, что мы сделали, когда вызвали функцию, т.е. разворачиваем/выкладываем стек вызовов:

    • удалить локальные переменные из стека вызовов
    • удалить функциональные переменные из стека вызовов
    • восстановить состояние регистров (тот, который мы сохранили ранее)
    • перейти на обратный адрес (тот, который мы сохранили ранее)

Вопрос:

Как это можно рассматривать как нечто, что "говорит вам, куда вы идете дальше", а не "рассказывать, откуда вы пришли"?

Есть ли что-то в среде выполнения С# JIT или С#, которая заставляет этот стек вызовов работать по-другому?

Спасибо за любые указатели на документацию об этом описании стека вызовов — существует множество документации о том, как работает традиционный стек вызовов.

Ответ 1

Вы сами это объяснили. "Обратный адрес" по определению сообщает вам, куда вы направляетесь дальше.

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

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

Кроме того: стек представляет собой лишь деталь реализации концепций продолжения и активации. Нет требования, чтобы обе концепции были реализованы одним и тем же стеклом; может быть два стека, один для активации (локальные переменные) и один для продолжения (обратные адреса). Такие архитектуры, очевидно, намного более устойчивы к атакам сокета, вызванным вредоносными программами, поскольку обратный адрес нигде рядом с данными.

Более интересно, что нет никакого требования, чтобы был какой-то стек! Мы используем стеки вызовов для реализации продолжения, потому что они удобны для такого рода программирования, которые мы обычно выполняем: синхронные вызовы на основе подпрограмм. Мы могли бы выбрать реализацию С# в качестве языка "Continuation Passing Style", где продолжение фактически reified как объект в куче, а не как куча байтов, загружаемых в системный стек в миллион байт. Затем этот объект передается от метода к методу, ни один из которого не использует какой-либо стек. (Активации затем обновляются, разбивая каждый метод на возможно много делегатов, каждый из которых связан с объектом активации.)

В продолжении стиля прохождения просто нет стека, и никоим образом не нужно рассказывать, откуда вы пришли; объект продолжения не имеет такой информации. Он знает только, куда вы идете дальше.

Это может показаться высокоэффективным теоретическим mumbo jumbo, но мы по существу делаем С# и VB в продолжение передачи языков стиля в следующей версии; приходящая "асинхронная" функция - это просто продолжение стиля прохождения в тонкой маскировке. В следующей версии, если вы используете функцию async, вы по существу отказываетесь от программирования на основе стека; не будет способа взглянуть на стек вызовов и узнать, как вы сюда попали, потому что стек часто будет пустым.

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

Введение в CPS с примерами в JScript:

http://blogs.msdn.com/b/ericlippert/archive/2005/08/08/recursion-part-four-continuation-passing-style.aspx

http://blogs.msdn.com/b/ericlippert/archive/2005/08/11/recursion-part-five-more-on-cps.aspx

http://blogs.msdn.com/b/ericlippert/archive/2005/08/15/recursion-part-six-making-cps-work.aspx

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

http://blogs.msdn.com/b/ericlippert/archive/tags/async/

Языки, поддерживающие стиль продолжения передачи, часто имеют примитивный поток управления магии, называемый "вызов с текущим продолжением", или "call/cc" для краткости. В этом вопросе stackoverflow я объясняю тривиальную разницу между "ожиданием" и "вызовом/cc":

Как можно реализовать новую функцию async в С# 5.0 с помощью call/cc?

Чтобы получить официальную "документацию" (кучу белой бумаги) и предварительный выпуск новой функции "асинхронного ожидания" на С# и VB, а также форум для поддержки Q & A, перейдите к:

http://msdn.com/vstudio/async

Ответ 2

Рассмотрим следующий код:

void Main()
{
    // do something
    A();
    // do something else
}

void A()
{
    // do some processing
    B();
}

void B()
{
}

Здесь последнее, что делает функция A, - это вызов B. A сразу же возвращается после этого. Умный оптимизатор может оптимизировать вызов B и заменить его простым переходом на начальный адрес B. (Не уверен, делают ли текущие компиляторы С# такую ​​оптимизацию, но почти все компиляторы С++). Зачем это работать? Поскольку в стеке есть адрес вызывающего A, поэтому, когда B заканчивается, он возвращается не к A, а непосредственно к A вызывающему.

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

Без оптимизации внутри B стек вызовов (это я опускаю локальные переменные и другие вещи для ясности):

----------------------------------------
|address of the code calling A         |
----------------------------------------
|address of the return instruction in A|
----------------------------------------

Таким образом, возврат из B возвращается к A и сразу же закрывает `A.

При оптимизации стек вызовов равен

----------------------------------------
|address of the code calling A         |
----------------------------------------

Итак B возвращается непосредственно к Main.

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

Ответ 3

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

Ответ 4

Это больше, чем вы думаете.

В C вполне возможно, что программа переписывает стек вызовов. В самом деле, эта техника является самой основой стиля эксплойта, известного как обратного ориентированного программирования.

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

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

Ответ 5

Я думаю, он пытается сказать, что он сообщает метод Called, куда идти дальше.

  • Метод A вызывает метод B.
  • Метод B завершает, куда он идет дальше?

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

Итак, метод B знает, куда идти после завершения. Метод B, на самом деле неважно, откуда оно взялось.