Системное программирование на C Массивы, строки и структуры: раскладка в памяти и выравнивание
0%

Массивы, строки и структуры: раскладка в памяти и выравнивание

Массивы, строки и структуры: раскладка в памяти и выравнивание

В Python list — это объект. В Java String — это объект с полем length. В Go []int — дескриптор из трёх машинных слов. Во всех этих языках между вами и байтами стоит рантайм, который знает, где что лежит и сколько чего. В C такого посредника нет: массив — это просто «столько-то байт подряд»; строка — «байты до первого нуля», и никто, кроме вас, не гарантирует, что этот ноль там есть; структура — «поля в порядке объявления плюс дырки, которые вставил компилятор, чтобы процессору было удобно».

Эта статья — про то, как из деклараций C получаются конкретные байты по конкретным адресам. Мы продолжаем разговор, начатый в «Памяти и указателях»: там был адрес и разыменование, здесь — агрегаты, которые из адресов строятся. Понимание раскладки — не академическое упражнение: от неё зависят размер рабочей нагрузки в оперативной памяти, попадания в кэш, совместимость бинарного протокола и добрая половина CVE в системном софте.

Модель памяти C: объект и его представление

Стандарт C оперирует двумя понятиями. Объект — это область хранения, в которой может быть представлено значение. Представление объекта (object representation) — последовательность из sizeof(T) байт, из которых объект состоит. Ключевое разрешение стандарта: любой объект можно рассматривать как массив unsigned char. Это единственный тип, через который законно смотреть на чужие байты:

void hexdump(const void *p, size_t n) {
    const unsigned char *b = p;   /* законное байтовое окно в любой объект */
    for (size_t i = 0; i < n; i++) printf("%02X ", b[i]);
    putchar('\n');
}
double d = 1.0;
hexdump(&d, sizeof d);   /* 00 00 00 00 00 00 F0 3F на little-endian */

Отсюда три следствия, которые надо запомнить сразу:

  1. sizeof(T) — это размер представления в байтах, а sizeof(char) == 1 по определению. Не «обычно 1», а по определению: байт в C — это то, что занимает char.
  2. У каждого типа есть требование выравнивания_Alignof(T) (в C11, через <stdalign.h> доступно как alignof). Адрес объекта типа T обязан быть кратен этому числу.
  3. Представление может содержать padding-байты, значения которых не определены. Сравнивать структуры через memcmp поэтому нельзя — одна из самых коварных ошибок, разберём ниже.

Как биты кодируют числа и почему 1.0 выглядит как 3F F0 ..., разбирается в «Представлении данных» — здесь принимаем это как данность и смотрим на компоновку.

Массивы: смежность и её последствия

Массив в C — это N объектов типа T, лежащих подряд, без промежутков. Стандарт гарантирует именно это, и из одной гарантии выводится всё остальное.

Индексация — это арифметика указателей

Выражение a[i] определено как *(a + i). Не «похоже на», а буквально: это синтаксический сахар. Отсюда знаменитый курьёз — a[i] и i[a] — одно и то же, потому что сложение коммутативно (писать так, конечно, не надо). Посмотрим, во что это превращается:

struct S { char a; int b; double d; };
struct P3 { float x, y, z; };
int    get_elem(const int *a, long i)    { return a[i];    }
int    get_b(const struct S *s)          { return s->b;    }
double get_d(const struct S *s)          { return s->d;    }
float  get_z(const struct P3 *p, long i) { return p[i].z;  }

gcc -O2 -S access.c — реальный вывод GCC 13 на x86-64 после отбрасывания директив:

get_elem:
	movl	(%rdi,%rsi,4), %eax      ; base + index*4 — одна инструкция
	ret
get_b:
	movl	4(%rdi), %eax            ; смещение поля b известно на этапе компиляции
	ret
get_d:
	movsd	8(%rdi), %xmm0           ; d лежит по смещению 8, а не 5
	ret
get_z:
	leaq	(%rsi,%rsi,2), %rax      ; rax = i*3
	movss	8(%rdi,%rax,4), %xmm0    ; base + i*12 + 8
	ret

