This PR is memorable: it was our first PR to Valkey.
Static linking background also shows up in CSAPP Linker Lab.
At the code level: add many ../fmacros.h includes and a compile-time sanity check before server.
/*
* Sanity check: we require large-file support. If include order caused
* _FILE_OFFSET_BITS to be ignored, off_t may end up 32-bit on 32-bit builds,
* which will lead to ODR/LTO type mismatches. Fail fast at compile time.
*/
#include <sys/types.h>
static_assert(sizeof(off_t) >= 8, "off_t must be 64-bit; ensure _FILE_OFFSET_BITS=64 is in effect before system headers");
// From here on, every off_t offset definition should be 8 bytes
Part 1: Core concepts #
1. What is an ABI (Application Binary Interface)? #
CSAPP chapters: Ch. 3 (machine-level code), but the idea runs through the book.
-
API (source interface): for humans. e.g.
void func(int a);— if the source matches, it compiles. -
ABI (binary interface): for the CPU and OS. It defines how compiled binaries interact.
-
Data layout: How many bytes is
int? How is astructaligned? -
Calling convention: Args in registers or on the stack? Where is the return value?
-
-
The bug you hit:
A classic ABI incompatibility.
-
File A.c treats
off_tas 4 bytes. -
File B.c treats
off_tas 8 bytes. -
When A calls B, or they share a struct containing
off_t, memory layout is wrong (field offsets misaligned). They talk past each other.
-
2. First point: off_t and LTO link-time conflicts
#
CSAPP chapters: Ch. 7 (linking).
-
Background: On 32-bit systems (e.g. x86), default
off_tis 32-bit and cannot represent files > 2GB. For Large File Support (LFS), Linux glibc provides the feature-test macro_FILE_OFFSET_BITS=64. -
What the macro does: When defined, the compiler rewrites
off_ttooff64_tandopentoopen64. -
Bug mechanism:
-
Include order matters.
-
If
<stdio.h>or<sys/types.h>is included before the macro is defined, the system headers freezeoff_tas 32-bit. -
On a 32-bit build that means compile-time type is 32-bit.
-
If included after the macro, it is 64-bit.
-
Result: some
.ofiles see 32-bitoff_t, others 64-bit.
-
-
Why does LTO warn?
-
Ordinary linker (
ld): Just stitches text sections; it usually cannot see argument type differences (it is blind).** -
LTO (Link Time Optimization): The compiler joins at link time and sees IR. It notices: “Module A’s
fooexpects a 4-byte arg, but module B passes 8 bytes — warn!”** -
During IR optimization, mismatched interpretations of the same field trigger the warning.
-
That is the point of intermediate representation: same symbol, different type view.
-
3. Second point: defensive programming with static_assert
#
CSAPP angle: software engineering meets the compiler.
-
Compile-time assertion:
_Static_assert(C11) /static_assert(C++11). -
Value:
-
Ordinary
assert()is runtime: the binary must run and crash before you know. -
static_assertis compile-time: ifsizeof(off_t) < 8, the compiler errors and refuses to emit a binary.
-
-
Architecture: “Fail Fast”. Turn a silent memory-corruption bug (possible on-disk corruption) into a loud compile error.
-
Compile-time assert prevents future include-order regressions.
—
Part 2: How to explain this in interviews (script) #
Interviewer: “What ABI issue did you fix?”
Answer with STAR + a CSAPP-level systems view.
1. Opening: Situation #
“This was on 32-bit Linux. Default
off_tis 4 bytes, so files larger than 2GB are unsupported. Valkey forces 64-bit offsets via_FILE_OFFSET_BITS=64.”“But include order was wrong in some files: system headers like
<stdio.h>were included before the macro. That produced a serious ABI mismatch.”
2. Technical depth (Task & Action) #
Interviewer: “What breaks?”
You: “It violates the C analogue of C++’s ODR (One Definition Rule).
Struct memory layout goes wrong.
Suppose a struct has an
off_tfield.- TU A thinks size/offset is 4.
- TU B thinks size is 8.
**Passing that struct by pointer across TUs corrupts later fields.** **Two files define the same struct but disagree on field sizes/offsets — that cannot work.**LTO found it: with LTO the linker sees cross-module type signatures and emitted
type mismatch. Without LTO this can be a random runtime crash that is nearly impossible to attribute to include order.”
3. Fix and architecture (Result) #
You: “Two changes:
1. Fix the dependency chain: force
fmacros.h(which defines the macro) to be the first include so the preprocessor sees a 64-bit world before any system header. (That is why so many headers changed.)2. Defense in depth: add a C11
static_assertin a core header likeserver.h.**`static_assert(sizeof(off_t) == 8, ...)` matters.** **If the build env or include order regresses, compile fails. A latent runtime bug becomes a deterministic compile-time error.”**
—
Part 3: CSAPP quick review (before interviews) #
- Translation Unit: A
.cfile after preprocessing (including all headers). The bug is different TUs disagreeing on one type. - Alignment (CSAPP 3.9.3): Growing
off_tfrom 4 to 8 bytes can also shift later fields due to alignment. - Symbol resolution (CSAPP 7.2): How the linker wires
.osymbols; LTO plugs in at this stage for cross-module analysis.
Summary Q&A #
What is glibc (GNU C Library)? Why was this only fixed now — because 32-bit is rare? Which compile stages are involved? Why put
static_assertinserver.h— is that the choke point that makes all offsets 64-bit?These four questions hit the systems core. Answers below.
—
1. What is glibc?
One-liner: glibc is the standard C library on Linux — the translator between apps and the kernel.
“Essentially the C header/runtime library” — GNU C Library
- Role:
**The kernel exposes raw syscalls (`sys_open`, `sys_read`, `sys_write`) that are awkward.** **glibc wraps them into familiar APIs: `fopen`, `printf`, `malloc`, `memcpy`, and here `open` / type `off_t`.**- Relation to this bug:
**For historical 32-bit compatibility, glibc keeps 32-bit defaults.** **It exposes a switch (feature test macro): `_FILE_OFFSET_BITS=64`.** **- Off: `off_t` is 32-bit; old `open`.** **- On: preprocessor rewrites `off_t` → `off64_t`, `open` → `open64`.**—
2. Why only fixed now? Because 32-bit is rare?
Mostly three reasons:
1. 32-bit servers are nearly extinct:
**- Valkey/Redis are memory-heavy; production often wants tens of GB to TB.** **- 32-bit VA space is ~4GB (user space often ~3GB) — fatal for Redis-class workloads.** **- ~99.9% of production is 64-bit (`x86_64` / `arm64`), where `off_t` is already 64-bit and the macro does not matter.**2. Silent corruption:
**- Without LTO, the linker usually does not check argument types.** **- A 32-bit build may “work” until the wrong offsets are used, or crash rarely — few people blame include order.**3. LTO becoming common:
**- LTO used to be rare (slow).** **- GCC/Clang LTO matured; performance-sensitive projects like Valkey enable it by default.** **- LTO is a mirror: at link time it sees source-level types and asks why `off_t` changes size.**—
3. Which compile stages?
CSAPP’s four stages from source to binary — this bug/fix spans the first three:
Stage 1: Preprocessing — crime scene
- Tool:
cpp- Action:
#include/#define.- What happened:
**- This is where the PR fixes the order.** **- Bug: `#include <stdio.h>` first (macro undefined → glibc picks 32-bit), then `#define _FILE_OFFSET_BITS 64` (too late).** **- Fix: `#include "fmacros.h"` first so the macro is live before system headers expand.** **- Preprocessing order makes `off_t` mean 64-bit.**### Stage 2: Compilation — evidence hardens
- Tool:
cc1- Action: C → assembly / object (
.o).- What happened:
**- This is where `static_assert` fires.** **- If the macro did not apply, `sizeof(off_t)` is 4.** **- `static_assert(sizeof(off_t) == 8)` aborts before a bad `.o` is emitted.******Stage 3: Linking — discovery
- Tool:
ld- Action: Merge
.ointo an executable.- What happened:
**- This is where LTO warns.** **- Signatures in `lrulfu.o` vs callers in `server.o` disagree on argument size → Warning.** **- Cross-module check that interpretations of `off_t` match.**—
4. Why
static_assertinserver.h?Choke-point strategy.
- Role of
server.h:**In Valkey/Redis it is a “god header”: almost every `.c` includes it for the global `server` struct and core config.**- Why that covers the tree:
**It does not “force” others’ offsets; almost everyone includes it.** **- If `a.c` includes system headers first (32-bit world) then `server.h`.** **- When the compiler hits `static_assert` in `server.h`, it checks `off_t` in the current context.** **- Context is 32-bit → assert fails → compile dies.**- Conclusion:
**It does not auto-fix a wrong offset; it detects it.** Include `server.h` and you must already be in a clean 64-bit LFS environment, or compilation refuses. Because `server.h` is so widely included, holding that gate covers ~90% of the codebase.Interview one-liner
“This bug is an ABI mismatch from bad preprocessing order.
1. Glibc switches type versions via a feature-test macro.
2. Misordered includes made some TUs 32-bit ABI and others 64-bit.
3. It stayed hidden because 32-bit servers are rare and non-LTO linkers ignore types.
4. The fix forces macro order at preprocess time and adds
static_asserton theserver.hentry path at compile time — defense in depth.”5. So before the fix, was 32-bit Valkey basically broken?
Not necessarily — if structs with off_t were never shared across mismatched TUs, you might never notice.