Память и указатели: адреса, разыменование, арифметика указателей
Зачем вообще нужны указатели
В языках с автоматическим управлением памятью вопрос «где лежит эта переменная» задавать не принято: рантайм знает сам. В C он обязателен, потому что без адресов нельзя выразить три вещи:
- Изменение чужих данных. Передача по значению копирует; чтобы функция изменила объект вызывающего, надо передать его адрес.
- Данные переменного размера и времени жизни. Список, дерево, буфер под ответ сети — размер неизвестен на этапе компиляции, значит объект живёт вне стека, и добраться до него можно только по адресу.
- Прямой доступ к железу и системе. Регистр периферии, отображённый файл, буфер 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 переводят его в физический через таблицы страниц. Механизм разобран в управлении памятью ОС; нам важна карта.
Практический вывод: по адресу почти всегда угадывается происхождение объекта. 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 и rsi — System 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)и вставляет сдвиг. Это буквальное доказательство того, что арифметика указателей работает в единицах типа, а не байтов.
Арифметика указателей
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, где p — void * |
— | вне стандарта; 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 дают три дисциплины, которые дешевле любого отладчика:
- Инициализируйте при объявлении:
int *p = NULL;. Неинициализированный указатель — случайное содержимое кадра стека, и оно часто «почти правильное»: программа работает, пока не изменится порядок вызовов. - Гасите после освобождения:
free(p); p = NULL;превращает use-after-free — тихую порчу чужих данных или дыру в безопасности — в мгновенный SIGSEGV в точке ошибки. - Никогда не возвращайте адрес автоматической переменной.
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, презумпция вины на коде, а не на компиляторе.
char-типы могут алиасить любой объект"] Q1 -- "нет" --> Q2{"C или C++?"} Q2 -- "C99 / C11 / C17" --> C2["union: пишем в одно поле,
читаем из другого — определено"] Q2 -- "C++" --> C3["union-punning — UB;
с C++20 есть std::bit_cast"] C1 --> R["memcpy в объект нужного типа:
работает везде, на -O2 сводится к одному mov"] C2 --> R C3 --> R BAD["*(float *)&u"] -.->|"нарушает strict aliasing"| UBX["UB: компилятор вправе
переупорядочить или выбросить доступ"]
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_t → int — источник половины переполнений буфера) и -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 Behavior — blog.llvm.org
- Ralf Jung. Pointers Are Complicated — почему адрес не сводится к числу: ralfj.de
- Ulrich Drepper. What Every Programmer Should Know About Memory — PDF
- GCC Optimize Options —
-fstrict-aliasing,-fdelete-null-pointer-checks: gcc.gnu.org
Что дальше
Мы разобрали одиночный объект и адрес, который на него указывает. Дальше — как из объектов собираются составные сущности и почему компилятор вставляет между полями пустоту: Массивы, строки и структуры: раскладка в памяти и выравнивание.