[esp8266] Replace millis() with fast accumulator, wrap Arduino callers

Arduino ESP8266's millis() uses 4x 64-bit multiplies with magic
constants to convert system_get_time() to ms while tracking overflow.
On the LX106 (no hardware multiply-high instruction), each 64-bit
multiply goes through the __umulsidi3 software helper — costing
~3.3 us per call.

Replace with a simple accumulator that tracks a running millis counter
from system_get_time() deltas using pure 32-bit integer ops (subtract,
add, compare, subtract). No 64-bit math, no __umulsidi3.

Use -Wl,--wrap=millis to intercept all ::millis() calls site-wide so
Arduino libraries and ISR handlers (Wiegand, ZyAura) also get the fast
version. Brief interrupt disable (~125 ns) protects the static state.

Overflow safety: unsigned 32-bit delta arithmetic handles the 71-minute
system_get_time() wrap correctly for one wrap. ESPHome calls millis()
thousands of times per second, so missing a full wrap is not a realistic
concern. At boot, both the accumulator and system_get_time() start at 0,
so no special initialization is needed.

Benchmarked on real ESP8266 hardware:
  Before: 3348 ns/call (Arduino 4x 64-bit multiply)
  After:  ~800-900 ns/call (accumulator, estimated)
This commit is contained in:
J. Nick Koston
2026-04-11 22:16:38 -10:00
parent bef4c8a86c
commit 406546876a
2 changed files with 47 additions and 2 deletions
+5
View File
@@ -314,6 +314,11 @@ async def to_code(config):
for symbol in ("vprintf", "printf", "fprintf"):
cg.add_build_flag(f"-Wl,--wrap={symbol}")
# Wrap Arduino's millis() so all callers (including Arduino libraries and ISR
# handlers) use our fast accumulator instead of the expensive 4x 64-bit multiply
# implementation in the Arduino ESP8266 core.
cg.add_build_flag("-Wl,--wrap=millis")
cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE])
ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
+42 -2
View File
@@ -16,8 +16,44 @@ extern "C" {
namespace esphome {
void HOT yield() { ::yield(); }
uint32_t IRAM_ATTR HOT millis() { return ::millis(); }
uint64_t millis_64() { return Millis64Impl::compute(::millis()); }
// Arduino ESP8266's millis() uses 4× 64-bit multiplies with magic constants to
// convert system_get_time() → ms while tracking overflow (~3.3 μs per call on
// the LX106 which has no hardware multiply-high instruction). We replace it with
// a simple accumulator that tracks a running millis counter from μs deltas using
// pure 32-bit ops (subtract, add, compare-and-subtract). No __umulsidi3 software
// multiply calls.
//
// Overflow safety: system_get_time() is a uint32_t that wraps every ~71.6 minutes.
// Unsigned subtraction (now - last) handles one wrap correctly. ESPHome calls
// millis() thousands of times per second (1+N per loop iteration at 60+ Hz), so
// missing a full 71-minute wrap period is not a realistic concern. At boot,
// s_last_us starts at 0 and system_get_time() counts from 0, so the first call's
// delta equals the real elapsed time — no special initialization needed.
//
// This function is also installed as __wrap_millis (via -Wl,--wrap=millis) so
// that Arduino library code and ISR handlers (e.g. Wiegand, ZyAura) calling
// ::millis() directly also get the fast version. Interrupts are briefly disabled
// (~10 instructions, ~125 ns at 80 MHz) to protect the static state from
// concurrent ISR access.
static uint32_t IRAM_ATTR HOT millis_accumulator_() {
static uint32_t s_cache = 0;
static uint32_t s_remainder = 0;
static uint32_t s_last_us = 0;
uint32_t ps = xt_rsil(15);
uint32_t now_us = system_get_time();
uint32_t delta = now_us - s_last_us;
s_last_us = now_us;
s_remainder += delta;
while (s_remainder >= 1000) {
s_cache++;
s_remainder -= 1000;
}
uint32_t result = s_cache;
xt_wsr_ps(ps);
return result;
}
uint32_t IRAM_ATTR HOT millis() { return millis_accumulator_(); }
uint64_t millis_64() { return Millis64Impl::compute(millis()); }
void HOT delay(uint32_t ms) { ::delay(ms); }
uint32_t IRAM_ATTR HOT micros() { return ::micros(); }
void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
@@ -78,4 +114,8 @@ extern "C" void resetPins() { // NOLINT
} // namespace esphome
// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator.
// Requires -Wl,--wrap=millis in build flags (added by __init__.py).
extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); }
#endif // USE_ESP8266