嵌入式启航 嵌入式启航
首页 作品展示 个人主页
首页 作品展示 个人主页
binarybard
binarybard
嵌入式软件工程师,专注于MCU+RTOS技术栈,了解各种MCU底层。
binarybard
binarybard
嵌入式软件工程师,专注于MCU+RTOS技术栈,了解各种MCU底层。

分类

  • 默认分类 1
  • 开发环境 1
  • keil_C51 5

最新文章

  • 51单片机函数调用与可重入函数
    2026-01-10
  • 51单片机中断系统与响应优化
    2026-01-03
  • 51单片机bank功能与扩展关键字
    2025-12-27
  • 51单片机内存模型
    2025-12-20
  • 51单片机函数指针覆盖分析问题
    2025-12-13

51单片机函数调用与可重入函数

binarybard 2026年01月10日 keil_C51 0 条评论

函数对于任何一个程序开发者我想都不陌生,很多时候也不需要搞清楚函数到底是怎么样调用的;不过对于一个开发MCU的嵌入式软件工程师来说,使用C/C++这种直接操作内存的语言尤其是在MCU上直接操作物理内存,对于一些底层行为有时候必须有一定了解,这有助于开发出高效的程序也能够在出现一些异常时能够迅速意识到问题所在。

首先要说明的是,不同架构的MCU函数调用背后的操作是不一致的,因为我最近在使用51单片机所以这篇文章我就分析了51单片机的函数调用过程。其他架构的MCU并不适用这个分析过程,不过如果有兴趣你也可以阅读,毕竟都是C语言的函数调用它们在有些方面总归是有些相似之处的,即使仅仅了解不同架构也是不错的。

普通函数:

/**
 * @brief   计算数组的和
 * @param   arr: 数组指针
 * @param   count: 数组元素个数
 * @return  返回  数组的和
 */
unsigned int calculateArraySum(unsigned char* arr, unsigned char count) {
  unsigned int sum = 0;
  unsigned char data i;
  for (i = 0; i < count; i++) {
    sum += arr[i];
  }
  return sum;
}
void main() {
  unsigned char testData[5] = {10, 20, 30, 40, 50};
  unsigned int result;
  result = calculateArraySum(testData, 5);
  while (1) {
  }
}

函数调用:

看红色框选的部分在LCALL调用函数calculateArraySum之前,可以看到有几个汇编指令向寄存器移入几个数字,不难猜测这就是给函数传递的参数。其中写入R5寄存器的0x05比较容易看出就是函数的第二个参数,移入R1-R3的数字则比较奇怪,数组testData的地址是0x0000(51地址16位,watch显示0x000000是显示问题),其实写入R3的0x01是因为51单片机特殊的架构需要一位识别通用指针指向的地址类型。

官网对于指针的使用也是有说明,其中指明类型时idata/(包含)data部分是1字节,xdata指针是两字节,三字节通用指针中code :0xFF,xdata :0x01,idata/data :0x00,pdata :0xFE。使用的时候需要注意两点,首先不能够随意进行强制转换否则可能丢失指针信息导致指向错误位置,其次也告诉我们编程的时候如果有可能应该指明类型信息有助于加速程序运行。

Cx51 编译器对于参数传递也有一个约定,最多可以通过 MCU 寄存器传递三个函数参数,下表详细列出了用于不同参数位置和数据类型的寄存器。如果参数超过寄存器可传递的范围,则函数参数将使用固定的内存位置(直接写入被调用的函数运行时使用的RAM地址,这需要链接器配合此处不进行展示)。

参数号char 1-byte ptrint 2-byte ptrlong floatgeneric ptr(3-byte)
1R7R6 & R7R4-R7R1-R3
2R5R4 & R5R4-R7R1-R3
3R3R2 & R3 R1-R3

有一个需要注意的一个特例是位类型,如果第一个参数是位类型会导致传递方案打乱,即使后续参数寄存器足够容纳也不会采用寄存器传递,因此如果有bit类型参数应该声明在参数列表的末尾。

函数运行:

这是刚刚进入calculateArraySum函数的截图,红色框部分是把传入的参数保存到RAM中以供后续使用,除了保存传入的参数还有局部变量sum,i 两个参数都是函数运行时需要占用的RAM空间。

看下面的map文件我们进行简单计算,传入的参数从汇编看一共是3+1个字节保存在X:0x0007开始的部分,然后是unsigned int sum,看watch2窗口可以看到其地址是X:0x000B在RAM中紧邻传入参数,正好是6个字节到X:0x000C结束;最后变量i因为显示声明在data中,对应下图的?DT?MAIN在data中的一个字节。

