Переменное количество аргументов функции в Си

Некоторые стандартные функции в Си принимают любые аргументы, которые вы только им подсунете. Самый известный пример такой функции — всевозможные *\*printf и **scanf. Вы задумывались, как это сделано? Или даже хотите сделать так сами?

Ответ на ваши вопросы — макросы variable arguments из заголовочного файла stdargs.h. Они предоставляют вам набор аргументов функции как непрерывный список, из которого можно последовательно считывать все переданные аргументы.

Вызов функции в ассемблере

Но для начала вспомним, как устроен вызов функции и передача ей аргументов. Любой вызов функции типа void some_func(param1, param2, param3) будет скомпилирован в такой ассемблерный код:

push param3
push param2
push param1
call some_func

Инструкция push помещает аргумент на вершину стека и продвигает указатель назад по памяти. После выполнения трёх push-ей в стеке аргументы будут лежать в «естественном» порядке: param1, param2, param3. В отдельном регистре процессора будет лежать указатель на первый параметр.

Инструкция call вызывает функцию some_func — переносит туда указатель выполнения. Функция начинает читать свои аргументы, используя инструкцию pop, которая поочерёдно вытащит из стека все параметры, начиная с первого (конечно, нужно учесть длину каждого аргумента).

Макросы variable arguments

Эти макросы работают несколько иначе. Они рассматривают весь стек как набор байт, из которого можно читать любое количество байт с любым смещением, и таким образом спозиционироваться точно по границе параметров, исходя из их длины.

  • Обёртка над всем процессом — переменная типа va_list, что-то вроде хендла аргументов.
  • Макрос va_start инициализирует процесс — устанавливает указатель чтения на начало стека.
  • Макрос va_arg принимает переменную va_list и тип читаемой переменной. Он читает с текущего положения sizeof(тип) байт, переводит этот набор байт в нужный тип и возвращает прочитанное значение нужного типа. После этого он передвигает указатель чтения вперёд на количество прочитанных байт.
  • Макрос va_end на большинстве архитектур не делает ничего, и служит просто для явного, видимого завершения процесса.

Таким образом, для чтения аргументов вам нужно знать их тип. Всё это можно сделать так:

void some_func(unsigned int num, ...)
{
char data;
va_list args;
va_start(args, num);
for(; num; num--)
data = va_arg(args, char);
va_end(args);
}

Обратите внимание на такой момент: мы не знаем, где находится конец стека. Мы просто начинаем сначала и идём порциями по sizeof(тип) байт, и не можем узнать что данные уже закончились. Поэтому обязательно нужно как-то передать функции количество параметров, явно или неявно.

В примере я передаю только char, и первым же аргументом явно сообщаю функции их количество. Теперь она знает, когда нужно будет остановиться.

В функциях вроде printf/scanf количество и тип аргументов зашифрованы в форматной строке, и когда они видят строку типа «%d %lf» они понимают, что сначала нужно прочесть 4 байта int, а потом 8 байт double, и на этом остановиться.

«Транзитный» макрос

Есть среди методов var_args ещё и такой макрос, который содержит в себе все аргументы, и его можно подставлять в другие макросы — \_VA_ARGS\_

Я использую его в функции DEBUG_PRINTF для вывода информации в telnet-консоль.

Безопасность

Эта возможность очень удобна, но таит в себе потенциальный источник проблем — в отличие от классического метода вызова функции, вы отдельно задаёте количество/тип аргументов, и отдельно передаёте сами аргументы. Здесь немудрено ошибиться.

Если ошибка будет в меньшую сторону — то ничего особенно страшного, вы просто недочитаете аргументы и функция недополучит параметры. Тут уже зависит от самой функции, как она поведёт себя при этом, но может и не упасть и продолжить работать.

А вот ошибка в большую сторону — это реальная проблема: вы начинаете читать не свой стек.

Поэтому стандарт MISRA C прямо запрещает использование variable arguments. Но если следить за этим — то всё окей.