Language-Level Memory Management (C/C++/Rust) Questions
How C and C++ expose and manage memory at the language level: pointers and pointer arithmetic, pointers to pointers and function pointers, arrays versus pointers and array decay, stack versus heap storage, manual allocation and freeing in C (malloc, realloc, free), new and delete versus malloc and free, placement new, RAII and owning smart pointers (unique_ptr, including custom deleters for C resources), shallow versus deep copies and move semantics in C++. Covers reasoning about who owns a buffer and designing ownership and lifetime contracts across function, library and plugin boundaries; dangling and uninitialized pointers, leaks, double frees, use-after-free, off-by-one errors and buffer overruns and how to prevent them; undefined behavior, strict aliasing and safe byte reinterpretation, endianness; struct layout, alignment and padding as the language defines them; and finding and diagnosing memory bugs with sanitizers, Valgrind-style tools, tracing allocators and heap-corruption triage, including allocator design and heap fragmentation at the language level. Boundary: garbage-collector behavior and tuning, embedded memory budgets, register access and packing structs to hardware layouts, OS virtual memory and paging, and lock-based concurrency are covered elsewhere.
What is strict aliasing in C and C++, and why can violating it cause bugs that appear only under optimization? What idioms do you use to inspect or reinterpret raw bytes safely?
Sample Answer
The rule. In C and C++, an object of a given type may be accessed through an lvalue (an expression naming a memory location) only of that same type or a closely related one: a qualified or signed/unsigned variant, a struct or union containing it, or a character type (char, signed char, unsigned char). cppreference's C page summarizes it: using an lvalue of a different type than the object's effective type (the type the object was created with, or last stored as) is undefined behaviour (UB), with those exceptions. Doing it anyway is called type punning: reading the bytes of an object as if it had another type. A short* and an int* are assumed not to point at the same memory, so the compiler need not reload a value after a store through the other.
Why it only bites under optimization. At -O0 every access goes to memory in order, so punning "works". With optimization on, the compiler keeps values in registers and reorders loads and stores on the strength of the no-alias assumption. Run in a gcc:14 container (GCC 14.4.0, aarch64), this program shows the effect:
#include <stdio.h>
#include <string.h>
#include <stdint.h>
static int bump_bad(int *i, short *s) { *i = 1; *s = 2; return *i; } /* UB if they alias */
static uint32_t bits_bad(float f) { return *(uint32_t *)&f; }
static uint32_t bits_good(float f) { uint32_t u; memcpy(&u, &f, sizeof u); return u; }
int main(void) {
int x = 0;
int r = bump_bad(&x, (short *)&x); /* deliberately aliasing */
printf("returned %d, x low half now %d\n", r, x);
printf("bad=%08x good=%08x\n", bits_bad(1.0f), bits_good(1.0f));
return 0;
}
gcc -O0 printed returned 2, x low half now 2. gcc -O2 -Wall printed the warning dereferencing type-punned pointer will break strict-aliasing rules for bits_bad and then returned 1, x low half now 2. The compiler's reasoning inside bump_bad goes: *i = 1 stores 1; *s = 2 writes through a short *, which cannot point at an int, so *i is still 1; therefore return *i can return the constant 1 without reading memory again. In the run the two pointers did point at the same int, so the memory held 2 and the function returned the stale 1. (The label says "low half" because the short store rewrites only the two low-order bytes of x: on a little-endian CPU, which stores the least significant byte first, those are the first two bytes in memory. The upper bytes were already 0, so x as a whole reads 2.) gcc -O2 -fno-strict-aliasing went back to returned 2. bits_bad and bits_good both printed 3f800000 in this run, so a punned read may happen to work; the rule is that you cannot rely on it, and the stale-value case above is the failure you actually see.
Safe idioms for looking at or converting raw bytes. Item 2 is the one to reach for whenever you need the bytes as a different type.
- Inspect any object as bytes: use
unsigned char *.unsigned char *p = (unsigned char *)&obj;and readp[i]. The character-type exception covers exactly this. Note it is one direction: you may read anintthroughchar *, but you may not take achararray and read it throughint *. - Reinterpret bytes as another type:
memcpyinto an object of that type.memcpy(&u, &f, sizeof u)is defined behaviour, and at-O2compilers turn small fixed-sizememcpycalls into a single register move, so it costs nothing. This is whatbits_gooddoes, and it was correct at every optimization level in the run. - C++20:
std::bit_cast<To>(from)from<bit>(cppreference: requires equal sizes and both trivially copyable types, meaning types whose bytes can be copied withmemcpysafely, such as plain integers, floats and simple structs). Same idea, and usable inconstexprcode, which is code the compiler can evaluate at compile time. - Unions: C allows reading the inactive member of a union to reinterpret bytes (cppreference notes that type punning through a union is permitted in C). I would still prefer
memcpyin shared C/C++ headers, since this is not a guarantee I would assume for C++ code. - Compiler escape hatches (
-fno-strict-aliasing(turns the assumption off for the whole build),__attribute__((may_alias))(marks one type as allowed to alias anything)) exist for code you cannot change. They make the bug harmless on that compiler but are not standard C.
Byte pointer round trip. int * to unsigned char * and back to the original int * is fine if the pointer targets a real int and is aligned. What is not fine is making an int * out of a byte buffer's address and dereferencing it: that violates both aliasing and, often, alignment.
How I catch it. Compile with -Wall -Wstrict-aliasing at -O2, and compare behaviour with -fno-strict-aliasing as a diagnostic (a bug that disappears with it is an aliasing bug). GCC's AddressSanitizer and UndefinedBehaviorSanitizer do not check aliasing, so with GCC the warning and the optimized-versus-unoptimized comparison are the tools. Clang has an experimental TypeSanitizer (-fsanitize=type) whose documentation describes it as a detector for strict type aliasing violations; it is worth a try on a Clang build, but it is still labelled experimental, so it supplements the comparison rather than replacing it.
What are alignment and padding in C structs, and why do they matter when you copy structs across platforms or reason about sizeof?
Sample Answer
Definitions. Alignment is a rule that an object of a type must sit at an address that is a multiple of that type's alignment requirement (often equal to its size for scalar types: a 4-byte int32_t at multiples of 4, an 8-byte double at multiples of 8). CPUs load aligned values fastest, and some cannot load misaligned ones at all. Padding is the unused bytes the compiler inserts inside a struct (between members, or after the last) so that every member is aligned and so that arrays of the struct keep every element aligned. cppreference's C struct page states the language rule: padding may appear between members or after the last one, but not before the first, and members are laid out in declaration order.
Seeing it. Compiled in a gcc:14 container on aarch64 Linux (GCC 14.4, gcc layout.c && ./layout), this program prints the layout:
#include <stdio.h>
#include <stddef.h>
#include <stdint.h>
struct A { char c; int32_t i; char d; double x; };
struct B { double x; int32_t i; char c; char d; };
int main(void) {
printf("A: size=%zu align=%zu c@%zu i@%zu d@%zu x@%zu\n", sizeof(struct A), _Alignof(struct A),
offsetof(struct A,c), offsetof(struct A,i), offsetof(struct A,d), offsetof(struct A,x));
printf("B: size=%zu align=%zu x@%zu i@%zu c@%zu d@%zu\n", sizeof(struct B), _Alignof(struct B),
offsetof(struct B,x), offsetof(struct B,i), offsetof(struct B,c), offsetof(struct B,d));
return 0;
}
Output:
A: size=24 align=8 c@0 i@4 d@8 x@16
B: size=16 align=8 x@0 i@8 c@12 d@13
Reading A: c is at 0, three padding bytes follow so i can start at 4, d is at 8, seven padding bytes follow so the double can start at 16, and the struct ends at 24. The members' own sizes add to 14 bytes (1 + 4 + 1 + 8), so 10 of the 24 bytes are padding. B holds the same four members with the large one first and the two chars together, and is 16 bytes: only 2 bytes of padding, at the end, to round the size up to a multiple of 8. Same data, a third less memory, just by reordering. (The compiler may not reorder members for you in C: declaration order is guaranteed.)
Why it matters.
sizeofis not the sum of the fields. Anything computed from a hand-added size (a buffer for N structs, an offset into a file) will be wrong. Usesizeofandoffsetof.- Copying and serializing structs across platforms is unsafe.
fwrite(&s, sizeof s, 1, f)or sending the struct over a socket writes the padding bytes (which hold indeterminate values, so output differs between runs and can leak stale memory) and bakes in this compiler's layout. A different compiler, a different ABI (the binary-level agreement between compiled code, which fixes sizes and alignments), a different word size (the CPU's natural integer width, such as 32 or 64 bits), or different packing options (compiler settings that remove the padding) can put members at different offsets, so the other side reads the wrong bytes. Byte order is a separate problem on top. Serialize field by field into a defined format instead: write each field's bytes at a fixed offset in a fixed byte order, and read them back the same way, never by copying the whole struct. - Memory and cache cost. Large arrays of badly ordered structs waste memory and cache lines (the fixed-size blocks, commonly 64 bytes, in which the CPU moves memory into its cache, so padding means fewer useful elements per block); ordering members from largest alignment to smallest is the usual first fix.
- Comparing structs with
memcmpcan fail even when all members are equal, because the padding bytes differ.
Practical habits. Print sizeof and offsetof on each target you ship to (the numbers above are for this aarch64 Linux GCC run; other ABIs can differ, for example in the size of long or pointers and in the alignment of double). Pin the expectation in the code with _Static_assert(sizeof(struct B) == 16, "layout changed"); (the C11 keyword; the shorter spelling static_assert needs #include <assert.h> before C23, and without it GCC 14 in its default C mode rejects the code, with an implicit-declaration error inside a function and a syntax error at file scope) so an unintended change breaks the build. Packing structs to match a hardware or wire layout brings its own hazards (misaligned access, portability).
What is a dangling pointer? Show how one arises after free or from returning the address of a local variable, and how you would make the bug easier to catch in review and testing.
Sample Answer
Direct answer. A dangling pointer is a pointer whose target object no longer exists. Two common ways: after free(p) the pointer still holds the old address, and after a function returns, a pointer to one of its locals points into a stack frame that is gone. Using it is undefined behaviour: it may appear to work, print garbage, or corrupt another object. Make the bug easier to catch by compiling with warnings, running tests under AddressSanitizer, nulling pointers after free, and making ownership rules obvious in review.
Case 1: use after free
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int *returns_local(void) {
int x = 10;
return &x; /* address of an object whose lifetime ends here */
}
static void after_free(void) {
char *s = malloc(16);
strcpy(s, "hello");
free(s);
printf("%s\n", s); /* use after free */
}
int main(int argc, char **argv) {
if (argc > 1 && argv[1][0] == 'a') after_free();
else { int *p = returns_local(); printf("%d\n", *p); }
return 0;
}
Run as ./s6 a (build: gcc -g -O1 -Wall -Wextra -fsanitize=address,undefined s6.c -o s6). GCC already warns at compile time (pointer 's' used after 'free' [-Wuse-after-free]), and AddressSanitizer reports at run time:
ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010 ...
READ of size 2 at ...
#1 ... in after_free /w/s6.c:13
0x502000000010 is located 0 bytes inside of 16-byte region [...]
freed by thread T0 here:
#1 ... in after_free /w/s6.c:12
previously allocated by thread T0 here:
#1 ... in after_free /w/s6.c:10
The report gives three stacks: the bad access (line 13), the free (line 12) and the allocation (line 10). That triple is how you fix these.
Case 2: address of a local
GCC warned function returns address of local variable [-Wreturn-local-addr] for return &x; in s6.c. Because the behaviour is undefined, the compiler may treat the returned address as anything: GCC at -O1 compiled the return to NULL, and the run crashed with a NULL read, not a stack read. This is observed behaviour, not a guarantee, and it shows that the compiler is not obliged to keep a returned stack address meaningful. To see the stack version, hide the escape from the compiler:
#include <stdio.h>
static int *saved; /* a pointer that outlives the call */
static void remember_local(void) {
int x = 10;
saved = &x; /* x dies when this function returns */
}
int main(void) {
remember_local();
printf("%d\n", *saved); /* dangling: x's frame is gone */
return 0;
}
Observed with GCC 14.4 on aarch64 Linux, same source:
gcc -O0 dangling_local.c && ./a.outprints10and exits 0. It looks correct, which is the danger.gcc -O2 dangling_local.c && ./a.outprints0and exits 0. The10is gone because nothing guarantees it is still stored: afterremember_localreturns, the stack space ofxis free for reuse, and the optimizer is also allowed to skip writing a value that the language says nobody may legally read. The program prints whatever that location holds, and the standard promises nothing about it.gcc -g -O0 -fsanitize=address dangling_local.creports (stack-use-after-return means using a local after its function returned; use-after-scope means using it after its{ }block ended)ERROR: AddressSanitizer: stack-use-after-return ... READ of size 4 ... Address ... is located in stack of thread T0 at offset 32 in frame #0 ... remember_local. In this GCC 14 build the check fired without extra options. If your toolchain does not report it, setASAN_OPTIONS=detect_stack_use_after_return=1(the Clang documentation lists this as the runtime switch).
So the same bug printed the "right" answer, a wrong answer, or a diagnostic depending on build flags: that is what undefined behaviour means in practice.
Making it easier to catch
- Compiler warnings in CI:
-Wall -Wextracaught both cases above at compile time, and they are cheap. Treat them as errors. - Sanitizers in tests: AddressSanitizer in the unit-test and fuzz builds (fuzz builds feed the code large volumes of automatically generated inputs), as shown. ASan's own documentation lists use-after-free, use-after-return and use-after-scope among the bugs it detects, at a typical slowdown it documents as about 2x.
- Null after free: a macro like
FREE_AND_NULL(p)turns a silent stale read into an immediate NULL crash:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* free + null in one place: the pointer cannot dangle through this name.
do { ... } while (0) makes the macro one statement, so `if (c) FREE_AND_NULL(p); else ...`
parses; a bare { } block followed by the `;` would break that `else` (GCC cpp manual). */
#define FREE_AND_NULL(p) do { free(p); (p) = NULL; } while (0)
struct session { char *buf; };
int main(void) {
struct session s = { malloc(16) };
struct session alias = s; /* a second copy of the pointer */
strcpy(s.buf, "token");
FREE_AND_NULL(s.buf);
printf("s.buf is %s\n", s.buf ? "live" : "NULL");
printf("alias.buf is %s\n", alias.buf ? "non-NULL (still dangling!)" : "NULL");
free(NULL); /* defined: no effect */
puts("free(NULL) is a no-op");
return 0;
}
Output (built with -fsanitize=address):
s.buf is NULL
alias.buf is non-NULL (still dangling!)
free(NULL) is a no-op
It protects only that one variable. The copy alias still dangles, which is why ownership matters more than the macro.
4. Review: ask who owns each pointer, and flag any pointer stored beyond the lifetime of the object it was derived from (a pointer into a container that may reallocate is the same bug).
5. Language help: in Rust, the compiler rejects both bug shapes before the program runs. A reference (&x, a borrow: a temporary, checked right to look at a value owned elsewhere) to a local may not outlive the function:
fn dangling() -> &'static i32 {
let x = 10;
&x
}
fn main() { println!("{}", dangling()); }
rustc 1.99.0 prints error[E0515]: cannot return reference to local variable x`` with the note "returns a reference to data owned by the current function". The second bug shape is use after free, which in Rust becomes use after a move: assigning let t = s; hands the heap text to t (a move), and s may no longer be used.
fn main() {
let s = String::from("hello");
let t = s;
println!("{} {}", s, t);
}
rustc 1.99.0 prints error[E0382]: borrow of moved value: s`` and points at the move and at the later use. In C++, owning smart pointers (objects such as std::unique_ptr that free their memory themselves) and standard containers remove most manual frees.
Pitfalls
A freed pointer is not NULL, so a if (p) guard passes. In the C standard, the value of a pointer to freed memory becomes indeterminate (it has no defined value, so any use of that value is itself undefined), so even comparing or printing it is not guaranteed. "It ran fine in testing" is the usual story, because freed memory is often untouched for a while.
A long-running C++ server develops latency spikes that you suspect come from heap fragmentation. How would you confirm fragmentation, which tools would you use, and when would you reach for pooling, a custom or arena allocator, or reduced object churn?
Sample Answer
Direct answer. Confirm fragmentation by showing that memory in use (logical bytes the application actually asked for) is flat or low while resident memory (RSS: the physical RAM the process is actually occupying, as opposed to memory it has reserved but not touched) and allocator-held free space stay high even after a deliberate trim, which separates a real leak from a fragmentation problem; the mitigation choice then follows from the object-size pattern: pooling for a single recurring size, an arena (one big block handed out in pieces, freed all at once) for a well-defined lifetime boundary (per-request, per-connection), and reduced object churn when the real fix is allocating less often in the first place.
Why fragmentation causes latency spikes, not just wasted memory. The OS hands memory to a process in whole pages (typically 4 KiB blocks, the unit the OS tracks and can map/unmap). A page can only be returned to the OS once every live object on it is gone (malloc can still reuse the free gaps inside a page that has live neighbours, but only for requests that fit a gap). If a page holds even one small surviving object, the whole page stays pinned and cannot be released. When live objects are scattered across many pages instead of packed into a few, two costs follow: the allocator's internal free-space bookkeeping (chunk lists, bins) has to search through more, smaller, scattered gaps to satisfy an allocation, and a page fault or TLB (translation lookaside buffer: the CPU's cache of recently used virtual-to-physical address mappings) miss touching a nearly-empty page costs the same as touching a full one. That extra bookkeeping search and the wider working set are what shows up as a latency spike under load, not a crash or an error.
Confirming fragmentation specifically. Fragmentation means memory the allocator holds is free but cannot satisfy new requests efficiently (internal: wasted space inside allocated blocks; external: free space scattered in gaps too small or too interspersed to reuse well). The diagnostic signature: mallinfo2()'s uordblks (bytes actually in use) is modest and stable, but fordblks (free bytes still held by the allocator) is large, and RSS does not shrink after calling malloc_trim(0), which asks glibc to release whole free pages back to the OS. If fordblks is large and RSS does drop sharply after malloc_trim(0), that free memory was contiguous and returnable, meaning it was not badly fragmented, just not yet trimmed; if RSS stays high after trim, the free space really is scattered across pages that also hold live data and cannot be released, which is the fragmentation signature proper. The comparison that matters is RSS after trim against uordblks: a gap that stays large after trim (here 51 MiB against 9 MiB) is fragmentation; a gap that disappears is just untrimmed free memory.
Worked demonstration.
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <malloc.h>
#include <unistd.h>
static long rss_kib(void) {
FILE *f = fopen("/proc/self/statm", "r");
long pages = 0, rss = 0;
fscanf(f, "%ld %ld", &pages, &rss);
fclose(f);
return rss * (sysconf(_SC_PAGESIZE) / 1024);
}
static void report(const char *label) {
struct mallinfo2 mi = mallinfo2();
printf("%-22s in use %4zu MiB | free inside heap %4zu MiB | RSS %4zu MiB\n",
label, mi.uordblks / 1024 / 1024, mi.fordblks / 1024 / 1024, rss_kib() / 1024);
}
int main(int argc, char **argv) {
int keep_every = argc > 1 ? atoi(argv[1]) : 10;
enum { N = 100000, SZ = 1000 };
void **blocks = malloc(N * sizeof *blocks);
for (int i = 0; i < N; i++) blocks[i] = malloc(SZ);
report("after allocating");
for (int i = 0; i < N; i++)
if (i % keep_every != 0) { free(blocks[i]); blocks[i] = NULL; }
report("after freeing the rest");
malloc_trim(0);
report("after malloc_trim(0)");
for (int i = 0; i < N; i++) free(blocks[i]); /* free(NULL) is a no-op */
free(blocks);
return 0;
}
100,000 1000-byte blocks allocated, then all but every 10th freed, with mallinfo2 and RSS sampled at each step (gcc -O2 -Wall -Wextra, GCC 14.4, glibc 2.41, aarch64 Linux container with 4 KiB pages; run as ./a.out 10, the default; the program frees the survivors and the array after the last report, so it is also clean under -fsanitize=address,undefined):
keep 1 block in every 10
after allocating in use 96 MiB | free inside heap 0 MiB | RSS 98 MiB
after freeing the rest in use 9 MiB | free inside heap 86 MiB | RSS 98 MiB
after malloc_trim(0) in use 9 MiB | free inside heap 86 MiB | RSS 51 MiB
Why trim recovers about half and not nothing: each malloc(1000) occupies a 1,008-byte chunk (1,000 bytes plus an 8-byte header, rounded to 16), so the 100,000 blocks span 100,000 x 1,008 / 4,096 = about 24,600 pages (about 96 MiB). Survivors are every 10th block, so they sit about 10,080 bytes apart, more than two pages, and each survivor pins only the one page it sits in (or two pages, with probability 1,008 / 4,096 = 24.6%, when it straddles a boundary). That is about 10,000 x 1.246 = 12,460 pinned pages (about 48.7 MiB), leaving about 12,150 completely empty pages (about 47.5 MiB) that malloc_trim can release. This matches the run: RSS fell from 98 MiB to 51 MiB, a 47 MiB recovery. The 51 MiB that remains, against only 9 MiB logically in use, is the fragmentation: about 42 MiB of RSS is held by pages that each contain one 1,008-byte survivor and about 3 KiB of unusable-for-release free space. Contrast with keeping 1 in 1000 (the same program run as ./a.out 1000; survivors so far apart that they pin few pages):
keep 1 block in every 1000
after allocating in use 96 MiB | free inside heap 0 MiB | RSS 98 MiB
after freeing the rest in use 0 MiB | free inside heap 96 MiB | RSS 98 MiB
after malloc_trim(0) in use 0 MiB | free inside heap 96 MiB | RSS 2 MiB
here trim recovers 96 of 98 MiB: about 100 survivors pin at most about 125 pages (roughly 0.5 MiB), so nearly every page is completely free and returnable (the 2 MiB left is the survivors, the 800,000-byte blocks pointer array that the program keeps, and the program's own baseline). The two runs use the identical allocation pattern and differ only in how scattered the survivors are, which is exactly the fragmentation mechanism: it is not how much memory you freed, it is how the live blocks are distributed across pages.
Tools. mallinfo2()/malloc_info() give the in-use-vs-free split from inside the process cheaply; jemalloc's or tcmalloc's own stats (stats.resident vs stats.allocated in jemalloc) give a similar split with better per-arena detail (an arena here is one of several independent memory regions the allocator maintains, often one per CPU core, to reduce cross-thread contention) if the service is built against one of those allocators; a sampling heap profiler (jemalloc's built-in profiler, gperftools) run across a period of climbing RSS, diffed against an earlier snapshot, shows whether the same call sites keep allocating (a candidate for pooling/arenas) or whether object sizes are drifting (a candidate for better size-class behavior). malloc_trim(0) itself doubles as a diagnostic: since glibc 2.8 it releases free whole pages from anywhere in the heap, not just the top, so the before/after RSS delta it produces is a direct, cheap measurement of how much of your "free" memory was actually fragmentation versus just untrimmed-but-contiguous.
When to reach for each mitigation. Pooling (a free list of same-sized objects, reused instead of returned to the general allocator) fits a single recurring object size with high alloc/free frequency, exactly the pattern a size-class (small-object) pool allocator is built for; it eliminates fragmentation for that size class entirely because the pool never interleaves with other sizes. An arena (bulk-allocate, bulk-free at a lifecycle boundary such as end-of-request) fits workloads with a clear scope: nothing in the arena outlives the boundary, so you trade per-object free cost for one reset, and you sidestep fragmentation because nothing from a different lifetime interleaves with the arena's pages. Reduced object churn (reusing a buffer across requests instead of allocating fresh each time, or batching many small allocations into fewer larger ones) is the right call when the allocation pattern itself is the problem: a server that creates and destroys thousands of small short-lived objects per second will fragment almost any general-purpose allocator eventually, and no allocator tuning fixes an avoidably high allocation rate.
Trade-offs and pitfalls. Do not reach for a custom or arena allocator before measuring: the worked example shows that a lot of apparent fragmentation is actually just untrimmed free space that malloc_trim or periodic compaction (rearranging live objects to pack them into fewer pages; not something malloc-based C/C++ can do automatically, unlike a garbage-collected runtime) already solves for free, and adding a custom allocator has real maintenance cost (a size-class pool needs careful cross-thread free handling, which is extra surface area for bugs). Watch the actual latency symptom too: if GC-style pause-the-world compaction is not available (true for malloc-based C++), fragmentation mitigation is inherently about avoiding the bad distribution in the first place, not fixing it after the fact at runtime, so a fragmentation fix found by profiling today should also change the allocation pattern that caused it, not just paper over today's symptom with a one-off malloc_trim call in a cron job.
How does an array differ from a pointer in C, particularly when passed to a function? Why does array-to-pointer decay lead to length-handling and bounds-check bugs, and what does sizeof return in each case?
Sample Answer
Direct answer. An array is a block of N contiguous elements; its name stands for the whole block, so sizeof gives N times the element size. A pointer is one variable holding an address; sizeof gives the pointer width. In most expressions an array name is converted ("decays") to a pointer to its first element, and in a function parameter int a[10] is not an array at all: it is adjusted to int *a. So inside the callee the length is gone and sizeof a is the pointer size. Bugs follow whenever code assumes the callee still knows the length.
#include <stdio.h>
#include <string.h>
static void takes_array_syntax(int a[10]) {
printf("inside callee: sizeof(a) = %zu (a is really int *)\n", sizeof a);
}
static size_t sum(const int *a, size_t n) {
size_t s = 0;
for (size_t i = 0; i < n; i++) s += (size_t)a[i];
return s;
}
int main(void) {
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int *p = arr; /* array decays to &arr[0] */
printf("in caller: sizeof(arr) = %zu, sizeof(p) = %zu\n", sizeof arr, sizeof p);
printf("element count = sizeof arr / sizeof arr[0] = %zu\n", sizeof arr / sizeof arr[0]);
takes_array_syntax(arr);
printf("p + 1 moves by %td bytes\n", (char *)(p + 1) - (char *)p);
printf("&arr has type int(*)[10]; &arr + 1 moves by %td bytes\n",
(char *)(&arr + 1) - (char *)&arr);
printf("sum = %zu\n", sum(arr, sizeof arr / sizeof arr[0]));
/* off-by-one overrun through a decayed pointer: the callee cannot know the length */
printf("reading p[10] (one past the end) is undefined behaviour\n");
fflush(stdout);
volatile int bad = p[10];
(void)bad;
return 0;
}
Built with gcc -g -O1 -fsanitize=address,undefined s4.c -o s4 (GCC also warns: 'sizeof' on array function parameter 'a' will return size of 'int *' [-Wsizeof-array-argument]) and run on a 64-bit Linux container:
in caller: sizeof(arr) = 40, sizeof(p) = 8
element count = sizeof arr / sizeof arr[0] = 10
inside callee: sizeof(a) = 8 (a is really int *)
p + 1 moves by 4 bytes
&arr has type int(*)[10]; &arr + 1 moves by 40 bytes
sum = 55
reading p[10] (one past the end) is undefined behaviour
then AddressSanitizer stops the program with ERROR: AddressSanitizer: stack-buffer-overflow ... READ of size 4, and its report says the access is at offset 88 of a frame object arr that spans [48, 88): that 88 is the array's own starting offset (48, where ASan placed it in the stack frame, which is bookkeeping, not something you compute) plus its full 40-byte size (10 ints x 4 bytes = 40), so 48 + 40 = 88 is exactly one int past the array's last byte, the first byte p[10] touches.
What differs
array int arr[10] | pointer int *p | |
|---|---|---|
sizeof | 40 (10 x 4 bytes) | 8 on a 64-bit target |
| Storage | the elements themselves | one address |
| Can be reassigned | no | yes |
& yields | int (*)[10], a pointer to the whole array | int ** |
| Passed to a function | decays to &arr[0] | copied as is |
Decay and length bugs
Once decayed (the array name is replaced by a plain pointer to its first element), only the address travels. The callee cannot compute the length, so:
sizeof a / sizeof a[0]inside the callee gives 8/4 = 2 forint *, not 10. This is the classic bug; the compiler warning above flags it.- Callers must pass the length (
size_t n) alongside the pointer, and the callee must check indexes against it. Nothing in C checks bounds for you:p[10]is UB, and usually just reads or overwrites the neighbouring object, which is how overruns become security bugs. - Use
sizeof arr / sizeof arr[0]only in the scope wherearris a real array. Do not apply it to a function parameter, a pointer frommalloc, or a string literal's decayed address.
Pointer arithmetic basics
p + 1 advances by sizeof(*p) bytes, not by 1. The run shows 4 bytes for int * and, for &arr + 1, 40 bytes because the pointed-to type is the whole array. a[i] is defined as *(a + i). Pointer arithmetic is defined only within an array object or one element past its end (you may form that one-past address, but not dereference it).
Where this bites in network frame parsing
When a receive buffer is cast to a header struct or walked with p += n, the length that arrived on the wire is attacker-controlled. Treat (pointer, length) as a pair, check remaining >= needed before every field read, and never trust a length field inside the frame to stay within the buffer without comparing it to the bytes actually received. Subtracting or adding to lengths in unsigned types also wraps silently, so check before subtracting.
Pitfall
Passing arr and writing int a[10] in the signature is misleading documentation: the 10 is ignored. Prefer const int *a, size_t n.
Unlock Full Question Bank
Get access to all 14 Language-Level Memory Management (C/C++/Rust) interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.