如果你有兴趣也可以分析一下main函数占用几个字节xdata空间是不是和map文件对的上。需要补充说明的是map文件中有时候并不会区分每个函数存储的位置,可能显示的是一个文件共用了多少xdata/data,如上图的?DT?MAIN部分,也可能显示你所有函数运行时总计占用的局部空间。这是因为当程序复杂优化等级提升运行时不同函数可能使用同一部分RAM空间,具体取决于你的优化等级。

函数返回:

然后看一下图片中sp寄存器调用函数前和进入函数后的变化,我在前面的图片中也进行了标注,你看到的增加了两个字节(0x08->0x0a),并且在增加的地址保存了0x01A7,正是调用函数指令之后的下一个指令,这对于函数运行结束后的返回至关重要。

另外关于返回值有了前面分析参数传递的经验这里再看红框部分,这部分函数返回也是有约定的,两个字节的返回值使用R6,R7寄存器,其他情况可以参照下表。当返回值准备完毕之后,执行ret指令将栈顶保存的返回地址取出写入PC寄存器(ret指令隐含操作无需写明),继续运行函数调用处会依照约定从寄存器取出返回值使用。

返回值类型RegistersStorage Format
bitCarry Flag
char, unsigned char, 1-byte ptrR7
int, unsigned int, 2-byte ptrR6 & R7MSB in R6, LSB in R7
long, unsigned longR4-R7MSB in R4, LSB in R7
floatR4-R732-Bit IEEE format
generic ptrR1-R3Memory type in R3, MSB R2, LSB R1

栈:

看到这里你应该明白了51单片机不是没有栈,而是不会把函数局部变量在栈中分配,没有所谓的栈帧;它会把函数运行局部使用的变量和参数保存到一个提前分配的固定位置(有的叫编译时栈),但是返回值还是会根据SP寄存器依次保存到栈顶的。

还有栈顶位置的确定问题,链接器会先把程序中所有在idata(包含data)中的变量尽量分配到低地址处,然后紧接着一个字节就是栈顶。下面的一段汇编是我从51的启动文件中截取的设置初始栈顶的部分,后续每一次函数调用栈空间增加两字节。idata(包含data)一共256字节,如果出现内部RAM变量过多或者函数调用链极深就有可能溢出发生未知的错误,出现问题时可以结合map文件进行分析。

?STACK          SEGMENT   IDATA
RSEG    ?STACK
DS      1       ; 
MOV     SP,#?STACK-1

可重入函数:

看完上面对calculateArraySum函数的调用全部过程你会发现这个函数局部变量和参数固定使用X:0x0007-X:0x000C这部分RAM,这就带来一个问题,如果主程序中正在运行calculateArraySum函数,这时候进入一个中断同样调用了calculateArraySum函数会怎么样,毫无疑问会把这主程序中正在使用的数值覆盖掉,等中断结束这部分已经被修改的不能使用了。

keil-C51提供了一个解决办法就是使用reentrant关键字修饰函数,一旦修饰调用该函数时会在指定RAM区域模拟一个运行时分配的栈,局部变量和参数都保存在栈中达到可以同时多次调用同一函数的效果。

int calc (char i, int b) reentrant  {
  int  x;
  x = table [i];
  return (x * b);
}

不过使用reentrant关键字时也不要忘记修改启动文件XBPSTACK为1启动模拟堆栈,XBPSTACKTOP设置最高地址(这个值是xdata空间最高地址+1);如果用IBPSTACK,IBPSTACKTOP也是同理。不建议使用模拟堆栈,因为51单片机模拟堆栈这个操作比较耗时,即使非要使用也不建议启用IBPSTACK将模拟堆栈在idata区域,很容易和硬件栈冲突。

IBPSTACK        EQU     0       ; set to 1 if small reentrant is used.
IBPSTACKTOP     EQU     0xFF +1     ; default 0FFH+1  
XBPSTACK        EQU     0       ; set to 1 if large reentrant is used.
XBPSTACKTOP     EQU     0xFFFF +1   ; default 0FFFFH+1 

另外让人容易误解的是并不是你在函数后面加上reentrant修饰这个函数就一定是可重入函数,reentrant关键字只能帮你解决掉了局部变量和参数使用同一内存空间的问题。如果函数内部使用了全局变量,函数仍然不可重入,而且这个这时候编译器也检查不出来,需要自己注意。

上一篇
51单片机中断系统与响应优化
下一篇
没有下一篇

评论已关闭

© 2025-2026 嵌入式启航.
豫ICP备2025159703号    备案图标 豫公网安备41172102000273号