Embedded HandbookПриложения
Все разделы › IX. Язык C: от основ до продвинутого
STM32F103, STM32F407, STM32G431 · уровень базовый · 14 мин чтения
Открыть в интерактивном справочнике

71. Многомодульные программы, препроцессор, аргументы командной строки

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

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

Как это называется в документации

Оригинал Перевод
translation unit, module единица трансляции, модуль
header, include guard заголовочный файл, защита от повторного включения
external linkage, extern внешняя связь, объявление без определения
internal linkage, static внутренняя связь
multiply defined symbol символ определён несколько раз
weak symbol слабый символ
map file файл карты компоновки
makefile, target, prerequisite make-файл, цель, зависимость
object-like, function-like macro макрос-константа, макрос с параметрами
stringizing #, token pasting ## превращение в строку, склейка
conditional compilation условная компиляция
command-line arguments аргументы командной строки

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

Одна причина — порядок: драйвер UART в своём файле, логика устройства в своём. Вторая — раздельная компиляция: каждый файл компилируется в свой объектный файл (раздел 63.6), а компоновщик собирает их в один образ (раздел 63.7). Изменили один файл — перекомпилируется только он.

Проект, созданный генератором кода, уже устроен так (раздел 5): main.c, stm32xx_it.c с обработчиками прерываний, файлы инициализации периферии, библиотека производителя, стартовый файл на ассемблере.


71.2 Заголовок и реализация

Модуль — это пара файлов:

/* uart.h */
#ifndef UART_H
#define UART_H
#include <stdint.h>
void uart_init(uint32_t baud);
void uart_send(const uint8_t *data, uint16_t len);
#endif
/* uart.c */
#include "uart.h"
static uint32_t tx_count;                 /* видна только здесь */
void uart_init(uint32_t baud) { ... }
void uart_send(const uint8_t *data, uint16_t len) { ... }

Частый вопрос — зачем uart.c включает свой же заголовок? Чтобы компилятор сверил объявления с определениями. Если прототип в заголовке и заголовок функции в .c разойдутся, это будет ошибка компиляции, а не странное поведение у тех, кто вызывает функцию.

В заголовке не должно быть определений — ни переменных, ни тел функций. Почему — в 71.4 и 71.5.


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

Заголовки включают друг друга, и один и тот же файл легко попадает в единицу трансляции дважды. Если в нём определён тип, второе включение — ошибка «redefinition of 'cfg'».

Защита — условная компиляция вокруг всего файла:

#ifndef UART_H
#define UART_H
/* … содержимое … */
#endif

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

Короче — #pragma once в первой строке. Её понимают и armclang, и GCC, но формально в стандарт она не входит. Заголовки CMSIS и библиотек производителя используют классическую защиту.


71.4 Глобальная переменная для нескольких файлов

Переменную определяют ровно в одном .c, а в заголовке только объявляют со словом extern:

/* ticks.h */
extern volatile uint32_t ticks;           /* объявление: есть такая где-то */

/* systick.c */
volatile uint32_t ticks;                  /* определение: вот она */

Если определить переменную в двух файлах, компоновщик остановится:

Error: L6200E: Symbol counter multiply defined (by b.o and a.o).

И это верно даже для int m; без начального значения. Старые компиляторы молча склеивали такие «пробные» определения в одно, и многие проекты годами жили с переменной, определённой прямо в заголовке. Современные — armclang и новые версии GCC — так не делают, и при переходе на них такой проект перестаёт собираться. Старое поведение у GCC возвращает ключ -fcommon, но правильнее исправить заголовок.

Переменная, которую меняет прерывание, объявляется volatile — и в extern, и в определении (раздел 3.4).

А лучше вообще не делать переменную общей: static в своём файле и функции доступа. Тогда ясно, кто её меняет.


71.5 static: спрятать внутри файла

static у функции или переменной на уровне файла делает имя невидимым для компоновщика (разделы 3.6 и 66.4). Два файла могут иметь свои static-функции helper — конфликта не будет.

Отсюда ловушка: static-функция, определённая в заголовке, получается в каждом файле, который его включил, своя. Проверено по карте компоновки: заголовок с такой функцией включён в два файла — в образе две одинаковые копии.

Исключение — короткие static inline функции: их в заголовки кладут намеренно, и оптимизатор всё равно встраивает их в место вызова (71.11).


71.6 Слабые символы: как подключаются обработчики прерываний

Как компоновщик узнаёт, что ваша функция TIM2_IRQHandler — обработчик прерывания? Он этого не знает. Работает механизм слабых символов.

В стартовом файле F1 для каждого прерывания есть три строки:

DCD     TIM2_IRQHandler            ; в таблице векторов — адрес этого имени
EXPORT  TIM2_IRQHandler  [WEAK]    ; имя слабое
TIM2_IRQHandler                    ; и указывает на обработчик по умолчанию

