Управление памятью и безопасный C

Завершающий урок собирает всю модель памяти C воедино, систематизирует классы уязвимостей и даёт практический чеклист и инструменты — valgrind, AddressSanitizer — для написания безопасного кода.
Около 70% уязвимостей в больших C-программах связаны с памятью. Хорошая новость: почти все они сводятся к нескольким известным классам, а современные инструменты ловят их автоматически.

Соберём полную карту памяти программы на C — это фундамент, на котором держится всё, что мы изучали: стек, куча, статические данные и код.

Карта памяти процесса C:
  +-----------------------------+ высокие адреса
  |   СТЕК (stack)              |  локальные переменные, кадры функций
  |   растёт вниз  v            |  авто-управление, быстрый
  |          ...                |
  |          ^                  |
  |   КУЧА (heap)               |  malloc/free, ручное управление
  |   растёт вверх              |  динамические данные
  +-----------------------------+
  |   Статические/глобальные    |  глобальные и static переменные
  +-----------------------------+
  |   Код программы (read-only) |  машинные инструкции, литералы
  +-----------------------------+ низкие адреса

Каждая изученная тема — это работа с одной из этих областей: локальные переменные и рекурсия — стек; malloc — куча; static и глобальные — сегмент данных; строковые литералы — read-only код.

Как работает под капотом

Систематизируем основные классы уязвимостей памяти — те самые 70%. Понимая их, вы узнаёте опасный паттерн ещё на этапе написания:

КЛАСС ОШИБКИ          ЧТО ПРОИСХОДИТ              ПРИМЕР
------------------   ------------------------   ----------------------
Buffer overflow      запись за границу буфера    strcpy в малый массив
Use-after-free       доступ к памяти после free  *p после free(p)
Double free          free одного блока дважды     free(p); free(p);
Memory leak          забыли free                  malloc без free
Null dereference     разыменование NULL           *p при p == NULL
Uninitialized read   чтение неинициализир. данных int x; use(x);
Out-of-bounds read   чтение за границей массива   arr[n] при размере n

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

Частые ошибки

  • Думать, что «у меня код простой, ошибок памяти нет». Даже в коротких программах легко допустить leak или выход за границу.
  • Тестировать только «счастливый путь». Уязвимости проявляются на граничных и некорректных входных данных.
  • Не использовать инструменты. Ручной поиск ошибок памяти ненадёжен; санитайзеры находят их за секунды.
  • Игнорировать предупреждения компилятора. Многие баги памяти компилятор предсказывает заранее.

Best practices

Практический чеклист безопасного C:

  • Компилируйте с предупреждениями и санитайзером: gcc -Wall -Wextra -fsanitize=address,undefined.
  • Инициализируйте каждую переменную и каждый указатель при объявлении.
  • На каждый malloc — один free; после free присваивайте указателю NULL.
  • Перед разыменованием проверяйте указатель на NULL.
  • Для строк и буферов используйте функции с лимитом размера (snprintf, strncpy) и всегда оставляйте место под нуль-терминатор.
  • Храните длину массивов отдельно; никогда не выходите за границы.
  • Регулярно прогоняйте программу под valgrind и тестируйте на некорректных входных данных.

Запустим под нужными флагами и проверим память инструментами:

# Сборка с максимальной диагностикой
gcc -Wall -Wextra -fsanitize=address,undefined prog.c -o prog
./prog

# Проверка утечек и ошибок памяти отдельным инструментом
valgrind --leak-check=full ./prog

Идею «следить за памятью» смоделируем на Python: простой «детектор утечек», который сверяет каждый malloc с free — ровно так мыслит valgrind.

# Мини-детектор утечек: считаем malloc и free
allocated = set()

def malloc(addr):
    allocated.add(addr)
    print(f"malloc -> блок {addr}")

def free(addr):
    if addr not in allocated:
        print(f"ОШИБКА: double free или неверный free({addr})")
        return
    allocated.remove(addr)
    print(f"free   -> блок {addr}")

malloc(100); malloc(200)
free(100)
# забыли free(200) — это утечка
if allocated:
    print("УТЕЧКА: не освобождены блоки", allocated)

Та же логика на Python ▶ — детектор фиксирует, что блок 200 не освобождён. Именно такие отчёты выдаёт valgrind --leak-check=full для настоящих C-программ.

Современный безопасный C

Индустрия не стоит на месте, и за последние годы появилось много способов сделать C безопаснее без потери его силы. На уровне компилятора — санитайзеры (-fsanitize=address,undefined), которые на тестовых прогонах ловят почти все ошибки памяти, и флаги усиления вроде защиты стека. На уровне процесса — статические анализаторы, прогоняющие код без запуска и находящие подозрительные паттерны, и фаззинг, который автоматически забрасывает программу случайными входными данными в поисках падений. На уровне регуляторов — рекомендации CISA 2025 года требовать от вендоров планов по устранению уязвимостей памяти. Поэтому современный профессиональный C — это не «писать осторожно и надеяться», а строить конвейер: предупреждения как ошибки, санитайзеры в тестах, статический анализ и фаззинг в CI. С такой дисциплиной C остаётся быстрым, переносимым и при этом надёжным языком.

Итоги

Память C делится на стек, кучу, статические данные и код — и каждая тема курса работает с одной из этих областей. Около 70% уязвимостей сводятся к семи классам: переполнение буфера, use-after-free, double free, утечки, разыменование NULL, чтение неинициализированных данных и выход за границы. Защита — дисциплина (инициализация, парность malloc/free, проверки, лимиты буферов) плюс инструменты: предупреждения компилятора, AddressSanitizer и valgrind. С ними C из «опасного» превращается в мощный и контролируемый язык.

Проверьте себя
1. Какой инструмент поможет автоматически найти утечки памяти и use-after-free в C-программе?
AТекстовый редактор
Bvalgrind или компиляция с флагом -fsanitize=address
CТолько ручное чтение кода
DОператор sizeof
2. Почему после free(p) рекомендуется писать p = NULL?
AЧтобы освободить память дважды
BЧтобы предотвратить use-after-free и double free: обращение по NULL даст явный сбой, а не работу с освобождённой памятью
CЭто требование стандарта C
DЧтобы ускорить программу