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— то, что модуль обещает остальным: прототипы функций, типы, константы, объявления общих переменных;uart.c— то, как он это делает: тела функций, внутренние переменные и вспомогательные функции.
/* 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) — одна буква |
ни ошибки, ни предупреждения; в таблице — заглушка |
Опечатка в имени обработчика превращается в зависание при первом же прерывании. Проверяют это по файлу карты компоновки: в нём видно, из какого объектного файла взят каждый символ, — ваш обработчик должен быть из вашего файла, а не из стартового.
Поймать опечатку можно и при компиляции. Ключ -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 нужны, потому что # превращает в строку аргумент
до его подстановки. Это стандартный приём, чтобы вывести значение
константы в сообщение.
Препроцессор знает и готовые имена:
| Имя | Что подставляется |
|---|---|
__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 Итоги
- Модуль — пара
.hи.c;.cвключает свой заголовок, чтобы компилятор сверил объявления. - Заголовок защищают
#ifndef/#define/#endifили#pragma once. - Общая переменная:
externв заголовке, определение ровно в одном.c. Два определения, даже без значения, — L6200E. staticпрячет имя в файле;static-функция в заголовке копируется в каждый файл.- Обработчики прерываний подключаются через слабые символы: опечатка в имени не даёт ошибки, а оставляет заглушку.
- Скобки — вокруг тела макроса и каждого параметра; аргумент с побочным эффектом вычисляется столько раз, сколько написан.
- Многооператорный макрос — в
do { } while (0); вычисления лучше делатьstatic inlineфункциями — код тот же. #делает строку,##склеивает имена;#errorостанавливает сборку.argv[0]— имя программы; уgetoptзначение ключа забирает следующее слово, а необязательное пишется слитно.
Дальше: раздел 72 «Отладка и динамические структуры данных».