C51中的函数指针
2021-10-28 来源:eefocus
概述
函数指针是C编程语言众多难懂的特性之一。由于C编译器对关于8051架构的独特要求,函数指针和可重入函数需要克服更大的挑战。这主要是因为函数参数传递的方式。
通常,(对于大多数非8051的芯片),函数参数是在栈上以压入和弹出的汇编指令来完成。由于8051的栈大小有限(仅128字节,某些设备上更低至64字节),函数参数传递必须用不同的技术来传递。
英特尔为8051推出PL/ML-51编译器时,他们引入了将参数存储在固定内存位置的技术。当链接器被调用时,它会建立程序的调用树,找出哪些函数参数是相互独立的,然后覆盖它们。这就是链接器OVERLAY指令的开始。
由于PL/M-51不支持函数指针,所以从未出现间接函数调用的问题。但是,关于C,问题更多。链接器如何“知道”哪些内存被间接函数使用?你又如何添加间接调用的函数进入调用树?
这篇文档解释如何在C51程序中有效地使用函数指针。一些示例被用来阐释讨论的问题和解决方案。
具体来说,就是这下面这些被讨论的主题:
转换常量为一个指针
声明函数指针
C51中函数指针的问题
用OVERLAY指令修改调用树
可重入函数指针
固定地址的指针
你可以轻松地将数字地址转换成函数指针。这样去做有很多原因。例如,你可能需要不用触发CPU的复位线就复位目标和应用程序。你能够用地址为0000h的函数指针来实现这。
你可能会用标准C特性来转换0x0000为一个指向0地址函数的指针。例如,当你编译下面的C代码时…
((void (code *) (void)) 0x0000) ();
…编译器产生如下信息:
; FUNCTION main (BEGIN)
; SOURCE LINE # 3
0000 120000 LCALL 00H
; SOURCE LINE # 4
0003 22 RET
; FUNCTION main (END)
这确实是我们期待的:LCALL 0。
转换数字常量为函数指针是一件有技巧的事。下列关于上面函数调用的各部分的描述将帮助你理解如何更好地使用它们。
在上述函数调用中,(void(*)void))是一个数据类型:一个指向函数的指针,这个函数不带参数,返回void。
0x0000是转换的地址。在类型转换之后,函数指针指向0x0000地址。注意,我们使用圆括号包围数据类型和0x0000。这不是必须的,如果我们只是想转换0x000到一个函数指针。但是,由于我们将要调用这个函数,这些括号是必需的。
转换数字常量至一个指针并不像通过指针调用一个函数。为此,我们必须指定一个参数列表。那就是在这一行末尾的()。
注意,在这个表达式中的所有括号都是必需的。括号分组和优先级也是重要的。上面的指针和指向带参数函数的指针之间的唯一的不同是数据类型和参数列表。例如,这下面的函数调用…
((long (code *) (int, int, int)) 0x8000) (1, 2, 3);
…调用一个地址为0x8000的函数,接收3个int参数并且返回long。
无参数的函数指针
函数指针是一个指向函数的变量。这个变量的值是函数的地址。例如,下列函数指针的声明…
void (*function_ptr) (void);
…是一个调用function_ptr的指针。用下面的代码来调用function_ptr指向的函数。
(*function_ptr) ();
由于function_ptr指向的函数没有一个参数被传递。这也是为什么参数列表为空。function_ptr的地址可以被赋值,当它被声明的时候。
void (*function_ptr) (void) = another_function;
或者,它也可以在程序执行中被赋值。
function_ptr = and_another_function;
重要的是要注意,你必须给函数指针赋值一个地址。如果你没有这样做,这指针的值可能是0(如果你幸运的话)或者它可能是完全不确定的值,具体取决于你使用数据内存的方式。如果一个指针没有初始化,当你间接通过它调用函数时,你的程序可能崩溃。
要声明一个有返回类型的函数指针,你必须在声明时指明返回类型。例如,下面的声明将上面的声明改为一个指向返回float类型的函数。
float (*function_ptr) (void) = another_function;
很简单。只要记住括号在哪里就行了。
带参数的函数指针
带参数的函数指针和不带参数的函数指针类似。例如:
void (*function_ptr) (int, long, char);
…是一个带有int,long和char作为参数的函数指针。用下面的代码调用function_ptr指向的函数。
(*function_ptr)(12, 34L, 'A');
注意,函数指针可能只能指定带3个或更少参数的函数。这是因为间接调用函数的参数必须驻存寄存器。有关使用多于3个参数的函数指针,参见可重入函数。
使用函数指针的注意事项
如果你在C51程序中使用函数指针,这里有几个你必须注意的事项。
参数列表限制
通过函数指针传递到函数的参数必须全部填充进寄存器。最多3个参数可以自动在在寄存器中传递。不要认为任意3个数据类型都可以。
由于C51至少可以通过寄存器传递3个参数。除非函数指定了更多参数,否则使用内存空间传递参数不是什么问题。如果是这种情况,你可以合并参数进一个结构体,然后传递指数结构体的指针。如果这不可接受,你可以使用可重入函数(参见下文)
调用树的保存
C51工具链不会将函数参数压入堆栈(除非可重入函数被调用)。相反,函数参数和自动变量(局部变量)是存储在寄存器中或在固定的内存位置。这会防止函数的可重入。例如,如果一个函数调用它自己,它将覆盖它自己的参数或局部变量。这个重入的问题通过reentrant关键字解决(参见下文)。另一个非可重入的副作用是函数指针能够,而且经常带来实现的问题。
为了保存尽可能多的数据空间,链接器执行调用树分析来确定一些内存空间是否要以安全地被覆盖。
例如,如果你的应用程序包括main函数、函数a、函数b和函数c;然后如果main调用a,b,c;并且a,b,c没有调用其他函数(也没有调用彼此);然后关于你的应用程序的调用树如下:
MAIN
+--> A
+--> B
+--> C
然后,被A,B和C使用的内存可以被安全覆盖。
当调用树不能被正确的构建时,关于函数指针的问题就出现了。这原因是链接器不能确定函数指针是引用哪个函数。这里没有自动的方法来解决这个问题。但是这里有一个手动的,尽管有点麻烦。
这下面的两个源文件帮助说明问题并且使得解决方案更容易理解。这第一个源文件FPCALLER.C,包含一个函数,这个函数通过函数指针(fptr)调用另外一个函数。
void func_caller (long (code *fptr) (unsigned int))
{
unsigned char i;
for (i = 0; i < 10; i++)
{
(*fptr) (i);
}
}
第二个源文件FPMAIN.C,包含main C函数,也包括通过func_caller(上面定义)间接调用的函数。注意,main调用func_caller并且传递一个函数的地址作为参数。
extern void func_caller (long (code *) (unsigned int));
int func (unsigned int count)
{
long j;
long k;
k = 0;
for (j = 0; j < count; j++)
{
k += j;
}
return (k);
}
void main (void)
{
func_caller (func);
while (1) ;
}
上述两个文件编译没有错误。它们链接也没有错误。这下面的调用是通过链接器在map文件中产生的。
SEGMENT DATA_GROUP
+--> CALLED SEGMENT START LENGTH
-------------------------------------------------
?C_C51STARTUP ----- -----
+--> ?PR?MAIN?FPMAIN
?PR?MAIN?FPMAIN ----- -----
+--> ?PR?_FUNC?FPMAIN
+--> ?PR?_FUNC_CALLER?FPCALLER
?PR?_FUNC?FPMAIN 0008H 000AH
?PR?_FUNC_CALLER?FPCALLER 0008H 0003H
尽管这是个简单的示例,但是依然可以从调用树中获取很多信息。
?C_C51STARTUP段调用MAIN C函数,那是?PR?MAIN?FPMAIN段。这个段名的组成部分可以被解码为:PR是PRogram内存,MAIN是函数名,FPMAIN是函数定义所在的源文件的名字。
MAIN函数调用FUNC和FUNC_CALLER(通过调用树)。注意,这不是正确的。MAIN从没有调用FUNC。但是它的确传递了FUNC的地址给FUNC_CALLER。同样要注意,通过调用树,FUNC_CALLER并没有调用FUNC。这是因为它是通过函数指针间接调用的。
在FPMAIN中的FUNC函数用开始于0008h的000Ah个字节数据。在FPCALLER中的FUNC_CALLER用起始于0008h的0003h个字节数据。这是重要的!
FUNC_CALLER用起始于0008h的内存,FUNC同样用起始于0008h的内存。由于FUNC_CALLER调用FUNC,并且两个函数都用相同的内存区域,然后我们出现问题了。当FUNC被调用时(被FUNC_CALLER),它会破坏FUNC_CALLER使用的内存。这是如何发生的呢?Keil C51编译器和链接器不起作用了吗?
这个问题的原因是函数指针。无论你什么时候使用函数指针,你一直会有类似的问题。幸运的是,它们很容易被修复。OVERLAY链接指令可以让你在调用树中指定函数是如何链接到一起的。
为了修正上面的调用树,针对FUNC的调用必须从MAIN函数移出,并且在FUNC_CALLER中插入对FUNC的调用。这下面的OVERLAY命令正是这样做的。
OVERLAY (?PR?MAIN?FPMAIN ~ ?PR?_FUNC?FPMAIN,
?PR?_FUNC_CALLER?FPCALLER ! ?PR?_FUNC?FPMAIN)
为了移除或插入对调用树的引用,需要首先指定调用者然后是被调用者。波浪线(~)是移除引用或调用,感叹号(!)是添加引用或调用。例如,?PR?MAIN?FPMAIN ~ ?PR?_FUNC?FPMAIN移除从MAIN中对FUNC的调用。
通过OVERLAY指令修正调用树的链接命令被调整之后,map文件中显示如下信息:
SEGMENT DATA_GROUP
+--> CALLED SEGMENT START LENGTH
-------------------------------------------------
?C_C51STARTUP ----- -----
+--> ?PR?MAIN?FPMAIN
?PR?MAIN?FPMAIN ----- -----
+--> ?PR?_FUNC_CALLER?FPCALLER
?PR?_FUNC_CALLER?FPCALLER 0008H 0003H
+--> ?PR?_FUNC?FPMAIN
?PR?_FUNC?FPMAIN 000BH 000AH
然后,这调用树现在被修正了,变量FUNC和FUNC_CALLER被分离在不同的空间(不再覆盖了)。
函数指针表
下面的内容是典型的函数指针表定义:
long (code *fp_tab []) (void) = { func1, func2, func3 };
如果你的main C函数通过fp_tab调用函数,那么这链接map将出现如下信息:
SEGMENT DATA_GROUP
+--> CALLED SEGMENT START LENGTH
----------------------------------------------
?C_C51STARTUP ----- -----
+--> ?PR?MAIN?FPT_MAIN
+--> ?C_INITSEG
?PR?MAIN?FPT_MAIN 0008H 0001H
?C_INITSEG ----- -----
+--> ?PR?FUNC1?FP_TAB
+--> ?PR?FUNC2?FP_TAB
+--> ?PR?FUNC3?FP_TAB
?PR?FUNC1?FP_TAB 0008H 0008H
?PR?FUNC2?FP_TAB 0008H 0008H
?PR?FUNC3?FP_TAB 0008H 0008H
通过表来调用这3个函数,func1,func2,func3,看起来像通过?C_INITSEG调用。但是,这并不是正确的。?C_INITSEG是初始化你代码变量的例程。这些函数只是在初始化代码中被引用,因为函数指针表是通过这些函数的地址初始化的。
注意,这些通过main C函数,同样还有func1,func2,func3作为变量被用的起始区域都是起始于0008h。这是无法运行的,因为main C函数调用func1、func2和fun3(通过函数指针表)。并且,在func1等函数中被用到的变量会覆盖那些在main中被用到的。
当你使用函数指针表时,C51编译器和BL51链接器组合工作,让覆盖函数变量空间变得很容易。但是,你必须恰当地声明函数指针表。如果你这样做,你能够避免使用OVERLAY指令。这下面的是一个函数指针表定义,C51和BL51可以自动处理。
code long (code *fp_tab []) (void) = { func1, func2, func3 };
注意,这唯一不同的是表格存储在代码空间。
现在,链接map显示如下:
SEGMENT DATA_GROUP
+--> CALLED SEGMENT START LENGTH
----------------------------------------------
?C_C51STARTUP ----- -----
+--> ?PR?MAIN?FPT_MAIN
?PR?MAIN?FPT_MAIN 0008H 0001H
+--> ?CO?FP_TAB
?CO?FP_TAB ----- -----
+--> ?PR?FUNC1?FP_TAB
+--> ?PR?FUNC2?FP_TAB
+--> ?PR?FUNC3?FP_TAB
?PR?FUNC1?FP_TAB 0009H 0008H
?PR?FUNC2?FP_TAB 0009H 0008H
?PR?FUNC3?FP_TAB 0009H 0008H
现在,这里没有来自从初始化代码中对func1、func2和func3的引用。相反,这里有个从main到FP_TAB的引用。
这是一个函数指针表,由于函数指针表引用了func1、func2和func3,因此这调用树是正确的。
只要这函数指针表是存放在独立的源文件中,C51和BL51可以为你让所一切在调用树中正确地链接。
函数指针建议和技巧
这里有一些函数指针的技巧,可以使得你让事情变得更容易。
用指定内存指针
将函数指针从通用指针转换成指定内存指针。这将为每个指针节省1个字节。到目前为止,示例使用的都是通用函数指针。由于函数仅仅驻留在代码内存(在8051上),因此可以将函数声明为Code类型指针来节省1个字节。例如:
void (code *function_ptr) (void) = another_function;
如果你在你的函数指针声明中选择包含一个code关键字,那么请确保所有的地方都这样用。如果你声明一个3个字节的函数指针,
并且传递指定内存2个字节的函数指针,糟糕的事情就会发生!
可重入函数和指针
Keil C51为可重入的函数提供reentrant关键字。可重入函数期望参数是通过模拟栈来传递。
栈是在用于small memory model的IDATA、用于compact memory model的PDATA或用于large memory model的XDATA上来维持的。如果你使用可重入函数,你必须在STARTUP.A51中初始化可重入栈指针。参见下列从启动代码中摘取的信息。
;----------------------------------------------------------------------
; Reentrant Stack Initilization
;
; The following EQU statements define the stack pointer for reentrant
; functions and initialized it:
;
; Stack Space for reentrant functions in the SMALL model.
IBPSTACK EQU 0 ; set to 1 if small reentrant is used.
IBPSTACKTOP EQU 0FFH+1 ; set top of stack to highest location+1.
;
; Stack Space for reentrant functions in the LARGE model.
XBPSTACK EQU 0 ; set to 1 if large reentrant is used.
XBPSTACKTOP EQU 0FFFFH+1 ; set top of stack to highest location+1.
;
; Stack Space for reentrant functions in the COMPACT model.
PBPSTACK EQU 0 ; set to 1 if compact reentrant is used.
PBPSTACKTOP EQU 0FFFFH+1 ; set top of stack to highest location+1.
你必须设置你使用哪个内存模型栈,并设置栈顶部。当有元素被压入栈,可重入栈指针减小(向下移动)。一个保存内部数据内存的小技巧是放置所有的可重入函数在独立的内存模型中,如large或compact。
要声明可重入函数,用reentrant关键字。
void reentrant_func (long arg1, long arg2, long arg3) reentrant
{
}
要声明一个large model的可重入函数,使用large和reentrant关键字。
void reentrant_func (long arg1, long arg2, long arg3) large reentrant
{
}
要声明一个指定可重入函数的指针,你必须同样使用reentrant关键字。
void (*rfunc_ptr) (long, long, long) reentrant = reentrant_func;
1
声明可重入函数指针和非可重入函数指针并没有太多不同。
当使用可重入函数指针时,因为参数必须压入模拟栈,所以更多的代码会产生。但是,没有链接控制需要指定,你也不必须混乱地使用OVERLAY指令。
如果你使用间接调用地方式传递多于3个参数到函数,那么可重入函数指针是需要的。
结论
如果你注意链接器调用树并且确保使用OVERLAY修正任何不一致的情况,那么函数指针是非常有用的并且也不是特别难用。
上一篇:C51简介及Keil的使用