news 2026/9/12 9:52:21

STM32驱动AT93C46 EEPROM:GPIO模拟Microwire总线实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32驱动AT93C46 EEPROM:GPIO模拟Microwire总线实战

简介:面向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 位字646 位参数与校验字连续存放
8 位字节1287 位字节流日志或兼容旧设计

选型时为什么不用 STM32 内部 Flash 而是外挂这颗小芯片?内部 Flash 的擦写次数和掉电一致性在某些场合不够可靠,外部 EEPROM 能让参数区独立于固件区,而且它没有页缓存和扇区概念,读写单位就是字,驱动代码量很小。我的经验是原理图阶段直接把 ORG 上拉或下拉到固定电平,不要在运行时用 GPIO 切换两个模式;一颗 1Kbit 的器件不值得让固件长期维护两套地址映射。

2.2 指令码与位序:起始位之后才轮到操作码

Microwire 每个操作都以 CS 拉高开始,随后先在 DI 上发一个 1 作为起始位,再是操作码和地址。这个“1”非常容易和操作码混在一起:很多移植失败的驱动把 READ 指令直接拼成二进制 110 后加地址去移位,丢掉起始位,芯片自然不响应。部分数据手册里把起始位一并纳入指令字,所以你会看到 READ 被写成 110,实际发送顺序仍然是 1 + 10 + 6 位地址。

16 位模式下的常用指令帧如下表。

指令帧结构(起始位 + 操作码 + 其余)总位宽说明
READ1 + 10 + 6 位地址9读一个 16 位字
WRITE1 + 01 + 6 位地址 + 16 位数据25写一个 16 位字
ERASE1 + 11 + 6 位地址9指定字复位为 0xFFFF
EWEN1 + 00 + 11 + 4 位任意9写使能,上电默认关闭
EWDS1 + 00 + 00 + 4 位任意9禁止写入
ERAL1 + 00 + 10 + 4 位任意9整片擦除
WRAL1 + 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 引脚方向
PA4CS输出
PA5SK输出
PA6DI输出
PA7DO输入
#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至少 250nsCS 拉高到第一个起始位的时间,太短首帧不识别
tSKH / tSKL至少 250nsSK 高/低电平宽度,决定总线速率
地址位宽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 一致。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 9:51:50

DeepSeek宕机12小时?多模型协同与故障自救实战指南

说实话&#xff0c;作为一个重度依赖 API 干活的开发者&#xff0c;那段时间我是被一串报错叫醒的。当时我在后台跑一个批量文档摘要任务&#xff0c;300 多份材料处理到第 214 份&#xff0c;日志里突然开始连续出现超时和 504。我第一反应是脚本写崩了&#xff0c;排查了半天…

作者头像 李华
网站建设 2026/9/12 9:51:13

智慧水利数字孪生解决方案:从平台建设到项目实践

随着新一代信息技术与治水实践深度融合&#xff0c;数字孪生水利正从概念走向落地&#xff0c;成为提升水安全保障能力的重要抓手。从流域防洪到水资源调配&#xff0c;从工程管理到智能大坝建设&#xff0c;以时空数据为底座、数学模型为核心、水利知识为驱动的数字孪生体系&a…

作者头像 李华
网站建设 2026/9/12 9:50:48

MetaERP 从“Oracle EBS 单库单事务”切到“多微服务 + 独立 Schema + 事件总线”后,跨服务不可能再靠一个 COMMIT 保证强一致。它的做法是:单服务内用本地 ACID;

MetaERP 从“Oracle EBS 单库单事务”切到“多微服务 独立 Schema 事件总线”后&#xff0c;跨服务不可能再靠一个 COMMIT 保证强一致。它的做法是&#xff1a;单服务内用本地 ACID&#xff1b;跨服务用事件驱动 本地消息表/事务消息 Saga 补偿 幂等消费 关账对账&#x…

作者头像 李华
网站建设 2026/9/12 9:45:55

Matlab风储联合调峰模型构建与CPLEX求解实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华