Функции

Простейшая программа на Си состоит минимум из одной функции — функции 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'

Изменено:

Язык Си: 3 комментария

  1. >Вместо нуля может быть почти любой другой символ. Плюс и минус работают по-другому. Для выравнивания числа по по левому краю, а не по правому, перед числом нужно поставить знак минус.
    Лишне по

  2. Термин «регистр ядра» довольно специфичен. Звучит не привычно. А почему не использовать термин CPU или процессор или на худой конец микроконтроллер?

  3. Смысл статических переменных вне функции мне кажется не раскрыт. Такой модификатор делает переменную недоступной из других единиц трансляции. Т.е. не будет конфликта имен между разными .с файлами.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.