嵌入式系统中的函数指针是否有用?

9 embedded functor

在一次采访中,他们问我在编写嵌入式系统代码时,使用函数指针是否有益(在速度方面)?我不知道嵌入式系统,所以无法回答这个问题.只是一个阴天或模糊的答案.那么真正的好处是什么?速度,可读性,维护,成本?

Cli*_*ord 21

我想也许Viren Shakya的回答错过了面试官试图引出的观点.在某些结构中,使用函数指针可以加速执行.例如,如果你有一个索引,使用它来索引一个函数指针数组可能比一个大开关更快.

但是,如果您将静态函数调用与通过指针调用进行比较,那么Viren正确地指出还有一个额外的操作来加载指针变量.但没有人合理地尝试以这种方式使用函数指针(只是作为直接调用的替代方法).

通过指针调用函数不是直接调用的替代方法.所以,"优势"的问题是有缺陷的; 它们用于不同的环境,通常用于简化其他代码逻辑和控制流程,而不仅仅是避免静态函数调用.它们的用处在于,要调用的函数的确定是在运行时由代码动态执行,而不是由链接器静态执行.从这个意义上说,它们当然在嵌入式系统中很有用,但并不是出于与嵌入式系统有关的任何原因.


Lun*_*din 17

有很多用途.

嵌入式系统中函数指针最重要的用途是创建向量表.许多MCU架构使用位于NVM中的地址表,其中每个地址指向ISR(中断服务例程).这样的向量表可以用C编写为函数指针数组.

函数指针对回调函数也很有用.作为现实世界的一个例子,前几天我正在为片上实时时钟编写驱动程序.芯片上只有一个时钟,但我需要很多定时器.这是通过为每个软件定时器保存一个计数器来解决的,该计数器由实时时钟中断增加.数据类型看起来像这样:

typedef struct
{
  uint16_t counter;
  void (*callback)(void);

} Timer_t;
Run Code Online (Sandbox Code Playgroud)

当硬件定时器与软件定时器相同时,通过与计数器一起存储的函数指针调用用户指定的回调函数.像上面这样的东西是嵌入式系统中非常常见的结构.

在创建引导加载程序等时,函数指针也很有用,您将在运行时将代码写入NVM,然后调用它.您可以通过函数指针执行此操作,但绝不能通过链接函数执行此操作,因为代码实际上并不在链接时.

如上所述,函数指针当然对许多优化很有用,比如优化掉每个"case"是相邻数字的switch语句.


spa*_*e78 7

另一件需要考虑的事情是,这个问题将是一个展示如何在开发过程中做出设计决策的好机会.我能想象给出的一个回应是转过身来考虑你的实施方案.从Casey和Lundin的答案中得到一个页面,我发现回调函数非常有用,可以将模块彼此隔离并使代码更改更容易,因为我的代码处于永久原型设计阶段,而且事情变化很快且经常发生.我目前关注的是易于开发,而不是速度.

在我的情况下,我的代码通常涉及具有多个模块,这些模块需要相互发信号以同步操作顺序.以前我把它作为一整套标志和数据结构实现了extern链接.有了这个实现,两个问题通常会占用我的时间:

  1. 由于任何模块都可以触及外部变量,因此我花了很多时间来监管每个模块,以确保这些变量按预期使用.
  2. 如果另一位开发人员引入了新的标志,我发现自己潜入了多个模块,寻找原始声明和(希望)评论中的用法说明.

使用回调函数,问题就会消失,因为函数成为信号机制,您可以利用这些优势:

  1. 模块交互由功能接口强制执行,您可以测试前/后条件.
  2. 由于回调用作外部模块的接口,因此对全局共享数据结构的需求较少.
  3. 减少耦合意味着我可以相对容易地交换代码.

目前,即使使用所有额外的函数调用,我的设备仍能充分执行,因此我将获得性能提升.当性能开始成为一个更大的问题时,我会考虑我的选择.

回到面试问题,即使你可能不熟悉功能指针的细节,我认为你仍然是一个有价值的候选人,知道你已经认识到在设计过程中所做的权衡.


old*_*mer 5

你获得了速度,但在可读性和维护方面却失去了一些.而不是if-then-else树,如果是那么fun_a(),否则如果b然后是fun_b(),否则如果c然后fun_c()其他fun_default(),并且每次必须这样做,而不是如果那么有趣= 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 ( 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}

我认为Clifford所说的是直接调用,如果足够接近(取决于架构),则是一条指令

    bl  fun1

