ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

无硬件SPI也能读写EEPROM:25LC256模拟SPI驱动封装与移植

无硬件SPI也能读写EEPROM:25LC256模拟SPI驱动封装与移植

简介:这份资源是面向嵌入式开发初学者与电子工程师的EEPROM 25LC256读写源码包,采用C++实现,重点演示通过模拟SPI方式完成对25LC256的完整读写控制。内容覆盖SPI接口初始化、片选与释放、命令与地址传输、读写使能、数据缓冲及错误处理等关键环节,适合用于保存配置参数、设备固件或用户数据等非易失存储场景,也可作为其他SPI设备驱动的学习模板。压缩包共20个文件,约38KB,包含c与h源码文件、obj与lst编译中间文件、hex烧录文件、uv2工程文件及a51启动代码等,结构清晰,便于直接导入Keil等环境编译调试。目前已有1259人学习下载,读者可借此掌握SPI通信时序与25LC256操作流程,理解寄存器配置、事务管理与兼容性适配思路,为嵌入式存储开发打下实践基础。

1. 一块 25LC256 的读写折腾:从裸机时序到可复用 C++ 封装

去年调一块工业采集板,主控的硬件 SPI 引脚被占满了,只剩几个普通 GPIO。板子上挂着一颗 25LC256,容量 32KB,走的是标准 SPI 协议。当时第一反应是找个现成库,翻了一圈发现要么绑死某家芯片的 HAL,要么把 CS、SCK、MOSI 全写死成宏,换块板子就得重写。后来干脆自己撸了一份 C++ 源码,用普通 IO 模拟 SPI 时序,把 25LC256 的读写、擦除、状态轮询全封进去。这份源码解决的就是「没有硬件 SPI 也能读写 EEPROM」这个具体问题,适合做嵌入式、写裸机驱动、或者需要在资源受限 MCU 上存配置参数的开发者。它不依赖任何 RTOS,纯 C++ 实现,移植时只要改几个 GPIO 操作函数就能跑起来。

2. 项目定位与技术选型:为什么用模拟 SPI 而不是硬件外设

2.1 25LC256 的通信特性决定了实现方式

25LC256 是 Microchip 的 256Kbit SPI EEPROM,换算下来 32KB 存储空间,页大小 64 字节,支持 10MHz 时钟。它的指令集很精简:读状态寄存器 RDSR、写使能 WREN、读数据 READ、写数据 WRITE、写状态寄存器 WRSR、擦除整个芯片等。每次写操作前必须先发 WREN,写完要轮询 RDSR 的 WIP 位,等芯片内部擦写完成才能发下一条指令。这些时序要求不复杂,但顺序错了就写不进去,而且不会报错,只会静默失败。

硬件 SPI 外设当然能驱动它,但很多低成本 MCU 的 SPI 引脚是复用的,一旦被 LCD 或 Flash 占用,剩下的 GPIO 就只能靠软件翻转。模拟 SPI 的好处是引脚随便选,坏处是速率上不去,通常只能跑到 1MHz 左右。对于存配置参数、日志这类低频写入场景,完全够用。

2.2 源码的整体架构

这份源码分成三层。最底层是SoftSpi类,只负责 SCK、MOSI、MISO 三个引脚的电平翻转和采样,不关心对面挂的是什么芯片。中间层是Eeprom25lc256类,实现 25LC256 的指令集,包括写使能、读状态、页写、字节写、连续读。最上层是应用接口,提供readBuffer、writeBuffer、eraseChip这些直接可调的函数。

目录结构大致是这样:

eeprom25lc256/ ├── soft_spi.h // 模拟 SPI 底层时序 ├── soft_spi.cpp ├── eeprom25lc256.h // 芯片指令封装 ├── eeprom25lc256.cpp ├── gpio_interface.h // GPIO 操作抽象,移植时改这里 └── main.cpp // 示例:写一段数据再读回来校验

gpio_interface.h是关键,它把pinWrite、pinRead、delayUs声明成纯虚函数或者函数指针,移植到 STM32、ESP32、AVR 都只需要实现这几个函数。我一般会在移植时先把这个文件改好,再编译,能省掉一大半调试时间。

2.3 模拟 SPI 的时序参数怎么定

