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

Память и указатели: адреса, разыменование, арифметика указателей

Память и указатели: адреса, разыменование, арифметика указателей

Зачем вообще нужны указатели

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

  1. Изменение чужих данных. Передача по значению копирует; чтобы функция изменила объект вызывающего, надо передать его адрес.
  2. Данные переменного размера и времени жизни. Список, дерево, буфер под ответ сети — размер неизвестен на этапе компиляции, значит объект живёт вне стека, и добраться до него можно только по адресу.
  3. Прямой доступ к железу и системе. Регистр периферии, отображённый файл, буфер DMA — это адрес плюс договорённость о том, что по нему лежит.

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

Память глазами программы

Для программы на C память — это один непрерывный массив байтов с номерами-адресами. Байт в C — это unsigned char, его ширина равна CHAR_BIT из <limits.h> (на машинах общего назначения — 8, на некоторых DSP 16 или 32).

Стандарт вводит понятие объекта: «область хранения данных, содержимое которой может представлять значения» (ISO/IEC 9899, п. 3.15). Это не про ООП: объект — это int x, элемент массива, поле структуры, кусок, возвращённый malloc. У объекта есть размер (sizeof), выравнивание (_Alignof), время жизни (статическое, автоматическое, выделенное, потоковое) и эффективный тип, по которому компилятор рассуждает об алиасинге.

Указатель хранит адрес объекта — и, на уровне рассуждений компилятора, знание о том, какому объекту этот адрес принадлежит. Второе не лежит в битах, но существует в модели языка. Именно поэтому «арифметически правильный» указатель бывает некорректным.

Адресное пространство процесса

Адрес, который печатает %p, — виртуальный: ядро и MMU переводят его в физический через таблицы страниц. Механизм разобран в управлении памятью ОС; нам важна карта.

Карта виртуального адресного пространства процесса Linux x86-64

Практический вывод: по адресу почти всегда угадывается происхождение объекта. 0x7fff… — стек. 0x7f… покороче — разделяемая библиотека или крупный mmap. 0x55… — код или данные PIE-образа, чуть выше — куча. Это первое, на что смотрят в отладчике, когда указатель «странный».

/* layout.c — где живут переменные.
   gcc -O0 -g -Wall -Wextra -o layout layout.c && setarch -R ./layout */
#include <stdio.h>
#include <stdlib.h>

int   g_init  = 1;              /* .data — есть начальное значение */
int   g_zero;                   /* .bss  — обнуляется загрузчиком  */
const char *g_msg = "привет";   /* указатель в .data, литерал в .rodata */

int main(void)
{
    int   local = 0;
    void *heap  = malloc(16);
    /* приведение указателя на функцию к void * гарантирует POSIX, но не ISO C */
    printf("код %p  .rodata %p  .data %p\n.bss %p  куча %p  стек %p\n",
           (void *)(size_t)main, (void *)g_msg, (void *)&g_init,
           (void *)&g_zero, heap, (void *)&local);
    free(heap);
}
код 0x555555555169  .rodata 0x555555556004  .data 0x555555558010
.bss 0x555555558018  куча 0x5555555592a0     стек 0x7fffffffe2ec

Порядок ровно такой, как на схеме. Полную карту процесса с правами и файлами-источниками даёт /proc/self/maps (формат — в proc(5)). Запустите программу дважды без setarch -R — адреса будут разными: это ASLR, и он существует потому, что предсказуемые адреса удобны атакующему.

Указатель: тип, значение, размер

Синтаксис деклараций C — самая недружелюбная его часть. Правило: читать от имени вправо, пока можно, потом влево; скобки меняют приоритет.

int   *p;           /* p — указатель на int                                */
int   *a[10];       /* a — массив из 10 указателей на int                  */
int  (*b)[10];      /* b — указатель на массив из 10 int                   */
int   *f(void);     /* f — функция, возвращающая указатель на int          */
int  (*g)(void);    /* g — указатель на функцию, возвращающую int          */
char *(*h[4])(int); /* h — массив из 4 указателей на функции int -> char * */
int *x, y;          /* ЛОВУШКА: x — указатель, y — обычный int             */
typedef int (*compare_fn)(const void *, const void *);  /* так читаемо     */

Проверять себя на cdecl.org не стыдно — это делают все, включая авторов компиляторов.

