Можно ли получить доступ к памяти локальной переменной вне ее области?

У меня есть следующий код.

#include <iostream>

int * foo()
{
    int a = 5;
    return &a;
}

int main()
{
    int* p = foo();
    std::cout << *p;
    *p = 8;
    std::cout << *p;
}

И код просто выполняется без исключений времени выполнения!

Выход был 58

Как это может быть? Разве память локальной переменной не доступна вне ее функции?

23 ответа

Решение

Как это может быть? Разве память локальной переменной не доступна вне ее функции?

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

Через неделю вы возвращаетесь в отель, не регистрируетесь, крадетесь в свою старую комнату с украденным ключом и смотрите в ящик. Ваша книга все еще там. Поразительно!

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

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

Администрация отеля не обязана удалять вашу книгу. Вы не заключили с ними контракт, в котором говорилось, что если вы оставите вещи позади, они уничтожат их для вас. Если вы незаконно войдете в свою комнату с украденным ключом, чтобы вернуть его, сотрудники службы безопасности отеля не обязаны отлавливать вас подкрадываясь. Вы не заключили с ними контракт, в котором говорилось "если я попытаюсь пробраться обратно в мой номер позже, вы обязаны остановить меня. " Скорее, вы подписали с ними контракт, в котором говорилось: "Я обещаю не возвращаться в мою комнату позже", контракт, который вы нарушили.

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

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

C++ не является безопасным языком. Это позволит вам весело нарушать правила системы. Если вы попытаетесь сделать что-то незаконное и глупое, например, вернуться в комнату, в которой вам не разрешено находиться, и порыться в столе, которого там больше нет, C++ не остановит вас. Более безопасные языки, чем C++, решают эту проблему, ограничивая ваши возможности - например, благодаря более строгому контролю над ключами.

ОБНОВИТЬ

Боже мой, этот ответ привлекает много внимания. (Я не уверен почему - я счел это просто "забавной" небольшой аналогией, но неважно.)

Я подумал, что было бы уместно обновить это немного с помощью нескольких технических мыслей.

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

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

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

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

По этой причине локальные переменные обычно генерируются как хранилище в структуре данных "стека", потому что у стека есть свойство, которое первое, что было помещено в него, будет последним, которое выталкивается.

Это похоже на то, что отель решает только сдавать комнаты в аренду последовательно, и вы не можете проверить, пока все с номером комнаты выше, чем вы проверили.

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

Мы используем стеки для временных магазинов, потому что они действительно дешевые и простые. Реализация C++ не обязана использовать стек для хранения локальных данных; это может использовать кучу. Это не так, потому что это замедлит работу программы.

Реализация C++ не обязана оставлять нетронутым мусор, оставленный в стеке, чтобы впоследствии вы могли незаконно вернуться к нему позже; для компилятора совершенно законно сгенерировать код, который обнуляет все в "комнате", которую вы только что освободили. Это не потому, что опять же, это будет дорого.

Реализация C++ не требуется, чтобы гарантировать, что при логическом сжатии стека адреса, которые раньше были действительными, по-прежнему отображаются в памяти. Реализация позволяет сообщить операционной системе: "Мы закончили с использованием этой страницы стека. Пока я не скажу иначе, выдайте исключение, которое уничтожит процесс, если кто-нибудь коснется ранее действующей страницы стека". Опять же, реализации на самом деле не делают этого, потому что это медленно и не нужно.

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

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

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

Для дальнейшего чтения:

  • Что если бы C# позволял возвращать ссылки? По совпадению это - тема сегодняшнего сообщения в блоге:

    http://blogs.msdn.com/b/ericlippert/archive/2011/06/23/ref-returns-and-ref-locals.aspx

  • Почему мы используем стеки для управления памятью? Типы значений в C# всегда хранятся в стеке? Как работает виртуальная память? И еще много тем о том, как работает менеджер памяти C#. Многие из этих статей также актуальны для программистов на C++:

    https://blogs.msdn.microsoft.com/ericlippert/tag/memory-management/

