C и системное программирование: карта трека и зачем это сегодня
Есть момент, знакомый каждому, кто долго писал на языке со сборщиком мусора: программа тормозит, и профилировщик показывает не ваш код. Или падает контейнер с OOM, хотя в коде нет ничего тяжёлого. Или библиотека, работавшая год, перестаёт работать после обновления системного пакета. В этот момент абстракция протекает, и вопрос «а что там внизу вообще происходит» перестаёт быть академическим.
Этот трек — про то, что внизу. Не «изучим язык C, потому что классика», а научимся видеть машину сквозь код: где живут байты, что делает компилятор с вашим текстом, где заканчивается ваш процесс и начинается ядро, почему одна и та же программа корректна на вашем ноутбуке и разваливается в проде. C здесь — не самоцель, а инструмент с самым тонким слоем между текстом и железом. Если абстракций почти нет, скрыть от вас нечего.
Что такое системное программирование
Определение «низкоуровневое программирование» бесполезно: оно описывает ощущение, а не работу. Рабочая формулировка такая.
Системное программирование — это код, который сам себе платформа: он управляет ресурсами, а не пользуется чужим управлением, и отвечает за корректность там, где её никто не проверит в рантайме.
Три признака, каждый из которых порождает свой класс задач:
- Вы владеете ресурсами. Память не «просто есть» — вы решаете, где объект живёт, сколько и кто его освободит. Файловые дескрипторы, потоки, отображения — тоже ваши.
- Ваш контрагент — не фреймворк, а контракт. Контракт ABI, контракт системных вызовов, контракт стандарта языка, контракт процессора. Эти контракты не выдают понятных ошибок при нарушении.
- Ошибку не ловит рантайм. В Python выход за границу списка —
IndexErrorс трассировкой. В C это либо соседняя переменная, либо чужой кадр стека, либо ничего заметного в течение месяца.
| Прикладная разработка | Системное программирование | |
|---|---|---|
| Память | управляет рантайм | управляете вы, вручную и явно |
| Ошибка адресации | исключение с трассировкой | порча данных или падение через час |
| Единица мышления | объект, запрос, сервис | байт, адрес, кадр стека, страница |
| Что стоит между кодом и железом | рантайм, ОС, гипервизор | почти ничего |
| Главный риск | неверная бизнес-логика | неверная модель памяти |
| Инструмент диагностики | логи, отладчик, APM | санитайзеры, valgrind, дизассемблер |
Схема задаёт и границы трека. Про ядро как систему — трек operating-systems. Про железо и представление данных — трек computer-science. Про код без ОС под ногами — трек embedded. Здесь — про слой между: язык, компиляция, память, обращения к ядру.
Почему C, спустя полвека
Честный ответ состоит из двух частей, и обе важны. Часть первая: C — это не язык, это интерфейс. Практически всё, что вы используете каждый день, либо написано на C, либо разговаривает через C-совместимый ABI:
- ядра Linux, Windows, macOS, все драйверы, все прошивки;
glibc,musl— то, во что упирается любая программа на любом языке;- CPython, Ruby MRI, PHP, Lua, Node.js (через V8 на C++), Erlang BEAM;
- OpenSSL, SQLite, zlib, libcurl, ffmpeg, PostgreSQL, Redis, nginx, Git, PCRE.
Ключевое следствие: у C нет конкурентов в роли лингва франка. Когда Python вызывает Rust-библиотеку, а Rust — библиотеку на Go, разговор идёт через C-ABI: соглашение о вызовах, раскладка структур, «указатель + длина». Не потому что C лучший, а потому что это единственный формат, который умеют все. Это разбирается в статье про сборку и компоновку и в разговоре про современные альтернативы.
Часть вторая: C не должен быть языком по умолчанию для нового кода. Это не мнение автора, а отраслевой консенсус, подкреплённый данными:
- Microsoft: около 70% CVE, которым присвоен идентификатор, — ошибки безопасности памяти (MSRC, 2019);
- Chromium: та же доля среди серьёзных багов безопасности (chromium.org);
- CISA и NSA прямо рекомендуют организациям иметь дорожные карты перехода на языки с безопасной памятью (The Case for Memory Safe Roadmaps, 2023);
- Android: доля memory-safety уязвимостей упала с 76% до 24% за шесть лет — не переписыванием старого кода, а переводом нового кода на Rust и Kotlin.
Эти два факта не противоречат друг другу. Знание C — не то же самое, что выбор C для нового проекта. Первое обязательно, второе — инженерное решение с аргументами. Отсюда позиционирование:
Правый нижний квадрант — не «плохой». Это место, где вы получаете полный контроль и полную ответственность. Весь трек — про то, как жить в этом квадранте профессионально.
Что скрывает высокоуровневый язык
Возьмём строку, которую вы писали тысячу раз, и посмотрим, во что она превращается на каждом уровне.
Сравним честно, что делает за вас рантайм и чего в C нет:
| Действие | Python / Java / Go | C |
|---|---|---|
| Проверка индекса | всегда, на каждом обращении | нет, никогда |
| Освобождение памяти | сборщик мусора или счётчик ссылок | ваш явный free |
| Инициализация переменной | нулём или значением по умолчанию | мусор из стека, чтение — UB |
| Переполнение целого | исключение либо длинная арифметика | UB для знаковых, обёртка для беззнаковых |
| Тип в рантайме | известен, проверяется | не существует, есть только байты |
| Строка | объект с длиной | соглашение: байты до первого нуля |
Разыменование null |
NullPointerException |
UB, обычно SIGSEGV, иногда хуже |
Правая колонка — не список недостатков. Это список вещей, за которые вы не платите: нет проверок — нет их стоимости, нет заголовка объекта — нет накладных байтов, нет сборщика — нет пауз. Именно поэтому C всё ещё пишут там, где считают наносекунды и килобайты, и именно поэтому за него платят дисциплиной. Увидеть, что осталось от вашего кода, можно за одну команду — вот ритуал, который стоит освоить сразу:
# посмотреть, что компилятор реально сгенерировал
gcc -O2 -S -masm=intel -fverbose-asm sum.c -o -
# посмотреть, что попало в готовый бинарь
objdump -d --no-show-raw-insn -M intel ./sum | sed -n '/<sum>:/,/^$/p'
# посмотреть, какие символы экспортируются и откуда берутся
nm -C --defined-only ./sum
readelf -d ./sum # нужные динамические библиотеки
Тот же взгляд онлайн — Compiler Explorer: слева C, справа ассемблер, синхронная подсветка строк. Это лучший тренажёр для интуиции «во что превращается мой код». Подробный разбор — в статье про ассемблер для программиста, а как компилятор принимает решения — в треке compilers.
Граница, за которую вы не можете переступить сами
Второй фундамент трека после памяти — граница пользователь/ядро. Ваша программа не умеет писать в файл. Совсем. Она умеет только попросить ядро это сделать, и просьба стоит денег.
4096 байт в пользовательской памяти Libc-->>App: возврат, на диск ничего не ушло App->>Libc: ещё 200 вызовов printf Note over Libc: буфер заполнился либо сработал
построчный сброс на терминале Libc->>Kern: syscall write(1, buf, n) Note over Kern: смена режима процессора,
проверка дескриптора и прав Kern->>Kern: копирование в page cache Kern-->>Libc: вернулось n, данные ещё в ОЗУ Libc-->>App: возврат Kern->>Dev: отложенная запись, свой график Note over App,Dev: fsync — единственный способ
узнать, что данные реально на носителе
Из этой диаграммы вытекают три вещи, которые ломают интуицию:
writeвернулся — не значит «записано». Данные в page cache ядра. Между вами и носителем ещё несколько кэшей, включая кэш самого диска. Про долговечность — в статье про системные вызовы и ввод-вывод.- Буферизация stdio — не оптимизация ядра, а библиотечный трюк. Именно из-за неё вывод
printfпропадает при падении программы, аprintfиз родителя дублируется послеfork. - Системный вызов стоит на два порядка дороже обычного. Порядки величин на современном x86-64 с включёнными митигациями:
| Операция | Порядок задержки | Во сколько раз дороже вызова функции |
|---|---|---|
| Вызов функции в том же бинаре | ~1 нс | 1 |
| Промах кэша L3 в DRAM | ~80–100 нс | ~100 |
| Настоящий системный вызов | ~100–500 нс | ~100–500 |
| Malloc с уходом за страницами к ядру | ~1–3 мкс | ~1000 |
| Чтение с NVMe | ~50–100 мкс | ~50 000 |
Отсюда почти вся практическая оптимизация ввода-вывода: не делать вызов, если можно не делать. Батчинг, буферы, writev, sendfile, io_uring. Эта же арифметика подробно разбирается в треке performance, а устройство границы со стороны ядра — в operating-systems.
Неопределённое поведение: центральное понятие трека
Если из всего трека нужно унести одну идею, то эту.
Программа на C не описывает последовательность машинных инструкций. Она описывает поведение абстрактной машины из стандарта. Компилятор обязан воспроизвести только наблюдаемое поведение, и при этом он имеет право исходить из железного допущения: неопределённого поведения в вашей программе нет. Если оно всё-таки есть, выводы компилятора становятся ложными — и вместе с ними ломается сгенерированный код, включая тот, что выполнился раньше по тексту.
Посмотрим на это не на словах. Функция, которая выглядит как проверка переполнения:
// overflow.c — компилировать: gcc -O2 -S -masm=intel overflow.c -o -
int always_true(int x) {
return x + 1 > x; // «проверим, что прибавление единицы увеличивает число»
}
Что генерирует GCC на -O2:
always_true:
mov eax, 1 ; безусловно возвращаем 1
ret
Компилятор рассуждает так: знаковое переполнение — UB, значит x + 1 никогда не переполняется, значит x + 1 > x тождественно истинно. Ваша проверка исчезла, потому что она проверяла ровно то, чего «не бывает». Добавим -fwrapv — обещание, что знаковое переполнение сворачивается по модулю:
; gcc -O2 -fwrapv
always_true:
lea eax, [rdi+1]
cmp eax, edi
setg al
movzx eax, al
ret
Код появился. Разница между двумя ассемблерными листингами — не микрооптимизация, это разные языки: в одном переполнение существует, в другом нет.
Второй пример — не учебный, а реальный. Ядро Linux, драйвер tun, CVE-2009-1897:
struct sock *sk = tun->sk; // разыменовали tun
if (!tun) // ...и только потом проверили на NULL
return POLLERR;
Компилятор рассуждает безупречно: разыменование NULL — UB, значит к моменту проверки tun заведомо не NULL, значит if мёртв и его можно удалить. Проверка исчезла из бинаря. В сочетании с возможностью отобразить страницу по нулевому адресу это дало полноценное повышение привилегий. Никто не написал «плохой код» — написали код, чей смысл компилятор имел право трактовать иначе.
Стандарт различает четыре категории поведения, и путать их дорого:
| Категория | Что гарантировано | Пример | Что делать |
|---|---|---|---|
| Определённое | точный результат | 3 / 2 == 1 |
пользоваться |
| Определяемое реализацией | результат есть и задокументирован | знаковость char, sizeof(long) |
читать доки, фиксировать флагами |
| Неуточнённое | один из нескольких вариантов | порядок вычисления аргументов | не полагаться на конкретный |
| Неопределённое | ничего | переполнение int, выход за границу |
не допускать, ловить инструментами |
Подробный каталог, механика оптимизаций и разбор классов ошибок — в статье про неопределённое поведение. Пока достаточно установки: UB — это не «упадёт», это «программа перестала иметь смысл».
«Работает на моей машине»: пять осей расхождения
Эта фраза токсична в любой разработке, но в C она особенно опасна, потому что здесь у неё пять независимых источников — и каждый может годами молчать.
- Компилятор и его версия. GCC 11 и GCC 14 по-разному агрессивны в выводах из UB. Обновление компилятора в CI — легитимный способ «сломать работающий код».
- Уровень оптимизации. Классический симптом: работает на
-O0, падает на-O2. Почти всегда это не «баг оптимизатора», а UB, которое на-O0случайно давало нужный результат. - Архитектура. x86-64 прощает невыровненные обращения, ARM — не всегда. Порядок байтов, знаковость
char(на ARM он беззнаковый!), размерlong(8 на Linux, 4 на Windows). - libc и ОС. glibc и musl по-разному ведут себя при
free(NULL)в патологических случаях, по-разному отдают память ядру, по-разному раскладывают кучу. Один и тот же код в Alpine-контейнере и в Ubuntu — разные условия. - Условия запуска. ASLR, порядок переменных окружения (он сдвигает стек!), состояние аллокатора после часа работы, число ядер, тайминги планировщика. Гонка данных из статьи про потоки и синхронизацию может ждать своего часа месяцами.
Практический вывод, который стоит принять как аксиому: успешный запуск не является доказательством корректности программы на C. Доказательством служат только явные механизмы — санитайзеры в CI, статический анализ, фаззинг, тесты на нескольких компиляторах и архитектурах.
Безопасность памяти как инженерная дисциплина
Разговор «C небезопасен» непродуктивен, пока не превратится в разговор «вот как ошибаться реже и вот чем ловить оставшееся». Дисциплина строится в три слоя.
Слой 1. Не создавать условия для ошибки. Правила, которые исключают целые классы дефектов:
- длина всегда путешествует вместе с указателем —
(char *buf, size_t len), а не голыйchar *; - владение фиксируется в имени и комментарии: кто выделил, тот и освобождает; функция либо забирает владение, либо нет — третьего не дано;
- после
freeуказатель обнуляется в той же строке; - никаких
strcpy,strcat,sprintf,gets— только варианты с явным размером и проверкой усечения; - арифметика над размерами проверяется на переполнение до аллокации, а ввод не парсится вручную там, где есть проверенная библиотека.
Слой 2. Инструменты, ловящие то, что всё-таки просочилось. Здесь важно понимать не только «что ловит», но и «чего не ловит»:
| Инструмент | Что ловит | Чего не ловит | Цена |
|---|---|---|---|
ASan (-fsanitize=address) |
выход за границы кучи и стека, use-after-free, double free, утечки | гонки, неинициализированное чтение | ~2× время, ~3× память |
UBSan (-fsanitize=undefined) |
переполнение, сдвиги, невыравненные доступы, null |
всё, что не проверяется точечно | единицы процентов |
MSan (-fsanitize=memory) |
чтение неинициализированного | нужно инструментировать все зависимости | ~3× время |
TSan (-fsanitize=thread) |
гонки данных | только на реально исполненных путях | 5–15× время |
| Valgrind memcheck | то же плюс чтение неинициализированного, без пересборки | медленно, не ловит переполнение в стеке | 20–50× время |
gcc -fanalyzer, clang-tidy |
часть дефектов до запуска | всё, что зависит от рантайм-значений | время сборки |
| libFuzzer, AFL++ | входные данные, ведущие к падению | логические ошибки | инфраструктура |
Ключевая мысль: санитайзеры не проверяют программу — они проверяют конкретный запуск. ASan найдёт переполнение только если тест дошёл до этой строки. Поэтому связка «санитайзеры + фаззинг + покрытие» работает, а санитайзеры сами по себе дают ложное спокойствие.
Слой 3. Процесс. Диагностическая воронка, по которой стоит идти, когда что-то сломалось:
расхождение -O0 и -O2"] --> A{"Собирается
с -Wall -Wextra
без предупреждений?"} A -- нет --> A1["Сначала почините предупреждения:
половина находится здесь"] A -- да --> B{"Воспроизводится
стабильно?"} B -- да --> C["Пересобрать с ASan + UBSan,
прогнать тот же сценарий"] B -- нет --> D{"Многопоточная
программа?"} D -- да --> E["TSan, затем стресс-тест
с изменённым числом потоков"] D -- нет --> F["Похоже на UB или состояние аллокатора:
valgrind, разные -O, разные компиляторы"] C --> G{"Санитайзер
указал строку?"} G -- да --> H["Читайте отчёт снизу вверх:
где выделено, где освобождено,
где нарушено"] G -- нет --> I["Сузить: минимальный пример,
бисекция коммитов, отключение оптимизаций по файлам"] E --> H F --> I I --> J["Ассемблер: objdump и godbolt.
Что компилятор выбросил и почему"] H --> K["Исправление + тест,
который падал бы без него"] J --> K
Каждый узел этой схемы — отдельная статья трека: предупреждения и флаги в основах C, санитайзеры и gdb в отладке и диагностике, поведение аллокатора в динамической памяти.
Базовая конфигурация сборки
Это то, с чего начинается любой новый проект на C. Не «когда-нибудь добавим» — сразу.
# Разработка и CI: строго, с отладочной информацией
CFLAGS_DEV="-std=c17 -Wall -Wextra -Wpedantic -Werror \
-Wshadow -Wconversion -Wsign-conversion -Wstrict-prototypes \
-Wvla -Wformat=2 -Wnull-dereference -Wdouble-promotion \
-g3 -O1 -fno-omit-frame-pointer"
# Отдельная сборка с санитайзерами — прогонять тесты именно ею
SAN="-fsanitize=address,undefined -fno-sanitize-recover=all"
# Релиз: те же предупреждения плюс защитные механизмы
CFLAGS_REL="-std=c17 -Wall -Wextra -O2 -g \
-D_FORTIFY_SOURCE=3 -fstack-protector-strong -fstack-clash-protection \
-fcf-protection=full -fPIE"
LDFLAGS_REL="-pie -Wl,-z,relro,-z,now -Wl,-z,noexecstack"
Три замечания, которые экономят вечер:
_FORTIFY_SOURCEтребует хотя бы-O1и конфликтует с ASan — в санитайзерной сборке добавляйте-U_FORTIFY_SOURCE;- ASan и TSan несовместимы между собой: это две разные сборки и два разных прогона тестов;
-Werrorвключайте в CI, но не в локальной сборке, иначе новое предупреждение от обновлённого компилятора остановит работу в неподходящий момент.
Что именно делает каждый флаг и как это устроено внутри — в статье про сборку и компоновку и в разделе про защитные механизмы трека security.
Три минуты, чтобы увидеть всё сразу
Проверьте, что среда работает, и заодно посмотрите на весь трек в миниатюре:
// bad.c — семнадцать байт в буфер на шестнадцать
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
int main(void) {
char *buf = malloc(16); // 16 байт
if (!buf) return 1;
strcpy(buf, "0123456789abcdef"); // 16 символов + завершающий ноль = 17
printf("%s\n", buf);
free(buf);
return 0;
}
Обычная сборка почти наверняка отработает «успешно» — аллокатор округлил запрос вверх, лишний байт попал в служебный отступ:
gcc -O2 -o bad bad.c && ./bad
# 0123456789abcdef ← и никаких признаков проблемы
Именно так выглядит «работает на моей машине». Теперь то же самое с санитайзером:
gcc -g -fsanitize=address,undefined -fno-omit-frame-pointer -U_FORTIFY_SOURCE -o bad bad.c
./bad
==31427==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000030
WRITE of size 17 at 0x602000000030 thread T0
#0 0x7f... in strcpy
#1 0x55... in main /home/user/bad.c:9
0x602000000030 is located 0 bytes to the right of 16-byte region
[0x602000000020,0x602000000030) allocated by thread T0 here:
#0 0x7f... in malloc
#1 0x55... in main /home/user/bad.c:7
Отчёт читается снизу вверх: где выделили (строка 7), сколько (16 байт), что нарушили (запись 17 байт по правой границе, строка 9). Одна пересборка превратила молчаливую порчу памяти в точный адрес дефекта. Это и есть главный практический навык трека: не «не ошибаться», а сделать так, чтобы ошибка кричала.
Карта трека
Тринадцать статей, каждая отвечает на один вопрос:
| Статья | Вопрос, на который она отвечает |
|---|---|
| 01. Основы C | Что такое абстрактная машина и почему целочисленные преобразования — источник половины дефектов |
| 02. Память и указатели | Что такое адрес, чем указатель отличается от числа, когда он перестаёт быть валидным |
| 03. Массивы, строки, структуры | Как объекты лежат в памяти, откуда берётся padding и почему sizeof удивляет |
| 04. Динамическая память | Что делает malloc внутри, как выглядят утечки и фрагментация, когда писать свой аллокатор |
| 05. Сборка и компоновка | Что происходит между .c и запущенным процессом, как читать ошибки компоновщика |
| 06. Отладка и диагностика | Как задавать вопросы через gdb, как читать отчёты санитайзеров и core dump |
| 07. Системные вызовы и ввод-вывод | Что такое дескриптор, где живут буферы, чем write отличается от «записано» |
| 08. Процессы и сигналы | Как один вызов возвращается дважды, что можно делать в обработчике сигнала |
| 09. Потоки и синхронизация | Почему гонка данных — это UB, как устроены мьютексы, условные переменные и атомарность |
| 10. Ассемблер для программиста | Как читать вывод компилятора и проверять гипотезы о том, что он сделал |
| 11. Неопределённое поведение | Полный каталог: как ломается и как выстроить защиту слоями |
| 12. C++ после C | Что даёт RAII, что чинят умные указатели и что не чинится в принципе |
| 13. Современные альтернативы | Когда уходить на Rust, Zig или Go, а когда оставаться на C с аргументами |
Как трек стыкуется с остальным порталом
Соседние треки смотрят на ту же реальность с других сторон — дублировать их здесь незачем, а сверяться полезно.
- operating-systems — вид со стороны ядра. Здесь вы вызываете
forkиmmap, там разбирается, что ядро при этом делает: процессы и планирование, управление памятью. - computer-science — уровень ниже: двоичное представление, устройство процессора, иерархия памяти. Если непонятно, почему промах кэша стоит сотню наносекунд, начинать надо оттуда.
- embedded — тот же C, но без ОС, без кучи и с байтами вместо мегабайт: C для встраиваемых систем.
- compilers — как устроена та машина, что превращает ваш текст в инструкции: оптимизации и кодогенерация.
- performance — локальность данных: C даёт контроль над раскладкой, а этот трек объясняет, зачем он нужен; security — эксплуатация памяти как атака, а не как баг.
- Трек про Rust на портале разбирает альтернативу подробно; здесь она обсуждается только в сравнительной статье 13.
Маршруты прохождения
Трек линейный, но входить в него можно по-разному.
| Кто вы | Маршрут | Ориентировочно |
|---|---|---|
| Пишу на Python/JS, хочу понять «что внизу» | 01 → 02 → 03 → 04 → 07 → 11 | 20–25 часов |
| Готовлюсь к системным собеседованиям | весь трек по порядку, с решением задач | 60–80 часов |
| Иду в embedded | 01 → 02 → 03 → 05 → 10, дальше трек embedded | 25–30 часов |
| Отлаживаю чужой C прямо сейчас | 06 → 11 → 04 → 05, остальное по мере надобности | 12–15 часов |
| Выбираю язык для нового системного проекта | 00 → 11 → 12 → 13 | 8–10 часов |
Практический совет по режиму работы: каждый пример компилируйте и ломайте. Читать про UB бесполезно — нужно своими руками увидеть, как компилятор выбрасывает вашу проверку на -O2. Держите открытыми терминал и godbolt.org; это даёт на порядок больше, чем внимательное чтение. Минимальный набор инструментов:
# Debian / Ubuntu
sudo apt install build-essential gdb valgrind clang clang-tidy binutils make bear
# Fedora
sudo dnf install gcc gdb valgrind clang clang-tools-extra binutils make
# macOS: clang идёт с Xcode CLT, вместо valgrind — leaks и Instruments
xcode-select --install
Немного истории: почему C выглядит именно так
Многие странности языка — не ошибки дизайна, а отпечатки эпохи. Компилятор должен был помещаться в машину с 64 КБ памяти и компилировать за один проход: отсюда объявления до использования, отсюда заголовочные файлы вместо модулей, отсюда отсутствие проверок, которые некуда было вместить.
Два вывода из этой линии. Первый: UB появился не как недосмотр, а как компромисс — стандарт описывал десятки несовместимых реализаций и не мог требовать одинакового поведения там, где машины расходились. Второй: язык не стоит на месте — C23 принёс nullptr, constexpr, typeof, двоичные литералы, _BitInt, атрибуты вместо GNU-расширений. Писать на C 2024 года — это не писать на C 1989-го.
Чего этот трек не делает
- Не учит писать драйверы и код ядра — это отдельная дисциплина со своими правилами; трек даёт базу, на которой она стоит.
- Не заменяет справочник. POSIX и стандарт языка читаются в оригинале; здесь объясняется, как их читать и что искать.
- Не убеждает выбирать C. Наоборот: в статье 13 разбирается, когда правильный ответ — Rust или Go.
- Не делает вашу программу безопасной. Санитайзеры находят то, до чего дошло исполнение; дисциплина, обзоры кода и фаззинг остаются на вас.
Типичные ошибки при входе в тему
- Учить синтаксис вместо модели. Синтаксис C изучается за выходные, а модель памяти и правил UB — месяцами. Время стоит распределять обратно пропорционально.
- Считать C «переносимым ассемблером». Это устаревшая метафора: между вашим текстом и инструкциями стоит оптимизатор, который рассуждает о вашей программе. Он не переводит, он выводит следствия.
- Верить успешному запуску на
-O0. Отсутствие падения ничего не доказывает, а именно на-O0UB чаще всего маскируется: тестировать нужно ту сборку, что поедет в прод, плюс отдельную санитайзерную. - Игнорировать предупреждения.
-Wall -Wextraнаходят значительную долю дефектов бесплатно, до запуска. Проект, который собирается с сотней предупреждений, теряет их диагностическую ценность полностью. - Копировать код из ответов на форумах. Половина примеров с
gets,strcpyи приведением результатаmallocнаписана до 2000 года и содержит дефекты, которые с тех пор стали уязвимостями.
Мини-итог
- Системное программирование — это ответственность за ресурсы и корректность там, где рантайм не подстрахует.
- C незаменим как интерфейс между всем и всем, но не обязан быть выбором для нового кода — это два разных утверждения.
- Ключевая идея языка — абстрактная машина и UB: компилятор оптимизирует, исходя из допущения, что вы не нарушаете правила.
- «Работает на моей машине» здесь не аргумент: есть пять независимых осей, по которым поведение расходится.
- Безопасность памяти — инженерная дисциплина из трёх слоёв: правила кодирования, инструменты, процесс.
- Главный навык, который даёт трек, — сделать ошибку громкой: собирать с предупреждениями, гонять тесты под ASan и UBSan, смотреть в ассемблер, когда что-то не сходится.
Источники
- Brian Kernighan, Dennis Ritchie. The C Programming Language, 2nd ed. — исторический канон, но C89; читать с поправкой на возраст.
- Jens Gustedt. Modern C — современный и бесплатный: inria.hal.science. Практическое введение попроще — Beej’s Guide to C.
- Randal Bryant, David O’Hallaron. Computer Systems: A Programmer’s Perspective — лучшая книга про стык кода и машины: csapp.cs.cmu.edu.
- W. Richard Stevens, Stephen Rago. Advanced Programming in the UNIX Environment — справочник по системным вызовам.
- Черновик стандарта C23 (N3096): open-std.org. Правила с обоснованиями — SEI CERT C Coding Standard.
- Chris Lattner. What Every C Programmer Should Know About Undefined Behavior: blog.llvm.org.
- John Regehr. A Guide to Undefined Behavior in C and C++: blog.regehr.org.
- Simon Tatham. The Descent to C — короткий текст для тех, кто идёт в C из высокоуровневых языков: chiark.greenend.org.uk.
- AddressSanitizer: github.com/google/sanitizers; Valgrind Memcheck: valgrind.org.
- CISA, NSA. The Case for Memory Safe Roadmaps: cisa.gov.
Что дальше
Начинаем с фундамента: абстрактная машина стандарта, типы и их представление, целочисленные преобразования, операторы и порядок вычислений, управление потоком, функции и связывание — то есть с модели, которую описывает синтаксис C, а не с самого синтаксиса.