На LP64 (Linux и macOS x86-64, ARM64) sizeof(void *) == 8, на ILP32 и 32-битных микроконтроллерах — 4. Стандарт не обещает, что все указатели одного размера: указатели на функции формально могут отличаться от указателей на данные. Отсюда правило: не храните указатель в int и не полагайтесь на sizeof(void *) == sizeof(long). Для целочисленного представления адреса есть uintptr_t из <stdint.h>: печатается как printf("0x%" PRIxPTR, (uintptr_t)p), а проверка выравнивания пишется как ((uintptr_t)p & (align - 1)) == 0 — считать надо на целом, а не на указателе.

& и *: что происходит на уровне машины

Разыменование — не «магия компилятора», а одна инструкция обращения к памяти. Проверить это дешевле, чем спорить: gcc -O2 -S -masm=intel -fverbose-asm deref.c -o - или objdump -d -M intel deref.o.

int  deref(int *p)         { return *p;    }
int  at(int *p, long i)    { return p[i];  }
long diff(int *a, int *b)  { return a - b; }

Аргументы приходят в rdi и rsiSystem V AMD64 ABI, подробнее в ассемблере для программиста:

deref:  mov     eax, DWORD PTR [rdi]        ; загрузить 4 байта по адресу
        ret
at:     mov     eax, DWORD PTR [rdi+rsi*4]  ; база + индекс*масштаб — одна инструкция
        ret
diff:   mov     rax, rdi
        sub     rax, rsi                    ; разность в БАЙТАХ
        sar     rax, 2                      ; делим на 4 = sizeof(int)
        ret

Три наблюдения, которые стоят целой лекции:

  • Разыменование — это mov из памяти. Проверки «а валиден ли указатель» в коде нет и быть не может: платить пришлось бы на каждом обращении, и C сознательно за это не платит.
  • Индексация бесплатна: режим адресации база + индекс*масштаб встроен в процессор. Отсюда и ограничение масштаба значениями 1/2/4/8 — массив структур по 12 байт уже потребует умножения.
  • Разность указателей делится на размер элемента. Компилятор знает sizeof(int) и вставляет сдвиг. Это буквальное доказательство того, что арифметика указателей работает в единицах типа, а не байтов.

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

Шаг арифметики указателей на массиве int

p + 1 — это не «адрес + 1», а «адрес + sizeof(*p)». Поэтому тип указателя важен даже там, где числовое значение адреса одинаково:

int   a[4] = {1, 2, 3, 4};
int  *p = a;             /* массив «распадается» в указатель на первый элемент */
char *c = (char *)a;     /* тот же адрес, другой шаг                           */

printf("%td %td\n", (char *)(p + 1) - (char *)p, (c + 1) - c);   /* 4 1 */

Разрешённые операции — короткий список; всё, чего в нём нет, запрещено:

Операция Результат Условие корректности
p + n, p - n, ++p указатель того же типа результат внутри объекта или на один элемент за концом
p - q ptrdiff_t оба указывают в один и тот же массив
p == q, p != q int всегда определено для указателей одного типа
p < q, p > q int только внутри одного массива или объекта
*p, p[i], p->f lvalue указатель валиден и не «на один за концом»
p + n, где pvoid * вне стандарта; GCC и Clang разрешают как расширение, шаг 1

Разность печатается через %td. Хранить её в int — старая привычка, ломающаяся на массивах больше 2 ГиБ.

Правило «на один за концом»

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

int a[4];
int *end = a + 4;             /* легально: one-past-the-end          */
for (int *it = a; it != end; ++it)
    *it = 0;                  /* end никогда не разыменовывается     */

int *bad  = a + 5;            /* UB уже здесь — в момент вычисления  */
int *bad2 = a - 1;            /* UB: «на один перед началом» нельзя  */

a + 5 — неопределённое поведение до всякого разыменования. На x86 ничего не «упадёт», но компилятор вправе рассуждать так, будто этого не бывает. Отсюда исчезновение проверок вида if (p + n < p): переполнения указателя, по мнению компилятора, не существует, и условие выкидывается целиком. Правильная проверка формулируется в целых: if (n > SIZE_MAX - offset).

По стандарту a[i] определено ровно как *(a + i), а сложение коммутативно — поэтому a[i] == i[a], и 3["hello"] даёт 'l'. Практической ценности ноль, но напоминание точное: массив в C — не контейнер, а адрес плюс правило пересчёта индекса.

void * и приведения