То, что вы делаете здесь, это просто чтение и запись в память, которая раньше была адресом a, Теперь, когда вы вне fooэто просто указатель на какую-то случайную область памяти. Просто так получилось, что в вашем примере эта область памяти существует, и ничто другое не использует ее в данный момент. Вы ничего не нарушаете, продолжая использовать его, и ничто другое еще не перезаписало это. Следовательно 5 все еще там. В реальной программе эта память использовалась бы почти сразу, и вы могли бы что-то сломать, делая это (хотя симптомы могут появиться не намного позже!)

Когда вы вернетесь из fooвы сообщаете ОС, что больше не используете эту память, и ее можно переназначить на что-то другое. Если вам повезло, и он никогда не будет переназначен, и ОС не поймает вас на том, что вы снова его используете, тогда вам сойдет с рук ложь. Скорее всего, вы в конечном итоге переписываете все остальное, что заканчивается этим адресом.

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

Короче говоря, это обычно не работает, но иногда будет случайно.

Потому что место для хранения еще не растоптано. Не рассчитывай на такое поведение.

Небольшое дополнение ко всем ответам:

если вы делаете что-то подобное:

#include<stdio.h>
#include <stdlib.h>
int * foo(){
    int a = 5;
    return &a;
}
void boo(){
    int a = 7;

}
int main(){
    int * p = foo();
    boo();
    printf("%d\n",*p);
}

выход, вероятно, будет: 7

Это связано с тем, что после возврата из foo() стек освобождается, а затем повторно используется boo(). Если вы разберете исполняемый файл, вы увидите это ясно.

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

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

unsigned int q = 123456;

*(double*)(q) = 1.2;

Здесь я просто трактую 123456 как адрес двойника и пишу в него. Может произойти любое количество вещей:

  1. q на самом деле может быть действительным адресом двойного, например, double p; q = &p;,
  2. q может указывать где-то внутри выделенной памяти, и я просто перезаписываю туда 8 байтов.
  3. q указывает вне выделенной памяти, и диспетчер памяти операционной системы посылает сигнал сбоя сегментации в мою программу, заставляя время выполнения прекратить его.
  4. Вы выиграли в лотерею.

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

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

Скомпилировали ли вы программу с включенным оптимизатором?

Функция foo() довольно проста и может быть встроена / заменена в результирующем коде.

Но я согласен с Марком Б в том, что получающееся поведение не определено.

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

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

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

предупреждение: адрес локальной переменной 'a' возвращен

Это сила C++. Вы должны заботиться о памяти. С -Werror флаг, это предупреждение стало ошибкой, и теперь вы должны отладить его.

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

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

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

int *a()
{
   int x = 5;
   return &x;
}

void b( int *c )
{
   int y = 29;
   *c = 123;
   cout << "y=" << y << endl;
}

int main()
{
   b( a() );
   return 0;
}

Это выдает "y=123", но ваши результаты могут отличаться (действительно!). Ваш указатель забивает другие, не связанные локальные переменные.

Вы просто возвращаете адрес памяти, это разрешено, но, вероятно, ошибка.

Да, если вы попытаетесь разыменовать этот адрес памяти, у вас будет неопределенное поведение.

int * ref () {

 int tmp = 100;
 return &tmp;
}

int main () {

 int * a = ref();
 //Up until this point there is defined results
 //You can even print the address returned
 // but yes probably a bug

 cout << *a << endl;//Undefined results
}

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

Вы фактически вызвали неопределенное поведение.

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

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

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

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

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

Тем не менее, это неопределенное поведение, и вы не должны полагаться на него, чтобы работать!

Вещи с правильным (?) Выводом на консоль могут сильно измениться, если вы используете::printf, но не cout. Вы можете поиграться с отладчиком в следующем коде (протестировано на x86, 32-битной, MSVisual Studio):

