Перейти к основному содержимому

200 вопросов по C (Си)

200 вопросов по C (Си)

Основы языка C

Вопрос

Что такое язык программирования C?

Ответ

Язык программирования C — это процедурный, компилируемый язык низкого уровня с элементами высокоуровневой абстракции. Он разработан для написания системного программного обеспечения, операционных систем и встраиваемых приложений. Язык обеспечивает прямой доступ к аппаратным ресурсам и управление памятью через указатели.


Вопрос

Какие основные характеристики языка C?

Ответ

Основные характеристики языка C:

  • Компилируемость
  • Статическая типизация
  • Поддержка процедурного программирования
  • Работа с указателями
  • Минимальный набор ключевых слов
  • Возможность прямого управления памятью
  • Портативность через стандартную библиотеку
  • Близость к архитектуре аппаратного обеспечения

Вопрос

Из каких частей состоит минимальная программа на C?

Ответ

Минимальная программа на C содержит функцию main, которая является точкой входа:

int main(void) {
return 0;
}

Функция main возвращает целое число, обычно 0 при успешном завершении.


Вопрос

Что означает директива #include?

Ответ

Директива #include указывает препроцессору вставить содержимое указанного файла в текущий исходный файл до компиляции. Она используется для подключения заголовочных файлов стандартной библиотеки или пользовательских объявлений.

Пример:

#include <stdio.h> // Стандартная библиотека
#include "mylib.h" // Пользовательский заголовок

Вопрос

Что такое комментарии в C и какие виды существуют?

Ответ

Комментарии — это участки кода, игнорируемые компилятором. В C поддерживаются два вида:

  • Однострочные: // комментарий (начиная со стандарта C99)
  • Многострочные: /* комментарий */

Пример:

/* Это многострочный комментарий */
int x = 5; // Это однострочный комментарий

Вопрос

Какие базовые типы данных есть в C?

Ответ

Базовые типы данных в C:

  • char — символьный тип (обычно 1 байт)
  • int — целочисленный тип
  • float — вещественное число одинарной точности
  • double — вещественное число двойной точности

Каждый из них может иметь модификаторы: signed, unsigned, short, long.


Вопрос

Что такое void в C?

Ответ

void обозначает отсутствие типа. Он используется:

  • В объявлении функций, которые ничего не возвращают: void func(void);
  • В указателях (void*), которые могут хранить адрес любого типа данных
  • В параметрах функции для явного указания отсутствия аргументов: int main(void)

Вопрос

Чем отличаются signed и unsigned типы?

Ответ

signed типы могут хранить как положительные, так и отрицательные значения.
unsigned типы хранят только неотрицательные значения, что расширяет диапазон положительных чисел.

Пример:

unsigned int x = 4294967295U; // Максимум для 32-битного unsigned int
signed int y = -1; // Допустимо только для signed

Вопрос

Как определить размер типа или переменной в C?

Ответ

Размер определяется с помощью оператора sizeof. Он возвращает размер в байтах.

Пример:

#include <stdio.h>
int main(void) {
printf("Size of int: %zu\n", sizeof(int));
printf("Size of char: %zu\n", sizeof(char));
return 0;
}

Вопрос

Что такое константы в C и как их объявлять?

Ответ

Константы — это значения, которые не изменяются во время выполнения программы. Их можно объявлять:

  • С помощью препроцессора: #define PI 3.14159
  • С помощью ключевого слова const: const double pi = 3.14159;

Разница: #define заменяется текстуально, а const создаёт переменную с защитой от изменения.


Переменные и область видимости

Вопрос

Что такое переменная в C?

Ответ

Переменная — это именованная область памяти, предназначенная для хранения значения определённого типа. Переменная должна быть объявлена до использования.

Пример:

int age = 25;

Вопрос

Что такое область видимости (scope) переменной?

Ответ

Область видимости определяет, где в программе переменная доступна.

  • Локальные переменные объявлены внутри блока (например, внутри функции) и видны только в этом блоке.
  • Глобальные переменные объявлены вне всех функций и видны во всём файле (и за его пределами при наличии extern).

Вопрос

Что такое время жизни переменной?

Ответ

Время жизни — это период, в течение которого переменная существует в памяти.

  • Локальные переменные автоматического класса хранения (auto) существуют только во время выполнения блока.
  • Статические переменные (static) существуют всё время выполнения программы.
  • Глобальные переменные также имеют время жизни на весь срок выполнения программы.

Вопрос

Что делает ключевое слово static применительно к переменной?

Ответ

Ключевое слово static влияет на время жизни и область видимости:

  • Для локальной переменной: сохраняет значение между вызовами функции и ограничивает видимость текущей функцией.
  • Для глобальной переменной: ограничивает видимость текущим файлом (внутреннее связывание).

Пример:

void counter(void) {
static int count = 0;
count++;
printf("%d\n", count);
}

Вопрос

Что такое extern и зачем он нужен?

Ответ

extern указывает, что переменная или функция определена в другом файле. Он используется для объявления без выделения памяти.

Пример:

// file1.c
int global_var = 42;

// file2.c
extern int global_var; // Объявление, не определение
void print_var(void) {
printf("%d\n", global_var);
}

Операторы и выражения

Вопрос

Какие арифметические операторы поддерживаются в C?

Ответ

В C поддерживаются следующие арифметические операторы:

  • + — сложение
  • - — вычитание
  • * — умножение
  • / — деление
  • % — остаток от деления (только для целочисленных типов)

Пример:

int a = 10, b = 3;
int sum = a + b; // 13
int remainder = a % b; // 1

Вопрос

Что такое приоритет операторов и ассоциативность?

Ответ

Приоритет определяет порядок выполнения операторов в выражении без скобок. Операторы с более высоким приоритетом выполняются первыми.
Ассоциативность определяет порядок вычисления операторов с одинаковым приоритетом:

  • Большинство бинарных операторов ассоциативны слева направо (a - b - c(a - b) - c)
  • Операторы присваивания и тернарный оператор ассоциативны справа налево (a = b = ca = (b = c))

Вопрос

Чем отличаются префиксные и постфиксные инкремент/декремент?

Ответ

Операторы ++ и -- могут использоваться в префиксной (++x) и постфиксной (x++) формах.

  • Префиксная форма сначала изменяет значение переменной, затем возвращает новое значение.
  • Постфиксная форма сначала возвращает текущее значение, затем изменяет переменную.

Пример:

int x = 5;
int a = ++x; // a = 6, x = 6
int b = x++; // b = 6, x = 7

Вопрос

Что делает оператор sizeof и к какому типу относится его результат?

Ответ

Оператор sizeof возвращает размер объекта или типа в байтах. Его результат имеет тип size_t, который определён в <stddef.h> и является беззнаковым целым типом, достаточным для представления размера любого объекта в памяти.

Пример:

size_t n = sizeof(int); // Обычно 4 на 32/64-битных системах

Вопрос

Можно ли применять sizeof к массиву? Что он вернёт?

Ответ

Да, sizeof можно применять к массиву. Он возвращает общий размер массива в байтах, то есть количество элементов × размер одного элемента.

Пример:

int arr[10];
size_t total = sizeof(arr); // 40 (если int — 4 байта)
size_t count = sizeof(arr) / sizeof(arr[0]); // 10

Важно: при передаче массива в функцию он превращается в указатель, и sizeof уже не даст общий размер.


Вопрос

Что такое побитовые операторы в C?

Ответ

Побитовые операторы работают с двоичным представлением целых чисел:

  • & — побитовое И
  • | — побитовое ИЛИ
  • ^ — побитовое исключающее ИЛИ (XOR)
  • ~ — побитовое НЕ (инверсия)
  • << — сдвиг влево
  • >> — сдвиг вправо

Пример:

unsigned char a = 0b1100;
unsigned char b = 0b1010;
unsigned char c = a & b; // 0b1000

Вопрос

Чем отличается логическое И (&&) от побитового И (&)?

Ответ

Логическое И (&&) возвращает 1, если оба операнда истинны (не равны нулю), и 0 в противном случае. Оно использует короткое замыкание: если левый операнд ложен, правый не вычисляется.
Побитовое И (&) выполняет операцию И над каждым битом операндов и всегда вычисляет оба операнда.

Пример:

int x = 0, y = 5;
int a = x && y; // 0 (y не вычисляется)
int b = x & y; // 0 (y вычисляется, но результат 0)

Вопрос

Что такое тернарный условный оператор?

Ответ

Тернарный оператор имеет форму условие ? выражение1 : выражение2. Если условие истинно, возвращается выражение1, иначе — выражение2.

Пример:

int max = (a > b) ? a : b;

Оба выражения должны быть совместимы по типу или приводиться к общему типу.


Вопрос

Можно ли использовать присваивание внутри выражения?

Ответ

Да, присваивание в C является выражением и возвращает присвоенное значение. Это позволяет писать цепочки присваиваний или использовать присваивание в условиях.

Пример:

int a, b, c;
a = b = c = 10; // Все получат значение 10

while ((ch = getchar()) != EOF) {
// Чтение до конца файла
}

Вопрос

Что такое оператор запятая (,)?

Ответ

Оператор запятая вычисляет левый операнд, отбрасывает его результат, затем вычисляет и возвращает правый операнд. Часто используется в циклах for для нескольких инициализаций или обновлений.

Пример:

for (int i = 0, j = 10; i < j; i++, j--) {
// ...
}

Важно не путать с запятой в списках аргументов функции или объявлений — там это не оператор.


Управляющие конструкции

Вопрос

Как работает условный оператор if?

Ответ