void * — «указатель на неизвестное»: не разыменовывается, не участвует в арифметике по стандарту, но любой указатель на объект конвертируется в него и обратно без потерь. Это единственный механизм обобщённости в C:

static int cmp_int(const void *a, const void *b)
{
    int x = *(const int *)a, y = *(const int *)b;  /* обратно к настоящему типу */
    return (x > y) - (x < y);   /* НЕ x - y: разность int может переполниться */
}
int data[] = {5, 3, 9, 1};
qsort(data, 4, sizeof data[0], cmp_int);

(x > y) - (x < y) вместо x - y — не педантизм: на данных вида INT_MIN и 1 разность переполняется, это UB, и сортировка ломается. Такое находят через -fsanitize=undefined, а не глазами. И наоборот: приведение void * к типизированному указателю в C неявное, поэтому писать (int *)malloc(...) не нужно — привычка из C++, иногда маскирующая забытый #include <stdlib.h>.

const, volatile, restrict

Квалификатор относится к тому, что слева; если слева ничего нет — к тому, что справа.

const char *p;        /* указатель на const char: *p менять нельзя, p — можно */
char * const q;       /* const-указатель на char: q менять нельзя, *q — можно */
const char * const r; /* нельзя ничего                                        */
char *evil = (char *)"литерал";
evil[0] = 'X';        /* UB; на Linux SIGSEGV: .rodata доступна на чтение */

const в C — обещание, а не защита: его можно снять приведением, и компилятор не обязан это ловить (ловит -Wcast-qual). Ценность его в интерфейсах: size_t strlen(const char *s) документирует, что функция не портит вашу строку.

volatile запрещает кэшировать и переупорядочивать обращения к объекту — нужен для регистров памяти-отображённой периферии и переменных, меняемых обработчиком сигнала (volatile sig_atomic_t). Он не делает операцию атомарной и не заменяет барьеры; это самое частое заблуждение, детали — в потоках и синхронизации и в C для встраиваемых систем.

restrict (C99) — обещание, что в течение времени жизни указателя к объекту обращаются только через него; это разрешает векторизацию, а нарушенное обещание даёт UB. Именно так memcpy отличается от memmove: у memcpy параметры помечены restrict, поэтому перекрывающиеся области — UB, а memmove их поддерживает.

/* обещаем: dst не пересекается с a и b — компилятор развернёт и векторизует */
void vec_add(int *restrict dst, const int *restrict a,
             const int *restrict b, size_t n)
{ for (size_t i = 0; i < n; ++i) dst[i] = a[i] + b[i]; }

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

Код тоже живёт по адресам. Это механизм полиморфизма в C: так устроены file_operations в ядре Linux, таблицы виртуальных методов в C++, колбэки в любой библиотеке.

static int add(int a, int b) { return a + b; }
static int mul(int a, int b) { return a * b; }
typedef int (*binop)(int, int);

/* таблица диспетчеризации вместо цепочки if/switch */
static const struct { char sym; binop fn; } ops[] = { {'+', add}, {'*', mul} };

binop lookup(char sym)
{
    for (size_t i = 0; i < sizeof ops / sizeof ops[0]; ++i)
        if (ops[i].sym == sym) return ops[i].fn;
    return NULL;                    /* «не нашли» — это тоже результат */
}
binop f = lookup('*');
if (f) printf("%d\n", f(6, 7));     /* 42; проверять ОБЯЗАТЕЛЬНО */

Имя функции само распадается в указатель, поэтому add и &add — одно и то же, а f(...) и (*f)(...) эквивалентны. Приводить указатель на функцию к указателю на данные ISO C не разрешает, а POSIX разрешает — иначе dlsym был бы бесполезен.

Двойные указатели и out-параметры

T ** — «указатель на переменную типа T *». Нужен, когда функция должна изменить сам указатель вызывающего:

int read_line(FILE *f, char **out, size_t *len)   /* *out — только при успехе */
{
    char   *buf = NULL;
    size_t  cap = 0;
    ssize_t n   = getline(&buf, &cap, f);  /* getline сам делает realloc */
    if (n < 0) { free(buf); return -1; }   /* *out не тронут — важно!    */
    *out = buf; *len = (size_t)n;
    return 0;
}

Правило хорошего API: не трогать out-параметры на пути ошибки, иначе вызывающий не знает, надо ли освобождать то, что там оказалось. Второе правило: кто выделяет — документирует, кто освобождает; подробнее в динамической памяти. Третий классический случай T ** — массив строк: char *argv[] это массив указателей, а argv[i][j] — два разыменования подряд, и каждая косвенность стоит отдельного кэш-промаха (кэш и локальность).

