C编译器学习笔记(七)函数调用及栈分析

调用函数时的参数传递无非就是将参数放到固定位置,函数内部再到固定位置取取值,常用两个位置:

  1. 放到寄存器中
  2. 放到栈中

不同架构对于函数传参有不同的约定,例如x86-32架构是把参数全部放到栈中的,x86-64和ARMv7架构前4个参数放到指定寄存器中,其余参数放到栈中。

想搞清楚各架构函数如何传参的,其实只需要写一个最简单的demo,然后用下面的命令生产各架构的汇编,分析汇编就能知道如何入栈出栈的:

arm-none-eabi-gcc -S -mcpu=cortex-m4 -mthumb demo.c -o armv7.c
gcc -S -fno-asynchronous-unwind-tables demo.c -o x86-64.c
gcc -S -m32 -fno-asynchronous-unwind-tables demo.c -o x86-32.c

ARM架构下的例子

x86架构汇编相对比较繁琐,并且用的最多的STM32系列芯片是ARM架构的。所以我们以ARMv7-m架构为例。

The registers R0-R12, SP, LR, and PC are named the Arm core registers. These registers can be described as R0-R15。R7经常用作基址寄存器,对应于x86下的BP寄存器,取值时以R7为基准,因为SP不能随意改动。

C语言中调用函数只需要一句:

myfunction(v1,v2,v3);

但如果翻译成汇编,其实做了很多事情:

	......
	pushal    	# 寄存器压栈
	pushl	%eax	# 参数压栈
	pushl 	%ebx
	pushl 	%ecx
	call 	foo	# 调用函数
	popl 	%ecx
	popl 	%ebx
	popl 	%eax
	popal			
	......
foo:
	pushl 	%ebp
	movl 	%esp, %ebp
	......
	popl 	%ebp
	ret

如果不理解汇编,没关系,来看图:

image

在进入新的函数之前,需要将寄存器放入栈中保存,调用函数时的参数压入栈中,函数的返回地址压入栈中。函数调用时还额外保存了一个叫call's EBP的东西,并且用一个寄存器EBP指向这个位置。这是做什么的?

EBP主要有两个功能:

  1. 方便获取参数,参数总在EBP的上方两个地址处
  2. 通过EBP的链接关系,可以追溯函数调用关系

变参函数

#include <stdio.h>
 
void f(int a, int b){       -------     a, b are parameters          
    int c = a + b;          -------     c is local variables.
            
    printf("%d \n",c);
}
int main(){
    f(3,4);
    return 0;
}

movl 20(%ebp), %eax         --------    a is 20(%ebp)
addl 24(%ebp), %eax         --------    b is 24(%ebp)
movl %eax, -4(%ebp)         --------    c is -4(%ebp)

#include <stdio.h>

float a = 1.0f;
double b = 2.0;
int c = 3;
char d = 'q';

int main(int argc, char * argv[]){
	printf("a = %f,b = %f,c = %d,d = %c\n",a,b,c,d);
	return 0;
}

在定义printf的时候,是不知道我们要传多少个参数的。例如我们的参数a、b、c、d,这些参数在函数 printf 的声明中并没有与之对应的形参。

按 C 标准的要求,C 编译器会对这些无名参数进行实参提升的操作,即把小于 int 型的 char 和 short 提升为 int 类型,把 float 类型提升为 double 类型。

从编译器生成的抽象语法树中可以看出实参 a 从float类型被转换成 double 类型,实参 d 从char类型被转换成 int 类型:

function main
{
  (call printf
        str0,
        (cast double
              a),
        b,
        c,
        (cast int
              d))

  (ret 0)
}

编译器产生的中间代码更直观地反映了实参提升的过程:

function main
	t0 :&str0;
	t1 : (double)(float)a;
	t2 : (int)(char)d;
	printf(t0, t1, b, c, t2);
	return 0;
	ret

再从汇编的角度看一下参数如何传递的:

.data
.str0:	.string	"a = %f,b = %f,c = %d,d = %c\012"

.align 4
.globl	a
a:	.long	1065353216

.align 8
.globl	b
b:	.long	0
.long	1073741824

.globl	c
c:	.long	3

.globl	d
d:	.byte	113

.text
.globl	main
main:
	pushl %ebp
	pushl %ebx
	pushl %esi
	pushl %edi
	movl %esp, %ebp
	subl $16, %esp
