The Linux main-thread stack can grow toward lower virtual addresses when a program touches memory below its current VMA. This experiment maps pages around that boundary to see when growth succeeds.

The test attempts to grow the stack by writing one byte below its current VMA:

1
2
3
volatile char* below_stack =
reinterpret_cast<volatile char*>(stack.start) - 1;
*below_stack = 1;

When that address is unmapped, the resulting page fault can make Linux extend a downward-growing stack VMA by one page.

Linux also enforces stack_guard_gap, which defaults to 256 pages. This gap is not a reserved or pre-mapped region. Linux applies it while choosing mmap addresses and while expanding a stack. The kernel boot option stack_guard_gap= can change its size.

The experiment uses MAP_FIXED_NOREPLACE so each mapping is attempted at an exact address without replacing an existing VMA. Each case runs in its own child process because two cases intentionally cause SIGSEGV. A child created by fork() inherits the same virtual address layout, while its stack growth and new mappings remain private to that child.

§Six cases

Case New mapping Location Result of growth attempt
1 Read/write Immediately above stack.end Grows
2 Read/write At stack.start mmap fails with EEXIST; stack grows
3 Read/write Inside the guard gap SIGSEGV
4 PROT_NONE Inside the guard gap Grows
5 PROT_NONE Immediately below stack.start SIGSEGV
6 Read/write Below the guard gap Grows

The cases run independently. The diagram places their mapping targets on one address-space axis. P is one page, and G is the guard-gap size.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
                         higher addresses

stack.end --------------------- case 1: RW starts here -> grows
existing stack VMA
stack.start --------------------- case 2: RW starts here -> EEXIST, grows
stack.start - 1 * faulting byte
case 5: NONE starts at stack.start - P -> SIGSEGV
unmapped space
stack.start - G/2 --------------------- case 3: RW page -> SIGSEGV
case 4: NONE page -> grows
unmapped space
stack.start - G --------------------- lower edge of guard gap
unmapped page
stack.start - G - 2P --------------------- case 6: RW page -> grows

lower addresses

Cases 3 and 4 differ only in memory protection. Linux checks whether the VMA below an expanding stack is accessible before enforcing the guard-gap distance. The relevant part of expand_downwards() is:

1
2
3
4
if (!(prev->vm_flags & VM_GROWSDOWN) &&
vma_is_accessible(prev) &&
(address - prev->vm_end < stack_guard_gap))
return -ENOMEM;

A read/write VMA is accessible, so case 3 fails the expansion check. A PROT_NONE VMA is not accessible, so case 4 bypasses that check. Unmapped space and the PROT_NONE range both provide inaccessible guard space at that distance.

Case 5 reaches a different path. The faulting address is already covered by the PROT_NONE VMA immediately below the stack. Linux handles it as a protection fault in that VMA rather than as a request to extend the stack, so the child receives SIGSEGV.

§The program

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif

#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>

struct stack_info {
uintptr_t start;
uintptr_t end;

size_t size() const noexcept {
return end - start;
}
};

constexpr size_t default_stack_guard_pages = 256;

size_t page_size = 0;

stack_info get_stack() {
volatile char marker;
uintptr_t stack_address = reinterpret_cast<uintptr_t>(&marker);

FILE* maps = fopen("/proc/self/maps", "r");
if (maps == NULL) {
perror("fopen");
exit(EXIT_FAILURE);
}

unsigned long start, end;
char line[256];

while (fgets(line, sizeof(line), maps) != NULL) {
if (sscanf(line, "%lx-%lx", &start, &end) == 2 &&
start <= stack_address && stack_address < end) {
fclose(maps);
return {start, end};
}
}

if (ferror(maps)) {
perror("fgets");
} else {
fputs("stack mapping not found\n", stderr);
}
fclose(maps);
exit(EXIT_FAILURE);
}

void print_stack() {
stack_info stack = get_stack();
printf("stack: [%p, %p], size: %zu KiB\n",
reinterpret_cast<void*>(stack.start),
reinterpret_cast<void*>(stack.end), stack.size() / 1024);
}

void run_mapping_test(int number, stack_info stack, uintptr_t target,
int protection) {
const char* protection_name = protection == PROT_NONE ? "NONE" : "RW";
printf("case %d: mmap at %p (%s): ", number,
reinterpret_cast<void*>(target), protection_name);
void* mapping =
mmap(reinterpret_cast<void*>(target), page_size, protection,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0);
if (mapping == MAP_FAILED) {
printf("failed (%s)\n", strerror(errno));
} else {
printf("succeeded\n");
}

printf("case %d: growing stack by one page...\n", number);
volatile char* below_stack =
reinterpret_cast<volatile char*>(stack.start) - 1;
*below_stack = 1;
print_stack();

if (mapping != MAP_FAILED) {
if (munmap(mapping, page_size) == -1) {
perror("munmap");
}
}
}

