写这篇是因为在另一篇博客中讲RO、RW、ZI的时候提到函数内部的局部变量存在栈里,然后刚好自己也没有亲自观察过局部变量到底是怎么在栈里面创建和回收的,所以在这里详细分析一下。

  本文中使用的MCU是STM32F407IG,IDE是IAR Embedded Workbench7.7。既然是要看栈操作,肯定要看汇编代码,IAR默认是不输出C文件对应的汇编代码的,需要我们在工程属性设置中打开开关,找到:Options - C/C++ Compiler - List - Output assembler file,将其勾选,同时也把Include source勾选,方便对比C和汇编。
  设置好后,重新编译一下,会在Debug/List目录下给每个.c文件生成对应的.s文件,这就是我们要看的汇编文件了。也可以在IDE里面直接查看,方法是点击c文件左侧的+号,再点击Output左侧的+号,就可以看到.s文件了。

1. 变量入栈顺序

先贴出C文件代码,只有一个简单的函数:

1
2
3
4
5
6
7
int main(void)
{
int c;
int d = 2;
c = d + 1;
return c;
}

查看对应的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
//    1 int main(void)
// 2 {
// 3 int c;
// 4 int d = 2;
main:
MOVS R1,#+2
// 5 c = d + 1;
ADDS R2,R1,#+1
MOVS R0,R2
// 6 return c;
BX LR ;; return
// 7 }

  可以看到只用到了R0、R1、R2这3个寄存器,找不到变量c、d的影子。这是因为它们被编译器优化掉了,这种确实没什么用的变量即使把优化等级调到最低也没用,你可以试一下。好在我们还有一种办法可以确保变量不被优化,那就是volatile关键字,对代码稍作修改:

1
2
3
4
5
6
7
int main(void)
{
volatile int c;
volatile int d = 2;
c = d + 1;
return c;
}

  重新编译,查看新的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//    1 int main(void)
