Раздел 18 — Модули и сборка

Программа из нескольких файлов: заголовки, раздельная компиляция, make и библиотека

Прогресс курса Раздел 18 из 28

Что вы освоите в этом разделе

2 академических часа теории. Практика по модулям — в блоке 3: модуль ПЗ №19 «словарь» собирается с make. Тест по разделу — на портале.
01

Зачем делить программу на файлы

Программы прошлых разделов помещались в один файл. Стек из раздела 17 повторялся в трёх практических работах — его копировали. Так нельзя работать дальше:

Модуль в C — пара файлов: заголовок stack.h описывает, что модуль умеет (типы, прототипы функций, комментарии к ним), а реализация stack.c — как. Кто пользуется стеком, включает только заголовок и о реализации не знает. Стандартная библиотека устроена так же: stdio.h — интерфейс, реализация printf лежит в уже скомпилированной библиотеке.

02

Интерфейс и реализация

Пример раздела — калькулятор обратной польской записи из раздела 17, разделённый на три файла и два модуля (examples/18/calc/): stack — стек, rpn — вычисление выражения, main.c — ввод и вывод.

/* stack.h — интерфейс модуля «стек целых» */
#ifndef STACK_H
#define STACK_H

#include <stddef.h>

typedef enum { STACK_OK, STACK_EMPTY, STACK_NO_MEMORY } StackStatus;

typedef struct {
    long *data;
    size_t size, capacity;
} Stack;

/* Сколько раз модуль перевыделял память — определена в stack.c */
extern long stack_reallocs;

void stack_init(Stack *s);
void stack_free(Stack *s);
StackStatus stack_push(Stack *s, long value);
StackStatus stack_pop(Stack *s, long *value);
size_t stack_size(const Stack *s);

#endif
/* stack.c — реализация */
#include <stdlib.h>
#include "stack.h"

long stack_reallocs = 0;                     /* определение: память под переменную — здесь */

/* static: функция видна только в этом файле, в интерфейс не входит */
static StackStatus grow(Stack *s)
{
    …
}

StackStatus stack_push(Stack *s, long value)
{
    if (s->size == s->capacity && grow(s) != STACK_OK) {
        return STACK_NO_MEMORY;
    }
    s->data[s->size++] = value;
    return STACK_OK;
}
…
03

Раздельная компиляция

Сборка из раздела 1 — препроцессор, компилятор, компоновщик — для нескольких файлов идёт так: каждый .c отдельно превращается в объектный файл .o (машинный код с «дырами» на месте вызовов чужих функций), затем компоновщик (ld) склеивает объектные файлы и библиотеки в программу и заполняет дыры адресами.

gcc -std=c11 -Wall -Wextra -pedantic -g -c main.c      → main.o   (-c: только компиляция)
gcc -std=c11 -Wall -Wextra -pedantic -g -c rpn.c       → rpn.o
gcc -std=c11 -Wall -Wextra -pedantic -g -c stack.c     → stack.o
gcc -std=c11 -Wall -Wextra -pedantic -g -o calc main.o rpn.o stack.o   → компоновка

./calc < in.txt
3 4 + 2 *            = 14
5 1 2 + 4 * + 3 -    = 14
1 2 3 4 5 6 7 8 9 + + + + + + + + = 45
4 0 /                error: division by zero
stack reallocs: 5

Каждый файл компилируется в одиночку: компилируя rpn.c, gcc видит только его и включённые заголовки. Поэтому для вызова stack_push нужен прототип из stack.h, а где лежит тело функции — дело компоновщика. Что модуль даёт и что ему нужно, показывает программа nm:

$ nm stack.o                            $ nm rpn.o
                 U free                 000000000000001f t apply
0000000000000000 t grow                 0000000000000000 d messages
                 U realloc              00000000000000e3 T rpn_eval
00000000000000c1 T stack_free           0000000000000000 T rpn_message
000000000000008f T stack_init                            U stack_free
0000000000000158 T stack_pop                             U stack_init
00000000000000eb T stack_push                            U stack_pop
0000000000000000 B stack_reallocs                        U stack_push
00000000000001b3 T stack_size                            U stack_size
                                                         U strtol  …

