简介:面向STM32开发者的AT93C46串行EEPROM驱动资源包,围绕Atmel 93C46芯片的SPI通信与读写控制,提供可直接参考的C语言工程与编译产物。压缩包内含17个文件,包含prj工程文件、c源码文件、hex固件、lst列表及调试辅助文件等,完整覆盖从代码编写、编译到下载验证的主要环节,整体仅26KB,轻量易用。已有389人学习下载,适合正在学习SPI外设或需要快速集成93C46的嵌入式初学者与项目开发者。资源中的源码展示了SPI总线初始化、按地址写字节与读字节等典型驱动函数,同时包含工程配置和生成文件,便于对照理解工具链组织方式。结合93C46的64字节存储特性,可应用于Bootloader参数、设备ID等配置存储场景,帮助读者打通芯片手册到实际代码的实现路径。
1. 为什么一块 3 线 EEPROM 比 I2C 更容易让 STM32 项目卡壳
AT93C46 是一颗 1Kbit 的 Microwire 总线 EEPROM,很多工程师把它放进 STM32 项目里当参数存储用,结果发现它既不是 I2C 也不是 SPI:CS、SK、DI、DO 四根线,读写只能一位一位来,而且没有现成的硬件外设帮你收尾。更反直觉的是,这颗芯片的地址位数和字长由 ORG 引脚决定,ORG 接高是 64x16,接低是 128x8,同样一段驱动在两种配置下读出的数据完全不同。如果你在调试时遇到“读回来全是 0xFF”或者“写不进去”,大概率不是芯片坏了,而是指令帧长度和 ORG 状态不匹配。
从路由器 MAC 备份到变频器校准值,再到 STM32 的 Bootloader 参数区,都有它出现的位置。下面按“先看懂指令帧、再用 GPIO 模拟读写、最后做可校验备份”的顺序展开,代码按 AT93C46 的 16 位模式给出,8 位模式只需改一个宏。
2. AT93C46 的指令集与读写时序:先分清 ORG 再写驱动
2.1 存储结构:ORG 引脚决定 128 x 8 还是 64 x 16
AT93C46 的物理容量固定为 1024 位,但对外地址空间有两种组织方式。ORG 接高电平时按 16 位字访问,共 64 个地址(0x00~0x3F);ORG 接低电平时按 8 位字节访问,共 128 个地址(0x00~0x7F)。对驱动的影响直接落在指令帧地址位数上:16 位模式只要 6 位地址,8 位模式要 7 位。如果硬件 ORG=0,而驱动按 6 位发地址,实际单元会被错位访问,读出来的每个字都像被“推”了一位。
| 配置 | ORG 电平 | 地址数 | 地址位宽 | 典型用途 |
|---|---|---|---|---|
| 16 位字 | 高 | 64 | 6 位 | 参数与校验字连续存放 |
| 8 位字节 | 低 | 128 | 7 位 | 字节流日志或兼容旧设计 |
选型时为什么不用 STM32 内部 Flash 而是外挂这颗小芯片?内部 Flash 的擦写次数和掉电一致性在某些场合不够可靠,外部 EEPROM 能让参数区独立于固件区,而且它没有页缓存和扇区概念,读写单位就是字,驱动代码量很小。我的经验是原理图阶段直接把 ORG 上拉或下拉到固定电平,不要在运行时用 GPIO 切换两个模式;一颗 1Kbit 的器件不值得让固件长期维护两套地址映射。
2.2 指令码与位序:起始位之后才轮到操作码
Microwire 每个操作都以 CS 拉高开始,随后先在 DI 上发一个 1 作为起始位,再是操作码和地址。这个“1”非常容易和操作码混在一起:很多移植失败的驱动把 READ 指令直接拼成二进制 110 后加地址去移位,丢掉起始位,芯片自然不响应。部分数据手册里把起始位一并纳入指令字,所以你会看到 READ 被写成 110,实际发送顺序仍然是 1 + 10 + 6 位地址。
16 位模式下的常用指令帧如下表。
| 指令 | 帧结构(起始位 + 操作码 + 其余) | 总位宽 | 说明 |
|---|---|---|---|
| READ | 1 + 10 + 6 位地址 | 9 | 读一个 16 位字 |
| WRITE | 1 + 01 + 6 位地址 + 16 位数据 | 25 | 写一个 16 位字 |
| ERASE | 1 + 11 + 6 位地址 | 9 | 指定字复位为 0xFFFF |
| EWEN | 1 + 00 + 11 + 4 位任意 | 9 | 写使能,上电默认关闭 |
| EWDS | 1 + 00 + 00 + 4 位任意 | 9 | 禁止写入 |
| ERAL | 1 + 00 + 10 + 4 位任意 | 9 | 整片擦除 |
| WRAL | 1 + 00 + 01 + 4 位任意 + 16 位数据 | 25 | 整片写同一数据 |
所有指令都按 MSB first 移位,也就是先发高位。驱动里可以做一层通用封装,把帧位宽和帧内容交给同一个发送函数:
#define AT93C46_ORG_16BIT 1 #if AT93C46_ORG_16BIT #define AT93C46_ADDR_BITS 6 #else #define AT93C46_ADDR_BITS 7 #endif static void AT93C46_SendBits(uint16_t frame, uint8_t len) { for (uint8_t i = len; i > 0; i--) { AT93C46_SendBit((frame >> (i - 1)) & 1U); } }这里 frame 是拼好的完整指令字,len 是总位宽。以 READ 为例,16 位模式下地址上限是 64,帧可以写成AT93C46_SendBits(0x180 | addr, 9);:0x180 的 bit8 是起始位 1,bit7 和 bit6 是操作码 10,低 6 位留给地址。把地址位宽做成宏,ORG 换挡时只改一个常量,比满屏改addr >> 5安全得多。
2.3 写周期与 DO 引脚复用:轮询还是延时
写操作发完并不代表立即生效,芯片内部会进入自定时写周期,典型时间在 2ms 左右。两种处理方式:固定延时 3~5ms,或者发完帧后把 CS 拉高,通过读 DO 引脚判断状态——DO 为高表示写完成,低表示忙。第二种省时间,在启动阶段连续写十几组校准时,累计时间差异非常明显。
不过 DO 在总线空闲时是高阻态,外部最好加一个 10kΩ 上拉电阻,否则轮询时可能误判。这跟 I2C 的地址应答逻辑不同:I2C 从机拉低 SDA 表示收到应答,Microwire 没有应答机制,写失败只能读回比较。所以我的驱动里从不用“发完就延时”的裸奔写法,而是读回校验,不一致就重试。
2.4 为什么不用硬件 SPI 而是 GPIO 模拟
Microwire 与 SPI 最直观的区别是 CS 极性:SPI 的 CS 是低电平选中,Microwire 要求 CS 拉高表示操作开始。用 STM32 的硬件 SPI 可以配出相近的时钟相位,但 CS 必须用普通 GPIO 手动拉高,起始位也得软件先发,外设的 8 位字节缓冲还要处理 9 位帧的组包问题。算下来寄存器初始化、中断标志判断的代码量,往往比纯 GPIO 模拟还多。GPIO 模拟另一大优势是跨平台:同一套 C 代码,从 Keil 工程换到 VSCode 配置的 GCC 工程,或者从 STM32 换到 APM32、GD32,改动量几乎为零。
3. 在 STM32 上用 GPIO 模拟 AT93C46 读写:可直接抄的 C 驱动
3.1 引脚映射与 GPIO 初始化
用三个输出和一个输入接 AT93C46,下面以 STM32F103 的 GPIOA 为例。引脚分配只影响宏定义,不涉及驱动逻辑。
| STM32 引脚 | AT93C46 引脚 | 方向 |
|---|---|---|
| PA4 | CS | 输出 |
| PA5 | SK | 输出 |
| PA6 | DI | 输出 |
| PA7 | DO | 输入 |
#define AT93C46_CS_PIN GPIO_PIN_4 #define AT93C46_SK_PIN GPIO_PIN_5 #define AT93C46_DI_PIN GPIO_PIN_6 #define AT93C46_DO_PIN GPIO_PIN_7 #define AT93C46_PORT GPIOA static void AT93C46_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = AT93C46_CS_PIN | AT93C46_SK_PIN | AT93C46_DI_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(AT93C46_PORT, &GPIO_InitStruct); GPIO_InitStruct.Pin = AT93C46_DO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(AT93C46_PORT, &GPIO_InitStruct); HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_CS_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_SK_PIN, GPIO_PIN_RESET); }三个输出都用推挽高速度,DO 配上拉。SK 初始化为低,避免上电瞬间芯片看到异常时钟沿。SK 速度档不建议选最高速,杜邦线连接时过快的边沿会产生过冲和串扰,反而容易误采样。
3.2 逐位移位:一位一位来,别想着并行
Microwire 没有时钟极性寄存器可配置,发送时先放 DI、再产生 SK 上升沿;接收时在 SK 上升沿之后读 DO。按这个顺序做,时序就是对的。
static void AT93C46_DelayUs(uint32_t us); static void AT93C46_SendBit(uint8_t bit) { HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_DI_PIN, bit ? GPIO_PIN_SET : GPIO_PIN_RESET); AT93C46_DelayUs(1); HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_SK_PIN, GPIO_PIN_SET); AT93C46_DelayUs(1); HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_SK_PIN, GPIO_PIN_RESET); AT93C46_DelayUs(1); } static uint8_t AT93C46_RecvBit(void) { uint8_t bit; HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_SK_PIN, GPIO_PIN_SET); AT93C46_DelayUs(1); bit = (HAL_GPIO_ReadPin(AT93C46_PORT, AT93C46_DO_PIN) == GPIO_PIN_SET); HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_SK_PIN, GPIO_PIN_RESET); AT93C46_DelayUs(1); return bit; }AT93C46_DelayUs 的实现有讲究:HAL_Delay 只能到毫秒级,微秒延时可以用 DWT 的 CYCCNT,也可以用空循环。工程里如果跑了 FreeRTOS,注意延时期间不要让长临界区打断,否则时序毛刺很难查。1us 延时对 100kHz 级别的 SK 时钟已经足够,不必追求极限速率。
3.3 读一个字的完整帧
读单个字:CS 拉高,发起始位 1,发操作码 10,发 6 位地址,然后连续接收 16 个数据位。
uint16_t AT93C46_Read(uint8_t addr) { uint16_t value = 0; int i; if (addr >= (1U << AT93C46_ADDR_BITS)) return 0xFFFF; HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_CS_PIN, GPIO_PIN_SET); AT93C46_DelayUs(1); AT93C46_SendBit(1); // 起始位 AT93C46_SendBit(1); // 操作码 10 AT93C46_SendBit(0); for (i = AT93C46_ADDR_BITS - 1; i >= 0; i--) { AT93C46_SendBit((addr >> i) & 0x01); } for (i = 15; i >= 0; i--) { // 读 16 位数据 if (AT93C46_RecvBit()) { value |= (1U << i); } } HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_CS_PIN, GPIO_PIN_RESET); return value; }地址入口用 uint8_t,但只取低 6 位,越界直接返回 0xFFFF 作保护。读数据的循环从 bit15 开始拼装,确保 value 的位序和帧里的 D15 对齐。如果读回来固定是 0xFFFF,优先检查发地址时是 6 位还是 7 位,其次查 DO 引脚是否被配置成了复用功能。
3.4 写使能与写操作
AT93C46 上电后写入功能是关闭的,必须先发 EWEN,再发 WRITE 帧。
void AT93C46_WriteEnable(void) { int i; HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_CS_PIN, GPIO_PIN_SET); AT93C46_SendBit(1); AT93C46_SendBit(0); AT93C46_SendBit(0); AT93C46_SendBit(1); AT93C46_SendBit(1); for (i = 0; i < 4; i++) AT93C46_SendBit(0); HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_CS_PIN, GPIO_PIN_RESET); } uint8_t AT93C46_Write(uint8_t addr, uint16_t value) { int i; if (addr >= (1U << AT93C46_ADDR_BITS)) return 0; AT93C46_WriteEnable(); HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_CS_PIN, GPIO_PIN_SET); AT93C46_DelayUs(1); AT93C46_SendBit(1); // 起始位 AT93C46_SendBit(0); // 操作码 01 AT93C46_SendBit(1); for (i = AT93C46_ADDR_BITS - 1; i >= 0; i--) { AT93C46_SendBit((addr >> i) & 0x01); } for (i = 15; i >= 0; i--) { AT93C46_SendBit((value >> i) & 0x01); } HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_CS_PIN, GPIO_PIN_RESET); AT93C46_DelayUs(100); HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_CS_PIN, GPIO_PIN_SET); AT93C46_DelayUs(1); while (HAL_GPIO_ReadPin(AT93C46_PORT, AT93C46_DO_PIN) == GPIO_PIN_RESET) { ; // 等待写周期结束,DO 拉高表示完成 } HAL_GPIO_WritePin(AT93C46_PORT, AT93C46_CS_PIN, GPIO_PIN_RESET); return (AT93C46_Read(addr) == value) ? 1 : 0; }每次写前都发 EWEN,避免不同执行路径上遗漏使能。CS 拉低后等 100us 再进入轮询,这是给内部写周期启动留余量。轮询循环在演示代码里没加超时,量产时必须加一个 10ms 上限,否则 DO 引脚焊接异常会把整个任务卡死。
提示:AT93C46 的 DO 在写周期内被拉低,CS 拉高后若一直读不到高电平,先查 DO 上拉电阻,再查电源纹波。
3.5 移植到寄存器版 STM32 驱动
不用 HAL 的工程可以直接操作 BSRR 和 IDR,代码更短:
#define CS_LO (GPIOA->BSRR = GPIO_PIN_4) #define CS_HI (GPIOA->BRR = GPIO_PIN_4) #define DI_HI (GPIOA->BRR = GPIO_PIN_6) #define DO_READ ((GPIOA->IDR & GPIO_PIN_7) != 0)按 STM32 的惯例,BSRR 写 1 置位,BRR 写 1 复位,别和标准库的PAout(4)=1混用。寄存器版主要规避 HAL_GPIO_WritePin 的函数调用开销,在低主频 MCU 上能跑出更高的 SK 频率,但实际收益有限;更大的价值是换 APM32 或 GD32 时,用寄存器版重写一遍可以快速验证引脚定义兼容性。
4. AT93C46 驱动的 5 个关键参数与 3 类常见故障
4.1 必调的 5 个参数及调整方法
| 参数 | 建议值 | 对项目的影响 |
|---|---|---|
| tCSS | 至少 250ns | CS 拉高到第一个起始位的时间,太短首帧不识别 |
| tSKH / tSKL | 至少 250ns | SK 高/低电平宽度,决定总线速率 |
| 地址位宽 | 6(ORG=1)/ 7(ORG=0) | 错一位,整片数据错位 |
| tWP | 典型 2ms,上限约 10ms | 写周期等待,建议轮询或取 5ms 延时 |
| 写使能 | 每次写前发 EWEN | 漏发则写操作静默失败 |
拿到新批次的芯片,我会先用逻辑分析仪测 tWP 的实际分布:开启写使能后写 0x5A5A,轮询 DO 上升沿并记录时间。实测值在 1.2ms 到 1.8ms 之间时,固定延时取 3ms 是稳妥的。注意 STM32 主频改动会影响延时函数,比如从 72MHz 降到 48MHz 而延时循环常量不变,真实等待时间会明显缩短,这也是“换个工程就写不进”的常见原因。
4.2 故障 1:读回来全部是 0xFF
先查 ORG 引脚。很多开发板把 ORG 悬空,内部下拉到 8 位模式,而你在 16 位模式下读,地址和字宽全对不上。其次是 CS 时序,用示波器看 CS 上升沿到第一个起始位之间是否满足 tCSS。最后确认 DI 和 DO 没有接反,杜邦线连接时这两根线最容易互换。
4.3 故障 2:写不进或读回旧值
最常见的两个原因:没发 EWEN,或发完 EWEN 后 CS 拉低时 SK 上还有毛刺。EWEN 帧末尾 4 位任意值建议都发 0,防止个别批次芯片解析出歧义。另一个隐藏点是电源电压偏低,Microwire EEPROM 在 2.5V 以下时写周期不稳定,单独量一下芯片 VCC 引脚,别只盯着 STM32 的 3.3V 网络看。
4.4 故障 3:边界地址数据错位
0x00 正常而 0x01 读出的是 0x00 的内容,这是地址移位问题:驱动按 6 位发地址,芯片实际按 7 位解析,地址被左移了一位。用万用表量 ORG 电平,再对着宏改AT93C46_ADDR_BITS。
提示:故障排查思路不要照搬 I2C EEPROM 的习惯。I2C 的设备地址是固定值,应答失败说明器件没响应;Microwire 没有应答机制,只能读回校验。
4.5 和 I2C EEPROM 调试习惯的差异
I2C 发完地址后从机拉低 SDA 就是应答,马上能判断链路通没通。Microwire 全程没有握手,发完帧就是发完了,有没有写进去必须读回才知道。我一般调试阶段先写一个扫描函数,把全部地址读出来从串口打印,两遍结果一致再继续往下做:
void AT93C46_Dump(void) { for (uint16_t addr = 0; addr < (1U << AT93C46_ADDR_BITS); addr++) { printf("%02X:%04X ", addr, AT93C46_Read(addr)); if ((addr & 0x07) == 0x07) printf("\r\n"); } printf("\r\n"); }打印格式固定成地址:数据,一组 8 个地址换行,方便和 93C46 工具导出的 bin 文件做 diff。如果扫描结果出现“每隔两个地址跳一组数据”,基本就是地址位宽和帧长不匹配,而不是芯片损坏。
5. 用 93C46 工具把备份做成可校验的 bin 文件
调通驱动后,下一步是把整片 AT93C46 内容备份到电脑。常见做法是让 STM32 通过串口把整片数据按 16 位小端序导出成 bin,再在上位机做校验。上位机协议可以定得很简单:发单字节 0xA5,设备收到后从地址 0x00 开始连续发 128 字节,对应 64 个字乘以 2 字节,发完再追加 2 字节 CRC16。
串口工具把原始数据存成at93c46_backup.bin,然后用 Python 脚本解析和校验。脚本先按 16 位小端把每两个字节合成一个字,再以 8 个一行的格式输出文本,最后用 CRC16-XMODEM 做完整性校验:
import sys def crc16_xmodem(data: bytes) -> int: crc = 0 for byte in data: crc ^= byte << 8 for _ in range(8): if crc & 0x8000: crc = ((crc << 1) ^ 0x1021) & 0xFFFF else: crc = (crc << 1) & 0xFFFF return crc raw = open("at93c46_backup.bin", "rb").read() words = [] for i in range(0, len(raw) & ~1, 2): words.append(f"{raw[i] | (raw[i+1] << 8):04X}") with open("at93c46_dump.txt", "w") as fp: for i in range(0, len(words), 8): fp.write(f"{i // 8:02X}: " + " ".join(words[i:i+8]) + "\n") stored_crc = int(sys.argv[1], 16) computed_crc = crc16_xmodem(raw) print(f"computed: {computed_crc:04X}") print("OK" if stored_crc == computed_crc else "MISMATCH")运行方式是python check_at93c46.py 0x3C8A,第二个参数传串口收到的 CRC 原始值。文本输出文件里地址列对应 AT93C46 的字地址,每行 8 个字,和 STM32 串口打印的 Dump 格式一致,肉眼比对也很方便。
一个容易被忽略的细节是写回备份的擦除策略:WRITE 指令只能把 1 写成 0,不能把 0 写成 1。如果目标单元原本是 0x0000,而备份数据里有位从 0 恢复到 1,必须先对整片发一次 ERAL 擦除,再做逐字写回。也就是说,写回过程不是简单的“读旧值、覆盖写”,而是先整片擦除,再逐字写入,最后全片读回算一遍 CRC,确认和源 bin 一致。
本文还有配套的精品资源,点击获取