Указатели, функции, структуры и динамическая память в C

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

Материал ориентирован на C23 с сохранением совместимости со старшими стандартами C11/C17. Для проверки примеров используйте -std=c23 -Wall -Wextra -Wpedantic -Werror и проверяйте предупреждения так же строго, как и ошибки компиляции.

Указатели

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

Формальные правила
  • Разыменовывать можно только валидный указатель на существующий объект подходящего типа.
  • Арифметика указателей определена только в пределах одного массива и позиции one-past (на один элемент за концом массива).
  • Сравнение указателей на порядок корректно только для элементов одного массива.
  • После free указатель становится висячим (dangling pointer), его чтение/разыменование недопустимо.

Что такое указатели

Указатель - это переменная, значением которой является адрес другого объекта. Тип указателя определяет, как интерпретируется память по этому адресу: int * читает целое, double * - вещественное,char * - байты символов.

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

#include <stdio.h>

int main(void) {
  int value = 42;
  int *ptr = &value;

  printf("value = %d\n", value);
  printf("*ptr = %d\n", *ptr);

  *ptr = 100;
  printf("value after pointer write = %d\n", value);
  return 0;
}

Ожидаемый вывод:

value = 42
*ptr = 42
value after pointer write = 100

Операции с указателями

Базовые операции: взятие адреса &, разыменование *, присваивание адреса, сравнение с NULL и сравнение на равенство/неравенство. В практическом коде почти все операции с указателем сводятся к этим примитивам.

Важно не смешивать адрес и значение: если вы пишете int *p, то p хранит адрес, а*p - значение по адресу.

#include <stddef.h>
#include <stdio.h>

int main(void) {
  int x = 10;
  int y = 20;
  int *p = &x;

  printf("p points to x: %d\n", *p);
  p = &y;
  *p += 5;
  printf("y after update: %d\n", y);

  p = NULL;
  printf("p == NULL -> %d\n", p == NULL);
  return 0;
}

Ожидаемый вывод:

p points to x: 10
y after update: 25
p == NULL -> 1

Арифметика указателей

При добавлении к указателю единицы он смещается на размер одного элемента его типа. Для int * это обычно 4 байта, для double * обычно 8 байт. Разность двух указателей дает количество элементов между ними, а не разницу в байтах.

Выход за границы массива - undefined behavior. Допустима только позиция one-past для сравнения и вычислений, но разыменовывать one-past нельзя.

one-past - это указатель на позицию сразу за последним элементом того же массива. Для массива из 5 элементов arr это arr + 5 (эквивалентно &arr[5]). Такой указатель не указывает на реальный объект, поэтому операция *one_past запрещена. Но one-past можно безопасно использовать в сравнении (например, в условии цикла) и для вычисления расстояния между указателями внутри одного массива.

#include <stddef.h>
#include <stdio.h>

int main(void) {
  int arr[] = {10, 20, 30, 40, 50};
  int *a = &arr[1];
  int *b = &arr[4];
  int *one_past = arr + 5; // позиция сразу за arr[4]

  printf("*a = %d\n", *a);
  printf("*(a + 2) = %d\n", *(a + 2));
  printf("b - a = %td\n", b - a);
  printf("one_past == &arr[5] -> %d\n", one_past == &arr[5]);
  printf("distance(one_past - a) = %td\n", one_past - a);

  for (int *p = arr; p != one_past; p++) {
    printf("%d ", *p);
  }
  printf("\n");

  // printf("%d\n", *one_past); // UB: разыменовывать one-past нельзя
  return 0;
}

Ожидаемый вывод:

*a = 20
*(a + 2) = 40
b - a = 3
one_past == &arr[5] -> 1
distance(one_past - a) = 4
10 20 30 40 50 

Константы и указатели

Важны три формы: const int *p (нельзя менять объект через указатель), int *const p(нельзя переназначить сам указатель) и const int *const p (оба ограничения одновременно).

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

#include <stdio.h>

int main(void) {
  int value = 7;
  int other = 11;

  const int *ptr_to_const = &value;
  int *const const_ptr = &value;
  const int *const const_both = &value;

  ptr_to_const = &other;
  *const_ptr = 8;

  printf("*ptr_to_const = %d\n", *ptr_to_const);
  printf("value via const_ptr = %d\n", value);
  printf("*const_both = %d\n", *const_both);
  return 0;
}

Ожидаемый вывод:

*ptr_to_const = 11
value via const_ptr = 8
*const_both = 8

Указатели и массивы

В большинстве выражений имя массива неявно преобразуется в указатель на первый элемент. Поэтому и записьarr[i], и *(arr + i) читают один и тот же элемент.

Критичный нюанс: внутри функции массив в параметре фактически уже указатель. Поэтому sizeof(arr)в параметре возвращает размер указателя, а не длину исходного массива. Длину нужно передавать отдельным аргументом.

#include <stddef.h>
#include <stdio.h>

int sum_array(const int *arr, size_t n) {
  int sum = 0;
  for (size_t i = 0; i < n; i++) {
    sum += *(arr + i);
  }
  return sum;
}

int main(void) {
  int numbers[] = {3, 6, 9, 12};
  size_t n = sizeof(numbers) / sizeof(numbers[0]);
  printf("sum = %d\n", sum_array(numbers, n));
  return 0;
}

Ожидаемый вывод:

sum = 30

Указатели и строки

Строка в C - это массив символов, завершенный нулевым байтом '\0'. Строковый литерал обычно размещается в read-only памяти, поэтому храните его как const char *.

Попытка изменить символ в строковом литерале - undefined behavior. Если строку нужно менять, создавайте изменяемый массив char text[] = "...", а не указатель на литерал.

#include <stdio.h>

int main(void) {
  char mutable_text[] = "hello";
  const char *literal = "world";

  char *p = mutable_text;
  p[0] = 'H';

  printf("mutable_text = %s\n", mutable_text);
  printf("literal = %s\n", literal);
  return 0;
}

