### Память процесса Стандарт C не определяет, как должна быть организована память для программы, но типичная память процесс, может выглядеть так ![Структура данных буферного кэша](https://whoisdeveloper.ru/static/img/pointers1.png) В самом низу, есть код, который запускает процесс. Код - это данные, инструкции, которым должен следовать процессор, и это часть памяти процесса. Кроме того, данные, которые существуют на протяжении всего срока службы процесса. Когда вы объявляете глобальные переменные, они живут до тех пор, пока выполняется программа, и именно здесь они находятся в памяти. Некоторые из этих данных будут доступны только для чтения. В программе определены константы, которые нельзя изменить. Строковые литералы, обычно они неизменяемы, они хранятся в памяти, доступной только для чтения, и ваша программа может выйти из строя, если вы попытаетесь их изменить. Глобальные переменные, которые вы определяете сами, если они не объявлены const, являются изменяемыми, и вы можете записывать в них. Кроме того есть память, которую программа выделяет (и освобождает) во время выполнения. Эту область памяти называют кучей. Когда процессу требуется больше памяти, куча растет вверх. Когда он избавляется от памяти, ситуация становится более сложной. Мы не удаляем блок посередине и не перемещаем все данные над ним вниз, это отнимет много времени, и мы не можем перемещать объекты, адрес которых у нас есть - тогда они бы переместились, и поэтому доступ к данным через адрес не будет работать. Однако не волнуйтесь, это то, с чем C справится за вас. Вверху у нас есть стек. Стек обрабатывает вызовы функций, и именно в нем находятся локальные переменные и аргументы функций. Обычно он растет вниз. Между стеком и кучей обычно существует барьер, часть памяти, доступ к которой вам запрещен. Это делается для того, чтобы предотвратить перерастание стека и кучи друг в друга. Память, которую видит процесс, редко является физической памятью, имеющейся у компьютера. Между запущенным процессом и физической памятью центральный процессор создает “виртуальную” память. Это пространство памяти, с которым работает программа, и каждый раз, когда ей требуется доступ к памяти, аппаратное обеспечение сопоставляет виртуальный адрес с физическим. Виртуальная память защищает процессы друг от друга и обеспечивает более простой адресный интерфейс для программ. Программам необходимо выделить память для стека и кучи, чтобы использовать ее, что обычно включает в себя запрос операционной системы на получение части памяти, которая, в свою очередь, настроит это виртуальное сопоставление с физическим. Это адреса, к которым программа может получить свободный доступ. Несмотря на то, что теоретически вы могли бы обратиться к полному адресному пространству, на практике аппаратное обеспечение вызовет прерывание, если вы получите доступ к данным за пределами памяти, выделенной программе операционной системой. Обычно это приводит к тому, что операционная система завершает процесс. ### Выделение памяти Большая часть управления памятью осуществляется автоматически в C. Когда вы объявляете переменную, компилятор генерирует код для выделения памяти для ее хранения. Для глобальных и статических переменных он выделяет память, которой хватит на все время выполнения программы. Для локальных переменных и аргументов функции, которые вы можете рассматривать как одно и то же, компилятор генерирует код, чтобы получить для них память при вызове функции. Эта память выделяется в стеке, и она существует только до тех пор, пока вызов функции, которая ее выделила. Существуют различные способы, которыми мы можем выделить несколько объектов одновременно. // Правильный спецификатор формата printf для size_t: %zu ``` #include <stdio.h> int main(int argc, char *argv[]) { int array[5]; printf("array = %p\n", (void *)array); for (int i = 0; i < 5; i++) { printf("&array[%d] = %p\n", i, (void *)&array[i]); } printf("sizeof array = %zu\n", sizeof(array)); printf("5 * sizeof(int) = %zu\n", 5 * sizeof(int)); return 0; } //array = 0x16fdff244 //&array[0] = 0x16fdff244 //&array[1] = 0x16fdff248 //&array[2] = 0x16fdff24c //&array[3] = 0x16fdff250 //&array[4] = 0x16fdff254 //sizeof array = 20 //5 * sizeof(int) = 20 ``` Расположение массива в памяти ![Структура данных буферного кэша](https://whoisdeveloper.ru/static/img/pointers2.png) Как со структурой, так и с объединением у вас есть единое выделение памяти при объявлении переменной, но структура обычно содержит более одного типа данных, как и объединение, хотя его цель - хранить разные типы в одной и той же ячейке памяти. Когда вы определяете переменную типа struct или union, вы гарантированно получаете фрагмент памяти соответствующего типа, который вы можете индексировать как последовательные адреса памяти. Для объединений вы получаете блок памяти, который достаточно велик, чтобы вместить самый большой элемент, и все элементы находятся по первому адресу в объединении. ``` #include <stdio.h> #define MAX(a, b) (((a) > (b)) ? (a) : (b)) #define MAX3(a, b, c) MAX((a), MAX((b), (c))) union data { char c; int i; double d; }; int main(int argc, char *argv[]) { union data data; printf("sizeof data = %zu\n", sizeof(data)); printf("max size of components = %zu\n", MAX3(sizeof(data.c), sizeof(data.i), sizeof(data.d))); printf("data at %p\n", (void *)&data); printf("data.c at %p\n", (void *)&data.c); printf("data.i at %p\n", (void *)&data.i); printf("data.d at %p\n", (void *)&data.d); return 0; } // sizeof data = 8 // max size of components = 8 // data at 0x16fdff248 // data.c at 0x16fdff248 // data.i at 0x16fdff248 // data.d at 0x16fdff248 ``` double — самый большой из трех типов (на компьютере), и union получает этот размер. Все элементы в объединении находятся по одному и тому же адресу, адресу самого объединения, но, конечно, вы не можете использовать их все одновременно. Это не является целью союзов. Вы можете обрабатывать блок памяти, который содержит объединение, как все три типа, но объединение содержит только один из типов в любой момент времени. Следовательно, они могут хранить свои данные в одном и том же блоке памяти и по одному и тому же адресу. Для структур вы получаете память для одновременного хранения всех компонентов, поэтому их размер, по крайней мере, достаточен для хранения всех из них. Элементы появляются один за другим в том порядке, в котором вы их определяете, и первый элемент находится по первому адресу структуры. Однако между элементами в структуре может быть неиспользуемая память. ``` #include <stdio.h> struct data { char c; int i; double d; }; int main(int argc, char *argv[]) { struct data data; printf("sizeof data = %zu\n", sizeof(data)); printf("size of components = %zu\n", sizeof(data.c) + sizeof(data.i) + sizeof(data.d)); printf("data at %p\n", (void *)&data); printf("data.c at %p\n", (void *)&data.c); printf("data.i at %p\n", (void *)&data.i); printf("data.d at %p\n", (void *)&data.d); return 0; } // sizeof data = 16 // size of components = 13 // data at 0x16fdff240 // data.c at 0x16fdff240 // data.i at 0x16fdff244 // data.d at 0x16fdff248 ``` Таким образом, данные переменной структуры занимают 16 адресов памяти, хотя данные в ней занимают всего 13 байт (или технически 13 sizeof(char)). Компоненты расположены по порядку; сначала у нас есть `c` по тому же адресу, что и структура, затем `i`, а затем `d`, но между `c` и `i` есть некоторые отступы.Если вы измените порядок элементов, вы получите их в другом порядке в памяти, но, вероятно, всегда будет какое-то заполнение. ![Структура данных буферного кэша](https://whoisdeveloper.ru/static/img/pointers3.png) Расположение структуры в памяти ### Выравнивание В абстрактной модели памяти адрес - это просто адрес, и мы можем поместить туда любой объект. Объект занимает определенный объем памяти, скажем, 4 байта для 32-разрядного целого числа, поэтому, если мы поместим целое число по адресу a, то этот адрес и следующие три байта - это то место, где находится целое число. Однако на реальном оборудовании память компьютера имеет большую структуру. Память - это не последовательность байтов, а скорее компьютерные слова некоторого заданного размера, например, 64 бита. Шина, которая передает данные из памяти в центральный процессор работает со словами определенного размера. Если вы просите получить целое число из памяти, и оно содержиткомпьютеру необходимо извлечь это единственное слово. Если вы помещаете целое число в место, которое охватывает более одного слова, компьютер должен извлечь оба слова, а затем выполнить некоторые битовые манипуляции, чтобы поместить его в регистр. И даже если вы запросите 32-разрядное целое число, которое находится внутри 64-разрядного целого числа, компьютеру может потребоваться больше работы, чтобы представить его как целое число в процессоре, если оно не находится на определенном смещении в его слове. ![Структура данных буферного кэша](https://whoisdeveloper.ru/static/img/pointers4.png) Расположения Структуры в памяти после перестановки Когда вы размещаете объекты в ячейках памяти, которые соответствуют тому, что аппаратное обеспечение может обрабатывать или просто считает удобным, мы говорим, что они выровнены, а выравнивание памяти может иметь решающее значение. Как правило, аппаратное обеспечение предпочитает, чтобы вы размещали объекты по адресам, кратным размеру объектов, поэтому, если у вас есть 4-байтовые целые числа, ваш компьютер может предпочесть, чтобы вы размещали их по адресам, кратным четырем. На каком-то оборудовании вы не разрешается размещать объекты по случайным адресам. Вы должны правильно выровнять их. На других платформах вы можете размещать объекты где угодно, но за это придется платить временем выполнения, если они не выровнены. А еще есть аппаратное обеспечение, которому все равно. Если ваш компилятор соответствует стандарту C11, вы можете использовать макрос `alignof()` для получения ограничений выравнивания типа. Он сообщит вам, какой адрес должен быть кратен, чтобы правильно выровнять тип. Оператор alignof возвращает выравнивание в байтах указанного типа в виде значения типа size_t. ``` #include <stdio.h> #include <stdalign.h> int main(int argc, char *argv[]) { printf("chars align at %zu and have size %zu\n", alignof(char), sizeof(char)); printf("ints align at %zu and have size %zu\n", alignof(int), sizeof(int)); printf("dobules align at %zu and have size %zu\n", alignof(double), sizeof(double)); return 0; } //chars align at 1 and have size 1 //ints align at 4 and have size 4 //dobules align at 8 and have size 8 ``` Чтобы правильно выровнять элементы внутри структуры, C, возможно, придется вставить отступы между ними. Размер структуры зависит от размера отдельных компонентов, их ограничений на выравнивание, а также от порядка, в котором они объявлены внутри структуры, поскольку память расположит их в этом порядке. В принципе, вы можете сделать структуру более эффективной с точки зрения памяти, переставив компоненты, но это того не стоит. Размер объектов и ограничения на их выравнивание варьируются от платформы к платформе, поэтому, если вы оптимизируете память для одной платформы, это не будет распространяться на все платформы. Итак, давайте представим, что мы помещаем две структуы в массив. ``` struct data array[2]; ``` ![Структура данных буферного кэша](https://whoisdeveloper.ru/static/img/pointers5.png) Структура с заполнением и без в массиве Тогда как насчет unoin? Например у нас есть массив из 9 элементов char и один double, размер которого кратен 8, тогда размер будет увеличен до 16 байт. ``` #include <stdio.h> union data { char c[9]; double d; }; #define MAX(a, b) (((a) > (b)) ? (a) : (b)) int main(void) { union data data; printf("sizeof data == %zu\n", sizeof data); printf("max size of components == %zu\n", MAX(sizeof data.c, sizeof data.d)); printf("data at %p\n", (void *)&data); printf("data.c at %p\n", (void *)&data.c); printf("data.d at %p\n", (void *)&data.d); return 0; } //sizeof data == 16 //max size of components == 9 //data at 0x16fdff248 //data.c at 0x16fdff248 //data.d at 0x16fdff248 ``` Как пример, случай, когда размер объекта и его ограничения на выравнивание различаются, мы также можем использовать char массив: ``` #include <stdio.h> #include <stdalign.h> struct data { int i; char c[9]; }; int main(void) { printf("sizeof components == %zu\n", sizeof(char[9]) + sizeof(int)); printf("sizeof(struct data) == %zu\n", sizeof(struct data)); printf("\n"); printf("alignof(struct data) == %zu\n", alignof(struct data)); printf("alignment of int == %zu\n", alignof(int)); printf("alignment of char[9] == %zu\n", alignof(char[9])); return 0; } //sizeof components == 13 //sizeof(struct data) == 16 // //alignof(struct data) == 4 //alignment of int == 4 //alignment of char[9] == 1 ``` Компоненты, `c` размером 9, поскольку `char` имеет размер 1, а `i` размером 4, заполняют в общей сложности 13 ячеек памяти. Структура больше, она имеет размер 16. Это связано с тем, что при выравнивании 4 для целых чисел мы не можем поместить экземпляр другого тира до 16 адресов после первого. Однако сама структура может выравниваться по адресам, кратным четырем, а не только по адресам, кратным ее размеру. Мы можем поместить структуру по таким адресам, потому что начальное целое число будет выровнено там (а буфер символов будет выровнен в любом месте). Если целое число выровняется там, оно также выровняется по первому адресу после структуры. Если первый адрес кратен 4, то следующие за ним адреса, кратные 16, также будут. ### Стеки вызовов и время жизни локальных переменных Когда мы объявляем переменную, C выделит для нее место в памяти. Когда вы используете переменные в выражениях, компилятор может решить, что он может исключить переменные, упростив выражения, в которых они используются, заменив переменные - значениями, которые у них есть. Или может оказаться, что он может генерировать более быстрый код, используя регистр в процессоре, а не ячейку памяти. Но в принципе, он выделяет одну или несколько ячеек памяти для хранения значения в переменной, и если вы запросите адрес с помощью амперсанда, вы его получите. Но не все переменные остаются неизменными навсегда. Глобальные переменные существуют до тех пор, пока выполняется ваша программа, да, но локальные переменные и параметры функций, которые вы пишете, этого не делают. Они живут только до тех пор, пока активен вызов функции. TODO - [прочитать](https://habr.com/ru/company/smart_soft/blog/234239/) Что значит "активен"? Концептуальная модель вызовов функций, которая вам нужна - это стек вызовов. Мы видели его в общей модели памяти процессов, но мы не обсуждали, что он делает. Си может свободно реализовывать вызовы функций так, как ему заблагорассудится, но современные компьютеры используют стеки. Идея заключается в следующем: когда вы вызываете функцию, Cи помещает так называемый "Стековый кадр" в стек, структуру данных "FIFO". Этот стековый кадр содержит пространство для всех параметров и локальных переменных, а также некоторую служебную информацию о том, как вернуться из вызова функции. Если вы вызываете другую функцию внутри первой функции, этот вызов функции делает то же самое — он помещает стековый кадр в стек с местом для его локальных переменных и его учета. Когда вы возвращаетесь из функции, C удаляет кадр из стека и использует служебную ифнормацию, чтобы вернуть программу в состояние, в котором она находилась до вызова функции. Когда кадр исчезает, исчезают и локальные переменные. Именно так это часто реализуется на практике; хотя это не соответствует какой-либо конкретной платформе, объяснение следует использовать только для понимания основных идей. Ваш компьютер использует по крайней мере два указателя: один, который отслеживает, где в вашем коде вы находитесь — указатель команд — и один, который отслеживает стек, указатель стека. Во время выполнения вашей программы компьютер принимает команду, указанную указателем команд, выполняет ее и увеличивает указатель. Приращение обычно относится к следующей инструкции, но когда у вас есть операторы if или циклы, оно будет переходить куда-то еще. Когда вы вызываете функцию, компьютер должен переместить указатель команд на код в функции и выполнить то, что там есть. Когда функция возвращается, ей необходимо переместить указатель командобратно сразу после вызова функции. Ему нужно где-то сохранить указатель команд, к которому он должен вернуться; он не может использовать глобальное местоположение, потому что тогда вы не смогли бы вызвать другую функцию из первой, не перезаписав ее, поэтому она помещается в стек, что означает, что она записывает ее туда, где находится указатель стека. В простейшей форме компьютер мог бы сохранить указатель инструкции в ячейке памяти, где находится указатель стека, а затем уменьшить указатель стека. (Я использую здесь уменьшение, так как наш стек перемещается от высоких адресов к низким, но с таким же успехом он может перемещаться от низкого к высокому и вместо этого увеличиваться). Если есть дополнительные вызовы функций, инструкции сохраняются одна за другой по мере перемещения указателя стека. Когда компьютеру нужно вернуться из функции, он может увеличить указатель стека, и он найдет там сохраненный указатель команд. Однако нам также необходимо выделить память для локальных переменных и других временных значений, если требуется, и стек является очевидным местом для размещения этой памяти. В этом случае расположение сохраненного указателя команд не так просто, и когда нам нужно переместить указатель стека, нам нужно знать, сколько памяти мы выделили в функции, чтобы переместить его на нужное количество. Указателями являются указатель команд(ip), указатель стека(sp), который указывает на следующий адрес, по которому мы можем выделить память в стеке, а затем указателем кадра (Frame Pointer, FP), который указывает на адрес памяти, где находился указатель стека при запуске вызова функции. Когда мы выделяем память в стеке, указатель стека перемещается, но указатель кадра остается постоянным, если мы не вызываем функцию или не возвращаем из нее, и мы можем использовать его для доступа к локальным переменным. Они будут находиться на фиксированном смещении от указателя кадра. ``` int foo(int x, int y) { return x + y; } int main(void) { int a = 13, b = 42; int c = foo(a, b); return 0; } ``` Мы начинаем пример со строки 2 в main(). Поскольку main() также является функцией, над ней есть некоторый стек, но мы не знаем, что происходит с нашей программой до вызова main(), поэтому мы игнорируем его. В main() мы выделили две переменные, a и b, они являются локальными переменными и помещены в стек. Указатель стека указывает на следующий свободный адрес, ниже a и b, а указатель кадра указывает на начало данных main(). ![Структура данных буферного кэша](https://whoisdeveloper.ru/static/img/pointers6.png) Следующее действие, которое мы должны предпринять, - это вызов foo(). Существуют различные способы, которые компьютер использует для передачи аргументов функциям, но стек - это распространенный подход, и я предполагаю, что это имеет место в примере. На самом деле аргументы передаются через регистры, где это возможно. Чтобы вызвать foo(a,b), мы должны поместить значения a и b в стек. Аргументы передаются как значения в Cи, поэтому нам нужны именно значения, а не, например, расположение переменных. Итак, мы помещаем эти два значения в стек, уменьшая sp, чтобы он указывал мимо них, и затем мы готовы к вызову функции. Когда мы вызываем foo(), наш компьютер сохраняет указатель кадра, чтобы мы могли восстановить его после вызова, и он сохраняет указатель команд, чтобы знать, куда он должен вернуться после вызова. В нашем примере это строка 3 в main(). Теперь указатель стека указывает на первую свободную ячейку памяти в стеке, куда foo() может поместить свои данные. Указатель кадра также должен идти в это место, поэтому foo() знает, где начинается его память.