51单片机的仿真栈(模拟栈/可重入栈)
51单片机的仿真栈(又叫模拟栈、或者可重入栈)。
首先来看,51的系统栈(又叫系统栈,或者硬件栈),就是SP所指向的栈,他是一个满增栈(注释1),位于片内RAM的128 bytes之中,上电之后系统堆栈指针SP的初值等于多少呢?这个要从51的启动文件来分析,启动文件中有这样的汇编代码:
?STACK SEGMENT IDATA ;定义一个片内数据段,段名:?STACK
RSEG ?STACK ;选择之前定义过的一个可重定位的段?STACK,下面的汇编语句将会被放置到该段,直到遇到下一个段定位指令,例如CSEG/RSEG。
DS 1 ;预留存储区命令。声明先占用一个字节的空间,在编译时,这个预留的空间不会被其他变量所使用。在这里的意义是,给硬件栈分配1个byte(实际这样是有问题的,应该为硬件栈预留更多空间)
还有:
MOV SP,#?STACK-1
由上可见,SP被初始化为#?STACK-1,在#?STACK地址处,DS指令预留了N个字节的空间,这些空间就是硬件栈的空间
但启动文件的代码中,DS 1相当于只给硬件栈预留了1个字节,这实际上会出问题,原因如下:片内RAM中会有多个数据段,只要使用XX SEGMENT IDATA指令即可在片内RAM中声明一个数据段XX,如果整个工程程序中,声明了多个数据段,?STACK数据段就只是片内RAM中众多数据段中的一个,如果只给?STACK段预留1个字节,而?STACK数据段后面又有别的数据段,那么我们的硬件栈就只有1个字节了,一旦发生中断,CPU寄存器自动入栈立即导致栈溢出,溢出后踩了别的变量的内存,程序基本崩溃;对于这个问题,keil是这样处理的:keil在链接阶段总是把?STACK数据段链接为片内RAM中的最后一个数据段,即使我们只给他预留了1个字节,那也不要紧,反正该段后面没有别的变量占用,只要SP别超出0X7F(片内RAM地址的上限)就行了。通过观察.m51(map文件)我们发现,keil确实是把?STACK数据段放到了片内RAM的最后,下面是某个51工程生成的map文件摘抄:
* * * * * * * D A T A M E M O R Y * * * * * * *
REG 0000H 0008H ABSOLUTE "REG BANK 0"
DATA 0008H 0002H UNIT ?C?LIB_DATA
IDATA 000AH 000DH UNIT ?ID?UCOS_II
0017H 0009H *** GAP ***
BIT 0020H.0 0000H.1 UNIT ?BI?SERIAL
0020H.1 0000H.7 *** GAP ***
IDATA 0021H 0041H UNIT ?STACK ; 作者注:就是这一行!
* * * * * * * X D A T A M E M O R Y * * * * * * *
XDATA 0000H 080EH UNIT ?XD?SERIAL
XDATA 080EH 0804H UNIT ?XD?MAIN
XDATA 1012H 0490H UNIT ?XD?UCOS_II
XDATA 14A2H 005CH UNIT _XDATA_GROUP_
为避免系统栈不够用,一个比较稳妥的办法就是,用汇编指令DS给?STACK数据段预留更多的空间,上面这个51工程中在另一个汇编文件中又给?STACK数据留出了40H个字节,这样总共就有41H个字节了。这样做的好处是可以在编译链接阶段即可排查堆栈错误,举个例子: 假设片内RAM中的数据段有很多,以至于,除了?STACK数据段之外,片内RAM只剩2个字节了,而?STACK数据段我们只默认采用了启动文件中的配置预留一个字节,这样编译没有任何问题,keil给编译通过了,但是运行过程中系统栈只有2个字节,肯定是分分钟就发生栈溢出,然后崩溃;假设片内RAM中的数据段有很多,以至于,除了?STACK数据段之外,片内RAM只剩2个字节了,而如果我们给?STACK数据段用DS指令分配40H个字节,这样keil在编译时就会发现51的片内RAM不足而报错,无法编译,从而在编译链接阶段帮助我们发现堆栈问题。
继续上面的问题,SP复位后的初值是多少,SP复位后等于0X07,但是立即就被启动文件通过语句MOV SP,#?STACK-1给改掉了,所以在进入main函数时SP的值是启动文件修改后的值,也即#?STACK-1(注,很好理解,这里-1是满增栈的特性),那么#?STACK的值又是多少呢?看上面的汇编语句?STACK SEGMENT IDATA,这一句声明?STACK段为一个可重定位的段,也就是说,?STACK段的首地址(#?STACK)在编译器进行程序链接时才能确定下来,也就是说,#?STACK的值是在链接时由编译器自动分配的,编译阶段不分配。仍然以上面摘抄的这段map文件为例,我们发现,?STACK段的起始地址是0021H,也就是说,#?STACK就等于21H。
仿真栈是keil为51生成可重入函数时用的(通过给函数使用关键词 REENTRANT限定,可使该函数具备可重入特性),对于STM32来说,默认生成的函数(不含全局变量和静态局部变量的函数)就是可重入的,而keil为51生成的函数,即使这个函数不含全局变量和静态局部变量,默认情况下keil也不会把这个函数汇编成可重入的,我认为keil主要是考虑到51的片内RAM匮乏,在不外接RAM的情况下,函数如果被编译为可重入的,可重入函数的执行需要占用一定的栈空间(尤其是由可重入函数嵌套调用产生的长的调用链,所需的栈更多)。
可重入函数在执行过程中是需要使用栈的,那么51的可重入函数使用的栈在哪呢?是SP指向的那个系统栈吗?答案是:不是。下面是解释:
当我们给51外扩了大的片外RAM时,就不用担心RAM不够的问题了,但是还有一个问题,系统栈指针SP只能寻址0~7FH共128字节的空间,可重入函数肯定不允许被编译成使用系统栈,否则,就算外扩了RAM,这个外扩RAM又无法供系统栈来使用,外扩RAM就没有意义了,所以keil为51打造了一个仿真栈的概念,keil在启动文件中声明了一个1或2字节的变量作为栈指针,这个栈指针的名字和大小根据编译模式的不同而不同,以大编译模式(注释2)为例,大编译模式下,启动文件中的XBPSTACK常量需要程序员手动设置为1,这样启动文件中使用到的条件编译,将会引用到一个2字节的仿真栈指针?C_XBP,由于keil把仿真栈作为满减栈,所以这个仿真栈指针?C_XBP被初始化为片外RAM地址的最大值加1,若我们外接了一个64K的片外RAM,该RAM的最大地址是0XFFFF,那么栈指针?C_XBP被初始化为0XFFFF+1=溢出为0x0000。再举一个小编译模式的例子,小编译模式是用来给没有外扩RAM的51用的,这样51只能使用片内0~127共128字节的RAM(这128RAN中还有一部分是Rn等,留给程序可用的RAM就更少了),在小编译模式下,keil给51生成的仿真栈指针名叫?C_IBP,同时需要程序员手动把IBPSTACK常量设置为1,指针?C_IBP的初值被初始化为可用RAM的最大地址(127)加1,也即0x7f+1。关于小编译模式small、压缩编译模式compact、大编译模式large在堆栈处理上方面的不同,可参考这篇文章点击打开链接,如果链接挂了,可自行搜索:《Keil模式设置和编程事项》。
注释1:满增栈,满指的是SP总是指向最后一个入栈的字节的地址,增指的是每入栈一次,SP变大。相应的,还有空增栈、空减栈、满减栈,空指的是SP总是指向栈中下一个空闲位置的地址。
注释2:如何选择大编译模式:以keil5为例,依次选择->魔术棒->Target选项卡,Memory Model选择Large:var...,Code Rom Size选择Large....
附:举一个不可重入函数使用中可能发生的陷阱,假设有分别有如下两个函数,第一个可重入,第二个不可重入
int add5_re(char a1,char a2,char a3,char a4,char a5) REENTRANT
{
int sum;
sum=a1+a2+a3+a4+a5;
return sum;
}
int add5(char a1,char a2,char a3,char a4,char a5)
{
int sum;
sum=a1+a2+a3+a4+a5;
return sum;
}
这两个函数的形参以及局部变量分配等信息我们查阅.m51文件,分别如下(分号后面的注释是博主自己加上的):
[plain] view plain copy------- PROC _?ADD5_RE
x:0002H SYMBOL a1 ;注意,地址标号前为小x,指a1倍分配到了仿真栈中
x:0003H SYMBOL a2
x:0004H SYMBOL a3
x:0005H SYMBOL a4
x:0006H SYMBOL a5
------- DO
x:0000H SYMBOL sum
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
------- PROC _ADD5
D:0007H SYMBOL a1 ;R7
D:0005H SYMBOL a2 ;R5
D:0003H SYMBOL a3 ;R3
X:14ABH SYMBOL a4 ;注意地址标号前为大X,指外部RAM
X:14ACH SYMBOL a5
------- DO
D:0006H SYMBOL sum ;R6
我们发现,add5中的形参和局部变量a1/a2/a3/sum分到了Rn中,a4/A5分到了外部RAM xdata的绝对地址处,如果我们在main的调用链中和中断函数中都调用了add5这个函数,就会发生错误,假设恰好在main的调用链中执行add5时发生了中断,切换到中断函数中去执行add5,那么main调用链中的a1/a2/a3/sum因为被分到了Rn中,进入中断会切换register BANK,使得main调用链中的a1/a2/a3/sum没有被破坏,得以幸免,但是a4/a5因为被分配到了绝对地址中,在中断执行完add5以后,main链条中的add5的a4/a5肯定会被破坏!!
对于可重入的add5_re函数,即使main调用链和中断同时调用它也不会出现上述被破坏的情形,因为add5_re的形参和局部变量全部都被定义到了仿真栈中(见上述代码注释),main调用链中使用add5_re函数会申请栈空间,中断时add5_re又会申请新的栈空间。
还要注意的是,因为keil编译51程序时,使用了覆盖技术(不同函数的形参和局部变量可分时共享同一个绝对内存单元),这也有可能产生陷阱,假设这样一种情况:有一个函数func2( )的局部变量b在编译后被分配到了绝对xdata的地址14ABH处,和上文的add5的a4变量共享内存,这种情况下,即使 { func2( )仅在中断中被调用,main调用链中不调用func2( )}、且{ add5仅在main调用链中被调用,中断中不调用add5 },也会出问题,原因是显而易见的,如果在add5执行过程中发生中断,中断中使用过变量b之后,会破坏add5中的变量a4。究其原因在于,共享地址的编译方式生成的函数,只要分时调用就不会产生被破坏的情形,但是发生中断导致了分时机制被破坏,以至于产生了同时调用。
结论:中断中使用的函数,要么是可重入的,要么是该函数的局部变量全部是独享内存单元的。