Жизненный цикл указателя

Большинство ошибок — не «неправильная арифметика», а использование указателя не в том состоянии. Полезно держать в голове явный автомат:

Три перехода в UB дают три дисциплины, которые дешевле любого отладчика:

  1. Инициализируйте при объявлении: int *p = NULL;. Неинициализированный указатель — случайное содержимое кадра стека, и оно часто «почти правильное»: программа работает, пока не изменится порядок вызовов.
  2. Гасите после освобождения: free(p); p = NULL; превращает use-after-free — тихую порчу чужих данных или дыру в безопасности — в мгновенный SIGSEGV в точке ошибки.
  3. Никогда не возвращайте адрес автоматической переменной.
int *broken(void)
{
    int x = 42;
    return &x;          /* кадр умрёт на ret; ловится -Wreturn-local-addr */
}
int *p = broken();
printf("%d\n", *p);     /* может напечатать 42 — и это худший исход */
printf("%d\n", *p);     /* после ещё одного вызова — уже мусор      */

Первый printf часто печатает 42: память ещё не переиспользована. Программа «работает». Через месяц кто-то добавит вызов между broken() и printf — и всё сломается «на ровном месте», в другом файле. Это и есть анатомия фразы «у меня работает»: UB не обязано ломаться сразу и не обязано ломаться там, где ошибка.

Что делает железо, когда указатель битый

Ключевой вывод: единица защиты — страница, обычно 4 КиБ, и ядро ничего не знает о ваших malloc-блоках и переменных. Поэтому запись на 4 байта за концом массива из 100 элементов почти наверняка не упадёт — адрес всё ещё внутри отображённой страницы, вы просто испортили соседние данные. Чтение по NULL падает всегда, потому что нулевая страница намеренно не отображается; чтение по (int *)0x1234 тоже, а по адресу, отличающемуся от валидного на 8 байт, — нет. Именно поэтому нужны санитайзеры: аппаратура ловит только грубые промахи, типичный buffer overflow для неё неотличим от нормальной работы. Устройство сигналов — в процессах и сигналах.

NULL: не «падение», а неопределённое поведение

Нулевой указатель гарантированно не равен адресу ни одного объекта. Записывается как NULL (из <stddef.h>) или литеральный 0 в контексте указателя; биты его не обязаны быть нулевыми, просто на всех практичных платформах они такие.

Заблуждение «разыменование NULL — это падение» опасно. Это UB, а падение — лишь один из исходов. Компилятор рассуждает так: раз тут *p, значит p != NULL, значит проверку ниже можно выбросить.

int f(int *p)
{
    int x = *p;             /* разыменовали — значит, p не NULL */
    if (p == NULL) return -1;   /* -O2: проверка удаляется целиком */
    return x;
}
/* результат: f: mov eax, DWORD PTR [rdi] / ret — проверки в коде нет */

Это не теория. В 2009 году ровно такой шаблон в драйвере tun ядра Linux дал CVE-2009-1897: GCC удалил проверку на NULL, стоявшую после разыменования, и получилась локальная эскалация привилегий (разбор на LWN). С тех пор ядро собирается с -fno-delete-null-pointer-checks.

Практика: проверяйте указатель до первого использования, а не после. И решите на уровне API, что означает NULL — «нет значения» или «ошибка вызывающего»; во втором случае честнее assert(p != NULL), чем молчаливый if (!p) return;, прячущий баг.

Строгий алиасинг и type punning

Два указателя алиасят, если могут указывать на один объект. Стандарт разрешает компилятору считать, что указатели на несовместимые типы не алиасят (правило эффективного типа, C17 6.5p7); GCC и Clang включают это на -O2 через -fstrict-aliasing.

int touch(int *ip, short *sp)
{
    *ip = 1;
    *sp = 2;        /* компилятор считает: short * не может задеть int   */
    return *ip;     /* -O2: возвращает константу 1, не перечитывая память */
}

Вызовите touch(&x, (short *)&x) — на -O0 вернётся 2 в младших битах, на -O2 вернётся 1. Обе сборки правильные: неправильна программа. Это самая коварная разновидность «работает на моей машине» — работает на моём уровне оптимизации. Правило: если поведение различается между -O0 и -O2, презумпция вины на коде, а не на компиляторе.