char* foo() 
{
  char buf[10];
  ::strcpy(buf, "TEST”);
  return buf;
}

int main() 
{
  char* s = foo();    //place breakpoint & check 's' varialbe here
  ::printf("%s\n", s); 
}

Это "грязный" способ использования адресов памяти. Когда вы возвращаете адрес (указатель), вы не знаете, относится ли он к локальной области действия функции. Это просто адрес. Теперь, когда вы вызвали функцию 'foo', этот адрес (ячейка памяти) для 'a' уже был размещен там в (по крайней мере, пока безопасно) адресуемой памяти вашего приложения (процесса). После того, как возвращена функция 'foo', адрес 'a' может считаться 'грязным', но он там, не очищен и не нарушен / не изменен выражениями в другой части программы (по крайней мере, в данном конкретном случае). Компилятор A C/C++ не останавливает вас от такого "грязного" доступа (хотя может вас предупредить, если вам не все равно). Вы можете безопасно использовать (обновлять) любую область памяти, которая находится в сегменте данных экземпляра вашей программы (процесса), если вы не защищаете адрес каким-либо способом.

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

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

Позвольте мне привести пример из реального мира:

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

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

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

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

Вместо этого рассмотрите этот пример и протестируйте его:

int * foo()
{
   int *x = new int;
   *x = 5;
   return x;
}

int main()
{
    int* p = foo();
    std::cout << *p << "\n"; //better to put a new-line in the output, IMO
    *p = 8;
    std::cout << *p;
    delete p;
    return 0;
}

В отличие от вашего примера, с этим примером вы:

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

Это зависит от языка. В C & C ++ / Cpp , ДА , вы технически могли бы, потому что он имеет очень слабые проверки того, действительно ли какой-либо данный указатель указывает на действительное или нет. Компилятор сообщит об ошибке, если вы попытаетесь получить доступ к самой переменной, когда она находится вне области видимости, но он, скорее всего, не будет достаточно умен, чтобы знать, намеренно ли вы копируете указатель на местоположение этой переменной в какую-либо другую переменную, которая все еще будет в объем позже.

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

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

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

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

Это определенно проблема времени! Объект, который p указатель указывает на "запланировано", чтобы быть уничтоженным, если если выходит из foo Сфера. Эта операция, однако, происходит не сразу, а через несколько циклов ЦП. Я не знаю, является ли это неопределенным поведением, или C++ фактически выполняет некоторые предварительные операции в фоновом режиме.

Если вы вставите вызов в вашей операционной системе sleep функция между вызовом foo и cout операторы, заставляющие программу подождать секунду или около того перед разыменованием указателя, вы заметите, что данные исчезли к тому времени, как вы захотите их прочитать! Посмотрите на мой пример:

#include <iostream>
#include <unistd.h>
using namespace std;

class myClass {
public:
    myClass() : i{5} {
        cout << "myClass ctor" << endl;
    }

    ~myClass() {
        cout << "myClass dtor" << endl;
    }

    int i;
};

myClass* foo() {
    myClass a;
    return &a;
}

int main() {

    bool doSleep{false};

    auto p = foo();

    if (doSleep) sleep(1);

    cout << p->i << endl;
    p->i = 8;
    cout << p->i << endl;
}

(Обратите внимание, что я использовал sleep функция от unistd.h , который присутствует только в Unix-подобных системах, поэтому вам придется заменить его на Sleep(1000) а также Windows.h если вы на Windows.)

Я заменил твой int с классом, так что я могу точно знать, когда вызывается деструктор.

Вывод этого кода следующий:

myClass ctor
myClass dtor
5
8

Однако, если вы измените doSleep в true:

myClass ctor
myClass dtor
0
8

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

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

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

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

Какой бы ни была причина этого, ясно, что данные уничтожены, но не сразу.

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