// Map immediately above the stack; downward stack growth remains safe.
void case_1(size_t) {
stack_info stack = get_stack();
run_mapping_test(1, stack, stack.end, PROT_READ | PROT_WRITE);
}

// Map over the existing stack; MAP_FIXED_NOREPLACE rejects the overlap.
void case_2(size_t) {
stack_info stack = get_stack();
run_mapping_test(2, stack, stack.start, PROT_READ | PROT_WRITE);
}

// Map inside the guard gap; mmap succeeds, but the stack cannot grow.
void case_3(size_t guard_gap) {
stack_info stack = get_stack();
run_mapping_test(3, stack, stack.start - guard_gap / 2,
PROT_READ | PROT_WRITE);
}

// Map PROT_NONE inside the guard gap; Linux still allows the stack to grow.
void case_4(size_t guard_gap) {
stack_info stack = get_stack();
run_mapping_test(4, stack, stack.start - guard_gap / 2, PROT_NONE);
}

// Map PROT_NONE immediately below the stack; the faulting address is
// inaccessible.
void case_5(size_t) {
stack_info stack = get_stack();
run_mapping_test(5, stack, stack.start - page_size, PROT_NONE);
}

// Map below the guard gap; the stack can still grow by one page.
void case_6(size_t guard_gap) {
stack_info stack = get_stack();
run_mapping_test(6, stack, stack.start - guard_gap - 2 * page_size,
PROT_READ | PROT_WRITE);
}

int main() {
setvbuf(stdout, nullptr, _IONBF, 0);

long detected_page_size = sysconf(_SC_PAGESIZE);
if (detected_page_size <= 0) {
perror("sysconf");
return 1;
}
page_size = static_cast<size_t>(detected_page_size);

// This experiment assumes Linux's default 256-page stack guard gap. The
// kernel's stack_guard_gap boot option can override it.
const size_t guard_gap = default_stack_guard_pages * page_size;
using case_function = void (*)(size_t);
constexpr case_function cases[] = {case_1, case_2, case_3,
case_4, case_5, case_6};
constexpr size_t case_count = sizeof(cases) / sizeof(cases[0]);

print_stack();

// Run each case in its own OS process.
for (size_t index = 0; index < case_count; ++index) {
int number = static_cast<int>(index + 1);
printf("\n=== Case %d ===\n", number);

pid_t child = fork();
if (child == -1) {
perror("fork");
return 1;
}
if (child == 0) {
cases[index](guard_gap);
_exit(0);
}

int status;
if (waitpid(child, &status, 0) == -1) {
perror("waitpid");
return 1;
}
if (WIFSIGNALED(status)) {
printf("case %d: terminated by %s\n", number,
strsignal(WTERMSIG(status)));
} else if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
fprintf(stderr, "case %d: child failed\n", number);
return 1;
}
}

return 0;
}

§Observed output

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
stack: [0x7fffc163a000, 0x7fffc165c000], size: 136 KiB

=== Case 1 ===
case 1: mmap at 0x7fffc165c000 (RW): succeeded
case 1: growing stack by one page...
stack: [0x7fffc1639000, 0x7fffc165c000], size: 140 KiB

=== Case 2 ===
case 2: mmap at 0x7fffc163a000 (RW): failed (File exists)
case 2: growing stack by one page...
stack: [0x7fffc1639000, 0x7fffc165c000], size: 140 KiB

=== Case 3 ===
case 3: mmap at 0x7fffc15ba000 (RW): succeeded
case 3: growing stack by one page...
case 3: terminated by Segmentation fault

=== Case 4 ===
case 4: mmap at 0x7fffc15ba000 (NONE): succeeded
case 4: growing stack by one page...
stack: [0x7fffc1639000, 0x7fffc165c000], size: 140 KiB

=== Case 5 ===
case 5: mmap at 0x7fffc1639000 (NONE): succeeded
case 5: growing stack by one page...
case 5: terminated by Segmentation fault

=== Case 6 ===
case 6: mmap at 0x7fffc1538000 (RW): succeeded
case 6: growing stack by one page...
stack: [0x7fffc1639000, 0x7fffc165c000], size: 140 KiB

The guard gap protects the stack from growing too close to accessible memory. It does not require all 256 pages to remain unmapped. Linux permits an inaccessible PROT_NONE VMA within that distance, then stops growth when the stack reaches the VMA itself.

§References