Функциональные указатели во встроенных системах, они полезны?
В одном из интервью они спросили меня, будет ли полезно использовать функциональные указатели (с точки зрения скорости) при написании кода для встроенных систем? Я понятия не имел о встроенной системе, поэтому не мог ответить на вопрос. Просто облачный или расплывчатый ответ. Так каковы реальные преимущества? Скорость, удобочитаемость, обслуживание, стоимость?
10 ответов
Я думаю, что, возможно, ответ Вирена Шакья не соответствует тому, что пытался выявить интервьюер. В некоторых конструкциях использование указателя на функцию может ускорить выполнение. Например, если у вас есть индекс, его использование для индексации массива указателей функций может быть быстрее, чем большой переключатель.
Однако, если вы сравниваете статический вызов функции с вызовом через указатель, то Viren прав, указывая, что есть дополнительная операция для загрузки переменной указателя. Но никто разумно не пытается использовать указатель на функцию таким образом (просто в качестве альтернативы непосредственному вызову).
Вызов функции через указатель не является альтернативой прямому вызову. Таким образом, вопрос о "преимуществе" некорректен; они используются в различных обстоятельствах, часто для упрощения другой логики кода и потока управления, а не просто для того, чтобы избежать статического вызова функции. Их полезность заключается в том, что определение вызываемой функции выполняется динамически во время выполнения вашим кодом, а не статически компоновщиком. В этом смысле они, конечно, полезны во встроенных системах, но не по какой-либо причине, конкретно связанной со встроенными системами.
Есть много применений.
Самое важное использование указателей функций во встроенных системах - это создание векторных таблиц. Многие архитектуры MCU используют таблицу адресов, расположенную в NVM, где каждый адрес указывает на ISR (подпрограмма обслуживания прерываний). Такая таблица векторов может быть записана в C как массив указателей на функции.
Указатели на функции также полезны для функций обратного вызова. Как пример из реального мира, на днях я писал драйвер для встроенных часов реального времени. На чипе были только одни часы, но мне нужно было много таймеров. Это было решено путем сохранения счетчика для каждого программного таймера, который был увеличен из-за прерывания часов в реальном времени. Тип данных выглядел примерно так:
typedef struct
{
uint16_t counter;
void (*callback)(void);
} Timer_t;
Когда аппаратный таймер был равен программному таймеру, функция обратного вызова, указанная пользователем, была вызвана через указатель функции, сохраненный вместе со счетчиком. Нечто подобное выше - довольно распространенная конструкция во встроенных системах.
Указатели на функции также полезны при создании загрузчиков и т. Д., Когда вы будете писать код в NVM во время выполнения, а затем вызывать его. Вы можете сделать это через указатель на функцию, но не через связанную функцию, так как код на самом деле отсутствует во время ссылки.
Конечно, как уже упоминалось, указатели на функции полезны для многих оптимизаций, например, для оптимизации оператора switch, где каждый "регистр" является соседним числом.
Еще одна вещь, которую следует учитывать, это то, что этот вопрос будет хорошей возможностью продемонстрировать, как вы принимаете решения о дизайне в процессе разработки. Один из ответов, который я мог бы представить, - это развернуться и подумать о том, каковы ваши варианты реализации. Взяв страницу из ответов Кейси и Лундина, я обнаружил, что функции обратного вызова очень полезны для изоляции моих модулей друг от друга и упрощения изменений кода, потому что мой код находится на стадии постоянного прототипирования, и все меняется быстро и часто. Что меня беспокоит, так это простота разработки, а не скорость.
В моем случае мой код обычно включает несколько модулей, которые должны сигнализировать друг другу, чтобы синхронизировать порядок операций. Ранее я реализовал это как целое множество флагов и структур данных с внешней связью. С этой реализацией две проблемы, как правило, высосали мое время:
- Поскольку любой модуль может касаться внешних переменных, я трачу много времени на проверку каждого модуля, чтобы убедиться, что эти переменные используются по назначению.
- Если другой разработчик ввел новый флаг, я обнаружил, что перебираю несколько модулей в поисках исходного объявления и (надеюсь) описания использования в комментариях.
С функциями обратного вызова эта проблема исчезает, потому что функция становится сигнальным механизмом, и вы получаете следующие преимущества:
- Взаимодействие с модулями обеспечивается функциональными интерфейсами, и вы можете проверять предварительные / постусловия.
- Меньшая потребность в глобально разделяемых структурах данных, поскольку обратный вызов служит этим интерфейсом для внешних модулей.
- Уменьшенная связь означает, что я могу поменять код относительно проще.
На данный момент я понесу удар по производительности, поскольку мое устройство по-прежнему работает адекватно даже со всеми дополнительными вызовами функций. Я рассмотрю свои альтернативы, когда эта производительность станет более серьезной проблемой.
Возвращаясь к вопросу об интервью, даже если вы не обладаете техническими знаниями в области функциональных указателей, я думаю, что вы все равно были бы ценным кандидатом, зная, что вы знакомы с компромиссами, достигнутыми в процессе проектирования.
Вы выигрываете в скорости, но теряете немного в удобочитаемости и обслуживании. Вместо дерева if-then-else, если a затем fun_a(), иначе, если b, то fun_b(), еще, если c, тогда fun_c(), еще fun_default(), и приходится делать это каждый раз, вместо этого, если тогда a fun=fun_a, иначе, если b, тогда fun=fun_b и т. д., и вы делаете это один раз, с этого момента просто вызывайте fun(). Намного быстрее. Как уже указывалось, вы не можете встроить, что является еще одним приемом скорости, но встраивание в дерево if-then-else не обязательно делает его быстрее, чем без встраивания, и в целом не так быстро, как указатель на функцию.
Вы теряете немного читабельности и обслуживания, потому что вам нужно выяснить, где установлен fun (), как часто он меняется, если вообще когда-либо, убедитесь, что вы не вызываете его до того, как он настроен, но это по-прежнему одно имя для поиска, которое вы можете использовать, чтобы найти и поддерживать все места, где он используется.
Это в основном трюк со скоростью, позволяющий избегать деревьев if-then-else каждый раз, когда вы хотите выполнить функцию. Если производительность не критична, если ничто другое fun () не может быть статическим и иметь в нем дерево if-then-else.
РЕДАКТИРОВАТЬ Добавление нескольких примеров, чтобы объяснить, о чем я говорил.
extern unsigned int fun1 (unsigned int a, unsigned int b); unsigned int (* funptr) (unsigned int, unsigned int); void have_fun (без знака int x, без знака int y, без знака int z) { unsigned int j; funptr=fun1; J =fun1(г,5); J =funptr(у,6); }
Компиляция дает это:
повеселись: 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() - это простая функция:
встроенный без знака int fun1 (без знака int a, без знака int b) { Возвращение (а + б); }
Когда вы добавляете fun1(), вы получаете что-то вроде этого:
stmfd sp!, {r4, lr} MOV R0, R1 MOV R1, № 6 добавить 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; еще если (outdev==OUTDEV_FILE) { // открыть файл и т. д. data_out=data_out_file; } ...
Или, возможно, вы не знаете, работаете ли вы на процессоре с fpu или у какого типа fpu у вас есть, но вы знаете, что деление с плавающей запятой, которое вы хотите сделать, может выполняться намного быстрее с помощью fpu:
if(futype==FPU_FPA) fdivide=fdivide_fpa; иначе if(futype==FPU_VFP) fdivide=fdivide_vfp; иначе fdivide=fdivide_soft;
И, безусловно, вы можете использовать оператор case вместо дерева if-then-else, плюсы и минусы каждому, некоторые компиляторы все равно превращают оператор case в дерево if-then-else, так что это не всегда имеет значение. Дело в том, что я пытался сделать это один раз:
if(futype==FPU_FPA) fdivide=fdivide_fpa; иначе if(futype==FPU_VFP) fdivide=fdivide_vfp; иначе fdivide=fdivide_soft;
И делайте это везде в программе:
а =fdivide(б, в);
По сравнению с альтернативой указателя без функции, где вы делаете это каждый раз, когда вы хотите разделить:
if(futype==FPU_FPA) a=fdivide_fpa(b,c); иначе if(futype==FPU_VFP) a=fdivide_vfp(b,c); иначе 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) { без знака int j; Переключатель (х) { дефолт: случай 1: j=fun1(y,z); перерыв; случай 2: j=fun2(y,z); перерыв; случай 3: j=fun3(y,z); перерыв; } вернуться (к); } unsigned int more_fun ( unsigned int x, unsigned int y, unsigned int z) { без знака int j; J =funptr(у, г); вернуться (к); }
дает нам это:
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 б веселье3
вместо этого
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) { Возвращение (а + б); } unsigned int fun2 ( unsigned int a, unsigned int b) { возвращать (а b); } unsigned int fun3 ( unsigned int a, unsigned int b) { Возвращение (а & б); } unsigned int have_fun ( unsigned int x, unsigned int y, unsigned int z) { без знака int j; Переключатель (х) { дефолт: случай 1: j=fun1(y,z); перерыв; случай 2: j=fun2(y,z); перерыв; случай 3: j=fun3(y,z); перерыв; } вернуться (к); }
дает
повеселись: cmp r0, #2 rsbeq r0, r2, r1 bxeq lr cmp r0, #3 addne r0, r2, r1 и r0, r2, r1 BX LR
LOL, ARM подтолкнул меня к этому. Это мило. Вы можете себе представить, хотя для универсального процессора вы получите что-то вроде
cmp r0, #2 beq .L3 cmp r0, #3 beq .L4 и r0,r1,r2 BX LR.L3: sub r0,r1,r2 BX LR.L4: добавить r0,r1,r2 BX LR
Вы по-прежнему записываете сравнения, чем больше у вас дел, тем длиннее дерево if-then-else. В среднем случае это не займет много времени, чем решение с указателем на функцию.
MOV R0, R1 ldr r1, .L7 лдр r3,[r1] MOV R1, R2 blx r3
Затем я также упомянул о удобочитаемости и обслуживании, используя подход с указателем функций, вы всегда должны знать, был ли назначен указатель функции перед его использованием. Вы не можете всегда просто использовать grep для этого имени функции и находить то, что вы ищете в чужом коде, в идеале вы можете найти одно место, где назначен этот указатель, тогда вы можете использовать grep для реальных имен функций.
Да, есть много других вариантов использования для указателей функций, и те, которые я описал, могут быть решены многими другими способами, эффективными или нет. Я пытался дать плакату некоторые идеи о том, как продумать различные сценарии.
Я думаю, что самый важный ответ на этот вопрос интервью не в том, что есть правильный или неправильный ответ, потому что я думаю, что нет. Но чтобы увидеть, что интервьюируемый знает о том, что компиляторы делают или не делают, о вещах, которые я описал выше. Вопрос для меня - это несколько вопросов, понимаете ли вы, что на самом деле делает компилятор, какие инструкции он генерирует. Вы понимаете, что меньше или больше инструкций не обязательно быстрее? понимаете ли вы эти различия между разными процессорами, или у вас есть хотя бы практические знания хотя бы для одного процессора? Затем речь идет о удобочитаемости и обслуживании. Это еще один поток вопросов, связанный с вашим опытом чтения кода других людей, а затем поддержки вашего собственного кода или кода других людей. На мой взгляд, это продуманный вопрос.
Я бы сказал, что они полезны (с точки зрения скорости) в любой среде, а не просто встроены. Идея состоит в том, что после того, как указатель будет указывать на правильную функцию, для вызова этой функции не требуется никакой дополнительной логики принятия решения.
Да, они полезны. Я не уверен, к чему стремился интервьюер. В основном это не имеет значения, если система встроена или нет. Если у вас нет сильно ограниченного стека.
- Скорость Нет, самая быстрая система - это отдельная функция, использующая только глобальные переменные и разбросанные по всему. Удачи с этим.
- Читаемость Да, это может сбить с толку некоторых людей, но в целом определенный код более читабелен с помощью указателей функций. Это также позволит вам увеличить разделение интересов между различными аспектами исходного кода.
- Поддерживаемость Да, благодаря указателям на функции у вас будет меньше условных обозначений, меньше дублирующегося кода, увеличенная степень разделения кода и, как правило, больше ортогонального программного обеспечения.
Еще один недостаток указателей на функции (в отношении виртуальных функций, поскольку они являются ничем иным, как указателями на уровне ядра):
создание встроенной функции && virtual заставит компилятор создавать внеплановую копию той же функции. Это увеличит размер конечного двоичного файла (при условии, что его интенсивное использование будет выполнено).
Основное правило: 1: не совершайте виртуальные звонки
Это был вопрос с подвохом. Есть отрасли, где указатели запрещены.
Одна негативная часть указателей на функции заключается в том, что они никогда не будут встроены в места вызова. Это может или не может быть полезным, в зависимости от того, компилируете ли вы по скорости или размеру. Если последнее, они не должны отличаться от обычных вызовов функций.
Посмотрим...
Скорость (скажем, мы на ARM): тогда (теоретически):
(Нормальный размер инструкции ARM для вызова функции) <(Размер инструкции (ов) для установки указателя функции)
Так как они являются дополнительным уровнем косвенности для установки вызова указателя функции, это потребует дополнительной инструкции ARM.
PS: нормальный вызов функции: вызов функции, настроенный с помощью BL.
PSS: Не знаю реальных размеров для них, но это должно быть легко проверить.