C++ Parameter Passing
One misconception that floats around the C++ world is that the language has three parameter passing styles: call-by-value, call-by-pointer, and call-by-reference. You can look at various tutorial or reference websites to see this.
In reality all these are wrong. C++ has two parameter passing styles: call-by-value and something I’ll call call-by-address, which subsumes call-by-pointer and call-by-reference (but not completely, as we’ll see below). These are the only two styles that fundamentally change the way arguments are passed into functions.
Let’s see this by compiling a sample program:
1struct A { int x; int y; };
2
3int noModifyVal(A a) { return a.x + a.y; }
4int noModifyRef(A& a) { return a.x + a.y; }
5int noModifyPtr(A* a) { return a->x + a->y; }
6
7void modifyThroughVal(A a) { a.y = 1; }
8void modifyThroughRef(A& a) { a.y = 3; }
9void modifyThroughPtr(A* a) { a->y = 5; }
10
11void modifyVal(int x) { x = 7; }
12void modifyRef(int& x) { x = 9; }
13void modifyPtr(int* x) { x = (int*)11; }
14
15int main()
16{
17 A a{10,20};
18
19 noModifyVal(a);
20 noModifyRef(a);
21 noModifyPtr(&a);
22
23 modifyThroughVal(a);
24 modifyThroughRef(a);
25 modifyThroughPtr(&a);
26
27 int x = 0;
28 modifyVal(x);
29 modifyRef(x);
30 modifyPtr(&x);
31
32 return x;
33}
We have functions taking arguments by value, pointer, and reference. noModify*()
functions only access their parameter, modifyThrough*()
modify the object underlying the parameter, but not the parameter itself, and modify*()
functions modify the parameter itself.
Let’s analyse the assembly. We’ll compile with the flags -fomit-frame-pointer
to reduce code, and with -fverbose-asm
for some compiler-generated comments.
Digging in
We can start with the main setup:
1main:
2 subq $16, %rsp #,
3# valueref.cpp:17: A a{10,20};
4 movl $10, 8(%rsp) #, a.x
5 movl $20, 12(%rsp) #, a.y
Remember that the stack grows downwards, so (2) “allocates” 16 bytes on the stack, and (4) & (5) put a.x
and a.y
on the stack at offset 8 (bytes 8-11) and 12 (bytes 12-15) from %rsp
, respectively.
a
is identified with 8(%rsp)
, so any address that we pass to functions will be this memory location.
As for the remaining bytes, bytes 4 to 7 from %rsp
are reserved for int x
a bit below in the function, and bytes 0 to 3 from %rsp
are used to push the current value of the instruction pointer (even though the instruction pointer holds 64-bit addresses, %eip
is pushed — not %rip
— which is the instruction pointer’s 32 bottom bits (4 bytes)) to the stack whenever call
is used.
Let’s see the call to noModifyVal(a)
:
1# valueref.cpp:19: noModifyVal(a);
2 movq 8(%rsp), %rax # a, tmp85
3 movq %rax, %rdi # tmp85,
4 call _Z11noModifyVal1A #
movq
moves the next 8 bytes (as opposed to movl
, which moves 4 bytes) at offset 8 from %rsp
(so bytes 8 to 15) to the %rax
register. This is the complete a
object (since it’s made of 2 integers, and each integer is 4 bytes).
I’m using Linux, where the x86 calling convention used is SystemV, which specifies that arguments to functions should be passed via %rdi
. Its unclear to me why the compiler doesn’t move 8(%rsp)
directly to %rdi
, especially since %rax
will be overwritten when noModifyVal()
returns (the SystemV ABI defines that return values from functions should be stored in %rax
). It seems like a small inefficiency.
Let’s look at noModifyVal()
:
1_Z11noModifyVal1A:
2 movq %rdi, -8(%rsp) # a, a
3 movl -8(%rsp), %edx # a.x, _1
4 movl -4(%rsp), %eax # a.y, _2
5 addl %edx, %eax # _1, _4
6 ret
The stack pointer is updated when we use call
. (2) copies the a
object onto the function’s stack (bytes -8 to -1 offset from %rsp
), (3) moves the next 4 bytes from -8(%rsp)
(bytes -8 to -5, a.x
) to %edx
, (4) moves the next 4 bytes from -4%(rsp)
(bytes -4 to -1, a.y
) to %eax
, and (5) performs the addition, storing the value in the %eax
register (which is the lowest 32 bits of the 64-bit %rax
register).
Note that we copied a
from the call site to %rax
to the callee’s stack. We did not touch any addresses anywhere — this is how we’d expect call-by-value to work.
Now for noModifyRef()
:
1# valueref.cpp:20: noModifyRef(a);
2 leaq 8(%rsp), %rax #, tmp86
3 movq %rax, %rdi # tmp86,
4 call _Z11noModifyRefR1A #
It seems to be the same, but note the leaq
instead of movq
. leaq
copies the address (mov
copies the value) of the first operand to the second, so we’re loading the address of 8(%rsp)
(which is &a
) onto %rax
, and then onto %rdi
.
As for the function itself:
1_Z11noModifyRefR1A:
2 movq %rdi, -8(%rsp) # a, a
3 movq -8(%rsp), %rax # a, tmp86
4 movl (%rax), %edx # a_4(D)->x, _1
5 movq -8(%rsp), %rax # a, tmp87
6 movl 4(%rax), %eax # a_4(D)->y, _2
7 addl %edx, %eax # _1, _5
8 ret
As before, we see (2) the contents of %rdi
being copied to -8(%rsp)
(i.e., put on the stack), but remember that %rdi
holds a memory address now.
We can’t dereference memory addresses directly from the stack; we need to copy it first to a register (3) and then dereference it. (4) dereferences the memory address held by %rax
, &a
, and moves the first 4 bytes to %edx
. Remember that the first four bytes of *&a
are a.x
.
(6) moves 4 bytes at a 4 byte offset from the memory address held by rax
(*(&a + 4)
) and stores them in %eax
. This is a.y
.
We see a small inefficiency again on line (5): it is unclear to me why the compiler is loading &a
onto %rax
again after doing so two instructions before. The previous value did not change, so this dereference seems redundant…
The rest of the functions performs the addition and returns.
Note that noModifyPtr()
is exactly the same as the reference version:
1# valueref.cpp:21: noModifyPtr(&a);
2 leaq 8(%rsp), %rax #, tmp87
3 movq %rax, %rdi # tmp87,
4 call _Z11noModifyPtrP1A #
5
6_Z11noModifyPtrP1A:
7 movq %rdi, -8(%rsp) # a, a
8 movq -8(%rsp), %rax # a, tmp86
9 movl (%rax), %edx # a_4(D)->x, _1
10 movq -8(%rsp), %rax # a, tmp87
11 movl 4(%rax), %eax # a_4(D)->y, _2
12 addl %edx, %eax # _1, _5
13 ret
These are the only two styles of parameter passing in C++: either %rdi
has a value, or it has an address.
Let’s explore the modifyThrough*()
functions. The call sites are the same as the ones above (but with proper labels).
Starting with modifyThroughVal()
:
1# valueref.cpp:23: modifyThroughVal(a);
2 movq 8(%rsp), %rax # a, tmp88
3 movq %rax, %rdi # tmp88,
4 call _Z16modifyThroughVal1A #
5
6_Z16modifyThroughVal1A:
7 movq %rdi, -8(%rsp) # a, a
8 movl $1, -4(%rsp) #, a.y
9 nop
10 ret
We copy a
onto to the stack (bytes -8 to -1 from %rsp
) and copy the value 1
to the 4 bytes at offset 4 from %rsp
(a.y
).
We also see an interesting nop
there… my guess is the compiler inserted it there to align the instructions to a multiple of 4.
The only modification we’re doing is at (8), which is the callee’s stack space. The caller has no conception of this, so the modification is purely local to the function. This is, again, as expected, since touching a function’s parameter that was passed by value only touches the copy.
Now modifyThroughRef()
:
1# valueref.cpp:24: modifyThroughRef(a);
2 leaq 8(%rsp), %rax #, tmp89
3 movq %rax, %rdi # tmp89,
4 call _Z16modifyThroughRefR1A #
5
6_Z16modifyThroughRefR1A:
7 movq %rdi, -8(%rsp) # a, a
8 movq -8(%rsp), %rax # a, tmp82
9 movl $3, 4(%rax) #, a_2(D)->y
10 nop
11 ret
%rdi
has &a
, which is being copied to -8(%rsp)
(bytes -8 to -1). Again, we need to copy the address to a register (8) to dereference it. In (9) we’re moving the value 3
to the 4 bytes at offset 4 from the address held by %rax
; this is the a.y
in the original stack.
And again, modifyThroughPtr()
is the same as the reference version.
1# valueref.cpp:25: modifyThroughPtr(&a);
2 leaq 8(%rsp), %rax #, tmp90
3 movq %rax, %rdi # tmp90,
4 call _Z16modifyThroughPtrP1A #
5
6_Z16modifyThroughPtrP1A:
7 movq %rdi, -8(%rsp) # a, a
8 movq -8(%rsp), %rax # a, tmp82
9 movl $5, 4(%rax) #, a_2(D)->y
10 nop
11 ret
Note that, in all cases, the parameter itself (A a
, A& a
or A* a
) is always copied to the stack, whether it is a proper value (a
) or an address (&a
). This happens on line (7) on all three examples.
Let’s now turn to the modify*()
functions.
First we put the value 0
in the next 4 bytes at offset 4 from %rsp
. This is int x
.
1# valueref.cpp:27: int x = 0;
2 movl $0, 4(%rsp) #, x
Starting with modifyVal()
:
1# valueref.cpp:28: modifyVal(x);
2 movl 4(%rsp), %eax # x, x.0_1
3 movl %eax, %edi # x.0_1,
4 call _Z9modifyVali #
5
6_Z9modifyVali:
7 movl %edi, -4(%rsp) # x, x
8 movl $7, -4(%rsp) #, x
9 nop
10 ret
As in modifyThroughVal()
, (7) copies x
to the stack (bytes -4 to -1 from %rsp
), and (8) copies the value 7
to the same location. We never leave the callee’s stack space, so nothing is altered outside of this function.
Looking at modifyRef()
:
1# valueref.cpp:29: modifyRef(x);
2 leaq 4(%rsp), %rax #, tmp91
3 movq %rax, %rdi # tmp91,
4 call _Z9modifyRefRi #
5
6_Z9modifyRefRi:
7 movq %rdi, -8(%rsp) # x, x
8 movq -8(%rsp), %rax # x, tmp82
9 movl $9, (%rax) #, *x_2(D)
10 nop
11 ret
See that the basic structure is the same as modifyThroughRef()
or modifyThroughPtr()
: we’re copying &x
to the stack on (7), loading it to a temporary register on (8), and dereferencing it on (9) as the second operand of movl
, so we’re copying the value 9
to the address pointed to by %rax
.
The pointer version:
1# valueref.cpp:30: modifyPtr(&x);
2 leaq 4(%rsp), %rax #, tmp92
3 movq %rax, %rdi # tmp92,
4 call _Z9modifyPtrPi #
5
6_Z9modifyPtrPi:
7 movq %rdi, -8(%rsp) # x, x
8 movq $11, -8(%rsp) #, x
9 nop
10 ret
Breaking the pattern, this is the same as modifyVal()
! Since pointers are 8 bytes, we’re using movq
and 64-bit registers instead of movl
and 32 bit registers, but the structure is the same.
%rdi
(and, after (7), -8(%rsp)
) is &x
, but it’s never dereferenced, so nothing is changed outside the function.
This is why some people sometimes say that pass-by-pointer is the same as pass-by-value, with the pointer being passed by value.
Conclusion
Just one thing matter: whether %rdi
has a value or an address. We see then that, for each type of function declaration, the compiler:
f(T x)
: always emits pass-by-value code.f(T* x)
: always emits pass-by-address code.f(T& x)
: emits pass-by-address code ifx
itself is not written to, otherwise emits pass-by-value code.
The call-by-value, by-reference, and by-pointer distinction is useful when teaching beginners, and indeed picking on it seems somewhat pedantic, but that’s what reference texts should be, no?
Complete assembly listing
1 .file "valueref.cpp"
2# GNU C++17 (GCC) version 13.1.1 20230614 (Red Hat 13.1.1-4) (x86_64-redhat-linux)
3# compiled by GNU C version 13.1.1 20230614 (Red Hat 13.1.1-4), GMP version 6.2.1, MPFR version 4.1.1-p1, MPC version 1.3.1, isl version none
4# GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072
5# options passed: -mtune=generic -march=x86-64 -fomit-frame-pointer
6 .text
7 .globl _Z11noModifyVal1A
8 .type _Z11noModifyVal1A, @function
9_Z11noModifyVal1A:
10.LFB0:
11 .cfi_startproc
12 movq %rdi, -8(%rsp) # a, a
13# valueref.cpp:3: int noModifyVal(A a) { return a.x + a.y; }
14 movl -8(%rsp), %edx # a.x, _1
15# valueref.cpp:3: int noModifyVal(A a) { return a.x + a.y; }
16 movl -4(%rsp), %eax # a.y, _2
17 addl %edx, %eax # _1, _4
18# valueref.cpp:3: int noModifyVal(A a) { return a.x + a.y; }
19 ret
20 .cfi_endproc
21.LFE0:
22 .size _Z11noModifyVal1A, .-_Z11noModifyVal1A
23 .globl _Z11noModifyRefR1A
24 .type _Z11noModifyRefR1A, @function
25_Z11noModifyRefR1A:
26.LFB1:
27 .cfi_startproc
28 movq %rdi, -8(%rsp) # a, a
29# valueref.cpp:4: int noModifyRef(A& a) { return a.x + a.y; }
30 movq -8(%rsp), %rax # a, tmp86
31 movl (%rax), %edx # a_4(D)->x, _1
32# valueref.cpp:4: int noModifyRef(A& a) { return a.x + a.y; }
33 movq -8(%rsp), %rax # a, tmp87
34 movl 4(%rax), %eax # a_4(D)->y, _2
35 addl %edx, %eax # _1, _5
36# valueref.cpp:4: int noModifyRef(A& a) { return a.x + a.y; }
37 ret
38 .cfi_endproc
39.LFE1:
40 .size _Z11noModifyRefR1A, .-_Z11noModifyRefR1A
41 .globl _Z11noModifyPtrP1A
42 .type _Z11noModifyPtrP1A, @function
43_Z11noModifyPtrP1A:
44.LFB2:
45 .cfi_startproc
46 movq %rdi, -8(%rsp) # a, a
47# valueref.cpp:5: int noModifyPtr(A* a) { return a->x + a->y; }
48 movq -8(%rsp), %rax # a, tmp86
49 movl (%rax), %edx # a_4(D)->x, _1
50# valueref.cpp:5: int noModifyPtr(A* a) { return a->x + a->y; }
51 movq -8(%rsp), %rax # a, tmp87
52 movl 4(%rax), %eax # a_4(D)->y, _2
53 addl %edx, %eax # _1, _5
54# valueref.cpp:5: int noModifyPtr(A* a) { return a->x + a->y; }
55 ret
56 .cfi_endproc
57.LFE2:
58 .size _Z11noModifyPtrP1A, .-_Z11noModifyPtrP1A
59 .globl _Z16modifyThroughVal1A
60 .type _Z16modifyThroughVal1A, @function
61_Z16modifyThroughVal1A:
62.LFB3:
63 .cfi_startproc
64 movq %rdi, -8(%rsp) # a, a
65# valueref.cpp:7: void modifyThroughVal(A a) { a.y = 1; }
66 movl $1, -4(%rsp) #, a.y
67# valueref.cpp:7: void modifyThroughVal(A a) { a.y = 1; }
68 nop
69 ret
70 .cfi_endproc
71.LFE3:
72 .size _Z16modifyThroughVal1A, .-_Z16modifyThroughVal1A
73 .globl _Z16modifyThroughRefR1A
74 .type _Z16modifyThroughRefR1A, @function
75_Z16modifyThroughRefR1A:
76.LFB4:
77 .cfi_startproc
78 movq %rdi, -8(%rsp) # a, a
79# valueref.cpp:8: void modifyThroughRef(A& a) { a.y = 3; }
80 movq -8(%rsp), %rax # a, tmp82
81 movl $3, 4(%rax) #, a_2(D)->y
82# valueref.cpp:8: void modifyThroughRef(A& a) { a.y = 3; }
83 nop
84 ret
85 .cfi_endproc
86.LFE4:
87 .size _Z16modifyThroughRefR1A, .-_Z16modifyThroughRefR1A
88 .globl _Z16modifyThroughPtrP1A
89 .type _Z16modifyThroughPtrP1A, @function
90_Z16modifyThroughPtrP1A:
91.LFB5:
92 .cfi_startproc
93 movq %rdi, -8(%rsp) # a, a
94# valueref.cpp:9: void modifyThroughPtr(A* a) { a->y = 5; }
95 movq -8(%rsp), %rax # a, tmp82
96 movl $5, 4(%rax) #, a_2(D)->y
97# valueref.cpp:9: void modifyThroughPtr(A* a) { a->y = 5; }
98 nop
99 ret
100 .cfi_endproc
101.LFE5:
102 .size _Z16modifyThroughPtrP1A, .-_Z16modifyThroughPtrP1A
103 .globl _Z9modifyVali
104 .type _Z9modifyVali, @function
105_Z9modifyVali:
106.LFB6:
107 .cfi_startproc
108 movl %edi, -4(%rsp) # x, x
109# valueref.cpp:11: void modifyVal(int x) { x = 7; }
110 movl $7, -4(%rsp) #, x
111# valueref.cpp:11: void modifyVal(int x) { x = 7; }
112 nop
113 ret
114 .cfi_endproc
115.LFE6:
116 .size _Z9modifyVali, .-_Z9modifyVali
117 .globl _Z9modifyRefRi
118 .type _Z9modifyRefRi, @function
119_Z9modifyRefRi:
120.LFB7:
121 .cfi_startproc
122 movq %rdi, -8(%rsp) # x, x
123# valueref.cpp:12: void modifyRef(int& x) { x = 9; }
124 movq -8(%rsp), %rax # x, tmp82
125 movl $9, (%rax) #, *x_2(D)
126# valueref.cpp:12: void modifyRef(int& x) { x = 9; }
127 nop
128 ret
129 .cfi_endproc
130.LFE7:
131 .size _Z9modifyRefRi, .-_Z9modifyRefRi
132 .globl _Z9modifyPtrPi
133 .type _Z9modifyPtrPi, @function
134_Z9modifyPtrPi:
135.LFB8:
136 .cfi_startproc
137 movq %rdi, -8(%rsp) # x, x
138# valueref.cpp:13: void modifyPtr(int* x) { x = (int*)11; }
139 movq $11, -8(%rsp) #, x
140# valueref.cpp:13: void modifyPtr(int* x) { x = (int*)11; }
141 nop
142 ret
143 .cfi_endproc
144.LFE8:
145 .size _Z9modifyPtrPi, .-_Z9modifyPtrPi
146 .globl main
147 .type main, @function
148main:
149.LFB9:
150 .cfi_startproc
151 subq $16, %rsp #,
152 .cfi_def_cfa_offset 24
153# valueref.cpp:17: A a{10,20};
154 movl $10, 8(%rsp) #, a.x
155 movl $20, 12(%rsp) #, a.y
156# valueref.cpp:19: noModifyVal(a);
157 movq 8(%rsp), %rax # a, tmp85
158 movq %rax, %rdi # tmp85,
159 call _Z11noModifyVal1A #
160# valueref.cpp:20: noModifyRef(a);
161 leaq 8(%rsp), %rax #, tmp86
162 movq %rax, %rdi # tmp86,
163 call _Z11noModifyRefR1A #
164# valueref.cpp:21: noModifyPtr(&a);
165 leaq 8(%rsp), %rax #, tmp87
166 movq %rax, %rdi # tmp87,
167 call _Z11noModifyPtrP1A #
168# valueref.cpp:23: modifyThroughVal(a);
169 movq 8(%rsp), %rax # a, tmp88
170 movq %rax, %rdi # tmp88,
171 call _Z16modifyThroughVal1A #
172# valueref.cpp:24: modifyThroughRef(a);
173 leaq 8(%rsp), %rax #, tmp89
174 movq %rax, %rdi # tmp89,
175 call _Z16modifyThroughRefR1A #
176# valueref.cpp:25: modifyThroughPtr(&a);
177 leaq 8(%rsp), %rax #, tmp90
178 movq %rax, %rdi # tmp90,
179 call _Z16modifyThroughPtrP1A #
180# valueref.cpp:27: int x = 0;
181 movl $0, 4(%rsp) #, x
182# valueref.cpp:28: modifyVal(x);
183 movl 4(%rsp), %eax # x, x.0_1
184 movl %eax, %edi # x.0_1,
185 call _Z9modifyVali #
186# valueref.cpp:29: modifyRef(x);
187 leaq 4(%rsp), %rax #, tmp91
188 movq %rax, %rdi # tmp91,
189 call _Z9modifyRefRi #
190# valueref.cpp:30: modifyPtr(&x);
191 leaq 4(%rsp), %rax #, tmp92
192 movq %rax, %rdi # tmp92,
193 call _Z9modifyPtrPi #
194# valueref.cpp:32: return x;
195 movl 4(%rsp), %eax # x, _15
196# valueref.cpp:33: }
197 addq $16, %rsp #,
198 .cfi_def_cfa_offset 8
199 ret
200 .cfi_endproc
201.LFE9:
202 .size main, .-main
203 .ident "GCC: (GNU) 13.1.1 20230614 (Red Hat 13.1.1-4)"
204 .section .note.GNU-stack,"",@progbits
Tags: #programming #C++