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

953

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

#include <iostream>

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

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

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

Выходной сигнал составил 58

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

  • 14
    это даже не скомпилируется как есть; если вы исправите несформирующийся бизнес, gcc по-прежнему будет предупреждать address of local variable 'a' returned ; valgrind показывает Invalid write of size 4 [...] Address 0xbefd7114 is just below the stack ptr
  • 3
    На некоторых платформах / компиляторах (особенно старых компиляторах для DOS) вы можете даже писать через указатель NULL, и все будет выглядеть нормально, пока вы не перезапишите что-то важное (например, выполняемый код). :)
Показать ещё 22 комментария
Теги:
memory-management
local-variables
dangling-pointer

20 ответов

4849
Лучший ответ

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

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

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

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

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

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

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

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

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

ОБНОВИТЬ

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • 6
    Если бы отель собирался заменить футбольным стадионом, разве вы не заметили бы отсутствие людей? Или чудовищная армия гигантских бульдозеров снаружи?
  • 54
    @muntoo: К сожалению, операционная система не выдает предупреждающую сирену перед тем, как отключить или освободить страницу виртуальной памяти. Если вы возитесь с этой памятью, когда у вас ее больше нет, то операционная система вполне может отменить весь процесс, когда вы касаетесь освобожденной страницы. Boom!
Показать ещё 46 комментариев
250

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

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

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

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

132

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

  • 24
    Очевидно, мне очень нравится ответ Эрика, и я не просто вопиющий, но этот ответ обладает достоинством краткости и правильности.
  • 1
    Чувак, это было самое долгое ожидание комментария с тех пор: «Что есть истина?» - сказал шутя Пилат. Может быть, это была Библия Гедеона в ящике отеля. И что с ними случилось? Обратите внимание, что их больше нет, по крайней мере, в Лондоне. Я предполагаю, что в соответствии с законодательством о равноправии вам понадобится библиотека религиозных трактатов.
Показать ещё 2 комментария
76

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

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

#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(). Если вы удалите исполняемый файл, вы увидите его четко.

  • 2
    Простой, но отличный пример, чтобы понять основную теорию стека. Просто одно тестовое дополнение, объявляющее "int a = 5;" в foo () как "static int a = 5;" может использоваться для понимания объема и времени жизни статической переменной.
  • 12
    -1 "для вероятно будет 7 ". Компилятор может зарегистрировать в boo. Это может удалить его, потому что это не нужно. Есть большая вероятность, что * p не будет 5 , но это не значит, что есть какая-то особенно веская причина, по которой это, вероятно, будет 7 .
Показать ещё 3 комментария
61

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

unsigned int q = 123456;

*(double*)(q) = 1.2;

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

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

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

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

  • 6
    Я просто собираюсь написать программу, которая будет продолжать эту программу, чтобы 4) I win the lottery
59

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

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

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

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

Но я согласен с Mark B, что полученное поведение undefined.

  • 0
    Это моя ставка. Оптимизатор сбросил вызов функции.
  • 9
    Это не обязательно. Поскольку после foo () новая функция не вызывается, кадр локального стека функций просто еще не перезаписан. Добавьте еще один вызов функции после foo (), и 5 изменится ...
Показать ещё 1 комментарий
20

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

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

  • 0
    Я вспоминаю старую копию Turbo C Programming для IBM , с которой я когда-то играл, когда детально описывалось, как напрямую манипулировать графической памятью и расположением видеопамяти IBM в текстовом режиме. Конечно, система, в которой выполнялся код, четко определяла, что означает запись по этим адресам, поэтому, пока вы не беспокоитесь о переносимости на другие системы, все будет хорошо. IIRC, указатели на аннулирование были общей темой в этой книге.
  • 0
    @Michael Kjörling: Конечно! Людям нравится иногда делать грязную работу;)
16

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

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

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
}
  • 0
    Я не согласен: перед cout есть проблема. *a указывает на нераспределенную (освобожденную) память. Даже если вы не разыщите его, это все равно опасно (и, вероятно, фальшиво).
  • 0
    @ereOn: Я пояснил больше, что я имел в виду под проблемой, но нет, это не опасно с точки зрения правильного кода на C ++. Но это опасно с точки зрения вероятности того, что пользователь допустил ошибку и сделает что-то плохое. Может быть, например, вы пытаетесь увидеть, как растет стек, и вы заботитесь только о значении адреса и никогда не разыменовываете его.
14

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

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

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

14

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

14

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

13

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

warning: адрес локальной переменной 'a', возвращенный

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

13

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

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

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

12

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

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

  • 3
    «распечатать значение блока памяти с адресом, который раньше был занят» не совсем верно. Это звучит так, как будто его код имеет какое-то четкое значение, а это не так. Вы правы, что, вероятно, именно так большинство компиляторов будет реализовывать это.
  • 0
    @BrennanVincent: В то время как хранилище было занято , указатель держал адрес . a a Хотя стандарт не требует, чтобы реализации определяли поведение адресов после истечения времени жизни их цели, он также признает, что на некоторых платформах UB обрабатывается документированным образом, характерным для среды. Хотя адрес локальной переменной, как правило, не будет иметь большого значения после того, как он вышел из области видимости, некоторые другие виды адресов могут все еще иметь смысл после времени жизни их соответствующих целей.
Показать ещё 1 комментарий
11

Он может, потому что a - это переменная, временно назначаемая на время жизни ее области (foo function). После возврата из foo память свободна и может быть перезаписана.

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

9

Вещи с правильным (?) выходом консоли могут сильно измениться, если вы используете:: 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); 
}
2

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

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

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

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

0

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

-6

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

Если вы вставляете вызов в свою операционную систему 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 С++, который определяет это поведение, но вполне может быть, что только стандарт promises, что ваши данные будут уничтожены после его выхода из сферы действия, но это не так Я ничего не скажу об этом, прежде чем любая другая инструкция будет выполнена. Если это так, то это вполне нормально, и люди просто недопонимают стандарт.

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

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

Ещё вопросы

Сообщество Overcoder
Наверх
Меню