Требуется обновление браузера.

Си: стек или куча, встраиваемая функция, размеры массива


Просмотров: 2589
02 ноября 2019 года

Проблематика


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

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

Вопросы


Описанные далее разбирательства помогут ответить сразу на несколько вопросов:

  • Где лучше создать тот или иной объект?
  • Почему нельзя увеличить размер массива, созданного внутри функции?
  • Что такое «встраиваемая» функция?

К истокам


asm_2008_small.jpg
Лабораторные работы по языку ассемблера (приблизительно, 2008 г.). Большинство одногруппников недолюбливало эти занятия - зря: программировать "железки" было веселее, чем писать в машинных кодах.
Для понимания происходящего, необходимо опуститься на уровни ниже - до уровня архитектуры набора команд (instruction set architecture). Язык ассемблера (то есть язык, являющийся входным для «ассемблера» - программы, обеспечивающей трансляцию в байт-код) - вещь не самая низкоуровневая: по сути, это понятная человеку запись байт-кода, использующая мнемоники (плюс директивы и макрокоманды). Записанное затем транслируется (относительно близко к тексту) в машинный байт-код, который разворачивается в микропрограммы (в соответствии с архитектурой конкретной ЭВМ), выполняемые непосредственно «на железе».

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

  • Решение проблемы выглядит как натуральная головоломка из сочетания вышивания бисером и жонглирования бензопилами.
  • Адекватный объём комментариев к программе существенно превышает объём инструкций самой программы.

Рассмотрим как программа на Си выглядит с точки зрения языка ассемблера: это покажет логичность и оправданность решений.

Примечание


Так как это узкоспециализированная заметка, я не ставил перед собой целью максимально полно описать тонкости низкоуровневого программирования, и потому в тексте не будет занудства про реальный и защищённый режимы работы процессора, постоянного напоминания про x86, описания вызовов из других сегментов памяти, страданий по соглашению вызова (calling convention), восторгов от Address Space Layout Randomization, Stack-Smashing Protection, Data Execution Prevention и тому подобных технологий - вместо этого я оставлю список «литературы для интересующихся» в конце заметки.

Функции


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

Стек


metro.jpg
Поездка в час пик - отличная возможность рассмотреть на практике некоторые алгоритмические примитивы
Как известно, стек можно представить как контейнер с принципом загрузки/выгрузки LIFO - последний вошедший выходит первым. В реальной жизни, с подобной организацией хранения я столкнулся когда добирался на метро в час пик лет десять назад: последний вошедший в вагон (Last In - LI) неминуемо покинет его на следующей остановке первым (First Out - FO).

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

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

Практика


С учетом приведённых ранее примечаний, вышесказанную теорию лучше продемонстрировать на практике.
Отмечу:

На заметку
приведённый далее код является трюкачеством и результаты его выполнения могут отличаться для различных конфигураций ПО - поэтому его исполнение не рекомендуется (запускаете на свой страх и риск)


Автоматические объекты располагаются в стеке друг за другом.
123456789
void f(){
    int a = 1;
    int b = 2;
    int c = 3;
    cout<<a<<" ("<<(&a)<<") "<<b<<" ("<<(&b)<<") "<<c<<" ("<<(&c)<<")"<<endl;
    *((&b)-1) = 100;
    *((&b)+1) = 200;
    cout<<a<<' '<<b<<' '<<c<<endl;
}

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

GCC
Переменнаяabc
Исходное значение123
Адрес при тестировании0x22ff340x22ff380x22ff3c
Новое значение1002200

Альтернативный компилятор
Переменнаяabc
Исходное значение123
Адрес при тестировании0012FF280012FF240012FF20
Новое значение2002100

Как видно, автоматические переменные создаются в памяти рядом (порядок следования зависит от компилятора).

Разрушение стекового кадра.
12345678
void print(){
    cout<<"enter"<<endl;
}

void g(){
	int x;
	(&x)[2] = int(&print);
}

Вызов функции
g
приведёт и к вызову функции
print
. К счастью, работает только на старых версиях компиляторов (завершается падением процесса).

Просто представьте: Вы присваиваете переменной значение (на самом деле не ей, но это если Вы увидели ошибку) и внезапно, на выходе из функции, оказываетесь в теле другой функции.

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

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

Выводы


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

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

Увеличение размера массива, созданного на стеке невозможно, так как он вплотную соседствует с другими переменными или стековым кадром.

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

Литература для интересующихся


Список без нумерации, так как жёсткой связи с текстом заметки нет. Курсивом приводится тематика заметки, разобранная в книге более детально.
  • «Переполнение буфера в стеке». (в двух частях) журнал "Хакер" №50, №51 за 2003 г. Обсуждение эксплуатации уязвимости "переполнение на стеке" с небольшим обзором низкоуровневой составляющей. Понимание проблемы для написания безопасного (не подверженного уязвимости) кода.
  • Тобиас Клейн Дневник охотника за ошибками. Путешествие через джунгли проблем безопасности программного обеспечения. Пер. с англ. Киселев А. Н. - М.: ДМК Пресс, 2013. - 240с.: ил. Более детально об уязвимостях и методах противодействия.
  • Изучаем Ассемблер / А. Б. Крупник. - СПб.: Питер, 2004. - 249 с.: ил. Кратко и доступно о языке ассемблера x86.
  • Таненбаум Э., Остин Т. Архитекутра компьютера. 6-е изд. - СПб.: Питер, 2014. - 816 с.: ил. Архитектура ЭВМ и обзор языка ассемблера x86.
  • Лафоре Р. Объектно-ориентированное программирование в C++. Классика Computer Science. 4-е изд. - СПб.: Питер, 2015. - 928 с.: ил. Лучший курс по Си++ для студента, что я видел.
  • Бьерн Страуструп Язык программирования C++. Специальное издание. Пер. с англ. - М.: Издательство Бином, 2011 г. - 1136 с.: ил. Настольная книга.

Запись опубликована в категориях:

Scrupulosus Алгоритмы и аспекты  
 

Внешние источники:

  1. Си++: указатель или ссылка

Комментарии

Инкогнито
  Загружаем captcha