Препроцессор для каждого файла сначала вставляет все заголовки: 23 строки main.c после gcc -E main.c превращаются в 1221 — почти всё из stdio.h.

04

Защита от повторного включения

Заголовки включают друг друга, и один файл легко попадает в программу дважды: main.c включает point.h и geometry.h, а geometry.h тоже включает point.h (examples/18/errors/). Без защиты структура объявится дважды:

$ gcc -std=c11 -Wall main_guard.c
In file included from geometry.h:2,
                 from main_guard.c:4:
point.h:2:16: error: redefinition of 'struct Point'
point.h:2:16: note: originally defined here

Защита — include guard: весь заголовок внутри условия препроцессора с уникальным именем. При первом включении имя ещё не определено — текст вставляется и имя определяется; при втором условие ложно, и текст пропускается.

#ifndef POINT_H                            /* имя — от имени файла, заглавными */
#define POINT_H

typedef struct Point {
    double x, y;
} Point;

#endif

Короче — #pragma once первой строкой. Это не стандарт C, но её понимают gcc, clang и MSVC. Защита нужна каждому заголовку, даже если сейчас его включает один файл.

05

static и extern вне функций

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

Частая ошибка — определить переменную прямо в заголовке: int counter = 0; в counter.h. Заголовок включён в main_counter.c и tick.c — переменная определена в обоих, и компоновщик это видит:

$ gcc -std=c11 -Wall main_counter.c tick.c
/usr/bin/ld: /tmp/ccp3lVMT.o:(.bss+0x0): multiple definition of `counter';
             /tmp/cc6SQ9Yx.o:(.bss+0x0): first defined here
collect2: error: ld returned 1 exit status

Исправление: в counter.h — extern int counter;, в tick.c — int counter = 0;. Но лучше вовсе обходиться без глобальных переменных, разделяемых между модулями: функция tick_count(), возвращающая static-счётчик, даёт тот же результат и не позволяет испортить его снаружи.

06

Ошибки компоновки

Ошибки компоновки приходят от ld, а не от компилятора: каждый файл по отдельности верен, не сходится целое. Их узнают по словам undefined reference или multiple definition и по строке collect2: error: ld returned 1 exit status. Номера строк в них часто нет, а имена файлов бывают временными (/tmp/cc….o).

СообщениеПричинаИсправление
undefined reference to `stack_pop'в сборку не включён stack.c (gcc main.c rpn.c)добавить файл или его .o в команду, в Makefile
undefined reference to `square'функция объявлена в другом файле static — снаружи её нетубрать static и объявить в заголовке, если функция нужна снаружи; иначе не вызывать
undefined reference to `sqrt'математическая библиотека в Linux подключается отдельноключ -lm в конце команды
undefined reference при библиотеке в команде-lds стоит раньше объектных файлов, которым она нужнабиблиотеки — после файлов: gcc main.o rpn.o -L. -lds
multiple definition of `counter'переменная или функция определена в заголовке или в двух .cв заголовке — extern или прототип, определение — в одном .c
undefined reference to `main'собирается программа без файла с main (или опечатка mian)добавить файл с main; модуль без main компилировать с -c

Отличайте их от ошибок компилятора: implicit declaration of function — забыт #include с прототипом, компилятор не знает функцию (в gcc 14 это ошибка, в старых версиях — лишь предупреждение, и программа собиралась с неверными типами аргументов), undefined reference — прототип был, а тела функции компоновщик не нашёл.

07

make: пересобирать только изменившееся

Набирать команды для каждого файла долго, а пересобирать всё после каждой правки — медленно в большой программе. Утилита make читает Makefile — список правил «цель: зависимости» и команд — и выполняет только те команды, цели которых старше своих зависимостей (сравнивает время изменения файлов).

# Калькулятор ОПН из трёх модулей. make — собрать, make lib — через статическую библиотеку, make clean — убрать
CC = gcc
CFLAGS = -std=c11 -Wall -Wextra -pedantic -g
OBJS = main.o rpn.o stack.o