Оператор if выполняет блок кода, если условие истинно (не равно нулю). Может включать необязательную ветку else.

Пример:

if (x > 0) {
printf("Positive\n");
} else {
printf("Non-positive\n");
}

Вопрос

Что такое «провал» (fall-through) в операторе switch?

Ответ

В switch выполнение продолжается от найденной метки case до конца блока, если явно не прервать его оператором break. Это поведение называется провалом.

Пример:

switch (code) {
case 1:
printf("One\n");
// fall-through
case 2:
printf("Two\n");
break;
}

Если code == 1, напечатается "One" и "Two".


Вопрос

Можно ли использовать переменные в метках case?

Ответ

Нет. Метки case в операторе switch должны быть целочисленными константными выражениями, вычислимые на этапе компиляции. Переменные недопустимы.

Пример допустимого:

#define RED 1
switch (color) {
case RED: ...
}

Пример недопустимого:

int val = 5;
switch (x) {
case val: ... // Ошибка компиляции
}

Вопрос

Как работают циклы for, while и do-while?

Ответ

  • for (инициализация; условие; итерация) — удобен, когда известно количество итераций.
  • while (условие) — проверяет условие перед каждой итерацией; тело может не выполниться ни разу.
  • do { ... } while (условие); — проверяет условие после выполнения тела; тело выполняется хотя бы один раз.

Вопрос

Что делают операторы break и continue?

Ответ

  • break немедленно завершает выполнение ближайшего цикла (for, while, do-while) или оператора switch.
  • continue завершает текущую итерацию цикла и переходит к проверке условия (или к шагу итерации в for).

Функции

Вопрос

Что такое функция в языке C?

Ответ

Функция — это именованный блок кода, выполняющий определённую задачу. Она может принимать входные параметры и возвращать результат. Каждая программа на C содержит как минимум одну функцию — main.

Пример:

int add(int a, int b) {
return a + b;
}

Вопрос

Как объявить и определить функцию?

Ответ

Объявление (прототип) сообщает компилятору имя функции, тип возвращаемого значения и типы параметров:

int multiply(int x, int y);

Определение содержит тело функции:

int multiply(int x, int y) {
return x * y;
}

Объявление может находиться в заголовочном файле, определение — в .c-файле.


Вопрос

Можно ли вызывать функцию до её определения?

Ответ

Да, если перед вызовом присутствует прототип (объявление). Без прототипа компилятор предполагает, что функция возвращает int и принимает неопределённое число аргументов, что может привести к ошибкам.

Пример корректного использования:

int square(int x); // прототип

int main(void) {
int y = square(5); // вызов до определения — допустимо
return 0;
}

int square(int x) {
return x * x;
}

Вопрос

Что означает void в списке параметров функции?

Ответ

void в скобках означает, что функция не принимает никаких аргументов. Это явное указание отсутствия параметров.

Пример:

void print_hello(void) {
printf("Hello\n");
}

Без void (print_hello()), в старом стиле C это означало «неизвестно сколько аргументов», но в современном C рекомендуется всегда писать void для функций без параметров.


Вопрос

Как передаются аргументы в функцию в C?

Ответ

Аргументы передаются по значению. Это означает, что в функцию копируется значение фактического параметра. Изменения внутри функции не влияют на исходную переменную в вызывающем коде.

Пример:

void increment(int x) {
x++; // изменяет локальную копию
}

Чтобы изменить оригинальную переменную, передают указатель.


Вопрос

Как вернуть несколько значений из функции?

Ответ

Язык C не поддерживает прямой возврат нескольких значений. Возможные подходы:

  • Возврат структуры
  • Передача указателей на переменные, которые функция должна изменить
  • Использование глобальных переменных (не рекомендуется)

Пример с указателями:

void min_max(int a, int b, int *min, int *max) {
*min = (a < b) ? a : b;
*max = (a > b) ? a : b;
}

Вопрос

Что такое рекурсия в C?

Ответ

Рекурсия — это вызов функцией самой себя. Рекурсивная функция должна иметь условие завершения (базовый случай), чтобы избежать бесконечной рекурсии и переполнения стека.

Пример вычисления факториала:

unsigned long long factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}

Вопрос

Где хранятся локальные переменные функции?

Ответ

Локальные переменные автоматического класса хранения размещаются в стеке вызовов. При каждом вызове функции для её локальных переменных выделяется новая область памяти в стеке, которая освобождается при выходе из функции.


Вопрос

Что такое inline-функции?

Ответ

Ключевое слово inline является подсказкой компилятору заменить вызов функции её телом (встраивание), чтобы уменьшить накладные расходы на вызов. Компилятор может проигнорировать эту подсказку.

Пример:

inline int max(int a, int b) {
return (a > b) ? a : b;
}

Обычно используется для коротких функций.


Вопрос

Можно ли определить функцию внутри другой функции?

Ответ

Нет. Стандарт C не разрешает вложенное определение функций. Все функции должны быть определены на глобальном уровне. Некоторые компиляторы (например, GCC) поддерживают это как расширение, но такой код не является переносимым.


Массивы и строки

Вопрос

Что такое массив в C?

Ответ

Массив — это последовательность элементов одного типа, расположенных в памяти непрерывно. Доступ к элементам осуществляется по индексу, начиная с нуля.

Пример:

int numbers[5] = {1, 2, 3, 4, 5};
int first = numbers[0]; // 1

Вопрос

Можно ли изменить размер массива во время выполнения?

Ответ

Нет. Размер обычного массива фиксирован на этапе компиляции (или на этапе входа в блок для массивов переменной длины). Для динамического изменения размера используют динамически выделенную память через malloc, realloc.


Вопрос

Что такое массив переменной длины (VLA)?

Ответ

Массив переменной длины (Variable Length Array, VLA) — это массив, размер которого определяется во время выполнения. Поддерживается начиная со стандарта C99, но в C11 стал необязательным.

Пример:

void process(int n) {
int arr[n]; // VLA
// ...
}

VLA размещаются в стеке, поэтому их размер ограничен.


Вопрос

Как передать массив в функцию?

Ответ

При передаче массива в функцию он автоматически преобразуется в указатель на первый элемент. Поэтому функция не знает реальный размер массива, и его нужно передавать отдельно.

Пример:

void print_array(int *arr, size_t size) {
for (size_t i = 0; i < size; i++) {
printf("%d ", arr[i]);
}
}

Вопрос

Что такое строка в C?

Ответ

Строка в C — это массив символов (char), завершающийся нулевым байтом \0 (null terminator). Этот символ обозначает конец строки и обязателен для корректной работы стандартных строковых функций.

Пример:

char greeting[] = "Hello"; // компилятор добавит '\0' автоматически

Вопрос

В чём разница между char s[] = "text" и char *s = "text"?

Ответ

  • char s[] = "text" создаёт массив в стеке и копирует в него строку. Содержимое можно изменять.
  • char *s = "text" создаёт указатель на строковый литерал, который обычно размещён в защищённой (read-only) памяти. Попытка изменения приведёт к неопределённому поведению.

Пример безопасного изменения:

char s1[] = "hello";
s1[0] = 'H'; // допустимо

char *s2 = "hello";
s2[0] = 'H'; // недопустимо — ошибка времени выполнения

Вопрос

Какие стандартные функции работают со строками?

Ответ

Основные функции объявлены в <string.h>:

  • strlen — длина строки
  • strcpy / strncpy — копирование
  • strcat / strncat — конкатенация
  • strcmp / strncmp — сравнение
  • strchr, strstr — поиск символа или подстроки

Важно использовать безопасные версии (strncpy, strncat) с ограничением длины, чтобы избежать переполнения буфера.


Вопрос

Почему gets считается опасной функцией?

Ответ

Функция gets читает строку из стандартного ввода без ограничения длины. Она не знает размер буфера и может записать больше данных, чем выделено, что приводит к переполнению буфера и потенциальным уязвимостям. Эта функция удалена из стандарта C11. Вместо неё следует использовать fgets.

Пример безопасного ввода:

char buffer[100];
fgets(buffer, sizeof(buffer), stdin);

Вопрос

Как определить длину строки?

Ответ

Длина строки — это количество символов до первого нулевого байта. Определяется функцией strlen из <string.h>.

Пример:

#include <string.h>
size_t len = strlen("Hello"); // 5

Функция не включает завершающий \0 в результат.


Вопрос

Что произойдёт, если строка не завершена нулём?

Ответ

Функции стандартной библиотеки будут продолжать чтение памяти за пределами массива, пока не встретят байт \0. Это приведёт к неопределённому поведению: сбою, утечке данных или некорректной работе программы.


Указатели

Вопрос

Что такое указатель в языке C?

Ответ

Указатель — это переменная, которая хранит адрес другой переменной в памяти. Указатель имеет тип, соответствующий типу данных, на которые он ссылается. Это позволяет компилятору правильно интерпретировать данные при разыменовывании.

Пример:

int x = 42;
int *p = &x; // p хранит адрес переменной x

Вопрос

Как получить адрес переменной и как получить значение по адресу?

Ответ

Оператор & возвращает адрес переменной. Оператор * (разыменовывание) получает значение по адресу, хранящемуся в указателе.

Пример:

int a = 10;
int *ptr = &a; // ptr содержит адрес a
int b = *ptr; // b получает значение 10

Вопрос

Можно ли выполнять арифметические операции с указателями?

Ответ

Да. К указателям можно прибавлять и вычитать целые числа. При этом смещение учитывает размер типа, на который указывает указатель. Например, прибавление 1 к указателю на int сдвигает адрес на sizeof(int) байт.

Пример:

