Динамическое выделение памяти в Си
Для исполнения любой программы потребуется выделение памяти. Если заранее известно сколько данных будет использовано в программе, то запланировать выделение памяти можно при компиляции. Но часто приходиться работать с данными количество которых нельзя узнать заранее. В этом случае используется динамическое выделение памяти.
Роль операционной системы при работе с памятью
Операционная система управляет физическими устройствами. Запущенному приложению выделяется виртуальная память. Как распределена эта память в оперативной памяти или файле подкачки из приложения определить невозможно. В операционной системе можно увидеть сколько памяти запросило приложение и сколько в итоге было выделено системой. В Windows можно использовать диспетчер задач или специальные программы. В Linux можно использовать команды top или ps.
Современные операционные системы используют страницы как единицу выделяемой памяти. Минимальный размер такой страницы 4096 байт для 32-х битной системы. Если операционная система фиксирует попытку приложения использовать больше памяти, чем было выделено, то оно будет тут же закрыто.
Структура приложения
Для работы с динамическим выделением памяти необходимо понимать структуру приложения в операционной системе. Подобная структура верна для всех приложений в операционных системах Windows или Linux. Это не оговаривается стандартами языка, но примеры работы в популярных системах помогут разобраться с принципами распределения и использования памяти приложением. Виртуальная память используемая приложением разбита на три части:
- сегмент с данными;
- стек вызовов;
- сегмент коды.
Часть отданная под код приложения неизменна на протяжении всей работы программы. Как правило, эта часть памяти доступна только для чтения из самого приложения. Этот сегмент расположен после стека и занимает фиксированное пространство. Эта часть памяти содержит инструкции для исполнения и не используется для хранения переменных или их значений.
Следующая часть памяти выделяется под стек. Он заполняется локальными переменными и вызовами функций. Стек растет в противоположном от расположения кода направлении в сторону сегмента данных. Данные помещаются в стек в виде фреймов. Фрейм обязательно хранит адрес возврата.
Переменные создаваемые внутри функций также расположены здесь. Исключением являются статические переменные, которые попадают в сегмент данных. Значение статических переменных нужно хранить все время работы программы.
Сегмент данных хранит глобальные и статические переменные. Константы расположены в сегменте доступном только для чтения, переменные в сегменте доступном для чтения и записи. Неинициированные переменные находятся в bss-сегменте. Далее расположено пространство именуемое кучей (heap), которое используется при прямых запросах выделения памяти программой.
Статическое выделение памяти
Если заранее известно сколько пространства нужно для данных, с которыми работает программа, то можно выделить весь необходимый объем памяти до начала работы. В эту память попадают константы и переменные объявленные как глобальные.
Пример:
int id = 150; // определение статической глобальной переменной
int main()
{
printf(“%d”, id + 8); // её использование
}
Глобальные переменные расположены в сегменте данных. Они доступны из любой части программы.
Автоматическое выделение памяти
Локальные переменные попадают в эту категорию. Для локальных переменных видимость ограничена определенной функцией или циклом. Держать подобные переменные в памяти на протяжении всей работы программы, как это происходит в случае глобальных переменных, не рационально. Поэтому при вызове функции они попадают в стек, а после завершения ее работы автоматически стек очищается.
Пример:
int main()
{
int a = 3;
int result = factorial(a);
printf(“%d”, result);
}int factorial(int n)
{
if (n <= 1) return 1;
return n * factorial(n — 1);
}
При использовании нового вызова данные добавляются в конец стека и не стирают информацию оставленную предшествующими вызовами. Описание технических особенностей вызова функций можно найти в calling convention. Конкретная реализация зависит от платформы.
Информация о функции, ее переменных, адресе возврата добавляется в конец стека. Каждый новый вызов создает собственную запись. Глубина стека ограничена, что следует учитывать при написании программ.
Динамическое выделение памяти
Когда количество данных нельзя указать при компиляции, используют специальные функции для добавления памяти в процессе работы программы. Эта память попадает в кучу в сегменте с данными. В отличие от статической и автоматической памяти, которая выделяется в момент запуска программы, динамическая память может быть добавлена в процессе работы.
При динамическом выделении памяти необходимо использовать указатели. Отличие указателя от других переменных в том, что значение указателя – это всегда адрес. Указатели объявляются с учетом типа данных, которые будут храниться по этому адресу.
При использовании указателей необходимо использовать операторы взятия адреса и разыменования. Указатели могут быть константами. Указатель может хранит адрес памяти другого указателя. Указатели являются одной из самых сложных и важных тем при изучении Си.
Пример работы с указателями:
#include <conio.h>
#include <stdio.h>void main() {
int A = 100;
int *p;//Получаем адрес переменной A
p = &A;//Выводим адрес переменной A
printf(«%p\n», p);//Выводим содержимое переменной A
printf(«%d\n», *p);//Меняем содержимое переменной A
*p = 200;printf(«%d\n», A);
printf(«%d», *p);getch();
}
Выделение памяти происходит при помощи системных вызовов операционной системы.
Функции для выделения памяти языка С
Можно использовать несколько функций для создания запроса о дополнительной памяти. Функции для работы с памятью расположены в библиотеке stdlib. Если в программе планируется вызов этих функций следует объявить эту библиотеку. В противном случае могут возникнуть ошибки или предупреждения на этапе компиляции или запуска программы.
malloc
Первая из функций для динамического выделения памяти в библиотеке stdlib.
Синтаксис:
void *malloc(size_t size)
Функция принимает число байт, которое необходимо выделить. Возвращает указатель на выделенную память или NULL, если что-то пошло не так.
Пример использования:
#include <conio.h>
#include <stdio.h>
#include <stdlib.h>void main() {
const int maxNumber = 100;
int *p = NULL;
unsigned i, size;do {
printf(«Enter number from 0 to %d: «, maxNumber);
scanf(«%d», &size);
if (size < maxNumber) {
break;
}
} while (1);p = (int*) malloc(size * sizeof(int));
for (i = 0; i < size; i++) {
p[i] = i*i;
}for (i = 0; i < size; i++) {
printf(«%d «, p[i]);
}_getch();
free(p);
}
calloc
Отличие от предыдущей функции в том, что выделенная память инициализируется нулями. Это удобно при создании массивов.
Синтаксис:
void *calloc(size_t nitems, size_t size)
Первым параметром передается число элементов массива, следующим размер отдельного элемента в байтах. Функция возвращает указатель на блок памяти или нулевой указатель в случае неудачи.
Пример использования:
#include <stdio.h>
#include <stdlib.h>int main () {
int i, n;
int *a;printf(«Number of elements to be entered:»);
scanf(«%d»,&n);a = (int*)calloc(n, sizeof(int));
printf(«Enter %d numbers:\n»,n);
for( i=0 ; i < n ; i++ ) {
scanf(«%d»,&a[i]);
}printf(«The numbers entered are: «);
for( i=0 ; i < n ; i++ ) {
printf(«%d «,a[i]);
}
free( a );return(0);
}
Результат работы функции будет зависеть от пользовательского ввода:
Number of elements to be entered:3
Enter 3 numbers:
22
55
14
The numbers entered are: 22 55 14
realloc
Эта функция используется, чтобы изменить объем выделенной ранее памяти. Она применяется после функций malloc или calloc.
Синтаксис:
void *realloc(void *ptr, size_t size)
Функции передается указатель на ту память объем которой следует изменить и новое значение для этого блока памяти. Результат работы функции будет возвращен в указатель, если память была выделена успешно, то там будет адрес памяти, в противном случае указатель будет равняться значению NULL.
В приведенном ниже примере память первый раз выделяется функцией malloc, а после размер выделенного фрагмента изменяется функцией realloc:
#include <stdio.h>
#include <stdlib.h>int main () {
char *str;/* Initial memory allocation */
str = (char *) malloc(15);
strcpy(str, «tutorialspoint»);
printf(«String = %s, Address = %u\n», str, str);/* Reallocating memory */
str = (char *) realloc(str, 25);
strcat(str, «.com»);
printf(«String = %s, Address = %u\n», str, str);free(str);
return(0);
}
Результат вывода в консоль будет:
String = tutorialspoint, Address = 355090448
String = tutorialspoint.com, Address = 355090448
free
Эту функцию необходимо использовать в конце любой программы с динамическим выделением памяти. Во всех примерах выше она была вызвана перед завершением работы функции, в которой использовалось динамическое выделение памяти.
Синтаксис:
void free(void *ptr)
Функция принимает указатель на память, которую нужно освободить. Она ничего не возвращает. Если переданным параметром был NULL, то не произойдет ничего.
Примечание для с++
В С++ поддерживается полная совместимость с Си. Описанные функции будут работать, но для работы с памятью рекомендуется использовать собственные методы языка С++. Для выделения памяти разработан метод new, а для освобождения delete.
Утечка памяти
В языке C нет сборщиков мусора, которые могут быть знакомы людям использовавшим python или Java. За всю память запрошенную программой несет ответственность программист. После каждого запроса памяти в программе должна идти команда освобождающая эту память.
При использовании выделения памяти без последующего ее освобождения возникает утечка памяти. При длительной работе программы количество запрашиваемой памяти растет, а так как возвращения не происходит, возникают проблемы связанные с конечностью оперативной памяти.
Примеры программы с некорректным обращением с памятью:
#include <stdio.h>
#include <stdlib.h>void swap_arrays(int *A, int *B, size_t N)
{
int * tmp = (int *) malloc(sizeof(int)*N); //временный массив
for(size_t i = 0; i < N; i++)
tmp [i] = A[i];
for(size_t i = 0; i < N; i++)
A[i] = B[i];
for(size_t i = 0; i < N; i++)
B[i] = tmp [i];
//выходя из функции, забыли освободить память временного массива
}int main()
{
int A[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int B[10] = {10, 9, 8, 7, 6, 5, 4, 3, 2, 1};
swap_arrays(A, B, 10); //функция swap_arrays() имеет утечку памятиint *p;
for(int i = 0; i < 10; i++) {
p = (int *)malloc(sizeof(int)); //выделение памяти в цикле 10 раз
*p = 0;
}
free(p); //а освобождение вне цикла — однократное. Утечка!return 0;
}
Ошибка сегментации
Попытка использовать больше памяти чем выделено, или обращение к чужой памяти называется ошибкой сегментации. При использовании динамического выделения памяти необходимо внимательно относиться к адресам и проверять какие значения возвращают функции malloc, calloc и realloc.
Примеры появления данной ошибки:
#include <stdio.h>
#include <stdlib.h>void foo(int *pointer)
{
*pointer = 0; //потенциальный Segmentation fault
}int main()
{
int *p;
int x;
*NULL = 10; //совсем очевидный Segmentation fault
*p = 10; //достаточно очевидный Segmentation fault
foo(NULL); //скрытый Segmentation fault
scanf(«%d», x); //скрытый и очень популярный у новичков на Си Segmentation faultreturn 0;
}
Какой способ выделения памяти использовать
Каждый способ работы с памятью обладает собственными преимуществами и недостатками. Использование памяти определяется тем, с какими данными предстоит работать программе.
Достоинства статического выделения памяти
При массивах размер которых известен до начала запуска программы и не изменяется на протяжении ее работы, лучше использовать статическое выделение памяти. Код программы в этом случае будет проще, следовательно, число ошибок, которые может допустить программист, снижается.
Достоинства динамического выделения памяти
При использовании динамического выделения памяти не возникнет ошибок переполнения стека из-за большого объема локальных переменных. Можно выделять и освобождать память используя ее более рационально. Динамическое выделение памяти не требует информации о количестве данных до запуска программы. Память можно перераспределять в процессе работы программы.