大飞的技术博客

·4 分钟·

给深度睡眠的设备记时间:RX8010SJ RTC 驱动实录

PocketOracle 每次醒来都想回答一个问题:「现在几点」。ESP32-C3 深度睡眠等于一次 重启,内部定时器全部失忆,芯片自己不知道墙上时间。所以板上加了一颗 EPSON RX8010SJ 实时钟(I2C 地址 0x32),主控睡觉时它靠自己的电源继续走时,醒来一问 便知。这篇文章记录这颗 RTC 的驱动落地过程。

先说一个 bring-up 花絮:原理图/BOM 上曾把它标成 RX8025T。两者都是爱普生的 I2C RTC,名字像双胞胎,寄存器布局却完全不同(RX8010 的时间寄存器从 0x10 开始,RX8025T 从 0x00 开始)。驱动落地前一定要按实际芯片的手册来——最终 确认板上丝印对应的是 RX8010SJ。

挂在别人的总线上

RX8010SJ 和加速度计 SC7A20 共享同一条 I2C 总线。ESP-IDF 新版 i2c_master 驱动的模型是「总线」和「设备」两级:总线创建一次,设备各自往上挂。项目里 让 SC7A20 的驱动负责创建总线,RTC 驱动通过一个访问器拿现成的总线句柄:

esp_err_t rx8010_init(void)
{
    if (s_ok) return ESP_OK;
    // 总线由 sc7a20_init() 创建,必须先跑
    i2c_master_bus_handle_t bus = sc7a20_get_i2c_bus();
    if (bus == NULL) return ESP_ERR_INVALID_STATE;

    i2c_device_config_t cfg = {
        .dev_addr_length = I2C_ADDR_BIT_LEN_7,
        .device_address = 0x32,
        .scl_speed_hz = 100000,
    };
    // 挂设备 → 读一次 CTRL 确认应答 → 清 FLAG
}

「RTC 组件依赖加速度计组件」看着有点怪,但在这块板上总线的上拉配置、 深睡眠 hold 都是加速度计 bring-up 时一并处理的,让总线只有一个创建者, 比两个组件各自抢着建总线干净。代价是初始化顺序有了隐式约定: sc7a20_init() 必须先于 rx8010_init()

时间寄存器:BCD 码

时间落在 7 个连续寄存器里,数值全是 BCD 码(每半个字节一位十进制数):

寄存器地址内容
SEC/MIN/HOUR0x10–0x12秒/分/时,HOUR 用 24 小时制
WDAY/MDAY/MON/YEAR0x13–0x16星期/日/月/年(年 = 2000+值)

转换只需要两个宏:

#define BCD_TO_DEC(x)  (((x) >> 4) * 10 + ((x) & 0x0F))
#define DEC_TO_BCD(x)  ((((x) / 10) << 4) | ((x) % 10))

读取时还要按位掩码剥离标志位:秒寄存器的 bit7 是 VL 位(下文主角), 小时的 bit7 是 12/24 制标记。掩码忘了剥,时间就会偶尔「跳」出一个怪值。

读时间:burst 读消除撕裂

第一直觉是逐个寄存器读 7 次。但这有时间撕裂问题:读秒和读分之间如果 恰好发生进位(59 秒跳下一分),拼出来的就是一个从未存在过的时间。 解法是把 7 个字节压缩进一条 I2C 事务连续读出,进位无法再插进读取 过程的中间:

uint8_t reg = REG_SEC, buf[7];
i2c_master_transmit_receive(s_dev, &reg, 1, buf, 7, 100);   // 一次读 0x10-0x16
time->vl     = (buf[0] >> 7) & 1;
time->second = BCD_TO_DEC(buf[0] & 0x7F);
// ...

写时间同理用 burst 写,并且前后要包一层 RESET 保护(下一节)。

写时间:RESET 位 + burst 写

更新时间不能对着正在走数的寄存器直接写。RX8010 的流程是:

wr(REG_CTRL, 0x40);          // 1. CTRL 的 RESET 位置 1,暂停走时
// 2. 一条事务 burst 写 7 个时间寄存器,VL 位顺手写 0(清除)
uint8_t buf[8] = { REG_SEC, bcd_sec, bcd_min, bcd_hour,
                   weekday & 0x07, bcd_day, bcd_mon, bcd_year };
i2c_master_transmit(s_dev, buf, 8, 100);
wr(REG_CTRL, 0x00);          // 3. RESET 清零,时钟恢复走动

set_time 入口还做了参数范围校验(秒 0–59、月 1–12……),非法值直接 拒绝——BCD 码写进一个 70 的「分钟」,芯片不会替你报错。

VL 位:时间可不可信,芯片自己知道

RX8010 的妙处是 VL(Voltage Low)标志:一旦芯片电源跌破维持走时的下限, 它把秒寄存器 bit7 置 1,并一直保留到软件主动写时间清除。也就是说, 时间是否经历过掉电,芯片比你清楚:

bool rx8010_is_valid(void)
{
    rx8010_time_t t;
    if (rx8010_get_time(&t) != ESP_OK) return false;
    return !t.vl;   // VL=0 才可信
}

主流程每次唤醒都检查一遍:

if (!rx8010_is_valid()) {
    ESP_LOGW(TAG, "RTC time invalid (VL set) — battery may have been disconnected");
}

没有 VL 位之前,「设备换过电池、时间回到了默认值」这种事只能靠猜 (比如判断年份是不是 2000);有了它,一句话就能把不可信时间拦在门外。 开机自检(selftest)也把 rx8010_init → get_time → is_valid 串成一项 检查,连时间值一起打到串口日志里,pytest 直接拿这些行做断言。

初始化顺手做的两件事

  • 读一次 CTRL 寄存器当「在不在」探测——总线通、芯片应答才算初始化成功;
  • 清 FLAG 寄存器(AF/TF/UF,报警/定时器/更新标志),保证从干净状态开始。

小结

外部 RTC 驱动的代码量很小(200 行以内),真正花心思的是三件事:按实际 芯片手册核对寄存器表(别信 BOM 上的名字)、burst 读写保证一致性、用 VL 位回答「这时间敢不敢用」。对一个每天醒几次的深度睡眠设备来说,这颗 几毛钱的芯片就是它与世界时间唯一的连线。