int arr[3] = {10, 20, 30};
int *p = arr; // указывает на arr[0]
p++; // теперь указывает на arr[1]
printf("%d\n", *p); // напечатает 20

Вопрос

Что такое указатель на void (void*)?

Ответ

void* — это универсальный указатель, способный хранить адрес любого типа данных. Он не может быть разыменован напрямую и не поддерживает арифметику указателей без приведения к конкретному типу. Часто используется в функциях общего назначения, таких как malloc.

Пример:

void* ptr = malloc(100); // выделено 100 байт
int* iptr = (int*)ptr; // приведение к int*
free(ptr);

Вопрос

Что происходит при присваивании одного указателя другому?

Ответ

При присваивании копируется адрес, а не данные. Оба указателя начинают ссылаться на одну и ту же область памяти. Изменение данных через один указатель будет видно через другой.

Пример:

int x = 5;
int *p1 = &x;
int *p2 = p1; // p2 теперь тоже указывает на x
*p2 = 10; // x становится равным 10

Вопрос

Что такое нулевой указатель?

Ответ

Нулевой указатель — это указатель, который не ссылается ни на какой объект. Он представлен константой NULL (обычно определённой как (void*)0). Разыменовывать нулевой указатель нельзя — это вызывает неопределённое поведение.

Пример:

int *p = NULL;
if (p == NULL) {
printf("Указатель не инициализирован\n");
}

Вопрос

Как проверить, что указатель корректен перед использованием?

Ответ

Перед разыменовыванием или освобождением памяти следует проверить, не равен ли указатель NULL. Особенно важно это после вызова функций вроде malloc, которые могут вернуть NULL при неудаче.

Пример:

int *p = malloc(sizeof(int) * 100);
if (p != NULL) {
// безопасно использовать p
free(p);
p = NULL; // хорошая практика — обнулить после free
}

Вопрос

Что такое двойной указатель (int**)?

Ответ

Двойной указатель — это указатель на указатель. Он хранит адрес переменной, которая сама является указателем. Используется, например, для изменения значения указателя внутри функции.

Пример:

void allocate_int(int **ptr) {
*ptr = malloc(sizeof(int));
**ptr = 42;
}

int main(void) {
int *p = NULL;
allocate_int(&p); // передаём адрес указателя
printf("%d\n", *p);
free(p);
return 0;
}

Вопрос

Как связаны массивы и указатели?

Ответ

Имя массива в большинстве контекстов преобразуется в указатель на его первый элемент. Однако между ними есть различия:

  • sizeof(array) даёт общий размер массива, а sizeof(pointer) — размер адреса
  • Массив нельзя переназначить (arr = another; — ошибка), а указатель можно
  • Массив всегда ссылается на фиксированный блок памяти

Пример:

int arr[5];
int *p = arr; // допустимо
// arr = p; // ошибка

Вопрос

Что такое указатель на функцию?

Ответ

Указатель на функцию хранит адрес исполняемого кода функции. Его можно использовать для вызова функции косвенно, передачи функции как аргумента или хранения в структурах.

Пример:

int add(int a, int b) { return a + b; }

int main(void) {
int (*func_ptr)(int, int) = add;
int result = func_ptr(3, 4); // вызов через указатель
return 0;
}

Вопрос

Как объявить указатель на функцию?

Ответ

Синтаксис: возвращаемый_тип (*имя_указателя)(список_параметров);

Пример:

// Указатель на функцию, принимающую два double и возвращающую double
double (*operation)(double, double);

Тип должен точно соответствовать сигнатуре целевой функции.


Вопрос

Можно ли сравнивать указатели?

Ответ

Да, но с ограничениями. Указатели на один и тот же массив (или на один после последнего элемента) можно сравнивать операторами <, <=, >, >=. Указатели на разные объекты можно сравнивать только на равенство (==, !=).

Пример допустимого:

int arr[10];
int *p = &arr[2];
int *q = &arr[5];
if (p < q) { /* всегда true */ }

Вопрос

Что такое «подвешенный указатель» (dangling pointer)?

Ответ

Подвешенный указатель — это указатель, который ссылается на память, уже освобождённую или вышедшую из области видимости. Использование такого указателя приводит к неопределённому поведению.

Пример:

int *ptr;
{
int x = 10;
ptr = &x;
} // x уничтожен
// *ptr — неопределённое поведение

Вопрос

Что такое «указатель-мусор» (wild pointer)?

Ответ

Указатель-мусор — это неинициализированный указатель, содержащий случайное значение. Разыменовывание такого указателя почти наверняка приведёт к сбою или повреждению памяти.

Пример опасного кода:

int *p; // мусорное значение
*p = 42; // катастрофа

Решение — всегда инициализировать указатели, например, значением NULL.


Вопрос

Как передать указатель в функцию, чтобы изменить оригинальный указатель?

Ответ

Нужно передать адрес указателя, то есть использовать двойной указатель. Тогда функция сможет изменить значение самого указателя, а не только данные, на которые он ссылается.

Пример:

void reset_pointer(int **pp) {
free(*pp);
*pp = NULL;
}

Структуры, объединения и перечисления

Вопрос

Что такое структура (struct) в C?

Ответ

Структура — это составной тип данных, объединяющий несколько переменных (полей) разных типов под одним именем. Поля размещаются в памяти последовательно (с возможными выравниваниями).

Пример:

struct Point {
int x;
int y;
};

Вопрос

Как объявить и использовать переменную структуры?

Ответ

После определения структуры можно объявлять переменные этого типа. Доступ к полям осуществляется через оператор . (точка).

Пример:

struct Point p;
p.x = 10;
p.y = 20;

Можно инициализировать при объявлении:

struct Point p = {10, 20};

Вопрос

Можно ли присваивать одну структуру другой?

Ответ

Да. Структуры одного типа можно присваивать целиком. При этом выполняется побайтовое копирование всех полей.

Пример:

struct Point a = {1, 2};
struct Point b = a; // b.x = 1, b.y = 2

Вопрос

Что такое typedef и как он используется со структурами?

Ответ

typedef создаёт псевдоним для типа. Это позволяет избежать повторного написания ключевого слова struct.

Пример:

typedef struct {
int width;
int height;
} Rectangle;

Rectangle r = {100, 50}; // не нужно писать "struct Rectangle"

Вопрос

Можно ли вложить структуру в другую структуру?

Ответ

Да. Структура может содержать поля других структур. Это называется композицией.

Пример:

struct Address {
char city[50];
};

struct Person {
char name[50];
struct Address addr;
};

Доступ: person.addr.city.


Вопрос

Можно ли создать структуру, содержащую указатель на саму себя?

Ответ

Да. Такие структуры используются для построения связных списков, деревьев и других динамических структур данных. Нужно использовать неполное объявление или указатель.

Пример:

struct Node {
int data;
struct Node *next;
};

Вопрос

Что такое объединение (union)?

Ответ

Объединение — это тип данных, все поля которого разделяют одну и ту же область памяти. Размер объединения равен размеру его самого большого поля. В каждый момент времени объединение может хранить значение только одного поля.

Пример:

union Data {
int i;
float f;
char str[20];
};

Вопрос

В чём разница между struct и union?

Ответ

  • В struct каждое поле имеет собственное место в памяти, и все поля могут хранить значения одновременно.
  • В union все поля используют одну и ту же память, и только одно поле может быть актуальным в конкретный момент.

Вопрос

Для чего используют объединения?

Ответ

Объединения применяются для:

  • Экономии памяти, когда известно, что одновременно используется только одно из значений
  • Интерпретации одного и того же участка памяти как разных типов (например, при работе с бинарными протоколами или аппаратными регистрами)

Пример:

union FloatInt {
float f;
unsigned int i;
};
// Позволяет получить битовое представление float

Вопрос

Что такое перечисление (enum)?

Ответ

Перечисление — это пользовательский тип, состоящий из именованных целочисленных констант. По умолчанию первая константа равна 0, следующая — 1 и так далее.

Пример:

enum Color { RED, GREEN, BLUE };
enum Color c = GREEN; // c == 1

Вопрос

Можно ли задать явные значения для элементов enum?

Ответ

Да. Каждому элементу можно присвоить конкретное целочисленное значение.

Пример:

enum StatusCode {
OK = 200,
NOT_FOUND = 404,
ERROR = 500
};

Вопрос

Каков базовый тип перечисления?

Ответ

Базовый тип перечисления — это реализация-определяемый целочисленный тип, достаточный для представления всех значений. Обычно это int, но стандарт не гарантирует это. Перечисления совместимы с целыми типами и могут неявно преобразовываться к ним и от них.


Вопрос

Можно ли использовать перечисления для повышения безопасности кода?

Ответ

Перечисления делают код более читаемым и самодокументируемым, но не обеспечивают строгой типовой безопасности: компилятор разрешает присваивать любое целое значение переменной перечислимого типа.

Пример:

enum State s = 999; // допустимо, хотя 999 не объявлено

Вопрос

Что такое анонимная структура или объединение?

Ответ

Анонимная структура или объединение не имеет имени и объявляется внутри другой структуры. Её поля становятся доступны напрямую, без дополнительного уровня вложенности.

Пример (поддерживается начиная с C11):

struct Packet {
int header;
union {
int as_int;
float as_float;
}; // анонимное объединение
};

struct Packet p;
p.as_int = 42; // прямой доступ

Вопрос

Как выровнять поля структуры в памяти?

Ответ

Выравнивание контролируется компилятором для повышения производительности. Для изменения выравнивания используются компилятор-специфичные директивы, такие как #pragma pack или атрибуты (__attribute__((packed)) в GCC). Упакованные структуры занимают меньше места, но могут работать медленнее на некоторых архитектурах.

