Функции

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

Изменено:

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

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

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

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

    0

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

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

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