J.S. Cruz

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:

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++