如果要讲51单片机函数指针的使用就不得不说一下它的函数是怎么分配RAM空间的,不同于现代的大多数C编译器将函数参数和局部变量在运行时存储在硬件栈(一块归编译器自主开辟释放的RAM空间)中。但是51编译器因为硬件的局限性采用了一种在编译时静态分配确定的方法(编译时栈),我们不去深究为什么这么做,只需要关注这么做带来的后果就可以。
void Function_A(void) {
// 省略
}
void Function_B(void) {
// 省略
}
void main() {
Function_A();
Function_B();
}
好处是所有函数占用多少空间是编译的时候就确定的,不存在动态的爆栈的风险,坏处是像上图左侧一样每个函数都一直占用固定空间即使函数不运行。这么说你该笑了,这么做不完全是浪费RAM空间嘛,51单片机有那么多RAM空间吗?
但是其实你很容易注意到FUNCTION_A和FUNCTION_B这两个函数实际上是不可能同时运行的,所以工具链开发人员引入了覆盖分析的概念,如果两个函数不可能同时运行就干脆给他们分配到一块空间(实际的覆盖分析更复杂,这里用简化示意说明原理),反正一个用完返回把空间让出来了才会调用另一个。
其他函数也是依次类推,很明显看右图极大的减少了RAM空间的开销,这么做可以比动态分配栈空间更加节约内存。不过因为C51的链接器又不足够智能在使用函数指针的时候如果不能够手动调整调用关系几乎是必然出现内存覆盖问题导致程序崩溃。
typedef void (*pf)(void);
void State_A(void);
void State_B(void);
void Function_A(pf fp);
void Function_B(void);
void Function_C(void);
pf State_Pointer = &State_A; // 函数指针
code pf function = &Function_C; // 函数指针
void State_A(void) {
// 省略
pf local_fp = &Function_B;
Function_A(local_fp);
State_Pointer = &State_B;
}
void State_B(void) {
// 省略
pf local_fp;
local_fp = &Function_B;
Function_A(local_fp);
State_Pointer = &State_A;
}
void Function_A(pf fp) {
// 省略
fp();
}
void Function_B(void) {
// 省略
function();
}
void Function_C(void) {
// 省略
}
void main() {
while (1) {
State_Pointer();
}
}上面这个例子算是个状态机,A状态的时候执行一些操作进入B状态,B状态的时候执行一些操作进入状态A。理论上来说应该是main函数调用State_A,State_B两个函数,这两个函数内部都是取Function_B地址传递给Function_A,让Function_A通过函数指针调用Function_B,其中Function_B又通过静态函数指针function调用了Function_C。

但是我们编译之后去看map文件会发现完全不是这样,?C_INITSEG(初始化段)调用了STATE_A;状态函数调用了Function_A,FunctionB,STATE_X;Function_A没有调用Function_B,Function_B也没有调用Function_C。(Function_A前面的_表示这个函数的参数使用寄存器传递了)
keil_C51的链接器进行覆盖分析的时候采用了一种简单粗暴的方式,把所有的引用当成调用处理。我们先分析一下可能产生的后果
- State_A和State_B有调用关系不能使用同一内存空间浪费了RAM空间,并导致误报了递归错误
- Function_A,Function_B,Function_C之间依次调用但可能在覆盖分析是使用同一个内存空间运行出错。
上面这个例子虽然简单,但覆盖了常见的函数指针误判场景,下面逐一说明。
初始化全局函数指针:pf State_Pointer = &State_A; // 函数指针
初始化State_Pointer时取了一次State_A地址就认为是对State_A有调用关系。
函数内部进行指针赋值与初始化:
State_Pointer = &State_X;
pf local_fp = &Function_B;
pf local_fp; local_fp = &Function_B;
其实和上面是一样的,分开写我就是想说明不管是全局函数指针还是局部函数指针,初始化的时候直接赋值还是之后进行赋值,都会被误认为调用。
函数指针作为一个参数:Function_A(local_fp);
常量函数指针:code pf function= &Function_C;
要注意的是这个链接器识别的调用关系只影响内存覆盖分析,不会影响实际的调用关系,也就是说你这个代码运行起来main函数依然会正确调用STATE_A,STATE_B;Function_A,Function_B,Function_C也依然会正常依次调用,但是不能保证出现内存覆盖问题。这个时候就需要我们自己识别出来这种错误,手动修改调用关系。
手册中OVERLAY可以有四种调整方法
创建一个ROOT函数:OVERLAY (* ! sfname)
将一个函数排除覆盖:OVERLAY (sfname ! *)
移除两个函数之间的调用关系:
OVERLAY (sfname-caller ~ sfname-callee)
OVERLAY (sfname-caller ~ (sfname-callee, sfname-callee))
添加两个函数之间的调用关系:
OVERLAY (sfname-caller ! sfname-callee)
OVERLAY (sfname-caller ! (sfname-callee, sfname-callee))
caller:调用者,callee:被调用者,sfname:函数名
前两种我不多说,像C_C51STARTUP,以及各个中断处理函数这种硬件唤起的都是一个ROOT,不过这些都是链接器自己可以识别的不用手动添加。除非你移植了某个RTOS系统,每个任务函数都需要你自己添加了。第二个感觉意义不大,只是让一个函数不参与覆盖分析给他单独准备一段空间,一定程度上会浪费空间。
关键是后面两个,使用它们可以清晰地为链接器指明调用关系,手动调整调用关系主要是下面3种情况,可以直接添加成链接器附加参数,也可以把指令全部写入一个lin文件。
//格式:?PR?函数名?文件名,
//PR表示程序,函数名可能会被链接器增加一些符号如_FUNCTION_A,可以去map文件搜索,全部大写
//移除全局变量初始化引用导致的调用:
OVERLAY(?C_INITSEG ~ ?PR?STATE_A?MAIN)
//移除函数内部引用导致的调用:
OVERLAY(?PR?STATE_A?MAIN ~ (?PR?STATE_B?MAIN,?PR?FUNCTION_B?MAIN))
OVERLAY(?PR?STATE_B?MAIN ~ (?PR?STATE_A?MAIN,?PR?FUNCTION_B?MAIN))
//移除常量函数指针引用导致的调用:
OVERLAY(?CO?MAIN ~ ?PR?FUNCTION_C?MAIN)
//添加使用函数指针没有识别出来的调用:
OVERLAY(?PR?MAIN?MAIN ! (?PR?STATE_A?MAIN,?PR?STATE_B?MAIN))//使用全局函数指针调用
OVERLAY(?PR?_FUNCTION_A?MAIN ! ?PR?FUNCTION_B?MAIN)//使用函数指针参数调用
OVERLAY(?PR?FUNCTION_B?MAIN ! ?PR?FUNCTION_C?MAIN)//使用常量函数指针调用这里面我需要特殊说明的就是常量函数指针,一般正常使用的时候肯定不是只用一个,有时候会把一系列回调函数通过指针做成回调函数表,但是不管使用多少只要使用了静态函数指针就会出现覆盖分析出错的问题。其中?CO?MAIN就是main文件中所有的常量,如果funcition 在xxx文件中就是?CO?XXX)

编译之后查看map文件就可以看到正确的调用树:

这里我要补充说明的是,错误的调用关系分析不一定就会在运行时出现错误,keil的文档中甚至列出了三种常量函数指针不需要调整的情况,如果你有兴趣可以去看一下。

不过我的建议是要么你就不要使用函数指针从根本上解决可能带来的问题,只要使用了就要去手动调整为正确的调用关系,不要等到运行时出现一些莫名其妙的问题再去修改。
评论已关闭