Пример:

#pragma pack(push, 1)
struct PackedData {
char a;
int b;
};
#pragma pack(pop)

Динамическое управление памятью

Вопрос

Какие функции используются для динамического выделения памяти в C?

Ответ

Основные функции объявлены в <stdlib.h>:

  • malloc(size_t size) — выделяет блок памяти указанного размера в байтах и возвращает void*. Содержимое не инициализируется.
  • calloc(size_t num, size_t size) — выделяет память для num элементов по size байт каждый и заполняет её нулями.
  • realloc(void *ptr, size_t new_size) — изменяет размер ранее выделенного блока. Может переместить данные в новую область.
  • free(void *ptr) — освобождает выделенную память.

Вопрос

Что возвращает malloc, если память выделить не удалось?

Ответ

Если системе не удаётся выделить запрошенный блок памяти, malloc возвращает NULL. Программа должна проверять результат перед использованием.

Пример:

int *arr = malloc(100 * sizeof(int));
if (arr == NULL) {
fprintf(stderr, "Не хватает памяти\n");
exit(EXIT_FAILURE);
}

Вопрос

В чём разница между malloc и calloc?

Ответ

  • malloc выделяет неинициализированную память.
  • calloc выделяет память и обнуляет её.
  • Синтаксис: malloc(размер_в_байтах) vs calloc(количество, размер_элемента).

Пример эквивалентности:

// Эти вызовы выделяют одинаковый объём памяти
int *a = malloc(10 * sizeof(int));
int *b = calloc(10, sizeof(int)); // плюс обнуление

Вопрос

Можно ли передать NULL в realloc?

Ответ

Да. Если первый аргумент realloc равен NULL, функция ведёт себя как malloc(new_size). Это позволяет использовать realloc для первого выделения и последующего изменения размера одним и тем же кодом.

Пример:

void *ptr = NULL;
ptr = realloc(ptr, 100); // эквивалентно malloc(100)

Вопрос

Что произойдёт, если дважды вызвать free для одного и того же указателя?

Ответ

Повторное освобождение одного и того же блока памяти приводит к неопределённому поведению. Это может вызвать сбой, повреждение кучи или уязвимость. Хорошая практика — присваивать NULL указателю сразу после free.

Пример безопасного подхода:

free(ptr);
ptr = NULL; // предотвращает повторный free

Вопрос

Можно ли освободить часть памяти, выделенной через malloc?

Ответ

Нет. Освобождать можно только весь блок, возвращённый malloc, calloc или realloc. Нельзя передать в free адрес внутри блока (например, ptr + 5). Это нарушает целостность менеджера памяти.


Вопрос

Что такое утечка памяти?

Ответ

Утечка памяти возникает, когда программа выделяет память динамически, но теряет все указатели на неё, не освободив её явно. Такая память остаётся занятой до завершения программы и недоступна для повторного использования.

Пример утечки:

void leak_example(void) {
int *p = malloc(sizeof(int));
p = NULL; // адрес потерян, free не вызван
}

Вопрос

Нужно ли освобождать память, выделенную в main, перед завершением программы?

Ответ

С технической точки зрения — нет. Современные операционные системы автоматически освобождают всю память процесса при его завершении. Однако освобождение памяти является хорошей практикой: это упрощает отладку утечек с помощью инструментов (например, Valgrind) и делает код более надёжным при рефакторинге.


Вопрос

Как правильно освободить массив структур?

Ответ

Массив структур выделяется и освобождается как единый блок. Нет необходимости освобождать каждую структуру отдельно.

Пример:

struct Point *points = malloc(10 * sizeof(struct Point));
// ... использование ...
free(points); // один вызов освобождает весь массив
points = NULL;

Вопрос

Можно ли использовать realloc для уменьшения размера блока?

Ответ

Да. realloc может как увеличивать, так и уменьшать размер блока. При уменьшении содержимое сохраняется (до нового размера), избыточная память возвращается системе или становится доступной для других выделений.


Вопрос

Что происходит с данными при увеличении блока через realloc?

Ответ

Если новый размер больше старого, исходные данные сохраняются без изменений. Дополнительная память не инициализируется (может содержать мусор). Если realloc не может расширить блок на месте, он выделяет новый блок, копирует старые данные и освобождает старый.


Вопрос

Как избежать потери указателя при неудачном realloc?

Ответ

Следует использовать временный указатель:

void *temp = realloc(ptr, new_size);
if (temp == NULL) {
// обработка ошибки, ptr остаётся валидным
} else {
ptr = temp;
}

Если присвоить результат напрямую (ptr = realloc(ptr, ...)), при ошибке будет потеряно значение оригинального указателя.


Вопрос

Поддерживает ли стандартная библиотека C сборку мусора?

Ответ

Нет. Язык C не включает автоматическую сборку мусора. Программист полностью отвечает за выделение и освобождение памяти. Существуют сторонние библиотеки (например, Boehm GC), но они не являются частью стандарта.


Вопрос

Можно ли выделять память для функций?

Ответ

Нет. Память под код функций выделяется компилятором и загрузчиком. Динамическое выделение через malloc предназначено только для данных. Для выполнения динамически сгенерированного кода требуются специальные механизмы (например, mmap с флагами исполнения в POSIX), но это выходит за рамки стандартного C.


Вопрос

Что такое «куча» (heap) в контексте C?

Ответ

Куча — это область памяти, используемая для динамического выделения через malloc и связанные функции. В отличие от стека, время жизни объектов в куче не связано с вызовами функций и контролируется программистом вручную.


Препроцессор и компиляция

Вопрос

Что такое препроцессор в C?

Ответ

Препроцессор — это инструмент, выполняющий обработку исходного кода до его передачи компилятору. Он обрабатывает директивы, начинающиеся с символа #, такие как #include, #define, #ifdef и другие. Результатом работы препроцессора является модифицированный текст программы без макросов и условных блоков.


Вопрос

Как работает директива #define?

Ответ

Директива #define создаёт макроопределение. При последующей обработке все вхождения имени макроса заменяются на его значение (текстовая подстановка). Макросы могут быть объектоподобными (без параметров) или функциеподобными (с параметрами).

Пример:

#define PI 3.14159
#define SQUARE(x) ((x) * (x))

Вопрос

Почему в макросах с параметрами используются скобки?

Ответ

Скобки предотвращают ошибки приоритета операторов. Без них выражение может быть вычислено неверно из-за неожиданного порядка операций.

Пример ошибки:

#define BAD_SQUARE(x) x * x
int a = BAD_SQUARE(1 + 2); // раскрывается как 1 + 2 * 1 + 2 → 5, а не 9

Правильно:

#define SQUARE(x) ((x) * (x)) // даёт (1 + 2) * (1 + 2) → 9

Вопрос

Что делает директива #undef?

Ответ

Директива #undef отменяет ранее определённый макрос. После этого имя больше не считается макросом и может быть использовано как обычный идентификатор.

Пример:

#define BUFFER_SIZE 1024
// ... использование ...
#undef BUFFER_SIZE
// Теперь BUFFER_SIZE — просто имя, не макрос

Вопрос

Как работают условные директивы препроцессора?

Ответ