25LC256 的 SPI 模式是 0,0,也就是空闲时 SCK 低电平,数据在 SCK 上升沿采样。模拟 SPI 的核心就是控制 SCK 的翻转节奏。源码里用一个spiDelay变量控制半周期延时,单位是微秒。比如主频 72MHz 的 MCU,一个delayUs(1)大概对应几十个时钟周期,实际 SCK 频率在 500kHz 左右。

// soft_spi.h class SoftSpi { public: void init(); uint8_t transfer(uint8_t data); // 全双工,发一个字节收一个字节 void csLow(); void csHigh(); private: void sckHigh(); void sckLow(); void mosiWrite(uint8_t bit); uint8_t misoRead(); uint32_t spiDelay = 1; // 半周期延时,单位 us };

transfer是核心函数,循环 8 次,每次先设置 MOSI,再拉高 SCK,然后读 MISO,最后拉低 SCK。顺序不能乱,因为 25LC256 在 SCK 上升沿采样 MOSI,在下降沿输出 MISO。如果先拉高 SCK 再设 MOSI,第一位数据就丢了。

uint8_t SoftSpi::transfer(uint8_t data) { uint8_t recv = 0; for (int i = 7; i >= 0; i--) { mosiWrite((data >> i) & 0x01); // 先准备数据 sckHigh(); // 上升沿,从机采样 delayUs(spiDelay); recv |= (misoRead() << i); // 读从机输出 sckLow(); // 下降沿 delayUs(spiDelay); } return recv; }

参数spiDelay调大能提高稳定性,但会降低吞吐。我一般先用 2us 跑通,再逐步降到 1us 甚至 0.5us,看什么时候开始出错,留一点余量。

3. 核心模块拆解:25LC256 指令封装与读写流程

3.1 写使能 WREN 与状态轮询 RDSR

25LC256 上电后默认是写保护状态,任何写操作前必须先发 WREN 指令,把状态寄存器的 WEL 位置 1。WREN 是单字节指令,发完拉高 CS 就生效。但注意,WREN 之后如果发了别的指令,WEL 位会自动清零,所以每次写之前都要重新发。

void Eeprom25lc256::writeEnable() { spi.csLow(); spi.transfer(CMD_WREN); // 0x06 spi.csHigh(); } void Eeprom25lc256::waitWriteComplete() { spi.csLow(); spi.transfer(CMD_RDSR); // 0x05 while (spi.transfer(0xFF) & 0x01) { // WIP 位为 1 表示芯片正在擦写,继续等 delayUs(100); } spi.csHigh(); }

waitWriteComplete里的spi.transfer(0xFF)是发一个哑字节,目的是产生时钟让从机把状态寄存器移出来。很多新手会忘了这一步,直接读 MISO 引脚,结果读到的永远是上一次的残留值。轮询间隔我设 100us,实际 25LC256 的页写周期最大 5ms,轮询太密会占 CPU,太疏会拖慢整体写入。

3.2 页写与字节写的区别

25LC256 的写操作分两种:字节写和页写。字节写一次写一个地址,页写一次最多写 64 字节,但要求这 64 字节不能跨页边界。比如从地址 0x3F 开始写 10 个字节,写到 0x40 就会回卷到 0x00,把前面的数据覆盖掉。这是血泪教训,我第一次调的时候没注意页边界,写进去的数据全乱了。

bool Eeprom25lc256::writePage(uint16_t addr, const uint8_t* data, uint16_t len) { if (len == 0 || len > PAGE_SIZE) return false; // 检查是否跨页 uint16_t pageStart = addr & ~(PAGE_SIZE - 1); if (addr + len > pageStart + PAGE_SIZE) return false; writeEnable(); spi.csLow(); spi.transfer(CMD_WRITE); // 0x02 spi.transfer((addr >> 8) & 0xFF); // 地址高字节 spi.transfer(addr & 0xFF); // 地址低字节 for (uint16_t i = 0; i < len; i++) { spi.transfer(data[i]); } spi.csHigh(); waitWriteComplete(); return true; }

PAGE_SIZE定义为 64,addr & ~(PAGE_SIZE - 1)是取页起始地址。如果调用者传的地址和长度跨页,函数直接返回 false,让上层去拆分。我一般会在writeBuffer里自动按页拆分,这样应用层就不用关心边界了。

3.3 连续读与地址自动递增

读操作比写简单,发完 READ 指令和 16 位地址后,芯片会从当前地址开始输出数据,每读一个字节地址自动加一,直到 CS 拉高。所以读整个 32KB 只需要一次 CS 拉低。

void Eeprom25lc256::readBuffer(uint16_t addr, uint8_t* buf, uint16_t len) { spi.csLow(); spi.transfer(CMD_READ); // 0x03 spi.transfer((addr >> 8) & 0xFF); spi.transfer(addr & 0xFF); for (uint16_t i = 0; i < len; i++) { buf[i] = spi.transfer(0xFF); // 哑字节产生时钟 } spi.csHigh(); }

注意spi.transfer(0xFF)的返回值才是有效数据,参数 0xFF 只是为了让 MOSI 保持高电平,对读操作没影响。如果传 0x00 也能读,但有些 SPI 从机对 MOSI 有要求,统一用 0xFF 比较稳妥。

3.4 状态寄存器与写保护

25LC256 的状态寄存器里除了 WIP 和 WEL,还有 BP0、BP1 和 WPEN 位,用来做块保护。比如把 BP0 和 BP1 都设成 1,整个芯片就只读了。这个功能在存校准参数时有用,防止程序跑飞误写。源码里提供了setBlockProtection函数,通过 WRSR 指令写状态寄存器。

void Eeprom25lc256::setBlockProtection(uint8_t bp) { writeEnable(); spi.csLow(); spi.transfer(CMD_WRSR); // 0x01 spi.transfer((bp & 0x03) << 2); // BP1 BP0 在 bit3 bit2 spi.csHigh(); waitWriteComplete(); }

参数bp取值 0 到 3,对应保护范围从无到全片。写完之后要重新上电或者发 WREN 才能解除,这个在调试时容易翻车,以为代码写错了,其实是保护位没清。

4. 避坑与常见问题排查

4.1 读出来全是 0xFF 或 0x00

现象:调用readBuffer读任何地址都返回 0xFF 或 0x00,写进去的数据读不回来。

原因:0xFF 通常是 MISO 一直高电平,说明从机没响应,可能是 CS 没拉低、SCK 没翻转、或者芯片没供电。0x00 则是 MISO 一直低,可能是 MOSI 和 MISO 接反了,或者芯片处于写保护状态。

解决:先用示波器看 CS、SCK、MOSI 三根线有没有波形。如果没有示波器,就在transfer里加翻转 GPIO 的代码,用 LED 看 SCK 有没有动。确认波形后再查芯片的 HOLD 和 WP 引脚,这两个脚如果悬空,芯片可能不工作,一般要上拉到 VCC。

4.2 写入后数据错位

现象:写 10 个字节,读回来发现从第 7 个字节开始变成别的数据。

原因:跨页了。25LC256 页写不能跨 64 字节边界,跨了就会回卷覆盖。

解决:在writeBuffer里按页拆分,每页单独调用writePage。拆分逻辑是:先算当前地址到页尾还有多少字节,取剩余长度和页尾空间的最小值,写完再算下一页。

bool Eeprom25lc256::writeBuffer(uint16_t addr, const uint8_t* data, uint16_t len) { while (len > 0) { uint16_t pageRemain = PAGE_SIZE - (addr % PAGE_SIZE); uint16_t chunk = (len < pageRemain) ? len : pageRemain; if (!writePage(addr, data, chunk)) return false; addr += chunk; data += chunk; len -= chunk; } return true; }

4.3 写操作偶尔失败

现象:大部分时候能写进去,偶尔读回来是旧数据。

原因:waitWriteComplete轮询不够,或者 WREN 没发成功。25LC256 的页写周期最大 5ms,如果轮询间隔太短,可能在 WIP 还没置位时就退出了。

解决:在waitWriteComplete里加超时机制,比如最多等 10ms,超时就返回错误。另外每次写之前都重新发 WREN,不要依赖上一次的 WEL 位。

4.4 模拟 SPI 速率上不去

现象:spiDelay降到 0 还是只能跑到几百 kHz。

原因:GPIO 翻转本身有开销,digitalWrite这类函数在有些平台很慢,一次调用可能几微秒。

解决:直接用寄存器操作替代库函数。比如 STM32 上用 BSRR 寄存器,AVR 上用 PORT 寄存器。源码里gpio_interface.h的pinWrite就是留给移植者优化的入口,我一般会把它改成宏或者内联函数。

4.5 多字节写入后芯片发热

现象:连续写大量数据时芯片明显发热。

原因:页写频率太高,芯片内部电荷泵一直在工作。

解决:每页写完后加 1ms 延时,或者降低写入频率。25LC256 的擦写寿命是 100 万次,但那是按页算的,频繁整片擦写会缩短寿命。存日志这类场景建议用环形缓冲,不要每次都擦整片。

5. 二次开发与移植技巧:从 STM32 到 ESP32 的改造路径

5.1 移植时先改 gpio_interface

这份源码的移植成本很低,核心就是把gpio_interface.h里的几个函数实现掉。以 STM32 为例,用 HAL 库的话:

// gpio_interface.h 的 STM32 实现 extern "C" void pinWrite(GPIO_TypeDef* port, uint16_t pin, uint8_t val) { HAL_GPIO_WritePin(port, pin, val ? GPIO_PIN_SET : GPIO_PIN_RESET); } extern "C" uint8_t pinRead(GPIO_TypeDef* port, uint16_t pin) { return HAL_GPIO_ReadPin(port, pin) == GPIO_PIN_SET ? 1 : 0; } extern "C" void delayUs(uint32_t us) { // 用 DWT 或者 TIM 实现微秒延时 uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }

ESP32 的话,pinWrite换成gpio_set_level,delayUs用esp_rom_delay_us。注意 ESP32 的 GPIO 有输入输出切换开销,如果 MISO 和 MOSI 是同一根线(三线 SPI),需要在transfer里切换方向,这份源码默认是四线,三线需要自己改。

5.2 加一层缓存减少写入次数

EEPROM 的擦写寿命有限,如果应用层频繁写同一个地址,比如每秒存一次传感器数据,芯片很快就废了。我一般会在Eeprom25lc256上面再加一个CachedEeprom类,维护一个 64 字节的 RAM 缓存,只有缓存满了或者主动调用flush时才写回芯片。

class CachedEeprom { public: void writeByte(uint16_t addr, uint8_t val) { uint16_t page = addr / PAGE_SIZE; if (page != currentPage) { flush(); currentPage = page; readPage(page * PAGE_SIZE, cache); } cache[addr % PAGE_SIZE] = val; dirty = true; } void flush() { if (dirty) { eeprom.writePage(currentPage * PAGE_SIZE, cache, PAGE_SIZE); dirty = false; } } private: Eeprom25lc256 eeprom; uint8_t cache[PAGE_SIZE]; uint16_t currentPage = 0xFFFF; bool dirty = false; };

这样写同一个页里的数据,不管写多少次,实际只擦写一次。代价是掉电时可能丢最后没 flush 的数据,所以关键参数写完要立刻 flush。

5.3 用校验和判断数据有效性

EEPROM 上电时读出来的数据可能是乱的,尤其是第一次使用或者被擦除过。我习惯在存储结构里加一个 2 字节的 CRC16 或者简单的累加和,读的时候先校验,不通过就恢复默认值。

struct Config { uint16_t magic; // 0x55AA uint8_t params[60]; uint16_t checksum; // 前面所有字节的累加和 }; bool loadConfig(Config& cfg) { eeprom.readBuffer(0, (uint8_t*)&cfg, sizeof(cfg)); if (cfg.magic != 0x55AA) return false; uint16_t sum = 0; uint8_t* p = (uint8_t*)&cfg; for (int i = 0; i < sizeof(cfg) - 2; i++) sum += p[i]; return sum == cfg.checksum; }

magic用来判断是不是第一次上电,checksum用来判断数据有没有被破坏。这两个字段加起来 4 个字节,对于 32KB 的空间来说可以忽略。

5.4 调试时先用 RAM 模拟

在没有硬件或者硬件还没焊好的时候,可以把Eeprom25lc256替换成一个 RAM 数组的模拟实现,接口完全一样。这样应用层代码不用改,等硬件到了再换回真实驱动。我一般会在eeprom25lc256.h里加一个编译开关:

#ifdef EEPROM_SIMULATE class Eeprom25lc256 { uint8_t ram[32768]; public: void readBuffer(uint16_t addr, uint8_t* buf, uint16_t len) { memcpy(buf, &ram[addr], len); } // ... 其他函数类似 }; #endif

这个技巧在单元测试里特别有用,可以模拟写入失败、数据损坏这些异常情况,不用真的去拔芯片。

从那以后我每次移植 SPI 驱动,都强制先用 RAM 模拟跑一遍逻辑,再上真实硬件。这样能把时序问题和逻辑问题分开,省掉很多来回插拔杜邦线的功夫。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进