Think of the stack as a sequence of function stack frames, not a sequence of variable addresses. Regardless of the direction that the stack grows, it grows in increments of whole stack frames, which are different sizes for each function.
The layout of a function's stack frame has fixed positions for where variables will be bound, similar to a struct, but the exact order of bindings within the frame is not guaranteed. If the function can be made to use space more efficiently with a different layout, it probably will. For example:
fn main() {
let i1: i32 = 1;
let i2: i64 = 2;
let i3: i32 = 3;
println!("i1 : {:?}", &i1 as *const i32);
println!("i2 : {:?}", &i2 as *const i64);
println!("i3 : {:?}", &i3 as *const i32);
}
// i1 : 0x7fff4b9271fc
// i2 : 0x7fff4b927200
// i3 : 0x7fff4b92720c
Here, i3
is stored before i2
. An i64
needs to be aligned to a multiple of 64 bits so it is more compact to store the two i32
s together rather than leaving a gap. This doesn't happen in debug builds, and the compiler could also have chosen to store i3
first with the same effect, so we cannot and should not rely on this ordering.
It's also possible that variables could be reordered for any other optimisation reasons, such as cache access efficiency.
To see that the stack does actually grow downwards, consider an example with multiple functions:
fn main() {
let i1 = 1;
println!("i1 : {:?}", &i1 as *const i32);
another();
}
#[inline(never)]
fn another() {
let i2 = 2;
println!("i2 : {:?}", &i2 as *const i32);
}
// i1 : 0x7fffc7601fbc
// i2 : 0x7fffc7601f5c
another
is called by main
so its stack frame has a lower address. Notice that I had to force the compiler not to inline the function, otherwise the combined layout would have been arbitrary.