float bad(uint32_t u) { return *(float *)&u; }    /* UB: lvalue чужого типа */
float ok(uint32_t u)                              /* определено всегда      */
{
    float f;
    memcpy(&f, &u, sizeof f);
    return f;                                     /* -O2: movd xmm0, edi    */
}

За корректность платить не приходится — версия с memcpy компилируется в одну инструкцию. Проверять такие вещи удобно на Compiler Explorer: вставили функцию, переключили -O0/-O2, увидели разницу. Как компилятор приходит к таким решениям — тема оптимизаций.

Выравнивание — коротко

Адрес объекта типа T обязан быть кратен _Alignof(T). Создание невыровненного указателя ((int *)&buf[1]) — UB; на старых ARM, MIPS и SPARC это аппаратная ошибка, на x86-64 обычно работает, но медленнее и не атомарно, что рождает гейзенбаги в многопоточном коде. Универсально выровненный буфер объявляется как alignas(max_align_t) char buf[64];. Полный разбор паддинга и раскладки структур — в следующей статье трека.

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

Безопасность памяти в C — не вопрос аккуратности, а вопрос процесса. Аккуратность масштабируется до нескольких тысяч строк; дальше нужны инструменты, включённые по умолчанию во всех сборках.

# базовая сборка: предупреждения дешевле отладки
gcc -std=c17 -Wall -Wextra -Wpedantic -Wshadow -Wconversion \
    -Wcast-qual -Wpointer-arith -Wwrite-strings -O2 -g -c main.c
# отладочная: два санитайзера сразу, падать на первой же ошибке
gcc -std=c17 -g -O1 -fno-omit-frame-pointer \
    -fsanitize=address,undefined -fno-sanitize-recover=all -o app main.c
# продакшн-закалка: FORTIFY, канарейки стека, RELRO, PIE
gcc -O2 -D_FORTIFY_SOURCE=3 -fstack-protector-strong \
    -fstack-clash-protection -Wl,-z,relro,-z,now -pie -o app main.c

Отдельно отметьте -Wconversion (ловит молчаливые сужения size_tint — источник половины переполнений буфера) и -Wcast-qual (снятие const приведением).

ASan инструментирует каждое обращение к памяти и держит теневую карту доступности байтов: ловит выход за границы кучи, стека и глобальных, use-after-free, use-after-return, двойное освобождение, утечки. В отчёте сразу три стека — где прочитали, где освободили, где выделили; ровно то, что по core dump собирают часами.

==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
READ of size 4 at 0x602000000010 thread T0
    #0 0x4011a6 in main /home/dev/uaf.c:9
freed by thread T0 here:      #1 0x401189 in main /home/dev/uaf.c:8
previously allocated here:    #1 0x401170 in main /home/dev/uaf.c:6

Полезно: ASAN_OPTIONS=detect_leaks=1:abort_on_error=1:strict_string_checks=1 (wiki AddressSanitizer). UBSan ловит другое: знаковое переполнение, слишком большой сдвиг, невыровненный доступ, разыменование NULL — он дополняет ASan, а не заменяет (подробно про UB). Valgrind (memcheck) работает без перекомпиляции: valgrind --leak-check=full --track-origins=yes --error-exitcode=1 ./app; опция --track-origins показывает, откуда пришло неинициализированное значение (руководство).

Инструмент Ловит Не ловит Замедление
-Wall -Wextra опечатки, сужения, -Wreturn-local-addr всё динамическое 0
ASan out-of-bounds, UAF, double free, утечки неинициализированное чтение, гонки ~2×
MSan чтение неинициализированного требует пересборки всех зависимостей ~3×
TSan гонки данных ошибки памяти 5–15×
UBSan переполнения, сдвиги, выравнивание, NULL утечки ~1,2×
Valgrind UAF, утечки, неинициализированное гонки (нужен helgrind) 10–50×

В CI гоняйте тесты трижды: обычная сборка, -fsanitize=address,undefined, -fsanitize=thread. ASan и TSan в одной сборке несовместимы. А когда падение уже случилось, память смотрят руками:

(gdb) print *arr@10                # 10 элементов начиная с arr
(gdb) x/16xb p                     # 16 байт в hex — увидеть порядок байт
(gdb) info symbol 0x555555555169   # какому символу принадлежит адрес
(gdb) info proc mappings           # карта памяти процесса

x/16xb на массиве int — самый быстрый способ убедиться, что little-endian понят правильно: вы увидите 01 00 00 00, а не 00 00 00 01 (см. представление данных и отладку и диагностику).

