Unix Epoch Time on Microcontrollers: Why Your Timestamps Are Wrong and How to Fix Them

Debugging timestamp drift on a microcontroller with a real-time clock module

You pull a month of field data from 200 deployed sensors, load it into your analysis pipeline, and the timestamps are wrong. Not dramatically wrong. Not “stuck at January 1, 1970” wrong. Off by 47 seconds here, 3 minutes there. A handful of records jump backward in time. Two devices in the same building disagree by 6 minutes on when the same event happened. You grep through the firmware, check the RTC init, verify the sync logic. Everything looks right.

Timestamp bugs on microcontrollers rarely announce themselves. They accumulate quietly, survive bench testing, and surface only when you’re staring at production data at scale.

Here’s a walkthrough of the most common software-level causes, with emphasis on BLE-synced and offline IoT devices. I’m assuming you already know what Unix epoch time is and how RTCs work. The goal: give you a diagnostic framework so you can trace the corruption to its source and fix it at the right layer.

Integer Representation Bugs

The 2038 problem gets all the press, but the practical integer bugs hit much earlier and more subtly.

Start with time_t. On your desktop compiler, it’s probably 64-bit. On your embedded toolchain? Check. ARM GCC targeting Cortex-M still defaults to a 32-bit time_t in many configurations as of 2025. If you’re doing signed arithmetic on what you assume is a large integer, you’re one edge case away from wrapping.

The sneakier variant: signed vs. unsigned subtraction for duration calculations. Say you compute elapsed = timestamp_b - timestamp_a using uint32_t. If timestamp_b is earlier than timestamp_a due to a sync correction, the unsigned subtraction doesn’t produce a negative number. It wraps to a massive positive value. Your code happily records that a sensor event lasted 4.2 billion seconds.

Then there’s the millisecond trap.

┌─────────────────────────────────────────────────┐
│     32-bit millisecond counter overflow          │
│                                                  │
│  0ms ──────────────────────────► 4,294,967,295ms │
│  Day 0                           Day 49.7        │
│                                  ↻ wraps to 0    │
│                                                  │
│  If storing ms-since-epoch:                      │
│  Unix ms epoch overflows 32-bit in... 1970 + 49d │
│  ❌ Unusable. Must use 64-bit or seconds.        │
└─────────────────────────────────────────────────┘

Storing millisecond-resolution timestamps in a uint32_t overflows in 49.7 days. I’ve seen teams discover this only after their 2-month pilot deployment produced garbage data in the back half.

Fix: Audit every time-related variable in your codebase. Use uint64_t for millisecond timestamps. Use uint32_t for second-resolution epoch time (good until 2106). If you’re doing duration calculations, cast to signed explicitly and handle the negative case. Document your epoch base next to every time variable declaration.

Epoch Base and Timezone Conflation

Your firmware uses Unix epoch (January 1, 1970). The BLE Current Time Service (CTS) profile references a base date of January 1, 1582. GPS epoch starts January 6, 1980. Mix these up, and every timestamp silently shifts by years or decades.

This sounds too obvious to get wrong. It happens constantly, usually when a developer copies a CTS parsing example without reading the spec’s date encoding, or when a GPS module’s output gets piped into a Unix time field without the 315,964,800-second offset.

Timezone conflation is the other classic. A phone sends local time over BLE. The firmware stores it as UTC. Everything looks fine until daylight saving time kicks in, and you get an hour of duplicate timestamps or an hour of missing ones. Depending on which direction the clock jumped, your event log has either a gap or a fold.

Fix: Normalize everything to UTC Unix epoch internally. Convert only at the display or export layer. Document the epoch base of every external time source your firmware touches, ideally in a comment right where the parsing happens.

RTC Oscillator Drift: The Software Side

A typical 32.768 kHz crystal specifies ±20 ppm accuracy. That sounds tiny. Here’s what it looks like over time:

┌──────────────────────────────────────────┐
│  Drift accumulation at ±20 ppm           │
│                                          │
│  1 hour  →  ±0.07 seconds               │
│  1 day   →  ±1.7  seconds               │
│  1 week  →  ±12   seconds               │
│  1 month →  ±52   seconds               │
│  1 year  →  ±10.5 minutes               │
│                                          │
│  Worse at temperature extremes.          │
└──────────────────────────────────────────┘

Nearly a minute per month. And that ±20 ppm is specced at 25°C. A device in a freezer warehouse or bolted to sun-baked outdoor equipment will drift 2–3x worse, because crystal frequency shifts with temperature in a parabolic curve.

The hardware drift is predictable. The software errors around it are what really hurt. The most common: reading the RTC’s seconds register while it’s mid-update, getting a stale or partially-written value. On many STM32 and Nordic parts, you need to read the register twice and check for consistency, or wait for a ready flag. Skip that, and you’ll occasionally read a time value that’s off by exactly 1 second, which looks like jitter in your data.

Another software mistake: applying a calibration offset with the wrong sign. If your drift compensation adds to a clock that’s already running fast, you’ve doubled the error.

Fix: If your MCU has RTC calibration registers (most Cortex-M parts do), use them. For software compensation, track the delta between your local RTC and the sync source at each sync event. Compute a running ppm estimate. Apply a linear correction factor to interpolate between syncs. Even a crude model (just dividing total observed drift by total elapsed time) strips away most of the error. If you’re working on a custom device integration, building drift compensation into your timekeeping layer from the start saves painful retrofitting later.