Это и есть суть «низкоуровневости»: индексация массива — режим адресации процессора (base + index*scale), доступ к полю структуры — константное смещение, никакой хеш-таблицы имён в рантайме нет. Масштаб в (%rdi,%rsi,4) ограничен значениями 1, 2, 4, 8 — для struct P3 размером 12 компилятору пришлось сначала посчитать i*3 через lea, а потом домножить на 4 адресацией. И обратите внимание на get_d: поле d лежит по смещению 8, хотя «по-наивному» после char и int должно было бы быть 5. Почему — разберём в разделе про структуры.

Распад массива в указатель

Самая частая ловушка для тех, кто пришёл из языков с настоящими массивами. В большинстве контекстов имя массива распадается (decays) в указатель на его первый элемент. Исключения ровно три: операнд sizeof, операнд & и строковый литерал в инициализаторе.

size_t count_bad(int a[10]) { return sizeof a; }               /* вернёт 8! */
size_t count_ok(const int *a, size_t n) { (void)a; return n; }
int arr[10];
sizeof arr;             /* 40 — здесь arr ещё настоящий массив */
count_bad(arr);         /* 8  — внутри функции это уже int *  */
count_ok(arr, sizeof arr / sizeof arr[0]);   /* 10 */

int a[10] в списке параметров — это ложь синтаксиса: компилятор молча переписывает его в int *a. Десятка игнорируется полностью, можно написать int a[9999] — ничего не изменится. GCC об этом честно предупреждает: warning: 'sizeof' on array function parameter 'a' will return size of 'int *' [-Wsizeof-array-argument].

Практический вывод: длина массива не путешествует вместе с массивом. Её надо передавать явно вторым параметром — это и есть основная причина, по которой в C так легко выйти за границы, и ровно то, что Rust и Go чинят срезами (slice = указатель + длина). Идиома sizeof arr / sizeof arr[0] работает только в области видимости объявления; в ядре Linux поверх неё есть макрос ARRAY_SIZE с __builtin_types_compatible_p, который превращает подстановку указателя в ошибку компиляции.

Многомерные массивы: строка за строкой

int m[3][4] — это не «массив указателей на массивы», а массив из трёх объектов типа int[4], лежащих подряд. Всего 48 байт одним куском, row-major: сначала вся нулевая строка, потом первая, потом вторая.

int m[3][4];
sizeof m;                /* 48 — весь массив одним куском           */
sizeof m[0];             /* 16 — одна строка из 4 int               */
&m[1][0] - &m[0][0];     /* 4  — следующая строка ровно через 4 int */
void fill(size_t rows, size_t cols, int m[rows][cols]) { /* VLA-параметр, C99 */
    for (size_t i = 0; i < rows; i++)
        for (size_t j = 0; j < cols; j++) m[i][j] = (int)(i * cols + j);
}

Именно из-за row-major обход for (i) for (j) m[i][j] в разы быстрее обхода for (j) for (i) m[i][j]: первый идёт по памяти линейно и попадает в предвыборку, второй прыгает через шаг строки и на больших матрицах промахивается мимо кэша на каждом шаге («Кэш и локальность»).

В fill параметр m всё равно распадается в int (*)[cols], но компилятор теперь знает шаг строки и сам считает i*cols + j. А вот VLA как локальные переменные (int buf[n];) — отдельная история: они живут на стеке, размер не проверяется, и переполнение стека при большом n — реальная уязвимость. В ядре Linux VLA запрещены с 2018 года, в MISRA C — тоже. Используйте malloc (см. «Динамическую память») или фиксированный верхний предел с проверкой.

Выход за границы — это UB, а не «мусор»

Чтение a[10] в массиве из десяти элементов не «вернёт мусор». Это неопределённое поведение, и оптимизатор имеет право предположить, что такого не случается:

int f(int *a, int i) {
    a[i] = 1;
    if (i < 0 || i >= 10) return -1;  /* проверка ПОСЛЕ доступа — будет удалена */
    return 0;                         /* компилятором целиком                   */
}

Компилятор рассуждает так: a[i] уже выполнен, значит i был валиден, значит проверка всегда ложна — и выбрасывает её целиком. Это не злой умысел, а следствие контракта; правило простое — проверяйте до, а не после (механика таких выводов разбирается в «Неопределённом поведении»). Отдельная тонкость: указатель «на один за концом» (a + n) формировать можно, разыменовывать — нельзя; это сделано ради идиомы for (int *p = a; p != a + n; ++p). А вот a - 1 — уже UB, даже если вы его не разыменуете.

