☰
C++实现25LC256 EEPROM的GPIO模拟SPI驱动设计与实践
2026/10/16 7:29:42 网站建设 项目流程

简介:这份C++源码围绕25LC256 EEPROM 的SPI模拟读写展开,面向嵌入式开发者和电子工程学习者,可用于理解SPI通信协议与非易失性存储操作。源码涵盖SPI接口初始化、片选控制、命令与地址传输、读写使能、数据收发及错误处理等关键环节,代码结构清晰,按功能拆分为独立函数,便于二次开发和调试。压缩包共20个文件,包含c源文件、h头文件、obj目标文件、hex烧录文件、uv2工程文件及lst列表文件等,整体约38KB,可直接用于单片机工程移植或学习参考。已有1259人学习下载,适合希望在项目中快速实现EEPROM读写或系统掌握SPI编程原理的开发者。

1. 项目定位:用C++把25LC256 EEPROM的读写盘明白

我平时接触嵌入式项目,经常要存设备参数、校准数据这类掉电不能丢的东西。25LC256这颗256Kbit的SPI EEPROM经典又耐用,容量够、时序简单,可真到写驱动时,很多人跳进硬件SPI的坑,主频、极性和相位调半天,换一颗MCU又得重来。这个项目干脆用GPIO模拟SPI,把25LC256的读、写、页写、状态查询封装成一套C++类,底层完全走软件时序,不依赖任何硬件SPI外设。你不用改驱动逻辑,只要提供几个GPIO读写函数,就能在任意平台上把EEPROM跑起来。适合刚接触SPI EEPROM的开发者,也适合需要在多平台间复用驱动的老手。

2. 技术选型与目录结构:模拟SPI为何是刚需

2.1 25LC256芯片特性

先看芯片本身。25LC256是256Kbit(32KB)的SPI接口EEPROM,内部按256字节一页组织。读操作可以任意地址连续读,写操作每次最多写一页,而且写之前必须先发写使能指令(WREN)。状态寄存器里的WIP位用来表示内部写周期是否完成,写完必须轮询它,不能立刻进行下一次操作。

这些特性直接决定了驱动怎么写。容量32KB听起来不大,但存配置、存日志足够用;256字节页缓冲意味着跨页写要拆成多笔事务;WIP轮询则是保证数据不丢的关键。芯片支持SPI Mode 0(CPOL=0, CPHA=0),时钟极性和相位都是0,这让模拟SPI变得很简单——空闲时SCK为低,数据在上升沿采样。

2.2 源码目录结构

源码按“平台抽象层 + 驱动层 + 示例层”分了三层:

eeprom_25lc256/ ├─ src/ │ ├─ eeprom_25lc256.h // EEPROM操作类定义 │ ├─ eeprom_25lc256.cpp // 读写指令实现 │ ├─ spi_hal.h // SPI底层抽象:GPIO读写接口 │ └─ spi_hal_demo.cpp // 某MCU平台的GPIO实现示例 ├─ examples/ │ └─ main.cpp // 读写演示与自测 ├─ CMakeLists.txt └─ README.md

spi_hal.h定义了底层需要的四个原语:初始化SCK/MISO/MOSI/CS引脚、拉高/拉低某个引脚、读取MISO电平、微秒延时。驱动层只依赖这四类函数,不关心你用的什么MCU。你要移植到新平台,只需要重写spi_hal_demo.cpp这类的文件,把GPIO操作映射到自己的库函数上。

这种分层的好处是很实在:EEPROM的逻辑代码(指令、时序、页写边界)一次写好,以后换平台只改底层。我在实际项目里都是这么干的,驱动上面已经跑过好几个芯片型号,改的永远只有HAL层。

2.3 EEPROM操作类接口设计

核心类是EEProm25LC256,头文件里这样定义:

// eeprom_25lc256.h #include <cstdint> #include "spi_hal.h" enum { CMD_READ = 0x03, CMD_WRITE = 0x02, CMD_WREN = 0x06, CMD_RDSR = 0x05, CMD_WRSR = 0x01 }; class EEProm25LC256 { public: explicit EEProm25LC256(SpiHal* hal); ~EEProm25LC256(); void init(); // 初始化GPIO与CS高电平 uint8_t readByte(uint16_t addr); // 读单个字节 void readBuffer(uint16_t addr, uint8_t* buf, uint16_t len); bool writeByte(uint16_t addr, uint8_t data); // 写单字节(等WIP完成) bool writePage(uint16_t addr, const uint8_t* data, uint8_t len); uint8_t readStatus(); void waitWip(); private: void sendCommand(uint8_t cmd, uint16_t addr); void wren(); SpiHal* _hal; };

