Skip to main content

32-bit Valkey builds: header include order and off_t ABI mismatch

Author
quanye
Systems software: OS, networking, distributed systems.
Table of Contents

This PR is memorable: it was our first PR to Valkey.

Static linking background also shows up in CSAPP Linker Lab.

PR

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 a struct aligned?

    • 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_t as 4 bytes.

    • File B.c treats off_t as 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_t is 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_t to off64_t and open to open64.

  • Bug mechanism:

    • Include order matters.

    • If <stdio.h> or <sys/types.h> is included before the macro is defined, the system headers freeze off_t as 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 .o files see 32-bit off_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 foo expects 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_assert is compile-time: if sizeof(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_t is 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_t field.

- 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_assert in a core header like server.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)
#

  1. Translation Unit: A .c file after preprocessing (including all headers). The bug is different TUs disagreeing on one type.
  2. Alignment (CSAPP 3.9.3): Growing off_t from 4 to 8 bytes can also shift later fields due to alignment.
  3. Symbol resolution (CSAPP 7.2): How the linker wires .o symbols; 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_assert in server.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 .o into 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_assert in server.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_assert on the server.h entry 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.