Doxygen для embedded C: документація, про яку неможливо забути
У кожного проєкту є документація, і в кожного проєкту є документація, яка бреше. Word-файл з описом інтерфейсу був правильним у тиждень, коли його писали. Коментар над функцією чесніший, бо він за кілька рядків від коду, який описує, але його пишуть ті самі люди, які забувають. Вихід не в більшій дисципліні, а в інструменті, який читає коментарі, будує з них документацію й ламає збірку, коли чогось бракує. Цей інструмент - Doxygen.
Ця стаття показує, як у моїх проєктах виглядає задокументований модуль, як його генерують і як зробити документацію частиною CI, яку не можна оминути. Приклади зібрано з Doxygen 1.9.8 та Graphviz, а виводи - справжні.
Що документувати, а що ні
У моєму стилі кодування цьому присвячено один абзац: коментарі у стилі Doxygen для всіх публічних функцій, типів і макросів, англійською, короткі й точні, чому, а не що. Кілька прикладів різниці:
| Коментар, який нічого не коштує | Коментар, який допомагає |
|---|---|
/* initializes the module */ | Clears the filter and marks the value as not valid. The module does not report a value until Temperature_Task() has run at least once. |
/* returns the value */ | Returns TEMPERATURE_REQUEST_OK if a valid value is available, TEMPERATURE_REQUEST_ERROR if the task has not run yet. |
/* value */ (для параметра) | Filtered temperature in tenths of a degree Celsius. Must not be NULL. Written only if the function returns TEMPERATURE_REQUEST_OK. |
Перша колонка повторює назву функції, яку читач і так бачить. Друга каже те, чого читач не бачить: одиницю виміру, допустимий діапазон, хто володіє пам'яттю, чи записується вихідне значення у випадку помилки, звідки можна викликати функцію, що треба зробити перед цим. Для embedded-інтерфейсу це також місце для інформації, яку небезпечно вгадувати: чи безпечно викликати з переривання, як довго функція може блокувати, чи може вказівник бути NULL.
Модуль у файлах платформи
Шаблони EmBi_Platform, з яких починається кожен модуль, мають однаковий скелет документації: заголовок файлу з \author, \file, \ingroup та \brief, а над кожною функцією блок з \brief, описом і \return. Шаблон скінченного автомата додає \pre для передумов і блок \par Used global variables зі списком змінних, які функція читає (in), записує (out) або й те, й інше (in,out). Це напрочуд корисний вид документації: потік даних функції в трьох рядках.
Увесь модуль - це одна група в Doxygen. \ingroup Temperature у кожному файлі каже, що файл належить модулю, а сама група визначається один раз, у port-файлі:
/**
* \author Mr.Nobody
* \file Temperature_Port.h
* \ingroup Temperature
* \brief Temperature module public functionality
*
* The only header that other modules include.
*/
/**
* \defgroup Temperature Temperature module
* \brief Measures the temperature and provides a filtered value.
*
* The module has to be initialized by Temperature_Init() and its
* Temperature_Task() has to be called periodically. The latest filtered value
* is read by Temperature_Get_Value().
*/
#ifndef TEMPERATURE_TEMPERATURE_PORT_H
#define TEMPERATURE_TEMPERATURE_PORT_H
#ifdef __cplusplus
extern "C" {
#endif
/* ============================== INCLUDES ================================== */
#include "Temperature_Types.h"
/* ========================= EXPORTED FUNCTIONS ============================= */
/**
* \brief Initializes the module.
*
* Clears the filter and marks the value as not valid. The module does not
* report a value until Temperature_Task() has run at least once.
*
* \pre Called once, before the first Temperature_Task().
*/
void Temperature_Init( void );
/**
* \brief Reads the sensor and updates the filtered value.
*
* Shall be called periodically from the main loop or from a scheduler, it
* takes about the same time on every call.
*
* \note Not safe to be called from an interrupt: the function is not reentrant.
*/
void Temperature_Task( void );
/**
* \brief Provides the latest filtered temperature.
*
* \param[out] value Filtered temperature in tenths of a degree Celsius. Must
* not be NULL. It is written only if the function returns
* \ref TEMPERATURE_REQUEST_OK.
*
* \return \ref TEMPERATURE_REQUEST_OK if a valid value is available,
* \ref TEMPERATURE_REQUEST_ERROR if the task has not run yet.
*/
temperature_RequestState_t Temperature_Get_Value( temperature_Value_t * const value );
#ifdef __cplusplus
}
#endif
#endif /* TEMPERATURE_TEMPERATURE_PORT_H */
Типи мають короткий опис самого типу та кожного значення (коментар /**< ... */ після члена документує цей член у тому самому рядку):
/**
* \author Mr.Nobody
* \file Temperature_Types.h
* \ingroup Temperature
* \brief Temperature module global types definition
*
* This file contains the types that are used across the module and are
* available for other modules through the port file.
*/
#ifndef TEMPERATURE_TEMPERATURE_TYPES_H
#define TEMPERATURE_TEMPERATURE_TYPES_H
/* ============================== INCLUDES ================================== */
#include <stdint.h>
/* ============================== TYPEDEFS ================================== */
/** Temperature in tenths of a degree Celsius, e.g. 253 means 25.3 degC. */
typedef int16_t temperature_Value_t;
/** Enumeration used to signal the result of a request. */
typedef enum
{
TEMPERATURE_REQUEST_ERROR = 0u, /**< The request failed, the output is not valid */
TEMPERATURE_REQUEST_OK /**< The request succeeded */
} temperature_RequestState_t;
#endif /* TEMPERATURE_TEMPERATURE_TYPES_H */
Генерація
Doxygen налаштовується файлом з кількома сотнями опцій. Писати його вручну - помилка, якої легко уникнути: платформа має цей файл як шаблон (Doxyfile.in в артефакті Doxygen) і заповнює його зі змінних CMake, а проєкти задають лише ті, що відрізняються від типових. Те саме можливо й за допомогою власного модуля CMake, і ось уся конфігурація прикладу:
cmake_minimum_required(VERSION 3.19)
project(DoxDemo C)
find_package(Doxygen REQUIRED dot)
set(DOXYGEN_PROJECT_NAME "Temperature module")
set(DOXYGEN_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/docs)
set(DOXYGEN_OPTIMIZE_OUTPUT_FOR_C YES)
set(DOXYGEN_FULL_PATH_NAMES NO)
set(DOXYGEN_GENERATE_LATEX NO)
set(DOXYGEN_QUIET YES)
# The documentation is a contract: a missing description is an error.
set(DOXYGEN_WARN_IF_UNDOCUMENTED YES)
set(DOXYGEN_WARN_IF_DOC_ERROR YES)
set(DOXYGEN_WARN_NO_PARAMDOC YES)
set(DOXYGEN_WARN_AS_ERROR FAIL_ON_WARNINGS)
# Graphs (the Graphviz artifact in the platform)
set(DOXYGEN_HAVE_DOT YES)
set(DOXYGEN_CALL_GRAPH YES)
set(DOXYGEN_CALLER_GRAPH YES)
set(DOXYGEN_DOT_IMAGE_FORMAT svg)
doxygen_add_docs(docs Temperature COMMENT "Generating the documentation")
а збірка - це одна ціль:
cmake -S . -B build -G Ninja
cmake --build build --target docs
Результат - тека зі сторінками HTML. Група модуля з файлами, що до неї належать, та описом із port-файлу виглядає так:

Doxygen також малює граф включень (include graph) кожного файлу, а також граф викликів (call graph) і граф викликачів (caller graph) кожної задокументованої функції (HAVE_DOT, Graphviz: ось чому платформа має для нього артефакт). На графи включень варто час від часу поглядати: стрілка від файлу застосунку до внутрішнього заголовка модуля - це та сама проблема, яку описує стаття про організацію файлів, лише побачена згори.
Документація як тест
Саме ця частина змінює дисципліну. Чотири опції перетворюють відсутню документацію із «варто б» на «збірка червона»:
set(DOXYGEN_WARN_IF_UNDOCUMENTED YES) # a public member without a description
set(DOXYGEN_WARN_IF_DOC_ERROR YES) # a wrong tag, a parameter that does not exist
set(DOXYGEN_WARN_NO_PARAMDOC YES) # a function whose parameters are not described
set(DOXYGEN_WARN_AS_ERROR FAIL_ON_WARNINGS)
З FAIL_ON_WARNINGS Doxygen проходить усе, виводить усі попередження й лише потім завершується з помилкою (простий YES зупиняється на першому). Я спробував: видалив опис Temperature_Task() та \param у Temperature_Get_Value() з port-файлу, і збірка відповіла:
Temperature/Temperature_Port.h:40: error: Member Temperature_Task(void) (function) of file Temperature_Port.h is not documented.
Temperature/Temperature_Port.h:45: error: parameters of member Temperature_Get_Value are not documented
ninja: build stopped: subcommand failed.
(Код завершення команди - 1, тож CI червоний.) Коли описи повернуто, та сама збірка завершується без жодного виводу й з кодом 0. Нова функція без коментаря тепер не може випадково потрапити в головну гілку, і нікому не треба бути тим, хто пам'ятає про це на рев'ю.
Що містить добра документація інтерфейсу
Для кожної функції публічного інтерфейсу перевірте список:
- Що вона робить - одним реченням, із дієсловом (
\brief). - Параметри: напрямок (
[in],[out],[in,out]), одиниця виміру, діапазон, чи дозволеноNULL. - Значення, що повертається: кожне можливе значення та що воно означає.
- Передумови (
\pre): що треба зробити раніше, наприклад ініціалізацію. - Контекст: чи можна викликати з переривання, чи блокує, скільки часу виконується.
- Побічні ефекти: що ще вона змінює (глобальний стан, периферію).
Якщо функція не має відповіді на одне з цих запитань, відповідь лежить у коді, і читач мусить його читати. Це ознака того, що інтерфейс не завершений, а вправа з написання коментаря часто й виявляється моментом, коли ви це помічаєте.
Де це вписується в платформу
Кожен модуль платформи має в своєму CMakeLists.txt виклик, який реєструє його теку для документації (коли артефакт Doxygen доступний у проєкті), а кореневий білд має опцію DOXYGEN_ENABLED. Отже, документація всього firmware генерується тією самою збіркою, що й firmware: -DDOXYGEN_ENABLED=ON. Артефакти (Doxygen і Graphviz) завантажуються у версії, яку зафіксував проєкт, тому сторінки сьогодні й наступного року виглядають однаково.
Чекліст
- Кожна публічна функція, тип і макрос має опис. Внутрішні - лише там, де не очевидно.
- Опис каже те, чого читач не бачить у сигнатурі: одиниці, діапазони, контекст, помилки.
- Одна група на модуль (
\defgroupу port-файлі,\ingroupв інших). WARN_IF_UNDOCUMENTED,WARN_NO_PARAMDOCіFAIL_ON_WARNINGSу CI.- Графи ввімкнені, і хтось час від часу дивиться на граф включень застосунку.
- Документація генерується тією самою збіркою, що й firmware, із зафіксованими інструментами.