Your 32-bit Product Will Fail in 2038. Your Build Will Not Stop It.

rust dev.to

This caught us in the middle of a routine product update on a 32-bit ARM platform, where everyone involved had assumed the toolchain settled it years ago. A 32-bit time_t stops counting on 19 January 2038, inside the service life of hardware shipping this quarter. The fix — building against a 64-bit time_t — has been in the kernel and the C libraries for years, and OpenEmbedded-Core applies it by default from Yocto 4.3 onward. What your build will not do is tell you when it has not been applied: the check that catches a 32-bit time_t binary is a warning rather than an error, it is switched off for every Rust component, and it never looks at prebuilt vendor libraries or at the filesystem underneath.

We found it by accident, and not on a target. A build machine came up with the wrong date, and chasing down what that broke led into a library in our own image still being compiled against a signed 32-bit time_t while everything around it had moved on. Nothing in the product update concerned timekeeping, and nobody had gone looking. The kernel and glibc had supported 64-bit time for years. How a single library ends up on the wrong side of that line is the subject of what follows.

A signed 32-bit time_t counts seconds since 1970 and reaches its maximum value of 2147483647 at 03:14:07 UTC on 19 January 2038, after which it wraps to December 1901 — just under twelve years away, and inside the service life of hardware entering production this quarter. This is not an unsolved problem: the kernel gained 64-bit time system calls in Linux 5.1 in 2019, and glibc the matching interfaces in 2.34, reached by building with -D_TIME_BITS=64 -D_FILE_OFFSET_BITS=64. So the useful question is not whether a fix exists. It is narrower, and only you can answer it for your own product: did that fix reach every binary and every partition in the image you ship? There are four places where the answer is commonly no.

The thirty-second check

Two checks tell you whether this article describes your problem or somebody else's. The first is your Yocto release, because the flags became a default at a specific point and many production BSPs predate it. The second is the build itself. The flags are appended to TARGET_CC_ARCH rather than CFLAGS, so they are part of CC and survive recipes that overwrite CFLAGS — and you can read the final value straight out of the build environment:

raghu@techveda.org:~$bitbake -e busybox | grep "^TARGET_CC_ARCH="
Enter fullscreen mode Exit fullscreen mode

If -D_TIME_BITS=64 is not in that value, nothing your build produces is using a 64-bit time_t, whatever else is true. That takes thirty seconds and costs no rebuild. Everything below is about why a 64-bit time_t might be missing, and what is still exposed when it is present.

Does your build ask for it?

glibc will not give you a 64-bit time_t by default. Its manual states that when _TIME_BITS is undefined, time_t "defaults to 64 bits on most architectures. Although it defaults to 32 bits on some traditional architectures (i686, ARM), this is planned to change and applications should not rely on this." That change has not happened. On 32-bit ARM the flags have to be passed, for every package, by the build system. musl took the other approach and made time_t 64-bit everywhere in 1.2.0, so C code built against musl headers needs no flags.

OE-core does pass them. It carries meta/conf/distro/include/time64.inc, which defaultsetup.conf includes, so it reaches every OE-based distribution rather than only poky:

GLIBC_64BIT_TIME_FLAGS = "${GLIBC_64BIT_TIME_FLAGS_WHEN_NEEDED}"
GLIBC_64BIT_TIME_FLAGS_WHEN_NEEDED = " -D_TIME_BITS=64 -D_FILE_OFFSET_BITS=64"
TARGET_CC_ARCH:append:arm = "${GLIBC_64BIT_TIME_FLAGS}"
TARGET_CC_ARCH:append:armeb = "${GLIBC_64BIT_TIME_FLAGS}"
TARGET_CC_ARCH:append:mipsarcho32 = "${GLIBC_64BIT_TIME_FLAGS}"
Enter fullscreen mode Exit fullscreen mode

Set globally like this, the flags still do not prove that every package is safe, because individual recipes can opt out. The :append:arm suffix is a BitBake override: the line applies only when the target architecture is arm, and equivalent lines cover 32-bit x86 and PowerPC. The indirection through GLIBC_64BIT_TIME_FLAGS is the opt-out hook — setting it empty for one recipe, as the file does with GLIBC_64BIT_TIME_FLAGS:pn-glibc = "", removes the flags from that recipe alone. That is one way a single library ends up on the wrong side of the line.

The date this arrived matters more than the mechanism. The include was added to defaultsetup.conf in June 2023, so a 64-bit time_t is the default from Yocto 4.3 Nanbield onward, and the first LTS to carry it is 5.0 Scarthgap. Kirkstone 4.0 LTS and earlier do not have it unless a layer requires time64.inc explicitly. The file exists in those trees; it is simply not included. A large number of vendor BSPs in production today are Kirkstone-based, and for those teams this is not a review exercise but an open task.