Условные директивы (#if, #ifdef, #ifndef, #else, #elif, #endif) позволяют включать или исключать фрагменты кода на этапе препроцессирования в зависимости от того, определены ли макросы или какие значения они имеют.

Пример:

#ifdef DEBUG
printf("Debug mode enabled\n");
#endif

Вопрос

В чём разница между #ifdef и #if defined?

Ответ

Обе формы проверяют, определён ли макрос. #ifdef MACRO — краткая форма. #if defined(MACRO) позволяет использовать логические операции и комбинировать несколько условий.

Пример:

#if defined(DEBUG) && !defined(NDEBUG)
// только если DEBUG определён, а NDEBUG — нет
#endif

Вопрос

Что такое охраняющие макросы (#ifndef HEADER_H)?

Ответ

Охраняющие макросы (include guards) предотвращают многократное включение одного и того же заголовочного файла в одну единицу трансляции. Это защищает от повторных объявлений и ошибок компиляции.

Пример:

#ifndef MYLIB_H
#define MYLIB_H

// содержимое заголовка

#endif

Альтернатива — #pragma once, но она не стандартизирована, хотя и широко поддерживается.


Вопрос

Что делает #pragma?

Ответ

Директива #pragma предоставляет компилятору специфичные инструкции, не определённые стандартом. Поведение зависит от реализации. Распространённые примеры: #pragma once, #pragma pack, #pragma GCC optimize.

Пример:

#pragma pack(1) // упаковка структур без выравнивания

Вопрос

Что такое строковые операторы # и ## в макросах?

Ответ

  • Оператор # превращает аргумент макроса в строковый литерал (stringification).
  • Оператор ## объединяет два токена в один (token pasting).

Пример:

#define STR(x) #x
#define CONCAT(a, b) a##b

printf("%s\n", STR(hello)); // "hello"
int value = CONCAT(var, 123); // var123

Вопрос

Можно ли переопределить стандартные макросы, такие как __FILE__ или __LINE__?

Ответ

Нет. Имена, начинающиеся с двух подчёркиваний или с подчёркивания и заглавной буквы, зарезервированы для реализации. Попытка определить или переопределить такие макросы приводит к неопределённому поведению.


Вопрос

Какие предопределённые макросы есть в C?

Ответ

Стандарт определяет несколько обязательных макросов:

  • __FILE__ — имя текущего файла (строка)
  • __LINE__ — номер текущей строки (целое)
  • __func__ — имя текущей функции (строка, начиная с C99)
  • __DATE__, __TIME__ — дата и время компиляции
  • __STDC__ — определён как 1, если компилятор соответствует стандарту C

Вопрос

Что такое единица трансляции?

Ответ

Единица трансляции — это результат обработки одного .c-файла препроцессором, включая все включённые заголовочные файлы. Каждая единица трансляции компилируется независимо в объектный файл.


Вопрос

Как происходит сборка программы на C?

Ответ

Сборка включает четыре этапа:

  1. Препроцессирование — обработка директив #
  2. Компиляция — перевод в ассемблерный код или промежуточное представление
  3. Ассемблирование — преобразование в объектный файл (машинный код с символами)
  4. Компоновка (линковка) — объединение объектных файлов и библиотек в исполняемый файл

Вопрос

Что такое внешнее и внутреннее связывание?

Ответ

  • Внешнее связывание: идентификатор (функция или глобальная переменная) доступен из других единиц трансляции. По умолчанию глобальные объекты имеют внешнее связывание.
  • Внутреннее связывание: идентификатор виден только внутри текущей единицы трансляции. Достигается с помощью ключевого слова static.

Вопрос

Зачем нужен заголовочный файл?

Ответ

Заголовочный файл (.h) содержит объявления функций, типов, макросов и переменных, которые должны быть доступны другим файлам. Он позволяет компилятору проверять корректность вызовов без необходимости видеть определения, обеспечивая модульность и повторное использование кода.


Стандартная библиотека C

Вопрос

Из каких основных заголовочных файлов состоит стандартная библиотека C?

Ответ

Стандартная библиотека C включает следующие ключевые заголовки:

  • <stdio.h> — ввод и вывод (printf, scanf, fopen и др.)
  • <stdlib.h> — общие утилиты (malloc, free, atoi, exit)
  • <string.h> — операции со строками и памятью (strcpy, strlen, memcpy)
  • <ctype.h> — классификация и преобразование символов (isdigit, toupper)
  • <math.h> — математические функции (sin, sqrt, pow)
  • <time.h> — работа с датой и временем (time, strftime)
  • <assert.h> — отладочные проверки (assert)
  • <errno.h> — коды ошибок
  • <limits.h>, <float.h> — пределы числовых типов

Вопрос

Что делает функция printf и как она работает?

Ответ

Функция printf выводит форматированную строку в стандартный поток вывода (stdout). Она принимает строку формата и список аргументов, которые подставляются в соответствии со спецификаторами в строке.

Пример:

int age = 25;
printf("Возраст: %d лет\n", age);

Спецификаторы: %d — int, %f — float/double, %s — строка, %p — указатель.


Вопрос

Чем отличается fprintf от printf?

Ответ

fprintf позволяет указать поток вывода явно, в то время как printf всегда пишет в stdout.

Пример:

FILE *file = fopen("log.txt", "w");
fprintf(file, "Ошибка: %s\n", message);
fclose(file);

Вопрос

Как открыть и закрыть файл в C?

Ответ

Файл открывается функцией fopen, которая возвращает указатель типа FILE*. Закрывается функцией fclose.

Пример:

FILE *fp = fopen("data.txt", "r");
if (fp == NULL) {
perror("Не удалось открыть файл");
return -1;
}
// ... работа с файлом ...
fclose(fp);

Режимы: "r" — чтение, "w" — запись (очистка), "a" — добавление, "rb" — двоичное чтение и т.д.


Вопрос

Как читать данные из файла построчно?

Ответ

Для построчного чтения используется функция fgets, которая читает строку до символа новой строки или конца файла.

Пример:

char buffer[256];
while (fgets(buffer, sizeof(buffer), fp) != NULL) {
printf("%s", buffer);
}

Важно указывать размер буфера, чтобы избежать переполнения.


Вопрос

Что делает функция sscanf?

Ответ

sscanf считывает данные из строки в переменные согласно формату, аналогично scanf, но источником является не ввод с клавиатуры, а строка в памяти.

Пример:

char input[] = "42 3.14";
int a;
double b;
sscanf(input, "%d %lf", &a, &b);

Вопрос

Как преобразовать строку в число?

Ответ

Для преобразования используются функции из <stdlib.h>:

  • atoi, atol, atof — простые, но без обработки ошибок
  • strtol, strtoll, strtoul, strtod — расширенные, с возможностью обнаружения ошибок и указанием конца разбора

Пример безопасного преобразования:

char *end;
long val = strtol(str, &end, 10);
if (end == str || *end != '\0') {
// ошибка преобразования
}

Вопрос

Что такое поток (FILE*) в C?

Ответ

Поток — это абстракция для источника или приёмника данных (файла, устройства, консоли). Все операции ввода-вывода в стандартной библиотеке работают через указатели типа FILE*. Стандартные потоки: stdin, stdout, stderr.


Вопрос

Как проверить ошибку при работе с файлами?

Ответ

Можно использовать:

  • Возврат функций (fopen возвращает NULL при ошибке)
  • Функцию ferror(FILE*), которая возвращает ненулевое значение при ошибке потока
  • Функцию feof(FILE*) для проверки конца файла
  • Глобальную переменную errno (из <errno.h>) для получения кода системной ошибки

Пример:

if (ferror(fp)) {
perror("Ошибка ввода-вывода");
}

Вопрос

Что делает функция memcpy?

Ответ

memcpy копирует указанное количество байт из одного участка памяти в другой. Она не проверяет содержимое и не останавливается на нулевом байте, в отличие от строковых функций.

Пример:

int src[3] = {1, 2, 3};
int dst[3];
memcpy(dst, src, sizeof(src));

Важно: области памяти не должны перекрываться. Для перекрывающихся областей используется memmove.


Вопрос

В чём разница между memcpy и memmove?

Ответ

memcpy не гарантирует корректную работу при перекрывающихся областях памяти. memmove специально разработан для безопасного копирования даже при перекрытии — он использует промежуточное буферирование при необходимости.


Вопрос

Как сравнить два блока памяти?

Ответ

Функция memcmp сравнивает два блока памяти побайтово. Возвращает отрицательное, нулевое или положительное значение в зависимости от результата сравнения.

Пример:

if (memcmp(buf1, buf2, size) == 0) {
// блоки идентичны
}

Вопрос

Что делает функция qsort?

Ответ

qsort сортирует массив элементов произвольного типа с помощью пользовательской функции сравнения. Она реализует алгоритм быстрой сортировки или его вариант.

Пример:

int compare(const void *a, const void *b) {
int ia = *(const int*)a;
int ib = *(const int*)b;
return (ia > ib) - (ia < ib); // безопасное сравнение
}

int arr[] = {3, 1, 4, 1, 5};
qsort(arr, 5, sizeof(int), compare);

Вопрос

Как работает функция bsearch?

Ответ

bsearch выполняет двоичный поиск элемента в отсортированном массиве. Требует ту же функцию сравнения, что и qsort.

Пример:

int key = 4;
int *found = bsearch(&key, arr, 5, sizeof(int), compare);
if (found) {
printf("Найдено: %d\n", *found);
}

Вопрос

Что такое errno и как его использовать?

Ответ

errno — это целочисленная переменная, в которую системные вызовы и некоторые функции стандартной библиотеки записывают код ошибки при неудаче. Её значение имеет смысл только если функция сообщила об ошибке.

Пример:

FILE *fp = fopen("nonexistent.txt", "r");
if (fp == NULL) {
fprintf(stderr, "Ошибка %d: %s\n", errno, strerror(errno));
}

Неопределённое, неуточнённое и реализация-определяемое поведение

Вопрос

Что такое неопределённое поведение (undefined behavior, UB) в C?

Ответ

Неопределённое поведение — это ситуация, при которой стандарт языка не накладывает никаких требований на результат выполнения программы. Программа может завершиться аварийно, работать некорректно, выдать странный результат или даже «работать правильно» — всё это допустимо. Компилятор имеет право генерировать любой код или вообще ничего не генерировать.

Примеры:

  • Разыменование нулевого указателя
  • Переполнение знакового целого типа
  • Доступ за пределы массива
  • Использование неинициализированной переменной

Вопрос

Почему переполнение signed int приводит к неопределённому поведению, а unsigned int — нет?

Ответ

Стандарт C гарантирует, что арифметика с беззнаковыми типами выполняется по модулю (2^n), где (n) — количество битов. Это обеспечивает предсказуемое «заворачивание» значений. Для знаковых типов такой гарантии нет: переполнение считается ошибкой логики программы и приводит к неопределённому поведению, что позволяет компиляторам применять более агрессивные оптимизации.


Вопрос

Что такое неуточнённое поведение (unspecified behavior)?

Ответ

Неуточнённое поведение — это корректная ситуация, в которой стандарт разрешает несколько возможных результатов, но не требует от реализации выбора конкретного варианта или его документирования. Программа остаётся корректной, но поведение может отличаться между компиляторами или запусками.

Пример: порядок вычисления аргументов функции:

int f() { printf("f\n"); return 1; }
int g() { printf("g\n"); return 2; }
int x = add(f(), g()); // неизвестно, какая функция вызовется первой

Вопрос

Что такое реализация-определяемое поведение (implementation-defined behavior)?

Ответ

Реализация-определяемое поведение — это поведение, которое должно быть однозначно определено и задокументировано реализацией (компилятором и средой). Программист может полагаться на это поведение, если знает характеристики своей платформы.

Примеры:

  • Размер типа int
  • Представление отрицательных чисел (дополнение до двух и др.)
  • Выравнивание структур
  • Значение CHAR_BIT

Вопрос

Как избежать неопределённого поведения?

Ответ

Основные меры:

  • Инициализировать все переменные
  • Проверять границы массивов
  • Не разыменовывать нулевые или подвешенные указатели
  • Избегать переполнения знаковых целых
  • Не изменять строковые литералы
  • Использовать инструменты: -fsanitize=undefined, Valgrind, статические анализаторы

Вопрос

Является ли порядок инициализации глобальных переменных в разных единицах трансляции определённым?

Ответ

Нет. Порядок инициализации глобальных переменных, определённых в разных файлах, не определён стандартом. Это может привести к неопределённому поведению, если одна глобальная переменная зависит от другой, определённой в другом файле. Такие зависимости следует избегать или использовать ленивую инициализацию.


Вопрос

Что происходит при выходе за границы массива?

Ответ

Доступ к элементу массива за пределами [0, размер) приводит к неопределённому поведению. Это включает чтение и запись. Даже проверка адреса (&arr[size]) допустима только для получения указателя «на один после последнего», но разыменовывать его нельзя.


Вопрос

Можно ли сравнивать указатели на разные объекты?

Ответ

Указатели на разные объекты можно сравнивать на равенство (==, !=). Сравнение порядка (<, >) разрешено только если оба указателя указывают на элементы одного и того же массива (или на один после последнего). Иное сравнение приводит к неопределённому поведению.


Вопрос

Что такое «строгое алиасинговое правило» (strict aliasing rule)?

Ответ

Правило строгого алиасинга запрещает обращаться к объекту через указатель типа, не совместимого с его фактическим типом, за исключением случаев с char* и некоторыми другими. Нарушение этого правила приводит к неопределённому поведению и мешает оптимизациям.

Пример недопустимого:

int x = 42;
float *p = (float*)&x; // нарушение strict aliasing
float y = *p; // UB

Вопрос

Почему использование char* для доступа к любому объекту разрешено?

Ответ

Стандарт явно разрешает доступ к объекту любого типа через указатель на char, unsigned char или signed char. Это необходимо для реализации функций вроде memcpy, которые работают с памятью на побайтовом уровне.


Оптимизации, ABI и низкоуровневые особенности

Вопрос

Что такое выравнивание (alignment) в C?

Ответ

Выравнивание — это требование архитектуры к размещению данных в памяти по адресам, кратным определённому числу байт. Например, 4-байтовое целое часто должно быть выровнено по границе 4 байт. Невыравненный доступ может вызвать сбой (например, на ARM) или замедлить работу (на x86). Компилятор автоматически добавляет «дыры» (padding) в структуры для соблюдения выравнивания.


Вопрос

Как узнать выравнивание типа?

Ответ

Начиная со стандарта C11, можно использовать оператор _Alignof или макрос alignof из <stdalign.h>.

Пример:

#include <stdalign.h>
printf("Выравнивание int: %zu\n", alignof(int));

Вопрос

Что такое ABI и зачем он нужен?

Ответ

ABI (Application Binary Interface) — это низкоуровневый интерфейс между двумя бинарными компонентами программы. Он определяет:

  • Соглашения о вызовах функций (как передаются аргументы, где возвращается результат)
  • Представление типов данных в памяти
  • Именование символов (name mangling, хотя в C его почти нет)
  • Обработку исключений (в C не используется)

Соблюдение ABI необходимо для совместной линковки объектных файлов и библиотек, скомпилированных разными компиляторами или в разное время.


Вопрос

Как передаются аргументы функции на уровне ABI?

Ответ

Это зависит от архитектуры и соглашения о вызовах (calling convention). Например:

  • На x86-64 System V ABI (Linux): первые 6 целочисленных аргументов передаются через регистры rdi, rsi, rdx, rcx, r8, r9; остальные — через стек.
  • На Windows x64: используются rcx, rdx, r8, r9.

Компилятор скрывает эти детали, но они важны при написании встроенных ассемблерных вставок или взаимодействии с другими языками.


Вопрос

Что такое порядок байтов (endianness)?

Ответ

Порядок байтов определяет, как многобайтовые значения хранятся в памяти:

  • Little-endian: младший байт по младшему адресу (используется на x86, x86-64)
  • Big-endian: старший байт по младшему адресу (некоторые сетевые протоколы, старые PowerPC)

Стандарт C не фиксирует порядок байтов — это реализация-определяемо.


Вопрос

Как проверить порядок байтов во время выполнения?

Ответ

Можно использовать объединение или указатель:

bool is_little_endian(void) {
union { uint32_t i; uint8_t c[4]; } u = {0x01020304};
return u.c[0] == 0x04;
}

Вопрос

Что такое volatile-квалификатор?

Ответ

Ключевое слово volatile указывает компилятору, что значение переменной может изменяться вне видимости программы (например, аппаратным регистром или другим потоком). Это запрещает оптимизации, связанные с кэшированием значения в регистрах.

Пример:

volatile int *hardware_register = (volatile int*)0x4000;
int value = *hardware_register; // всегда читается из памяти

Вопрос

Можно ли использовать volatile для синхронизации между потоками?

Ответ

Нет. volatile не обеспечивает атомарности и не создаёт барьеров памяти. Для многопоточного программирования в C следует использовать средства из <stdatomic.h> (начиная с C11) или платформенно-зависимые примитивы. volatile подходит только для взаимодействия с оборудованием или сигнальными обработчиками.


Вопрос

Что делает ключевое слово restrict?

Ответ

restrict — это подсказка компилятору, что указатель является единственным способом доступа к данной области памяти в течение его времени жизни. Это позволяет выполнять более агрессивные оптимизации, особенно при работе с массивами.

Пример:

void copy(int *restrict dst, const int *restrict src, size_t n) {
for (size_t i = 0; i < n; i++) {
dst[i] = src[i];
}
}

Если dst и src перекрываются, поведение не определено.


Вопрос

Как компилятор оптимизирует код на основе предположений о неопределённом поведении?

Ответ

Компилятор предполагает, что неопределённое поведение никогда не происходит. Это позволяет удалять «недостижимый» код или упрощать выражения.

Пример:

int foo(int x) {
if (x + 1 < x) return 1; // UB при переполнении signed int
return 0;
}

Компилятор может удалить условие целиком, так как оно «никогда не истинно».


Многопоточность и работа с сигналами

Вопрос

Поддерживает ли язык C многопоточность?

Ответ

Начиная со стандарта C11, в язык добавлена поддержка многопоточности через заголовок <threads.h>. Он предоставляет типы и функции для создания потоков, управления мьютексами, условными переменными и таймерами. Однако не все компиляторы и стандартные библиотеки полностью реализуют эту часть стандарта (например, glibc поддерживает частично, MSVC — нет).


Вопрос

Как создать поток в C11?

Ответ

Поток создаётся функцией thrd_create, которая принимает указатель на объект потока, функцию-обработчик и аргумент.

Пример:

#include <threads.h>
#include <stdio.h>

int thread_func(void *arg) {
printf("Поток запущен\n");
return 0;
}

int main(void) {
thrd_t t;
if (thrd_create(&t, thread_func, NULL) == thrd_success) {
thrd_join(t, NULL);
}
return 0;
}

Вопрос

Что такое мьютекс и как его использовать в C11?

Ответ

Мьютекс (взаимное исключение) — это примитив синхронизации, обеспечивающий, что только один поток может выполнять критическую секцию кода в определённый момент времени. В C11 используется тип mtx_t и функции mtx_lock, mtx_unlock.

Пример:

mtx_t mutex = MTX_INIT;

void critical_section(void) {
mtx_lock(&mutex);
// защищённый код
mtx_unlock(&mutex);
}

Вопрос

Какие уровни поддержки многопоточности определяет стандарт C11?

Ответ

Стандарт определяет три уровня:

  • Thread-safe functions: функции, которые можно вызывать из нескольких потоков одновременно
  • Thread-local storage: переменные с классом хранения _Thread_local
  • Full threading support: полная поддержка через <threads.h>

Реализация может поддерживать не все уровни. Это можно проверить через макрос __STDC_NO_THREADS__.


Вопрос

Что делает ключевое слово _Thread_local?

Ответ

_Thread_local объявляет переменную с локальным хранилищем потока. Каждый поток имеет свою собственную копию такой переменной. Аналог thread_local в C++ или __thread в GCC.

Пример:

_Thread_local int per_thread_counter = 0;

Вопрос

Как обрабатывать сигналы в C?

Ответ

Сигналы обрабатываются с помощью функции signal или более надёжной sigaction (из POSIX). Обработчик сигнала — это функция, вызываемая при получении сигнала.

Пример:

#include <signal.h>
#include <stdio.h>

void handler(int sig) {
printf("Получен сигнал %d\n", sig);
}

int main(void) {
signal(SIGINT, handler);
while (1); // ожидание сигнала
return 0;
}

Вопрос

Какие функции можно безопасно вызывать из обработчика сигнала?

Ответ

Только асинхронно-сигнально-безопасные (async-signal-safe) функции. Большинство функций стандартной библиотеки (включая printf, malloc) не являются таковыми. Безопасные функции включают:

  • _Exit, abort
  • write (системный вызов, не stdio)
  • Присваивание переменным типа volatile sig_atomic_t

Пример безопасного обработчика:

volatile sig_atomic_t flag = 0;

void handler(int sig) {
flag = 1; // единственное безопасное действие
}

Вопрос

Что такое sig_atomic_t?

Ответ

sig_atomic_t — это целочисленный тип, чтение и запись которого гарантированно атомарны даже в присутствии асинхронных сигналов. Используется для флагов, изменяемых в обработчиках сигналов.

Пример:

volatile sig_atomic_t shutdown_requested = 0;

Вопрос

Можно ли использовать потоки и сигналы вместе?

Ответ

Да, но с осторожностью. В многопоточной программе сигналы доставляются одному из потоков, для которых сигнал не заблокирован. Лучше выделять один поток для обработки всех сигналов с помощью sigwait, а остальные блокировать сигналы через pthread_sigmask (в POSIX). Стандарт C11 не регулирует взаимодействие сигналов и потоков — это область POSIX.


Вопрос

Почему printf нельзя использовать в обработчике сигнала?

Ответ

printf использует внутренние буферы и блокировки, не предназначенные для повторного входа (reentrancy). Если сигнал пришёл во время выполнения printf в основном коде, повторный вызов приведёт к повреждению состояния или зависанию. Поэтому в обработчиках разрешены только асинхронно-безопасные функции.


Практические паттерны, идиомы и частые ошибки

Вопрос

Как безопасно вычислить среднее двух целых чисел без переполнения?

Ответ

Прямое вычисление (a + b) / 2 может привести к переполнению. Безопасный способ:

int mid = a + (b - a) / 2;

Это работает, если a <= b. Для произвольного порядка можно использовать побитовую арифметику:

unsigned int avg = (a & b) + ((a ^ b) >> 1);

Вопрос

Как обменивать значения двух переменных без временной переменной?

Ответ

Можно использовать побитовое XOR:

a ^= b;
b ^= a;
a ^= b;

Однако этот метод не рекомендуется: он неприменим к одинаковым переменным (a ^= a даёт 0), менее читаем и не работает с плавающей точкой. Лучше использовать временную переменную.


Вопрос

Как избежать ошибки «off-by-one» при работе с массивами?

Ответ

Следовать идиоме: использовать полуоткрытый интервал [start, end), где end указывает на элемент после последнего. Это упрощает циклы и совместимо со стандартной библиотекой.

Пример:

for (size_t i = 0; i < size; i++) { ... }
// а не i <= size-1

Вопрос

Почему нельзя сравнивать вещественные числа на равенство?

Ответ

Из-за ограниченной точности представления чисел с плавающей запятой операции могут давать результаты с небольшими погрешностями. Прямое сравнение a == b часто даёт ложный результат.

Правильный подход — проверка с допуском (epsilon):

#include <math.h>
bool equal(double a, double b) {
return fabs(a - b) < 1e-9;
}

Выбор epsilon зависит от контекста.


Вопрос

Как правильно освободить двумерный массив, выделенный динамически?

Ответ

Если массив выделен как массив указателей на строки:

int **matrix = malloc(rows * sizeof(int*));
for (size_t i = 0; i < rows; i++) {
matrix[i] = malloc(cols * sizeof(int));
}

// Освобождение:
for (size_t i = 0; i < rows; i++) {
free(matrix[i]);
}
free(matrix);

Если выделен как единый блок, освобождается одним вызовом free.


Вопрос

Что такое «правило трёх» в C? (Аналог в C++)

Ответ

В C нет деструкторов, но идиома применяется к ресурсам: если структура управляет ресурсом (например, памятью), необходимо явно реализовать:

  • Создание (инициализацию)
  • Копирование (глубокое или запрет)
  • Освобождение

Пример:

typedef struct {
int *data;
size_t size;
} Vector;

Vector vector_create(size_t n) {
Vector v = {malloc(n * sizeof(int)), n};
return v;
}

void vector_destroy(Vector *v) {
free(v->data);
v->data = NULL;
v->size = 0;
}

Вопрос

Как избежать утечки памяти при ошибке в середине функции?

Ответ

Использовать метку выхода и централизованное освобождение:

int process(void) {
char *buf1 = malloc(100);
if (!buf1) goto fail;

char *buf2 = malloc(200);
if (!buf2) goto fail_buf1;

// основная логика

free(buf2);
free(buf1);
return 0;

fail_buf1:
free(buf1);
fail:
return -1;
}

Это распространённая идиома в системном коде на C.


Вопрос

Почему sizeof не работает для динамических массивов внутри функции?

Ответ

При передаче массива в функцию он превращается в указатель. sizeof(ptr) возвращает размер указателя (например, 8 байт на x64), а не размер выделенного блока. Поэтому размер всегда нужно передавать отдельно.


Вопрос

Как проверить, является ли число степенью двойки?

Ответ

Для положительных целых:

bool is_power_of_two(unsigned int n) {
return n && !(n & (n - 1));
}

Работает, потому что степени двойки имеют ровно один бит установленным, а n-1 инвертирует все младшие биты.


Вопрос

Что такое «защитное программирование» в контексте C?

Ответ

Защитное программирование — это практика написания кода, устойчивого к ошибкам:

  • Проверка всех входных данных
  • Инициализация всех переменных
  • Использование констант вместо магических чисел
  • Явное управление ресурсами
  • Избегание небезопасных функций (gets, strcpy)
  • Использование статических анализаторов и санитайзеров

Цель — сделать программу отказоустойчивой даже при некорректном использовании.


Стандарты языка C и эволюция

Вопрос

Какие основные версии стандарта C существуют?

Ответ

Основные стандарты:

  • C89 (ANSI C / C90) — первый официальный стандарт, принятый ANSI в 1989 и ISO в 1990.
  • C99 — добавил комментарии //, переменные длины массивов (VLA), локальные объявления в любом месте блока, long long, <stdbool.h>, <stdint.h>.
  • C11 — добавил многопоточность (<threads.h>), _Generic, _Static_assert, _Alignof, улучшенную поддержку Unicode, атомарные операции.
  • C17 (C18) — техническое исправление C11, без новых возможностей.
  • C23 — новейший стандарт (принят в 2023), добавил auto для вывода типа, улучшения в препроцессоре, удаление устаревших функций (gets окончательно исключён), поддержку UTF-8 строк по умолчанию.

Вопрос

Что такое _Generic и зачем он нужен?

Ответ

_Generic — это механизм выбора выражения на основе типа аргумента. Он позволяет создавать макросы, работающие с разными типами, подобно перегрузке функций.

Пример:

#define PRINT(x) _Generic((x), \
int: print_int, \
float: print_float, \
char*: print_string \
)(x)

PRINT(42); // вызовет print_int(42)
PRINT("hello"); // вызовет print_string("hello")

Вопрос

Что делает _Static_assert?

Ответ

_Static_assert проверяет условие на этапе компиляции. Если условие ложно, компиляция прерывается с ошибкой. Это полезно для проверки предположений о размерах типов или констант.

Пример:

_Static_assert(sizeof(int) == 4, "int должен быть 4 байта");

Начиная с C23, можно писать просто static_assert.


Вопрос

Какие заголовки появились в C99?

Ответ

Ключевые новые заголовки в C99:

  • <stdint.h> — фиксированные целочисленные типы (int32_t, uint64_t)
  • <stdbool.h> — тип _Bool и макрос bool
  • <complex.h> — поддержка комплексных чисел
  • <tgmath.h> — тип-универсальные математические функции
  • <inttypes.h> — макросы для форматирования фиксированных типов (PRId64)

Вопрос

Можно ли использовать переменные, объявленные в заголовке цикла for, вне цикла?

Ответ

В C89 — да, если компилятор позволяет (но это не соответствует стандарту). В C99 и новее область видимости таких переменных ограничена циклом. После выхода из цикла переменная недоступна.

Пример (C99+):

for (int i = 0; i < 10; i++) { }
// i здесь недоступен

Вопрос

Что такое «гибкий член массива» (flexible array member)?

Ответ

Гибкий член массива — это последнее поле структуры, объявленное как тип имя[] без указания размера. Позволяет выделять структуру и массив данных одним блоком.

Пример:

struct Buffer {
size_t len;
char data[]; // гибкий массив
};

struct Buffer *b = malloc(sizeof(struct Buffer) + 100);
b->len = 100;
// b->data[0..99] доступны

Поддерживается начиная с C99.


Вопрос

Почему в C23 убрали gets?

Ответ

Функция gets не имеет возможности ограничить количество читаемых символов, что делает её уязвимой к переполнению буфера. Она была помечена как устаревшая в C99 и полностью удалена в C11, но некоторые реализации всё ещё предоставляли её. В C23 окончательно исключили из стандарта все упоминания, чтобы усилить безопасность.


Вопрос

Что нового в C23 для строк?

Ответ

C23 делает UTF-8 основной кодировкой для строковых литералов. Добавлен префикс u8 как обязательный, а также поддержка:

  • char8_t (аналог из C++)
  • Универсальные строковые литералы
  • Улучшенная совместимость с международными символами

Вопрос

Поддерживает ли C вывод типа переменной?

Ответ

До C23 — нет. Начиная с C23, можно использовать auto для автоматического вывода типа при инициализации:

auto x = 42; // int
auto y = 3.14; // double
auto z = "hello"; // const char*

Это упрощает написание обобщённого кода.


Вопрос

Как проверить, поддерживает ли компилятор определённый стандарт?

Ответ

Можно использовать макрос __STDC_VERSION__:

#if __STDC_VERSION__ >= 202311L
// C23
#elif __STDC_VERSION__ >= 201710L
// C17
#elif __STDC_VERSION__ >= 201112L
// C11
#elif __STDC_VERSION__ >= 199901L
// C99
#else
// C89/C90
#endif

Продвинутые темы и вопросы на понимание

Вопрос

Как найти количество установленных битов в целом числе?

Ответ

Можно использовать цикл с проверкой младшего бита:

int count_bits(unsigned int n) {
int count = 0;
while (n) {
count += n & 1;
n >>= 1;
}
return count;
}

Или более эффективно — с помощью операции n & (n - 1), которая сбрасывает младший установленный бит:

int count_bits(unsigned int n) {
int count = 0;
while (n) {
n &= n - 1;
count++;
}
return count;
}

Вопрос

Как поменять местами байты в 32-битном числе (байтовый своп)?

Ответ

Можно использовать побитовые операции:

uint32_t swap_bytes(uint32_t x) {
return ((x & 0x000000FF) << 24) |
((x & 0x0000FF00) << 8) |
((x & 0x00FF0000) >> 8) |
((x & 0xFF000000) >> 24);
}

Или, если доступна функция __builtin_bswap32 (в GCC/Clang), использовать её.


Вопрос

Что такое «строгая типизация» в контексте C?

Ответ

C имеет статическую, но слабую типизацию. Однако компилятор выполняет строгую проверку типов при:

  • Присваивании указателей разных типов (требуется явное приведение)
  • Вызове функций с прототипом
  • Операциях с несовместимыми типами

Это помогает выявлять ошибки на этапе компиляции, хотя язык допускает обход через void* и приведения.


Вопрос

Почему нельзя присвоить int** переменной типа const int**?

Ответ

Потому что это нарушило бы константность. Если бы такое присваивание было разрешено, можно было бы изменить const объект косвенно:

const int value = 42;
const int *p = &value;
int **pp = (int**)&p; // опасное приведение
*pp = &some_int; // теперь p указывает на неконстантный объект
// Но даже без этого: **pp = 100; могло бы изменить value

Компилятор запрещает int** → const int**, но разрешает int** → const int* const*.


Вопрос

Как реализовать простой стек на массиве в C?

Ответ

#define STACK_SIZE 100

typedef struct {
int data[STACK_SIZE];
int top;
} Stack;

void stack_init(Stack *s) {
s->top = -1;
}

bool stack_push(Stack *s, int value) {
if (s->top >= STACK_SIZE - 1) return false;
s->data[++s->top] = value;
return true;
}

bool stack_pop(Stack *s, int *out) {
if (s->top < 0) return false;
if (out) *out = s->data[s->top--];
return true;
}

Вопрос

Как реализовать связный список в C?

Ответ

typedef struct Node {
int data;
struct Node *next;
} Node;

Node* list_insert(Node *head, int value) {
Node *new_node = malloc(sizeof(Node));
if (!new_node) return head;
new_node->data = value;
new_node->next = head;
return new_node;
}

void list_free(Node *head) {
while (head) {
Node *next = head->next;
free(head);
head = next;
}
}

Вопрос

Что такое «кэширование строк» (string interning) и поддерживает ли его C?

Ответ

Кэширование строк — это оптимизация, при которой одинаковые строковые литералы хранятся в памяти один раз. В C это реализация-определяемо: компилятор может объединить одинаковые литералы, а может и нет. Программист не должен полагаться на то, что "hello" == "hello" вернёт true.


Вопрос

Как работает ленивая инициализация глобальной переменной?

Ответ

Глобальная переменная инициализируется один раз при первом обращении:

static int *global_ptr = NULL;

int* get_global(void) {
if (global_ptr == NULL) {
global_ptr = malloc(sizeof(int));
*global_ptr = 42;
}
return global_ptr;
}

В многопоточной среде требуется синхронизация.


Вопрос

Что делает функция setjmp / longjmp?

Ответ

setjmp сохраняет текущее состояние выполнения (регистры, стек), а longjmp восстанавливает его позже, выполняя нелокальный переход (аналог исключения). Используется для обработки ошибок в глубоко вложенных вызовах.

Пример:

#include <setjmp.h>
jmp_buf env;

void deep_function(void) {
longjmp(env, 1); // возврат в setjmp
}

int main(void) {
if (setjmp(env) == 0) {
deep_function();
} else {
printf("Возврат через longjmp\n");
}
return 0;
}

Вопрос

Безопасно ли использовать longjmp через границы функций с автоматическими переменными?

Ответ

Нет. Если longjmp возвращает управление в функцию, где уже вышли из блока, содержащего локальные переменные, поведение не определено. Особенно опасно, если эти переменные использовались после setjmp.


Вопрос

Как передать двумерный массив фиксированного размера в функцию?

Ответ

Можно указать размер второго измерения:

void process(int rows, int matrix[][10]) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 10; j++) {
// matrix[i][j]
}
}
}