// 2 {
main:
SUB SP,SP,#+8
// 3 volatile int c;
// 4 volatile int d = 2;
MOVS R0,#+2
STR R0,[SP, #+0]
// 5 c = d + 1;
LDR R0,[SP, #+0]
ADDS R0,R0,#+1
STR R0,[SP, #+4]
// 6 return c;
LDR R0,[SP, #+4]
ADD SP,SP,#+8
BX LR ;; return
// 7 }

  这次可以看到变量c、d的操作过程了,我们一步步来分析一下整个函数的动作。

  1. 因为函数有两个int变量,共需要8个字节,所以进来之后,首先把SP减8,给变量留出空间;
  2. 接着给R0赋值2,然后存入SP当前指向的地址,这个地址就是变量d了;
  3. 然后取出变量d到R0中(volatile关键字要求变量在每次使用的时候都重新从内存里面取),执行+1操作后,存到SP+4的地址,SP+4也就是变量c了;
  4. 取出变量c到R0中,作为函数返回值;
  5. 栈指针SP+8,恢复到进函数之前的栈地址
  6. 返回到LR中保存的地址处

  由于ARM的栈是满减栈,从高地址向低地址生长,所以如果把上面变量c、d的初始化过程看做入栈操作的话,变量c的地址高于变量d,所以c先入栈,d后入栈,(注意入栈的先后顺序只跟地址高低有关,跟赋初值的时机无关)。
  因此我们有了结论:对于IAR的ARM编译器,函数内部局部变量的入栈顺序是先声明先入栈。

2. 栈对齐

  我们知道Cortex M系列MCU的栈至少是4字节对齐的(有些时候要求8字节对齐,因为有些AAPCS函数要求栈8字节对齐,所以最好是保证从汇编进入C时栈是8字节对齐的),并且SP的最后两位是在硬件上保持0的,所以想不遵守都不可能。那么当我们的局部变量不是4字节对齐的时候是怎么入栈的?在验证之前我们先推测一下,如果变量不足4字节,则剩余的字节补0,下面来验证一下:
C:

1
2
3
4
5
6
7
int main(void)
{
volatile int c;
volatile char d = 2;
c = d + 1;
return c;
}

汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//    1 int main(void)
// 2 {
main:
SUB SP,SP,#+8
// 3 volatile int c;
// 4 volatile char d = 2;
MOVS R0,#+2
STRB R0,[SP, #+0]
// 5 c = d + 1;
LDRB R0,[SP, #+0]
ADDS R0,R0,#+1
STR R0,[SP, #+4]
// 6 return c;
LDR R0,[SP, #+4]
ADD SP,SP,#+8
BX LR ;; return
// 7 }

  可以看到虽然变量d只有1字节长度,在入栈的时候还是按照4字节对齐的方式,只是在存储的时候使用了STRB指令,即只把寄存器的最低字节赋值给变量。
  等等,似乎没有把剩余的3个字节清零的操作,这样当我强制把char型变量d当成int来使用的时候如果取了4个字节,岂不是会出错?这与实际编程中的经验char型强制类型转换成int型时值不变好像是矛盾的,我们做个试验验证一下,用仿真的方式把这个程序跑起来,实验代码如下:

1
2
3
4
5
6
7
8
int main(void)
{
volatile int c;
volatile char d = 2;
c = (int)d + 1; // char强转为int
c = *((int *)&d) + 1; // char*强转为int*再取int值
return c;
}

  由于特殊原因无法截图,下面就用文字的方式描述一下实验过程
刚进入main函数时的状态:

1
2
3
4
SP = 0x10002000  
栈里的内容:
0 4 8 c
0x10001FF0 : cd cd cd cd cd cd cd cd cd cd cd cd cd cd cd cd

执行完c = (int)d + 1;后,c的值为0x03
执行完c = *((int *)&d) + 1;后,c的值为0xcdcdcd03
结论很明显:

  1. 确实没有对剩余的3个字节清零
  2. char强转为int后值不变
  3. char*强转为int*再取int值后值可能会变化

其实结论2和3对于稍微有点儿编程经验的人来说都是常识,我们继续深入看一下为什么会这样,继续上汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//    1 int main(void)
// 2 {
main:
SUB SP,SP,#+8
// 3 volatile int c;
// 4 volatile char d = 2;
MOVS R0,#+2
STRB R0,[SP, #+0]
// 5 c = (int)d + 1;
LDRB R0,[SP, #+0]
ADDS R0,R0,#+1
STR R0,[SP, #+4]
// 6 c = *((int *)&d) + 1;
LDR R0,[SP, #+0]
ADDS R0,R0,#+1
STR R0,[SP, #+4]
// 7 return c;
LDR R0,[SP, #+4]
ADD SP,SP,#+8
BX LR ;; return
// 8 }

  从上面的汇编可以看出,在对变量c的两次赋值操作中,只有一行语句是不一样的:
(int)d这个值的时候对应的是这句

1
LDRB     R0,[SP, #+0]

*((int *)&d)这个值的时候对应的是这句

1
LDR      R0,[SP, #+0]

关键就在于LDRB和LDR的区别,LDRB只取SP指向的地址处的1字节数据,而LDR取4字节,所以结果就不一样了。这里也可以看出C语言指针的强大和灵活性。

3. Keil和IAR的不同处理方式

  依旧是这段C代码,拿到Keil里编译之后的汇编代码如下:

1
2
3
4
5
6
7
8
9
10
main    PROC
PUSH {r2,r3,lr}
MOVS r0,#2
STR r0,[sp,#0]
LDR r0,[sp,#0]
ADDS r0,r0,#1
STR r0,[sp,#4]
LDR r0,[sp,#4]
POP {r2,r3,lr}
ENDP

  我刚看到这段代码的时候,没有细想push{r2,r3,lr}的作用,看到下面没有进行SP减少直接就进行赋值的操作,很疑惑,PUSH操作的入栈顺序是从右向左的,执行完PUSH {r2,r3,lr}之后,SP指向了被PUSH进去的r2。这时候直接对SP指向的地址赋值,不就把刚刚入栈的r2,r3给覆盖了吗?而且最后POP出来的r2,r3根本就不是他们被PUSH进去的值了。
  后来想了一下终于想明白了,首先PUSH会产生两个作用,一是寄存器入栈,二是栈指针偏移。所以keil执行PUSH {r2,r3,lr}仅仅是为了让栈指针偏移,给变量留出空间,而且整个函数里没有用到r2,r3,所以它有没有被覆盖对整个函数功能没有影响。

  虽然函数功能是实现了,但是Keil的这种实现方式着实不优雅,而且相对于IAR的实现方式,PUSH和POP的过程多了6次无意义的存储器访问。

分享一个面试题

题目如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int fun(const int src[16])
{
int val1 = 0;
int dst[16] = {0};
int val2 = 0;
const int *pscr = src;
// 问题1:如下代码输出什么?
fprintf(stdout, "size of src=%lu\n", sizeof(src));
// 问题2:如下代码有什么副作用?
for (int i = 0; i <=16; i++) {
dst[i] = *psrc + i;
psrc++;
}
return 0;
}

问题1比较简单,跟机器字长有关,32位机就是4,64位机就是8。
  这里有个小陷阱就是const int src[16]这个形参其实和const int *src这种写法是等价的,都代表传入的参数src是个int型指针,只不过用数组的写法能提高代码的可读性,虽然用了数组的写法,但是src是个指针而不是数组。对比之下sizeof(dst)就等于64,dst是个数组,对数组取sizeof的作用是求数组的大小。

问题2的答案是:val1被覆盖。这里涉及到以下几个知识点:

  1. 栈的生长方向一般是向下的,又叫满减栈
  2. 局部变量的入栈顺序是先声明的先入栈
  3. 数组中元素地址随下表增长,也就是dst[0]的地址小于dst[1]

因此,val1首先入栈,然后是dst,然后是val2,val1的地址最高,当dst的下标溢出时,会向高地址覆盖,也就是覆盖了val1。他们在栈里面的存储情况如下:

XXX (变量声明前SP指向的位置)
val1 高地址
dst[15]
dst[14]
dst[13]
dst[12]
dst[11]
dst[10]
dst[9]
dst[8]
dst[7]
dst[6]
dst[5]
dst[4]
dst[3]
dst[2]
dst[1]
dst[0]
val2 低地址
pscr (变量声明后SP指向位置)