BLE Time Sync Done Wrong

This is where IoT timestamp problems compound, because BLE sync introduces multiple error sources that stack.

CTS parsing pitfalls. The Bluetooth SIG’s Current Time Service encodes the year as a full 16-bit value (not an offset from 1582, despite the epoch base). Day-of-week encoding uses three values to know: 1 = Monday, 7 = Sunday, and 0 = “unknown.” The “Adjust Reason” bitfield tells you why the time changed (manual update, external reference, timezone change), and almost every implementation I’ve seen ignores it. If the phone’s time jumped because a user manually set the clock, you probably want to handle that differently than a carrier network sync.

What does the round-trip look like? A BLE connection event has inherent latency: the connection interval (often 1–4 seconds on power-constrained devices), scheduling jitter, and processing time on both sides. A naive implementation that receives a time value and stamps it as “now” can be off by hundreds of milliseconds to multiple seconds. On a device syncing every few hours, this becomes the dominant error source.

How trustworthy is the phone? The user’s phone might be in airplane mode with stale time. They might have manually set their clock. They might have traveled across time zones without the phone updating. If the incoming time jumps backward by 3 hours, or forward by 2 days, something is wrong on the phone side.

Sync frequency tradeoffs. More syncs mean better accuracy but higher power draw. Calculate your drift budget: if ±20 ppm gives you ~1.7 seconds/day of drift, and your application tolerates ±5 seconds, you need to sync at least every 3 days. Build this into your power budget explicitly.

Fix: Implement a simple sync protocol. (1) Request time from the phone. (2) Note your local RTC value at both the request and the response. (3) Estimate one-way latency as half the round-trip. (4) Apply the offset. Reject the sync if the round-trip exceeds a threshold (say, 4 seconds). Sanity-check: if the new time differs from your current estimate by more than your maximum expected drift since the last sync, flag it and don’t apply it blindly. Store sync metadata (local RTC at sync, received time, round-trip duration) alongside your data. This lets you correct timestamps retroactively if you later discover a sync was bad.

For devices that transmit data over BLE to a gateway or cloud backend, your timing management strategy needs to account for all of these layers.

Handling Reboot and Power Loss

On MCUs without a battery-backed RTC, power loss resets time to epoch 0. Or to the firmware’s compile timestamp, if that’s what your init code uses as a default. Either way, the device wakes up and starts logging data with completely wrong timestamps. If your backend doesn’t reject or flag these, they silently contaminate your dataset.

Even with a battery-backed RTC, there’s a subtler bug: firmware that caches the RTC value in RAM on first read and then fails to re-read after a warm reboot. Or worse, firmware that writes a stale RAM value back to the RTC on init, overwriting the correct hardware time.

Fix: On every boot, read the hardware RTC. If there’s no valid time source (no battery-backed RTC, no sync has occurred), flag all logged data as “unsynced” with a validity marker. Don’t fabricate a timestamp. Run a monotonic tick counter (from a hardware timer) alongside your epoch time. Even when absolute time is unknown, the tick counter preserves event ordering. Sequence numbers work for the same purpose and cost almost nothing.

A Minimal Timekeeping Architecture

Here’s the pattern that holds up well across projects:

Time source layer. Abstracts the hardware RTC read, BLE sync events, GPS time, or whatever external sources you have. Each source reports a time value plus metadata (source type, estimated accuracy, round-trip latency if applicable).

Time manager. Maintains the current best estimate of UTC epoch time. Validates incoming sync values (sanity checks, round-trip threshold). Applies drift compensation between syncs. Manages the “no valid time yet” state explicitly. Logs every sync event with before/after values.

Consumer API. A single function that returns the current timestamp plus a confidence or validity flag. Callers don’t need to know whether the time came from BLE, GPS, or a corrected RTC. They just need to know if they can trust it.

The key insight: log your sync events. If you store the local RTC value and the received reference time at every sync, you can retroactively correct all the timestamps between those two sync points. This turns a “the data is ruined” situation into one that just needs a post-processing step.

Monotonic counters or sequence numbers should run in parallel (as described in the reboot/power-loss section above) to preserve event ordering when absolute time is uncertain.

Pre-Release Timestamp Diagnostic Checklist

┌─ Timestamp Diagnostic Checklist ─────────────┐
│                                               │
│ □ All time variables audited for bit-width    │
│ □ Epoch base documented for every source      │
│ □ All internal time stored as UTC             │
│ □ RTC read handles register update race       │
│ □ Drift compensation implemented              │
│ □ BLE sync includes latency estimation        │
│ □ Incoming time sanity-checked                │
│ □ Power-loss / reboot state handled           │
│ □ Unsynced data flagged, not fake-stamped     │
│ □ Sync history logged for post-hoc correction │
│                                               │
└───────────────────────────────────────────────┘

The fix is never just “sync more often.” It’s understanding exactly where your time goes wrong and correcting at that layer.


Hubble Network enables accurate, timestamped data collection from microcontroller-based devices at global scale—without relying on frequent sync windows or gateway proximity. See how it works →