Keil C51的Data Overlaying机制导致的函数重入问题

IO引脚电平读取引出的问题

最近在忙于公司一个使用51单片机的项目时,碰到一个蹊跷的问题。项目在主函数的while循环中使用若干IO引脚获取外部电平状态,同时使用一个ADC在周期性的定时器中断中对外部模拟信号进行采集,在实际运行中,发现IO在获取外部电平时会随机性的获取到错误的电平(比如外部输入为低电平,但是读取IO显示为高电平),问题相关的代码片段大致如下:

/* 定时器1周期性溢出中断 */
void timer_irq(void) TIMER_VECTOR
{
    u16 adc_date = adc_get(); /* 采样 */
    buf->append(adc_date);    /* 加入缓冲 */
}

int main(void)
{   
    while(1)
    {
        get_io_status(); /* 获取IO状态 */
    }
}

进一步查找发现,get_io_status()和adc_get()这两个函数都使用了同一个函数io_get()来获取IO电平,函数实现大概为:

bool io_get(u8 io_number)
{
    bool status;
    status = get_io_status_by_number(io_number);
    return status;
}

在注释adc_get()函数后执行程序,发现get_io_status()就可以正常获取电平了,后来想了想,可能是由于io_get函数发生重入导致的问题,后续的实验也验证了我的想法。

导致问题的Data Overlaying特性

也许有些人会有疑问:在io_get()函数中并未使用全局变量,为什么对io_get()函数进行重入会导致问题呢?确实,对于stm32等基于现代的arm内核的单片机来说,以上代码基本不会导致任何问题,大多数arm内核单片机的编译器(或者说几乎所有C语言编译器)都将函数的参数和局部变量存储在RAM的堆栈中,可以理解为每次调用函数,都会为本次调用分配一块空间用来存储本次调用的参数和函数内的局部变量,因此如果一个函数只使用了局部变量,并且内部调用的其他函数也是可重入的,那么对函数进行重入基本不会导致问题;但是对比较古老的51内核单片机来说,情况就有所不同了:很多51内核单片机的RAM可能只有几百字节,堆栈指针支持的寻址范围更是只有区区256字节,如果依然希望在调用函数时在堆栈上存储参数和局部变量,那么这小小的堆栈很快就会溢出了,而且基于堆栈的函数调用在函数进入与退出时的入栈出栈操作,对于很多低速的51单片机来说,也是一笔不小的开销了。因此,为了解决这个问题,keil C51的编译器在实现C语言函数特性时,使用了一种称为Data Overlaying的特性,这里摘录下手册中的原文:

Most C compilers store function arguments and local variables in a portion of the hardware stack called a stack frame. A base pointer is set on entry to a function and is used to access function arguments (on one side of the base pointer) and local variables (on the other side of the pointer). While this technique works well for devices in which the stack can be arbitrarily large is performs poorly on the 8051. The 8051 hardware stack is limited to a maximum of 256 bytes. As such, using stack frames on the 8051 is very wasteful of the limited memory available.

The Keil C51 C Compiler works with the LX51 Linker to store function arguments and local variables in fixed memory locations using well-defined names (so that function arguments are easily passed and accessed). The linker analyzes the structure of the program and creates a call tree which it uses to overlay the data segments containing local variables and function arguments. This technique works extremely well and provides the same efficient use of memory as a conventional stack frame. It is sometimes called a compile-time stack since the stack layout is fixed by the compiler and linker. An advantage of the compile-time stack is that arguments and local variables are accessed directly using fixed memory addresses (which is very fast on the 8051). Typical stack-based accesses are indirect and execute more slowly on the 8051.