Слабый символ — это определение «на случай, если другого не найдётся». Если в каком-то объектном файле есть обычная функция с тем же именем, компоновщик берёт её, а слабую выбрасывает. Если нет — остаётся слабая, то есть бесконечный цикл обработчика по умолчанию (раздел 65.9).

И вот цена этого механизма, проверенная компоновкой:

Функция в проекте Результат
void TIM2_IRQHandler(void) в образе ваша функция, заглушки нет
void TIM2_IRQhandler(void) — одна буква ни ошибки, ни предупреждения; в таблице — заглушка

Опечатка в имени обработчика: nm показывает слабую заглушку, в таблице векторов — Default_Handler

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

Поймать опечатку можно и при компиляции. Ключ -Wmissing-prototypes (у GCC и armclang; в -Wall -Wextra он не входит) предупреждает о глобальной функции, для которой не было объявления. Если прототипы обработчиков подключены из заголовка — в проекте CubeMX это stm32f1xx_it.h, — правильное имя проходит молча, а на TIM2_IRQhandler появляется «no previous prototype». Та же заглушка остаётся, если обработчик написан в файле C++ без extern "C": компилятор C++ меняет имя — в объектном файле оно стало _Z15TIM2_IRQHandlerv, — и компоновщик не узнаёт в нём обработчик.

В C слабая функция объявляется так:

__attribute__((weak)) void on_button(void) { }

Этим пользуются библиотеки: функция обратного вызова объявлена слабой пустой, а вы переопределяете её, если нужно (раздел 67.8).


71.7 Сборка: make и проекты

Вручную многофайловая программа собирается так:

gcc -c -o main.o main.c        # каждый файл — в объектный
gcc -c -o func.o func.c
gcc -o prog main.o func.o      # и компоновка

Утилита make делает это по правилам из Makefile и пересобирает только то, что изменилось:

prog: main.o func.o
    gcc -o prog main.o func.o
main.o: main.c mylibrary.h
    gcc -c -o main.o main.c
func.o: func.c
    gcc -c -o func.o func.c
clean:
    rm -f *.o prog

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

Среды для микроконтроллеров делают то же сами: проект Keil хранит список файлов и ключей, CubeIDE генерирует make-файлы. Командная строка armclang для одного файла — в разделе 4.8, о самих компиляторах — раздел 77.


71.8 Препроцессор: макросы

Препроцессор подставляет текст, не понимая C (раздел 63.4). Отсюда все ловушки макросов — они проверены на настоящем компиляторе:

#define A B + 1
#define B 2
A + 2          /* 5 — как ожидалось, но случайно */
A * 2          /* 4, а не 6: получилось 2 + 1 * 2 */

#define HALF(x) x/2
HALF(a + 5)    /* при a = 5 — 7, а не 5: получилось a + 5/2 */

#define SQUARE(x) x*x
SQUARE(1 + 2)  /* 5, а не 9: получилось 1 + 2*1 + 2 */

Правило: в скобки берут и всё тело макроса, и каждый параметр:

#define HALF(x)   ((x) / 2)
#define SQUARE(x) ((x) * (x))

Но скобки не спасают от второй беды — параметр вычисляется столько раз, сколько он написан:

#define MAX(a, b) ((a) > (b) ? (a) : (b))
int i = 5, j = 3;
int m = MAX(i++, j);   /* m = 6, а i стало 7: i++ выполнилось дважды */

И третья, менее известная: обмен через исключающее ИЛИ #define SWAP(a, b) (a ^= b, b ^= a, a ^= b) работает для разных переменных, но SWAP(z, z) обнуляет z. В сортировке, где элемент иногда меняется сам с собой, это портит массив.

Макрос из нескольких операторов оборачивают в do { … } while (0):

#define LED_BLINK() do { led_on(); delay(); led_off(); } while (0)

Без этой обёртки макрос из нескольких операторов под if без фигурных скобок работает неправильно: под условием оказывается только первый оператор, остальные выполняются всегда, и собирается это молча. Если же за if стоит else, ломается сборка: else остаётся без своего if. С обёрткой макрос ведёт себя как одна команда.


71.9 #, ## и предопределённые имена

# превращает параметр в строку, ## склеивает два куска в одно имя:

#define STR(x) #x
#define XSTR(x) STR(x)
#define CAT(a, b) a##b
#define LIMIT 100

STR(LIMIT)       /* "LIMIT" — текст как есть */
XSTR(LIMIT)      /* "100" — сначала подстановка, потом строка */
int CAT(val, 42) = 7;    /* объявлена переменная val42 */

