Кладовочка

О разном, чтобы было под рукой онлайн

Си (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 — смотреть ассемблер, который генерирует компилятор

Что дальше


Связанные шпаргалки: C++, Rust, Go, Алгоритмы, Linux