Строки: соглашение, а не тип

В C нет строкового типа. Есть соглашение: «строка — это последовательность байт, заканчивающаяся байтом '\0'». Всё, что делает <string.h>, построено на этом соглашении, и всё ломается, когда соглашение нарушено.

Строка в памяти: массив на стеке против указателя на литерал в .rodata

Два способа «объявить строку» — и они разные

char        s[] = "hi";   /* массив из 3 байт в кадре стека, копия литерала  */
const char *p   = "hi";   /* указатель на 8 байт, литерал лежит в .rodata    */
sizeof s;   /* 3 — 'h', 'i', '\0' */
sizeof p;   /* 8 — размер указателя, к длине строки отношения не имеет */
s[0] = 'H'; /* законно: своя копия */
p[0] = 'H'; /* UB: литерал в read-only странице, на Linux — SIGSEGV */

Строковые литералы в C имеют тип char[N] (а не const char[N] — историческая рана языка), но модифицировать их нельзя: компилятор вправе класть их в защищённые от записи страницы и склеивать одинаковые литералы в один объект. Пишите const char * всегда, -Wwrite-strings включит проверку.

Цена NUL-терминации

Длина строки не хранится, поэтому strlen(s) — линейный проход, O(n) по времени и O(1) по памяти. Отсюда три следствия. Наивная конкатенация в цикле for (...) strcat(dst, piece)O(n²), потому что каждый strcat заново ищет конец: это знаменитая «проблема Шлемиэля-маляра» из эссе Джоэла Спольски. Подстрока не может быть представлена без копирования — нельзя «указать на кусок», конец обозначается нулём, а нуля в середине нет. И, как результат, любой серьёзный C-проект заводит свой строковый тип { const char *ptr; size_t len; }: так устроены sds в Redis, bstring, ngx_str_t в nginx, struct iovec в POSIX.

Небезопасные функции и чем их заменять

Опасно Почему Замена
strcpy(dst, src) нет предела записи snprintf / strlcpy / memcpy с явной длиной
strcat(dst, src) то же + O(n²) в цикле аккумулятор с курсором (см. ниже)
sprintf(buf, ...) нет предела snprintf(buf, sizeof buf, ...)
gets(s) предела нет в принципе удалена из стандарта в C11
strncpy(d, s, n) не гарантирует '\0' snprintf или ручная терминация

strncpy — самая обманчивая функция стандартной библиотеки. Она никогда не задумывалась как «безопасный strcpy»: её писали для полей фиксированной длины в старых форматах файлов UNIX. Если src короче n — добивает нулями до n (лишняя работа); если длиннее или равен — нуля не будет вообще. Классическая ошибка: char name[16]; strncpy(name, user_input, sizeof name); printf("%s\n", name); — при вводе от 16 байт printf читает за границей массива.

Безопасный аккумулятор

Правильная схема сборки строки — курсор плюс липкий флаг переполнения. snprintf возвращает сколько байт потребовалось бы, а не сколько записал, — на этом и строится проверка:

typedef struct { char *buf; size_t cap; size_t len; int overflow; } SB;

void sb_init(SB *sb, char *buf, size_t cap) {
    sb->buf = buf; sb->cap = cap; sb->len = 0; sb->overflow = 0;
    if (cap) buf[0] = '\0';
}
void sb_append(SB *sb, const char *fmt, ...) {
    if (sb->overflow || sb->len >= sb->cap) { sb->overflow = 1; return; }
    va_list ap;  va_start(ap, fmt);
    int r = vsnprintf(sb->buf + sb->len, sb->cap - sb->len, fmt, ap);
    va_end(ap);
    if (r < 0) { sb->overflow = 1; return; }              /* ошибка кодирования */
    if ((size_t)r >= sb->cap - sb->len) {                 /* усечение           */
        sb->len = sb->cap - 1; sb->overflow = 1; return;
    }
    sb->len += (size_t)r;                                 /* O(1), конец не ищем */
}
/* итог для буфера в 24 байта: 'user=root;uid=0;home=/v', len=23, overflow=1 */

Строка корректно оборвана, флаг взведён, вызывающий код отдаёт ошибку наверх вместо тихого повреждения данных. Сложность сборки из k кусков суммарной длины nO(n) по времени против O(n²) у наивного strcat, по памяти O(1) сверх самого буфера.