如果一个函数指针,将至少花费你两个

    ldr r3, [r4, #0]
    blx r3

我还提到了直接和间接之间的区别是你承受的额外负担.

在继续之前,值得一提的是内联的优缺点.对于这些示例正在使用的ARM的情况,调用约定使用r0-r3作为函数的传入参数,并使用r0返回.因此,使用三个参数进入have_fun()意味着r0-r3具有内容.使用ARM时,还假设函数可以破坏r0-r3,因此has_fun()需要保留输入,然后将两个输入放在r0和r1中的fun1()中,因此会发生一些寄存器跳舞.

    mov r5, r1
    mov r0, r2
    mov r1, #5
    ldr r2, .L2+4
    str r2, [r4, #0]
    bl  fun1

编译器足够智能,看到我们从不需要第一次输入has_fun()函数,因此r0被丢弃并允许立即更改.此外,编译器足够聪明,知道在第一次调用后将它发送到fun1()后我们永远不需要第三个参数z(r2),所以它不需要将它保存在高位寄存器中.但是,对于has_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

编译器不需要随机调整低位寄存器以准备调用.同样,您可能已经注意到,当我们输入hello_fun()时,r4和lr将保留在堆栈中.使用这个ARM调用约定,一个函数可以破坏r0-r3但必须保留所有其他寄存器,因为在这种情况下have_fun()需要四个以上的寄存器才能完成它在堆栈中保存r4的内容以便它可以使用它.同样这个函数,因为我编译它确实调用了另一个函数,bl/blx指令使用/销毁lr寄存器(r14)所以为了让has_fun()返回,我们还必须在堆栈上保留lr.fun1()的简化示例没有显示这一点,但是你从内联得到的另一个节省是,在输入时,调用的函数不必设置堆栈帧并保留寄存器,它实际上就像你从函数中获取代码一样并将其与调用函数内联.

为什么你不会一直内联?首先,它可以并且将使用更多的寄存器,这可能导致更多的堆栈使用,并且堆栈相对于寄存器来说很慢.最重要的是它增加了二进制文件的大小,如果fun1()是一个大小合适的函数,并且你在have_fun()中调用了20次,那么你的二进制文件会大得多.对于具有千兆字节内存的现代计算机来说,几百或几十万字节并不是什么大不了的事,但对于有限资源的嵌入式而言,这可能会让你失望.在现代的千兆赫多核桌面上,您需要多长时间才能刮一条指令或五条指令?有时是,但并非所有时间都适用于所有功能.所以只是因为你可能不能在桌面上使用它.

回到函数指针.因此,我试图用我的答案提出的观点是,你在什么情况下可能想要使用函数指针,有什么用例,在这些用例中它有多大帮助或伤害?

我想到的案例类型是插件,或特定于调用参数的代码或对检测到的特定硬件作出反应的通用代码.例如,假设的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树,每个都有利弊,一些编译器无论如何都将if语句转换为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

大声笑,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

您仍然刻录比较,您拥有的if-then-else树的时间越长.平均情况比函数指针解决方案花费更长时间并不需要太多.

    mov r0, r1
    ldr r1, .L7
    ldr r3,[r1]
    mov r1, r2
    blx r3

然后我还提到了可读性和维护,使用函数指针方法,您需要始终知道在使用之前是否已分配函数指针.你不能总是只是grep这个函数名,并在别人的代码中找到你要找的东西,理想情况下你找到一个指定了指针的地方,然后你就可以找到真正的函数名.

是的,函数指针有许多其他用例,我所描述的用例可以通过许多其他方式解决,有效与否.我试图给海报一些关于如何思考不同场景的想法.

我认为这个面试问题最重要的答案并不是有正确或错误的答案,因为我认为没有.但要看看受访者知道编译器做了什么或不做什么,我上面描述的事情.对我来说面试问题是几个问题,你是否了解编译器实际做了什么,它产生了什么指令.您是否理解更少或更多的指令不一定更快.你是否了解不同处理器之间的这些差异,或者你至少掌握了至少一个处理器的工作知识.然后它继续可读性和维护.这是另一个问题流,它与您阅读其他人的代码,然后维护自己的代码或其他人的代码的经验有关.在我看来,这是一个巧妙设计的问题.

  • 使用正确,您可以通过使用函数指针来增强可读性和组织,以用经过深思熟虑的抽象替换选择逻辑. (2认同)
  • @dwelch:我不相信你的解释,也许你发布真实的代码示例来解释你的意思,而不是在文本中"内联"你的代码.在这个解释中突然出现"随机数量的代码"在哪里? (2认同)