Или использовать VLA (C99+):

void process(int rows, int cols, int matrix[rows][cols]);

Вопрос

Что такое «псевдонимы» (aliasing) и почему они важны?

Ответ

Псевдоним возникает, когда два указателя ссылаются на одну и ту же область памяти. Это влияет на оптимизации: компилятор не может предполагать, что запись через один указатель не влияет на значение, читаемое через другой. Правило строгого алиасинга ограничивает такие ситуации.


Вопрос

Как определить, поддерживает ли система 64-битные целые?

Ответ

Можно проверить наличие long long (гарантирован в C99) или использовать <stdint.h>:

#include <stdint.h>
// int64_t и uint64_t доступны, если реализация поддерживает 64-битные типы

Также можно проверить UINT64_MAX из <stdint.h> или ULLONG_MAX из <limits.h>.


Вопрос

Что такое «sequence point» и почему он важен?

Ответ

Sequence point — это точка в программе, где гарантируется, что все побочные эффекты предыдущих вычислений завершены, а последующие ещё не начались. Между двумя sequence points изменение одной переменной более одного раза приводит к неопределённому поведению.

Пример UB:

i = i++; // два изменения i между sequence points

Sequence points есть после &&, ||, ,, ? :, в конце полного выражения.


Вопрос

Как избежать проблемы с модификацией и чтением одной переменной в одном выражении?

