Основы 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
#include и макросы"] PP -->|"gcc -S"| ASM["main.s — ассемблер"] -->|"as"| OBJ["main.o — секции
и таблица символов"] OBJ --> LD["компоновщик ld"] CRT["crt1.o, crti.o"] --> LD LIB["libc.so, libm.so"] --> LD LD --> EXE["a.out — ELF-образ"] -->|"execve"| PROC["процесс: сегменты в памяти"]
Каждую стрелку можно остановить и посмотреть глазами — это лучший способ учить 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 не вмещает все значения). Дальше, если типы всё ещё различаются, работают «обычные арифметические преобразования».
char, short, bool, битовые поля к int"] D --> E{"типы совпали?"} E -- да --> F["результат этого типа"] E -- нет --> G{"одинаковая знаковость?"} G -- да --> H["к типу большего ранга"] G -- нет --> I{"ранг беззнакового не меньше знакового?"} I -- да --> J["ЗНАКОВЫЙ к беззнаковому:
минус единица становится максимумом"] I -- нет --> K{"знаковый вмещает все значения беззнакового?"} K -- да --> L["беззнаковый к знаковому"] K -- нет --> M["оба к беззнаковой версии
знакового типа"]
Ветка 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 системным языком, начинается там, где значение получает адрес. Следующий шаг — Память и указатели: адреса, разыменование, арифметика указателей: что такое указатель на самом деле, чем арифметика указателей отличается от арифметики целых и почему правила времени жизни из этой статьи становятся там вопросом безопасности.