Два уровня в XSTR нужны, потому что # превращает в строку аргумент до его подстановки. Это стандартный приём, чтобы вывести значение константы в сообщение.

Раскрытие STR, XSTR и CAT по шагам: что остаётся после препроцессора

Препроцессор знает и готовые имена:

Имя Что подставляется
__FILE__ имя файла
__LINE__ номер строки
__DATE__, __TIME__ дата и время сборки
__STDC_VERSION__ версия стандарта языка
__func__ имя текущей функции (это переменная, а не макрос)

Отладочные сообщения и assert используют __FILE__ и __LINE__, чтобы сказать, где случилась ошибка. __DATE__ и __TIME__ удобны для отметки сборки, но делают каждую сборку отличающейся от предыдущей — иногда это нежелательно.


71.10 Условная компиляция

#if, #ifdef, #ifndef, #elif, #else, #endif включают или выключают куски текста до компиляции:

#ifdef DEBUG
#define LOG(msg) uart_puts(msg)
#else
#define LOG(msg) ((void)0)       /* в рабочей сборке — ничего */
#endif

Имена для условий задают ключом -D в командной строке компилятора или в поле определений проекта Keil. Именно так заголовок семейства узнаёт, какой у вас камень, — -DSTM32F10X_MD или -DSTM32F407xx (раздел 4.8).

#error останавливает сборку со своим сообщением — это способ защититься от неподдерживаемой конфигурации:

#if !defined(STM32F1) && !defined(STM32F4)
#error "unknown family"
#endif

И ещё один приём: #if 0 … #endif выключает большой кусок кода. Комментарием его не закрыть, если внутри уже есть комментарии /* */ — они не вкладываются.


71.11 Макрос или функция

Макрос подставляется как текст: у него нет типов, аргументы вычисляются столько раз, сколько написаны, а отладчик его не видит. У функции всё это есть — но раньше за неё платили вызовом.

Сейчас это уже не так. static inline функция компилируется в тот же код, что и макрос: MAX в виде макроса и в виде static inline дали на Cortex-M3 одинаковые команды. А вот ловушки нет — imax(k++, j) при k = 5 возвращает 5 и увеличивает k один раз.

Отсюда практика: вычисления — static inline функции в заголовке. Макросы остаются для того, что функции не умеют: констант для размеров массивов (раздел 64.12), условной компиляции, # и ##, описаний регистров.


71.12 Аргументы командной строки

Программа на компьютере получает слова из командной строки через параметры main:

int main(int argc, char *argv[])

argc — число слов, argv — массив указателей на них (раздел 67.7). Нулевое слово — имя самой программы: запуск prog hello world даёт argc = 3.

На микроконтроллере командной строки нет, main объявляется как int main(void), а параметры приходят кнопками, по порту или из Flash. Но утилиты, которые общаются с камнем, — загрузчики прошивки, программы приёма данных — на компьютере разбирают ключи именно так.

Для разбора ключей есть getopt. Строка "ab:C::d" означает: a и d — без значения, b — со значением, C — с необязательным значением. Как это работает на деле:

Командная строка Что получено
-a -b -d -C hello a; b = «-d»; у C значения нет; hello — лишнее слово
-a -b -d -Chello a; b = «-d»; C = «hello»
-a -b1 -C -d a; b = «1»; у C значения нет; d
-b1 -b2 -b 15 b трижды: 1, 2, 15
-h -a неизвестный ключ — «?»; a

Две строки заслуживают внимания. Ключ со значением забирает следующее слово, даже если оно похоже на ключ: -b -d сделало -d значением b. А необязательное значение пишется только слитно: -Chello работает, -C hello — нет.


71.13 Грабли

«redefinition» при сборке. Заголовок включён дважды без защиты.

L6200E: символ определён несколько раз. Переменная определена в заголовке или в двух файлах; в заголовке — только extern.

Проект собирался старым компилятором и не собирается новым. Та же причина: int x; в заголовке больше не склеивается.

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

Прерывание вызывает зависание, хотя обработчик написан. Имя обработчика с опечаткой; в образе осталась слабая заглушка. Проверьте по файлу карты.

Макрос считает неправильно. Нет скобок вокруг тела или параметров.

Переменная увеличилась дважды. Аргумент с ++ передан в макрос.

Сортировка обнулила элементы. Обмен через исключающее ИЛИ элемента с самим собой.

else без if после макроса. Макрос из нескольких операторов без do { } while (0).

Makefile не работает: «missing separator». Команды начинаются с пробелов вместо табуляции.

Ключ командной строки «съел» следующий. Ключ со значением забирает следующее слово целиком.

Необязательное значение ключа не читается. Его нужно писать слитно.


71.14 Итоги

Дальше: раздел 72 «Отладка и динамические структуры данных».

Открыть в интерактивном справочнике