Ожидаемый вывод:

mutable_text = Hello
literal = world

Массивы указателей и многоуровневая адресация

Массив указателей хранит адреса нескольких объектов одинакового типа. Это базовый инструмент для наборов строк, таблиц адресов функций и интерфейсов вида char **argv.

Многоуровневая адресация (int **, char ***) полезна, когда нужно менять сам указатель в вызывающей функции. На каждом уровне косвенности важно явно понимать, адрес чего хранится в текущей переменной.

#include <stdio.h>

int main(void) {
  const char *colors[] = {"red", "green", "blue"};
  const char **pp = colors;

  int value = 123;
  int *p = &value;
  int **pp_int = &p;

  printf("colors[1] = %s\n", colors[1]);
  printf("*(pp + 2) = %s\n", *(pp + 2));
  printf("**pp_int = %d\n", **pp_int);
  return 0;
}

Ожидаемый вывод:

colors[1] = green
*(pp + 2) = blue
**pp_int = 123

Функции

Функции в C формируют модульность: они изолируют ответственность, задают контракт параметров и возвращаемого значения, и определяют точки расширения через указатели на функции.

Формальные правила
  • Функция должна быть объявлена до первого вызова через прототип или полное определение.
  • Аргументы передаются по значению; для эффекта «по ссылке» передают указатель.
  • Рекурсия требует корректного базового случая, иначе будет переполнение стека.
  • Вызов через указатель на функцию корректен только при совместимых типах; mismatch сигнатур - undefined behavior.

Определение и описание функций

Описание (прототип) фиксирует сигнатуру: имя, тип результата и параметры. Определение содержит тело функции. Разделение удобно для интерфейсных заголовков и реализации в отдельных файлах.

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

#include <stdio.h>

int square(int x);

int square(int x) {
  return x * x;
}

int main(void) {
  printf("square(9) = %d\n", square(9));
  return 0;
}

Ожидаемый вывод:

square(9) = 81

Параметры функции

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

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

#include <stdio.h>

void inc_copy(int x) {
  x++;
  printf("inside inc_copy: x = %d\n", x);
}

int main(void) {
  int n = 10;
  inc_copy(n);
  printf("outside after call: n = %d\n", n);
  return 0;
}

Ожидаемый вывод:

inside inc_copy: x = 11
outside after call: n = 10

Результат функции

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

Нельзя «вернуть» адрес локальной переменной автоматического хранения: после выхода из функции она уничтожается, и указатель становится dangling. Для сложных результатов используйте структуру или внешний буфер.

#include <stdio.h>

typedef struct {
  int sum;
  int diff;
} PairResult;

PairResult calc(int a, int b) {
  PairResult r;
  r.sum = a + b;
  r.diff = a - b;
  return r;
}

int main(void) {
  PairResult r = calc(11, 4);
  printf("sum = %d, diff = %d\n", r.sum, r.diff);
  return 0;
}

Ожидаемый вывод:

sum = 15, diff = 7

Рекурсивные функции

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

Главный риск - отсутствие корректной остановки и чрезмерная глубина вызовов. В этом случае программа может закончиться stack overflow, поэтому всегда формулируйте базовый случай явно.

#include <stdio.h>

unsigned long long factorial(unsigned int n) {
  if (n <= 1) {
    return 1ULL;
  }
  return n * factorial(n - 1);
}

int main(void) {
  printf("factorial(5) = %llu\n", factorial(5));
  return 0;
}

Ожидаемый вывод:

factorial(5) = 120

Область видимости переменных

Область видимости определяет, где имя переменной доступно. Локальные переменные функции видны только в теле функции, а блочные переменные - только внутри соответствующего блока { ... }.

Ошибка затенения (shadowing), когда внутреннее имя перекрывает внешнее, часто усложняет чтение кода и маскирует дефекты. В критичных участках лучше использовать уникальные имена и короткие блоки.

#include <stdio.h>

int g = 100;

int main(void) {
  int x = 10;
  {
    int x = 20;
    printf("inner x = %d\n", x);
  }
  printf("outer x = %d\n", x);
  printf("global g = %d\n", g);
  return 0;
}

Ожидаемый вывод:

inner x = 20
outer x = 10
global g = 100

Внешние объекты

Внешние объекты - это глобальные переменные с storage duration на время всей программы. Их можно объявлять в одном переводимом модуле через extern и определять в другом.

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

#include <stdio.h>

extern int g_counter;

void tick(void) {
  g_counter++;
}

int g_counter = 0;

int main(void) {
  tick();
  tick();
  printf("g_counter = %d\n", g_counter);
  return 0;
}

Ожидаемый вывод:

g_counter = 2

Классы хранения (Storage class)

Классы хранения в C задают три вещи: время жизни объекта (storage duration), область видимости (scope) и, для объектов на уровне файла, тип связывания (linkage).

  • auto: автоматическое хранение для локальных переменных блока. Время жизни - от входа в блок до выхода из него. Это значение по умолчанию для обычных локальных переменных.
  • register: тоже автоматическое хранение, но с подсказкой компилятору держать переменную в регистре. Современные компиляторы сами решают оптимизацию, поэтому ключевое слово чаще используется как часть старого кода. У классического register нельзя брать адрес через &.
  • extern: объявление внешнего объекта/функции, определенной в другом месте (или позже в этом же файле). Объект имеет статическое время жизни и внешнее связывание.
  • static: для локальной переменной - статическое время жизни с сохранением значения между вызовами; для объекта на уровне файла - внутреннее связывание (доступ только в текущем переводимом модуле).

Ниже пример, который иллюстрирует все классы хранения в одном месте: auto, register,extern и два варианта static (локальный и файловый).

#include <stdio.h>

extern int shared_counter;           // extern: объявление внешнего объекта
static int file_local_total = 100;   // static (file scope): внутреннее связывание
int shared_counter = 10;             // определение внешнего объекта