接口覆盖了最常用的四个操作:单字节读、连续读、单字节写、页写。writeByte内部会调用wren,然后发写指令,再等WIP;writePage则要处理跨页拆分。waitWip通过发RDSR指令读取状态寄存器的bit0。SpiHal是一个抽象接口指针,具体实现可以在运行时注入,方便单元测试时用mock替换。

这里的参数设计是有讲究的。地址用uint16_t,因为25LC256有16位地址线;长度用uint16_t,因为最大连续读地址范围是0x0000-0x7FFF。如果你要移植到更大容量的EEPROM,可以把地址类型提取成模板参数,不过当前项目这么写已经够用。

3. 核心模块拆解:模拟SPI时序与读写指令的完整链路

3.1 模拟SPI的GPIO驱动

模拟SPI的本质是用GPIO翻转来模拟时钟和数据线。25LC256工作在Mode 0:空闲时SCK为低,数据在SCK上升沿被采样,MISO在下降沿输出。实现一个字节收发函数:

// spi_hal_demo.cpp 中的关键函数 uint8_t spi_transfer_byte(uint8_t byte) { uint8_t received = 0; for (int bit = 7; bit >= 0; bit--) { // MOSI:在SCK上升沿之前设置好数据 if (byte & (1 << bit)) { hal_gpio_write(MOSI_PIN, 1); } else { hal_gpio_write(MOSI_PIN, 0); } // SCK上升沿,主机发送,从机采样 hal_gpio_write(SCK_PIN, 1); delay_us(2); // 在SCK高电平期间读MISO if (hal_gpio_read(MISO_PIN)) { received |= (1 << bit); } // SCK恢复低电平 hal_gpio_write(SCK_PIN, 0); delay_us(2); } return received; }

这段代码把一个字节按位发送并接收。注意MISO的采样点放在SCK高电平期间,只要满足建立时间,Mode 0下是安全的。我一般会把MISO读取放在SCK置低后、下一个上升沿之前,这样更接近芯片手册的时序图,也能减少GPIO电平还没稳定就读取的概率。

delay_us(2)里的2微秒是保守值,确保SCK频率不高于500kHz。25LC256最大支持5MHz时钟,模拟SPI不需要那么快,慢一点更稳。你手上的MCU如果主频高,GPIO翻转本身就有几十纳秒,可以直接去掉延时;但为了可移植性,保留小延时最保险。如果改成1us,频率可以差一倍,但可靠性会稍有下降。

3.2 读操作:从指令发送到数据返回

读单个字节需要先发送3字节帧头:指令、地址高8位、地址低8位,接下来每收一个字节,片选必须保持低电平。看实现:

uint8_t EEProm25LC256::readByte(uint16_t addr) { _hal->csLow(); _hal->transfer(CMD_READ); _hal->transfer((uint8_t)(addr >> 8)); _hal->transfer((uint8_t)(addr & 0xFF)); uint8_t data = _hal->transfer(0xFF); // 主机发0xFF以产生时钟 _hal->csHigh(); return data; }

transfer(0xFF)是SPI读的经典写法:MISO线上要出数据,必须由主机提供SCK时钟。即使没有数据要发送,也要发一个任意字节来驱动时钟。这里的0xFF就是占位时钟,发送方不在意它是什么。csLow和csHigh之间的所有传输必须是连续的,中间不能有任何其它SPI设备操作,否则25LC256会终止当前读操作。

对于连续读,只需要在第一个读命令后继续调用transfer(0xFF),芯片地址会自动递增。你需要保证循环读取的字节数不越过地址空间末尾,因为这个封装里没有做自动回卷处理。readBuffer内部其实就是循环调用readByte,但会保持CS低电平,效率高一些。

3.3 写操作:写使能、页写缓冲与跨页拆分

写操作比读复杂,因为芯片规定写入前必须先发WREN指令把内部的WEL锁存位置1,否则整个写指令会被忽略。最简单的单字节写:

void EEProm25LC256::wren() { _hal->csLow(); _hal->transfer(CMD_WREN); _hal->csHigh(); delayUs(2); // 时序保险,wren后短暂延时 } bool EEProm25LC256::writeByte(uint16_t addr, uint8_t data) { wren(); _hal->csLow(); _hal->transfer(CMD_WRITE); _hal->transfer((uint8_t)(addr >> 8)); _hal->transfer((uint8_t)(addr & 0xFF)); _hal->transfer(data); _hal->csHigh(); waitWip(); // 等待内部写周期结束 return true; }

wren发送后为什么要延时?在有些MCU上,GPIO翻转太快,CS上升沿刚结束就立刻开始下一次写指令,会让芯片来不及完成内部锁存。加2微秒是玄学也是血泪经验,去掉后偶发写失败。

页写更要注意边界。25LC256页大小256字节,写指令发送后,地址低8位到达页末尾时,地址会自动回卷到该页开头。如果你一次性写超出一页的数据,数据会被写到错误的位置。所以封装的writePage会做拆分:

bool EEProm25LC256::writePage(uint16_t addr, const uint8_t* data, uint8_t len) { const uint8_t pageSize = 256; while (len > 0) { uint8_t pageOffset = addr % pageSize; uint8_t chunk = pageSize - pageOffset; // 当前页剩余空间 if (chunk > len) chunk = len; wren(); _hal->csLow(); _hal->transfer(CMD_WRITE); _hal->transfer((uint8_t)(addr >> 8)); _hal->transfer((uint8_t)(addr & 0xFF)); for (uint8_t i = 0; i < chunk; i++) { _hal->transfer(data[i]); } _hal->csHigh(); waitWip(); addr += chunk; data += chunk; len -= chunk; } return true; }

pageOffset计算的是当前地址在页内的偏移,chunk就是本页还能写的字节数。每写完一块就等WIP完成,再把地址和剩余长度往前挪。这个逻辑如果不做,直接一次WRITE跨页写,数据就会在页边界处错乱。我在第一次移植时就踩过这个坑,写一个结构体穿越页边界,读回来的数据中间少了一段,后来一查手册才明白是回卷机制搞的鬼。

注意len的类型是uint8_t,所以单次调用最多写255字节,这样设计是为了匹配页大小。如果你要写超过255字节的连续数据,可以关注后面二次开发里的改造方案。

3.4 状态检测:WIP轮询与写周期等待

WIP(Write In Progress)是状态寄存器bit0。写入过程中它保持为1,写完后自动清零。等待函数:

uint8_t EEProm25LC256::readStatus() { _hal->csLow(); _hal->transfer(CMD_RDSR); uint8_t status = _hal->transfer(0xFF); _hal->csHigh(); return status; } void EEProm25LC256::waitWip() { int retry = 10000; while (retry--) { uint8_t status = readStatus(); if ((status & 0x01) == 0) break; delayUs(10); } }

retry=10000是超时上限。25LC256内部写周期最多5ms(手册典型值),按10us轮询间隔,1万次足够覆盖100ms,远远超过写周期。如果芯片损坏或CS引脚接触不良,轮循会一直读回WIP=1,这个超时能防止程序卡死。实际项目中我还会把超时次数通过返回值暴露出来,方便上层做错误处理——当前这个简单实现直接空跑,你可以按需改造成返回bool。

状态寄存器还有一个关键位:BP0和BP1是块保护位,可以锁定地址高段保护区域。默认为0,不保护任何地址。如果你需要防误写,可以通过WRSR指令配置,但日常用不到。

4. 常见问题排查:模拟SPI读写25LC256的踩坑记录

4.1 现象:读回全0xFF

模拟SPI最常见的现象就是读回来全是0xFF。原因有两个:一是MISO引脚配置不对,二是在读时序中采样点不对。很多新手只配置了MOSI、SCK的输出模式,忘了把MISO设置成输入模式;或者即使设置了输入,但复用功能没关,引脚被外设占用。

解决:先检查GPIO模式,MISO必须设置为浮空输入或带上拉输入,建议加10k外部上拉。再看时序,25LC256在Mode 0下,MISO数据在SCK下降沿之后有效,主机应该在SCK高电平期间读取。如果你把读取放在SCK刚拉高时,应确认没有在SCK上升沿同一条语句里读到旧值——有些编译器优化会打乱GPIO写入和读取顺序,我建议在读取前加一条反序屏障(__asm volatile("nop"))或延时。逻辑分析仪抓一下CS、SCK、MOSI、MISO四根线最直观。全0xFF意味着芯片返回默认状态,大概率是读取采样失败,而不是芯片没工作。这里容易踩坑的是模拟SPI的代码看起来逻辑正确,但MCU主频高,GPIO翻转后电平还没稳定就采样,导致读回1。把delay_us(2)改成delay_us(5)往往就正常了。

4.2 现象:写操作不生效,读回还是旧数据

这个坑比全0xFF更隐蔽。因为读操作通常正常,写操作也没报错,但数据就是没进去。最常见原因是WREN写使能没被芯片锁存。25LC256的WREN指令要求指令发送结束后CS产生一个完整的从低到高的跳变,有些代码把WREN和WRITE放在同一个CS低电平期间连续发送,这样的话WEL位不会被置位,后续WRITE会被芯片忽略。当CS保持低电平超过一帧,芯片可能把后续的WRITE当成了WREN的一部分,状态错乱。

解决:把WREN单独作为一次完整事务:CS拉低、发送0x06、CS拉高、短暂延时。再开始下一次CS低电平的WRITE流程。我踩坑时是直接在wren()函数里加了一个delayUs(1),结果偶发失败;后来改成CS拉高后必须延时超过100ns,才算稳定。如果你的平台GPIO翻转太慢或太快,都需要确认。

另一个原因是WP引脚(写保护)拉低后,芯片会拒绝所有写指令。如果读正常但写后读回来全FF,先量一下WP引脚电平,最好固定接VCC。当初我就是为了省电把WP接到了IO口,初始化时忘了置高,结果排查了整整一个下午。

4.3 现象:页写跨页丢数据

页写是25LC256最经典的玄学问题。芯片的页是256字节,WRITE指令发出的地址后8位到0xFF时,地址会自动回卷到本页的0x00偏移,而不是自动跳到下一页的0x00。也就是说你在地址0x00FF写5个字节,字节2会被写到0x0000,把前面本来就有的数据覆盖掉。

解决:所有批量写都要用writePage里的拆分逻辑,先算出当前地址在页内的偏移pageOffset = addr % 256,然后本页剩余空间就是256 - pageOffset,一次写这么多。写完等到WIP为0之后,把基地址加上已写长度,继续下一轮。关键点是每轮后必须等WIP,因为芯片内部页写周期大概是3ms,如果连续发两次WRITE而中间没有间隔,第二次指令可能被当成无效命令。

我自己在项目里因此翻过车:定义了一个结构体ConfigData,大小是300字节,从地址0x00FF开始写。本以为地址自动递增,结果读回后前一部分覆盖了页首,数据熔断。从那以后,我只要看到writePage,先检查有没有按页偏移循环。

4.4 现象:CS引脚悬空造成SPI总线数据错乱

还有一种比较隐蔽的现象是芯片偶尔会进入错误状态,读写的字节偶尔错位,或者某个地址的数据莫名其妙变成0x00。原因多半是CS引脚在平台启动阶段没有被明确控制,处于高阻或浮空状态,此时SCK或MOSI上的干扰信号会误触发片选。EEPROM的CS是低有效,浮空时可能随相邻线耦合变低,芯片被选中,随机指令被解码执行。

解决:在初始化代码里第一件事就是把CS配置成推挽输出并拉高,然后延时几个毫秒。如果板子上CS到地之间有寄生电容,拉高太慢,可以在CS和VCC之间接一个10k上拉电阻,确保默认高电平。我一般还会在init()里做一次状态寄存器的读取来验证芯片在线:读到0x00正常;读到0xFF或0x80说明CS没控制住或电源不稳。这个自检步骤非常简单,但能帮你在早期拦截一大半硬件连线问题。

4.5 现象:读写速度慢得离谱

模拟SPI本身不快,但如果你的时序里大量使用堵塞延时,性能会更差。一些初学者把delay_us(2)写成了delay_ms(2),写一页256字节要256次传输,每次传输8个时钟周期,每个时钟4毫秒,一页写完接近10秒,简直不可接受。

解决:检查延时单位。2us级别的延时在5MHz SPI下是合理的,但如果你不需要那么快,可以降低延时到1us。另外,减少在传输字节内部的函数调用开销,比如把hal_gpio_write直接内联或使用寄存器操作,移除调试打印。模拟SPI的瓶颈就在延时时长,精确的短延时能直接提升吞吐。这条经验也提醒我,移植到新平台后第一时间用示波器量SCK频率,不要靠猜。

5. 移植运行与验证:从源码到目标板的完整落地

5.1 硬件连接

25LC256共8个引脚。和MCU连接只需要5根线,电源和地跟上。

EEPROM引脚接到MCU说明
CS(Pin1)任意GPIO,输出片选,低有效
SCK(Pin2)任意GPIO,输出SPI时钟
SI(Pin3)任意GPIO,输出MOSI
SO(Pin4)任意GPIO,输入MISO
VCC(Pin8)3.3V/5V手册支持2.5V-5.5V
GND(Pin5)GND接地
HOLD(Pin7)VCC或GPIO低电平暂停通讯
WP(Pin6)VCC或GND写保护,低时禁止写

HOLD和WP引脚可以固定接上拉或下拉,具体看你的保护需求。如果想让写保护失效,WP接高;如果用不到HOLD,直接接VCC。注意CS、SCK、MOSI三个输出脚最好加上限流电阻,避免初始化瞬间的电平冲突。

5.2 基于CMake构建示例工程

示例工程用CMake组织,完全独立于具体MCU。在PC上可以编译成命令行程序,通过模拟GPIO测试;在嵌入式平台,只需要把spi_hal_demo.cpp替换成对应平台的实现。

// examples/main.cpp #include "eeprom_25lc256.h" #include "spi_hal_demo.h" #include <cstdio> int main() { SpiHalDemo hal; EEProm25LC256 eep(&hal); eep.init(); // 在地址0x0000写入0xAA eep.writeByte(0x0000, 0xAA); uint8_t val = eep.readByte(0x0000); printf("read: 0x%02X\n", val); return 0; }

CMakeLists.txt简化为:

cmake_minimum_required(VERSION 3.10) project(eeprom_25lc256) add_executable(demo examples/main.cpp src/eeprom_25lc256.cpp src/spi_hal_demo.cpp ) target_include_directories(demo PRIVATE src)

编译命令很简单:

mkdir build && cd build cmake .. make ./demo

如果你的平台没有标准printf,把输出改成日志接口或LED指示即可。这个示例本质是验证驱动逻辑,不涉及外部硬件,所以工程结构很轻。实际交叉编译时,你还需要调整工具链和链接选项,比如-mcpu参数,但驱动代码本身不依赖编译器特性。在嵌入式板上,spi_hal_demo.cpp中的hal_gpio_write函数改成直接操作GPIO寄存器的值,其余代码不需要动。

5.3 功能自测:全地址读写与页写压力测试

跑起来之后,建议先做一轮完整的自测,不然后面出了问题很难分清是驱动还是电路。我习惯写三个用例。

第一,全FF/00填充测试。把整个32KB都写0x00,再全读回来比对;再写0xFF,读回来比对。这能暴露地址线短路或MISO接触不良。

第二,地址线测试。往地址0x0000写0xA5,再往地址0x7FFF写0x5A,分别读回。主要是查高位地址线和片选时序。

第三,跨页压力测试:

// 从地址0x00FF连续写10字节 uint8_t data[10] = {0,1,2,3,4,5,6,7,8,9}; eep.writePage(0x00FF, data, 10); uint8_t buf[10]; eep.readBuffer(0x00FF, buf, 10); for (int i = 0; i < 10; i++) { if (buf[i] != data[i]) { printf("mismatch at offset %d\n", i); } }

如果writePage没有正确拆分,buf[0]和buf[1]会匹配,但buf[2]开始错位,因为0x00FF页内只剩1字节,后面的数据被回卷到页开头。跑一次这个测试,基本能确认跨页逻辑是否可靠。

自测代码每次写后都要调用waitWip。完整测试没有必要跑很多次,一次写读校验通过后,驱动基本可用了。这个验证流程我每次换板子都会走一遍,它不复杂,但能一次性排除大部分硬件问题,省得以后排查玄学问题。

6. 二次开发技巧:让驱动更通用、更健壮

拿到这套源码,大多数人的第一个需求是把它改造成非阻塞。当前waitWip是死循环,会卡住其他任务。我一般会改成状态机:把writeByte拆成startWrite和pollWrite两个函数,前者发WREN和写指令,后者只读状态寄存器。上层放在RTOS任务中定时调用,就能避免阻塞。

第二个值得改的是地址范围和容量抽象。25LC256用uint16_t地址,如果以后换到更大的25LC512或25LC1024,需要把类型改成模板参数或者直接统一为uint32_t。这个时候类接口会变成:

template<uint32_t MaxAddr> class EEProm { void readBuffer(uint32_t addr, uint8_t* buf, uint32_t len); void writePage(uint32_t addr, const uint8_t* data, uint16_t len); };

第三个方向是加磨损均衡。EEPROM有100万次擦写寿命,如果总是写固定地址,那一段会先报废。常见做法是地址映射:每次写新数据写到下一个空闲页,用一小块元数据区记录当前偏移。这个项目没有实现,但是接口已经预留了——只要把writeByte内部改成先读元数据再算物理地址,上层调用完全感知不到。

我自己的教训是,拿到这种驱动源码,第一件事不是直接上业务,而是先写个跨页自测。上次在项目里直接调用writePage存配置,结果配置长度超过页边界,数据分两次写,第二次写前忘了等WIP,导致配置被写进错误地址。从那以后,凡是接手外部驱动源码,我都强制先跑一遍writePage边界测试再进业务开发。希望这个项目能帮你少踩这几个坑,跑通后按自己的场景改造,很快就能变成顺手的基础组件。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询