Область видимости

В языке Си применяется три различных схемы хранения данных. Однако прежде чем о них говорить, стоит разобраться с понятием «объявление» (англ. declaration). Все переменные или функции должны быть объявлены до того, как они будут использованы. Компиляция кода ниже приведет к ошибке:

void function() {
    a = 10;
    // ...
}

Компилятору не известно, что такое a, и если это переменная, то какого она типа. Допустим a — это целое, тогда, если мы хотим ее использовать таковой, необходимо ее явным образом объявить.

int a;

void function(void) {
    a = 10;
    // ...
}

Переменные можно инициализировать значением прямо в объявлении.

Автоматическая продолжительность хранения

Переменные, объявленные внутри блока ({ ... }), в том числе параметры функций и счётчики циклов имеют автоматическую продолжительность хранения. Они создаются, когда выполнение программы входит в функцию или блок, где эти переменные определены. По выходу из блока/функции используемая переменными память освобождается, т.е. управление памятью происходит автоматически.

int main(void) {
    int a = 0;
    int i = 0;                    // i (main)
    for (int i = 0; i < 5; i++) { // i (for)
        a += i;                   // i (for)
    }                             // a = (0+1+2+3+4) = 10
                                  // i (for)
    for (i = 0; i < 5; i++) {     // i (main)
        a += i;
    }                             // a = (10+0+1+2+3+4) = 20
    i += a;                       // i (main) = 5 + (20) = 25
    return 0;
}

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

Статическая продолжительность хранения

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

Любая вызываемая в программе функция или переменная должна быть определена (англ. definition), и при этом всего один раз (в языке C++ это правило называется One Definition Rule). Если используется переменная, определена в другом файле, необходимо как-то проинформировать компилятор, откуда ее брать. Как создать переменную, которая будет доступна из разных модулей программы? Глобальные переменные имеют внешние связи, которые обеспечивает спецификатор extern.

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

Пример весьма искусственный, лучше сделать это через аргумент функции.

// settings.c
// variable definition
int precise_algorithm = 1; // 0 = fast, 1 = precise
​
uint32_t algorithm(void) {
    return precise_algorithm ? f() : g();
}

Получить доступ к переменной precise_algorithm можно двумя способами: написать специальную функцию или объявить ее внешней через extern.

// main.c
// ...
// external variable declaration
extern int precise_algorithm;
​
int main(void) {
    precise_algorithm = 0;
    uint32_t a = algorithm();
    return 0;
}

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

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

Динамическая продолжительность хранения

В отличие от автоматических и статических переменных, память под которые выделяется во время компиляции, динамические получают (или не получают) ресурсы во время выполнения программы. При этом память не будет освобождена до тех пор, пока программист явно не укажет этого или пока программа не завершит свою работу. Для выделения памяти используются функции malloc() / calloc() (входят в состав стандартной библиотеки), а освобождается она функцией free(). Эта память имеет динамическую продолжительность хранения и часто называется свободным хранилищем или кучей (англ. heap). Автоматические переменные при этом хранятся в другой области, называемой стеком (англ. stack).

Стек

Стек — это один из типов структур данных, описываемый фразой «первый пришел, последний ушел». Представьте, что вы положили в ведро книгу, а за ней вторую. Тогда чтобы достать первую, вам для начала нужно убрать ту, что лежит сверху. Именно так организован программный стек, где хранятся все необходимые программе данные (адреса переменных, адреса возврата и т.д.) Строго говоря, программный стек много меньше кучи, и по дурости можно легко переполнить его — тогда ваша программа аварийно завершится. Размер стека задается при компиляции программы специальным ключом. Попробуйте найти его самостоятельно.

Рассмотрим, что происходит при вызове некоторой функции uint32_t gcd(uint32_t n).

На первом этапе (рис. а) в стеке хранятся все необходимые для работы с функцией main() адреса и переменные. Далее (рис. б) в стек складываются параметры функции gcd(uint32_t n) (в нашем случае параметр всего один, n) и указатель на область памяти, где хранится наша функция. По завершении работы gcd(uint32_t) вершина стека опускается (физического стирания не происходит), т.е. указатель (о котором поговорим позже) спускается к main() (в состояние стека после работы функции).

Вызвать переполнение стека (англ. stack overflow) довольно просто, достаточно неправильно рекурсивно внутри функции вызывать ее же саму.

Самый крупный сайт, где люди могут задавать вопросы по программированию и получать на них ответы от других пользователей, называется Stack Overflow. На нём существует русское сообщество, но база английской версии намного богаче. Учите язык.

uint32_t gcd(uint32_t n) {
    return gcd(n);
}

Правда, компиляторы иногда способны обходить подобные ситуации. Другой причиной переполнения стека может послужить желание выделить место под большой объем данных, например так:

double x[1000000];

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

Куча

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

uint32_t n = 10;
uint32_t* arr_ptr = malloc(n * sizeof(uint32_t));

Переменные n и arr_ptr будут помещены непосредственно в стек. arr_ptr — указатель, в нем будет храниться адрес, указывающий на область кучи, где выделяется память под 10 элементов типа uint32_t. Переменная не является автоматической, т.е. вы сами должны контролировать время ее жизни. Освобождение памяти производится через вызов функции free(arr_ptr). (Подробнее эти функции будут рассмотрены позже, в главе про стандартную библиотеку.) Если вы этого не сделаете, то со временем будут накапливаться занятые куски в куче, она переполнится, и программа аварийно завершится. Пример неправильного выделения памяти приведен ниже.

void func(void) {
    uint32_t arr_ptr = malloc(n * sizeof(uint32_t));
}

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

void func(void) {
    uint32_t arr_ptr = malloc(n * sizeof(uint32_t));
    // do something useful here
    free(arr_ptr);
}

Более того, даже если работа организована правильно, т.е. вся выделяемая память рано или поздно освобождается, есть и другая проблема — фрагментация памяти. Пусть имеется 10 ячеек памяти. Первые две ячейки выделяются под один массив, а затем еще шесть — под другой. В ходе работы память, выделенная под первый массив, была освобождена, после чего потребовалось создать другой массив на четыре ячейки. Математика сходится, но память фрагментирована. Технически памяти достаточно для массива из четырех элементов, но создать его нельзя. Дело в том, что массив — это линейный участок, а из-за фрагментации подходящего участка попросту нет.

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


Изменено:

Язык и компилятор: 2 комментария

    • Согласен, наверное «с приставной» не правильно. Поправил на «с окнчанием».

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.