void use_static_local(void) {
  static int calls = 0;
  calls++;
  printf("static local calls = %d\n", calls);
}

void bump_extern(void) {
  shared_counter++;
  printf("extern shared_counter = %d\n", shared_counter);
}

int main(void) {
  auto int local_auto = 5; // auto: переменная блока
  register int i;          // register: переменная цикла (адрес брать нельзя)
  register int sum = 0;

  printf("auto local_auto = %d\n", local_auto);
  printf("static file_local_total = %d\n", file_local_total);

  for (i = 1; i <= 3; i++) {
    sum += i;
  }
  printf("register sum = %d\n", sum);

  use_static_local();
  use_static_local();
  bump_extern();

  return 0;
}

Ожидаемый вывод:

auto local_auto = 5
static file_local_total = 100
register sum = 6
static local calls = 1
static local calls = 2
extern shared_counter = 11

Указатели в параметрах функции

Если нужно изменить данные в вызывающем коде, функция должна получить адрес объекта. Такой прием используют для swap-операций, записи результата в out-параметры и работы с буферами.

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

#include <stdio.h>

void swap_int(int *a, int *b) {
  if (a == NULL || b == NULL) {
    return;
  }

  int tmp = *a;
  *a = *b;
  *b = tmp;
}

int main(void) {
  int x = 3;
  int y = 9;
  swap_int(&x, &y);
  printf("x = %d, y = %d\n", x, y);
  return 0;
}

Ожидаемый вывод:

x = 9, y = 3

Указатели на функции

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

Базовая форма синтаксиса: return_type (*name)(param_types). Скобки вокруг *name обязательны: без них объявление читается как прототип функции, а не как переменная-указатель. Примеры: int (*fn)(int, int) - указатель на функцию int f(int, int); int (*table[2])(int, int) - массив из двух указателей на функции такой сигнатуры.

Присваивание возможно как через имя функции, так и через адрес: fn = add; и fn = &add; эквивалентны. Вызов также эквивалентен в двух формах: fn(2, 5) и (*fn)(2, 5). Нельзя вызывать функцию через указатель несовместимого типа: mismatch сигнатур приводит к undefined behavior.

Формальные правила
  • Тип указателя на функцию должен быть совместим с типом вызываемой функции.
  • Оператор вызова () для указателя на функцию и для разыменованного указателя эквивалентен: p(args) и (*p)(args).
  • Арифметика указателей для function pointer не применяется (это не указатель на объект).
  • Для сложных сигнатур используйте typedef, чтобы сократить риск ошибок в объявлениях и вызовах.

Для практики полезно читать объявление «изнутри наружу»: сначала имя, затем уровень указателя, затем список параметров и тип результата. Этот прием помогает быстро разбирать даже сложные объявления.

#include <stdio.h>

int add(int a, int b) {
  return a + b;
}

int mul(int a, int b) {
  return a * b;
}

int apply(int (*op)(int, int), int x, int y) {
  if (op == NULL) {
    return 0;
  }
  return op(x, y);
}

int main(void) {
  int (*fn)(int, int) = add;
  int (*table[2])(int, int) = {add, mul};

  printf("fn(add): %d\n", fn(2, 5));
  fn = &mul;
  printf("fn(mul): %d\n", (*fn)(2, 5));
  printf("table[0]: %d\n", apply(table[0], 3, 4));
  printf("table[1]: %d\n", apply(table[1], 3, 4));
  return 0;
}

Ожидаемый вывод:

fn(add): 7
fn(mul): 10
table[0]: 7
table[1]: 12

Тип функции

В C тип функции и тип указателя на функцию - разные сущности. Для читаемости сложных сигнатур обычно вводятtypedef, чтобы сигнатура callback-а имела короткое имя.

Тип функции удобно использовать как контракт API: так проще контролировать совместимость при рефакторинге. Если сигнатура меняется, компилятор покажет все места, где контракт нарушен.

#include <stdio.h>

typedef int operation_fn(int, int);

int sub(int a, int b) {
  return a - b;
}

int run(operation_fn *op, int x, int y) {
  return op(x, y);
}

int main(void) {
  printf("sub: %d\n", run(sub, 10, 3));
  return 0;
}

Ожидаемый вывод:

sub: 7

Функции как параметры других функций

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

Синтаксис callback-параметра обычно выглядит так: return_type (*name)(param_types). Например, сигнатура void map_int(int *arr, size_t n, int (*fn)(int)) означает, что третий параметр - это указатель на функцию, которая принимает int и возвращает int. Скобки вокруг *fn обязательны: без них объявление читается как функция, возвращающая указатель.

Для сложных API запись через typedef часто проще: сначала объявляют тип callback-а, затем используют его в параметрах (например, typedef int (*map_fn)(int); и далее void map_int(int *arr, size_t n, map_fn fn)). При таком подходе важно четко описать контракт callback-а: допустимые значения, side effects и требования к потокобезопасности.

Формальные правила
  • Тип указателя callback-а должен быть совместим с фактически передаваемой функцией, включая типы аргументов и тип результата.
  • Выражения fn(x) и (*fn)(x) для указателя на функцию эквивалентны.
  • Если callback может отсутствовать, API должен явно обрабатывать NULL до вызова.
  • Function pointer не поддерживает арифметику указателей; допустимы только присваивание, сравнение и вызов (при валидном значении).

Практичный прием чтения сигнатуры: сначала смотрите на имя параметра, затем на уровень указателя, потом на список аргументов и тип результата.

#include <stddef.h>
#include <stdio.h>

void map_int(int *arr, size_t n, int (*fn)(int)) {
  for (size_t i = 0; i < n; i++) {
    arr[i] = fn(arr[i]);
  }
}

int square_value(int x) {
  return x * x;
}

int main(void) {
  int a[] = {1, 2, 3, 4};
  map_int(a, 4, square_value);
  printf("%d %d %d %d\n", a[0], a[1], a[2], a[3]);
  return 0;
}

Ожидаемый вывод:

1 4 9 16

Функция как результат другой функции

Функция не может вернуть функцию напрямую, но может вернуть указатель на функцию. Так обычно выбирают стратегию вычисления на основе параметров конфигурации.

Вариант с typedef обычно самый читаемый: сначала объявляют тип указателя на функцию, затем используют его как тип результата. Например: typedef int (*binary_op)(int, int); и далее binary_op select_op(char mode).

Без typedef синтаксис выглядит так: int (*select_op_raw(char mode))(int, int). Читать удобно «изнутри наружу»: select_op_raw - функция, которая принимает char и возвращает указатель на функцию с сигнатурой int (int, int). Скобки вокруг *select_op_raw(...) обязательны.

Формальные правила
  • Возвращаемый function pointer должен быть совместим с фактической сигнатурой функции, на которую он указывает.
  • Сигнал «нет подходящей функции» обычно передается через NULL, и вызывающий код обязан это проверять до вызова.
  • Вызовы op(args) и (*op)(args) эквивалентны для валидного указателя на функцию.
  • Вызов через несовместимый тип function pointer - undefined behavior.
#include <stdio.h>

typedef int (*binary_op)(int, int);

int add_op(int a, int b) {
  return a + b;
}

int max_op(int a, int b) {
  return (a > b) ? a : b;
}

binary_op select_op(char mode) {
  if (mode == '+') {
    return add_op;
  }
  if (mode == 'm') {
    return max_op;
  }
  return NULL;
}

int (*select_op_raw(char mode))(int, int) {
  return select_op(mode);
}

int main(void) {
  binary_op op1 = select_op('m');
  int (*op2)(int, int) = select_op_raw('+');

  if (op1 != NULL) {
    printf("select_op('m') -> %d\n", op1(8, 5));
  }
  if (op2 != NULL) {
    printf("select_op_raw('+') -> %d\n", op2(8, 5));
  }
  return 0;
}

Ожидаемый вывод:

select_op('m') -> 8
select_op_raw('+') -> 13

Функции с переменным количеством параметров

Variadic-функции работают через stdarg.h: va_list, va_start, va_arg, va_end. Такой подход нужен для API уровня printf и логгеров.

Как используются ключевые элементы varargs по шагам:

  • va_list: служебный тип, который хранит текущее состояние обхода переменных аргументов. Обычно объявляется как локальная переменная, например va_list ap;.
  • va_start(ap, last_named_param): инициализирует обход. Второй аргумент обязан быть последним именованным параметром функции (в примере это n в sum_ints(size_t n, ...)).
  • va_arg(ap, T): читает следующий аргумент как тип T и сдвигает внутренний указатель. Вызывать нужно столько раз, сколько аргументов реально передано по вашему протоколу.
  • va_end(ap): завершает работу с va_list. Вызывать обязательно перед выходом из функции, даже если реализация на платформе выглядит как «пустая» операция.

Дополнительно: если нужно пройтись по аргументам повторно, используют va_copy(dst, src), а затем завершают оба списка через va_end. У varargs нет полной статической проверки типов, поэтому любое несоответствие протоколу аргументов приводит к чтению мусора и потенциальному UB.

Типичный корректный порядок всегда один: объявить va_list → вызвать va_start → читать аргументы через va_arg → завершить va_end.

#include <stdarg.h>
#include <stdio.h>

int sum_ints(size_t n, ...) {
  va_list ap;
  va_start(ap, n);

  int sum = 0;
  for (size_t i = 0; i < n; i++) {
    sum += va_arg(ap, int);
  }

  va_end(ap);
  return sum;
}

int main(void) {
  printf("sum = %d\n", sum_ints(5, 10, 20, 30, 40, 50));
  return 0;
}

Ожидаемый вывод:

sum = 150

Параметры командной строки

В сигнатуре main(int argc, char *argv[]) параметр argc хранит число аргументов, а argv - массив строк. Элемент argv[0] обычно содержит имя запускаемой программы.

При обработке аргументов всегда проверяйте границы и ожидаемые форматы. Нельзя читать argv[i], если i >= argc, иначе доступ выйдет за пределы массива указателей.

#include <stdio.h>

int main(int argc, char *argv[]) {
  printf("argc = %d\n", argc);

  for (int i = 0; i < argc; i++) {
    printf("argv[%d] = %s\n", i, argv[i]);
  }

  return 0;
}

Ожидаемый вывод:

# пример запуска: ./app Alice 42
argc = 3
argv[0] = ./app
argv[1] = Alice
argv[2] = 42

Препроцессор

Препроцессор работает до компиляции: он подставляет заголовки, раскрывает макросы и управляет условной компиляцией. Это мощный, но «текстовый» этап, поэтому правила безопасности здесь особенно важны.

Директива #include. Включение файлов

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

В больших проектах критичны include guards или #pragma once, иначе возможны повторные определения. Также важно избегать циклических включений и тяжелых лишних зависимостей в публичных заголовках.

#include <stdio.h>
#include <string.h>

int main(void) {
  const char *text = "include";
  printf("length = %zu\n", strlen(text));
  return 0;
}

Ожидаемый вывод:

length = 7

Директива #define

#define задает текстовую подстановку, которую препроцессор выполняет до синтаксического анализа C. Константные макросы часто используют для размеров буферов, флагов сборки и compile-time конфигурации.

Макросы не знают типов, поэтому для типобезопасных констант часто лучше использовать const или enum. Для публичных макро-имен важно соблюдать префиксы проекта, чтобы избежать конфликтов.

#include <stdio.h>

#define APP_NAME "c-demo"
#define BUFFER_SIZE 64

int main(void) {
  printf("app = %s\n", APP_NAME);
  printf("buffer = %d\n", BUFFER_SIZE);
  return 0;
}

Ожидаемый вывод:

app = c-demo
buffer = 64

Макросы