Байты — не символы

char — это байт, а не символ. В UTF-8 «привет» занимает 12 байт, strlen вернёт 12, а s[3] укажет на середину кодовой точки. Практические правила: обрабатывайте UTF-8 как байты там, где можно (поиск подстроки, сравнение, копирование работают, потому что UTF-8 самосинхронизируется); подключайте ICU или utf8proc там, где нужны настоящие операции над текстом (нормализация, регистр, границы графем); никогда не «обрезайте по N байт» без проверки границы кодовой точки — получите битую последовательность.

Структуры: порядок, выравнивание, padding

Теперь главное. Стандарт C даёт две гарантии и одну свободу:

  • Гарантия 1: поля лежат в памяти в порядке объявления, смещения строго возрастают. Компилятор не имеет права переставлять поля (в отличие от Rust, где repr(Rust) разрешает перестановку).
  • Гарантия 2: смещение первого поля равно нулю. Поэтому указатель на структуру можно привести к указателю на её первое поле — на этом стоит всё «наследование в C» вроде struct sockaddr.
  • Свобода: между полями и в конце компилятор может вставлять сколько угодно padding-байт.

Откуда берётся padding

Процессор читает память словами. Загрузка int по адресу, не кратному четырём, на x86-64 работает, но медленнее (на границе строки кэша — сильно медленнее); на многих ARM, MIPS, SPARC — вызывает аппаратное исключение. Поэтому ABI платформы требует, чтобы объект типа T лежал по адресу, кратному alignof(T), и компилятор обеспечивает это, раздвигая поля. Алгоритм полностью механический:

Прогоним алгоритм на живом примере — реальный вывод программы с offsetof на x86-64 Linux, GCC 13:

struct bad  { char a; int b; char c; double d; short e; };
struct good { double d; int b; short e; char a; char c; };
/* bad:  size=32 align=8 | a=0 b=4 c=8 d=16 e=24  */
/* good: size=16 align=8 | d=0 b=8 e=12 a=14 c=15 */

Те же пять полей — разница вдвое:

Раскладка struct bad и struct good по байтам с padding

Эвристика, покрывающая 95% случаев: объявляйте поля от большего требования выравнивания к меньшему. Указатели и double сверху, потом int, потом short, потом char и bool, флаги — в битовые поля или один uint32_t. Дырки схлопываются сами.

Как это увидеть, а не угадывать

Три инструмента, от простого к точному. Первый — offsetof из <stddef.h>: работает везде, ставить ничего не надо. Второй — предупреждение компилятора, clang -Wpadded печатает точные числа:

warning: padding struct 'struct bad' with 3 bytes to align 'b' [-Wpadded]
warning: padding struct 'struct bad' with 7 bytes to align 'd' [-Wpadded]
warning: padding size of 'struct bad' with 6 bytes to alignment boundary [-Wpadded]

У GCC формулировка беднее (без количества байт в первых двух случаях). Глобально -Wpadded включать не стоит — он шумит на любой разумной структуре; включайте точечно на горячих типах. Третий — pahole из пакета dwarves: читает DWARF из собранного с -g бинаря и печатает раскладку с дырками, это стандартный инструмент разработчиков ядра Linux.

$ gcc -g -c struct.c && pahole struct.o
struct bad {
	char    a;   /*  0  1 */
	/* XXX 3 bytes hole, try to pack */
	int     b;   /*  4  4 */
	char    c;   /*  8  1 */
	/* XXX 7 bytes hole, try to pack */
	double  d;   /* 16  8 */
	short   e;   /* 24  2 */
	/* size: 32, members: 5, sum members: 16    */
	/* holes: 2, sum holes: 10, padding: 6      */
};

Зафиксируйте раскладку тестом

pahole --reorganize даже предложит перестановку полей, но если структура участвует в бинарном контракте (формат файла, разделяемая память, FFI), раскладку надо ещё и проверять на этапе компиляции. Это стоит ноль рантайма и ловит расхождение ABI при смене платформы или вставке поля в середину:

