PIC16F54软件模拟Microwire驱动93LC66B EEPROM实战详解
1. 项目概述与核心价值
最近在做一个基于PIC16F54的小型控制器项目,需要存储一些校准参数和运行状态,断电后还得能保存。选来选去,最终敲定了Microchip的93LC66B这颗Microwire串行EEPROM。为什么是它?原因很简单:PIC16F54这颗8位MCU,成本控制到了极致,没有硬件I2C或SPI模块,所有通信都得靠软件模拟。而Microwire协议,以其极简的三线制(片选、时钟、数据)和清晰的时序,成为了在这种资源受限场景下的绝佳搭档。这个“接口设计与软件实现”的项目,说白了,就是如何在“一穷二白”的硬件条件下,用几根通用IO口,通过代码“掰”出标准的Microwire时序,可靠地完成对EEPROM的读写擦除。这不仅是完成一个功能,更是一次对底层时序理解和软件精确控制能力的深度锻炼,对于从事嵌入式开发,尤其是低成本MCU开发的工程师来说,是必须掌握的看家本领。
2. 核心硬件接口设计与原理剖析
2.1 器件选型与Microwire协议简析
我们选择的93LC66B是一颗1Kx16位(即2KB)的串行EEPROM。这里需要注意“16位”的组织结构,意味着它的最小寻址单元是一个16位的字(Word),而非8位的字节(Byte)。这在软件寻址时需要特别注意。Microwire协议可以工作在8位或16位模式,93LC66B通过指令码来区分。
协议线缆精简到极致,主要就三根:
- CS (Chip Select):片选信号,高电平有效。所有操作必须在CS为高时进行,CS变低标志操作结束,器件进入低功耗待机状态。
- SK (Serial Clock):串行时钟,由主控制器(MCU)产生,用于同步数据位传输。数据在SK的上升沿或下降沿被采样(具体取决于器件,93LC66B通常在上升沿采样)。
- DI/DO (Data In/Data Out):这是一根双向数据线,或者在某些封装中是分开的DI和DO。对于PIC16F54,我们通常用一根IO口模拟这根双向线,通过控制IO方向寄存器来实现输入输出的切换。
协议通信的基本单元是指令。一次完整的操作始于CS拉高,接着主控发送一个起始位(1),然后是操作码(Opcode,2位),最后是地址码(Address,对于93LC66B是9位或10位,取决于组织模式)。对于写操作,之后紧跟着就是数据(16位或8位)。所有位都是在SK时钟的驱动下,逐位发送或接收的。
2.2 PIC16F54与93LC66B的硬件连接
PIC16F54的IO口资源有限,我们需要精心分配。假设我们使用以下连接方式:
RB0配置为输出,连接至93LC66B的CS引脚。RB1配置为输出,连接至93LC66B的SK引脚。RB2配置为双向,连接至93LC66B的DI/DO引脚。在软件中,我们需要动态改变TRISB2的方向(输出用于发送,输入用于接收)。
此外,93LC66B的ORG引脚决定了存储器组织模式。接VCC(高电平)为16位模式,接GND(低电平)为8位模式。根据我们的需求(存储16位的数据,如ADC校准值)选择将其接高电平,即16位模式。VCC和GND的连接、电源去耦电容(通常一个0.1uF陶瓷电容靠近芯片电源引脚)是必须的,这决定了通信的稳定性。
注意:对于双向数据线
RB2,务必在MCU端考虑是否增加一个上拉电阻。虽然93LC66B的DO输出在有效时能驱动高低电平,但在空闲或高阻态时,明确的上拉可以保证线路处于确定状态,避免因干扰产生意外电流。通常4.7kΩ到10kΩ即可。
2.3 时序参数的计算与软件延时保证
软件模拟协议的核心在于满足器件数据手册(Datasheet)规定的最小时序参数。对于93LC66B,几个关键参数如下(数值为典型值,具体需查您所用型号的手册):
tCS:CS建立时间(CS有效到第一个SK脉冲),最小25ns。tSU:数据建立时间(数据有效到SK上升沿),最小100ns。tHD:数据保持时间(SK上升沿后数据保持),最小25ns。tSKH/tSKL:SK高/低电平时间,最小250ns。tCSS:CS保持时间(最后一个SK脉冲到CS无效),最小250ns。
PIC16F54在4MHz晶振下,指令周期为1μs。这意味着即使是最简单的NOP指令(空操作)也持续1μs。对比上述纳秒级的时序要求,用软件循环产生延时是绰绰有余的,甚至需要“减速”以满足最小时间要求。我们的策略是:在每次操作IO(如翻转SK、改变数据)后,插入足够数量的NOP指令或一个简短的延时循环,以确保tSKH、tSKL、tSU、tHD等参数远大于最小值,从而建立稳定的时序窗口。
例如,产生一个SK时钟脉冲的代码可能如下:
void PulseSK(void) { SK_PIN = 1; // SK拉高 NOP(); NOP(); NOP(); NOP(); // 延时,保证tSKH SK_PIN = 0; // SK拉低 NOP(); NOP(); NOP(); NOP(); // 延时,保证tSKL }这里的多个NOP就是为了确保高电平和低电平时间都远大于250ns。
3. 软件驱动层实现详解
3.1 底层比特读写函数
一切高层操作都建立在可靠的位读写之上。我们需要实现四个最基础的函数:
void SendBit(uint8_t bit):向EEPROM发送一位数据。void SendBit(uint8_t bit) { if(bit) { DATA_PIN = 1; // 数据线置高,代表‘1’ } else { DATA_PIN = 0; // 数据线置低,代表‘0’ } NOP(); // 短暂延时,保证数据稳定(满足tSU) PulseSK(); // 产生时钟上升沿,EEPROM在此刻采样数据 // 注意:数据需在SK上升沿后继续保持一段时间(tHD),我们的延时已涵盖 }这里的关键是先设置好数据,再产生时钟边沿。
uint8_t ReadBit(void):从EEPROM读取一位数据。uint8_t ReadBit(void) { uint8_t bit_val; PulseSK(); // 先产生一个时钟脉冲 // 在SK的下降沿后,EEPROM会输出下一位数据到DO线上 // 我们需要在SK为低期间,且数据稳定后读取 NOP(); NOP(); // 小延时,等待数据有效 bit_val = DATA_PIN; // 读取数据线状态 return bit_val; }对于93LC66B,数据通常在SK的下降沿后变得有效。读取的关键是在恰当的延时后采样数据线。
void SendByte(uint8_t data)和void SendWord(uint16_t data):调用SendBit,从最高位(MSB)开始,依次发送8位或16位数据。uint16_t ReadWord(void):调用ReadBit16次,从最高位开始拼接成一个16位数据。
实操心得:在编写
ReadBit时,最初我犯了一个错误:在PulseSK()之前就将数据线配置为输入。结果发现读取不稳定。后来意识到,在发送指令和地址阶段,数据线是输出模式;只有在发送完“读”指令和地址后,才需要切换为输入模式来接收数据。模式切换的时机至关重要,必须在CS为高、一次完整操作序列的中间进行。一个稳健的做法是,在发送“读数据”指令码的最后一位之前,数据线保持输出;发送完该位并产生时钟后,立即将IO口方向改为输入,然后才开始ReadBit。
3.2 指令集封装与高层操作函数
93LC66B的指令集很简单,核心指令如下(以16位模式为例):
EWEN(Erase/Write Enable):0011XXXXXX。必须先发送此指令,才能使能擦写操作。ERASE:11A9-A0。擦除指定地址的一个字(全部置为1)。WRITE:10A9-A0D15-D0。向指定地址写入一个字。READ:110A9-A0。从指定地址读出一个字。ERAL(Erase All):0010XXXXXX。擦除整个芯片(慎用!)。WRAL(Write All):0001XXXXXXD15-D0。向所有地址写入相同数据(慎用!)。EWDS(Erase/Write Disable):0000XXXXXX。禁用擦写,进入写保护状态。建议在不需要写操作时执行,提高可靠性。
基于底层比特函数,我们可以封装发送指令的函数:
void EEPROM_SendCommand(uint8_t opcode, uint16_t address, uint16_t data, uint8_t is_write) { CS_PIN = 1; // 启动传输 Delay_us(10); // 等待tCS,确保CS建立时间 SendBit(1); // 起始位‘1’ // 发送操作码 (2位) SendBit((opcode >> 1) & 0x01); SendBit(opcode & 0x01); // 发送地址 (9位,因为1K x 16,地址线A9-A0,但最高位A9由指令隐含?需仔细看手册) // 对于93LC66B,在16位模式下,地址是9位(A8-A0)。READ指令是“110”+A8-A0。 // 这里需要根据具体指令格式处理地址发送位数。 for(int8_t i=8; i>=0; i--) { // 发送9位地址,MSB first SendBit((address >> i) & 0x01); } // 如果是写或WRAL指令,接着发送16位数据 if(is_write) { SendWord(data); } // 如果是读指令,此时需要切换数据线方向为输入,然后读取数据 // ... 具体实现见下文 CS_PIN = 0; // 结束传输 Delay_us(10); // 等待tCSS,确保CS保持时间 }注意,上述函数是一个逻辑示意,实际需要根据READ、WRITE等不同指令细化。
更上层,我们封装出应用层直接调用的函数:
void EEPROM_WriteEnable(void)void EEPROM_WriteDisable(void)uint16_t EEPROM_ReadWord(uint16_t addr)void EEPROM_WriteWord(uint16_t addr, uint16_t data)void EEPROM_EraseWord(uint16_t addr)
在EEPROM_WriteWord函数中,必须遵循严格的流程:
- 发送
EWEN指令。 - 发送
WRITE指令(包含地址和数据)。 - 等待写周期完成。这是最容易出错的地方。93LC66B在接收到写/擦除指令后,内部会启动一个自定时写周期(典型值3ms),在此期间它不会响应任何指令。我们必须通过“轮询”或延时来等待。
- 轮询法(推荐):在发送
WRITE指令并拉低CS结束操作后,稍作延时(如几百微秒),然后重新拉高CS,发送“读”指令的起始位‘1’和操作码‘110’。如果芯片忙,DO线会保持低电平;如果写完成,DO线会输出高电平(即数据的最高位)。我们可以检测这个位来判断。
void EEPROM_WaitReady(void) { uint8_t busy; CS_PIN = 1; SendBit(1); // 起始位 SendBit(1); // 读指令码‘110’的前两位 SendBit(0); // 此时芯片如果忙,DO输出0;就绪,DO输出1(数据MSB) DATA_TRIS = 1; // 切换为输入 PulseSK(); // 产生一个时钟来读取这个状态位 busy = (ReadBit() == 0); // 如果读到0,表示忙 CS_PIN = 0; while(busy) { Delay_ms(1); // 短暂延时后再试 // ... 重新发起一轮检测 } }- 延时法:简单粗暴地延时一个远大于
tWC(写周期时间,如5ms)的时间。虽然简单,但效率低,且无法应对极端情况(如芯片异常)。
- 轮询法(推荐):在发送
- 发送
EWDS指令(可选,但建议加上以提高安全性)。
4. 驱动代码的优化与调试技巧
4.1 代码优化与可移植性考虑
对于PIC16F54这种资源紧张的MCU,代码效率和尺寸很重要。
- 函数内联:对于
SendBit、PulseSK这种被频繁调用的小函数,可以考虑使用宏定义来代替,以节省函数调用和返回的开销。#define SEND_BIT(bit) do { \ DATA_PIN = (bit); \ NOP(); NOP(); \ SK_PIN = 1; NOP(); NOP(); NOP(); NOP(); \ SK_PIN = 0; NOP(); NOP(); \ } while(0) - 端口操作抽象:将
CS_PIN、SK_PIN、DATA_PIN、DATA_TRIS等具体端口和位定义成宏或变量,这样当硬件连接改变时,只需修改这些宏定义,而不必翻遍所有代码。 - 条件编译:通过宏定义来区分8位/16位模式、不同的时钟速度,使代码易于适配不同项目。
4.2. 调试与问题排查实录
软件模拟通信的调试,逻辑分析仪是神器。没有的话,示波器也能勉强应对。以下是我在调试中遇到的几个典型问题及解决方法:
问题:读写数据全错或完全无响应。
- 排查思路:
- 检查硬件连接:这是第一步也是最常见的一步。用万用表确认VCC、GND、CS、SK、DI/DO线连接正确且无虚焊。确认
ORG引脚电平是否符合预期模式。 - 检查电源与去耦:用示波器测量EEPROM的VCC引脚,看是否有明显的毛刺或跌落。确保0.1uF去耦电容紧靠芯片电源引脚。
- 用示波器看时序:抓取CS、SK、DI/DO三路信号。重点看:
- CS拉高后,是否在第一个SK脉冲前有足够长的建立时间(
tCS)? - SK的周期和高低电平时间是否满足最小值(
tSKH,tSKL)? - 数据线(DI)在SK上升沿前是否稳定(
tSU)?上升沿后是否保持(tHD)?
- CS拉高后,是否在第一个SK脉冲前有足够长的建立时间(
- 核对指令格式:对照数据手册,用示波器解码出实际发送的比特流,看起始位‘1’、操作码、地址是否正确。一个常见的错误是地址位数发送不对,或者在8/16位模式理解上有偏差。
- 检查硬件连接:这是第一步也是最常见的一步。用万用表确认VCC、GND、CS、SK、DI/DO线连接正确且无虚焊。确认
- 排查思路:
问题:可以读,但写不进去。
- 排查思路:
- 检查写使能(EWEN):是否在每次写操作前都正确发送了
EWEN指令?有些设计是上电后发送一次EWEN,然后一直保持使能状态直到发EWDS。但更稳妥的做法是每次写之前都发EWEN。 - 检查写保护状态:93LC66B是否有硬件写保护引脚(如
WC)?检查它是否被意外拉高。 - 检查写等待:写操作后是否等待了足够的时间?是否使用了轮询法正确检测了写完成?可以用示波器长时间观察CS线,在写指令后,CS拉低,然后应有一段空白(芯片忙),之后才能进行下一次操作。如果连续操作间隔太短,后一条指令会被忽略。
- 擦除后再写:Microwire EEPROM的写操作通常要求目标地址是已擦除状态(全为1)。所以标准的流程是
ERASE->等待完成->WRITE->等待完成。你的写函数是否包含了擦除步骤?或者你写入的数据恰好是0xFFFF?
- 检查写使能(EWEN):是否在每次写操作前都正确发送了
- 排查思路:
问题:数据偶尔出错,有比特位翻转。
- 排查思路:
- 检查时钟干扰:SK时钟线上是否有过冲、振铃?长距离连接时,考虑在MCU输出端串接一个几十欧姆的小电阻。
- 检查数据线方向切换:在从发送切换到接收的瞬间,如果时序没配合好,可能出现总线冲突。确保在切换
DATA_TRIS方向前,最后一个发送时钟的边沿已过去,且有一个小的空闲时间。 - 降低通信速度:如果之前为了追求速度把SK时钟周期压得很短,可以尝试在
PulseSK函数里增加NOP,降低时钟频率,看问题是否消失。这有助于排除时序余量不足的问题。 - 检查电源噪声:在MCU和EEPROM动作时,观察电源纹波。如果系统中有电机、继电器等大电流负载,可能会引起电源扰动,导致逻辑错误。加强电源滤波,或考虑在通信关键阶段暂时关闭干扰源。
- 排查思路:
4.3 编写健壮的应用程序
驱动写好后,在应用层使用时也要注意:
- 上电延时:MCU上电后,应延时几十毫秒再初始化EEPROM,给芯片一个稳定的准备时间。
- 写操作频率限制:EEPROM有擦写寿命(如93LC66B为100万次)。避免在循环中频繁写入同一地址。对于需要频繁更新的数据,可以采用“磨损均衡”策略,轮流写入不同地址。
- 数据校验:重要的数据写入后,应立即读回进行校验。也可以采用存储“校验和”(如CRC8)的方式来确保数据块的完整性。
- 错误处理:驱动程序应具备基本的错误处理能力,比如写操作后校验失败,应能重试或上报错误。
5. 项目总结与扩展思考
通过这个项目,我们成功地在没有硬件串行外设的PIC16F54上,通过软件精准模拟Microwire时序,驱动了93LC66B EEPROM。整个过程就像在微秒的时间尺度上编排一场精细的舞蹈,每一个信号的边沿、每一次电平的切换、每一次IO方向的改变,都必须严格遵循数据手册的乐章。
我个人在实际操作中体会最深的一点是:数据手册是你的圣经,示波器/逻辑分析仪是你的眼睛。无论理论多么清晰,最终都要落实到示波器上那几条跳动的波形。当看到严格按照时序要求产生的完美波形,并且EEPROM正确响应时,那种成就感是巨大的。另一个深刻的教训是关于写等待。早期我采用固定延时,大部分时间工作正常,直到有一次在低温环境下出现了数据错误。改为轮询状态位后,问题彻底解决。这让我明白,鲁棒性往往来自于对器件行为的细致观察和遵循其设计机制,而非一厢情愿的假设。
这个基础的驱动框架可以很容易地移植到其他支持Microwire或类似三线制协议的存储器或传感器上。例如,一些实时时钟芯片(RTC)、数字电位器也采用类似的接口。掌握了这种软件模拟串行通信的能力,就等于解锁了一大类低成本、低引脚数外设的使用方法,这在资源受限的嵌入式开发中是一项极其宝贵的技能。