While this technique is mostly automatic, there are a few situations that require special attention.

  • Indirect Function Calls (Function Pointers).
  • Tables or Arrays of Function Pointers.
  • Recursive Functions (functions that call themselves).
  • Reentrant Functions (functions invoked by more than one thread of execution — for example, a function called by main and an interrupt service routine.

Each of these situations requires special attention on your part and may require use of the linker OVERLAY directive.

其实这个特性按大白话解释就是说:按照惯例我们应该和很多其他C编译器一样用堆栈来实现函数调用时的传参和局部变量保存,但是C51的堆栈实在太小,所以经典的这一套东西在51内核单片机上性能不行,而且相对于51单片机可怜的RAM来说,如果每个函数调用都开辟堆栈空间的话再做入栈出栈的话,那51内核单片机那点点RAM和那点点性能实在不够用。那么怎么才能减少堆栈的开销呢,keil C51说我在编译时就分析你程序的函数调用树,直接在RAM上给你每个函数的参数和局部变量的存储的地址提前分配好,这样就减少了函数调用时出入栈的开销;并且对于某些函数的局部变量,我们发现它们如果不需要同时使用时,那么就把它们分配到一个地址,相当于把这个地址的空间分时复用了,这样就减少了RAM的使用,其实就相当于在编译阶段就将堆栈结构给确定下来。

对于RAM空间分时复用这里举个例子:假设我分析发现程序中存在函数A、函数B和函数C,同时函数A中调用了函数B和函数C,那么在分配RAM空间时,函数A的局部变量就要分配到单独的地址,不能与函数B、C的局部变量地址重合,因为函数A的生命周期会与函数B、C的生命周期存在重合,但是函数B和C中的局部变量就可以使用相同的RAM地址进行空间分时复用了,因为它们不存在调用关系,函数生命周期不重合。下图描述函数A、B、C之间的调用关系与执行生命周期:

image-20220625011935120

那么它们的局部变量在内存中的地址分配可能就是这样的:

image-20220625012734117

不过官方文档里也讲了:我们这个东西全自动的,大部分场景很好用,但是在以下两种特殊情况需要格外注意:

  1. 程序中出现使用函数指针或函数指针数组等非直接调用函数的情况。(编译器不知道运行时函数指针最终会指向哪个函数,自然也没法生成正确的函数调用链来指导内存分配,所以用keil C51时最好别用函数指针。)

  2. 程序中出现函数重入的情况,比如递归调用一个函数;或者在不同的上下文中同时调用同一个函数,比如主函数循环调用函数A到一半,这时中断打断函数A执行,并且中断处理函数中又调用了一次函数A。(函数中所有局部变量都是预先分配好地址的,因此如果对函数进行递归调用或者在不同上下文中同时调用同一个函数,那么每次调用使用的局部变量其实都是存储在同一个地址,这样就可能会导致问题)

如果程序中不得不出现以上两种情况,keil C51也给出了称为OVERLAY Linker Directive解决方法,其实就是我们自己去手动修改编译器生成的函数调用链,这里提供链接:BL51 User's Guide: OVERLAY Linker Directive (keil.com),不展开描述,文末我会给出一些自己认为可行的规避以上两种情况的方法。

很明显,上面提到的特殊情况2导致了我项目中的IO读取错误问题。

问题复盘

再来看一遍io_get()的实现:

bool io_get(u8 io_number)
{
    bool status;
    status = get_io_status_by_number(io_number);
    return status;
}

我在main函数的主循环和中断中都使用了这个函数,但是keil C51在编译时只给局部变量status分配了一处RAM空间,那么可能会出现这样的场景:

  1. 我在main函数主循环中调用io_get获取某个状态引脚电平为低电平0,并存储到了status局部变量中。
  2. 这时还没来得即返回status就收到了timer中断,跳转去执行中断。
  3. 中断中又调用了io_get读取ADC数据,并正好使得status变量被修改为了高电平1。
  4. 这时再退出中断并返回main函数执行,main中调用的io_get函数返回status。
  5. 由于status在中断中被修改过,则就会出现状态引脚明明为低电平0,io_get函数却返回高电平1的“神奇”现象。

规避方法

如果使用keil C51时一个函数需要同时在正常main函数流程与中断中调用,则可以考虑采取以下措施来防止重入问题:

  1. 为函数使用keil C51的reentrant关键字告诉编译器这个函数会重入,则编译器会用传统的堆栈方式来处理这个函数的调用,但是可能会增加RAM使用并增加函数调用开销(入栈出栈)。
  2. 函数执行时关闭中断,执行完成后再打开来避免重入,但是这不适合对于中断响应时间有严格要求的场合。
  3. 不在中断中调用此函数,而是将函数代码直接在中断函数中展开,不是任何场合都适用,而且可能显著降低代码质量与可维护性。

以上方法也许可以解燃眉之急,或许有时间要研究下keil官方给出的 OVERLAY Linker Directive 的解决方法。。。。。。

发表评论

您的电子邮箱地址不会被公开。