Функциональные макросы применяют для коротких шаблонов, которые должны работать на этапе препроцессора. Безопасный стиль: оборачивать аргументы и все выражение скобками, а для многострочных макросов использовать шаблон do { ... } while (0).

Макросы с побочными эффектами опасны: например, SQUARE(i++) может расшириться до выражения с двойной модификацией переменной без упорядочивания, что ведет к undefined behavior. Такие случаи лучше решать обычной функцией.

Препроцессор делает чистую текстовую подстановку и не учитывает приоритет операторов. Если написать #define BAD_SQUARE(x) x * x, то вызов BAD_SQUARE(1 + 2) превратится в 1 + 2 * 1 + 2 (получится 5, а не 9). Поэтому безопасный шаблон всегда такой: скобки вокруг каждого аргумента и вокруг всего результата, как в #define SQUARE(x) ((x) * (x)). Даже если сегодня вы передаете просто переменную, завтра в этот же макрос могут передать выражение.

#include <stdio.h>

#define SQUARE(x) ((x) * (x))
#define SWAP_INT(a, b) \
  do { \
    int tmp = (a); \
    (a) = (b); \
    (b) = tmp; \
  } while (0)

int main(void) {
  int x = 2;
  int y = 9;
  int v = SQUARE(x + 1);

  SWAP_INT(x, y);

  printf("v = %d\n", v);
  printf("x = %d, y = %d\n", x, y);
  return 0;
}

Ожидаемый вывод:

v = 9
x = 9, y = 2

Условная компиляция

Условная компиляция через #if, #ifdef, #ifndef позволяет включать платформенные или отладочные блоки кода без runtime-ветвлений.

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

#include <stdio.h>

#define DEBUG 1

int main(void) {
  int value = 42;

#if DEBUG
  printf("DEBUG: value = %d\n", value);
#else
  printf("release mode\n");
#endif

  printf("result = %d\n", value * 2);
  return 0;
}

Ожидаемый вывод:

DEBUG: value = 42
result = 84

Встроенные макросы

Стандартные встроенные макросы вроде __FILE__, __LINE__, __DATE__, __TIME__ полезны для диагностики и простого логирования.

Эти значения зависят от окружения сборки и не должны участвовать в критичной бизнес-логике. Обычно их оставляют только в отладочных сообщениях и аварийных логах.

#include <stdio.h>

int main(void) {
  printf("file: %s\n", __FILE__);
  printf("line: %d\n", __LINE__);
  printf("date: %s\n", __DATE__);
  printf("time: %s\n", __TIME__);
  return 0;
}

Ожидаемый вывод:

file: demo.c
line: 6
date: Feb 18 2026
time: 12:34:56
# line/date/time зависят от файла и момента сборки

Структуры

Структуры объединяют связанные данные в один тип и задают явную модель предметной области. Это базовый инструмент для проектирования интерфейсов между модулями на языке C.

Формальные правила
  • Поля структуры располагаются в порядке объявления, но между ними возможны паддинги.
  • Оператор . работает с объектом структуры, оператор -> - с указателем.
  • Поля union разделяют одну область памяти и перекрывают друг друга.
  • Размер и размещение битовых полей имеют implementation-defined аспекты.

Определение структур

Структура определяется через struct и список полей. Для удобства часто сразу вводят алиас через typedef, чтобы использовать тип без префикса struct.

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

#include <stdio.h>

typedef struct {
  double x;
  double y;
} Point;

int main(void) {
  Point p = {3.0, 4.0};
  printf("point = (%.1f, %.1f)\n", p.x, p.y);
  return 0;
}

Ожидаемый вывод:

point = (3.0, 4.0)

Структуры как элементы структур

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

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

#include <stdio.h>

typedef struct {
  const char *city;
  int zip;
} Address;

typedef struct {
  const char *name;
  Address address;
} User;

int main(void) {
  User u = {"Alex", {"Moscow", 101000}};
  printf("%s -> %s, %d\n", u.name, u.address.city, u.address.zip);
  return 0;
}

Ожидаемый вывод:

Alex -> Moscow, 101000

Указатели на структуры

Указатели на структуры нужны, когда объект большой, меняется в функции или живет в динамической памяти. Доступ к полям через указатель обычно записывают оператором ->. Это сокращение для формы (*ptr).field: запись ptr->field полностью эквивалентна (*ptr).field.

При передаче указателя важно гарантировать валидность времени жизни объекта. Доступ через dangling pointer к структуре приводит к тем же проблемам, что и для любых других типов: UB и повреждение состояния программы. Важно не забывать скобки в форме (*ptr).field: вариант *ptr.field некорректен из-за приоритета операторов.

#include <stdio.h>

typedef struct {
  int id;
  double score;
} Record;

void update_score(Record *r, double score) {
  if (r == NULL) {
    return;
  }
  r->score = score;
}

int main(void) {
  Record rec = {1, 0.0};
  Record *p = &rec;

  update_score(p, 98.5);
  printf("via -> : id=%d score=%.1f\n", p->id, p->score);
  printf("via (*p). : id=%d score=%.1f\n", (*p).id, (*p).score);
  return 0;
}

Ожидаемый вывод:

via -> : id=1 score=98.5
via (*p). : id=1 score=98.5

Массивы структур

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

При линейном поиске по массиву структур сложность остается O(n). Если объем данных растет, имеет смысл проектировать индексные структуры или сортировку с бинарным поиском.

#include <stdio.h>

typedef struct {
  const char *name;
  int age;
} Person;

int main(void) {
  Person team[] = {{"Ann", 23}, {"Bob", 29}, {"Kate", 31}};
  size_t n = sizeof(team) / sizeof(team[0]);

  for (size_t i = 0; i < n; i++) {
    printf("%s: %d\n", team[i].name, team[i].age);
  }

  return 0;
}

Ожидаемый вывод:

Ann: 23
Bob: 29
Kate: 31

Структуры и функции

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

Для небольших immutable-структур копия обычно приемлема. Для крупных структур или частых вызовов выгоднее передавать const T * для чтения и T * для изменения.

