Основы C: типы, операторы, управление потоком, функции
Синтаксис C помещается на трёх страницах: 32 ключевых слова, десяток управляющих конструкций, ни классов, ни модулей, ни исключений. Именно поэтому «выучить C за выходные» кажется реалистичным — и именно поэтому люди годами пишут код, который компилируется, проходит тесты и разваливается на другой версии компилятора. Дело в том, что в C синтаксис не главное. Главное — модель, которую этот синтаксис описывает: что такое объект, когда он существует, какие операции над ним определены, а какие стандарт объявил несуществующими. В языке со сборщиком мусора эта модель спрятана, и знать про неё необязательно. В C она и есть язык. Ниже — про модель, а не про то, как пишется for; общая карта трека — в статье C и системное программирование.
Абстрактная машина: почему компилятор не обязан делать то, что написано
Первое, что нужно перестроить в голове: программа на C не описывает последовательность машинных инструкций. Она описывает поведение гипотетического устройства — абстрактной машины стандарта. Компилятор обязан воспроизвести не ваш текст, а только наблюдаемое поведение: обращения к volatile-объектам, ввод-вывод и состояние на момент завершения. Это правило «как если бы» (as-if rule), раздел 5.1.2.3 стандарта.
Отсюда следует всё остальное. Компилятор вправе вычислить 2 * 3 + f(x) в любом порядке, если у f нет наблюдаемых эффектов; удалить цикл, который ничего наблюдаемого не делает; заменить деление на умножение со сдвигом; держать переменную только в регистре — и, самое важное, считать, что неопределённого поведения в вашей программе нет, и строить оптимизации на этом допущении. Стандарт делит поведение на четыре категории, и путать их дорого:
| Категория | Что означает | Пример | Что делать |
|---|---|---|---|
| Определённое | Стандарт задаёт результат | 3 / 2 == 1 |
пользоваться |
| Определяемое реализацией | Результат есть, компилятор его документирует | знаковость char, sizeof(long) |
читать документацию, фиксировать флагами |
| Неуточнённое | Один из нескольких вариантов, какой — не сказано | порядок вычисления аргументов | не полагаться на конкретный вариант |
| Неопределённое (UB) | Требований нет вообще | переполнение int, выход за границу массива |
не допускать, ловить санитайзерами |
Разница между последними двумя принципиальна. Неуточнённое поведение оставляет несколько допустимых исходов — программа остаётся корректной. Неопределённое выводит её за пределы языка целиком: после UB ни одна строка не обязана работать, включая выполнившиеся раньше. Подробный разбор — в статье про неопределённое поведение.
От текста к процессу: что происходит между main.c и ./a.out
Каждую стрелку можно остановить и посмотреть глазами — это лучший способ учить C:
gcc -std=c17 -E main.c -o main.i # что реально увидел компилятор
gcc -std=c17 -O2 -S main.c -o main.s # ассемблер С оптимизациями
gcc -std=c17 -O2 -g -c main.c -o main.o && objdump -d -S -M intel main.o | less
nm --defined-only main.o # что увидит компоновщик
Правило, экономящее недели: если непонятно, что делает конструкция, смотрите -S при -O2, а не при -O0. На -O0 компилятор буквально переводит текст и про модель ничего не показывает; следствия абстрактной машины видны только с оптимизациями. Сборка и компоновка подробно — в соответствующей статье трека, взгляд со стороны компилятора — в генерации кода.
Типы: размер, представление, ранг
У каждого типа четыре независимые характеристики: размер, требование выравнивания, интерпретация битов и ранг преобразования. Первые три интуитивны, четвёртый — источник большинства тихих багов.
Стандарт задаёт лишь минимумы и соотношения: char ровно 1 байт по определению sizeof, short и int не меньше 16 бит, long не меньше 32, long long не меньше 64, ранги не убывают. Остальное — модель данных платформы, и классический баг переносимости живёт ровно в строке long: код, где его использовали как «широкое целое», собирается на Linux и теряет старшие биты на Windows. Лечится это типами, а не комментариями.
| Тип | ILP32 (32-битный Linux) | LP64 (Linux, macOS, BSD) | LLP64 (Windows x64) |
|---|---|---|---|
char / short / int |
8 / 16 / 32 | 8 / 16 / 32 | 8 / 16 / 32 |
long |
32 | 64 | 32 |
long long |
64 | 64 | 64 |
указатель, size_t |
32 | 64 | 64 |
#include <stdint.h> /* int8_t … int64_t, intptr_t, uintmax_t */
#include <stddef.h> /* size_t, ptrdiff_t, NULL */
#include <inttypes.h> /* PRId64, SCNu32 — переносимые макросы форматов */
uint32_t crc; /* «ровно 32 бита без знака» — так и напишите */
int64_t balance_cents; /* деньги в целых, никогда не в double */
size_t len; /* размеры и индексы — только size_t */
printf("crc=%" PRIu32 " balance=%" PRId64 " len=%zu\n", crc, balance_cents, len);
%zu для size_t обязателен: %d или %lu здесь означают передачу в вариативную функцию аргумента не того типа, то есть UB, а не «обычно работает». -Wformat из -Wall ловит это сразу.
Дополнительный код и две границы диапазона
C23 закрепил дополнительный код как единственное представление знаковых чисел, но одна вещь не изменилась: переполнение знакового типа остаётся неопределённым поведением, а беззнакового — нет. Это видно прямо в выводе компилятора:
int naive_check(int x) { return x + 1 > x; }
unsigned real_check(unsigned x) { return x + 1u > x; }
/* gcc -O2 -S, x86-64:
naive_check: movl $1, %eax / ret ← проверка выброшена целиком
real_check: xorl %eax,%eax / cmpl $-1,%edi / setne %al / ret ← честный код */
Это не «злой компилятор», а прямое следствие абстрактной машины: раз переполнение невозможно по контракту, x + 1 > x тождественно истинно. Проверять надо до операции либо встроенными функциями, которые превращаются в одну инструкцию с флагом переноса:
static bool add_ok(int a, int b, int *out) { /* переносимо */
if (b > 0 && a > INT_MAX - b) return false;
if (b < 0 && a < INT_MIN - b) return false;
*out = a + b; return true;
}
/* GCC и Clang: то же одной инструкцией — !__builtin_add_overflow(a, b, out) */
Как биты превращаются в числа — в статьях двоичная система и представление данных.
Продвижения и обычные арифметические преобразования
Самая недооценённая часть языка. В C нет арифметики над типами у́же int: любой такой операнд сначала повышается до int (или unsigned int, если int не вмещает все значения). Дальше, если типы всё ещё различаются, работают «обычные арифметические преобразования».
Ветка J — тот самый ад, а рядом с ней живёт вторая ловушка: продвижение маленьких беззнаковых типов в знаковый int.
int i = -1; size_t n = sizeof(int);
if (i < n) { } /* никогда: i становится SIZE_MAX */
for (size_t k = 10; k >= 0; k--) { } /* бесконечный цикл: k >= 0 всегда истинно */
uint32_t bug(uint16_t a, uint16_t b) {
/* a и b продвинуты до int. При a = b = 65535 произведение 4294836225
не помещается в int — переполнение ЗНАКОВОГО типа, то есть UB */
return a * b;
}
uint32_t fixed(uint16_t a, uint16_t b) { return (uint32_t)a * (uint32_t)b; }
Правила выживания: считайте в типах не у́же int; при смешивании знаковых и беззнаковых приводите явно; включите -Wconversion -Wsign-conversion -Wtype-limits и разбирайте каждое предупреждение, а не глушите приведением «чтобы замолчало».
char — три разных типа
char, signed char и unsigned char — три различных типа, а знаковость голого char определяется реализацией: на x86-64 Linux он знаковый, на AArch64 и ARM — беззнаковый. Отсюда два классических дефекта:
char ch;
while ((ch = getchar()) != EOF) { } /* ОШИБКА: EOF не помещается в char. С unsigned
char цикл вечен; с signed char байт 0xFF = EOF */
if (isspace(*p)) { } /* ОШИБКА: при *p == (char)-30 (UTF-8) индекс
таблицы отрицателен — чтение за границей.
Верно: if (isspace((unsigned char)*p)) */
Правило: для текста — char, для байтов — unsigned char (только он гарантированно не имеет ловушечных представлений и годится для побайтового доступа к чему угодно), для символа из потока — int.
Операторы: приоритет, побочные эффекты, порядок
Приоритеты C проектировались, когда & и | использовали как логические операции, и с тех пор не менялись ради совместимости. Не соревнуйтесь с таблицей приоритетов — ставьте скобки; -Wparentheses из -Wall прикрывает лишь часть случаев.
| Выражение | Как читает компилятор | Как обычно думают |
|---|---|---|
a & b == c |
a & (b == c) |
(a & b) == c |
1 << 2 + 3 |
1 << (2 + 3) = 32 |
(1 << 2) + 3 = 7 |
*p++ |
*(p++) |
(*p)++ |
!x & y |
(!x) & y |
!(x & y) |
uint32_t x = 1;
x << 32; /* UB: сдвиг на ширину типа и больше */
x << -1; /* UB: отрицательный сдвиг */
1 << 31; /* UB в C17: литерал 1 имеет тип int. Верно: 1u << 31 */
-1 >> 1; /* до C23 определяется реализацией; C23 закрепил арифметический сдвиг */
INT_MIN / -1; /* UB: результат не представим в int (то же с % -1) */
-7 / 2; /* -3: с C99 деление усекает В СТОРОНУ НУЛЯ */
-7 % 2; /* -1: знак остатка совпадает со знаком делимого */
Последняя пара регулярно ломает кольцевые буферы и хеш-таблицы: index = hash % size при знаковом hash даёт отрицательный индекс. Беззнаковый тип для хеша — это не стиль, это корректность.
C11 заменил «точки следования» на модель «упорядочено до / не упорядочено», но практический вывод прежний: порядок вычисления подвыражений не задан, а два неупорядоченных побочных эффекта над одним объектом — это UB.
i = i++; /* UB: две записи в i не упорядочены */
a[i] = i++; /* UB */
printf("%d %d\n", i++, i++); /* UB, и порядок аргументов тоже не задан */
g() + h(); /* не UB, но кто вызовется первым — неизвестно */
Упорядочивают вычисление ровно четыре оператора: &&, ||, ?: и запятая. Это делает их инструментом корректности, а не удобством: в if (p != NULL && p->len > 0) правая часть гарантированно не вычисляется при нулевом указателе. И отдельная деталь: sizeof не вычисляет свой операнд (исключение — массивы переменной длины), так что sizeof(x++) не изменит x.
Управление потоком: где скрываются дефекты
Ошибка в управляющей конструкции не падает — она меняет логику, поэтому обходится дороже всего. Февраль 2014 года, CVE-2014-1266 в Apple SecureTransport, проверка подписи TLS:
if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
goto fail;
goto fail; /* ← вторая строка без всякого условия */
if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0)
goto fail;
/* сюда управление не доходило никогда: err == 0, подпись «валидна» */
fail:
return err;
Одна лишняя строка отключила проверку сертификатов на всех устройствах Apple. Отсюда два правила, которые в системном коде не обсуждаются: фигурные скобки всегда, даже вокруг одной инструкции, и сборка с -Wmisleading-indentation (флаг появился в GCC 6 именно после этой истории).
switch (state) {
case ST_IDLE: reset(p);
/* fallthrough */ /* GCC понимает комментарий; C23 — [[fallthrough]]; */
case ST_HEADER: parse_header(p); break; /* без break провал в следующую ветку */
case ST_BODY: parse_body(p); break;
/* Никакого default: тогда -Wswitch напомнит про элемент enum, который вы
добавите через полгода. Это лучше молчаливого default. */
}
-Wimplicit-fallthrough=3 превращает случайный провал в ошибку компиляции. Полезно один раз посмотреть, во что switch компилируется: при плотном наборе меток строится таблица переходов (jmp QWORD PTR [rax*8+0x0] в выводе objdump), при разреженном — цепочка сравнений или бинарный поиск.
Про циклы есть тонкость, о которой почти не пишут: в C11 (6.8.5p6) компилятор вправе предполагать, что цикл, управляющее выражение которого не является константой, завершается. Поэтому while (x) { } без изменения x внутри оптимизатор может удалить, а while (1) { } с константным условием — не может: он законен и обязан крутиться. На этом стоят главные циклы bare-metal-прошивок, см. C для встраиваемых систем.
goto, который на самом деле нужен
В C нет ни деструкторов, ни defer, ни try/finally. Единственный масштабируемый способ освободить несколько ресурсов при ошибке — лестница goto вперёд; так пишут ядро Linux и любой серьёзный C-код:
int load_config(const char *path, struct config *out) {
int rc = -1;
FILE *fp = NULL; char *buf = NULL; struct parser *p = NULL;
if ((fp = fopen(path, "rb")) == NULL) goto out; /* ничего не захвачено */
if ((buf = malloc(BUF_SIZE)) == NULL) goto out_close; /* освободить fp */
if ((p = parser_new()) == NULL) goto out_free; /* освободить buf и fp */
if (parser_run(p, fp, buf, out) != 0) goto out_parser;
rc = 0; /* успех */
out_parser: parser_free(p);
out_free: free(buf);
out_close: fclose(fp);
out: return rc;
}
Метки идут в порядке, обратном захвату ресурсов; точка выхода одна. Это единственная форма goto, которая делает код понятнее: альтернатива — пять уровней вложенных if или дублирование free в каждой ветке, где рано или поздно один вызов забудут.
Функции: копии, кадры и связывание
void f() и void f(void) исторически разные вещи: первая форма отключала проверку аргументов и позволяла f(1, 2, 3) скомпилироваться без предупреждений. C23 сделал их эквивалентными, но код пишется под разные стандарты — пишите (void) всегда и включайте -Wstrict-prototypes -Wmissing-prototypes.
В C нет передачи по ссылке, есть передача копии, в том числе копии указателя. Из этого следует всё поведение параметров:
void wont_work(int x) { x = 42; } /* меняет свою копию */
void will_work(int *x) { *x = 42; } /* меняет объект вызывающего */
void takes_array(int a[10]) { /* компилятор читает это как int *a: массив распался
в указатель, и sizeof(a) — размер УКАЗАТЕЛЯ (8), а не 40 (-Wsizeof-array-argument) */ }
void takes_span(const int *a, size_t n); /* честная сигнатура */
void takes_at_least(const int a[static 4]); /* C99: «не NULL и минимум 4 элемента» */
Здесь C расходится с высокоуровневыми языками сильнее всего: массив не объект первого класса, длина нигде не хранится, передать его можно только парой «указатель + длина». Подробно — в статьях про массивы, строки и структуры и память и указатели.
Последняя строка — причина самого частого дефекта у пришедших из языков со сборщиком мусора:
const char *bad_name(void) {
char buf[64];
snprintf(buf, sizeof buf, "node-%d", 7);
return buf; /* КАТАСТРОФА: время жизни buf кончилось. -Wreturn-local-addr */
}
int ok_name(char *dst, size_t cap) { return snprintf(dst, cap, "node-%d", 7); }
Соглашения о вызовах и раскладка кадра — тема статьи про ассемблер для программиста; механику стека процесса разбирает управление памятью в треке операционных систем.
/* static: внутреннее связывание. Символа нет в таблице, компилятор видит все
вызовы и может встроить функцию или переписать соглашение о вызове */
static uint32_t rotl32(uint32_t v, unsigned s) {
return (v << s) | (v >> ((32 - s) & 31)); /* & 31 спасает от сдвига на 32 */
}
/* В заголовках — только static inline: обычный inline в C99 требует ровно одного
внешнего определения, и забыть о нём означает ошибку компоновки, всплывающую
при смене уровня оптимизации */
static inline size_t min_sz(size_t a, size_t b) { return a < b ? a : b; }
/* Вариативные функции не проверяют типы аргументов; атрибут format возвращает
проверку компилятору — иначе log_msg("%s", 42) соберётся молча */
__attribute__((format(printf, 2, 3))) void log_msg(int level, const char *fmt, ...);
Область видимости, связывание, время жизни
Три свойства объявления ортогональны, и путать их — значит не понимать static: на файловом уровне он меняет связывание, внутри функции — время жизни, а на функции делает и то и другое.
Нюанс, которого нет в других языках: неинициализированный автоматический объект содержит не «случайный мусор», а неопределённое значение. Компилятор вправе считать, что его чтения не происходит, и выбросить зависящую от него ветку. «Прочитал неинициализированную переменную, получил случайное число» — описание удачи, а не поведения.
| Квалификатор | Что означает на самом деле | Типичная ошибка |
|---|---|---|
const |
обещание не менять через это имя | считать, что объект неизменен и по другим путям |
volatile |
значение может меняться вне программы: запрет кэширования в регистре и переупорядочивания относительно других volatile |
использовать вместо атомарности |
restrict |
обещание, что через этот указатель объект недоступен по другому имени | нарушить обещание — UB, видимое только при -O2 |
_Atomic |
настоящая атомарность и модель памяти C11 | путать с volatile |
volatile не делает операцию атомарной и не создаёт барьеров для не-volatile данных. Для межпоточного обмена нужны _Atomic и <stdatomic.h> — тема статьи про потоки и синхронизацию.
«Работает на моей машине» — почему это здесь особенно опасно
В языке с проверками времени выполнения ошибка воспроизводится: выход за границу массива даёт исключение и там, и здесь. В C ошибка не воспроизводится по определению — «поведения нет» означает буквально что угодно, и это «что угодно» зависит от версии компилятора, уровня оптимизации, раскладки стека и ASLR. Показательный случай из ядра Linux, CVE-2009-1897: строка struct sock *sk = tun->sk; разыменовывала указатель, и только следующей строкой шла проверка if (!tun) return POLLERR;.
GCC рассуждает так: если бы tun был NULL, первая строка была бы UB, значит tun != NULL, значит проверка мертва — и удаляет её. Код, защищавшийся от нулевого указателя на -O0, перестал защищаться на -O2, и это стало локальным повышением привилегий. Компилятор был прав, код — нет.
Второй столь же коварный механизм — строгий алиасинг: объект нельзя читать через указатель несовместимого типа, поэтому return *(uint32_t *)&f; для float f — это UB, а не «переинтерпретация битов». Корректная форма — uint32_t u; memcpy(&u, &f, sizeof u); return u;, которую -O2 превращает в одну инструкцию movd.
Практический вывод: всё, что «проверено экспериментом», проверено только для одной сборки. Надёжная опора — стандарт плюс инструменты, ловящие UB во время выполнения.
Инженерная дисциплина: флаги, санитайзеры, процесс
Безопасность памяти в C — не свойство языка, а результат процесса из четырёх слоёв: предупреждения компилятора, статический анализ, санитайзеры в тестах, фаззинг на границах ввода.
WARN="-std=c17 -Wall -Wextra -Wpedantic -Werror -Wconversion -Wsign-conversion \
-Wshadow -Wcast-qual -Wstrict-prototypes -Wmissing-prototypes \
-Wmisleading-indentation -Wimplicit-fallthrough=3 -Wvla"
HARDEN="-D_FORTIFY_SOURCE=3 -fstack-protector-strong -fstack-clash-protection \
-fPIE -Wl,-z,relro,-z,now"
SAN="-fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer"
gcc $WARN $HARDEN -O2 -g -o app main.c parser.c # релиз
gcc $WARN $SAN -O1 -g -o app-debug main.c parser.c # тесты и CI
Ключевые решения здесь: -Werror (предупреждение, которое можно проигнорировать, будет проигнорировано), -D_FORTIFY_SOURCE=3 работает только вместе с -O1 и выше, а санитайзеры требуют -fno-omit-frame-pointer, иначе стеки в отчётах бесполезны. Как выглядят находки:
==31427==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffd4c9f2074
WRITE of size 4 at 0x7ffd4c9f2074 thread T0 #0 0x55a1b2 in fill demo.c:6
[32, 52) 'buf' (line 12) <== Memory access at offset 52 overflows this variable
demo.c:4:14: runtime error: signed integer overflow: 2147483647 + 1 cannot be
represented in type 'int'
demo.c:9:11: runtime error: shift exponent 32 is too large for 32-bit type 'unsigned int'
Регламент, который окупается: ASan и UBSan во всех тестовых прогонах CI; Valgrind memcheck — на ночной сборке (он медленнее, но ловит чтение неинициализированной памяти, которое ASan не видит); gcc -fanalyzer — на каждом PR; фаззинг — на любой функции, разбирающей внешние данные. Инструменты подробно — в статье про отладку и диагностику, процессная сторона — в безопасном кодировании.
Ограничение, о котором надо знать сразу: санитайзеры находят только те ошибки, которые исполнились на данном входе. Отсутствие сообщений — не доказательство корректности, а лишь отсутствие контрпримера. Поэтому фаззинг (генерация входов) дополняет санитайзеры (обнаружение ошибок), а не заменяет их.
Собираем всё вместе: гистограмма длин слов
Классическая задача из K&R с современными типами и проверками. Ни одной новой конструкции — только разобранное выше.
/* wordlen.c — гистограмма длин слов из стандартного ввода.
Тесты: gcc -std=c17 -Wall -Wextra -Werror -fsanitize=address,undefined \
-g -O1 -o wordlen wordlen.c && ./wordlen < /usr/share/dict/words */
#include <stdio.h>
#include <ctype.h>
#define MAXLEN 16u /* столбцы 1..14, всё длиннее попадает в «15+» */
#define BAR_WIDTH 40u
static size_t bucket_of(size_t len) { return (len < MAXLEN) ? len : MAXLEN - 1; }
static void print_bar(unsigned long count, unsigned long max) {
/* max == 0 обрабатываем отдельно: деление на ноль — UB. Сначала умножаем,
потом делим, иначе целочисленное деление обнулит масштаб */
unsigned long width = (max == 0) ? 0 : count * BAR_WIDTH / max;
for (unsigned long i = 0; i < width; i++) putchar('#');
}
int main(void) {
unsigned long hist[MAXLEN] = {0}; /* явная инициализация: ни байта мусора */
unsigned long words = 0, max = 0;
size_t cur = 0; int ch; /* именно int: EOF не влезает в char */
while ((ch = getchar()) != EOF) {
if (isspace((unsigned char)ch)) { /* приведение обязательно */
if (cur > 0) { hist[bucket_of(cur)]++; words++; cur = 0; }
} else { cur++; }
}
if (ferror(stdin)) { perror("чтение stdin"); return 1; } /* EOF != ошибка */
if (cur > 0) { hist[bucket_of(cur)]++; words++; } /* ввод без пробела */
for (size_t i = 1; i < MAXLEN; i++) if (hist[i] > max) max = hist[i];
for (size_t i = 1; i < MAXLEN; i++) {
printf("%2zu%s %7lu ", i, (i == MAXLEN - 1) ? "+" : " ", hist[i]);
print_bar(hist[i], max); putchar('\n');
}
printf("всего слов: %lu\n", words);
return 0;
}
Что здесь и есть «мышление на C»: int ch вместо char ch; (unsigned char) перед isspace; size_t для индексов и длин; count * BAR_WIDTH / max вместо count / max * BAR_WIDTH; = {0} как единственная гарантия нулей у автоматического массива; проверка ferror, отличающая конец файла от ошибки ввода-вывода (подробнее — в статье про системные вызовы и ввод-вывод). Сложность: O(N) по времени от числа прочитанных символов и O(1) по дополнительной памяти.
Типичные ошибки и чем их ловить
-Wall — это не «все предупреждения», а исторический минимум: настоящий набор начинается с -Wall -Wextra -Wpedantic и расширяется списком ниже. Проект, собирающийся без предупреждений, — не признак качества, а признак того, что флаги не включены.
| Ошибка | Почему возникает | Что ловит |
|---|---|---|
Сравнение знакового с size_t |
неявные преобразования | -Wsign-compare, -Wtype-limits |
char для символа из потока |
привычка «символ = char» | ревью, -Wchar-subscripts |
Переполнение знакового int |
считают, что «обернётся» | UBSan, __builtin_*_overflow |
| Сдвиг на ширину типа и больше | 1 << 32 кажется нулём |
-fsanitize=shift |
| Возврат адреса локального массива | опыт языков с GC | -Wreturn-local-addr, ASan |
Забытый break, одиночный if без скобок |
опечатка, «так короче» | -Wimplicit-fallthrough=3, -Wmisleading-indentation |
| Чтение неинициализированной переменной | «там мусор, не страшно» | -Wmaybe-uninitialized, Valgrind |
| Каст указателя ради «переинтерпретации битов» | привычка из C++ | -Wstrict-aliasing=2, memcpy |
Мини-итог
- Программа на C описывает поведение абстрактной машины; компилятор сохраняет только наблюдаемое поведение и вправе считать, что UB в программе нет.
- Тип — это размер, выравнивание, интерпретация битов и ранг; ранг определяет неявные преобразования, а они — большинство тихих багов.
- Переполнение беззнакового типа определено (по модулю), знакового — UB. Всё у́же
intпродвигается доint, а смешивание знаковых и беззнаковых даёт контринтуитивные сравнения. - Порядок вычисления подвыражений не задан; упорядочивают только
&&,||,?:и запятая. - В управлении потоком дефекты не падают, а меняют логику: скобки всегда,
-Wmisleading-indentationи-Wimplicit-fallthroughобязательны,goto— легитимный инструмент освобождения ресурсов. - Всё передаётся по значению, массив распадается в указатель, адрес автоматического объекта нельзя выпускать за пределы блока;
static,const,volatileиrestrictуправляют разными осями — видимостью, связыванием, временем жизни и обещаниями оптимизатору. - Безопасность в C — процесс: строгие флаги, ASan и UBSan в тестах, Valgrind ночью, фаззинг на границах ввода.
Источники
- ISO/IEC 9899:2024 (C23), рабочий проект N3096 — https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3096.pdf
- ISO/IEC 9899:2011 (C11), проект N1570 в удобной вёрстке — https://port70.net/~nsz/c/c11/n1570.html
- Brian Kernighan, Dennis Ritchie. The C Programming Language, 2-е изд. — первоисточник, который читают вместе со стандартом, а не вместо него
- Chris Lattner. What Every C Programmer Should Know About Undefined Behavior — https://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html
- John Regehr. A Guide to Undefined Behavior in C and C++ — https://blog.regehr.org/archives/213
- GCC Warning Options — https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html; AddressSanitizer и UndefinedBehaviorSanitizer — https://clang.llvm.org/docs/AddressSanitizer.html и https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html
- SEI CERT C Coding Standard — https://wiki.sei.cmu.edu/confluence/display/c; разбор CVE-2014-1266 («goto fail») — https://www.imperialviolet.org/2014/02/22/applebug.html
Что дальше
Мы разобрали язык на уровне значений: типы, выражения, поток управления, функции. Но всё, что делает C системным языком, начинается там, где значение получает адрес. Следующий шаг — Память и указатели: адреса, разыменование, арифметика указателей: что такое указатель на самом деле, чем арифметика указателей отличается от арифметики целых и почему правила времени жизни из этой статьи становятся там вопросом безопасности.