Nobody on our update had checked either. The assumption on every side was that the toolchain settled this years ago, and nothing in the build was asking anyone to question it — which is the whole difficulty with this particular problem, and the subject of the next two sections.

Buildroot treats the change as a deliberate choice. Its Config.in defines BR2_TIME_BITS_64, prompt "Build Y2038-ready code", which adds -D_TIME_BITS=64; Buildroot already passes -D_FILE_OFFSET_BITS=64 unconditionally, so the option supplies the missing half. There is no default y. Unless somebody selected it, a Buildroot image built against a glibc toolchain for 32-bit ARM has 32-bit time_t today. The option is hidden for musl, which is already safe, and for uClibc-ng, which has no equivalent switch at all. This is one more entry in the ledger of defaults that differ between the two build systems, which is worth reading alongside the wider comparison in Yocto vs Buildroot.

Would a green build tell you?

Assume the flags are set and a 64-bit time_t is what your toolchain produces. Nothing yet proves the flags reached every package, and OE-core does not ask you to take it on trust. insane.bbclass defines a QA test named 32bit-time that runs objdump over each packaged ELF file, matches its undefined symbols against a list of 32-bit glibc APIs, and reports each hit as "uses 32-bit api". So the verification exists. What happens when it fires is the problem:

WARN_QA ?= "32bit-time native-last pep517-backend"
Enter fullscreen mode Exit fullscreen mode

It is a warning, not an error. A recipe that adds a 32-bit-time binary produces one line in a build log that already runs to thousands, and the build goes green.

There is a second limit that is easy to miss and worse than the first. The check matches symbols carrying a @GLIBC_ version tag, which is how glibc marks its exported functions. On a musl image no symbol carries that tag, so the check matches nothing and reports clean by construction — not because the image is clean, but because it cannot see. It also only runs when the target is arm, armeb, mipsarcho32, powerpc or x86.

What is the check told to ignore?

A warning you can at least go looking for. The more useful question is what the check never examines. time64.inc suppresses it, via a per-recipe setting called INSANE_SKIP, for glibc, strace, pseudo and the cross-canadian toolchain packages that run on the SDK host rather than the target. Those exemptions are structural — glibc has to implement both sets of entry points, and pseudo wraps every libc call including the legacy ones — and none of them decides what time your product thinks it is.

One exemption is different in kind. cargo_common.bbclass suppresses the check for everything built with Cargo, and states why:

# The culprit for this setting is the libc crate,
# which as of Jun 2023 calls directly into 32 bit time functions in glibc,
# bypassing all of glibc provisions to choose the right Y2038-safe functions. As
# rust components statically link with that crate, pretty much everything
# is affected, and so there's no point trying to have recipe-specific
# INSANE_SKIP entries.
Enter fullscreen mode Exit fullscreen mode

So every Rust component in an OE image is exempted from the check, not cleared by it, and the absence of a warning tells you nothing at all about that code. The same holds for anything statically linked or calling syscalls directly, because the check only inspects undefined dynamic symbols.

The upstream ticket, rust-lang/libc issue 3223, was opened in April 2023 and is still open. The maintainers' position is consistent rather than careless: the libc crate deliberately matches what C sees with no preprocessor options defined, and for glibc that means 32-bit time. Be careful about how wide the exposure actually is, though. A maintainer notes in the same thread that most Rust programs do not touch the libc crate directly, and that std::time::SystemTime already calls __clock_gettime64 on glibc, so it is already using a 64-bit time_t. What is exposed is code calling libc::clock_gettime, libc::stat or libc::time from FFI or a -sys wrapper. The crate is nearly universal as a transitive dependency; direct use of its time entry points is not. That distinction is the difference between an audit and a panic.

What about the filesystem?

Suppose every binary is clean. The image still sits on storage you did not compile. In ext4, the timestamps held in the original 128 bytes of the inode are 32-bit signed values that overflow in January 2038; the wider ones live in the extra space that only a larger inode has. The kernel encodes both limits directly, in fs/ext4/ext4.h:

#define EXT4_EXTRA_TIMESTAMP_MAX    (((s64)1 << 34) - 1  + S32_MIN)
#define EXT4_NON_EXTRA_TIMESTAMP_MAX    S32_MAX
Enter fullscreen mode Exit fullscreen mode

The first widens the seconds field to 34 bits, offset so that pre-1970 times still encode; fs/ext4/super.c assigns it to s_time_max when the inode is larger than 128 bytes, and the second otherwise. Decoded, that is 10 May 2446 against 19 January 2038.

Current tools get this right: mke2fs.conf sets inode_size = 256 in its defaults, and since e2fsprogs 1.46.4 the small and floppy profiles no longer override it. The risk is old images rather than new ones — partitions created by a pre-2021 host toolchain, or by a vendor flashing script passing -I 128. Which is a reason to check the device rather than the recipe.

