Функции
Простейшая программа на Си состоит минимум из одной функции — функции main
.
int main(void) {
return 0;
}
У каждой функции есть название, тип возвращаемой переменной и список аргументов. В нашем случае аргументы отсутствуют, поэтому в скобках после имени записан тип void
. Сама функция возвращает целочисленное значение, в нашем случае 0
. По договоренности 0
означает правильное выполнение программы. Это не имеет значения в случае с микроконтроллером, но имеет смысл в случае операционной системы: с помощью этого возвращаемого значения система может реагировать на результат выполнения.
Создадим функцию led_toggle()
, которая будет мигать определенным светодиодом с заданной задержкой. В качестве аргументов принимается перечисление LED_t
(указывающее, каким светодиодом надо мигать) и задержка в миллисекундах ms
.
void led_toggle(LED_t led_num, uint16_t ms) { /* */ }
Функция ничего не возвращает, а значит, и return
не требуется. Тем не менее, его можно использовать. Например, можно добавить условие в начале: если задержка больше или равна 10 секундам, то выполнение функции следует прекратить.
if (ms >= 10000)
return;
В этом случае return
завершает работу функции и не возвращает никакого значения.
Если мы хотим вызвать данную функцию, то она должна быть либо создана до функции main()
, либо должен быть создан прототип функции.
// Можно не указывать названия переменных, так как имеет
// значение только их тип и порядок
void led_toggle(LED_t, uint16_t); // <- prototype
// ...
int main(void) {
led_toggle(LED_GREEN, 1000);
return 0;
}
void led_toggle(LED_t led, uint16_t ms) {
// some code here
}
Обычно прототипы функций помещают в заголовочный файл, особенно тех функций, которые будет использоваться в других модулях. Мы говорили об этом, когда проходили процесс компиляции.
В Си нет классов, но если проводить аналогии с С++, то прототипы в заголовочном файле — это публичные функции класса (модуля), а прототипы внутри исходного файла — приватные.
Внимание стоит обратить на то, что все переменные передаются по значению, а не по адресу. Когда вы вызываете функцию led_toggle(LED_GREEN, d)
, где d
— это переменная, отвечающая за задержку, то в самой функции создается ее локальная копия, и, меняя ее значение в функции, вы не меняете значение вне функции. Для того чтобы изменять передаваемую переменную, необходимо передавать указатель на нее.
void led_toggle(LED_t led, uint16_t *ms);
// ...
led_toggle(LED_GREEN, &d);
При этом в самой функции вам придется разыменовывать указатель, чтобы получить значение. Массивы же передаются по указателю, поэтому нужно работать с ними аккуратно. Если вы хотите явно запретить изменять содержимое по ссылке, то стоит использовать ключевое слово const
в аргументе.
void led_toggle(LED_t led, const uint16_t *ms);
При попытке изменить содержимое компилятор выдаст ошибку. При помощи всё того же слова можно запретить изменять сам указатель.
void led_toggle(LED_t led, uint16_t *const ms);
Рекурсивный вызов
Язык Си позволяет функции вызывать саму себя. Такой вызов называется рекурсивным (англ. recursion). Рекурсия — весьма полезный механизм для решения некоторых задач. Допустим нам нужно рассчитать факториал числа. Математически его можно представить следующим образом:
Попробуйте зайти в google.com и набрать слово «recursion». Поисковая система предложит исправить запрос на слово «recursion», что является шуткой.
n! = 1 · 2 · … · n = ∏k=1n k
Очевидным способом реализовать функцию для подсчета факториала является использование цикла.
uint32_t factorial(uint32_t n) {
uint32_t result = 1;
for (uint32_t i = 1; i <= n; i++)
result = i * result;
return result;
}
Данную функцию можно переписать рекурсивно.
uint32_t factorial(uint32_t n) {
if(n == 1)
return 1;
return factorial(n - 1) * n;
}
Рассмотрим поведение рекурсивной функции при аргументе 3
. При первом вызове n
не равно 1
, значит, будет вызвана функция с аргументом на единицу меньше, т.е. 2
. При проверке снова окажется, что 2
не равно 1
, поэтому функция будет вызвана еще раз с аргументом 1
. Внутри третьего вызова окажется, что аргумент равен 1
, поэтому вернется 1
, т.е. далее рекурсия начнет схлопываться. Во втором вызове в переменную result
запишется 1 * n
, где n
будет равно 2
, и вернется 2
в первый вызов функции, где 2
будет умножено на n
, которая там равняется 3
. Таким образом в месте первого вызова мы получим число 6
, которое и является ответом.
Когда мы говорили о стеке, то упомянули, что его можно переполнить. Даже если функция написана правильно, при достаточно глубокой рекурсии стек может быть переполнен, и программа завершится с ошибкой, уйдя в обработчик исключительной ситуации. По этой причине во встраиваемых системах лучше отказываться от подобных реализаций алгоритмов либо ограничивать глубину рекурсии заранее.
Модификаторы функции
В стандарте c99
появился модификатор функции inline
. Мотивом к его внедрению послужило желание ускорить программы, написанные на Си. Дело в том, что при вызове функции идет работа со стеком — туда складываются адреса возврата, аргументы и т.д. В англоязычной литературе это называется overhead, в переводе «накладные расходы». Проще говоря, следуя декомпозиции, программист должен выделять в небольшие функции законченную функциональность, которая в коде используется несколько раз. Это пример хорошего тона. Однако, выделяя куски кода в функцию, мы теряем в производительности. Особенно обидно, когда функция вызывается часто, а выполняет какую-то маленькую, утилитарную операцию. Ключевое слово inline
подсказывает компилятору (он может не согласиться с мнением программиста), что для ускорения программы стоит подставить эту функцию на место ее вызова. Это чем-то похоже на макрос define
.
Во-первых, GCC, если отключены оптимизации (ключ
-O0
), не будет обращать внимание на ключевое словоinline
совсем. Для того чтобы принудить компилятор в любом случае встроить функцию, следует при её объявлении в конце добавить атрибут__attribute__((always_inline))
. Во-вторых, если вы включите оптимизацию по размеру (ключ-Os
), а ваша функция чересчур тяжёлая, то компилятор примет решение не вставлять её.
inline uint32_t max_value(uin32_t a, uint32_t b) {
return (a >= b) ? a : b;
}
Встроенная функция всегда должна работать с функциями и переменными, объявленными внутри модуля (т.е. иметь внутреннюю линковку), в противном случае компилятор выдаст ошибку.
Функция к которой применён модификатор static
будет иметь внутреннюю линковку, т.е. её нельзя будет вызвать из другого модуля. При этом, вы можете нарушать правило однократного объявления, т.е.:
// main.c
void read_data(void) { /* ... */ }
// esp8266.h
static void read_data(void) { /* ... */ }
Скомпилируется, в то время как
// main.c
void read_data(void) { /* ... */ }
// esp8266.h
void read_data(void) { /* ... */ }
выдаст ошибку на этапе линковки.
Если функция расположена в другом модуле, то ее, так же как и глобальную переменную, можно объявить при помощи ключевого слова extern
.
extern uint32_t get_value(void);
В таком случае нет необходимости подключать заголовочный файл модуля. Компоновщик самостоятельно подставит вместо метки, которая была оставлена внутри объектного файла, адрес функции, объявленной в другом модуле. Такое часто можно увидеть при использовании функций из объектных файлов.
Переменное число аргументов
Приводимые ранее функции имели фиксированное количество аргументов, однако, в некоторых случаях, заранее может быть не известно их количество. Функция printf()
будет рассмотрина позже.
printf("Just a text\n");
printf("Total number of channels is %d\n", n_channels);
printf("Current distance: %4.2f m\nAverage distance: %4.2f m\n", distance, avg_distance);
В Си нет перегрузки, то есть нельзя создать несколько функций с одинаковым именем, но разными аргументами… да и предугадать все возможные комбинации заведомо провальная идея. Тем не менее, Си позволяет написать функцию с произвольным количеством аргументов (англ. variadic function), через троеточие (...
). Но как в таком случае получить доступ к ним? Нам известно, что аргументы передаются по значению, т. е. внутри функции создаются локальные копии переменных, которые в свою очередь складываются на стек. Следовательно, зная адрес хотя бы одного аргумента, мы интегрируясь по памяти, можем обработать их все. Небольшой пример:
void func(uint32_t a, uint32_t b, uint32_t c) {
printf("%d == %d\n", a, *(&a - 0));
printf("%d == %d\n", b, *(&a - 1));
printf("%d == %d\n", c, *(&a - 2));
}
// somewhere in the code
func(1, 2, 3);
Для компилятора GCC с уровнем оптимизации -O0
вывод будет следующим:
1 == 1
2 == 2
3 == 3
Здорово! Но… Во-первых, при любом уровне оптимизации отличным от -O0
, такой фокус может, и, скорее всего, не сработает. А во-вторых, поведение ...
не регламентируется стандартом и значения будут лежать не там, где вы ожидаете.
По факту, переменные не обязательно ложить на стек, а можно сразу подгружать в регистры ядра. Правила вызова функций определяется бинарным интерфейсом, конкретно для компилятора GCC для ARM он называется Embedded Application Binary Interface, EABI.
// -O1
void func(uint32_t a, ...) {
printf("%d\n", *(&a - 0));
printf("%d\n", *(&a - 1));
printf("%d\n", *(&a - 2));
}
// output
1
0
0
Следующая проблема с нефиксированным количеством аргументов банальна и очевидна: кто знает сколько аргументов было передано в действительности? Самый простой способ разрешить незадачу — указать напрямую.
void func(uint32_t n, ...);
Это не очень удобно, легко ошибиться. printf()
, например, решает ту же самую проблему через маркеры в строке, которую вы передаёте (а точнее указатель на неё) в качестве аргумента:
void printf(const char *format, ...);
// somewhere in the code
printf("result is %f", 2.2f); // just one argument
// output
result is 2.2
Из работы с printf()
вы уже видите ещё одну проблему – можно передать абсолютно любой тип данных, но внутри функции он не известен. К слову, по историческим причинам все типы уже int
расширяются до его размеров, а float
расширяется до double
. Как раз именно поэтому, не важно, что вы передадите в printf("%f", var)
— 2.2
или 2.2f
, это все равно будет double
на той стороне.
Итого, как же быть? Работать с переменным количеством аргументов следует через стандартную библиотеку, а именно, через заголовочный файл <stdarg.h>
. В нём определён специальный тип данных va_list
для работы c ...
и несколько макросов (вызывающие функции вашего компилятора):
va_start(va_list ap, paramN)
— инициализирует работу, принимаетva_list
и последний известный аргумент; по окончанию работы со списком необходимо вызватьva_end()
;va_arg(va_list ap, type)
— возвращает очередной аргумент, принимаетva_list
и тип данных, напримерchar
;va_end(va_list ap)
— завершить работу с аргументамиv.
Макрос
va_end
подчищает работу сva_list
, если его забыть вызвать стек может быть повреждён.
Ниже приведён небольшой пример использования ...
для подсчёта среднего арифметического:
float average(int n, ...) {
double result = 0;
int index = n + 1;
va_list args;
va_start(args, n);
while(--index)
result += va_arg(args, double);
va_end(args);
return (float)(result / n);
}
// pass floats, that promoted to double
float avg = average(3, 2.2f, 2.4f, 2.6f);
А вот вопрос поинтереснее. Допустим вы хотите написать модуль логирования, который, в зависимости от настроек, отправляет заданную строку либо в UART, либо, например, на SD-карту, при этом вам, хотелось бы сохранить сигнатуру обычного printf
. Будете писать собственный парсер строки? К счастью, в стандартной библиотеке уже имеются специальные функции, которые могут принимать в качестве аргумент va_list
; у таких функций имеется префикс v
, например, vprintf()
или vsprintf()
.
#include <stdarg.h>
#define LOGGER_NONE 0
#define LOGGER_UART 1
#define LOGGER_STDO 2
#define LOGGER_PIPE LOGGER_UART
void logger(const char *format, ...) {
#if LOGGER_PIPE == LOGGER_UART
static char buffer[256];
static uint32_t len = 0;
#endif
va_list arg;
va_start(arg, format);
#if LOGGER_PIPE == LOGGER_STDO
vprintf(format, arg);
#elif LOGGER_PIPE == LOGGER_UART
len = vsprintf(buffer, format, arg);
HAL_UART_Transmit(&huart1, buffer, len, 1000); // printf("%s", buffer);
#else
#warning "Logger is turned off"
#endif
va_end(arg);
}
С такими функциями связана одна уязвимость, uncontrolled format string, позволяющая злоумышленнику получить доступ к стеку, менять значение переменных или, что ещё хуже, вызывать другой код.
Обобщённые макросы
В языке Си функции нельзя перегружать (англ. overload) — иметь несколько функций с одинаковыми названиями, но разным поведением (в основном, из-за типа или количества принимаемых аргументов). В стандарте c11
появилось новое ключевое слово _Generic
, которое позволяет создавать «обобщённые» макросы.
Решение довольно странное и очень неудобное. Если в функции переменное количество аргументов, то
_Generic
вам не поможет. Плюс ко всему, в отличие от перегрузки, где компилятор автоматически генерирует нужное количество функций с нужными типами, в Си вам придётся прописывать их вручную. По всей видимости, введение_Generic
-макросов было нужно для упрощения работы сmath.h
. Например, для расчёта арккосинуса, в зависимости от принимаемого аргумента, нужно было вызвать одну из шести функций. Теперь, подключаяtgmath.h
, можно про это забыть и вызывать толькоacos()
.
_Generic( control-expression , generic-assoc-list );
Данное ключевое слово позволяет сделать выбор на этапе компиляции. Макрос ниже позволяет «выяснить» тип переменной:
#define get_type(var) \
_Generic((var), \
int: "int", \
char: "char", \
double: "double" \
// ...
)
Рассмотрим другой пример. Допустим, нужно сравнивать (узнать, какой «старше») какие-нибудь ID, причём они могут быть как целочисленными, так и строчными.
uint32_t max_int(uint32_t a, uint32_t b) {
return (a > b ? a : b);
}
char* max_string(char* a, char* b) {
return (strcmp(a, b) > 0 ? a : b); // function strcmp() is from <string.h>
}
Лучше написать обобщённый макрос и работать с ним, чем каждый раз вспоминать, что нужно добавить к имени функции, чтобы код скомпилировался.
#define MAX(X, Y) \
((_Generic((X), \
int: max_int, \
char*: max_string))(X,Y) \
)
// ...
MAX(0xFF, 0xAA); // will return '0xFF'
MAX('FF', 'AA'); // will return 'FF'