В одном из интервью меня спросили, будет ли полезно использование указателей функций (с точки зрения скорости) при написании кода для встраиваемых систем? Я понятия не имел о встроенной системе, поэтому не мог ответить на вопрос. Просто туманный или расплывчатый ответ. Итак, каковы реальные преимущества? Скорость, читабельность, обслуживание, стоимость?
Указатели функций во встроенных системах, полезны ли они?
Ответы (10)
Я думаю, что, возможно, ответ Вирена Шакья упускает из виду тот момент, который пытался выявить интервьюер. В некоторых конструкциях использование указателя на функцию может ускорить выполнение. Например, если у вас есть индекс, его использование для индексации массива указателей на функции может быть быстрее, чем большой переключатель.
Однако если вы сравниваете вызов статической функции с вызовом через указатель, то Вирен прав, указывая, что существует дополнительная операция для загрузки переменной указателя. Но никто разумно не пытается использовать указатель на функцию таким образом (просто в качестве альтернативы прямому вызову).
Вызов функции через указатель не является альтернативой прямому вызову. Итак, вопрос о «преимуществе» некорректен; они используются в различных обстоятельствах, часто для упрощения другой логики кода и потока управления, а не просто для того, чтобы избежать вызова статической функции. Их полезность заключается в том, что определение вызываемой функции выполняется динамически во время выполнения вашим кодом, а не статически компоновщиком. В этом смысле они, конечно, полезны во встроенных системах, но не по какой-либо причине, связанной конкретно со встроенными системами.
Есть много применений.
Самое важное использование указателей функций во встроенных системах — это создание векторных таблиц. Многие архитектуры MCU используют таблицу адресов, расположенную в NVM, где каждый адрес указывает на ISR (процедуру обслуживания прерываний). Такая таблица векторов может быть записана на C как массив указателей на функции.
Указатели функций также полезны для функций обратного вызова. В качестве примера из реального мира, на днях я писал драйвер для встроенных часов реального времени. На чипе были только одни часы, а мне нужно было много таймеров. Это было решено путем сохранения счетчика для каждого программного таймера, который был увеличен прерыванием часов реального времени. Тип данных выглядел примерно так:
typedef struct
{
uint16_t counter;
void (*callback)(void);
} Timer_t;
Когда аппаратный таймер совпадал с программным таймером, вызывалась функция обратного вызова, указанная пользователем, через указатель функции, хранящийся вместе со счетчиком. Нечто подобное приведенному выше является довольно распространенной конструкцией во встраиваемых системах.
Указатели функций также полезны при создании загрузчиков и т. д., когда вы будете писать код в NVM во время выполнения, а затем вызывать его. Вы можете сделать это с помощью указателя на функцию, но не с помощью связанной функции, так как кода на самом деле нет во время компоновки.
Указатели функций, конечно, как уже упоминалось, полезны для многих оптимизаций, таких как оптимизация оператора switch, где каждый «случай» является соседним числом.
Еще одна вещь, которую следует учитывать, это то, что этот вопрос будет хорошей возможностью продемонстрировать, как вы принимаете дизайнерские решения в процессе разработки. Один ответ, который я могу себе представить, будет развернуться и рассмотреть, каковы ваши альтернативы реализации. Взяв страницу из ответов Кейси и Лундина, я обнаружил, что функции обратного вызова очень полезны для изоляции моих модулей друг от друга и упрощения изменений кода, потому что мой код находится на стадии постоянного прототипирования, и все меняется быстро и часто. Что меня сейчас беспокоит, так это простота разработки, а не скорость.
В моем случае мой код обычно включает в себя наличие нескольких модулей, которые должны сигнализировать друг другу, чтобы синхронизировать порядок операций. Ранее я реализовал это как множество флагов и структур данных с внешней связью. С этой реализацией две проблемы обычно поглощали мое время:
- Поскольку любой модуль может обращаться к внешним переменным, я много времени трачу на контроль каждого модуля, чтобы убедиться, что эти переменные используются по назначению.
- Если другой разработчик вводил новый флаг, я ловил себя на том, что копаюсь в нескольких модулях в поисках оригинального объявления и (надеюсь) описания использования в комментариях.
С функциями обратного вызова эта проблема исчезает, потому что функция становится сигнальным механизмом, и вы пользуетесь следующими преимуществами:
- Взаимодействие модулей обеспечивается функциональными интерфейсами, и вы можете проверить предварительные/пост-условия.
- Меньшая потребность в глобально общих структурах данных, поскольку обратный вызов служит интерфейсом для внешних модулей.
- Уменьшенная связанность означает, что я могу относительно легче менять код.
На данный момент я возьму на себя удар по производительности, поскольку мое устройство по-прежнему работает адекватно даже со всеми дополнительными вызовами функций. Я рассмотрю свои альтернативы, когда эта производительность станет более серьезной проблемой.
Возвращаясь к вопросу с собеседования, даже если вы, возможно, не так хорошо разбираетесь в тонкостях указателей функций, я думаю, вы все равно будете ценным кандидатом, зная, что вы осведомлены о компромиссах, сделанных в процессе проектирования.
Вы выигрываете в скорости, но теряете в удобочитаемости и обслуживании. Вместо дерева если-то-иначе, если a, то fun_a(), иначе, если b, то fun_b(), иначе если c, то fun_c(), иначе fun_default(), и делать это каждый раз, вместо этого, если a, то fun =fun_a, иначе если b, то fun=fun_b и т. д., и вы делаете это один раз, с этого момента просто вызывайте fun(). Намного быстрее. Как уже отмечалось, вы не можете встроить, что является еще одним приемом скорости, но встраивание в дерево if-then-else не обязательно делает его быстрее, чем без встраивания, и, как правило, не так быстро, как указатель функции.
Вы немного теряете читабельность и обслуживание, потому что вам нужно выяснить, где установлен fun(), как часто он меняется, если вообще когда-либо, убедитесь, что вы не вызываете его до его настройки, но это все еще одно имя для поиска, которое вы можете использовать для поиска и поддерживать все места, где он используется.
По сути, это трюк со скоростью, позволяющий избежать деревьев «если-то-иначе» каждый раз, когда вы хотите выполнить функцию. Если производительность не критична, то fun() может быть статическим и содержать в себе дерево if-then-else.
РЕДАКТИРОВАТЬ Добавление нескольких примеров, чтобы объяснить, о чем я говорил.
extern unsigned int fun1 ( unsigned int a, unsigned int b ); unsigned int (*funptr)(unsigned int, unsigned int); void have_fun ( unsigned int x, unsigned int y, unsigned int z ) { unsigned int j; funptr=fun1; j=fun1(z,5); j=funptr(y,6); }
Компиляция дает это:
have_fun: stmfd sp!, {r3, r4, r5, lr} .save {r3, r4, r5, lr} ldr r4, .L2 mov r5, r1 mov r0, r2 mov r1, #5 ldr r2, .L2+4 str r2, [r4, #0] bl fun1 ldr r3, [r4, #0] mov r0, r5 mov r1, #6 blx r3 ldmfd sp!, {r3, r4, r5, pc}
Я предполагаю, что Клиффорд говорил о том, что прямой вызов, если он достаточно близок (в зависимости от архитектуры), является одной инструкцией.
bl fun1
Где указатель на функцию будет стоить вам как минимум два
ldr r3, [r4, #0] blx r3
Я также упомянул, что разница между прямым и косвенным является дополнительной нагрузкой, которую вы несете.
Прежде чем двигаться дальше, стоит упомянуть плюсы и минусы инлайнинга. В случае ARM, который используется в этих примерах, соглашение о вызовах использует r0-r3 для входящих параметров функции и r0 для возврата. Таким образом, вход в функцию have_fun() с тремя параметрами означает, что у r0-r3 есть содержимое. В ARM также предполагается, что функция может уничтожить r0-r3, поэтому функция have_fun() должна сохранить входные данные, а затем поместить два входа функции fun1() в r0 и r1, так что происходит небольшой танец регистров.
mov r5, r1 mov r0, r2 mov r1, #5 ldr r2, .L2+4 str r2, [r4, #0] bl fun1
Компилятор был достаточно умен, чтобы понять, что нам никогда не нужны были первые входные данные для функции have_fun(), поэтому r0 был отброшен и разрешен для немедленного изменения. Кроме того, компилятор был достаточно умен, чтобы знать, что нам никогда не понадобится третий параметр, z (r2), после отправки его в fun1() при первом вызове, поэтому ему не нужно было сохранять его в верхнем регистре. R1, однако, второй параметр для have_fun() необходимо сохранить, поэтому он помещается в регистратор, который не будет уничтожен fun1().
Вы можете видеть, что то же самое происходит для второго вызова функции.
Предполагая, что fun1() — это простая функция:
inline unsigned int fun1 ( unsigned int a, unsigned int b ) { return(a+b); }
Когда вы встраиваете fun1(), вы получаете что-то вроде этого:
stmfd sp!, {r4, lr} mov r0, r1 mov r1, #6 add r4, r2, #5
Компилятору не нужно перетасовывать нижние регистры для подготовки к вызову. Точно так же вы могли заметить, что r4 и lr сохраняются в стеке, когда мы вводим hello_fun(). При таком соглашении о вызовах ARM функция может уничтожить r0-r3, но должна сохранить все остальные регистры, так как в этом случае функции have_fun() потребовалось более четырех регистров, чтобы выполнить свою задачу, она сохранила содержимое r4 в стеке, чтобы можно было использовать Это. Точно так же эта функция, когда я ее скомпилировал, вызвала другую функцию, инструкция bl/blx использует/уничтожает регистр lr (r14), поэтому для возврата функции have_fun() мы также должны сохранить lr в стеке. Упрощенный пример для fun1() не показал этого, но еще одна экономия, которую вы получаете от встраивания, заключается в том, что при входе вызываемая функция не должна настраивать фрейм стека и сохранять регистры, это действительно так, как если бы вы взяли код из функции и вставил его в вызывающую функцию.
Почему бы вам не инлайнить все время? Ну, во-первых, он может и будет использовать больше регистров, и это может привести к большему использованию стека, а стек работает медленно по сравнению с регистрами. Однако наиболее важным является то, что это увеличивает размер вашего двоичного файла, если бы fun1() была функцией хорошего размера, и вы вызывали ее 20 раз в have_fun(), ваш двоичный файл был бы значительно больше. Для современных компьютеров с гигабайтами оперативной памяти несколько сотен или нескольких десятков тысяч байт не имеют большого значения, но для встроенных систем с ограниченными ресурсами это может сделать вас или сломать. На современном гигагерцовом многоядерном десктопе как часто нужно шейпить инструкцию или все-таки пять? Иногда да, но не всегда для каждой функции. Так что только потому, что вам, вероятно, это сойдет с рук на рабочем столе, вы, вероятно, не должны.
Вернемся к указателям на функции. Итак, смысл, который я пытался подчеркнуть своим ответом, заключается в том, в каких ситуациях вы, вероятно, захотите использовать указатель на функцию, каковы варианты использования и в этих случаях использования, насколько это помогает или вредит?
Типы случаев, о которых я думал, - это плагины, или код, специфичный для вызывающего параметра, или общий код, реагирующий на определенное обнаруженное оборудование. Например, гипотетическая программа tar может захотеть вывести данные на ленточный накопитель, в файловую систему или что-то еще, и вы можете написать код с помощью универсальных функций, вызываемых с помощью указателей функций. При входе в программу параметры командной строки указывают вывод, и в этот момент вы устанавливаете указатели функций на конкретные функции устройства.
if(outdev==OUTDEV_TAPE) data_out=data_out_tape; else if(outdev==OUTDEV_FILE) { //open the file, etc data_out=data_out_file; } ...
Или, возможно, вы не знаете, работаете ли вы на процессоре с fpu или какой тип fpu у вас есть, но вы знаете, что разделение с плавающей запятой, которое вы хотите сделать, может работать намного быстрее с использованием fpu:
if(fputype==FPU_FPA) fdivide=fdivide_fpa; else if(fputype==FPU_VFP) fdivide=fdivide_vfp; else fdivide=fdivide_soft;
И, безусловно, вы можете использовать оператор case вместо дерева if-then-else, плюсы и минусы для каждого, некоторые компиляторы в любом случае превращают оператор case в дерево if-then-else, так что это не всегда имеет значение. Я пытался подчеркнуть, что если вы сделаете это один раз:
if(fputype==FPU_FPA) fdivide=fdivide_fpa; else if(fputype==FPU_VFP) fdivide=fdivide_vfp; else fdivide=fdivide_soft;
И делайте это везде в программе:
a=fdivide(b,c);
По сравнению с альтернативой без указателя функции, где вы делаете это везде, где хотите разделить:
if(fputype==FPU_FPA) a=fdivide_fpa(b,c); else if(fputype==FPU_VFP) a=fdivide_vfp(b,c); else a=fdivide_soft(b,c);
Подход с указателем на функцию, хотя и стоит вам дополнительного ldr при каждом вызове, намного дешевле, чем множество инструкций, необходимых для дерева if-then-else. Вы платите немного вперед, чтобы установить указатель fdivide один раз, а затем платите дополнительный ldr за каждый экземпляр, но в целом это быстрее, чем это:
unsigned int fun1 ( unsigned int a, unsigned int b ); unsigned int fun2 ( unsigned int a, unsigned int b ); unsigned int fun3 ( unsigned int a, unsigned int b ); unsigned int (*funptr)(unsigned int, unsigned int); unsigned int have_fun ( unsigned int x, unsigned int y, unsigned int z ) { unsigned int j; switch(x) { default: case 1: j=fun1(y,z); break; case 2: j=fun2(y,z); break; case 3: j=fun3(y,z); break; } return(j); } unsigned int more_fun ( unsigned int x, unsigned int y, unsigned int z ) { unsigned int j; j=funptr(y,z); return(j); }
дает нам это:
cmp r0, #2 beq .L3 cmp r0, #3 beq .L4 mov r0, r1 mov r1, r2 b fun1 .L3: mov r0, r1 mov r1, r2 b fun2 .L4: mov r0, r1 mov r1, r2 b fun3
вместо этого
mov r0, r1 ldr r3, .L7 mov r1, r2 blx r3
В случае по умолчанию дерево if-then-else записывает два сравнения и два beq перед прямым вызовом функции. В основном иногда дерево if-then-else будет быстрее, а иногда указатель на функцию быстрее.
Еще один комментарий, который я сделал, заключается в том, что если вы используете встраивание, чтобы сделать дерево if-then-else быстрее, вместо указателя на функцию, встраивание всегда будет быстрее, верно?
unsigned int fun1 ( unsigned int a, unsigned int b ) { return(a+b); } unsigned int fun2 ( unsigned int a, unsigned int b ) { return(a-b); } unsigned int fun3 ( unsigned int a, unsigned int b ) { return(a&b); } unsigned int have_fun ( unsigned int x, unsigned int y, unsigned int z ) { unsigned int j; switch(x) { default: case 1: j=fun1(y,z); break; case 2: j=fun2(y,z); break; case 3: j=fun3(y,z); break; } return(j); }
дает
have_fun: cmp r0, #2 rsbeq r0, r2, r1 bxeq lr cmp r0, #3 addne r0, r2, r1 andeq r0, r2, r1 bx lr
LOL, ARM помогла мне в этом. Это хорошо. Вы можете себе представить, что для универсального процессора вы получите что-то вроде
cmp r0, #2 beq .L3 cmp r0, #3 beq .L4 and r0,r1,r2 bx lr .L3: sub r0,r1,r2 bx lr .L4: add r0,r1,r2 bx lr
Вы по-прежнему записываете сравнения, чем больше у вас случаев, тем длиннее дерево «если-то-иначе». Для среднего случая не требуется много больше времени, чем решение указателя функции.
mov r0, r1 ldr r1, .L7 ldr r3,[r1] mov r1, r2 blx r3
Затем я также упомянул удобочитаемость и обслуживание, используя подход с указателем на функцию, вам всегда нужно знать, был ли назначен указатель на функцию, прежде чем использовать его. Вы не всегда можете просто найти это имя функции и найти то, что ищете, в чужом коде, в идеале вы найдете одно место, где назначен этот указатель, а затем вы можете найти настоящие имена функций.
Да, есть много других вариантов использования указателей на функции, и те, которые я описал, могут быть решены многими другими способами, эффективными или нет. Я пытался дать плакату несколько идей о том, как продумывать различные сценарии.
Я думаю, что самый важный ответ на этот вопрос интервью не в том, что есть правильный или неправильный ответ, потому что я думаю, что нет. Но чтобы увидеть, что интервьюируемый знает о том, что компиляторы делают или не делают, о том, что я описал выше. Вопрос интервью для меня — это несколько вопросов, понимаете ли вы, что на самом деле делает компилятор, какие инструкции он генерирует. Вы понимаете, что меньше или больше инструкций не обязательно быстрее. понимаете ли вы эти различия между разными процессорами или хотя бы имеете практические знания хотя бы для одного процессора. Затем он переходит к удобочитаемости и обслуживанию. Это еще один поток вопросов, который связан с вашим опытом чтения кода других людей, а затем с поддержкой вашего собственного кода или кода других людей. На мой взгляд, это остроумный вопрос.
Я бы сказал, что они выгодны (с точки зрения скорости) в любой среде, а не только во встроенной. Идея состоит в том, что после того, как указатель был направлен на правильную функцию, для вызова этой функции не требуется дальнейшей логики принятия решения.
Да, они полезны. Я не уверен, что интервьюер имел в виду. По сути, не имеет значения, встроена система или нет. Если только у вас не сильно ограниченный стек.
- Скорость Нет, самой быстрой системой будет одна функция, использующая только глобальные переменные и разбросанные по ней команды goto. Удачи с этим.
- Читаемость Да, это может сбить с толку некоторых людей, но в целом определенный код более удобочитаем с указателями на функции. Это также позволит вам увеличить разделение проблем между различными аспектами исходного кода.
- Удобство сопровождения Да, с указателями на функции у вас будет меньше условных выражений, меньше дублированного кода, большее разделение кода и, как правило, более ортогональное программное обеспечение.
Одна из отрицательных сторон указателей на функции заключается в том, что они никогда не будут встроены в места вызова. Это может быть или не быть полезным, в зависимости от того, компилируете ли вы скорость или размер. Если последнее, то они не должны отличаться от обычных вызовов функций.
Еще один недостаток указателей на функции (по отношению к виртуальным функциям, поскольку они не что иное, как указатели на функции на уровне ядра):
создание функции встроенной && виртуальной заставит компилятор создать внестрочную копию той же функции. Это увеличит размер конечного бинарного файла (при условии его интенсивного использования).
Эмпирическое правило: 1. Не совершать виртуальные вызовы в режиме реального времени.
Это был вопрос с подвохом. Есть отрасли, где указатели запрещены.
Давайте посмотрим...
Скорость (скажем, мы на ARM): тогда (теоретически):
(Размер инструкции ARM при обычном вызове функции) ‹ (Размер инструкции (инструкций) установки вызова указателя функции)
Поскольку это дополнительный уровень косвенности для настройки вызова указателя функции, он потребует дополнительной инструкции ARM.
PS: Обычный вызов функции: вызов функции, настроенный с помощью BL.
PSS: не знаю их реальных размеров, но это должно быть легко проверить.