struct WireHdr { uint8_t ver; uint8_t kind; uint16_t len; uint32_t crc; };
static_assert(sizeof(struct WireHdr) == 8,        "неожиданная раскладка WireHdr");
static_assert(offsetof(struct WireHdr, crc) == 4, "crc сдвинулся");
static_assert(alignof(struct WireHdr) == 4,       "изменилось выравнивание");

memcmp по структурам — почти всегда баг

Padding-байты имеют неопределённые значения. Две структуры с одинаковыми полями могут отличаться в дырках:

struct P { char a; int b; };
struct P x = {1, 2};
struct P y;  y.a = 1;  y.b = 2;
memcmp(&x, &y, sizeof x);   /* может вернуть != 0 — байты 1..3 разные */

Инициализация через = {0} или memset перед заполнением делает поведение предсказуемым на практике, но стандарт этого не обещает (присваивание полю может «протухнуть» padding заново). Пишите явное поэлементное сравнение. По той же причине опасно отдавать структуру наружу целиком: вы утекаете содержимое дырок, а это целая серия CVE об утечке неинициализированных байт из ядра — тема разбирается в «Безопасном кодировании».

Packed-структуры: когда очень хочется, но не надо

Соблазн описать сетевой пакет структурой один в один:

struct __attribute__((packed)) Hdr { unsigned char ver; unsigned int id; unsigned short flags; };
/* sizeof == 7, alignof == 1 — вместо 12 и 4 у обычной структуры */

Работает, размер сходится. Проблема появляется, как только вы возьмёте адрес поля — const unsigned int *p = &g.id; даёт указатель, кратный единице, тогда как int требует четырёх. GCC предупреждает, UBSan подтверждает в рантайме:

warning: taking address of packed member of 'struct Hdr' may result in an
         unaligned pointer value [-Waddress-of-packed-member]

runtime error: load of misaligned address 0x5ba9aa1fa011 for type
               'const unsigned int', which requires 4 byte alignment

На x86-64 программа при этом «работает» — процессор терпит невыровненный доступ. На ARMv7 с включённой проверкой, на SPARC, на некоторых DSP тот же код упадёт с SIGBUS. Это ровно тот случай, когда «работает на моей машине» опаснее всего: баг существует всегда, но проявляется только на целевой платформе, обычно в проде. Обращение к полю напрямую (g.id, без взятия адреса) безопасно — компилятор знает про packed и сгенерирует побайтовую сборку. Опасно именно рождение указателя, который потом уплывает в другую функцию.

Правильный способ работы с бинарными форматами — явная сериализация:

static size_t put_u32be(unsigned char *out, uint32_t v) {
    out[0] = (unsigned char)(v >> 24);  out[1] = (unsigned char)(v >> 16);
    out[2] = (unsigned char)(v >>  8);  out[3] = (unsigned char)(v);
    return 4;
}
static uint32_t get_u32be(const unsigned char *in) {
    return ((uint32_t)in[0] << 24) | ((uint32_t)in[1] << 16)
         | ((uint32_t)in[2] <<  8) |  (uint32_t)in[3];
}

Десяток строк, ноль зависимости от endianness, ноль зависимости от ABI, ноль UB. Современные компиляторы распознают этот паттерн и схлопывают его в movbe или mov + bswap — вы не платите за портируемость. memcpy структуры в сокет или файл — это, наоборот, гарантированная несовместимость между архитектурами и утечка padding-байт в эфир.

Битовые поля

struct Flags { unsigned ready : 1; unsigned kind : 3; unsigned seq : 12; }; — компактно и выразительно, но порядок битов внутри слова, направление упаковки и поведение на границе единицы хранения определяются реализацией. Для внутренних структур программы это нормально; для бинарного протокола — нет, собирайте и разбирайте маски вручную ((v >> 4) & 0x7). В ядре Linux битовые поля используются, но никогда для формата на проводе.

Гибкий член массива

Идиома «заголовок плюс переменный хвост одним куском памяти» — C99, §6.7.2.1p18:

struct Record { uint32_t id; uint16_t len; char data[]; };  /* data — только последним */

struct Record *record_new(uint32_t id, const char *s) {
    size_t n = strlen(s);
    if (n > UINT16_MAX) return NULL;              /* проверка ДО арифметики */
    struct Record *r = malloc(sizeof *r + n + 1); /* заголовок + хвост      */
    if (!r) return NULL;
    r->id = id;  r->len = (uint16_t)n;
    memcpy(r->data, s, n + 1);
    return r;
}

