На самом деле тема довольно интересная, разнообразная и творческая. И тем не менее, хватает людей, даже уже не новичков, которые допускают ошибки или просто не самым лучшим способом используют возможности современной редакции языка. Бегло прочел посты - да, есть такой момент, в частности, в заталкивании в .h-файлы всего того, что там быть не должно. Конечно, чистый язык Си (без ++) не назовешь модульным, в нем модульность если и есть, то через "одно место". Но тем не менее, раз хоть какая-то модульность есть, то её можно и нужно использовать.
Ключевое слово к модульности в Си -
static. Именно с его помощью блокируется глобальная видимость функций и переменных.
Но обо всём по порядку. (кто всё это уже давно знает - можете не читать, ибо че еще раз снова одно и то же).
Вначале конечно сложно понять принцип модульности, но если разобраться - всё получается настолько удобным, что потом не хочется отказываться от модульности.
Модуль представляет собой некий код, который выполняет работу с каким-то функциональным узлом. Доступ к этому коду предоставляется только через функции взаимодействия с модулем. Внутренние функции и переменные модуля используются только кодом самого модуля и не должны быть доступны извне.
Пусть для первого примера это будет драйвер аппаратного SPI из микроконтроллера. Что требуется от такого драйвера? Первоначальная инициализация аппаратного SPI и отправка байтов (приём байтов пока не рассматриваем). Опционально - деинициализация (выключение) аппаратного SPI. Итак, доступ к этому модулю драйвера должен осуществляться извне только через вызов функций
SPI_Init(), SPI_TX_byte(byte), SPI_Deinit(). Причем, при инициализации можно как передать параметры инициализации, так и доверить установку их самим драйвером, конкретный выбор зависит от программы. В нашем примере пусть драйвер устанавливает параметры сам.
Итак, мы определились с тем, что нам нужно от модуля драйвера. Теперь будем писать сам модуль. Понятное дело, что исполняемый код функций помещается в .c-файле, назовем его
SPI_Drv.c и поместим его в папку с исходными кодами.
(забегая вперед, скажу, что можно вообще выделить отдельную папку Drivers и указать для нее в настройках компилятора, что она тоже содержит исходные коды. Так более правильно с точки зрения модульности)Но .c-файлы нельзя подключать директивой #include, а для всего остального кода будет недоступен текст из созданного нами файла. Значит, создаем .h-файл с таким же именем
SPI_Drv.h, кладем его туда же, где и файл кода драйвера и подключаем этот заголовочный файл (с указанием полного пути, если он лежит в другой папке) к файлу main.c, прописав вначале его строчку
#include "SPI_Drv.h" (либо
#include "../Drivers/SPI_Drv.h").
Теперь в .h-файле прописываем прототипы функций, дающие доступ к коду модуля:
Код:
void SPI_Init(void);
void SPI_TX_byte(unsigned char byte);
void SPI_Deinit(void);
Прототипы функций являются связующим звеном между вызовом функции и ее телом.
Теперь идем в .c-файл и пишем "заглушки" функций, т.е. некие функции-пустышки, ничего полезного не делающие, но зато показывающие структуру связей (кстати, это очень полезно вообще при написании своего кода).
Код:
/*-- Инициализация аппаратного SPI --*/
void SPI_Init(void)
{
}
/*-- Отправка байта по SPI --*/
void SPI_TX_byte(unsigned char byte)
{
}
/*-- Деинициализация (отключение) SPI --*/
void SPI_Deinit(void)
{
}
Вот, считаем, что заготовка нашего драйвера уже написана! можем проверить ее работу (в пошаговом режиме отладки, конечно же!), вызвав из main.c функции драйвера, вот так:
SPI_Init();Если вы всё делали внимательно и без ошибок, то ваш модуль будет работать. Можем вписать реальный код работы с реальным SPI и получить действующий драйвер.
Теперь второй акт балета
Самое главное в модульности. Внутренние функции модуля, используемые только самим модулем.
Например, в функции
SPI_Init может быть вызов функции Port_Init(), находящейся в том же файле. Это внутренняя функция, используемая только этим модулем драйвера. При обычном подходе эту функцию теоретически можно вызвать из любого места кода, из любого файла. Максимум, получите предупреждение компилятора, что функция не была объявлена в том файле, откуда вызывается. Но вызвана она будет и будет выполнена, иногда с косяками относительно параметров. А представим себе ситуацию, что таких функций с таким же именем - две. Компилятор уже выдаст ошибку - функция уже определена (написана) в таком-то месте, повторное определение невозможно.
Что делать? Очень просто! Пишем, что эта функция - статичная внутри этого файла и не имеет выхода наружу. т.е,
Код:
static void Port_Init(void)
{
}
и всё! Теперь мы можем вызывать ее только из того файла, в котором она есть. Сам вызов - точно так же, как и для любой другой функции. Прототип этой статичной функции тоже должен быть обхявлен как static и находиться должен в том же файле, но никак не в заголовочном .h-файле! Оно и понятно - статичная функция должна быть доступна только внутри одного файла, не подключаясь к другим.
Напомню, что прототип нужен, если функция будет вызвана по тексту ранее, чем ее тело (хм. как бы это попроще написать так, чтобы не усложнять терминами, потому что из-за терминов путаница то и есть.)
Проверяем, как это работает: в main.c пишем вызов
Port_Init(); и получаем ошибку, что "референс такой то функции не найден". Вуаля! Мы заблокировали глобальную видимость функций и теперь вольны использовать СТАТИЧНЫЕ функции с одинаковыми именами внутри разных модулей.
Да, именно на таком принципе написана операционная система FreeRTOS. Кстати, можно посмотреть, как оно там всё сделано. Хотя букафф там дофига, но зато как работает то.
Еще моменты по модульности.
Аналогичным образом поступаем и с переменными. Те переменные, которые используются только внутри модуля, прописываются либо внутри функций модуля (локальная переменная функции), либо прописывается как СТАТИЧНАЯ глобальная переменная модуля вне функций в .c-файле. Переменные, которые принимает и возвращает модуль, крайне желательно передавать только через параметры функций. Те же принципы относятся и к структурам, перечислениям, типам.
В основном, модуль должен работать как самостоятельная и самодостаточная единица, а другие участки кода не должны зависеть от него, не должны знать того, как он устроен, какие там есть переменные и функции. Всё общение с модулем - только через предоставленные им функции и параметры функций. Это позволяет легко заменять один модуль другим. Например, если бы в рассмотренном примере имена функций были бы
Periph_Init() и Transmitt(data), то модуль драйвера SPI можно было бы заменить на модуль драйвера UART, и главный код не заметил бы подмены.
Очень важно разобраться с правильным оформлением и включением переменных (равно как и структур, типов, объединений) в состав модуля. Ошибки в этом могут приводить к труднообъяснимому краху компиляции кода. Например, вы пишите какую-либо переменную в .h-файл модуля. Теперь подумайте - эта переменная будет объявлена как ГЛОБАЛЬНАЯ переменная для ВСЕГО кода, начиная от точки подключения. А попытка тут же инициализовать эту переменную может приводить к краху выделения памяти под нее. Однако, переменную по видимости можно заблокировать static-ом. Тогда она будет видна непосредственно в этом модуле и в модуле выше уровнем, к которому подключен исходный модуль.
Нужно очень внимательно отслеживать взаимосвязи и пределы видимости. Если действительно нужна такая переменная, которая будет глобально использоваться как в модуле, так и в main.c или другом файле, пропишите эту переменную в main.c или main.h.
Один модуль может включать в себя другие, подчиненные модули и взаимодействовать с ними. Это очень распространено.
Рассмотрим на примере некоего гипотетического устройства на интерфейсе SPI. Для этого возьмем написанный нами драйвер SPI и подключим его в качестве дочернего модуля в другой модуль, который теперь будет называться "драйвером устройства". По аналогии, создаем модуль для устройства - файлы
Device_Drv.c, Device_Drv.h, подключаем
Device_Drv.h к файлу main.c (предыдущее подключение SPI_Drv.h оттуда убираем!!).
Нашему модулю драйвера устройства потребуется уже написанный нами модуль драйвера SPI, подключаем его к файлу Device_Drv.
c. Заметьте структуру подключений. Драйвер SPI подключен только к драйверу устройства, а не к main.c, отчего доступ к SPI формально есть только у драйвера устройства. Формально! Поскольку драйвер SPI всё еще может быть подключен к любому другому модулю, так и напрямую к main.c. И это тоже логично, поскольку на физической шине SPI может сидеть несколько устройств.
Теперь аналогичным образом в файле Device_Drv.h запишем прототипы функций, через которые пойдет взаимодействие с нашим устройством в целом.
Код:
void Device_Init(void); // инит (запуск) устройства
void Device_Execution(unsigned char command_code, unsigned int param); // некоторое действие устройства
а в файле Device_Drv.c пишем эти функции, вот так:
Код:
/*-- инит (запуск) устройства --*/
void Device_Init(void)
{
SPI_Init(); // инит SPI-интерфейса
SPI_TX_byte(0x31); // отправка команды включения устройства
SPI_TX_byte(0x54); // отправка команды настройки параметров устройства
SPI_TX_byte(0xA5); // отправка параметра устройства
SPI_TX_byte(0x7C); // отправка параметра устройства
}
/*-- команда устройству --*/
void Device_Execution(unsigned char command_code, unsigned int param)
{
SPI_TX_byte(command_code); // отправка команды
SPI_TX_byte(param >> 8); // отправка параметра команды (старший байт)
SPI_TX_byte(param); // отправка параметра команды (младший байт)
}
Вот, таким образом мы создали полноценный модульный драйвер устройства, в котором прослеживается четкая иерархическая структура. Я не стал усложнять излишками в виде возврата кода ошибки и прочих наворотов типа проверки занятости, хотя в реальном, не учебном коде конечно это необходимо.
Теперь из главного файла мы можем работать с нашим устройством с помощью компактной короткой записи, и при этом, главный файл совершенно не перегружен фукнционалом и мы можем сосредоточиться лишь на последовательности команд устройству. Кстати, можно добавить еще один уровень вложенности (вернее, абстракции), переведя последовательности отправляемых команд в еще один модуль. И этот новый модуль теперь будет называться не уже драйвером, а будет относиться к классу Middleware - "средний уровень". Допустим, наше гипотетическое устройство - это управлялка манипулятором. Тогда в новый модуль мы включим функции, такие как например
Set_Home_Position(); Move_to_position(x, y); Taking_cargo(); Причем, этот модуль может включать в себя модули, работающие с датчиками положения манипулятора и обеспечивать обратную связь по координатам.
Такие абстракции от нижних уровней (которые уже были написаны и отлажены ранее) позволяет программисту сосредоточиться на решении непосредственно задачи в целом, не отвлекаясь на низкоуровневые операции. Да, такую же структуру можно создать и в одном файле, но представьте себе навигацию по такому файлу!
Еще немаловажный момент - использование
extern. С одной стороны это упрощает иерархию и убирает сложнозапутанные вкладывания и завязывания, с другой стороны делает модуль зависимым от внешнего кода. Но если пишешь некий комплект модулей, в которых есть заложенное ранее единообразие, то применение
extern порядком упрощает жизнь. Например, есть функция или переменная, которая может использоваться любым модулем. Например, функция задержки Delay(time), написанная в каком-то файле delay.c. Да, можно было сделать файл delay.h и подключать его ко всем .c-файлам. Но вдруг у этого файла изменится имя или расположение? Придется переписывать везде. Ну а если в каждый модуль прописать функцию, объявив ее внешней -
extern void Delay(volatile unsigned int) ? Теперь нам всё равно, где и в каком файле написано тело функции Delay, нам лишь важно, что она где-то есть и называется именно так и содержит именно такую структуру, как в прототипе.
Однако, если мы всё-таки забыли написать тело функции, компилятор выдаст ошибку. А представим себе, что таких функций может быть не одна, и нам заранее неизвестно, какие нужны?
На такой случай есть еще один инструмент -
слабосвязанные функции, weak. Это уже из свежей редакции языка Си.
Суть слабосвязанных функций такова. Допустим, нам нужна некоторая внешняя (вне нашего модуля) функция, реализующая что-то. Пока эта функция вне модуля есть (существует написанная), всё в порядке, всё работает. Но как только та функция исчезла (стерли мы ее нафик или не подключили еще), работать уже не будет - функции то нету. Чтобы избежать этого, нам нужна местная (внутри модуля) функция с таким же именем (хотя ее функционал может быть совершенно другим). В обычных условиях нам придется раскомментировать строки или закомментировать их обратно. Это не производительно.
И вот именно тут на помощь приходит weak-функция. Она точно такая же, но при ее написании указывается атрибут weak. Теперь всё работает автоматически. Нет внешней функции - работает внутренняя (которая может быть простой заглушкой), есть внешняя функция - работает уже именно внешняя, а внутренняя уже не доступна. Это хороший способ для построения межмодульных связей на этапе написания, когда несуществующие еще функции заменяются заглушками, не приводя к ошибкам компиляции.
Ну чтож, кто дочитал эту писанину до конца - тот молодец!
Те, кто уже давно всё это знает - тем более молодцы!
Короче говоря, выводы:
- в идеале, модули должны быть самодостаточными и не зависеть от внешнего кода. На практике не всегда это возможно, да и не всегда это нужно, но основной принцип именно такой.
- изоляция функций и глобальных переменных внутри модуля - с помощью квалификатора
static- заголовочный файл модуля .h может содержать только то, что модуль предоставляет наружу - прототипы наружных функций, переменные и структуры, предоставляемые модулем в качестве уникальных для модуля и допускаемых для использования во внешнем коде. Не прописывайте в заголовочном файле модуля глобальных переменных, не относящихся к модулю.
- модули могут подключаться в иерархической цепочке, вкладываясь друг в друга.
- применяйте extern для упрощения взаимосвязей в логически связанных модулях, но избегайте чрезмерного extern, чтобы не потерять концепции модульности
- отделяйте уровни абстракций модулей. Только лишь самый низкий уровень абстракции может непосредственно работать с периферией микроконтроллера. Остальные уровни должны быть аппаратно-независимыми. Верхний уровень должен выполнять общие задачи, типа "переместиться на 20 см влево". Промежуточные, "средние" модули могут работать с драйверами внешних (относительно микроконтроллера) устройств и являются так же в большей степени аппаратнонезависимыми. Это обеспечит переносимость кода и быструю его интеграцию в другие ваши проекты.
Сорри за крайне очень длинный текст
надеюсь, кому-то это может быть полезным. надеюсь, написанное достаточно понимаемо, постарался не усложнять