Ответ

Разделить выражение на несколько операторов:

// Плохо:
a[i] = i++;

// Хорошо:
a[i] = i;
i++;

Это делает порядок операций явным и переносимым.


Вопрос

Что происходит при делении на ноль в C?

Ответ

Для целочисленного деления — неопределённое поведение. Для деления с плавающей точкой — результат зависит от IEEE 754:

  • 1.0 / 0.0+inf
  • -1.0 / 0.0-inf
  • 0.0 / 0.0NaN

Но только если поддержка IEEE 754 включена (реализация-определяемо).


Вопрос

Как проверить, является ли символ цифрой, не используя isdigit?

Ответ

Сравнить с диапазоном:

bool is_digit(char c) {
return c >= '0' && c <= '9';
}

Это безопасно, потому что стандарт гарантирует, что цифры '0''9' идут подряд.


Вопрос

Почему sizeof('a') не равен sizeof(char)?

Ответ

В C символьная константа 'a' имеет тип int, а не char. Поэтому sizeof('a') == sizeof(int). Это отличается от C++, где 'a' имеет тип char.


Вопрос

Как работает offsetof и где он используется?

Ответ

offsetof (из <stddef.h>) возвращает смещение поля структуры от начала в байтах. Реализован через макрос:

#define offsetof(type, member) ((size_t)&((type*)0)->member)