💡 Key insight: Whether a device still works after January 2038 is a property of each binary and each filesystem, not of the image as a whole. An image can be almost entirely correct and still fail, because the parts that handle time properly and the parts that do not sit side by side in the same rootfs.

What to check, in order

1. Your release and your flags. The two checks above. If TARGET_CC_ARCH has no -D_TIME_BITS=64 and you are on Kirkstone or older, add require conf/distro/include/time64.inc to your distro configuration and deal with what breaks. If you are on a vendor distro you do not own, this belongs in a .bbappend or a distro override in your own layer, not in local.conf, so that CI sees it too.

2. Promote the QA check so a new recipe cannot add a 32-bit-time binary without stopping the build:

ERROR_QA:append = " 32bit-time"
Enter fullscreen mode Exit fullscreen mode

Use :append, not +=. In a configuration file, += is an immediate assignment that replaces the variable, so ERROR_QA += " 32bit-time" would silently throw away every other error-level QA check you currently have. Promotion also changes nothing for Rust, because the exemption removes the check before it is classified.

3. Test on a future clock. OE-core's notes give the QEMU line. It goes in your image or machine configuration, and setting it requires rebuilding the image, because the value is written into the .qemuboot.conf file at image time:

QB_OPT_APPEND:append = " -rtc base=2040-02-02"
Enter fullscreen mode Exit fullscreen mode

Boot it and watch what fails: services asserting or exiting on time-related calls, the clock reporting an implausible date, a watchdog reset nobody asked for. Running the package test suites under that clock is the thorough version.

4. Inspect the binaries you did not build. Vendor blobs and prebuilt SDK libraries never pass through your compiler flags, so the QA check cannot see them. Use the dynamic symbol table — objdump -t reads .symtab, which strip removes, so on a stripped library it prints nothing and an empty result looks like a clean one:

raghu@techveda.org:~$arm-linux-gnueabihf-objdump -Tw /path/to/vendor/libfoo.so | grep UND
Enter fullscreen mode Exit fullscreen mode

On glibc, a component built with a 64-bit time_t refers to redirected symbols such as __clock_gettime64, and one that refers to plain clock_gettime is using the 32-bit entry point. On musl the plain name is correct and this test tells you nothing.

5. Check the inode size of every writable partition on a fielded device:

raghu@techveda.org:~$sudo dumpe2fs -h /dev/mmcblk0p2 | grep "Inode size"
Inode size:               256
Enter fullscreen mode Exit fullscreen mode

A value of 128 means that partition's timestamps stop working in 2038 whatever the software above it was compiled with, and it cannot be fixed by an update. The realistic remedy is to correct the format in your image build and migrate the data partition during an update, which is a far easier conversation to have now than in 2037.

Running those five checks takes an afternoon. Acting on what they return — moving a Kirkstone BSP forward, or auditing a vendor stack you did not build — is longer work, and it is the kind of platform and BSP engagement TECH VEDA's engineering services take on.

Key takeaways

  • Check TARGET_CC_ARCH for -D_TIME_BITS=64 before anything else; it takes thirty seconds and needs no rebuild.
  • OE-core sets a 64-bit time_t by default, but only since Yocto 4.3 Nanbield. Kirkstone 4.0 LTS and earlier do not, and many production BSPs are still on Kirkstone. Buildroot's BR2_TIME_BITS_64 is off unless you select it.
  • The 32bit-time QA check is a warning by default, exempts every Rust component, and reports clean by construction on a musl image.
  • The Rust exposure is narrower than usually described: std already uses a 64-bit time_t, direct libc crate calls do not.
  • An ext4 filesystem with 128-byte inodes stops recording timestamps in 2038 whatever user space was built with, and no update can fix it.

Frequently asked questions

How do I tell whether my build already uses 64-bit time_t?
Run bitbake -e on any target recipe and read the final value of TARGET_CC_ARCH. If it does not contain -D_TIME_BITS=64, the flags are not reaching your build. This needs no rebuild and works on any release.

Does my Yocto build have it by default?
Only if it is based on Yocto 4.3 Nanbield or later. The include that enables the flags was added to defaultsetup.conf in June 2023, so Kirkstone 4.0 LTS and earlier builds do not have it unless a layer requires time64.inc explicitly.

Does building with 64-bit time_t require a newer kernel?
It works on older kernels, but not fully. glibc's release notes give a minimum kernel version of 5.1 for the 64-bit time system calls; on older kernels glibc falls back to the legacy 32-bit calls, which reintroduces the 2038 limit at the syscall boundary.

Why is the 32bit-time check only a warning?
Because several packages still legitimately trip it, including glibc itself and the cross-canadian toolchain components, which is why time64.inc carries explicit exceptions for them. You can raise it to an error in your own distribution configuration, but use ERROR_QA:append rather than += or you will discard the default error list.

Further reading

Source: dev.to

arrow_back Back to Tutorials