#include <stdio.h>

typedef struct {
  int w;
  int h;
} Size;

int area_by_value(Size s) {
  return s.w * s.h;
}

void scale(Size *s, int k) {
  if (s == NULL) {
    return;
  }
  s->w *= k;
  s->h *= k;
}

int main(void) {
  Size s = {3, 4};
  printf("area = %d\n", area_by_value(s));
  scale(&s, 2);
  printf("scaled = %d x %d\n", s.w, s.h);
  return 0;
}

Ожидаемый вывод:

area = 12
scaled = 6 x 8

Размещение структур и их полей в памяти

Размер структуры может быть больше суммы размеров полей из-за выравнивания и паддинга. Это влияет на объем памяти, бинарные протоколы и совместимость layout между компиляторами.

Ключевая идея выравнивания: каждое поле обычно должно начинаться с адреса, кратного его alignment. Если текущее смещение не подходит, компилятор вставляет пустые байты (padding) до следующей допустимой границы.

  • Внутренний паддинг (internal padding): вставка между полями, чтобы выровнять следующее поле.
  • Хвостовой паддинг (tail padding): вставка в конце структуры, чтобы sizeof(struct) был кратен максимальному alignment ее полей.
  • Порядок полей влияет на итоговый размер структуры: группировка полей от «более широких» к «более узким» часто уменьшает паддинг.

Нельзя сериализовать структуру «сырыми байтами» без явного контроля layout. Для внешних форматов лучше использовать явную упаковку полей, фиксированные типы и проверять смещения через offsetof.

Оператор alignof(T) в C23 возвращает требование выравнивания для типа T в байтах. По нему можно заранее понять, почему компилятор вставляет паддинг: если поле требует выравнивание 4, его смещение обычно будет кратно 4.

#include <stddef.h>
#include <stdio.h>

typedef struct {
  char tag;
  int value;
  short code;
} ItemA;

typedef struct {
  int value;
  short code;
  char tag;
} ItemB;

int main(void) {
  printf("alignof(char) = %zu\n", alignof(char));
  printf("alignof(short) = %zu\n", alignof(short));
  printf("alignof(int) = %zu\n", alignof(int));

  printf("sizeof(ItemA) = %zu\n", sizeof(ItemA));
  printf("A offsets: tag=%zu value=%zu code=%zu\n",
         offsetof(ItemA, tag), offsetof(ItemA, value), offsetof(ItemA, code));

  printf("sizeof(ItemB) = %zu\n", sizeof(ItemB));
  printf("B offsets: value=%zu code=%zu tag=%zu\n",
         offsetof(ItemB, value), offsetof(ItemB, code), offsetof(ItemB, tag));
  return 0;
}

Ожидаемый вывод:

alignof(char) = 1
alignof(short) = 2
alignof(int) = 4
sizeof(ItemA) = 12
A offsets: tag=0 value=4 code=8
sizeof(ItemB) = 8
B offsets: value=0 code=4 tag=6
# конкретные значения зависят от ABI и компилятора

Составные литералы

Составной литерал позволяет создать временный объект заданного типа прямо в выражении: например, (Point){1.0, 2.0}. Это сокращает шаблонный код и делает вызовы функций компактнее.

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

#include <stdio.h>

typedef struct {
  double x;
  double y;
} Point;

void print_point(Point p) {
  printf("(%.1f, %.1f)\n", p.x, p.y);
}

int main(void) {
  print_point((Point){1.5, 2.5});

  int *arr = (int[]){10, 20, 30};
  printf("arr[2] = %d\n", arr[2]);
  return 0;
}

Ожидаемый вывод:

(1.5, 2.5)
arr[2] = 30

Перечисления

Перечисления (enum) задают именованный набор целочисленных констант. Они повышают читаемость кода и уменьшают число «магических чисел» в условиях и состояниях.

Базовый синтаксис: enum Tag { A, B, C };. Можно сразу объявить переменную: enum Tag value;, либо создать псевдоним через typedef: typedef enum Tag { ... } TagType;. Значения можно задавать явно (B = 10) или оставить неявными: тогда следующее значение будет на единицу больше предыдущего.

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

Формальные правила
  • Имена перечислителей в одном перечислении должны быть уникальны.
  • Если значение перечислителя не указано, оно вычисляется как значение предыдущего плюс 1 (первый по умолчанию 0).
  • Явно заданные значения перечислителей должны быть целочисленными константными выражениями.
  • Перечислители можно использовать в switch/case и других контекстах, где ожидаются целочисленные константы.

Практичный стиль: задавайте явные значения для стабильных внешних протоколов и храните проверку «неизвестного значения» в default-ветке.

#include <stdio.h>

typedef enum StatusTag {
  STATUS_NEW = 0,
  STATUS_READY = 10,
  STATUS_DONE // 11
} Status;

int main(void) {
  Status st = STATUS_READY;

  printf("STATUS_NEW = %d\n", STATUS_NEW);
  printf("STATUS_READY = %d\n", STATUS_READY);
  printf("STATUS_DONE = %d\n", STATUS_DONE);

  if (st == STATUS_READY) {
    printf("ready\n");
  }

  printf("numeric = %d\n", st);
  return 0;
}

Ожидаемый вывод:

STATUS_NEW = 0
STATUS_READY = 10
STATUS_DONE = 11
ready
numeric = 10

Объединения

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

Чтение поля union, которое не было последним записанным, имеет ограничения и может быть implementation-defined/undefined в зависимости от случая. Безопасный путь - хранить явный discriminant и читать только активное поле.

Формальные правила
  • Все поля union разделяют одну область памяти и начинаются с одного адреса.
  • Размер union не меньше размера самого крупного поля и обычно учитывает требования выравнивания этого поля.
  • Гарантированно корректно читать то поле, в которое было последнее присваивание (активное поле).
  • Чтение другого поля возможно только в специальных случаях; в общем коде это считается implementation-defined/undefined и требует аккуратного документирования.
