函数参数传递与激活记录详解
1. 函数或过程的参数传递
1.1 参数数量和类型的影响
参数的数量和类型会对编译器为过程和函数生成的代码效率产生重大影响。简单来说,传递的参数数据越多,过程或函数调用的开销就越大。程序员常常调用或设计通用函数,这些函数需要传递多个可选参数,但函数可能并不会使用这些参数的值。这种做法可以使函数更广泛地适用于不同应用,但也会带来一定的代价。如果对空间或速度有要求,可能需要考虑使用特定于应用的函数版本。
1.2 参数传递机制
参数传递机制(如按引用传递或按值传递)也会影响过程调用和返回的开销。一些语言允许按值传递大型数据对象,例如 Pascal 允许按值传递字符串、数组和记录,C/C++ 允许按值传递结构体。当按值传递大型数据对象时,编译器必须生成机器代码将数据复制到过程的激活记录中,这可能非常耗时,尤其是复制大型数组或结构体时。此外,大型对象可能无法放入 CPU 的寄存器组,因此在过程或函数中访问此类数据的成本也很高。通常,按引用传递大型数据对象(如数组和结构体)比按值传递更高效,因为间接访问数据的额外成本通常会因无需将数据复制到激活记录中而节省很多。
以下是一个按值传递大型结构体的 C 代码示例:
#include <stdio.h>
typedef struct
{
int array[256];
} a_t;
void f( a_t a )
{
a.array[0] = 0;
return;
}
int main( void )
{
a_t b;
f( b );
return( 0 );
}
GCC 生成的 PowerPC 代码如下:
.text
.align 2
.globl _f
_f:
li r0,0 ; To set a.array[0] = 0
;Note: the PowerPC ABI passes the
; first eight dwords of data in
; R3..R10. We need to put that
; data back into the memory array
; here:
stw r4,28(r1)
stw r5,32(r1)
stw r6,36(r1)
stw r7,40(r1)
stw r8,44(r1)
stw r9,48(r1)
stw r10,52(r1)
;Okay, store zero into a.array[0]:
stw r0,24(r1)
;Return to caller:
blr
; Main function:
.align 2
.globl _main
_main:
;Set up main's activation record:
mflr r0
li r5,992
stw r0,8(r1)
;Allocate storage for a:
stwu r1,-2096(r1)
;Copy all but the first
; eight dwords to the
; activation record for f:
addi r3,r1,56
addi r4,r1,1088
bl L_memcpy$stub
;Load the first eight dwords
; into registers (as per the
; PowerPC ABI):
lwz r9,1080(r1)
lwz r3,1056(r1)
lwz r10,1084(r1)
lwz r4,1060(r1)
lwz r5,1064(r1)
lwz r6,1068(r1)
lwz r7,1072(r1)
lwz r8,1076(r1)
;Call the f function:
bl _f
;Clean up the activation record
; and return zero to main's caller:
lwz r0,2104(r1)
li r3,0
addi r1,r1,2096
mtlr r0
blr
;Stub function that copies the structure
; data to the activation record for the
; main function (this calls the C standard
; library memcpy function to do the actual copy):
.data
.picsymbol_stub
L_memcpy$stub:
.indirect_symbol _memcpy
mflr r0
bcl 20,31,L0$_memcpy
L0$_memcpy:
mflr r11
addis r11,r11,ha16(L_memcpy$lazy_ptr-L0$_memcpy)
mtlr r0
lwz r12,lo16(L_memcpy$lazy_ptr-L0$_memcpy)(r11)
mtctr r12
addi r11,r11,lo16(L_memcpy$lazy_ptr-L0$_memcpy)
bctr
.data
.lazy_symbol_pointer
L_memcpy$lazy_ptr:
.indirect_symbol _memcpy
.long dyld_stub_binding_helper
从上述代码可以看出,调用函数
f
时会调用
memcpy
将数据从
main
的局部数组复制到
f
的激活记录中,复制内存是一个缓慢的过程,因此应避免按值传递大型对象。
以下是按引用传递相同结构体的代码示例:
#include <stdio.h>
typedef struct
{
int array[256];
} a_t;
void f( a_t *a )
{
a->array[0] = 0;
return;
}
int main( void )
{
a_t b;
f( &b );
return( 0 );
}
GCC 将此 C 源代码转换为 PowerPC 汇编代码如下:
.text
.align 2
.globl _f
; function f:
_f:
li r0,0 ;Store zero into
stw r0,0(r3) ; a.array[0]
blr ;Return to main
.align 2
.globl _main
; The main function:
_main:
; Build main's activation record
mflr r0 ;Save return adrs
stw r0,8(r1)
stwu r1,-1104(r1) ;Allocate a
;Pass the address of a to f in R3:
addi r3,r1,64
;Call f:
bl _f
;Tear down activation record
; and return:
lwz r0,1112(r1)
li r3,0
addi r1,r1,1104
mtlr r0
blr
1.3 小数据对象的传递
根据 CPU 和编译器的不同,按值传递小(标量)数据对象可能比按引用传递略高效。例如,使用 80x86 编译器在栈上传递参数时,按引用传递内存对象需要两条指令,而按值传递同一对象只需要一条指令。因此,虽然按引用传递大型对象是个好主意,但对于小对象通常相反。不过,这不是绝对的规则,其有效性会因使用的 CPU 和编译器而异。
1.4 使用全局变量传递数据
一些程序员认为通过全局变量将数据传递给过程或函数更高效,因为如果数据已经存储在过程或函数可访问的全局变量中,调用该过程或函数时无需额外指令来传递数据,从而减少调用开销。然而,编译器在优化过度使用全局变量的程序时会遇到困难。虽然使用全局变量可能会减少函数/过程调用的开销,但也可能会阻止编译器进行其他原本可行的优化。
以下是一个使用 Microsoft Visual C++ 的简单示例:
#include <stdio.h>
// Make geti an external function
// to thwart constant propagation so we
// can see the effects of the following
// code.
extern int geti( void );
// globalValue is a global variable that
// we use to pass data to the "usesGlobal"
// function:
int globalValue = 0;
// Inline function demonstration. Note
// that "_inline" is the MSVC++ "C" way
// of specifying an inline function (the
// actual "inline" keyword is a C++ feature,
// which this code avoids in order to make
// the assembly output a little more readable).
_inline int usesGlobal( int plusThis )
{
return globalValue+plusThis;
}
_inline int usesParm( int plusThis, int globalValue )
{
return globalValue+plusThis;
}
int main( int argc, char **argv )
{
int i;
int sumLocal;
int sumGlobal;
// Note: the call to geti inbetween setting globalValue
// and calling usesGlobal is intentional. The compiler
// doesn't know that geti doesn't modify the value of
// globalValue (and neither do we, frankly), therefore,
// the compiler cannot use constant propagation here.
globalValue = 1;
i = geti();
sumGlobal = usesGlobal( 5 );
// If we pass the "globalValue" as a parameter rather
// than setting a global variable, then the compiler
// can optimize the code away:
sumLocal = usesParm( 5, 1 );
printf( "sumGlobal=%d, sumLocal=%d\n", sumGlobal, sumLocal );
return 0;
}
MSVC++ 编译器为该代码生成的 MASM 源代码(带有手动注释)如下:
_main PROC NEAR
; globalValue = 1;
mov DWORD PTR _globalValue, 1
; i = geti();
;
; Note that because of dead code elimination,
; MSVC++ doesn't actually store the result
; away into i, but it must still call geti()
; because geti() could produce side-effects
; (such as modifying globalValue's value).
call _geti
; sumGlobal = usesGlobal( 5 );
;
; Expanded inline to:
;
; globalValue+plusThis
mov eax, DWORD PTR _globalValue
add eax, 5 ; plusThis = 5
; The compiler uses constant propagation
; to compute:
; sumLocal = usesParm( 5, 1 );
; at compile time. The result is six, which
; the compiler directly passes to print here:
push 6
; Here's the result for the usesGlobal expansion,
; computed above:
push eax
push OFFSET FLAT:formatString ; 'string'
call _printf
add esp, 12 ;Remove printf parameters
; return 0;
xor eax, eax
ret 0
_main ENDP
_TEXT ENDS
END
从汇编语言输出可以看出,一些看似无关的代码可能会轻易阻碍编译器对全局变量进行优化。在这个例子中,编译器无法确定外部
geti()
函数是否会修改
globalValue
变量的值,因此在计算
usesGlobal
的内联函数结果时,不能假设
globalValue
仍然为 1。在使用全局变量在过程或函数与其调用者之间传递信息时要格外谨慎,与当前任务无关的代码(如调用
geti()
)可能会阻止编译器优化使用全局变量的代码。
2. 激活记录与栈
2.1 栈的工作原理与激活记录的组织
由于栈的工作方式,软件创建的最后一个过程激活记录将是系统首先释放的激活记录。因为激活记录保存着过程参数和局部变量,后进先出(LIFO)的组织方式是实现激活记录的一种非常直观的机制。
以下是一个简单的 Pascal 程序示例:
program ActivationRecordDemo;
procedure C;
begin
(* Stack Snapshot here *)
end;
procedure B;
begin
C;
end;
procedure A;
begin
B;
end;
begin (* Main Program *)
A;
end.
程序执行时的栈布局如下:
graph TD;
A1[程序开始,创建主程序激活记录] --> A2[主程序调用过程 A,构建 A 的激活记录并压入栈]
A2 --> A3[过程 A 调用过程 B,构建 B 的激活记录并压入栈]
A3 --> A4[过程 B 调用过程 C,构建 C 的激活记录并压入栈]
当程序开始执行时,首先为 主程序创建一个激活记录。主程序调用 A 过程,进入 A 过程后,完成 A 的激活记录的构建,将其压入栈。在 A 过程中调用 B 过程,此时 A 仍处于活动状态,A 的激活记录留在栈上。进入 B 过程后,系统构建 B 的激活记录并将其压入栈顶。在 B 过程中调用 C 过程,C 在栈上构建其激活记录。到达注释
(* Stack Snapshot here *)
时,栈的状态如上述流程图所示。
因为过程将其局部变量和参数值保存在激活记录中,这些变量的生命周期从系统首次创建激活记录开始,到过程返回给调用者时系统释放激活记录结束。在上述示例中,可以看到在 B 和 C 过程执行期间,A 的激活记录仍留在栈上,因此 A 的参数和局部变量的生命周期完全包含了 B 和 C 的激活记录的生命周期。
2.2 递归函数的栈布局
考虑以下带有递归函数的 C/C++ 代码:
void recursive( int cnt )
{
if( cnt != 0 )
{
recursive( cnt - 1 );
}
}
int main( int argc; char **argv )
{
recursive( 2 );
}
这个程序会调用递归函数三次才开始返回(主程序以参数值 2 调用一次,递归函数以参数值 1 和 0 调用自身两次)。每次递归调用
recursive
时,在当前调用返回之前会压入另一个激活记录。当程序最终执行到
if
语句且
cnt
等于 0 时,栈的布局如下:
| 栈状态 | 描述 |
| ---- | ---- |
| 初始 | 主程序调用
recursive(2)
,
recursive(2)
的激活记录压入栈 |
| 第一次递归 |
recursive(2)
调用
recursive(1)
,
recursive(1)
的激活记录压入栈 |
| 第二次递归 |
recursive(1)
调用
recursive(0)
,
recursive(0)
的激活记录压入栈 |
由于每个过程调用都有单独的激活记录,每个过程激活都会有自己的参数和局部变量副本。在过程或函数代码执行时,它只会访问最近创建的激活记录中的局部变量和参数,从而保留先前调用的值。
2.3 激活记录的组成
2.3.1 80x86 寄存器的作用
80x86 使用两个寄存器来维护栈和激活记录:ESP(栈指针)和 EBP(帧指针寄存器,Intel 称为基指针寄存器)。ESP 寄存器指向当前栈顶,EBP 寄存器指向激活记录的基地址。过程可以使用索引寻址模式,并提供相对于 EBP 寄存器值的正或负偏移量来访问其激活记录内的对象。通常,过程会在 EBP 值的负偏移处为局部变量分配内存存储,在正偏移处为参数分配内存存储。
2.3.2 示例过程的激活记录
考虑以下具有参数和局部变量的 Pascal 过程:
procedure HasBoth( i:integer; j:integer; k:integer );
var
a :integer;
r :integer;
c :char;
b :char;
w :smallint; (* smallints are 16 bits *)
begin
.
.
.
end;
该 Pascal 过程的典型激活记录如下:
| 偏移量 | 描述 |
| ---- | ---- |
| -12 |
w
的存储位置 |
| -10 |
b
的存储位置 |
| -9 |
c
的存储位置 |
| -8 |
r
的存储位置 |
| -4 |
a
的存储位置 |
| 0 | EBP(基指针) |
| +4 | 旧的 EBP 值 |
| +8 | 返回地址 |
| +12 |
k
的值 |
| +16 |
j
的值 |
| +20 |
i
的值 |
2.3.3 激活记录的构建
激活记录的构建分为两个阶段。第一阶段在调用过程的代码中将调用参数压入栈时开始。例如,调用
HasBoth( 5, x, y + 2 );
对应的 HLA/x86 汇编代码可能如下:
pushd( 5 );
push( x );
mov( y, eax );
add( 2, eax );
push( eax );
call HasBoth;
这三条
push
指令构建了激活记录的前三个双字,
call
指令将返回地址压入栈,从而创建激活记录的第四个双字。调用后,执行继续在
HasBoth
过程本身中进行,程序继续构建激活记录。
HasBoth
过程的前几条指令负责完成激活记录的构建。进入
HasBoth
时,栈的形式如下:
| 偏移量 | 描述 |
| ---- | ---- |
| +0 | 返回地址 |
| +4 |
k
的值 |
| +8 |
j
的值 |
| +12 |
i
的值 |
| (ESP 指向此处) | |
过程代码首先要做的是保存 80x86 EBP 寄存器的值,因为进入时 EBP 可能指向调用者的激活记录的基地址,退出
HasBoth
时,EBP 需要包含其原始值。因此,进入时
HasBoth
需要将当前 EBP 值压入栈以保存该值。接下来,
HasBoth
过程需要更改 EBP,使其指向
HasBoth
激活记录的基地址。以下 HLA/x86 代码完成这两个操作:
// Preserve caller's base address.
push( ebp );
// ESP points at the value we just saved. Use its address
// as the activation record's base address.
mov( esp, ebp );
最后,
HasBoth
过程开头的代码需要为其局部(自动)变量分配存储。这些变量位于激活记录中帧指针下方。为防止未来的
push
操作覆盖这些局部变量的值,代码需要将 ESP 设置为激活记录中最后一个双字局部变量的地址,这可以通过以下单条机器指令轻松实现:
sub( 12, esp );
像
HasBoth
这样的过程的标准入口序列由上述三条机器指令组成:
push( ebp );
、
mov( esp, ebp );
和
sub( 12, esp );
,这三条指令完成了过程内部激活记录的构建。
2.3.4 激活记录的释放
在返回之前,Pascal 过程负责释放与激活记录相关的存储。Pascal 过程的标准退出序列通常如下(在 HLA 中):
// Deallocates the local variables
// by copying EBP to ESP.
mov( ebp, esp );
// Restore original EBP value.
pop( ebp );
// Pops return address and
// 12 parameter bytes (3 dwords)
ret( 12 );
标准退出序列的第一条指令释放图中所示局部变量的存储。注意,EBP 指向旧的 EBP 值,该值存储在所有局部变量上方的内存地址处。通过将 EBP 中的值复制到 ESP,将栈指针移过所有局部变量,从而有效地释放它们。将 EBP 中的值复制到 ESP 后,栈指针现在指向栈上旧的 EBP 值,因此序列中的
pop
指令恢复 EBP 的原始值,并使 ESP 指向栈上的返回地址。标准退出序列中的
ret
指令做两件事:从栈中弹出返回地址(当然,将控制权转移到该地址),并从栈中移除 12 字节的参数。因为
HasBoth
有三个双字参数,从栈中弹出 12 字节就移除了这些参数。
2.4 为局部变量分配偏移量
HasBoth
示例按编译器遇到的顺序分配局部(自动)变量。典型的编译器会维护一个当前偏移量,用于局部变量在激活记录中的位置(初始值通常为 0)。随着编译器遇到局部变量声明,它会根据变量的大小调整偏移量,并为变量分配相应的存储位置。例如,对于上述
HasBoth
过程,编译器会依次为
a
、
r
、
c
、
b
和
w
分配偏移量,如前面激活记录表格所示。这种分配方式确保了局部变量在激活记录中有明确的存储位置,并且可以通过相对于 EBP 的偏移量方便地访问。
3. 总结与最佳实践建议
3.1 参数传递总结
- 参数数量和类型 :传递的参数数据越多,过程或函数调用的开销越大。若对空间或速度有要求,可考虑使用特定于应用的函数版本。
- 传递机制 :按引用传递大型数据对象(如数组和结构体)通常比按值传递更高效,可避免复制数据的开销;而对于小数据对象,按值传递可能更高效,但这取决于 CPU 和编译器。
- 全局变量 :使用全局变量传递数据虽可减少调用开销,但会增加编译器优化的难度,使用时需谨慎。
3.2 激活记录总结
- 栈的组织 :栈采用后进先出(LIFO)的方式组织激活记录,适合保存过程参数和局部变量。
- 递归调用 :递归函数每次调用都会创建新的激活记录,确保每次调用有独立的参数和局部变量副本。
- 构建与释放 :激活记录的构建分为调用代码压入参数和过程内部完成剩余构建两个阶段;释放时按标准退出序列操作,以正确处理局部变量和参数的存储。
3.3 最佳实践建议
| 场景 | 建议 |
|---|---|
| 大型数据对象传递 | 优先选择按引用传递 |
| 小数据对象传递 | 根据 CPU 和编译器测试后选择合适的传递方式 |
| 全局变量使用 | 尽量避免过度使用,若必须使用需确保代码逻辑清晰,减少对编译器优化的影响 |
| 过程调用 | 遵循标准的激活记录构建和释放序列,确保程序的稳定性和可维护性 |
graph LR;
A[参数传递] --> B[大型对象: 按引用传递]
A --> C[小对象: 按需选择]
A --> D[避免过度使用全局变量]
E[激活记录] --> F[LIFO 组织]
E --> G[递归调用创建新记录]
E --> H[遵循标准构建和释放]
4. 常见问题解答
4.1 参数传递相关问题
- 问 :为什么按值传递大型数据对象效率低?
- 答 :按值传递时,编译器需要将数据复制到过程的激活记录中,这在复制大型数组或结构体时非常耗时,且大型对象可能无法放入 CPU 寄存器组,访问成本高。
- 问 :如何确定小数据对象按值还是按引用传递更高效?
- 答 :可以针对具体的 CPU 和编译器进行测试,比较不同传递方式下的代码性能。
4.2 激活记录相关问题
- 问 :激活记录的构建和释放过程有什么作用?
- 答 :构建过程确保过程有独立的参数和局部变量存储,释放过程则正确回收这些存储,保证程序的内存管理正常。
- 问 :递归调用时激活记录是如何管理的?
- 答 :每次递归调用都会创建一个新的激活记录,这些记录按后进先出的顺序压入栈中,每个记录保存着当前调用的参数和局部变量,确保各次调用之间的数据独立。
4.3 全局变量相关问题
- 问 :使用全局变量传递数据有什么风险?
- 答 :编译器难以优化过度使用全局变量的程序,一些看似无关的代码可能会影响编译器对使用全局变量代码的优化。
5. 代码示例综合分析
5.1 综合示例代码
#include <stdio.h>
// 定义大型结构体
typedef struct
{
int array[256];
} a_t;
// 按值传递函数
void f_by_value( a_t a )
{
a.array[0] = 0;
return;
}
// 按引用传递函数
void f_by_reference( a_t *a )
{
a->array[0] = 0;
return;
}
// 全局变量
int globalValue = 0;
// 使用全局变量的内联函数
_inline int usesGlobal( int plusThis )
{
return globalValue + plusThis;
}
// 使用参数的内联函数
_inline int usesParm( int plusThis, int value )
{
return value + plusThis;
}
// 递归函数
void recursive( int cnt )
{
if( cnt != 0 )
{
recursive( cnt - 1 );
}
}
int main( int argc, char **argv )
{
a_t b;
// 按值传递调用
f_by_value( b );
// 按引用传递调用
f_by_reference( &b );
// 全局变量使用示例
globalValue = 1;
int i = 0;
int sumGlobal = usesGlobal( 5 );
int sumLocal = usesParm( 5, 1 );
printf( "sumGlobal=%d, sumLocal=%d\n", sumGlobal, sumLocal );
// 递归调用
recursive( 2 );
return 0;
}
5.2 代码分析
| 函数 | 传递方式 | 特点 | 影响 |
|---|---|---|---|
f_by_value
| 按值传递 | 复制大型结构体数据 | 效率低,有数据复制开销 |
f_by_reference
| 按引用传递 | 传递结构体地址 | 效率高,避免数据复制 |
usesGlobal
| 全局变量 | 依赖全局变量 | 编译器优化困难 |
usesParm
| 参数传递 | 独立参数 | 编译器可优化 |
recursive
| 递归调用 | 多次创建激活记录 | 栈空间占用增加 |
5.3 性能优化建议
-
对于
f_by_value,可改为按引用传递以提高效率。 -
对于
usesGlobal,若可能,尽量改为参数传递,以方便编译器优化。 - 对于递归函数,需注意栈空间的使用,避免栈溢出。
graph TD;
A[main 函数] --> B[f_by_value 调用]
A --> C[f_by_reference 调用]
A --> D[全局变量使用]
A --> E[递归调用]
B --> F[数据复制开销大]
C --> G[高效传递]
D --> H[编译器优化困难]
E --> I[栈空间占用增加]
6. 结语
通过对函数参数传递和激活记录的深入探讨,我们了解了不同参数传递方式的优缺点,以及激活记录在栈中的组织和管理方式。在实际编程中,应根据具体情况选择合适的参数传递方式,遵循激活记录的构建和释放规则,以提高程序的性能和可维护性。同时,要注意全局变量的使用,避免给编译器优化带来不必要的困难。希望本文能为你在函数调用和内存管理方面提供有益的参考。
超级会员免费看
4962

被折叠的 条评论
为什么被折叠?