Таксономия: что бывает указателем

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

Ошибка Как выглядит Чем ловится Как не допускать
Возврат адреса локальной переменной «работает», потом мусор -Wreturn-local-addr, ASan out-параметр или malloc
Use-after-free случайные значения, редкие падения ASan, valgrind free(p); p = NULL;
Двойное освобождение падение внутри libc ASan владелец ровно один
Off-by-one на границе порча соседних данных ASan, UBSan цикл it != end, не <=
Чтение неинициализированного зависит от предыдущих вызовов valgrind --track-origins = NULL при объявлении
Проверка на NULL после использования проверку выкидывает -O2 ревью, UBSan проверять до
sizeof от указателя вместо массива буфер «внезапно» 8 байт -Wsizeof-pointer-div передавать длину явно
Type punning приведением разное поведение на -O0 и -O2 UBSan, -Wstrict-aliasing memcpy или union
Смешение size_t и int отрицательный размер → гигантский malloc -Wconversion, UBSan size_t везде

Как это применяют в проде

  • Один владелец на объект. Даже без RAII дисциплина «выделяет и освобождает один и тот же модуль» убирает большую часть use-after-free. Ядро Linux и SQLite построены на явной, документированной передаче владения.
  • Указатель всегда с длиной. В C нет «толстых» указателей, поэтому пара (ptr, len) — де-факто тип. Всё, что принимает буфер, обязано принимать и его размер; strcpy, sprintf, gets изгнаны из кодовых баз именно за нарушение этого правила (безопасное кодирование).
  • Санитайзеры в CI, закалка в релизе. ASan и UBSan дороги для продакшна, но -D_FORTIFY_SOURCE=3, -fstack-protector-strong, RELRO и PIE стоят единицы процентов и включены у всех дистрибутивов по умолчанию.
  • Фаззинг. libFuzzer или AFL++ поверх ASan находят за ночь то, что ревью не найдёт никогда; любой парсер входных данных на C обязан иметь фаззинг-цель.
  • Изоляция небезопасного кода. Около 70 % критических уязвимостей в больших C/C++ проектах — ошибки памяти (данные Microsoft и Chromium). Отсюда движение к языкам с проверяемым владением, но и там unsafe-слой пишется по правилам из этой статьи — см. современные альтернативы и трек по Rust на портале.

Мини-итог

  • Указатель — это адрес плюс тип; тип задаёт и шаг арифметики, и правила алиасинга.
  • Разыменование компилируется в одну инструкцию обращения к памяти, без каких-либо проверок.
  • p + n работает в единицах sizeof(*p); корректно только внутри объекта и на один элемент за концом.
  • Аппаратура защищает страницами по 4 КиБ, а не объектами, поэтому большинство переполнений буфера не падают, а тихо портят данные.
  • UB — не «упадёт», а «компилятор вправе предположить, что этого не бывает»: отсюда исчезающие проверки на NULL и разница между -O0 и -O2.
  • Безопасность памяти — процесс: предупреждения как ошибки, санитайзеры в CI, фаззинг парсеров, закалка релиза, (ptr, len) вместо голого указателя.

Чеклист перед коммитом: указатель инициализирован при объявлении → проверен до использования → длина передаётся рядом → освобождается ровно один раз → обнуляется после free → собирается чисто с -Wall -Wextra -Wconversion → проходит тесты под ASan и UBSan.

Источники

  • ISO/IEC 9899:2018 (C17), проект N2176 — разделы 6.3.2.3, 6.5.6, 6.5.7: черновик
  • Randal Bryant, David O’Hallaron. Computer Systems: A Programmer’s Perspective, гл. 3 и 9 — csapp.cs.cmu.edu
  • Peter van der Linden. Expert C Programming: Deep C Secrets — лучшее объяснение деклараций и распада массивов
  • Steve Summit. comp.lang.c FAQ, разделы 4–7 — c-faq.com
  • Chris Lattner. What Every C Programmer Should Know About Undefined Behaviorblog.llvm.org
  • Ralf Jung. Pointers Are Complicated — почему адрес не сводится к числу: ralfj.de
  • Ulrich Drepper. What Every Programmer Should Know About MemoryPDF
  • GCC Optimize Options — -fstrict-aliasing, -fdelete-null-pointer-checks: gcc.gnu.org

Что дальше

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

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

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

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

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