#include <stdint.h>
#include <stdio.h>

typedef union {
  uint32_t u32;
  unsigned char bytes[4];
} NumberView;

int main(void) {
  NumberView v;
  v.u32 = 0x12345678u;

  printf("u32 = 0x%08x\n", v.u32);
  printf("bytes: %u %u %u %u\n", v.bytes[0], v.bytes[1], v.bytes[2], v.bytes[3]);
  return 0;
}

Ожидаемый вывод:

u32 = 0x12345678
bytes: 120 86 52 18
# порядок байт зависит от endianness платформы

Битовые поля

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

Порядок размещения битов и некоторые аспекты выравнивания зависят от реализации компилятора. Для сетевых и файловых форматов не стоит полагаться на битовые поля без дополнительной нормализации.

Синтаксис битового поля: тип имя : ширина;. Например, unsigned read : 1; выделяет 1 бит под флаг read. Несколько таких полей можно объявлять подряд в struct, а сумма их ширин обычно группируется в единицы хранения, выбранные компилятором.

Формальные правила
  • Ширина битового поля задается целочисленным константным выражением и не может быть отрицательной.
  • Чаще всего для битовых полей используют unsigned int/signed int/int; поведение других базовых типов зависит от реализации.
  • Порядок упаковки битов, выравнивание и размещение между единицами хранения являются implementation-defined.
  • Битовые поля удобны для внутренних флагов, но для межплатформенных бинарных протоколов лучше использовать явные маски и сдвиги.
#include <stdio.h>

typedef struct {
  unsigned read : 1;
  unsigned write : 1;
  unsigned exec : 1;
  unsigned reserved : 5;
} Flags;

int main(void) {
  Flags f = {1, 0, 1, 0};
  printf("read=%u write=%u exec=%u\n", f.read, f.write, f.exec);
  printf("sizeof(Flags) = %zu\n", sizeof(Flags));
  return 0;
}

Ожидаемый вывод:

read=1 write=0 exec=1
sizeof(Flags) = 4
# размер может отличаться на другой платформе

Динамическая память

Динамическая память в C управляется вручную: программист выделяет блоки через allocator API и сам отвечает за своевременное освобождение. Это дает контроль и производительность, но требует дисциплины.

Allocator API - это набор функций стандартной библиотеки для управления памятью в куче: malloc, calloc, realloc и free. По сути это контракт между вашей программой и аллокатором: вы запрашиваете блок нужного размера, используете его по назначению и возвращаете обратно через free.

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

Формальные правила
  • malloc/calloc/realloc возвращают указатель на блок или NULL при ошибке.
  • free(NULL) допустим и безопасен.
  • Повторный free одного и того же блока (double free) - undefined behavior.
  • После realloc старый указатель недействителен при успешном переносе.

Выделение и освобождение памяти

malloc выделяет неинициализированный блок, calloc выделяет и обнуляет, аrealloc изменяет размер существующего блока. Освобождение всегда делается через free.

Синтаксис allocator API из <stdlib.h>:

  • void *malloc(size_t size); - выделяет size байт и возвращает указатель на начало блока или NULL при ошибке. Содержимое блока не инициализируется.
  • void *calloc(size_t nmemb, size_t size); - выделяет память под nmemb элементов по size байт и заполняет блок нулями.
  • void *realloc(void *ptr, size_t new_size); - изменяет размер ранее выделенного блока. Может вернуть тот же адрес или новый; при переносе данные копируются в новый блок (в пределах старого/нового размера).
  • void free(void *ptr); - освобождает блок, ранее полученный через allocator API. После этого указатель становится висячим и его нужно считать недействительным.

Безопасный шаблон для realloc: использовать временный указатель. Если сразу присвоить результат в исходный указатель и получить NULL, можно потерять ссылку на уже выделенную память и получить утечку.

Формальные правила
  • Выделенная память принадлежит вызывающему коду и должна быть освобождена ровно один раз через free.
  • free(NULL) допустим и безопасен.
  • Повторный free одного и того же адреса (double free) и доступ к памяти после free (use-after-free) - undefined behavior.
  • При неуспешном realloc возвращается NULL, а исходный блок остается валидным.
  • Корректный контракт: передавать в realloc/free только указатели, полученные из allocator API, либо NULL.
#include <stdio.h>
#include <stdlib.h>

int main(void) {
  size_t n = 3;
  int *arr = malloc(n * sizeof(*arr));
  if (arr == NULL) {
    return 1;
  }

  for (size_t i = 0; i < n; i++) {
    arr[i] = (int)(i + 1) * 10;
  }

  int *tmp = realloc(arr, 5 * sizeof(*arr));
  if (tmp == NULL) {
    free(arr);
    return 1;
  }
  arr = tmp;
  arr[3] = 40;
  arr[4] = 50;

  int *zeroed = calloc(4, sizeof(*zeroed));
  if (zeroed == NULL) {
    free(arr);
    return 1;
  }

  printf("arr: %d %d %d %d %d\n", arr[0], arr[1], arr[2], arr[3], arr[4]);
  printf("zeroed: %d %d %d %d\n", zeroed[0], zeroed[1], zeroed[2], zeroed[3]);

  free(zeroed);
  zeroed = NULL;
  free(arr);
  arr = NULL;
  return 0;
}

Ожидаемый вывод:

arr: 10 20 30 40 50
zeroed: 0 0 0 0

Выделение памяти для двухмерного массива произвольной длины

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

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

#include <stdio.h>
#include <stdlib.h>

int **alloc_matrix(size_t rows, size_t cols) {
  int **m = malloc(rows * sizeof(*m));
  if (m == NULL) {
    return NULL;
  }

  for (size_t i = 0; i < rows; i++) {
    m[i] = malloc(cols * sizeof(*m[i]));
    if (m[i] == NULL) {
      for (size_t k = 0; k < i; k++) {
        free(m[k]);
      }
      free(m);
      return NULL;
    }
  }

  return m;
}