calc: $(OBJS)
	$(CC) $(CFLAGS) -o $@ $(OBJS)

# Объектный файл зависит от своего .c и от всех заголовков, которые он включает
main.o: main.c rpn.h stack.h
	$(CC) $(CFLAGS) -c main.c
rpn.o: rpn.c rpn.h stack.h
	$(CC) $(CFLAGS) -c rpn.c
stack.o: stack.c stack.h
	$(CC) $(CFLAGS) -c stack.c

clean:
	rm -f calc calc-lib *.o libds.a

.PHONY: lib clean
$ make                          — после правки ничего не изменилось
make: 'calc' is up to date.
$ touch rpn.c && make           — «изменили» rpn.c: один файл и компоновка
gcc -std=c11 -Wall -Wextra -pedantic -g -c rpn.c
gcc -std=c11 -Wall -Wextra -pedantic -g -o calc main.o rpn.o stack.o
$ touch stack.h && make         — изменили заголовок: все, кто его включает
gcc -std=c11 -Wall -Wextra -pedantic -g -c main.c
gcc -std=c11 -Wall -Wextra -pedantic -g -c rpn.c
gcc -std=c11 -Wall -Wextra -pedantic -g -c stack.c
gcc -std=c11 -Wall -Wextra -pedantic -g -o calc main.o rpn.o stack.o

Если забыть заголовок в зависимостях, после правки stack.h часть файлов не пересоберётся и будет работать со старым описанием структуры — ошибка, которую трудно найти. В больших проектах зависимости от заголовков gcc пишет сам (ключи -MMD -MP), а сами Makefile генерирует CMake — с ним вы встретитесь в разделе 25 (Qt). В Windows make ставится из MSYS2: pacman -S make.

08

Статическая библиотека

Когда модулей много и они нужны разным программам, их объектные файлы собирают в статическую библиотеку — архив libИМЯ.a. Компоновщик берёт из неё только те объектные файлы, в которых есть нужные программе символы, и копирует их в программу.

$ ar rcs libds.a stack.o                 — создать архив (r — добавить/заменить, c — создать, s — оглавление)
$ ar t libds.a                           — что в архиве
stack.o
$ gcc main.o rpn.o -L. -lds -o calc-lib  — -L. — искать библиотеки здесь, -lds — файл libds.a
$ ./calc-lib < in.txt
…
stack reallocs: 5

Ключевые выводы раздела

Запомните главное

  • Модуль = заголовок (что умеет) + реализация (как); пользователи включают только заголовок
  • В заголовке — объявления (типы, прототипы, extern), определения — в одном .c
  • Каждому заголовку — include guard (#ifndef X_H / #define X_H / #endif) или #pragma once
  • static вне функции — имя видно только в своём файле; extern — объявление переменной из другого файла
  • Каждый .c компилируется отдельно в .o; компоновщик сводит U одних файлов с T других
  • undefined reference — тела не нашлось (не тот набор файлов, static, нет -lm, порядок библиотек); multiple definition — определено дважды
  • Makefile: цель, зависимости (включая заголовки), команда после табуляции; make пересобирает только устаревшее
  • Статическая библиотека: ar rcs libX.a *.o, подключение -L. -lX — после объектных файлов
Связи раздела. Опирается на раздел 1 — этапы сборки; раздел 15 — static, прототипы, область видимости; раздел 17 — стек и очередь как готовые модули. Нужен для: раздела 19 — ПЗ №19, модуль «словарь» на хеш-таблице с Makefile; раздела 20 — заголовки C++ и пространства имён вместо префиксов; разделов 25–28 — сборка оконного приложения из многих файлов (CMake в Qt Creator).
Тест по разделу 18 — на учебном портале. Курс на portal.nevabit.ru, учётную запись выдаёт преподаватель. Пример раздела целиком — examples/18/: соберите calc через make, измените заголовок и посмотрите, что пересоберётся; повторите ошибки из errors/ и исправьте их.
Раздел 17: Динамическая память Все разделы Экзамен по блоку 2