Кто-то любит пирожки, а кто-то - нет.
Код в виде длинной неразрывной простыни – не здорово. Потому посмотрим, как его разделять. По функциям и файлам.
Что же такое функция? Я бы сказал, что это такая маленькая вспомогательная программка, которая делает какое-то законченное действие. Ей можно передавать параметры, – например, числа, которые надо как-то перемножить или строку, которую надо куда-то вывести. Аргументов может и не быть, если действие какое-то конкретное. Например, сбросить какой-то таймер.
читать дальше
// Описание модуля – что в нём содержится.
// Описание модуля – что в нём содержится.
А ещё функция может возвращать значение. Или не возвращать, если оно не нужно. И да, никто не обязывает этим значением пользоваться.
Ну на этом лирика заканчивается и начинается более практическая часть:
<тип возвращаемого значения> <название функции>(<список аргументов>)
{
// А тут код, который что-то делает.
}
Начнём с возвращаемого значения. Им может быть любой тип: число, указатель, структура и всё такое. Но структуры лучше не передавать ни в аргументах, ни в результате. Для контроллеров это особенно накладно.
Если функция ничего не возвращает, то возвращаемое значение имеет тип void. И да, если у функции нет аргументов, то в списке аргументов пишем это же слово:
// Функция сбрасывает счётчик таймера в 0.
void timer_Reset(void)
{
// Тут код сброса таймера.
// Параметры функции не нужны, возвращать ей тоже нечего.
}
Каждую функцию хорошо бы сопровождать пояснительным комментарием сверху – что же она делает. И если там что-то сложное, то и как.
Вот пример объявления функции об опросе какого-то канала АЦП (то есть измерить напряжение на какой-то ножке):
// Функция возвращает результат измерения с заданного канала АЦП:
// Channel – номер канала (от 0 до 11)
uint16_t adc_GetValue(uint8_t Channel)
{
// Код измерения напряжения
}
В качестве типа для номера канала используется uint8_t - больше незачем. Так как номера от 0 до 12 туда вписываются. Тип результата выбирается такой, чтобы все значения могли быть в нём представлены. Например, тип uint16_t хорош для результатов измерения с не дифференциального входа (то есть который меряет от 0 до максимального значения) АЦП разрядности более 8, но не более 16.
По такому принципу типы аргументов и результата и подбираются.
Если функция значения не возвращает, то из любого места её можно выйти с помощью оператора return:
// Функция выставляет на заданном канале ЦАП напряжение
// пропорциональное величине Value.
// Channel – канал ЦАП (0 - 1)
// Value - величина напряжения (0 – 1023)
void dac_SetValue(uint8_t Channel, uint16_t Value)
{
// Правильно ли задали параметры:
if (Channel > 1)
{
// Номер канала не верен, выходим: так как такого канала
// нет физически, то в функции больше делать нечего
return;
}
if (Value > 1023)
{
// Слишком много просят – обрежем до корректной величины
// Это не критическая ошибка в аргументах, так что
// продолжим с этим.
Value = 1023;
}
// А тут код, который напряжение выставляет.
}
Если же функция что-то возвращает, то обязательно надо при любом выходе из функции указать что будет возвращаться. Иначе неопределённое поведение будет гарантировано.
// Функция возвращает максимальное значение из двух
uint8_t max(uint8_t A, uint8_t B)
{
if (A > B)
{
// Раз A больше, то вернём его
return A;
}
else
{
// Если же А меньше или равно B, тот вернём B.
return B;
}
}
Видно, что как бы выполнение ни происходило, функция обязательно то или иное число вернёт. И так всегда быть и должно.
Вызывать функцию мы можем после того, как её объявили, то есть после того, как компилятор, читая сверху-вниз код, её увидел и запомнил. Причём, не обязательно функция сама должна там присутствовать – достаточно описания функции – что она хочет и что вернёт (чтобы компилятор знал, как её в код-то встраивать и что проверять):
// Функция возвращает максимальное значение из двух
uint8_t max(uint8_t A, uint8_t B);
Точка с запятой после заголовка показывает, что это только описание – как аннотация к книжке, а сама реализация будет где-то ещё. Либо в этом же файле ниже, либо в каком-то другом, либо в библиотеке функций (я потом скажу, что это, если не забуду). Такие описания обычно размещают в самом начале файла, перед кодом – чтобы функции заведомо были компилятору известны.
С объявлением разобрались, теперь вызов функций. Тут тоже всё просто. Чтобы вызвать функцию, запишите её название, а в скобочках список аргументов в том порядке, как они требуются:
// На нулевом канале АЦП выставим напряжение, величиной 512 (из 1023).
dac_SetValue(0, 512);
Если нам надо получить результат вычисления функции, то всё то же самое, только функция может выступать как любая переменная, значение которой используется для расчётов:
// В переменной c будет храниться меньшее значение из a и b.
uint8_t c = min(a, b);
// Так же можно вызовы функции пихать в формулы:
uint8_t mm = min(a, b) + 10;
// Или использовать результат вычисления, как аргумент другой функции.
// Максимальное число среди переменных a, b, c.
uint8_t max3 = max(a, max(b, c));
Пока с функциями закончим.
Несмотря на то, что мы код разбили на законченные небольшие функции, при их большом количестве код будет плохо читаться – попробуй найди функцию в пять строчек в файле из двадцати тысяч строк. Не здорово. Потому надо проект разбивать на небольшие, и желательно, обособленные модули. Один из которых занимается АЦП, второй – ЦАП, третий ещё чем-то.
В любом почти проекте встречаются файлы с расширениями h (header, заголовочный файл) и c (файл с кодом). Рассмотрим их поподробнее.
Как я уже сказал, функциями можно пользоваться лишь после того, как компилятор о них узнал. А узнаёт он всё сверху вниз – от начала файла и к концу. И естественным образом (чтоб все вызываемые функции оказались выше тех мест, откуда их вызывают) функции бывает разместить весьма затруднительно, да и не надо. Для каждого модуля заводят по два файла: <название модуля>.h и <название модуля>.c.
Первый содержит в себе описания всех функций, макросов и типов, которые будут использоваться вне этого модуля – то есть в других. Выглядит это примерно так:
// Описание модуля – что в нём содержится.
// Например, модуль работы с АЦП.
// Здесь идут подключение библиотек, если таковые требуются
// с используемыми типами данных (stdint.h, stdbool.h и т.д.)
#include
// Потом специальная запись, чтобы содержимое файла всегда
// включалось в элемент компиляции только один раз. Я потом
// расскажу как это.
#ifndef _ADC_H // Название даётся с потолка и я его обычно
#define _ADC_H // делаю из имени файла “adc.h”, добавляя
// спереди черту и вместо точки её же. Ну и капс.
// Функция инициализации АЦП (настройка портов и всего прочего)
void adc_Init(void);
// Функция возвращает результат измерения с заданного канала АЦП:
// Channel – номер канала (от 0 до 11)
uint16_t adc_GetValue(uint8_t Channel);
#endif // Тут файл заканчивается.
// И постарайтесь оставлять одну пустую строку в конце (Enter’ом)
Как вы могли заметить, я предпочитаю называть функции согласно их принадлежности к модулю и функционального назначения:
adc_Init – можно прочитать как функция инициализации (Init) из модуля adc (adc.h).
dac_SetValue – можно прочитать как «Установить значение» (SetValue) из модуля dac (dac.h).
И так далее.
Второй файл содержит всю реализацию этих функций и не только. Примерное содержание файла adc.c:
// Описание модуля – что в нём содержится.
// Например, модуль работы с АЦП.
// Подключение библиотек.
// Сначала подключаем библиотеку именно от этого модуля:
#include "adc.h"
// Потом уже все остальные, нужные для реализации. Например,
#include
// Дальше объявляются внутренние типы, постоянные, макросы и функции:
// ...
// Потом переменные.
// ...
// Ну а в самом конце пишем реализацию всех наших функций.
// Желательно те функции, которые выписаны в заголовочном файле
// разместить рядом. Ну а все вспомогательные уже как удобно.
// Функция инициализации АЦП (настройка портов и всего прочего)
void adc_Init(void)
{
// Настройка портов, периферии и прочего.
}
// Функция возвращает результат измерения с заданного канала АЦП:
// Channel – номер канала (от 0 до 11)
uint16_t adc_GetValue(uint8_t Channel)
{
// Если канал неверный вернём 0
if (Channel > 11) return 0;
// Дальше меряем.
// ...
}
Сразу замечу – все заголовочные файлы из этого проекта пишутся в кавычках "adc.h". А все стандартные заголовочные файлы в угловых скобках: . Содержимое всех заголовочных файлов как бы впишется вместо этих строк: #include "…". То есть получится такой длиннофайл, который компилятор и будет обрабатывать.
В модуле могут быть различного рода вспомогательные функции, которые снаружи (из других модулей) никто не должен видеть. Такие функции объявляются точно так же, как и обычные, только не пишутся в заголовочном файле (а только сверху в фале с кодом) и имеют атрибут static:
// Функция измеряет заранее известное напряжение и вычисляет
// коэффициенты перевода числа в милливольты.
static void Calibrate(void);
Этот можификатор ограничивает область видимости только модулем, где функция расположена. В других модулях может быть и своя функция Calibrate, но при таком объявлении они не подерутся.
Теперь я расскажу, как, по моему мнению, стоит разбивать код программы на модули. Основной принцип я уже рассказал – по функциональному назначению. Все функции SPI – в модуль spi.h, I2C – в i2c.h и т.д.
Чем это хорошо?
Во-первых, этот код потом можно будет без особых проблем перенести на другой контроллер. Если там регистры настройки SPI немного не такие, что ж, нам потребуется лишь чуть-чуть подправить файл spi.c, не трогая остальные. Или вообще на другую платформу – с AVR на ARM. Тогда потребуется всего лишь переписать соответствующие функции периферии, в то время как логика работы останется той же.
Конечно, так далеко не всё возможно перенести. Контроллеры имеют много разной и уникальной периферии. Ну что ж, бывает и такое. Но всё же, чёткая и ясная структура проекта всегда будет полезна.
@темы: программизмы, C