.BB0:
	leal .str0, %eax
	flds a           // 把 float 类型的 a 转换为 double 类型
	fstpl -12(%ebp)  // 转换后的结果存到临时变量-12(%ebp)中
	movsbl d, %ecx   // 把参数 d 由 char 到 int 的转换
	pushl %ecx       // 参数d入栈
	pushl c          // 参数c入栈
	subl $8, %esp    // 从全局静态数据区加载双精度浮点数 b 并入栈
	fldl b           //
	fstpl (%esp)     //
	subl $8, %esp    // 从临时变量-12(%ebp)中 加载双精度浮点数,并入栈
	fldl -12(%ebp)   //
	fstpl (%esp)     //
	pushl %eax       // 把格式化字符串的首地址入栈
	call printf      // 调用printf
	addl $28, %esp
	movl $0, %eax
	movl %ebp, %esp
	popl %edi
	popl %esi
	popl %ebx
	popl %ebp
	ret

由于所有的参数都是存放在栈中,printf只需按照格式化字符串的说明,从栈中取出相应的参数:

// 实际上只有 10 这一个参数,但 printf 看到有两个%d, 
// 于是仍试图从栈中取两个参数,打印出形如 10,1074172310 的垃圾值 
printf(“%d, %d “,10);

// 实际上有 10,20,30 这 3 个参数,但 printf 只看到一个%d, 
// 于是只打印出参数 10 
printf(“%d”, 10,20,30);

按照 C 调用约定,参数按从右到左的次序依次入栈:

image

我们通过表达式&format可以知道 format 在栈中的内存地址。根据格式化字符串还可以知道其他参数的类型及个数,从而可以推算出其他参数在栈中的位置,进而访问他们:

int printf(const char *format, ...);

float a = 1.0f;
double b = 2.0;
int c = 3;
char d = 'q';

// 不同的平台栈的内存布局不同,以下代码仅适用于i386
void OurPrintfV1(const char *format, ...){
	unsigned int addr = (unsigned int) &format;
	addr += sizeof(char *);
	printf("%f ", *((double *)addr));
	addr += sizeof(double);
	printf("%f ", *((double *)addr));
	addr += sizeof(double);
	printf("%d ", *((int *)addr));
	addr += sizeof(int);
	printf("%c ", (char)(*((int *)addr)));
}

int main(){
	OurPrintfV1("a=%f,b=%f,c=%d,d=%c \n",a,b,c,d);
	return 0;
}

由 format 的地址定位其他无名参数位置的代码可以写成宏定义,这样可以让代码看起来更简洁:

int printf(const char *format, ...);

float a = 1.0f;
double b = 2.0;
int c = 3;
char d = 'q';

// 不同的平台栈的内存布局不同,以下代码仅适用于i386
typedef char * va_list;
#define ALIGN_INT(n) ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))
#define va_start(list, start) (list = (va_list)&start + ALIGN_INT(start))
#define va_arg(list, t) (*(t *)((list += ALIGN_INT(t)) - ALIGN_INT(t)))
#define va_end(list) (list = (va_list)0)

void OurPrintfV1(const char *format, ...){
	va_list ap;
	va_start(ap,format);
	printf("%f ",va_arg(ap,double));
	printf("%f ",va_arg(ap,double));
	printf("%d ",va_arg(ap,int));
	printf("%c \n",(char)va_arg(ap,int));
	va_end(ap);
}

int main(){
	OurPrintfV1("a=%f,b=%f,c=%d,d=%c \n",a,b,c,d);
	return 0;
}

我们有意定义这些宏,是因为这些宏已经在标准C头文件stdarg.h中被实现。头文件stdarg.h中的宏有对各种平台都有不同的处理,所以下面的代码可以在所有平台上运行:

#include <stdarg.h>

int printf(const char *format, ...);

float a = 1.0f;
double b = 2.0;
int c = 3;
char d = 'q';


void OurPrintfV1(const char *format, ...){
	va_list ap;
	va_start(ap,format);
	printf("%f ",va_arg(ap,double));
	printf("%f ",va_arg(ap,double));
	printf("%d ",va_arg(ap,int));
	printf("%c \n",(char)va_arg(ap,int));
	va_end(ap);
}

int main(){
	OurPrintfV1("a=%f,b=%f,c=%d,d=%c \n",a,b,c,d);
	return 0;
}
posted @ 2022-05-16 20:43:08
评论加载中...
发表评论