本文整理 F103、RS‑485、Delta A3 伺服控制项目中的参数持久化方案。目标是让通信参数、转子运行参数和传感器选择能够在断电后恢复。EEPROM 只解决参数保存;运动控制、故障联锁和替代原 PLC 的功能验收仍是独立工作。
原笔记包含一份 63 字节的 C 结构体和一张 60 字节的地址表,两者不一致。本文保留原有参数和历史布局差异,给出可检查的分页驱动,以及明确标为 V2 提案的序列化格式。没有提供原工程、完整原理图或 EEPROM 转储,因此不能确认已部署设备实际使用哪一种布局,也不能直接覆盖旧设备数据。
芯片组织与地址
跳转到“芯片组织与地址”AT24C02C 是 2 Kbit EEPROM,即 256 字节,地址为 0x00~0xFF。它有 32 个写页,每页 8 字节;字节地址仍连续,页边界只约束单次页写。器件名称中的 02 不能直接理解成 2 KB。
对于字节地址 :
例如从 0x1C 写入 10 字节,第一笔写 0x1C~0x1F 共 4 字节,等芯片就绪,再写 0x20~0x25 共 6 字节。若把 10 字节作为一次页写发送,地址会在 0x18~0x1F 页内回卷,后面的数据覆盖该页前部。
7 位 I²C 地址是 1010 A2 A1 A0;只有确认三个地址引脚均接地,才能使用 0x50。STM32 HAL 的 DevAddress 参数使用左移一位的地址 0x50 << 1,读写方向由 HAL 设置。芯片手册、STM32F1 HAL I²C 源码分别说明芯片地址和接口参数。
接线与读写时序
跳转到“接线与读写时序”原记录提出 SDA、SCL 各使用 4.7 kΩ 上拉到 3.3 V,电源附近放 100 nF 去耦。这些可以作为原设计的核对项目,不能视作已经验过原理图;上拉值要结合总线电容、速率和器件电平要求确定。
WP 为高时禁止写入,推荐将其接到明确的逻辑电平。AT24C02C 的 WP 有内部下拉,原文“悬空必然随机写失败”的结论不成立;工程上仍应避免不明确的连线,尤其不能误接到时钟或数据线上。受写保护时,收到 ACK 也不代表存储内容已改变。
一次页写的顺序为:
START → 设备地址+写 → ACK → 字节地址 → ACK → 同一页内的数据和 ACK → STOP → 内部写周期 → ACK 轮询确认就绪手册给出的内部写周期 tWR 为最大 5 ms。软件可以按最大值等待,也可以进行 ACK 轮询,但必须有超时,不能无限等待失联器件。
随机读先以写方向发送起始地址,再重复 START,切换读方向,最后一个字节由主机返回 NACK 后 STOP。顺序读可跨 8 字节页边界;仍要限制总范围,避免超过 0xFF 后回到开头。
哪些参数需要保存
跳转到“哪些参数需要保存”运行时的临时状态、实时采样值与需要掉电保存的配置应分开。原项目参数如下;数值来自旧笔记,只是历史示例,不是已验收的机器默认值。
| 参数 | 含义或单位 | 原示例 |
|---|---|---|
stationID | Modbus 从站地址 | 1 |
baudRate | 波特率 | 38400 |
timeout | 通信超时,ms | 500 |
encoderResolution | 编码器分辨率;计数口径须与驱动器核对 | 10000 |
gearRatio | 传动比;分子分母方向须在控制程序中统一 | 1.0 |
maxSpeed | 最大转速,rpm | 3000 |
maxAccel、maxDecel | 加、减速度限制;原文未给出可靠单位 | 各 1000 |
balanceSpeed | 平衡测试转速,rpm | 600 |
searchSpeed | 寻找基准转速,rpm | 200 |
positionAccuracy | 定位判定容差,° | 1.0 |
angleOffset | 安装基准相对传感器基准的偏移,° | 0.0 |
secondPosAngle | 二次定位角度,° | 0.0 |
secondPosSpeed | 二次定位转速,rpm | 200 |
photoSensorAlarmTime | 光电异常等待时间,100 ms 为一单位 | 10,即 1 s |
sensorType | 0:光电;1:接近开关 | 0 |
加载后先做数据验证,再将通信字段应用到 RS‑485/Modbus 模块,将运动字段应用到控制器,将传感器枚举映射到实际输入。原代码中的 g_servoController 与 g_tigerServoController 只是原工程对象名称,无法仅凭笔记确认其当前成员布局。
旧布局为什么不能直接沿用
跳转到“旧布局为什么不能直接沿用”旧代码使用 __attribute__((packed)),并在 version 后放了一个 reserved0,末尾放了 reserved1[3]。在其声明的 16/32 位整数和 32 位浮点条件下,总长度为 63 字节,原文“约 55 字节”计算错误。后附地址表删除了前面的保留字节,并把末尾保留区缩为 1 字节,因此是 60 字节。
| 项目 | 原 C 结构体 | 原地址表 |
|---|---|---|
magic | 0x00~0x01 | 相同 |
version | 0x02 | 相同 |
stationID | 0x04 | 0x03 |
baudRate | 0x05~0x08 | 0x04~0x07 |
gearRatio | 0x0F~0x12 | 0x0E~0x11 |
sensorType | 0x39 | 0x38 |
| 末尾保留区 | 0x3A~0x3C,3 字节 | 0x39,1 字节 |
| CRC | 0x3D~0x3E | 0x3A~0x3B |
| 总长度 | 63 字节 | 60 字节 |
旧 C 代码的 CRC 覆盖从 version 到末尾保留区,即偏移 2~60,不含 magic 和 CRC 字段。它检查 magic 与 CRC,却没有检查 version 和参数取值,也没有实现正文所说的读取备用区 B。
迁移旧设备时,先导出完整 256 字节内容和对应固件版本,通过原工程的 sizeof、offsetof、序列化代码和实测值确认格式。为已确认的旧格式写专用解码器,完成值域验证后再迁移。仅凭 magic 或 CRC 匹配不能把两种布局混读,也不能用新的 packed 结构体覆盖旧记录。
HAL 分页驱动
跳转到“HAL 分页驱动”以下驱动负责字节传输,不负责配置语义或断电事务。接口显式接收 I²C 句柄,检查地址范围,分页写入并等待就绪,所有 HAL 调用采用有限超时。零长度操作定义为成功;非零长度数据指针不能为空。
调用条件是:I²C 外设已初始化、HAL 时基正常运行、在普通任务或主循环上下文执行。同一总线由多个任务使用时,需要在调用外部串行化整个 EEPROM 操作;不要在中断中调用带阻塞等待的版本。
at24c02c.h
跳转到“at24c02c.h”#ifndef AT24C02C_H#define AT24C02C_H#include "stm32f1xx_hal.h"#include <stdint.h>
HAL_StatusTypeDef AT24C02C_Write(I2C_HandleTypeDef* bus, uint16_t address, const uint8_t* data, uint16_t length);HAL_StatusTypeDef AT24C02C_Read(I2C_HandleTypeDef* bus, uint16_t address, uint8_t* data, uint16_t length);#endifat24c02c.c
跳转到“at24c02c.c”#include "at24c02c.h"
enum { CAPACITY = 256, PAGE_BYTES = 8, DEVICE_ADDRESS = 0x50u << 1 };enum { BUS_TIMEOUT_MS = 25, WRITE_READY_MS = 10 };
static HAL_StatusTypeDef wait_ready(I2C_HandleTypeDef* bus) { const uint32_t started = HAL_GetTick(); do { const HAL_StatusTypeDef result = HAL_I2C_IsDeviceReady(bus, DEVICE_ADDRESS, 1, 2); if (result == HAL_OK) return HAL_OK; if (result == HAL_BUSY || result == HAL_TIMEOUT) return result; HAL_Delay(1); } while ((uint32_t)(HAL_GetTick() - started) < WRITE_READY_MS); return HAL_TIMEOUT;}
static int valid_range(uint16_t address, uint16_t length) { return address <= CAPACITY && length <= CAPACITY - address;}
HAL_StatusTypeDef AT24C02C_Write(I2C_HandleTypeDef* bus, uint16_t address, const uint8_t* data, uint16_t length) { if (!bus || !valid_range(address, length)) return HAL_ERROR; if (length == 0) return HAL_OK; if (!data) return HAL_ERROR; while (length) { const uint16_t available = PAGE_BYTES - (address % PAGE_BYTES); const uint16_t chunk = length < available ? length : available; HAL_StatusTypeDef result = HAL_I2C_Mem_Write(bus, DEVICE_ADDRESS, address, I2C_MEMADD_SIZE_8BIT, (uint8_t*)data, chunk, BUS_TIMEOUT_MS); if (result != HAL_OK) return result; result = wait_ready(bus); if (result != HAL_OK) return result; address += chunk; data += chunk; length -= chunk; } return HAL_OK;}
HAL_StatusTypeDef AT24C02C_Read(I2C_HandleTypeDef* bus, uint16_t address, uint8_t* data, uint16_t length) { if (!bus || !valid_range(address, length)) return HAL_ERROR; if (length == 0) return HAL_OK; if (!data) return HAL_ERROR; return HAL_I2C_Mem_Read(bus, DEVICE_ADDRESS, address, I2C_MEMADD_SIZE_8BIT, data, length, BUS_TIMEOUT_MS);}WRITE_READY_MS=10 为示例轮询窗口,实际返回还包含单次 HAL 调用和调度开销;它不是严格的 10 ms 实时期限。HAL_ERROR 在轮询期会重试,硬件故障可能最终表现为超时,应结合 HAL_I2C_GetError() 和总线波形进一步诊断。
例如向地址 0x1C 写 10 字节,调用 AT24C02C_Write(&hi2c1, 0x1C, data, 10);必须检查返回值,再读回比较。写保护、线路故障或中途掉电都不能仅靠发送函数返回成功排除。
明确的 60 字节格式:V2 提案
跳转到“明确的 60 字节格式:V2 提案”这里沿用旧地址表中的字段顺序,统一使用小端整数和 IEEE 754 binary32 浮点。版本改为 2,保留字节固定为零,CRC 覆盖前 58 字节,即 0x00~0x39,包含 magic 与 version。CRC 参数为多项式 0x1021、初始值 0xFFFF、不反射、无最终异或;校验输入 123456789 的结果应为 0x29B1。
这是本文提出并离线检查的格式,尚未接入设备固件,不兼容旧代码直接复制的 63 字节记录。 不应自动把旧 version=1 改成 2 后写回。
| 偏移 | 长度 | 字段 | 表示 |
|---|---|---|---|
0x00 | 2 | magic | u16,0xA3A3 |
0x02 | 1 | version | u8,2 |
0x03 | 1 | stationID | u8 |
0x04 | 4 | baudRate | u32 |
0x08 | 2 | timeout | u16 |
0x0A | 4 | encoderResolution | u32 |
0x0E | 4 | gearRatio | float32 |
0x12 | 4 | maxSpeed | float32 |
0x16 | 4 | maxAccel | float32 |
0x1A | 4 | maxDecel | float32 |
0x1E | 4 | balanceSpeed | float32 |
0x22 | 4 | searchSpeed | float32 |
0x26 | 4 | positionAccuracy | float32 |
0x2A | 4 | angleOffset | float32 |
0x2E | 4 | secondPosAngle | float32 |
0x32 | 4 | secondPosSpeed | float32 |
0x36 | 2 | photoSensorAlarmTime | u16 |
0x38 | 1 | sensorType | u8,0 或 1 |
0x39 | 1 | reserved | u8,0 |
0x3A | 2 | crc16 | u16,小端 |
配置字段跨页没有问题:例如 gearRatio 从 0x0E 开始,它会跨过 0x0F 的页尾,驱动自动拆分。应用层应按字节格式编码,不依赖编译器对齐和本机结构体大小。
下面的 Python 3.10+ 程序是参考编解码器和离线检查入口,不连接硬件。struct 的 < 前缀明确规定小端、标准字段长度和无自动填充,f 使用 binary32。Python struct 文档
"""Proposed V2 wire format; never silently reinterpret historical V1 EEPROM."""import mathimport struct
MAGIC = 0xA3A3VERSION = 2LAYOUT = struct.Struct("<HBBIHI10fHBBH")FLOAT_FIELDS = ( "gearRatio", "maxSpeed", "maxAccel", "maxDecel", "balanceSpeed", "searchSpeed", "positionAccuracy", "angleOffset", "secondPosAngle", "secondPosSpeed",)FIELDS = ("stationID", "baudRate", "timeout", "encoderResolution", *FLOAT_FIELDS, "photoSensorAlarmTime", "sensorType")
def crc16(data): crc = 0xFFFF for byte in data: crc ^= byte << 8 for _ in range(8): crc = ((crc << 1) ^ (0x1021 if crc & 0x8000 else 0)) & 0xFFFF return crc
def validate(cfg): if set(cfg) != set(FIELDS): raise ValueError("missing or unexpected fields") limits = { "stationID": (1, 247), "baudRate": (1, 0xFFFFFFFF), "timeout": (1, 0xFFFF), "encoderResolution": (1, 0xFFFFFFFF), "photoSensorAlarmTime": (0, 0xFFFF), "sensorType": (0, 1), } for key, (low, high) in limits.items(): if type(cfg[key]) is not int or not low <= cfg[key] <= high: raise ValueError(f"invalid {key}") for key in FLOAT_FIELDS: value = cfg[key] if type(value) not in (int, float) or not math.isfinite(value): raise ValueError(f"invalid {key}") if cfg["gearRatio"] <= 0 or cfg["positionAccuracy"] < 0: raise ValueError("invalid ratio or accuracy") for key in ("maxSpeed", "maxAccel", "maxDecel", "balanceSpeed", "searchSpeed", "secondPosSpeed"): if cfg[key] < 0: raise ValueError(f"negative {key}") if max(cfg[k] for k in ("balanceSpeed", "searchSpeed", "secondPosSpeed")) > cfg["maxSpeed"]: raise ValueError("requested speed exceeds configured maximum")
def decode(data): if len(data) != LAYOUT.size: raise ValueError("wrong size") values = LAYOUT.unpack(data) if values[0:2] != (MAGIC, VERSION): raise ValueError("unsupported magic/version") if values[-2] != 0 or crc16(data[:-2]) != values[-1]: raise ValueError("reserved byte or CRC mismatch") cfg = dict(zip(FIELDS, values[2:-2], strict=True)) validate(cfg) return cfg
def encode(cfg): validate(cfg) try: raw = LAYOUT.pack(MAGIC, VERSION, *(cfg[k] for k in FIELDS), 0, 0) except (OverflowError, struct.error) as exc: raise ValueError("value cannot be represented in V2") from exc raw = raw[:-2] + struct.pack("<H", crc16(raw[:-2])) decode(raw) # Validate the actual rounded float32 representation, too. return raw
if __name__ == "__main__": # Historical examples, NOT machine-approved defaults. example = dict(zip(FIELDS, ( 1, 38400, 500, 10000, 1.0, 3000.0, 1000.0, 1000.0, 600.0, 200.0, 1.0, 0.0, 0.0, 200.0, 10, 0, ), strict=True)) assert LAYOUT.size == 60 assert crc16(b"123456789") == 0x29B1 raw = encode(example) assert decode(raw) == example assert raw[:8] == bytes.fromhex("a3 a3 02 01 00 96 00 00")
def rejected(data): try: decode(data) except ValueError: return raise AssertionError("bad record accepted")
for bit in range(60 * 8): damaged = bytearray(raw) damaged[bit // 8] ^= 1 << (bit % 8) rejected(damaged) rejected(raw[:-1]) for offset, payload in ((2, b"\x01"), (14, struct.pack("<f", float("nan")))): damaged = bytearray(raw) damaged[offset:offset + len(payload)] = payload damaged[-2:] = struct.pack("<H", crc16(damaged[:-2])) rejected(damaged) print("V2 size, offsets, CRC, 480 single-bit faults, version and NaN checks passed")这里的值域检查只排除明显无效的数据,例如 NaN、无效传感器枚举、负转速、超出配置最大值的工作转速。波特率支持列表、加速度单位、传动比方向、允许的机械转速和报警策略仍需用已验收的项目规则约束。CRC 只能检测数据变化,不能证明参数适合机器运行。
加载、保存与掉电边界
跳转到“加载、保存与掉电边界”加载流程应为“读完整记录 → 验证长度/magic/version/CRC → 解码并验证参数 → 一次性应用到运行配置”。失败时保留明确的错误状态,使用项目批准的初始配置或进入待配置状态,不要每次失败都自动把示例默认值写回 EEPROM。
保存流程应为“取得一致的配置快照 → 参数验证 → 编码 → 分页写 → 读回验证 → 向界面报告成功”。保存失败要反馈给调用者;运行中的参数更新与持久化成功是两个状态。旧示例中 cfg.secondPosAngle = /*单位度*/; 之类占位语句不能编译,必须由实际配置模型提供数值。
原文规划 A 区 0x00~0x3B,B 区 0x40~0x7B,各放 60 字节。这个地址规划可以保留为历史方案,但上面的驱动和编解码器均没有实现双副本事务。两个有效副本也不等于知道哪个更新:例如 B 已保存新配置,A 仍是旧配置,固定“先读 A”会恢复旧值。
若项目需要掉电保护,应另行设计并验证带版本、长度、代数和提交状态的记录格式:保留最后一份有效记录,先写另一槽,读回校验后再提交;启动时按定义好的代数比较规则选择有效记录。提交标记的写入、旧标记失效、代数回绕,以及写周期中掉电导致的部分数据变化都要纳入故障模型。不能把“最后写 CRC”当作原子提交保证。增加这些字段会改变上面的 60 字节格式,必须分配新版本并重新计算两个槽的位置。
写入频率与验收
跳转到“写入频率与验收”配置没有变化就不写;连续拖动滑块时先更新 RAM,用户确认或合理合并多次操作后再持久化。实际保存周期要覆盖业务需要,不能以“每秒写一次”代替设计。手册的耐久度有测试温度、电压和写模式条件,不应由标称次数直接宣称某台机器的寿命。
本次已完成的检查包括:C 分页驱动使用主机端模拟 HAL 编译并执行,覆盖跨页拆分、末字节、越界、空指针、零长度、就绪超时和时基回绕;Python 参考格式通过 60 字节长度、关键偏移、CRC 标准测试串、480 个单比特损坏、版本拒绝和 NaN 拒绝检查。
尚需在目标 F103 工程和实物上完成:实际 HAL 版本编译;确认 A0~A2、WP、上拉、电平和速率;验证波形、全部页边界、读回与写保护;测试总线断开/占用与并发访问;确认旧格式迁移;在每一阶段切断电源验证恢复结果。当前检查没有证明双副本事务、机械参数安全范围或替代 PLC 的整体功能已经完成。