12_C_Programming — interview questions and model answers¶
40 questions, ordered from basic to senior. Each answer is what you would say out loud in 30 to 60 seconds. The module that covers the topic in depth is given in brackets. Five questions (6, 9, 14, 24 and 33) are "what does this print / what is wrong with this code" snippets of the kind used in written tests and language rounds; their outputs were checked with GCC 13.3 and glibc 2.39 on Ubuntu 24.04.
Answers describe GCC 13 and glibc on x86-64 Linux. Where MSVC or Windows behaves differently in a way that breaks ported code, the answer says so: for a Linux role, knowing both is a strength, and it is the honest way to connect your Win32 server work to the question.
Practise by covering the answer, saying yours aloud, then comparing. If yours is longer than the
model answer, cut it. The coding questions (strlen, memmove, atoi, list reversal, loop
detection) are in module 16, section 2.4, and in exercise 16.1.
Part A — Building and linking¶
1. What happens between gcc main.c util.c and a running program, and which tool reports "undefined reference" and "multiple definition"? [01]
The preprocessor expands #includes and macros in each .c file, giving one translation unit per
file; the compiler turns each unit into assembly and the assembler into an object file holding
machine code, a symbol table and relocations. The linker resolves every undefined symbol against
the other objects and libraries, lays out the sections and patches the addresses; at run time the
loader maps the program and the C runtime calls main. Both errors come from the linker, because
only the linker sees all objects together: "undefined reference" means a symbol is used but no
object or library defines it, "multiple definition" means two objects define the same external
symbol. MSVC reports the same two situations as LNK2019 and LNK2005.
2. Two .c files both contain int counter; at file scope, and a third declares extern long counter;. What happens? [01]
int counter; without an initialiser is a tentative definition. GCC 9 and older emitted it as a
"common" symbol and the linker silently merged the two copies; since GCC 10 the default is
-fno-common, so GCC 13 fails with "multiple definition of counter". The extern long file
links anyway, because object files record names, not types, so on Linux it reads 8 bytes where only
4 exist (on Windows long is 4 bytes, so the same bug hides there). C has no cross-file check like
C++'s one-definition rule; the defence is one header declaring extern int counter;, included by
every user and by the one .c file that defines it, so the compiler compares declaration and
definition. Everything that does not need to be shared should be static.
3. What does static mean in C? Give all three contexts. [01, 04, 16]
On a local variable it gives static storage duration: one object, initialised once before main
(with a constant expression only), that keeps its value between calls and is shared by every
thread, so concurrent use needs a lock or an atomic. At file scope, on a variable or a function, it
gives internal linkage: the name is invisible to other translation units, so two files can each
have a private helper() without a link clash. In an array parameter, void f(int a[static 4])
promises that the argument points to at least four elements, which lets the compiler optimise and
warn about a NULL or too-short argument. The first two share a keyword but are unrelated features.
Part B — Types and integers¶
4. What is the difference between LP64 and LLP64, and why does it matter? [02]
They are the two common 64-bit data models: on LP64 (Linux, macOS) long and pointers are 64 bits;
on LLP64 (64-bit Windows) long stays 32 bits and only long long and pointers are 64 bits. int
is 32 bits on both. Code written on one breaks on the other: a pointer or file offset stored in a
long is truncated on Windows, %ld is the wrong specifier for int64_t on one of them, and a
struct containing long changes size and layout, so a file written on one platform is misread on
the other. Use <stdint.h> types for anything with a required width, size_t for sizes,
intptr_t/uintptr_t for pointers stored as integers, and the PRId64 family for printing.
5. What are the integer promotions and the usual arithmetic conversions, and how do they cause signed/unsigned bugs? [02]
Before arithmetic, every type narrower than int (char, short, uint8_t, small bit-fields) is
promoted to int, so (uint8_t)200 + (uint8_t)100 is the int 300, and ~(uint8_t)0 is the
int -1. Then the two operands are converted to a common type, and when a signed and an unsigned
type of the same rank meet, the signed one becomes unsigned: -1 < 1u is false because -1 becomes
UINT_MAX. That is why a negative int length compares as huge against a sizeof (a size_t),
or turns into a huge count when passed to memcpy. The rules also depend on the data model:
-1L < 1u is true on Linux (a 64-bit long holds every unsigned int) and false on 64-bit
Windows (both become unsigned long). Build with -Wsign-compare (in -Wextra for C) and
-Wconversion, or /W4 on MSVC.
6. What does this print on x86-64 Linux? [02]
#include <stdio.h>
int main(void)
{
unsigned char a = 200, b = 100;
int sum = a + b;
unsigned char wrapped = a + b;
int len = -1;
printf("%d %d\n", sum, wrapped);
if (len < sizeof(int))
puts("small");
else
puts("huge");
printf("%d\n", -1L < 1u);
return 0;
}
300 44, then huge, then 1. a + b is computed in int after promotion, so sum
is 300; storing 300 into an unsigned char reduces it modulo 256, giving 44, which is well defined
because the target is unsigned. len < sizeof(int) compares an int with a size_t, so -1 is
converted to SIZE_MAX and the test is false; GCC warns with -Wsign-compare under -Wextra. The
last line prints 1 on Linux and 0 on 64-bit Windows, for the data-model reason in question 5.
7. Why is signed overflow undefined, and how do you check for it? [02, 09]
Unsigned arithmetic is defined to wrap modulo 2^N, but signed overflow is undefined, so the
compiler may assume it never happens: it can treat i + 1 > i as always true and widen a 32-bit
loop counter to 64 bits without re-checking it. The price is that after-the-fact checks are folded
away: GCC 13 turns b > 0 && a + b < a into constant false, even at -O0. Check before the
operation with comparisons that cannot overflow (b > 0 && a > INT_MAX - b), or use
__builtin_add_overflow and __builtin_mul_overflow (C23 standardises them as ckd_add and
ckd_mul in <stdckdint.h>, available from GCC 14). MSVC has no such builtins in C, so Windows
code uses hand-written pre-checks or <intsafe.h> helpers such as SizeTMult. For legacy code,
-fwrapv makes wrapping defined, and UBSan finds the overflows at run time.
Part C — Pointers, memory and lifetime¶
8. What is the difference between an array and a pointer, and what is decay? [03]
An array is a contiguous block of elements whose length is part of its type; a pointer is a
variable that holds an address. In most expressions an array name is converted ("decays") to a
pointer to its first element, which is why a[i] and p[i] look the same (a[i] is defined as
*(a + i)). It does not decay as the operand of sizeof (you get the whole array's size), of
unary & (&a has type int (*)[N]: same address, different type), or when a string literal
initialises a char array. Arrays cannot be assigned or returned, and char a[] = "hi" is a
modifiable 3-byte copy while char *p = "hi" points at a read-only literal. The difference also
shows across files: extern int *table; for an object defined as int table[4] reads the array's
first bytes as an address and crashes.
9. What is wrong with this code? [03, 16]
#include <string.h>
void clear_balances(long balances[16])
{
memset(balances, 0, sizeof balances);
}
int main(void)
{
long b[16];
clear_balances(b); /* most of b still holds garbage */
return 0;
}
balances is a long * and
sizeof balances is 8 on x86-64: only the first 8 bytes are cleared, which is one long on Linux
and two on Windows. The 16 in the parameter is ignored and nothing checks it. GCC 13 warns twice:
-Wsizeof-array-argument (on by default) and -Wsizeof-pointer-memaccess (in -Wall). Pass the
count (void clear_balances(long *b, size_t n) with memset(b, 0, n * sizeof *b)), or pass a
pointer to the array, long (*b)[16], where sizeof *b is the full size.
10. What is the difference between const int *p and int *const p? [03, 16]
Read the declaration from the name outward, right to left. const int *p (the same as
int const *p) is a pointer to a const int: you can re-point p, but cannot write *p.
int *const p is a const pointer to an int: *p = 5 is fine, p = &other is an error; const int
*const p fixes both. A const on the pointee is a promise in an API ("I will not modify your
buffer", as in strlen(const char *)), not a guarantee that the object never changes, because
other non-const pointers may still modify it. C does not convert char ** to const char **
implicitly, because that would let you store the address of a const object into a plain char *.
11. What can you do with a void *, and what can you not? [03]
void * is the generic object pointer: any object pointer converts to it and back without a cast
and without loss, which is why malloc returns it and qsort and callback APIs take it. You
cannot dereference it, and standard C does not allow arithmetic on it: GCC allows void *
arithmetic as an extension (-Wpointer-arith warns), MSVC rejects it, so convert to
unsigned char * for bytes. It carries no type information, so casting it to the wrong type in a
callback is silent undefined behaviour. In C, int *p = malloc(n); needs no cast (in C++ it does),
and function pointers are not object pointers: converting them to void * is not ISO C, although
POSIX requires it to work for dlsym.
12. What does restrict mean, and how does it relate to memcpy versus memmove? [03, 05]
restrict on a pointer parameter promises that, for the duration of the call, the object it points
to is accessed only through that pointer (or pointers derived from it). The compiler can then keep
values in registers across stores made through other pointers, and vectorise loops without
run-time overlap checks; breaking the promise is undefined behaviour. memcpy's parameters are
restrict, so overlapping source and destination is UB, and real programs broke when glibc changed
its copy direction; memmove must handle overlap, typically by choosing the copy direction. C++
has no restrict; GCC, Clang and MSVC all accept __restrict as an extension there.
13. What are the four storage durations, and what is the difference between scope and lifetime? [04]
Automatic: locals and parameters, alive from block entry to block exit, indeterminate unless
initialised. Static: globals and static locals, alive for the whole run, zero- or
constant-initialised before main. Allocated: from malloc, calloc or realloc until free.
Thread: _Thread_local objects, one instance per thread for that thread's lifetime. Scope is the
part of the source where a name is visible; lifetime is when the object exists at run time, so a
static local has block scope but program lifetime, and a heap block has a lifetime but no name.
A dangling pointer is a pointer used after the lifetime of its object has ended, whatever its scope.
14. What is wrong with this code, and what does it print when built with GCC 13? [04]
#include <stdio.h>
const char *format_account(int id)
{
char buf[16];
snprintf(buf, sizeof buf, "ACC-%06d", id);
return buf;
}
int main(void)
{
const char *a = format_account(42);
const char *b = format_account(7);
printf("%s %s\n", a, b);
return 0;
}
buf has automatic storage, so its lifetime ends at return and both pointers dangle. GCC 13 warns
(-Wreturn-local-addr, on by default) and deliberately compiles the return as NULL, so the program
prints (null) (null) (glibc's text for a NULL %s) at every optimisation level. MSVC warns
(C4172) but returns the real stack address; the bytes are often still there, so such code "works"
until another call reuses that stack: here the second call overwrites the first result, and
printf's own frame can overwrite both. Fix it
with a caller-provided buffer (int format_account(char *out, size_t outsz, int id)), a heap
result the caller frees, or a struct returned by value; a static buffer stops the crash but is
neither thread-safe nor safe to call twice in one printf.
15. State the contracts of malloc, calloc, realloc and free. What is the realloc leak? [04, 10]
malloc(n) returns uninitialised memory aligned for any type, or NULL; malloc(0) may return NULL
or a unique pointer (glibc returns a pointer), which must still be freed. calloc(count, size)
returns zeroed memory and checks count * size for overflow, which malloc(count * size) does not.
realloc(p, n) may move the block: on success the old pointer is invalid; on failure it returns
NULL and leaves the old block allocated and unchanged, so p = realloc(p, n) leaks the block and
loses your data exactly when memory is short. The idiom is void *tmp = realloc(p, n); if (!tmp)
return ERR; p = tmp;, after checking the size calculation for overflow. free(NULL) is a no-op,
a double free is UB, realloc(p, 0) is implementation-defined in C17 and undefined in C23, and on
Windows a block must be freed by the same CRT that allocated it (a classic DLL-boundary bug).
Part D — Strings¶
16. Why is strncpy not a safe strcpy, and what do you use instead? [05]
strncpy(d, s, n) was designed for fixed-width, NUL-padded record fields: it always writes exactly
n bytes, padding with zeros when s is shorter, and writes no terminator at all when s has n
or more characters, so the result may not be a string. snprintf(d, sizeof d, "%s", s) always
terminates (when the size is non-zero) and returns the length it wanted to write, so
ret < 0 || (size_t)ret >= sizeof d detects truncation. A strlcpy-style helper (copy
min(len, size - 1) bytes, add a NUL, return strlen(src)) does the same without format parsing;
glibc has had strlcpy since 2.38, but portable code still carries its own. On MSVC, the old
_snprintf does not terminate on truncation and returns -1 (only snprintf from Visual Studio 2015
on is conforming), and strncpy_s truncates only if you pass _TRUNCATE.
17. Why is atoi dangerous, and how do you use strtol correctly? [05, 10]
atoi cannot report errors: "abc" gives 0 and an out-of-range value is undefined behaviour. With
strtol: set errno = 0, pass an explicit base (base 0 treats a leading zero as octal, so "010"
becomes 8), then check that end != s (some digits were read), that errno != ERANGE (on overflow
the value is clamped to LONG_MIN or LONG_MAX), that *end == '\0' (no trailing junk), and that
the value fits your target type. The range check matters across platforms: strtol("3000000000")
succeeds on Linux but gives ERANGE on Windows, where long is 32 bits; use strtoll for 64-bit
values. strtoul accepts "-1" and returns ULONG_MAX, so reject a minus sign yourself, and wrap
all of this once in a helper that writes *out only on success.
Part E — Structs, layout and binary data¶
18. Why is sizeof a struct often larger than the sum of its members, and what is offsetof for? [06]
Each member is placed at an offset that is a multiple of its alignment, and the total size is
rounded up to a multiple of the largest member alignment so that array elements stay aligned. On
x86-64, struct { char a; double b; char c; } is 24 bytes (b at offset 8, then 7 bytes of tail
padding), while struct { double b; char a; char c; } is 16, so sorting members by decreasing
alignment usually minimises padding. offsetof(type, member) from <stddef.h> gives a member's
byte offset; combine it with _Static_assert to lock layouts that matter, and use pahole to see
the holes. Padding bytes have unspecified values, so memcmp on structs or writing them out byte
for byte compares or leaks garbage.
19. What do packed structs cost, and what is the problem with unaligned access? [06, 09]
__attribute__((packed)) in GCC, or #pragma pack(push, 1) ... #pragma pack(pop) in MSVC (GCC
accepts it too), removes padding so a struct matches an external byte layout. Accessing a member by
name stays correct, because the compiler knows it is misaligned and emits safe loads, but it can be
slower. The danger is taking a member's address: an int * to a misaligned int is undefined
behaviour when dereferenced, faults on strict-alignment CPUs, can break vectorised code even on x86,
and GCC warns with -Waddress-of-packed-member; casting buf + 1 to uint32_t * is the same bug,
and UBSan's alignment check reports it. Atomics and locks inside a packed struct may not be atomic.
Decode external formats field by field from a byte buffer instead, and when you must pack, always
use push/pop so the setting does not leak into other headers.
20. What is the strict aliasing rule, and how do you reinterpret bits legally? [06, 09]
C17 6.5p7 says an object may be accessed only through an lvalue of its own (compatible) type, its
signed or unsigned variant, a qualified version, an aggregate or union containing it, or a
character type. So reading a float through *(uint32_t *)&f is undefined: at -O2 GCC assumes a
uint32_t * and a float * never refer to the same object, and may reuse stale register values or
reorder the accesses. Legal ways to reinterpret bits are memcpy(&u, &f, sizeof u), which compiles
to a single register move; reading the bytes through unsigned char *; or, in C but not in C++,
writing one union member and reading another. Legacy code that casts buffers to struct pointers can
be built with -fno-strict-aliasing (the Linux kernel does). MSVC does not, in practice, optimise
on strict aliasing, which is why code ported from Windows can break only under GCC or Clang.
21. Why should you never fwrite a struct to a file or a socket? [06, 13]
The in-memory bytes of a struct depend on the compiler and platform: padding (garbage, and possibly
leaked old data), member sizes (long is 8 bytes on Linux and 4 on Windows), alignment and packing
rules, and host byte order. So a record written by one build is misread by another compiler, OS or
CPU, or by the same program after someone adds a field, and nothing in the bytes says which layout
wrote them. Define an explicit format instead: a magic number, a version, fixed-width fields at
documented offsets in a stated byte order, lengths and a checksum, encoded and decoded field by
field with bounds checks. A versioned format also lets the reader accept old files, which a struct
dump never can.
Part F — Functions and the preprocessor¶
22. How do function pointers and callbacks with a void * context work in C? [07]
A function pointer holds the address of code: int (*cmp)(const void *, const void *) points to a
function taking two const void * and returning int, and a typedef keeps such declarations
readable. C callback APIs pass a void *ctx next to the function; the library stores it and hands
it back unchanged, and the callback casts it to the real type, so one callback can serve many
callers and threads without globals. The costs are no type checking across that cast and a
lifetime rule: the context must outlive every invocation, including ones on other threads or after
an unsubscribe. qsort has no context parameter, which is why glibc added qsort_r (comparator
(a, b, ctx)) and MSVC has qsort_s (comparator (ctx, a, b)): same idea, different argument
order. A dispatch table of handlers indexed by a range-checked opcode is the same mechanism used to
route requests in a server.
23. How does a variadic function work, and what is a format-string bug? [07]
A variadic function reads its extra arguments with va_start, va_arg(ap, type) and va_end, but
it cannot know how many there are or their types; caller and callee agree through a count, a
sentinel or a format string. Default argument promotions apply (float becomes double, char
and short become int), and reading an argument as the wrong type is undefined, which is why
%ld for an int64_t works on Linux and fails on Windows (use PRId64). A format-string
vulnerability is passing untrusted data as the format, printf(msg): %x leaks stack contents,
%s crashes and %n writes to memory. Always use a literal format (printf("%s", msg)), mark
your own wrappers __attribute__((format(printf, 2, 3))) so GCC checks every caller, and build
with -Wformat=2. MSVC checks literal formats passed to the CRT's own printf family (warnings such as
C4477, since Visual Studio 2015), checks your own wrappers only under /analyze with
_Printf_format_string_ annotations, and disables %n by default.
24. What does check(0, 3) print, and what is wrong with these macros? [08]
#include <stdio.h>
#define SQUARE(x) x * x
#define LOG_FAIL(msg) fprintf(stderr, "%s\n", msg); ++g_failures
static int g_failures = 0;
static void check(int rc, int n)
{
printf("%d\n", SQUARE(n + 1));
if (rc != 0)
LOG_FAIL("transfer failed");
}
7, not 16, and increments g_failures even though rc is 0. SQUARE(n + 1) expands
to n + 1 * n + 1, so parameters and the whole body need parentheses, ((x) * (x)), and even then
SQUARE(i++) evaluates i++ twice (unsequenced modifications: UB). LOG_FAIL expands to two
statements, so the if controls only the fprintf (GCC warns with -Wmultistatement-macros, in
-Wall). Bare braces do not fix it, because if (rc) LOG_FAIL("x"); else ... becomes { ... };
else, a syntax error; the fix is do { fprintf(stderr, "%s\n", (msg)); ++g_failures; } while (0),
one statement that takes exactly one semicolon and that the compiler optimises away. For
computations, a static inline function avoids every one of these traps and is type-checked.
25. What is an X-macro, and why use one? [08]
An X-macro keeps one list of items in a macro that takes another macro as its parameter, and
expands the list several times with different definitions of that parameter. For example
#define OPCODES(X) X(OP_BALANCE, h_balance, "balance") X(OP_TRANSFER, h_transfer, "transfer")
can generate the opcode enum, the table of names for logs and the dispatch table of handlers. Adding
an opcode is one line and the generated artefacts cannot drift apart; a _Static_assert comparing
the table size with an OP_COUNT catches what remains. The cost is code that is harder to read and
grep, so keep the list in one obvious place and put a sample gcc -E expansion in a comment.
26. What is _Generic, and what are its traps? [08]
_Generic (C11) selects an expression at compile time by the type of a controlling expression:
_Generic((x), int: print_int, double: print_double, const char *: print_str, default: print_other)(x)
calls the function that matches x's type, after lvalue conversion (top-level qualifiers dropped,
arrays decayed). It is how <tgmath.h> makes sqrt work for all floating types, and how you write
type-safe macros without C++ overloading. The associations must be distinct types, so listing both
long and int64_t fails on Linux with "'_Generic' specifies two compatible types" (they are the
same type there), while long long and int64_t fails on Windows; list the base types and let
typedefs fall into them. Character literals are int in C, so _Generic('a', char: ..., int: ...)
picks int. MSVC supports _Generic from Visual Studio 2019 16.8 with /std:c11 or /std:c17.
Part G — volatile, cleanup and errors¶
27. What does volatile do, and why is it not a tool for threads? [09, 14, 16]
volatile tells the compiler that every read and write of the object is an observable side effect:
each access must actually happen, in program order relative to other volatile accesses, with no
caching in registers. That is what memory-mapped hardware registers need, and what a
volatile sig_atomic_t flag written by a signal handler needs. It does nothing for threads: v++
is still a separate load, add and store, ordinary memory around it is not ordered, no CPU barrier is
emitted, and a concurrent non-atomic access is still a data race, which is undefined. Use
_Atomic/<stdatomic.h> or a mutex. Windows code sometimes gets away with volatile flags
because MSVC on x86 and x64 defaults to /volatile:ms, which gives volatile accesses
acquire/release semantics; that is a vendor extension, off by default when targeting ARM, and GCC
never does it.
28. How do you release resources on every error path in C? Explain the goto-cleanup idiom. [10]
C has no destructors, so a function that acquires several resources needs one place that releases
them. Initialise every handle to a "nothing" value (NULL, -1) at the top, acquire in order, and on
any failure set the error code and goto cleanup; the cleanup block at the end releases, in reverse
order, whatever was acquired, followed by a single return. The jumps only go forward to one place,
so the flow stays readable, and adding a resource means one acquire and one release instead of
editing every early return. The Linux kernel coding style recommends it (often with one label per
stage) and CERT C MEM12-C describes it; it is the C counterpart of RAII. GCC's
__attribute__((cleanup)) automates it but is not available in MSVC; either way, prove it with
fault injection that fails the Nth allocation or open for every N.
29. What are the rules for using errno correctly? [10]
errno is meaningful only immediately after a function has reported failure through its return
value (-1, NULL, EOF); successful calls may change it, nothing resets it to 0, and any later call,
including the logging call you make next, can overwrite it, so save it into a local at once.
Functions whose return value cannot signal failure, such as strtol and some <math.h> functions,
need errno = 0 before the call. Many APIs do not use it: pthread functions return the error number
directly, getaddrinfo returns EAI_* codes, Win32 calls report through GetLastError() (which
also must be saved before any other API call) and Winsock through WSAGetLastError(). errno is
thread-local in every modern C library, so threads do not overwrite each other's value. A library
should return its own error codes and provide a lib_strerror rather than make errno its API.
Part H — Data structures and allocators¶
30. What is an intrusive list, and how does container_of work? [06, 11]
In an intrusive list the link fields are embedded in the object
(struct account { ...; struct dlink by_branch; struct dlink by_status; }) instead of in a
separately allocated node that points to the object. Linking and unlinking need no allocation, an
object can be on several lists at once, and given the object you can remove it in O(1). To get from
a link back to its object, container_of(ptr, type, member) subtracts the member's offset:
(type *)((char *)(ptr) - offsetof(type, member)). The Linux kernel's list_head and most LRU
caches in C servers work this way. The costs: the type must know in advance which lists it can join,
and a wrong type or member argument silently yields a garbage pointer, so hide it behind typed
helper functions.
31. When do you use an arena, a pool, or plain malloc? [04, 12]
malloc is general purpose: any size, frees in any order, at the cost of a per-block header (the
smallest glibc chunk is 32 bytes on 64-bit), locking or per-thread caches, and fragmentation over
time. An arena hands out pieces of a large block by bumping an aligned offset and frees everything
at once with a reset: ideal when many objects share one lifetime (a request, a parse, a migration
batch), wrong when objects need individual frees. A pool serves one fixed size from a free list
threaded through the free slots themselves: O(1) allocation and free and no external fragmentation,
ideal for millions of same-size objects such as sessions or connections. The arena's risk is its
contract: every pointer is borrowed until the next reset, so storing one in a long-lived cache
gives silent wrong data later. Both hide bugs from ASan and Valgrind unless you add manual
poisoning or a build switch that routes them to malloc; on Windows, HeapCreate/HeapDestroy
gives you an arena-like private heap.
32. How does malloc work internally? [12]
glibc's allocator (ptmalloc2) splits memory into chunks, each with a size header whose low bits
hold flags (previous chunk in use, mmapped, non-main arena); the previous chunk's size field acts as
a boundary tag, so free can coalesce with both neighbours in O(1). Free chunks live in bins by
size: a per-thread tcache (by default up to 7 chunks per size, for requests up to about 1 KiB, with
no lock), fastbins, an unsorted bin and sorted small and large bins; allocation tries roughly that
order and splits a bigger chunk when needed. The heap grows with brk; requests above the mmap
threshold (128 KiB initially, adjusted dynamically) get their own mmap and go back to the OS on
free, while freed memory inside the heap is returned only when the top can be trimmed, which is
why RSS can stay high after a burst with no leak. Contending threads get extra arenas (up to 8 per
core on 64-bit, capped by MALLOC_ARENA_MAX). Errors such as free(): invalid next size are
glibc's integrity checks finding a corrupted header, usually long after the overflow; on Windows
the equivalent layers are the NT heap and the Low Fragmentation Heap, and PageHeap catches the
overflow at the faulting write.
Part I — Files and I/O¶
33. What does this print on a terminal, and what does it print with ./a.out | cat? [13]
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
printf("start\n");
if (fork() == 0) {
printf("child\n");
return 0;
}
wait(NULL);
printf("parent\n");
return 0;
}
start, child, parent. Piped: start, child, start, parent. stdout is
line buffered when it is a terminal, so start\n is written before fork; when it is a pipe or a
file it is fully buffered, so start\n is still in the buffer, fork copies the buffer into the
child, and each process flushes its own copy at exit (stderr is unbuffered, so it never has this
problem). Fix it with fflush(stdout), or fflush(NULL) for every stream, before fork, and end a
child that does not exec with _exit after flushing only its own output. The same mechanism
explains "lost" last log lines when a process crashes with stdout redirected: exit flushes stdio,
but _exit, abort and a crash do not. On Windows there is no fork, but note that MSVC's CRT
treats _IOLBF as full buffering, so redirected output is also block-buffered there.
34. Why must read and write be called in a loop, and what is EINTR? [13]
Both may transfer fewer bytes than requested: routinely on pipes, sockets and terminals, and on
regular files at end of file, on a full disk, or when a signal arrives after some data has moved. A
blocking call interrupted by a signal handler before transferring anything fails with EINTR
unless the handler was installed with SA_RESTART, and some calls (poll, select, epoll_wait)
never restart. So "send these N bytes" or "read this N-byte header" is a loop: advance by the count
returned, retry on EINTR, treat 0 from read as end of file (the peer closed), and stop on any
other error. Library code always retries on EINTR because it cannot know how the application
installed its handlers. On non-blocking sockets the loop also waits in poll on EAGAIN; Winsock's
send and recv can be partial too, with WSAEWOULDBLOCK in place of EAGAIN.
35. Does a successful write mean the data is on disk? How do you make a file update durable? [13]
No: it means the data is in the kernel's page cache, which Linux writes back later (typically
within about 30 seconds), so a power cut or kernel crash can lose it, although a process crash
cannot. fsync(fd) returns only when the file's data and metadata are on stable storage, and asks
the drive to flush its own cache; fdatasync skips metadata that is not needed to read the data
back. To replace a file atomically and durably: write a temporary file in the same directory,
fsync it, rename it over the target (atomic within one filesystem), then fsync the directory
so the rename itself survives. Treat an fsync failure as data loss, not as something to retry:
Linux may already have dropped the dirty pages, so a second fsync can succeed without the data
ever reaching disk (the 2018 PostgreSQL "fsyncgate" finding). With stdio, fflush first, then
fsync(fileno(f)); on Windows the equivalents are FlushFileBuffers and MoveFileExW with
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH.
Part J — Threads¶
36. Why must pthread_cond_wait be called in a while loop? [14]
pthread_cond_wait(&cv, &m) must be called with m held; it atomically releases the mutex and
blocks, and reacquires the mutex before returning. A return does not mean the condition is true:
POSIX allows spurious wakeups, another thread may consume the item between the signal and your
reacquiring the mutex, and one condition variable may announce several conditions (item available,
shutdown). So the only correct form is while (!predicate) pthread_cond_wait(&cv, &m); with the
predicate stored in shared state protected by m. The signalling side must change that state under
the same mutex; otherwise it can set the flag and signal between the waiter's check and its sleep,
and the wakeup is lost for good. Win32's SleepConditionVariableSRW and SleepConditionVariableCS
follow exactly the same rules, whereas auto-reset Events are a different, coalescing mechanism.
37. What is the difference between thread-safe and reentrant? [14]
Thread-safe means correct when called from several threads at the same time, possibly by using locks
or per-thread state. Reentrant means correct when called again before a previous call in the same
thread has finished (recursion, a callback, a signal handler), which requires no hidden shared state
at all. strtok, localtime, strerror and inet_ntoa are neither, because they keep a static
cursor or buffer; their _r versions take that state from the caller and are both, while a static
buffer moved to _Thread_local becomes thread-safe but still not reentrant (two calls in one
printf still overwrite each other). malloc and printf are thread-safe because they lock, which
is exactly why they are not async-signal-safe: a handler may call only functions such as write and
_exit, or set a volatile sig_atomic_t flag. MSVC's CRT keeps per-thread buffers for strtok and
localtime, so Windows code that used them from threads breaks when ported to glibc; the Windows
replacements are strtok_s and localtime_s (whose argument order is the reverse of C11 Annex K's).
38. What do C11 atomics give you, and when do you use them instead of a mutex? [14]
<stdatomic.h> provides _Atomic types (atomic_int, atomic_bool, _Atomic(struct node *))
and indivisible, race-free operations such as atomic_load, atomic_store, atomic_fetch_add and
atomic_compare_exchange_weak. The plain forms are memory_order_seq_cst; the _explicit forms
take an order: relaxed gives atomicity only (right for statistics counters), a release store
read by an acquire load makes everything written before the store visible after the load (how
you publish a filled buffer or pointer), and seq_cst adds one global order of all such operations.
On x86, loads are plain moves in every order, a seq_cst store becomes xchg, and read-modify-writes
are lock-prefixed instructions. Use an atomic for a single variable with a simple update; once an
invariant spans several fields, use a mutex. MSVC supports <stdatomic.h> only in recent Visual
Studio 2022 releases (initially behind /experimental:c11atomics), so Windows C code traditionally
uses the Interlocked* functions, which are full barriers.
Part K — Beyond the language¶
39. What are the differences between C and C++ that matter in practice? [16]
Some code compiles in both but means different things: 'a' is an int in C (sizeof 'a' is 4)
and a char in C++; void f() declares unspecified parameters in C before C23 but no parameters in
C++; reading a union member other than the last one written is defined in C and undefined in C++;
and const int n = 10; is a constant expression in C++, while in C int a[n] inside a function is
a VLA. Some valid C is not valid C++: int *p = malloc(n) without a cast, out-of-order or array
designated initialisers, compound literals, VLAs and restrict; and a file-scope const has
external linkage in C but internal linkage in C++. C++ mangles names to encode parameter types, so
a C library header must wrap its declarations in extern "C" { } (inside #ifdef __cplusplus) for
C++ callers to link. Plain C99 inline also differs: a header inline function with no external
definition can fail to link at -O0, which is why C headers use static inline. More broadly, C
leaves ownership to discipline (goto cleanup, ownership comments, void * generics) where C++
encodes it in types (RAII, smart pointers, templates).
40. You join a team with a large, unfamiliar legacy C code base. How do you get productive and change it safely? [15]
First make it build and run locally, and generate a compile_commands.json (for example with
bear -- make) so clangd, ctags or cscope can index it; on Windows, Visual Studio's "Find All
References" and call hierarchy do the same job. Then read the core data structures, find the entry
points (main, thread start routines, the request dispatch table), and trace one real request end
to end in a debugger, writing a one-page map as you go. Before changing anything, look for hidden
coupling: globals written from several files, functions returning static buffers, non-reentrant
library calls, and which lock protects which data. For each fix, write a characterisation test that
pins today's behaviour, isolate the code through a seam (-Wl,--wrap, link substitution, function
pointers), make the smallest change that removes the root cause, and keep refactoring in separate
commits. Use git log -L, git blame -w -C and git bisect run to learn why the code is the way it
is, and run sanitizers, -fanalyzer and cppcheck against a recorded baseline so CI fails only on
new findings.