Используется в макросах вроде container_of в ядре Linux для получения указателя на структуру по указателю на её поле.


Вопрос

Что такое «padding» в структурах и как его минимизировать?

Ответ

Padding — это неиспользуемые байты, добавляемые компилятором для выравнивания полей. Чтобы минимизировать padding, следует располагать поля в порядке убывания их выравнивания (например, double, int, short, char).


Вопрос

Можно ли создать массив функций в C?

Ответ

Нет. Массив функций невозможен, но можно создать массив указателей на функции:

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }

int (*ops[2])(int, int) = {add, sub};
int result = ops[0](5, 3); // вызов add

Вопрос

Как реализовать простой хеш-таблицу в C?

Ответ

Минимальная реализация с цепочками:

#define TABLE_SIZE 100

typedef struct Entry {
char *key;
int value;
struct Entry *next;
} Entry;

Entry *table[TABLE_SIZE] = {0};

unsigned int hash(const char *key) {
unsigned int h = 0;
while (*key) h = h * 31 + *key++;
return h % TABLE_SIZE;
}

void put(const char *key, int value) {
unsigned int idx = hash(key);
Entry *e = malloc(sizeof(Entry));
e->key = strdup(key);
e->value = value;
e->next = table[idx];
table[idx] = e;
}

Вопрос

Что такое «reentrant function»?

Ответ

Reentrant-функция — это функция, которую можно безопасно вызывать одновременно из нескольких потоков или прерываний, даже если предыдущий вызов ещё не завершился. Такая функция не использует статические или глобальные данные, не вызывает непотокобезопасные функции и не возвращает указатель на статическую память.

Пример: strlen — reentrant, strtok — нет.


Вопрос

Почему strtok не является потокобезопасной?

Ответ

strtok использует внутреннюю статическую переменную для хранения позиции в строке между вызовами. При вызове из разных потоков эта переменная будет общей, что приведёт к повреждению состояния. Вместо неё следует использовать strtok_r (POSIX) или собственную реализацию.


Вопрос

Как правильно завершить программу при ошибке?

Ответ

Использовать exit(EXIT_FAILURE) или _Exit(EXIT_FAILURE) для немедленного завершения. Перед этим можно вывести сообщение об ошибке в stderr:

fprintf(stderr, "Ошибка: не удалось открыть файл\n");
exit(EXIT_FAILURE);

Если нужно гарантировать немедленное завершение без вызова обработчиков выхода, использовать _Exit.