简介:面向嵌入式初学者与需要复用软件IIC方案的工程师,这份STM32模拟IIC读取PCF8563资料聚焦用GPIO模拟两线时序完成实时时钟芯片的读写,可解决无硬件IIC外设或跨型号移植时的通信问题。压缩包仅12KB,共4个文件,由2个.h头文件与2个.c源文件组成,分别承载IIC底层时序接口和PCF8563寄存器操作驱动,结构精简,便于直接加入工程对照学习。已有4726人学习下载,说明该方案在RTC调试场景中具有一定参考价值。内容围绕起始/停止条件、7位地址0x51、应答检测、逐位收发与错误处理展开,并涉及秒分时日期星期、闹钟和定时器等寄存器读取设置思路;读者可据此理解开漏GPIO配置、轮询或中断实现方式,并将软件IIC代码迁移到其他支持IIC的器件上,提升调试与复用效率。
1. 板子断电就回到 2000 年,逼我把 STM32 模拟 IIC 读 PCF8563 从头捋了一遍
有块采集板每次断电再上电,串口打印的时间必然回到 2000-01-01 00:00:00。起初以为是 STM32 内部 RTC 的备份域没配好,折腾了半天才发现方向错了:板子上外挂了一颗 PCF8563 做时钟源,主控压根就没读通过它。后来把硬件 IIC 换成 STM32 模拟 IIC,用两根普通 GPIO 去磕 PCF8563 的秒、分、时、日、月、年六个 BCD 字节,一个晚上就跑通了。这套方案的适用面其实很窄也很明确:主控 IIC 外设被占用、引脚复用打架、或者总线上挂的设备时序比较挑剔的时候,软件 IIC 是最省事的解法。PCF8563 只跑 100kHz,读一次七个字节一毫秒出头,完全没必要上硬件外设。下面把选型、时序、寄存器拆解和踩过的坑一次写清楚,照着接线就能复现。
2. 软件 IIC 的时序账:开漏、上拉和延时到底怎么配
2.1 硬件 IIC 与模拟 IIC 的选型边界
先说清楚什么时候该用软件 IIC,免得上来就抄代码。STM32 的硬件 IIC 外设在一些老型号上有总线死锁的毛病,从机在传输中途掉电或者被复位,SCL 被拉低不放,主控就卡在 BUSY 状态出不来,只能靠断电恢复。软件 IIC 没有这个问题,因为每一根线的电平都在你手里,出事了手动补九个时钟脉冲就能把从机从"咬着 SDA 不放"的状态里拽出来。
引脚资源是另一个现实因素。硬件 IIC 只能走固定的复用引脚,一旦这几个脚被别的外设占了,比如被 SPI 或者串口抢走,就只能改板。软件 IIC 随便挑两个 GPIO 就行,我这块板子用的是 PB6 和 PB7,因为原来那组 IIC 引脚被一块并口屏占了。
代价也很清楚。软件 IIC 全程阻塞,一个字节八个位,每个位至少要拉高拉低各一次再加延时,读七个字节的时间全花在空转上。速率上,标准模式 100kHz 理论值,实际用普通 GPIO 翻转加循环延时,稳定跑在 50kHz 到 80kHz 之间比较现实,再往上就得抠延时精度了。
| 维度 | 硬件 IIC | 模拟 IIC |
|---|---|---|
| 引脚选择 | 固定复用脚 | 任意 GPIO |
| 典型速率 | 100kHz / 400kHz | 实测 50~80kHz |
| CPU 占用 | DMA 模式下极低 | 全程阻塞 |
| 总线死锁恢复 | 需要专门复位时序 | 补 9 个时钟即可 |
| 换平台成本 | 换外设库 | 只改一个 IO 头文件 |
PCF8563 这种 RTC 芯片的交互频率很低,正常业务里一秒读一次都算勤快的,阻塞一毫秒完全无所谓。所以这里的结论很干脆:选模拟 IIC,把硬件外设省下来给更需要的地方。
2.2 开漏输出与上拉的电气账
这一步翻车的人最多。模拟 IIC 的 GPIO 必须配成开漏输出,不能配推挽。原因在于 IIC 是真正的多主多从总线,SDA 线上任何一个设备都可以把它拉低,如果有设备用推挽往外输出高电平,另一边在拉低,那就是两个 MOS 管对灌,轻则波形畸变,重则烧引脚。
开漏配置下,写 1 等于释放总线,由上拉电阻把线拉到 VCC;写 0 才是真正拉低。这样一来,读 SDA 电平的时候不需要切换成输入模式,直接读 IDR 寄存器拿到的就是引脚真实电平,省掉了方向切换的代码,也省掉了切换瞬间的毛刺。
/* iic_soft.c —— 软件 IIC 的底层初始化,换平台只改这一段 */ #include "stm32f10x.h" #include "iic_soft.h" void iic_init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); gpio.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; /* SCL = PB6, SDA = PB7 */ gpio.GPIO_Mode = GPIO_Mode_Out_OD; /* 开漏,关键,别写 PP */ gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &gpio); IIC_SDA_H(); /* 空闲态:两条线都释放,由上拉拉高 */ IIC_SCL_H(); }参数上有两个数值得自己算。上拉电阻选 4.7k 是 3.3V 系统里的常规值,灌电流约 0.7mA,标准 IIC 器件吸收 3mA 没问题。上升时间按 tr ≈ 0.847 × R × C 估,线容按 100pF 算,4.7k 对应大约 0.4us,离 100kHz 标准模式允许的 1000ns 上限还有余量。如果总线拉得比较长,线容上到 200pF 以上,就该把上拉降到 2.2k,代价是功耗翻倍。
引脚宏定义我单独抽了一个头文件,这样以后换到别的芯片,比如换到某款国产 Cortex-M0,只改这一页宏就够:
/* iic_soft.h —— 所有位操作集中在这里 */ #ifndef __IIC_SOFT_H #define __IIC_SOFT_H #include "stm32f10x.h" #define PCF8563_ADDR_W 0xA2 /* 7 位地址 0x51 左移一位,写 */ #define PCF8563_ADDR_R 0xA3 /* 同一地址,读 */ #define IIC_SCL_H() (GPIOB->BSRR = GPIO_BSRR_BS6) #define IIC_SCL_L() (GPIOB->BRR = GPIO_BRR_BR6) #define IIC_SDA_H() (GPIOB->BSRR = GPIO_BSRR_BS7) #define IIC_SDA_L() (GPIOB->BRR = GPIO_BRR_BR7) #define IIC_SDA_IN() ((GPIOB->IDR & GPIO_IDR_IDR7) ? 1 : 0) void iic_init(void); void iic_start(void); void iic_stop(void); uint8_t iic_write_byte(uint8_t dat); uint8_t iic_read_byte(uint8_t ack); #endif用 BSRR 和 BRR 直接操作寄存器而不是调GPIO_SetBits,是因为标准库的函数调用本身要几十个周期,在延时只有几百纳秒的场景下,函数调用开销会直接吃掉时序余量,波形上能看到明显的抖动。
2.3 起始、停止、应答的时序实现
延时函数是整套代码里最玄学的一环。我一般不用定时器,就一个空循环加__NOP(),具体循环次数靠示波器或者逻辑分析仪实测微调,因为编译器优化等级一改,循环就被优化没了,时间全变。
static void iic_delay(void) { volatile uint32_t n = 10; /* 72MHz 下约 0.6us,按实测调整 */ while (n--) { __NOP(); } }volatile不能省,否则 -O2 下整个循环会被优化掉,SCL 变成几 MHz 的方波,从机直接不认。
起始条件是 SCL 保持高电平期间,SDA 由高变低;停止条件反过来,SCL 高电平期间 SDA 由低变高。这两个条件必须严格保证 SDA 的跳变发生在 SCL 高电平期间,否则从机会把它识别成普通数据位。
void iic_start(void) { IIC_SDA_H(); iic_delay(); IIC_SCL_H(); iic_delay(); IIC_SDA_L(); iic_delay(); /* SCL 高电平期间 SDA 下降:起始 */ IIC_SCL_L(); iic_delay(); } void iic_stop(void) { IIC_SDA_L(); iic_delay(); IIC_SCL_H(); iic_delay(); IIC_SDA_H(); iic_delay(); /* SCL 高电平期间 SDA 上升:停止 */ }写一个字节,是从最高位开始逐位送,每次在 SCL 低电平期间改 SDA,然后拉高 SCL 让从机采样:
uint8_t iic_write_byte(uint8_t dat) { uint8_t i, ack; for (i = 0; i < 8; i++) { if (dat & 0x80) IIC_SDA_H(); else IIC_SDA_L(); dat <<= 1; iic_delay(); IIC_SCL_H(); iic_delay(); /* 从机在 SCL 高电平期间采样 */ IIC_SCL_L(); iic_delay(); } IIC_SDA_H(); /* 释放 SDA,交给从机应答 */ iic_delay(); IIC_SCL_H(); iic_delay(); ack = IIC_SDA_IN(); /* 0 = 应答,1 = 无应答 */ IIC_SCL_L(); iic_delay(); return ack; }返回值这里容易看反。IIC 的应答是从机把 SDA 拉低,所以读到 0 才代表成功,读到 1 代表没人理你。上层判断写成if (iic_write_byte(x)) 失败;正好对上,因为非 0 即真。
读字节稍微绕一点。主机要先释放 SDA,让从机去驱动它,然后在 SCL 高电平中间采样。八个位读完之后,主机必须发一个应答或者非应答,告诉从机还要不要继续:
uint8_t iic_read_byte(uint8_t ack) { uint8_t i, dat = 0; IIC_SDA_H(); /* 释放 SDA,交给从机驱动 */ for (i = 0; i < 8; i++) { dat <<= 1; iic_delay(); IIC_SCL_H(); iic_delay(); if (IIC_SDA_IN()) dat |= 0x01; IIC_SCL_L(); iic_delay(); } if (ack) IIC_SDA_L(); else IIC_SDA_H(); /* 应答:主机拉低 */ iic_delay(); IIC_SCL_H(); iic_delay(); IIC_SCL_L(); iic_delay(); IIC_SDA_H(); return dat; }连续读多个字节时,最后一个字节必须回非应答,否则从机会以为你还要,继续往外吐数据,主机这边一旦发停止条件,从机的状态机就乱了。这个细节在第 5 章还会展开说。
3. PCF8563 的寄存器地图:BCD 时间怎么拆成六个数
3.1 从 0x00 到 0x0F,每个字节管什么
PCF8563 内部一共 16 个寄存器,地址 0x00 到 0x0F,一口气读完也就十六个字节。真正和走时相关的是 0x02 到 0x08 这七个,剩下的都是控制位、报警和定时器。
| 地址 | 名称 | 关键位 | 说明 |
|---|---|---|---|
| 0x00 | Control_status_1 | bit7 TEST1, bit5 STOP, bit3 TESTC | STOP=1 停止走时 |
| 0x01 | Control_status_2 | bit3 TI_TP, bit2 AF, bit1 TF, bit0 AIE | 报警与定时器标志 |
| 0x02 | VL_seconds | bit7 VL, bit6:0 秒 BCD | VL=1 表示电压不足 |
| 0x03 | Minutes | bit6:0 分 BCD | |
| 0x04 | Hours | bit5:0 时 BCD | 24 小时制 |
| 0x05 | Days | bit5:0 日 BCD | |
| 0x06 | Weekdays | bit2:0 星期 | 0~6,对应关系自定义 |
| 0x07 | Century_months | bit7 C, bit4:0 月 BCD | C 为世纪位 |
| 0x08 | Years | bit7:0 年 BCD | 00~99 |
| 0x09 | Minute_alarm | bit7 AE_M | 报警使能 + 分钟 |
| 0x0A | Hour_alarm | bit7 AE_H | |
| 0x0B | Day_alarm | bit7 AE_D | |
| 0x0C | Weekday_alarm | bit7 AE_W | |
| 0x0D | CLKOUT_control | bit7 FE, bit1:0 FD | FE=0 关闭时钟输出省电 |
| 0x0E | Timer_control | bit7 TE, bit6:5 TD | 定时器频率与使能 |
| 0x0F | Timer | bit7:0 倒计数 |
这张表里有三个位属于"不看会死"的级别。0x00 的 STOP 位,上电默认是 0(正常走时),但有些批次或者被误写过之后是 1,此时晶振停振,读回来的时间是冻结的,你会以为通信成功了其实数据一直没变。0x02 的 VL 位,上电默认是 1,意思是"电压过低,时间不可信",只有往这个寄存器写一次秒值才会清零。0x0D 的 FE 位,默认是 0 关闭 CLKOUT,如果不小心写成 1,会持续输出 32.768kHz,白白多耗几百微安。
3.2 BCD 掩码:为什么秒寄存器不能整体转
PCF8563 所有时间寄存器都是 BCD 编码,高四位是十位,低四位是个位。但问题在于,这些寄存器的高位里塞了别的控制位,直接拿整个字节去做(v>>4)*10 + (v&0x0F)会算出鬼一样的数。
举两个具体的例子。秒寄存器 0x02 的 bit7 是 VL 标志,如果掉电过没清,读回来是 0x80 | 秒值,比如 0x85,直接转 BCD 得到 85 秒,超出范围。月寄存器 0x07 的 bit7 是世纪位,bit5 是保留位,读回来可能是 0x91(C=1,月=0x11),直接转得到 91 月。时寄存器 0x04 的 bit6、bit7 是保留位,正常是 0,但不能假设。
所以每个寄存器在转换之前都得先掩码:
/* BCD 与二进制互转:纯位运算,无分支,适合在中断里跑 */ static uint8_t bcd2bin(uint8_t v) { return (uint8_t)(((v >> 4) & 0x0F) * 10 + (v & 0x0F)); } static uint8_t bin2bcd(uint8_t v) { return (uint8_t)(((v / 10) << 4) | (v % 10)); }掩码的位置放在读出来之后、转换之前,而且每个寄存器掩的位不一样:
t->valid = (raw[0] & 0x80) ? 0 : 1; /* VL 位:1 表示掉电过 */ t->second = bcd2bin(raw[0] & 0x7F); t->minute = bcd2bin(raw[1] & 0x7F); t->hour = bcd2bin(raw[2] & 0x3F); /* 屏蔽 bit7:6 */ t->day = bcd2bin(raw[3] & 0x3F); t->week = raw[4] & 0x07; /* 星期不是 BCD,直接取低三位 */ t->month = bcd2bin(raw[5] & 0x1F); /* 屏蔽 bit7 世纪位和 bit5 */ t->year = bcd2bin(raw[6]);星期寄存器是唯一一个不做 BCD 转换的,它的取值是 0 到 6,直接按二进制读,具体哪天算 0 由你自己定义,用之前统一约定好就行,我一般按 0 = 周日、1 = 周一到 6 = 周六来。
3.3 读时序:写指针、重复起始、连续读
PCF8563 的读操作分两步:先把寄存器指针写进去,然后把方向切到读。中间切方向的时候,要用重复起始条件(Repeated Start),而不是先发停止再发起始。
/* 从 reg 开始连续读 len 个字节到 buf,返回 0 表示成功 */ uint8_t pcf_read_regs(uint8_t reg, uint8_t *buf, uint8_t len) { uint8_t i; iic_start(); if (iic_write_byte(PCF8563_ADDR_W)) { iic_stop(); return 1; } if (iic_write_byte(reg)) { iic_stop(); return 1; } iic_start(); /* 重复起始,不释放总线 */ if (iic_write_byte(PCF8563_ADDR_R)) { iic_stop(); return 1; } for (i = 0; i < len; i++) { buf[i] = iic_read_byte((i == len - 1) ? 0 : 1); /* 末字节回 NACK */ } iic_stop(); return 0; }为什么要用重复起始?如果中间插一个停止条件,总线释放,理论上另一个主机可以抢占总线,从机的寄存器指针也可能被别的操作改掉。虽然单主系统里这种情况很少发生,但 PCF8563 的数据手册明确要求读操作使用重复起始,而且从波形上看,停止再起始会多出一段总线空闲时间,连续读多个寄存器时容易读到跨秒的数据。
这里还有个隐藏的时间一致性问题。秒寄存器在 0x02,年在 0x08,如果分两次读,一次读秒、一次读年月日,中间正好跨过整秒,就会出现"秒是 59、分已经进位"这种自相矛盾的组合。PCF8563 支持地址自动递增,从 0x02 开始连读七个字节,中间不会被打断,就能保证七个值来自同一个时刻。这就是为什么pcf_read_regs要设计成连续读而不是逐个读。
4. 代码落地:一套可移植的 PCF8563 驱动加主循环
4.1 初始化:STOP 位、CLKOUT 和报警开关
初始化不是简单地写几个寄存器,得先读回当前值,改掉需要改的位,再写回去。原因是 Control_status_1 里的 TEST1 和 TESTC 是厂家测试用的,随便写 0 或者写 1 都可能让芯片进入测试模式。
void pcf_init(void) { uint8_t ctrl; iic_init(); /* 控制状态 1:只清 STOP 位(bit5),其余位保持原样 */ if (pcf_read_regs(0x00, &ctrl, 1) == 0) { ctrl &= (uint8_t)~0x20; /* STOP = 0,启动走时 */ pcf_write_reg(0x00, ctrl); } /* 控制状态 2:关掉报警中断和定时器中断,INT 引脚保持高阻 */ pcf_write_reg(0x01, 0x00); /* CLKOUT:FE = 0,关闭 32.768kHz 输出,省电 */ pcf_write_reg(0x0D, 0x00); /* 定时器控制:TE = 0,定时器不启动 */ pcf_write_reg(0x0E, 0x00); }ctrl &= ~0x20这个写法比直接写 0x00 安全,因为它只改 STOP 位,不碰 TEST1 和 TESTC。曾经有一批板子直接写pcf_write_reg(0x00, 0x00),结果其中几块出现了走时明显偏快的问题,后来查出来是写进去的值把 TESTC 位清掉了,芯片内部的分频链被切到了测试路径上。虽然理论上默认值就是 0,但读改写永远是更稳妥的做法。
0x0D 写 0x00 而不是 0x80,是因为 bit7 是 FE(频率输出使能),bit1:0 是 FD(分频选择),全写 0 就是关闭输出加最低分频,效果一样但省掉一次位运算。如果你的板子上 CLKOUT 引脚悬空,这两者没区别;如果接了东西,一定要确认 FE 是 0。
4.2 读时间与写时间:结构体设计和 VL 位
时间结构体里我特意留了一个valid字段,用来把 VL 位的状态透传到上层。这个字段的价值在于:如果 RTC 掉过电,读回来的时间就是一堆垃圾,业务层看到valid == 0就应该去走对时流程,而不是拿 2000 年 1 月 1 日的数据去做日志。
typedef struct { uint8_t year; /* 0~99,代表 2000~2099 */ uint8_t month; /* 1~12 */ uint8_t day; /* 1~31 */ uint8_t week; /* 0~6 */ uint8_t hour; /* 0~23 */ uint8_t minute; /* 0~59 */ uint8_t second; /* 0~59 */ uint8_t valid; /* 0 = 掉电过,时间不可信 */ } rtc_time_t; uint8_t pcf_get_time(rtc_time_t *t) { uint8_t raw[7]; if (pcf_read_regs(0x02, raw, 7)) return 1; t->valid = (raw[0] & 0x80) ? 0 : 1; t->second = bcd2bin(raw[0] & 0x7F); t->minute = bcd2bin(raw[1] & 0x7F); t->hour = bcd2bin(raw[2] & 0x3F); t->day = bcd2bin(raw[3] & 0x3F); t->week = raw[4] & 0x07; t->month = bcd2bin(raw[5] & 0x1F); t->year = bcd2bin(raw[6]); return 0; } uint8_t pcf_set_time(const rtc_time_t *t) { uint8_t raw[7]; raw[0] = bin2bcd(t->second) & 0x7F; /* 写回时自动清 VL 位 */ raw[1] = bin2bcd(t->minute) & 0x7F; raw[2] = bin2bcd(t->hour) & 0x3F; raw[3] = bin2bcd(t->day) & 0x3F; raw[4] = t->week & 0x07; raw[5] = bin2bcd(t->month) & 0x1F; raw[6] = bin2bcd(t->year); return pcf_write_regs(0x02, raw, 7); }写秒寄存器这一步顺带把 VL 位清了,这是 PCF8563 的设计意图:你既然能写出一个合法的秒值,就说明系统电压是够的,时间可以重新信任。所以上电流程应该是先pcf_get_time,看valid是不是 0,是 0 就调pcf_set_time写一次当前时间(哪怕时间不准),把 VL 清掉,后面的走时才有意义。
pcf_write_regs和读函数对称,只是少了重复起始那一步:
uint8_t pcf_write_regs(uint8_t reg, const uint8_t *buf, uint8_t len) { uint8_t i; iic_start(); if (iic_write_byte(PCF8563_ADDR_W)) { iic_stop(); return 1; } if (iic_write_byte(reg)) { iic_stop(); return 1; } for (i = 0; i < len; i++) { if (iic_write_byte(buf[i])) { iic_stop(); return 1; } } iic_stop(); return 0; }注意连续写的时候寄存器地址会自动递增,所以只发一次起始和一次地址就行,后面直接灌数据。有些实现会每个字节都重新发一次起始,能跑通但效率低一半,而且中间留出的空隙给了从机处理时间,反而掩盖了时序问题,调试阶段不容易暴露。
4.3 主循环:上电判定、周期读和串口打印
主循环没什么花活,就是初始化、判断 VL、周期读、打印。关键在于读失败时的处理策略,不能一失败就死循环重试,那样会把整条总线拖死。
int main(void) { rtc_time_t t; uint32_t last = 0; uart_init(115200); pcf_init(); if (pcf_get_time(&t) == 0 && t.valid == 0) { /* 掉电过,写入一个编译时间或者上位机下发的时间 */ t.year = 26; t.month = 1; t.day = 1; t.week = 4; t.hour = 0; t.minute = 0; t.second = 0; pcf_set_time(&t); } while (1) { if (systick_ms() - last >= 1000) { last = systick_ms(); if (pcf_get_time(&t) == 0) { printf("20%02d-%02d-%02d %02d:%02d:%02d W%d%s\r\n", t.year, t.month, t.day, t.hour, t.minute, t.second, t.week, t.valid ? "" : " [VL]"); } else { printf("pcf8563 read fail\r\n"); /* 不重试,下一轮再来 */ } } } }读失败直接放弃这一轮,等下一秒再试,这个策略比当场重试三次要稳。因为 IIC 失败往往是总线被拉死,连续重试只会在同一个死状态里打转,反而让看门狗更容易咬。真要恢复,得在失败计数超过阈值之后走一次总线复位:把 SCL 当普通 GPIO 手动翻转九个脉冲,直到 SDA 释放,再重新发起始。
5. 避坑与排查:读回 0xFF、日期跳变和走时不准的五种翻车
5.1 总线层面:读回全 0xFF 和 SDA 一直被拉低
现象:pcf_get_time每次都返回 1,串口一直打pcf8563 read fail,用调试器看raw数组全是 0xFF。
原因:0xFF 是上拉电阻拉出来的电平,说明从机根本没应答,SDA 线全程没人管。常见的情况有三种:一是从机没供电,VCC 引脚虚焊;二是 SDA 和 SCL 接反了,很多模块的丝印和实际引脚顺序对不上;三是从机在上一次异常复位后咬着 SDA 不放,主控发起始时它还在等时钟。
解决:先量电压,VCC 是 3.3V 还是 0,SCL 和 SDA 空闲时是不是都被拉到 3.3V。如果电平正常但不应答,走一次总线复位:把 SCL 切成推挽输出,手动发 9 个时钟脉冲,每个脉冲之后检查 SDA 是否释放,释放了再发一个正常的停止条件。这个操作在实际项目里救过我至少三次。
5.2 时序层面:NACK 出现在第二个字节
现象:第一个字节(设备地址 + 写)能收到 ACK,第二个字节(寄存器地址)回 NACK,或者读方向切过去之后就断了。
原因:延时太短。72MHz 主频下iic_delay里的循环次数如果调到 3 或者 4,SCL 高电平时间只有一百多纳秒,PCF8563 的最小时钟高电平要求是 0.6us(100kHz 标准模式),采样窗口根本不够。另一种可能是 GPIO 速度等级设成了 2MHz,边沿太缓,在长线上出现过冲。
解决:把iic_delay的循环次数从 10 往上加到 20,GPIO 速度改成 50MHz,然后用逻辑分析仪抓一次波形,看 SCL 的高电平宽度是否大于 0.6us、上升时间是否小于 1us。调延时这件事没有公式,只能靠示波器,血泪经验是宁可慢一点,50kHz 也够用。
5.3 数据层面:BCD 掩码没做干净导致日期跳变
现象:读回来的月份偶尔变成 0x11 转出来的 11 月,或者秒值变成 85、91 这种超范围数字,跨零点的时候日期直接跳到下个月。
原因:只掩了秒寄存器的 bit7,忘了月寄存器的 bit7(世纪位)和 bit5(保留位)。PCF8563 上电后世纪位可能是 1,读出来是 0x80 | 月份,直接 BCD 转换就多出 80。
解决:把每个寄存器的掩码写死成常量,别用& 0x7F一把梭。秒用 0x7F、分用 0x7F、时用 0x3F、日用 0x3F、月用 0x1F、年用 0xFF、星期用 0x07。写回的时候也要掩,尤其是写月的时候如果结构体里 month 被误传成 13,bin2bcd会算出 0x13,会污染 bit4,读回来又是一堆乱码。
5.4 掉电与走时:VL 位没清和晶振偏差
现象:第一次上电读出来的时间是 2000-01-01 00:00:00,valid是 0;或者跑了一个月发现每天慢 2 秒,一个月差一分钟。
原因:前者是 VL 位没有清,芯片认为电压不足,时间不可信;后者是 32.768kHz 晶振的负载电容匹配不准,或者晶振本身的精度只有 ±20ppm,一天的理论误差就是 1.7 秒。
解决:VL 位的问题在 4.2 节说过,上电检测到valid == 0就写一次时间把它清掉。走时精度没办法靠软件根治,但可以缓解:晶振旁边那两颗负载电容(通常 12pF 或 22pF)按晶振手册的 CL 值算,PCB 上走线尽量短,远离发热器件。如果业务要求高,就定期和上位机对时,或者用第 6 章的差值法估算偏差再做软件补偿。
提示:VL 位只有在写秒寄存器时才会清零,写其他寄存器没用,别指望写个月份就把它清掉。
6. 验证与进阶:三层验证法和一次跨秒读的修正
驱动写完不等于能用,我一般按三层去验证。波形层用逻辑分析仪抓 SCL/SDA,看起始条件、地址字节、ACK 位置、重复起始、末字节 NACK 是否都对得上,这一层能抓出 90% 的时序问题。寄存器层用调试器单步,把raw[7]数组打出来,手工按 BCD 解一遍,和串口打印的结果比对,这一层能抓出掩码错误。业务层让程序连续跑 24 小时,看日期有没有在零点正确进位、星期有没有跟着变,这一层专抓跨寄存器的一致性问题。
三层都过了,还有个小问题值得处理:读时间的瞬间如果正好跨秒,pcf_get_time内部虽然用了连续读保证七个字节同源,但两次调用之间仍可能跨越秒边界。做定时任务的时候,我习惯连读两次,比较秒值,如果第二次的秒比第一次小或者大了超过 1,就认为跨秒了,取第二次的值:
uint8_t pcf_get_time_stable(rtc_time_t *t) { rtc_time_t a, b; if (pcf_get_time(&a)) return 1; if (pcf_get_time(&b)) return 1; /* 秒值没有突变,说明两次读在同一秒内,取后者 */ if (b.second == a.second || b.second == a.second + 1) { *t = b; return 0; } *t = b; /* 跨秒了就以后一次为准,不重试 */ return 0; }另一件值得做的是估算晶振偏差。记录两次相隔较长时间(比如 24 小时)的读数,用主控的 SysTick 计时做参考,算出 PCF8563 走的秒数和真实秒数的差,得到 ppm 偏差值。如果每天稳定慢 2 秒,就是 -23ppm,这种系统性偏差可以记在本子上,知道这块板子大概多久需要对一次时。真正的硬件校准只能改负载电容,软件层面能做的是把对时周期从一个月缩到一周。
从那以后我每次调 RTC,都会先把iic_delay用示波器量一遍,再拿逻辑分析仪完整抓一次读时序,最后挂机跑一天看日期进位——这三步一步都不能省,省了哪一步,哪一步就会在量产之后找上门来。希望帮到你。
本文还有配套的精品资源,点击获取