Си (C) с нуля: большая шпаргалка по синтаксису, указателям и памяти
Язык Си (C) — компилируемый язык программирования общего назначения с ручным управлением памятью, созданный Деннисом Ритчи в 1972 году для UNIX. Спустя полвека этот язык программирования Си остаётся фундаментом индустрии: на нём написаны ядра Linux, Windows и macOS, интерпретаторы Python, PHP и Ruby, драйверы и встраиваемые системы. Эта шпаргалка Си для начинающих проведёт от первой программы и компиляции до указателей, динамической памяти и отладки в Valgrind. Основы языка Си здесь изложены плотно и по делу — типы, операторы, структуры, препроцессор и Makefile, с примерами от простого к сложному. Держите страницу под рукой как справочник: не читайте залпом, а возвращайтесь к нужному разделу.
Быстрый старт
C — компилируемый язык: вы пишете текст, компилятор превращает его в машинный код, линкер собирает исполняемый файл.
// hello.c — первая программа
#include <stdio.h>
int main(void) {
printf("Привет, мир!\n");
return 0; // 0 — успешное завершение
}
# Linux: sudo apt install build-essential | macOS: xcode-select --install
# Windows: MSYS2, MinGW-w64 или WSL
gcc hello.c -o hello # скомпилировать и слинковать
./hello # запустить → Привет, мир!
# Рабочий набор флагов, который стоит набирать всегда:
gcc -std=c17 -Wall -Wextra -Wpedantic -O2 -g hello.c -o hello
-std=c17 фиксирует стандарт, -Wall -Wextra включают максимум предупреждений, -g добавляет отладочную информацию, -O2 оптимизирует. Предупреждения компилятора — это найденные баги; во время разработки добавляйте -Werror, чтобы они останавливали сборку.
Ключевые термины
| Термин | Значение |
|---|---|
| Компилятор | Программа (gcc, clang), переводящая C в машинный код |
| Компиляционная единица | Один .c файл после работы препроцессора |
| Линкер | Собирает объектные файлы и библиотеки в исполняемый файл |
| Препроцессор | Обрабатывает #include, #define, #if до компиляции |
| Заголовочный файл | .h с объявлениями, подключается через #include |
| Указатель | Переменная, хранящая адрес памяти |
| Массив | Непрерывный блок элементов одного типа |
| Строка | Массив char, завершающийся нулевым байтом '\0' |
| Структура | Составной тип из именованных полей |
| Куча (heap) | Память для malloc/free, живёт до явного освобождения |
| Стек (stack) | Память локальных переменных, освобождается автоматически |
| UB | Undefined behavior — неопределённое поведение, программа может делать что угодно |
Компиляция: препроцессор, компилятор, ассемблер, линкер
Компиляция — конвейер из четырёх этапов. Понимание каждого объясняет ошибки вроде «undefined reference» или «implicit declaration».
gcc -E main.c -o main.i # 1. препроцессор: раскрывает #include и #define
gcc -S main.i -o main.s # 2. компилятор: C → ассемблер
gcc -c main.s -o main.o # 3. ассемблер: ассемблер → объектный код
gcc main.o -o main # 4. линкер: объектный код + библиотеки → бинарник
| Флаг | Назначение |
|---|---|
-std=c17 |
Стандарт языка (ещё c11, c99, gnu17) |
-Wall / -Wextra |
Основной и дополнительный наборы предупреждений |
-Wpedantic / -Werror |
Строгий стандарт / предупреждения как ошибки |
-O0, -O2, -O3, -Os |
Уровень оптимизации (-Os — по размеру) |
-g |
Отладочная информация для gdb |
-c / -o file |
Только компиляция / имя выходного файла |
-I dir / -L dir / -l name |
Каталог заголовков / библиотек / линковка (-lm) |
-D NAME=1 / -pthread |
Макрос из командной строки / потоки POSIX |
-fsanitize=address,undefined |
Санитайзеры ASan и UBSan |
undefined reference to 'foo' приходит от линкера: функция объявлена, но её тело не попало в сборку. implicit declaration — от компилятора: не подключён заголовок с прототипом.
Структура программы и функция main
Программа — набор функций, одна из которых, main, является точкой входа и обязана возвращать int (код возврата видит оболочка через echo $?).
#include <stdio.h>
#include <stdlib.h>
int add(int a, int b); // прототип: компилятор знает сигнатуру до определения
int main(int argc, char *argv[]) {
// argc — число аргументов, argv[0] — имя программы, argv[argc] == NULL
if (argc < 3) {
fprintf(stderr, "Использование: %s <a> <b>\n", argv[0]);
return 1;
}
printf("%d + %d = %d\n", atoi(argv[1]), atoi(argv[2]), add(atoi(argv[1]), atoi(argv[2])));
return 0;
}
int add(int a, int b) { return a + b; }
int main(void)— без аргументов,int main(int argc, char *argv[])— с ними. Написаниеvoid main()нестандартно и запрещено.- В C99+
mainможно не заканчиватьreturn— вернётся 0. Явныйreturnнагляднее. - Прервать программу можно через
exit(0)илиabort();exitвызывает обработчикиatexitи сбрасывает буферыstdio.
Типы данных
Размеры базовых типов зависят от платформы; стандарт задаёт лишь минимумы. На 64-битном Linux действует модель LP64: long и указатель — 8 байт.
#include <stdio.h>
#include <limits.h>
#include <float.h>
int main(void) {
printf("char=%zu int=%zu long=%zu double=%zu size_t=%zu void*=%zu\n",
sizeof(char), sizeof(int), sizeof(long),
sizeof(double), sizeof(size_t), sizeof(void *));
// char всегда 1; int обычно 4; long 8 на Linux и 4 на Windows
printf("INT_MAX=%d UINT_MAX=%u DBL_DIG=%d\n", INT_MAX, UINT_MAX, DBL_DIG);
return 0; // limits.h и float.h: границы типов и точность
}
| Тип | Типичный размер | Назначение |
|---|---|---|
char |
1 байт | Символ; знаковость зависит от реализации |
unsigned char |
1 байт | 0…255, безопасен для байтов |
short |
2 байта | −32768…32767 |
int |
4 байта | ±2.1·10⁹ |
long |
8 байт (Linux) | ±9.2·10¹⁸ |
float |
4 байта | ~7 значащих цифр |
double |
8 байт | ~15 значащих цифр |
size_t |
8 байт | Индексы и размеры; беззнаковый |
ptrdiff_t |
8 байт | Разность указателей; знаковый |
Когда размер критичен (протоколы, бинарные форматы), берут типы фиксированной ширины и переносимые форматы вывода:
#include <stdint.h>
#include <inttypes.h>
#include <stdbool.h>
#include <stdio.h>
int main(void) {
int8_t a = -100;
uint8_t b = 255;
int64_t e = 9000000000000000000LL;
bool ok = true; // C99
printf("%" PRId64 " %d %u %d\n", e, a, (unsigned)b, ok);
return 0;
}
Вещественные числа сравнивают с допуском: 0.1 + 0.2 != 0.3 из-за двоичной точности, проверка — if (fabs(a - b) < 1e-9).
Переменные, области видимости и квалификаторы
Область видимости (scope) определяет, где доступно имя; время жизни — когда занята память.
#include <stdio.h>
int global = 10; // внешняя линковка
static int hidden = 20; // виден только в этом .c файле
int next_id(void) {
static int id = 0; // инициализируется один раз, живёт всю программу
return ++id;
}
int main(void) {
int local = 30;
{
int local = 99; // затеняет внешнюю
printf("%d\n", local); // 99
}
printf("%d\n", local); // 30
printf("%d %d\n", next_id(), next_id()); // порядок вызова не определён!
printf("%d %d\n", global, hidden);
return 0;
}
const double PI = 3.14159; // нельзя изменить после инициализации
volatile int flag; // компилятор не кеширует: меняется извне
extern int shared; // объявление: определение в другом файле
register int i; // подсказка «в регистр», в современном C бесполезна
volatile int done = 0;
while (!done) { /* ожидание */ } // без volatile оптимизатор заменит на while (1) {}
volatile критичен именно в C: оптимизатор вправе выбросить повторное чтение, если переменная, по его мнению, не менялась, — но для регистров устройств и переменных из обработчиков сигналов это неверно. restrict (C99) сообщает, что два указателя не пересекаются, и разрешает перестановку чтений и записей; неверное применение ведёт к UB, поэтому по умолчанию его лучше не использовать.
Операторы и приоритет
Главная ловушка — целочисленное деление и разница между / и %.
#include <stdio.h>
int main(void) {
int x = 7, y = 3;
printf("%d %d\n", x / y, x % y); // 2 1 — дробная часть отбрасывается
double half = x / 2.0; // 3.5 — из-за 2.0 операнд стал double
double bad = x / 2; // 3.0! деление целых уже потеряло точность
printf("%.1f %.1f\n", half, bad);
int neg = -7;
printf("%d %d\n", neg / 2, neg % 2); // -3 -1 (округление к нулю)
int i = 5;
int a = i++; // a = 5, i = 6 (постфикс возвращает старое значение)
int b = ++i; // b = 7, i = 7 (префикс возвращает новое)
printf("%d %d %d\n", a, b, i);
int p = 0;
if (p != 0 && 10 / p > 2) { /* правый операнд не вычисляется: короткое замыкание */ }
printf("max = %d\n", (x > y) ? x : y);
return 0;
}
| Приоритет | Операторы | Ассоциативность |
|---|---|---|
| 1 | () [] . -> ++ -- (постфикс) |
слева направо |
| 2 | ! ~ унарные ++ -- + - * & sizeof (тип) |
справа налево |
| 3 | * / % |
слева направо |
| 4 | + - |
слева направо |
| 5 | << >> |
слева направо |
| 6 | < <= > >= |
слева направо |
| 7 | == != |
слева направо |
| 8–10 | &, ^, | |
слева направо |
| 11–12 | &&, || |
слева направо |
| 13 | ?: |
справа налево |
| 14 | = += -= *= /= %= &= ^= |= <<= >>= |
справа налево |
| 15 | , |
слева направо |
Правило простое: любой непонятный приоритет — ставьте скобки. *p++ читается как *(p++), а (*p)++ увеличивает значение по указателю, а не сам указатель.
Управляющие конструкции
Ветвление, циклы и переходы. goto в C есть, и один сценарий для него уместен: единая точка освобождения ресурсов.
#include <stdio.h>
int main(void) {
int score = 85;
if (score >= 90) puts("Отлично");
else if (score >= 70) puts("Хорошо");
else puts("Нужно подтянуть");
switch (score / 10) {
case 10:
case 9: puts("A"); break; // без break управление «провалится» дальше
case 8: puts("B"); break;
default: puts("C"); break;
}
for (int i = 0; i < 5; i++) printf("%d ", i); // переменную можно объявлять в for (C99)
putchar('\n');
int n = 3;
while (n > 0) printf("%d ", n--);
putchar('\n');
int k = 0;
do { printf("выполнится хотя бы раз: k=%d\n", k); k++; } while (k < 1);
for (;;) break; // бесконечный цикл с выходом
for (int i = 0; i < 10; i++) {
if (i == 2) continue; // пропустить итерацию
if (i == 5) break; // выйти из цикла
printf("%d ", i); // 0 1 3 4
}
putchar('\n');
return 0;
}
switch работает только с целочисленными типами (включая char и enum) и сравнивает на равенство; забытый break — классический источник ошибок (-Wimplicit-fallthrough предупредит).
#include <stdio.h>
#include <stdlib.h>
int process(void) {
int rc = 0;
char *buf = malloc(1024);
if (!buf) return -1;
FILE *f = fopen("data.txt", "r");
if (!f) { rc = -1; goto cleanup_buf; }
if (fread(buf, 1, 1024, f) == 0) { rc = -1; goto cleanup_file; }
cleanup_file:
fclose(f);
cleanup_buf:
free(buf);
return rc;
}
Функции
Все аргументы передаются по значению: функция получает копии. Чтобы изменить переменную вызывающего, передают указатель.
#include <stdio.h>
void swap_bad(int a, int b); // НЕ сработает: меняет копии
void swap(int *a, int *b); // работает: меняет значения по адресам
int factorial(int n);
int main(void) {
int x = 1, y = 2;
swap_bad(x, y);
printf("%d %d\n", x, y); // 1 2
swap(&x, &y);
printf("%d %d\n", x, y); // 2 1
printf("5! = %d\n", factorial(5)); // 120
return 0;
}
void swap_bad(int a, int b) { int t = a; a = b; b = t; }
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }
int factorial(int n) {
if (n <= 1) return 1; // база рекурсии обязательна, иначе переполнение стека
return n * factorial(n - 1);
}
- Прототип нужен, если функция вызывается до определения; без него C17 предупредит о неявном объявлении.
voidв списке параметров означает «аргументов нет»; пустые()— «неизвестно сколько», так писать не нужно.- Возвращать локальную переменную по значению можно, а указатель на неё — нельзя.
int *dangling(void) { int local = 42; return &local; } // ОШИБКА: адрес мёртв после return
int safe(void) { int local = 42; return local; } // OK: возвращается копия значения
Функция с типом int, не возвращающая значение в какой-то ветке, — источник UB; -Wreturn-type (входит в -Wall) это ловит.
Массивы
Массив — непрерывный блок элементов. Границы не проверяются, а выход за них — UB, а не ошибка выполнения.
#include <stdio.h>
int main(void) {
int a[5] = {1, 2, 3, 4, 5};
int zeros[5] = {0}; // все элементы — нули
int auto_size[] = {10, 20, 30}; // размер выведен: 3
size_t n = sizeof(auto_size) / sizeof(auto_size[0]); // идиома «число элементов»
for (size_t i = 0; i < n; i++) printf("%d ", auto_size[i]);
putchar('\n');
int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}}; // память по строкам (row-major)
for (size_t i = 0; i < 2; i++) {
for (size_t j = 0; j < 3; j++) printf("%d ", matrix[i][j]);
putchar('\n');
}
printf("a[4]=%d zeros[0]=%d\n", a[4], zeros[0]);
return 0; // a[5] не существует: индексы 0..4, чтение a[5] — UB
}
Массивы не копируются оператором = (нужен memcpy), а при передаче в функцию «затухают» до указателя, поэтому длину передают отдельно:
void fill(int *arr, size_t n, int value) { // int a[] здесь идентично int *a
for (size_t i = 0; i < n; i++) arr[i] = value;
}
Строки
Строка — массив char, последний элемент которого равен '\0'. Длину никто не хранит: функции ищут нулевой байт. Отсюда большинство уязвимостей на C.
#include <stdio.h>
#include <string.h>
int main(void) {
char s1[] = "Hello"; // массив из 6 байт: 'H','e','l','l','o','\0'
char *s2 = "Hello"; // указатель на литерал в read-only памяти
printf("длина=%zu размер=%zu s2=%s\n", strlen(s1), sizeof(s1), s2);
s1[0] = 'J'; // OK: массив изменяем
// s2[0] = 'J'; // UB: строковый литерал менять нельзя
char buf[64];
strcpy(buf, s1); // БЕЗ проверки размера
strcat(buf, " world"); // БЕЗ проверки вместимости
if (strcmp(s1, "Jello") == 0) puts("совпало"); // 0 — равны; == не работает
return 0;
}
| Функция | Что делает | Осторожно |
|---|---|---|
strlen(s) |
Длина без '\0' |
Возвращает size_t, не int |
strcpy(d, s) |
Копирует строку | Нет проверки размера — переполнение |
strncpy(d, s, n) |
Копирует до n байт |
Может не поставить '\0' |
strcat(d, s) |
Приклеивает в конец | Нет проверки вместимости |
strcmp(a, b) |
Сравнение | 0 — равны; результат не «1/0» |
strchr / strstr |
Поиск символа / подстроки | Возвращает указатель или NULL |
strtok(s, sep) |
Разбиение на токены | Меняет строку, не потокобезопасен |
memcpy / memmove |
Копирует n байт |
memmove — при пересечении областей |
memset / memcmp |
Заполнение / сравнение байт | Только побайтово |
Опасные функции и безопасные замены:
#include <stdio.h>
#include <string.h>
int main(void) {
char buf[16];
// gets(buf); // УДАЛЕНА из C11: переполнение буфера
if (fgets(buf, sizeof buf, stdin)) { // не больше 15 символов + '\0'
size_t len = strlen(buf);
if (len > 0 && buf[len - 1] == '\n') buf[len - 1] = '\0';
}
char safe[16];
snprintf(safe, sizeof safe, "%s", buf); // всегда ставит '\0' и знает размер
printf("[%s]\n", safe);
return 0;
}
strncpy не гарантирует завершающий '\0', если строка длиннее n. snprintf гарантирует, но возвращает длину, которая была бы записана (может превышать размер буфера) — проверяйте её, если важна полнота.
Указатели
Указатель хранит адрес. Это главный инструмент C: он даёт прямой доступ к памяти и позволяет функциям изменять чужие данные. Здесь же появляются разыменование NULL, висячие указатели и выход за границы.
#include <stdio.h>
#include <stddef.h>
int main(void) {
int x = 42;
int *p = &x; // & — взять адрес, * — разыменовать
printf("%d %d\n", x, *p); // 42 42
*p = 100;
printf("%d\n", x); // 100
printf("адрес: %p\n", (void *)p); // %p ждёт void*
int *nothing = NULL;
if (nothing != NULL) printf("%d\n", *nothing); // сюда не попадём
int **pp = &p; // указатель на указатель
printf("**pp = %d\n", **pp); // 100
int a[4] = {10, 20, 30, 40};
int *q = a; // a распадается до &a[0]
printf("%d %d %d\n", *q, *(q + 1), q[2]); // 10 20 30
printf("%td\n", (q + 3) - q); // 3 — разность указателей = число элементов
return 0;
}
Арифметика указателей идёт в единицах типа, а не в байтах, и допустима только внутри одного массива. const с указателями читается справа налево:
const int *a; // указатель на const int: нельзя менять *a, можно менять a
int * const b = &x; // const указатель: нельзя менять b, можно менять *b
const int * const c = &x; // нельзя ни то, ни другое
Указатель на функцию позволяет выбирать поведение в рантайме — так устроен qsort:
#include <stdio.h>
#include <stdlib.h>
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
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 main(void) {
int (*op)(int, int) = add; // указатель на функцию
printf("%d\n", op(5, 3)); // 8
op = sub; // меняем op отдельной строкой: порядок вычисления
printf("%d\n", op(5, 3)); // аргументов printf не определён (2)
int arr[] = {3, 1, 2};
qsort(arr, 3, sizeof arr[0], cmp_int);
printf("%d %d %d\n", arr[0], arr[1], arr[2]); // 1 2 3
return 0;
}
Связь массивов и указателей (decay)
Имя массива в большинстве выражений превращается в указатель на первый элемент — это array decay. Исключения: sizeof, &arr и инициализация строковым литералом.
#include <stdio.h>
void f(int a[]) { // параметр фактически int *a
printf("в функции: %zu\n", sizeof(a)); // размер указателя, не массива!
}
int main(void) {
int arr[10];
printf("в main: %zu\n", sizeof(arr)); // 40 байт
printf("&arr: %zu\n", sizeof(&arr)); // 8 байт — указатель на весь массив
f(arr); // напечатает 8, а не 40
return 0;
}
Именно поэтому функцию с параметром-массивом нельзя спросить о размере: массив уже стал указателем, размер передавайте явно.
Динамическая память
Локальные переменные живут на стеке и исчезают при выходе из функции. Когда размер неизвестен заранее или объект должен жить дольше, память берут из кучи.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t n = 10;
int *arr = malloc(n * sizeof *arr); // sizeof *arr не зависит от типа
if (arr == NULL) { perror("malloc"); return 1; } // проверять ВСЕГДА
for (size_t i = 0; i < n; i++) arr[i] = (int)i * i;
int *zeros = calloc(n, sizeof *zeros); // обнуляет память
if (zeros == NULL) { free(arr); return 1; }
int *tmp = realloc(arr, 20 * sizeof *arr); // НЕЛЬЗЯ присваивать прямо в arr!
if (tmp == NULL) { free(zeros); free(arr); return 1; } // arr всё ещё действителен
arr = tmp;
printf("%d %d %d\n", arr[0], arr[3], zeros[5]);
free(zeros);
free(arr);
arr = NULL; // защита от повторного free и висячего указателя
return 0;
}
| Критерий | Стек | Куча |
|---|---|---|
| Выделение | Автоматическое | Явное (malloc) |
| Освобождение | При выходе из функции | Вручную (free) |
| Скорость | Очень быстро | Медленнее |
| Размер | Малый (единицы МБ) | До доступной памяти |
| Ошибка | Переполнение стека | Утечка, фрагментация |
mallocвозвращаетvoid *: приведение типа не нужно и маскирует ошибку «не подключёнstdlib.h».- Память из
mallocне обнулена — читать её до записи значит читать мусор. free(NULL)безопасен,freeдважды по одному указателю — UB.strdup(POSIX, не ISO C) сама выделяет память и требуетfree.
Структуры, объединения и перечисления
Структура группирует поля разных типов, union накладывает их на одну память, enum задаёт именованные константы, typedef вводит короткие имена.
#include <stdio.h>
#include <stddef.h>
struct Point { int x; int y; };
typedef struct Point Point; // теперь пишем Point, а не struct Point
typedef struct { int width, height; } Size; // анонимная структура + typedef
enum Color { RED, GREEN = 5, BLUE }; // RED=0, GREEN=5, BLUE=6
int main(void) {
Point p = {1, 2}; // позиционная инициализация
Point q = {.y = 5, .x = 3}; // designated initializers (C99)
Point *pp = &p;
printf("p=(%d,%d) q=(%d,%d)\n", p.x, p.y, q.x, q.y); // q инициализирован по именам
printf("%d\n", pp->x); // стрелка для указателя: pp->x == (*pp).x
pp->x = 100;
printf("%d\n", p.x); // 100 — изменили структуру через указатель
Size s = {.width = 1920, .height = 1080};
printf("%dx%d BLUE=%d offset_y=%zu\n",
s.width, s.height, (int)BLUE, offsetof(struct Point, y));
return 0;
}
Выравнивание добавляет к структуре байты-заполнители (padding):
#include <stdio.h>
struct A { char c; int i; }; // sizeof = 8: после char 3 байта padding
struct B { int i; char c; }; // sizeof = 8: хвостовой padding
struct C { char c; int i; } __attribute__((packed)); // GCC: 5 байт, доступ невыровнен
union Value { int i; float f; unsigned char bytes[4]; }; // все поля делят одну память
int main(void) {
printf("%zu %zu %zu\n", sizeof(struct A), sizeof(struct B), sizeof(struct C));
union Value v;
v.i = 1;
printf("байты: %02x %02x %02x %02x\n", v.bytes[0], v.bytes[1], v.bytes[2], v.bytes[3]);
v.f = 3.14f; // перезаписали ту же память
printf("float: %g, размер union: %zu\n", v.f, sizeof v);
return 0;
}
Поля располагайте по убыванию размера — так padding минимален. __attribute__((packed)) экономит память, но невыровненный доступ на части архитектур медленнее или запрещён. Чтение поля union, в которое не записывали, для байтового представления допускается, но результат зависит от платформы: для разбора байтов берите unsigned char.
Препроцессор
Препроцессор выполняется до компилятора: подставляет #include, раскрывает #define, выбирает код по #if. Это текстовый инструмент, а не часть языка, — отсюда его ловушки.
#include <stdio.h> // <...> — поиск в системных каталогах
// #include "local.h" // "..." — сначала в каталоге текущего файла
#define PI 3.14159
#define SQUARE(x) ((x) * (x)) // скобки вокруг аргумента и всего выражения!
#define MAX(a, b) ((a) > (b) ? (a) : (b)) // аргументы вычисляются ДВАЖДЫ
#define STR(x) #x // строкификация: STR(42) → "42"
#define CONCAT(a, b) a##b // склейка токенов: CONCAT(var, 1) → var1
int main(void) {
printf("PI=%f SQUARE(3)=%d SQUARE(1+2)=%d\n", PI, SQUARE(3), SQUARE(1 + 2));
printf("MAX(3,5)=%d STR(hello)=%s\n", MAX(3, 5), STR(hello));
return 0;
}
int i = 2;
// int r = SQUARE(i++); // раскроется в ((i++) * (i++)) — UB, i увеличится дважды!
Двойное вычисление аргументов — главная опасность макросов. Там, где можно, используйте static inline-функции: они типизированы и вычисляют аргументы один раз.
// point.h — include guard защищает от повторного подключения
#ifndef POINT_H
#define POINT_H
typedef struct { int x; int y; } Point;
int point_sum(const Point *p);
#endif // POINT_H
// #pragma once — популярная, но не входящая в стандарт альтернатива
#if defined(__linux__)
#define PLATFORM "Linux"
#elif defined(__APPLE__)
#define PLATFORM "macOS"
#else
#define PLATFORM "Unknown"
#endif
#ifdef DEBUG
#define LOG(...) fprintf(stderr, __VA_ARGS__) // variadic macro (C99)
#else
#define LOG(...) ((void)0)
#endif
Полезные предопределённые макросы: __FILE__, __LINE__, __DATE__, __TIME__, __STDC_VERSION__, а также идентификатор __func__ (локальная строка с именем функции).
Ввод и вывод
printf и scanf — базовый форматированный ввод-вывод. Половина ошибок на C вызвана несоответствием спецификатора и типа: это UB, а не аккуратная ошибка.
#include <stdio.h>
int main(void) {
int n = 42;
unsigned u = 3000000000u;
double d = 3.14159;
printf("%d %u\n", n, u); // знаковое / беззнаковое
printf("%f %.2f %e %g\n", d, d, d, d); // 6 знаков / 2 знака / экспонента / компактно
printf("%c %s\n", 'A', "text"); // символ / строка
printf("%zu %p\n", sizeof n, (void *)&n); // size_t — именно %zu, не %d!
printf("%x %o %%\n", 255, 8); // ff 10 %
puts("строка с автоматическим переводом строки");
return 0;
}
Чтение ввода и проверка результата:
#include <stdio.h>
int main(void) {
int n;
if (scanf("%d", &n) != 1) { return 1; } // & обязателен; возврат — число полей
char name[32];
scanf("%31s", name); // ширина ограничивает ввод — защита от переполнения
double price;
scanf("%lf", &price); // для double в scanf — %lf (в printf достаточно %f)
char line[256]; // построчное чтение — через fgets, не scanf("%s")
while (fgets(line, sizeof line, stdin)) printf("прочитано: %s", line);
return 0;
}
scanf возвращает число успешно прочитанных полей, EOF — при конце ввода. Игнорировать его нельзя: при неверном вводе переменная останется неинициализированной.
Файлы
Работа идёт через указатель FILE *. Потоки буферизуются: данные попадают на диск не в момент fwrite, а при заполнении буфера или закрытии, поэтому fclose обязателен.
#include <stdio.h>
int main(void) {
FILE *out = fopen("data.txt", "w"); // "w" создаёт/обрезает файл
if (!out) { perror("fopen"); return 1; }
fprintf(out, "Строка %d\n", 1);
fputs("Ещё строка\n", out);
fclose(out);
FILE *in = fopen("data.txt", "r");
if (!in) { perror("fopen"); return 1; }
char buf[128];
while (fgets(buf, sizeof buf, in)) fputs(buf, stdout); // NULL — конец или ошибка
if (ferror(in)) fprintf(stderr, "ошибка чтения\n"); // отличить от конца файла
fclose(in);
return 0;
}
| Режим | Действие |
|---|---|
"r" |
Чтение, файл должен существовать |
"w" |
Запись, файл создаётся/обрезается |
"a" |
Дозапись в конец, файл создаётся |
"r+" / "w+" / "a+" |
Чтение и запись (w+ обрезает, a+ пишет в конец) |
"rb" / "wb" |
То же в бинарном режиме (важно на Windows) |
Бинарный ввод-вывод считает элементы, а не строки:
#include <stdio.h>
int main(void) {
int arr[4] = {1, 2, 3, 4};
FILE *f = fopen("nums.bin", "wb");
if (!f) return 1;
size_t written = fwrite(arr, sizeof arr[0], 4, f); // число элементов
fclose(f);
if (written != 4) return 1;
FILE *g = fopen("nums.bin", "rb");
if (!g) return 1;
int loaded[4] = {0};
size_t got = fread(loaded, sizeof loaded[0], 4, g);
fseek(g, 0, SEEK_END); // в конец; ftell даст размер файла
rewind(g); // назад, в начало (== fseek(f, 0, SEEK_SET))
fclose(g);
printf("прочитано: %zu\n", got);
return 0;
}
feof не проверяет «файл кончился» заранее: флаг встаёт только после неудачной попытки чтения, поэтому цикл строят на fgets/fread, а feof вызывают после. Удаление и переименование — remove и rename; текст ошибки по errno печатает perror.
Битовые операции
Побитовые операторы — инструмент низкого уровня: флаги, маски, упаковка данных, регистры. Смещение отрицательных чисел и сдвиг на величину, не меньшую ширины типа, — UB, поэтому берите беззнаковые типы.
#include <stdio.h>
#define BIT(n) (1u << (n))
#define SET(x, n) ((x) |= BIT(n))
#define CLEAR(x, n) ((x) &= ~BIT(n))
#define TOGGLE(x, n) ((x) ^= BIT(n))
#define CHECK(x, n) (((x) >> (n)) & 1u)
int main(void) {
unsigned x = 0x0B; // 1011 — шестнадцатеричная запись совместима с C17
printf("%u\n", x & 0x01u); // 1 — младший бит
printf("%u\n", x | (1u << 3)); // 11 — установить бит 3
printf("%u\n", x & ~(1u << 1)); // 9 — сбросить бит 1
printf("%u\n", x ^ (1u << 2)); // 15 — инвертировать бит 2
printf("%u popcount=%d %s\n", ~x, __builtin_popcount(x), (x & 1u) ? "нечётное" : "чётное");
unsigned flags = 0;
SET(flags, 0); SET(flags, 3); // 9
if (CHECK(flags, 3)) puts("флаг 3 установлен");
CLEAR(flags, 3);
printf("%u\n", flags); // 1
return 0;
}
Приёмы: x << 1 — умножение на 2, x >> 1 — деление на 2 (только для беззнаковых), x & (x - 1) обнуляет младший установленный бит, x & -x выделяет его. Применяйте осознанно: компиляторы сами оптимизируют умножение и деление.
Многомодульная сборка и Makefile
Реальная программа состоит из многих .c файлов. Объявления кладут в заголовки .h, определения — в .c. Компиляция каждого файла независима, линкер соединяет результаты.
// point.h
#ifndef POINT_H
#define POINT_H
typedef struct { int x; int y; } Point;
int point_sum(const Point *p); // объявление
#endif
// point.c
#include "point.h"
int point_sum(const Point *p) { return p->x + p->y; } // определение
// main.c
#include <stdio.h>
#include "point.h"
int main(void) {
Point p = {.x = 1, .y = 2};
printf("sum = %d\n", point_sum(&p));
return 0;
}
gcc -c point.c -o point.o # компилируем каждый файл отдельно
gcc -c main.c -o main.o
gcc main.o point.o -o app # линкуем вместе
./app # sum = 3
CC := gcc
CFLAGS := -std=c17 -Wall -Wextra -Wpedantic -O2 -g
TARGET := app
SRCS := $(wildcard *.c)
OBJS := $(SRCS:.c=.o)
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) -o $@ $^
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
.PHONY: clean run
clean:
rm -f $(OBJS) $(TARGET)
run: $(TARGET)
./$(TARGET)
В рецептах make обязательны символы табуляции; $@ — цель, $< — первый зависимый файл, $^ — все зависимости. Запуск: make, затем make run и make clean. Заголовок должен быть самодостаточным и иметь include guard; не определяйте переменные в заголовке без extern (иначе множественное определение); static на функциях и глобалах скрывает их от других модулей; порядок -l при линковке важен — библиотека идёт после использующего её объектного файла.
Отладка: gdb, Valgrind, AddressSanitizer
Отладка на C обязательна, потому что ошибки памяти не проявляются как исключения: программа продолжает работать или падает через час.
gcc -g -O0 -Wall main.c -o app # -g добавляет отладочную информацию, -O0 упрощает шаг
gdb ./app
| Команда gdb | Действие |
|---|---|
run / r |
Запустить программу |
break main / b file.c:42 |
Точка останова |
next / n, step / s |
Следующая строка с заходом в функции или без |
continue / c |
Продолжить до следующей точки останова |
print x / p *ptr |
Напечатать значение или разыменовать указатель |
backtrace / bt |
Стек вызовов |
watch x |
Остановиться при изменении x |
info locals, quit |
Локальные переменные, выход |
# Valgrind находит утечки и обращения к неинициализированной памяти без пересборки
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./app
# AddressSanitizer и UBSan быстрее и встраиваются компилятором
gcc -fsanitize=address,undefined -fno-omit-frame-pointer -g main.c -o app_asan
./app_asan
Invalid read of size 4 от Valgrind означает выход за границы, definitely lost: 40 bytes — утечку, Conditional jump depends on uninitialised value — чтение мусора. ASan ловит переполнение буфера, use-after-free и double free с точной трассировкой; UBSan сообщает о переполнении знаковых, неверных сдвигах и разыменовании NULL. Для многопоточного кода есть -fsanitize=thread.
Undefined behavior: где программа ломается молча
Стандарт C описывает неопределённое поведение как ситуации, в которых «стандарт не налагает требований». Компилятор вправе предположить, что UB никогда не возникает, и оптимизировать код соответственно — последствия непредсказуемы.
#include <limits.h>
#include <stdlib.h>
int overflow(void) { int x = INT_MAX; return x + 1; } // UB: переполнение знакового
int uninit(void) { int x; return x + 1; } // UB: чтение неинициализированной
int *dangling(void) { int local = 5; return &local; } // UB при разыменовании
int shift_bad(unsigned x) { return x << 32; } // UB: сдвиг на ширину типа
void use_after_free(void) {
int *p = malloc(sizeof *p);
free(p);
*p = 10; // UB: use-after-free
free(p); // UB: double free
}
char *literal(void) { char *s = "abc"; s[0] = 'A'; return s; } // UB: изменение литерала
Частые источники UB:
- Переполнение знаковых целых (беззнаковые определены и заворачиваются по модулю 2ⁿ).
- Выход за границы массива, включая строку без
'\0'. - Использование неинициализированной переменной или указателя.
use-after-free,double free, разыменованиеNULL.- Изменение строкового литерала.
- Отсутствие
returnв функции с ненулевым типом возврата. - Деление на ноль и
INT_MIN / -1. - Нарушение strict aliasing: доступ к объекту через указатель несовместимого типа.
- Несколько модификаций одной переменной между точками следования:
i = i++ + ++i.
Практический вывод: включайте санитайзеры в тестовых сборках — они превращают молчаливый UB в громкую ошибку с трассировкой. И никогда не полагайтесь на «у меня на машине работает».
Частые ошибки
| Ошибка | Причина | Решение |
|---|---|---|
| Use-after-free | Память освобождена, указатель используется | После free установить p = NULL; искать через ASan/Valgrind |
| Buffer overflow | Запись за границей массива или строки | Проверять размеры, snprintf/fgets, -fsanitize=address |
| Uninitialized variable | Переменная объявлена, но не задана | Инициализировать при объявлении: int x = 0; |
Забыт & в scanf |
Ожидается адрес переменной | scanf("%d", &n), проверять возврат |
Забыт * при разыменовании |
Работаете с адресом, а не значением | *p — значение, p — адрес |
| Off-by-one | Цикл i <= n при n элементах |
Верхняя граница строго < n |
| Memory leak | Забыли free для malloc |
Симметрия malloc/free, Valgrind |
| Double free | free вызван дважды |
free(p); p = NULL; — free(NULL) безопасен |
| Dangling pointer | Указатель на освобождённую или локальную память | Не возвращать адрес локальной; обнулять после free |
| Array decay | В функции sizeof(arr) даёт размер указателя |
Передавать длину отдельным параметром |
| Format mismatch | %d для size_t, %f для float* в scanf |
Подбирать спецификатор: %zu, %lf, %p с (void *) |
| Integer overflow | Знаковые переполняются (UB) | long long, проверки, беззнаковые для битов |
Нет return в не-void |
Функция доходит до конца без возврата | Компилятор с -Wall предупредит; всегда возвращать |
= вместо == |
Присваивание в условии | if (0 == x) или -Wparentheses |
strncpy без '\0' |
Копия длиннее буфера | Явно ставить buf[n-1] = '\0' или snprintf |
Потеря указателя в realloc |
Присваивание результата прямо в p |
Временный указатель: tmp = realloc(p, ...) |
gets/sprintf |
Нет контроля размера | fgets, snprintf; gets удалён из C11 |
| Изменение строкового литерала | char *s = "abc"; s[0] = 'A'; |
char s[] = "abc"; — копия в изменяемой памяти |
Ресурсы
- cppreference: C — точная справка по стандарту C17/C23 с примерами
- Брайан Керниган, Деннис Ритчи. «Язык программирования C» (K&R) — канонический учебник от создателей языка
- Стивен Прата. «Язык C. Лекции и упражнения» — самый подробный самоучитель на русском
- Modern C — Jens Gustedt, бесплатная книга о современном C (C17)
- Рабочие документы ISO C (WG14) — черновики стандарта
man-страницы:man 3 printf,man 2 malloc,man 3 strcpy; онлайн — man7.org- Документация GCC и Clang — флаги и расширения
- GDB, Valgrind, AddressSanitizer — отладка и поиск ошибок памяти
- SEI CERT C Coding Standard — правила безопасного C
- Compiler Explorer — смотреть ассемблер, который генерирует компилятор
Что дальше
- Linux: шпаргалка по командам — bash, права, процессы, grep/sed/awk
- Шпаргалка по Git: ветки, слияние, откат, решение конфликтов
- Rust: шпаргалка — владение, заимствование, типы, трейты
- Go: шпаргалка — горутины, каналы, интерфейсы, модули
- Python: шпаргалка от основ до профи
- C++: большая шпаргалка — ООП, шаблоны, STL, современные стандарты
- Алгоритмы и структуры данных на C/C++: шпаргалка к экзамену
- C++ Core Guidelines на русском: ключевые правила