void free_matrix(int **m, size_t rows) {
  if (m == NULL) {
    return;
  }
  for (size_t i = 0; i < rows; i++) {
    free(m[i]);
  }
  free(m);
}

int main(void) {
  size_t rows = 3;
  size_t cols = 4;
  int **m = alloc_matrix(rows, cols);
  if (m == NULL) {
    return 1;
  }

  for (size_t i = 0; i < rows; i++) {
    for (size_t j = 0; j < cols; j++) {
      m[i][j] = (int)(i * 10 + j);
    }
  }

  for (size_t i = 0; i < rows; i++) {
    for (size_t j = 0; j < cols; j++) {
      printf("%d ", m[i][j]);
    }
    printf("\n");
  }

  free_matrix(m, rows);
  return 0;
}

Ожидаемый вывод:

0 1 2 3
10 11 12 13
20 21 22 23

Управление динамической памятью

Управление памятью - это не только вызовы allocator API, но и дисциплина владения: кто создает блок, кто и когда обязан его освободить, допустимо ли разделение владения между модулями.

Практичные правила: фиксировать ownership в API, освобождать ресурсы в одной точке выхода, обнулять указатель после free и исключать double free через утилиты вроде safe_free.

#include <stdio.h>
#include <stdlib.h>

void safe_free(void **pp) {
  if (pp != NULL && *pp != NULL) {
    free(*pp);
    *pp = NULL;
  }
}

int main(void) {
  int *data = malloc(3 * sizeof(*data));
  if (data == NULL) {
    return 1;
  }

  data[0] = 7;
  data[1] = 8;
  data[2] = 9;
  printf("data[1] = %d\n", data[1]);

  safe_free((void **)&data);
  printf("data == NULL -> %d\n", data == NULL);
  return 0;
}

Ожидаемый вывод:

data[1] = 8
data == NULL -> 1

Указатель как результат функции

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

Нельзя возвращать адрес локального массива автоматического хранения. Корректно возвращать либо динамический блок, либо указатель на static-объект при хорошо документированном контракте.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char *build_message(const char *name) {
  const char *prefix = "Hello, ";
  size_t n = strlen(prefix) + strlen(name) + 1;

  char *msg = malloc(n);
  if (msg == NULL) {
    return NULL;
  }

  snprintf(msg, n, "%s%s", prefix, name);
  return msg;
}

int main(void) {
  char *msg = build_message("C programmer");
  if (msg == NULL) {
    return 1;
  }

  printf("%s\n", msg);
  free(msg);
  return 0;
}

Ожидаемый вывод:

Hello, C programmer

Организация памяти программы и структура сегментов

Адресное пространство процесса обычно делят на четыре основные области: сегмент кода (text), сегмент глобальных и статических данных (data/bss), кучу (heap) и стек (stack). Такой разбор помогает сразу понимать, почему одни объекты живут всю программу, а другие - только до выхода из функции.

Сегмент code/text хранит машинные инструкции функций. Сегмент data/bss содержит глобальные и static-объекты, а также литералы. В heap лежат блоки, полученные через malloc/calloc/realloc. В stack размещаются кадры вызовов: параметры, автоматические локальные переменные и служебные данные вызова.

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

В программе ниже это видно по адресам: &local_auto попадает в область стека, heap_value указывает на блок в куче, а &global_value, &global_static и &local_static относятся к сегменту данных.

Адреса в примерах зависят от ОС, рантайма и защиты памяти (ASLR), поэтому важны не конкретные числа, а взаимная роль областей памяти. Для диагностики утечек и выходов за границы полезно подключать ASan/Valgrind.

#include <stdio.h>
#include <stdlib.h>

int global_value = 1;
static int global_static = 2;

int main(void) {
  static int local_static = 3;
  int local_auto_1 = 1;
  int local_auto_2 = 2;
  int *heap_value_1 = malloc(sizeof(*heap_value_1));
  if (heap_value_1 == NULL) {
    return 1;
  }
  int *heap_value_2 = malloc(sizeof(*heap_value_2));
  if (heap_value_2 == NULL) {
    return 1;
  }

  *heap_value_1 = 1;
  *heap_value_2 = 2;

  printf("&global_value  = %p\n", (void *)&global_value);
  printf("&global_static = %p\n", (void *)&global_static);
  printf("&local_static  = %p\n", (void *)&local_static);
  printf("&local_auto_1    = %p\n", (void *)&local_auto_1);
  printf("&local_auto_2    = %p\n", (void *)&local_auto_2);
  printf("heap_value_1     = %p\n", (void *)heap_value_1);
  printf("heap_value_2     = %p\n", (void *)heap_value_2);

  free(heap_value_1);
  free(heap_value_2);
  return 0;
}

Ожидаемый вывод (точные адреса зависят от ОС, ASLR и запуска):

&global_value  = 0x104ca4000
&global_static = 0x104ca4008
&local_static  = 0x104ca4004
&local_auto_1    = 0x16b16288c
&local_auto_2    = 0x16b162888
heap_value_1     = 0x1052b5ec0
heap_value_2     = 0x1052b5ed0
Схема расположения объектов в памяти процессаВертикальная карта памяти: сверху стек с local_auto_1 и local_auto_2, ниже heap с heap_value_1 и heap_value_2, затем bss с global_value, global_static, local_static, внизу code text. Сверху указаны высокие адреса, снизу низкие адреса.Высокие адресаstack&local_auto_1 = 0x16b16288c&local_auto_2 = 0x16b162888рост stackheapheap_value_1 = 0x1052b5ec0heap_value_2 = 0x1052b5ed0рост heapbss&global_value = 0x104ca4000&global_static = 0x104ca4008&local_static = 0x104ca4004code/textинструкции main(), malloc(), printf(), free()Низкие адреса
Схема отражает этот конкретный запуск: она показывает относительное расположение stack, heap, bss и code/text для выведенных адресов.