Одна аллокация вместо двух, одна ссылка вместо двух, данные рядом с заголовком в кэше. sizeof(struct Record) равен 8 — гибкий член в размер не входит, но хвостовой padding заголовка учитывается, так что sizeof *r + n + 1 — правильная формула. Опасность здесь одна: переполнение при вычислении размера. Если n приходит из сети и вы пишете malloc(sizeof *r + n) без проверки, большое n даст обёртку и крошечную аллокацию, а потом memcpy запишет гигабайты. Это целый класс CVE — проверяйте предел явно, до сложения.

Объединения и type punning

union занимает столько, сколько самый большой член, с выравниванием самого требовательного. В C (в отличие от C++) чтение неактивного члена определено — это законный способ переинтерпретации байт. Но переносимее и понятнее memcpy:

static uint32_t float_bits(float f) {
    uint32_t u;
    memcpy(&u, &f, sizeof u);   /* работает везде, оптимизируется в ноль */
    return u;                   /* float_bits(1.0f) == 0x3F800000        */
}

Почему не *(uint32_t*)&f? Потому что это нарушение strict aliasing: стандарт разрешает компилятору считать, что объекты несовместимых типов не пересекаются в памяти. Под -O2 GCC на этом основании переставляет загрузки и записи, и вы получаете «невозможные» результаты. memcpy — единственный универсально корректный способ, и компилятор не генерирует вызов для маленьких размеров. Флаг -fno-strict-aliasing, на котором собирается ядро Linux, отключает правило, но это признание, что код не соответствует стандарту, а не решение.

Выравнивание за пределами padding

Раскладка структуры — результат сговора четырёх сторон, и знать надо все четыре:

alignas, aligned_alloc и ложное разделение

Иногда выравнивание нужно сильнее, чем требует тип: 16 или 32 байта для SIMD, 64 — под строку кэша, 4096 — под страницу для O_DIRECT или mmap.

alignas(64) static double table[512];      /* статически, <stdalign.h>        */
double *v = aligned_alloc(64, 4096);       /* C11: размер кратен выравниванию */
free(v);                                   /* обычный free, не _aligned_free  */
struct Counters { alignas(64) _Atomic long a; alignas(64) _Atomic long b; };

Нюанс aligned_alloc: по C11 размер должен быть кратен выравниванию (в C17 требование смягчено, glibc не проверяет, MSVC живёт по своим правилам с _aligned_malloc); на POSIX переносимее posix_memalign. Обычный malloc уже гарантирует alignof(max_align_t) — на x86-64 Linux это 16 байт, чего хватает для любого скалярного типа, включая long double (подробнее — в «Динамической памяти»).

Зачем alignas(64) в Counters: строка кэша на x86-64 — 64 байта (getconf LEVEL1_DCACHE_LINESIZE). Если два потока пишут в разные переменные, попавшие в одну строку, ядра гоняют эту строку туда-сюда по протоколу когерентности, и производительность падает на порядок при полном отсутствии логической конкуренции. Это ложное разделение, тема развивается в «Потоках и синхронизации».

AoS против SoA

Раскладка определяет не только размер, но и скорость — вот классический выбор:

Array of Structures (Particle items[N]) удобнее писать и лучше, когда вы почти всегда трогаете все поля одного объекта. Structure of Arrays (float x[N]; float y[N]; ...) выигрывает, когда горячий цикл читает подмножество полей: меньше трафика в память, работает векторизация. Разница на реальных нагрузках — 2–5 раз; игровые движки и колоночные СУБД выбирают SoA не из эстетики.

Инструменты: как ошибаться реже

Безопасность памяти в C — инженерная дисциплина, а не героизм. Три уровня защиты, все дешёвые. Уровень 1 — компилятор, базовый набор для любого нового проекта:

gcc -std=c17 -O2 -g -Wall -Wextra -Wpedantic -Wshadow -Wconversion \
    -Wstrict-prototypes -Wvla -Wcast-align -Wwrite-strings \
    -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fno-common prog.c -o prog

