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

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

#include <iostream>

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

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

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

вывод 58

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

19 ответов


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

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

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

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

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

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

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

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 предполагает, что вы знаете, что делаете, и технически вы не нарушили область здесь (нет ссылки на за пределами 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++, вы can доступ к любому адресу, но это не значит, что вы должны. Адрес вы обращаетесь уже не действует. Это работает потому что ничего другого яичница памяти после Foo вернулся, но она может рухнуть при многих обстоятельствах. Попробуйте проанализировать свою программу с помощью отчет, или даже просто компиляция оптимизирована, и смотрите...


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

unsigned int q = 123456;

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

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

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


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

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

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


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

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


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

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

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
}

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


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

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

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", но ваши результаты могут отличаться (действительно!). Ваш указатель избивает другие, несвязанные локальные переменные.


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


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

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

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


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

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

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

warning: адрес локальной переменной ' a ' returned

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


может, потому что 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 и a уничтожается после возврата его адреса. И вы можете получить доступ к измененному значению через этот возвращаемый адрес.

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

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


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