_FORTIFY_SOURCE подменяет strcpy, memcpy, sprintf версиями с проверкой, когда размер назначения известен компилятору. На Ubuntu он включён по умолчанию, и это заметно: strcpy в char buf[8] из 17-байтового источника ловится на этапе компиляции (warning: '__builtin___strcpy_chk' writing 17 or more bytes into a region of size 8 overflows the destination), а если размер известен только в рантайме — процесс аккуратно завершается с *** buffer overflow detected *** вместо того, чтобы стать вектором атаки.

Уровень 2 — санитайзеры: gcc -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer. ASan даёт замедление примерно вдвое и втрое больше памяти — в CI это ничто по сравнению с ценой находки. Реальный отчёт на том же strcpy:

==4151298==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7344d0500028
WRITE of size 17 at 0x7344d0500028 thread T0
    #0 in strcpy asan_interceptors.cpp:563
    #1 in main ovf.c:7
Address 0x7344d0500028 is located in stack of thread T0 at offset 40 in frame
    [32, 40) 'buf' (line 5) <== Memory access at offset 40 overflows this variable

Точный файл, точная строка, точная переменная — сравните с «segfault где-то потом». UBSan ловит другой класс: невыровненный доступ, переполнение знакового целого, сдвиги за разрядность, разыменование NULL. В CI обязательно UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1, иначе UBSan напечатает предупреждение и продолжит — тест пройдёт «зелёным».

Уровень 3 — Valgrind (valgrind --leak-check=full --track-origins=yes ./prog). Не требует пересборки и ловит чтение неинициализированной памяти, чего ASan не умеет. Замедление в 20–50 раз, зато можно натравить на готовый бинарь. Подробный разбор всех трёх — в «Отладке и диагностике». Правило для команды: в CI собираются две конфигурации — релизная с -O2 и отладочная с санитайзерами, тесты гоняются на обеих. UB часто проявляется только под оптимизацией, а санитайзеры видят только то, что реально исполнилось; ни один подход не полон, вместе они закрывают большую часть.

Типичные ошибки

Чек-лист для код-ревью:

  1. sizeof от параметра-массива. Вернёт размер указателя — передавайте длину явно.
  2. strncpy без ручной терминации. Не строковая функция; используйте snprintf.
  3. char *p = "literal"; p[0] = 'X'; — запись в read-only память.
  4. Off-by-one в размере буфера. char buf[N] вмещает N-1 символов плюс '\0'.
  5. Проверка границ после доступа. Оптимизатор её удалит.
  6. memcmp структур. Padding-байты не определены.
  7. Взятие адреса поля packed-структуры. UB, ломается на ARM.
  8. memcpy структуры в файл или сокет. Несовместимость ABI плюс утечка padding.
  9. malloc(sizeof *r + n) без проверки n. Переполнение размера.
  10. *(uint32_t*)&f для type punning. Нарушение strict aliasing, берите memcpy.
  11. Указатель на локальный массив, возвращённый из функции. Кадр стека уже мёртв.
  12. VLA с размером из внешнего ввода. Переполнение стека.

Мини-итог

  • Массив — это байты подряд, и больше ничего: ни длины, ни границ. a[i] — это *(a + i), а в машинном коде — режим адресации. Почти везде массив распадается в указатель, поэтому длину надо носить с собой отдельно; это фундаментальный источник ошибок в C и главное, что чинят срезы в современных языках.
  • Строка — соглашение «байты до нуля». Длина не хранится, strlen линеен, подстрока требует копии. Для серьёзной работы с текстом заводите свой тип с явной длиной.
  • Структура: порядок полей сохраняется, padding вставляется под требования выравнивания, размер округляется вверх. Объявление от большего выравнивания к меньшему часто сокращает размер вдвое.
  • packed решает одну проблему и создаёт худшую — невыровненные указатели. Для бинарных форматов пишите явную сериализацию по байтам. Раскладка — часть ABI: если она важна, зафиксируйте её static_assert и проверьте pahole.
  • Инструменты не опциональны: -Wall -Wextra, _FORTIFY_SOURCE, ASan и UBSan в CI, Valgrind по необходимости. Это дешевле любого инцидента.

Источники

Что дальше

Мы разобрали, как данные лежат в памяти, которая уже есть, — в статических объектах и на стеке. Следующий шаг: откуда берётся память, размер которой неизвестен на этапе компиляции, кто за неё отвечает и что происходит, когда ответственность теряется.

Динамическая память: malloc, free, утечки, фрагментация, свои аллокаторы

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов