简介提供一份基于SPI协议模拟读写EEPROM 25LC256的C源码包面向嵌入式开发者与电子工程学习者解决在无硬件SPI或需灵活控制时序场景下对25LC256进行非易失性存储的问题。源码涵盖初始化SPI接口、片选选中与释放、写使能、命令和地址发送、单字节与多字节读写、事务完整性控制等核心流程并包含缓冲机制与简单错误处理代码结构清晰、分函数封装便于理解SPI时序和移植到其他SPI从设备也可作为通用模板用于保存配置参数、固件数据等应用。压缩包内共20个文件以.c源文件、.h头文件为主附带Keil工程文件.uv2/.opt/.lnp、编译中间文件.obj/.lst以及可直接烧录的.hex文件和启动汇编程序共38KB整体轻量紧凑。目前已有1259人学习浏览对想快速上手25LC256读写或深入理解SPI模拟通信的开发者具有较高参考价值。1. 25LC256这类 SPI EEPROM 为什么值得自己写 C 驱动真正量产时调过 25LC256 的人都有这种感觉原理图上周边的电阻没配好或者初始化时序差半拍读回来的就是最不想看到的默认值。25LC256 是 256Kbit 的 SPI EEPROM容量不大但做参数存储、掉电恢复、运行日志这类场景非常稳很多老工程师宁可不用硬件 SPI 也要用 GPIO 模拟时序。这份用 C 写的源码走的就是模拟 SPI 这条路用两三个 GPIO 口把 SPI 时序逐位拉出来不依赖芯片自带的 SPI 外设。老平台、国产 MCU、引脚紧张的板子都能直接移植适合正在写裸机 C、想把 EEPROM 驱动彻底吃透的开发者。下面按“时序原理 → 模拟 SPI 封装 → 读写接口 → 踩坑 → 验证”拆解给的都是能直接抄的代码骨架。2. 先看懂 25LC256 的时序再写代码指令集与状态位2.1 存储组织与指令表25LC256 的存储阵列是 32K 字节地址用 16 位表示每一页固定 64 字节。写数据时最忌讳的一件事是连续写了 70 字节以为芯片会自己换页结果第 65 字节起全部回到当前页的开头覆盖旧数据。这个回绕行为由 25LC256 的页边界决定写驱动之前必须把地址对齐算清楚。读写用的操作码不多先看这张基础指令表。命令操作码地址数据说明READ0x032 字节先高后低连续读出可跨页连续读最多读到地址末尾WRITE0x022 字节先高后低1~64 字节只能在单页内写跨页自动回绕WRDI0x04无无清除写使能锁存位WREN0x06无无设置写使能锁存位写操作前必发RDSR0x05无1 字节读状态寄存器WRSR0x01无1 字节写状态寄存器受块保护位控制状态寄存器里最常用的三个位是 WIP、WEL、BP0/BP1。WIP 是忙标志写周期进行中为 1平时为 0。WEL 是写使能锁存每次上电默认是 0必须发 WREN 置 1 之后WRITE 和 WRSR 才会被芯片接受。BP0/BP1 是块保护位出厂默认可能是 0但如果上一手程序往状态寄存器里写过保护值就会出现“某些地址能写、某些地址写不进”的诡异问题。驱动初始化时建议主动把保护位清成 0。另外要注意 READ 操作发出 16 位地址后芯片每接收 8 个时钟就会从 SO 线上推出一字节数据主机在给时钟的同时要把 MOSI 置成任意空闲电平或者继续发 0x00。代码里我给的是发 0x00 这种习惯因为模拟 SPI 的发送和接收本来就是同一条Transfer函数发给芯片的 dummy 字节不会影响读数据。2.2 模拟 SPI 与硬件 SPI 的取舍硬件 SPI 外设的速率可以做到 MHz 级别但 25LC256 这类 EEPROM 的读写在绝大多数项目里并不需要那么猛。用模拟 SPI 换灵活性和可移植性是值得的。对比项硬件 SPI模拟 SPI引脚要求必须复用或占用固定引脚任意 GPIO 即可速率可达 MCU 外设上限几 MHz 常见一般几十 kHz 到 1 MHz 以内时序自由度模式、位宽、时钟极性由寄存器决定代码里逐位拉电平可任意调整调试成本波形异常时定位困难每一步都直观可加打印点适用场景大量数据、高速外设EEPROM、传感器这类低速器件25LC256 的时钟频率上限与供电电压有关典型值在 2MHz 到 10MHz 之间模拟 SPI 把时钟周期放到微秒级完全在芯片允许范围内反而更容易避开信号完整性坑。我一般把 SCK 的半周期设为 5 微秒到 10 微秒对应约 50kHz 到 100kHz对 GPIO 翻转能力要求很低。有一点比硬件 SPI 更好的是模拟 SPI 可以把片选信号起始时刻精确放在指令第一个字节之前而不需要等 DMA 或 FIFO 的节奏。25LC256 的 CS 引脚要求在指令开始前由高变低整个指令过程中必须保持低电平指令结束后再拉高。硬件 SPI 如果配置不好容易在连续字节之间出现多余的 CS 抖动模拟 SPI 手动控制这个问题天然不存在。3. 把底层时序封装成 C 类软件 SPI 的实现细节3.1 GPIO 抽象与位操作裸机 C 项目里最忌直接写寄存器地址满天飞。我一般会把 GPIO 操作抽象成一组函数指针驱动代码只依赖这个结构体不依赖具体是哪块板子。// gpio_pin.h #pragma once #include cstdint struct GpioPin { void (*write)(GpioPin* pin, bool level); // 拉高或拉低引脚 bool (*read)(GpioPin* pin); // 读取引脚电平 void* user_data; // 指向平台寄存器或句柄 }; static inline void GpioWrite(GpioPin* pin, bool level) { if (pin pin-write) { pin-write(pin, level); } } static inline bool GpioRead(GpioPin* pin) { if (pin pin-read) { return pin-read(pin); } return false; }这样做的目的是把“电平怎么落到底层寄存器”这件事完全隔离出去。你在某一款 MCU 上实现初始化时只要能构造出带write和read回调的GpioPin驱动代码不用改一行。有些偏 C 的写法会直接上std::function但很多嵌入式 GCC 工具链默认不开异常和 RTTIstd::function在某些平台上还会引入动态内存分配。EEPROM 驱动本身就是底层模块没必要把依赖搞重函数指针加void* user_data是代价最小的抽象。顺带一提如果用的是 C11 以上版本也可以把这些函数指针改成模板参数让编译器内联掉回调调用速度更快但代码会稍微绕一些。3.2 字节收发与时序参数配置有了 GPIO 抽象模拟 SPI 主机就是一个非常简单的类。SCK 负责时钟MOSI 负责输出MISO 负责输入CS 由上层读写接口单独控制。// soft_spi.h #pragma once #include cstdint #include gpio_pin.h class SoftSpi { public: SoftSpi(GpioPin* sck, GpioPin* mosi, GpioPin* miso) : sck_(sck), mosi_(mosi), miso_(miso) {} void SetHalfPeriodUs(uint32_t us) { half_period_us_ us; } uint8_t Transfer(uint8_t tx_byte); private: GpioPin* sck_; GpioPin* mosi_; GpioPin* miso_; uint32_t half_period_us_ 5; // SCK 半周期默认约 100kHz };Transfer 的按位实现如下这套写法对应 SPI Mode 0也就是 CPOL0、CPHA0。// soft_spi.cpp #include soft_spi.h // 平台延时函数源码包里预留接口由使用者实现 extern void delay_us(uint32_t us); uint8_t SoftSpi::Transfer(uint8_t tx_byte) { uint8_t rx_byte 0; // CPOL0 表示空闲时 SCK 为低电平 GpioWrite(sck_, false); for (int bit 7; bit 0; --bit) { // 先把 MOSI 数据准备好再抬升时钟避免对端采到旧电平 bool tx_level (tx_byte (1 bit)) ! 0; GpioWrite(mosi_, tx_level); delay_us(half_period_us_); GpioWrite(sck_, true); delay_us(half_period_us_); // 模拟主机在 SCK 上升沿附近读取 MISO if (GpioRead(miso_)) { rx_byte | static_castuint8_t(1 bit); } // 时钟拉低进入下一位的建立阶段 GpioWrite(sck_, false); delay_us(half_period_us_); } return rx_byte; }参数说明half_period_us_是 SCK 半周期决定整条总线速率。设为 5 微秒一位大约 10 微秒一个字节加循环开销后约 100 微秒对应将近 100kHz 的时钟。若板子走线较长或者 GPIO 翻转偏慢可以调到 20 微秒甚至 50 微秒保证波形完整。25LC256 手册里给的最小时间远小于这个值所以不必担心速度不够。还有一点要注意如果项目里实际用的是 SPI Mode 3也就是 CPOL1、CPHA1只需要把空闲电平和采样点换一下改动不超过三行。25LC256 同时兼容 Mode 0 和 Mode 3但大多数应用都会统一在 Mode 0 上省心也稳妥。4. 25LC256 读写接口写使能、页写与连续读4.1 写使能状态机与忙轮询25LC256 的写操作和普通 24 系列 I2C EEPROM 最大的区别就是多了一道 WREN 命令。WEL 位在每次内部写周期结束后会被自动清零所以不能只写一次 WREN 就连续发多条 WRITE必须保证每页写入前 WEL 都是 1。下面给出 EEPROM 封装类的头文件。// eeprom_25lc256.h #pragma once #include cstddef #include cstdint #include gpio_pin.h #include soft_spi.h class Eeprom25LC256 { public: static constexpr uint16_t kCapacity 0x8000; // 256Kbit约 32K 字节 static constexpr uint16_t kPageSize 64; // 页大小 64 字节 Eeprom25LC256(SoftSpi* spi, GpioPin* cs) : spi_(spi), cs_(cs) {} void WriteEnable(); // WREN uint8_t ReadStatus(); // RDSR bool WaitWriteDone(uint32_t timeout_ms); // 等 WIP 清零 bool Write(uint16_t addr, const uint8_t* data, size_t len); void Read(uint16_t addr, uint8_t* data, size_t len); private: void BeginCs() { GpioWrite(cs_, false); } void EndCs() { GpioWrite(cs_, true); } SoftSpi* spi_; GpioPin* cs_; };写使能和状态轮询实现如下// eeprom_25lc256.cpp #include eeprom_25lc256.h // 平台毫秒延时由使用者实现 extern void delay_ms(uint32_t ms); void Eeprom25LC256::WriteEnable() { BeginCs(); spi_-Transfer(0x06); // WREN EndCs(); // CS 拉高后 WEL 位才正式生效 } uint8_t Eeprom25LC256::ReadStatus() { BeginCs(); spi_-Transfer(0x05); // RDSR uint8_t status spi_-Transfer(0x00); EndCs(); return status; } bool Eeprom25LC256::WaitWriteDone(uint32_t timeout_ms) { uint32_t waited 0; while (waited timeout_ms) { if ((ReadStatus() 0x01) 0) { return true; // WIP0写周期已结束 } delay_ms(1); waited; } return false; }注意WriteEnable的 CS 时序先拉低发完 WREN 指令后必须拉高WEL 位才会被锁存生效。如果 CS 一直保持低电平这条 WREN 会被芯片当作普通字节忽略后面真正执行 WRITE 时就直接翻车。4.2 页写与连续读的实现页写接口需要做两层事情第一层把一次大长度写入拆成多个 64 字节页写第二层是处理地址在页内偏移时剩余空间不足的情况。bool Eeprom25LC256::Write(uint16_t addr, const uint8_t* data, size_t len) { if (addr len kCapacity) { return false; } while (len 0) { // 上一轮写周期未结束前不要发 WRENWIP1 时命令会被忽略 if (!WaitWriteDone(100)) { return false; } WriteEnable(); // 计算当前页内剩余字节数超过会回绕覆盖本页开头 size_t remain_in_page kPageSize - (addr % kPageSize); size_t chunk (len remain_in_page) ? len : remain_in_page; BeginCs(); spi_-Transfer(0x02); // WRITE spi_-Transfer(static_castuint8_t(addr 8)); spi_-Transfer(static_castuint8_t(addr 0xFF)); for (size_t i 0; i chunk; i) { spi_-Transfer(data[i]); } EndCs(); // CS 拉高后才开始内部写周期 addr static_castuint16_t(addr chunk); data chunk; len - chunk; } return true; }这段代码的执行顺序是等待上一次写周期结束 → WREN → 按页边界截取本次要写的长度 → 发送操作码和地址 → 逐个字节刷入 → CS 拉高。这样即使调用方直接写 500 字节驱动也会每 64 字节自动拆成一页。25LC256 的地址是 16 位但实际容量只有 32K 字节地址范围是 0x0000 到 0x7FFF。如果输入地址是 0x8000 以上属于越界上面代码用addr len kCapacity做了拦截。连续读实现比页写简单因为读操作不受页边界限制地址内部会自动递增只要保证不越过存储阵列末尾就行。void Eeprom25LC256::Read(uint16_t addr, uint8_t* data, size_t len) { if (addr len kCapacity) { return; } BeginCs(); spi_-Transfer(0x03); // READ spi_-Transfer(static_castuint8_t(addr 8)); spi_-Transfer(static_castuint8_t(addr 0xFF)); for (size_t i 0; i len; i) { data[i] spi_-Transfer(0x00); // dummy 时钟SCK 每转 8 次吐一字节 } EndCs(); }读数据时 MOSI 线虽然发的是 0x00但真正有意义的是 MISO 上同步返回的数据。模拟 SPI 的 Transfer 天然是双向的读和写共用一个函数这一点比硬件 SPI 更容易理解。5. 避坑与常见问题模拟 SPI 读写 25LC256 的四种翻车现场5.1 读回全是 0xFF片选没管住现象用逻辑分析仪能看到 SCK 和 MOSI 都有波形但读状态寄存器或者读数据时MISO 一直返回高电平读回的数据全是 0xFF。原因CS 引脚在初始化时没有被明确拉高。模拟 SPI 方案里 SCK、MOSI、MISO 都配置了 GPIO唯独 CS 忘记配置成输出并置高。如果 CS 初始为低芯片一直处于选中状态操作码和地址的边界就乱了。另一个常见情况是 CS 在每次 Transfer 之间都被手动拉低再拉高芯片会把同一个字节拆成多段命令理解自然什么都读不到。解决办法驱动初始化时先把 CS 拉高再开始任何指令。CS 只允许在一条完整指令开始前拉低、结束后拉高节与节之间的字节交换不能动 CS。我习惯在 Init 函数里加一句GpioWrite(cs_, true)把 CS 初始高电平当成驱动初始化的一部分。5.2 明明发了 WREN写操作还是被忽略现象调用 WriteEnable 之后再调用 Write读回数据还是旧的写入操作像完全没发生过。原因WEL 位在上电后默认是 0且每次内部写周期结束后会被自动清零。很多刚接触 25LC256 的人会在初始化时发一次 WREN后面连续写多页时不再重复发结果从第二页开始写入失效。另外如果芯片还在内部写周期中此时发出的 WREN 会被 WIP1 的状态挡住。解决办法把 WREN 放进每一次页写循环里不要把它当成一次性初始化。写入前先用 WaitWriteDone 确保 WIP 为 0再发 WREN然后立刻发 WRITE。上面 4.2 的代码已经按这个顺序处理照着用就不会踩中。5.3 写了 40 字节只能留在 24 字节页边界回绕现象往某个地址连续写入超过当前页剩余长度的数据读回后发现后半段数据出现在本页开头或者直接被覆盖丢失。原因25LC256 页大小是 64 字节页内地址计数器写满后不会自动进位到下一页而是回绕到本页起始地址。这是芯片硬件行为不是 bugs但很多第一次用的开发者误以为它和 24 系列 EEPROM 一样可以跨页连续写。解决办法在 Write 接口里按remain_in_page截断每轮写入长度。如果从地址 0x0040 开始写 10 字节第一轮只能写剩余 0 字节不对0x0040 是页边界正好整页若地址 0x0050 还在第二页就有 14 字节剩余空间超过部分必须拆到 0x0080 之后的第三页。用64 - (addr % 64)计算剩余空间是最稳妥的写法。5.4 只有部分地址能写其他地址写入无效现象低地址区域写进去读不出来高地址可以正常读写或者反过来提示范围被保护。原因状态寄存器里的 BP0/BP1 位设置了块保护范围加上 WPEN 位和芯片 WP 引脚电平共同作用会限制某一段地址的写权限。上一手程序往状态寄存器里写入过保护值或者硬件上 WP 引脚被直接下拉都会造成这种半片可写半片不可写的假故障。解决办法上电初始化时先读一次状态寄存器确认 WEL、WIP、BP0、BP1 的状态。如果需要解除保护先发 WREN再发 WRSR 并把状态寄存器写成 0x00等本次写周期完成后重新检查。注意 WRSR 本身也受写使能控制不能省略 WREN。如果板上 WP 引脚被拉低且 WPEN 位为 1则需要在硬件层面把 WP 拉高再执行 WRSR。6. 收尾验收用回环测试和随机地址压力把 25LC256 驱动钉死6.1 回环自检代码写完驱动不能只看一次读写成功就收工我一般会跑一段覆盖不同地址和不同数据形态的自检序列。测试数据里同时带上 0x00、0xFF 和交替位能更快暴露时钟或片选问题。int SelfTest(Eeprom25LC256 eep) { const uint8_t pattern[8] { 0x00, 0xFF, 0xA5, 0x5A, 0x55, 0xAA, 0x01, 0xFE }; uint8_t buf[8]; // 先清掉状态寄存器里的块保护避免地址区段被锁写 eep.WriteEnable(); uint8_t status eep.ReadStatus(); if ((status 0x30) ! 0) { // 需要把 BP0/BP1 清 0暂时简化处理实际工程里要封装 WRSR return -2; } for (uint16_t addr 0; addr Eeprom25LC256::kCapacity; addr 256) { if (!eep.Write(addr, pattern, sizeof(pattern))) { return addr 1; } if (!eep.WaitWriteDone(100)) { return addr 2; } eep.Read(addr, buf, sizeof(buf)); for (size_t i 0; i sizeof(pattern); i) { if (buf[i] ! pattern[i]) { return addr 3; } } } return 0; }这个测试按 256 字节步进覆盖全片每个测试点只写 8 字节不会跨越页边界因此能单独验证地址线、指令解析和读写通路。如果驱动里有页拆分逻辑可以再写一个跨页测试从0x003C开始写 16 字节看后半段是否落到0x007C附近而不是回绕到0x0000。6.2 一个写寿命习惯25LC256 的典型擦写寿命在 100 万次左右对参数保存够用但对高频日志记录不够。如果你用它存运行记录千万别每次都写同一个地址。更稳妥的做法是把日志区域做成环形缓冲区每一页头部存一个自增序号读的时候先找到最新页再顺序回放。这样寿命可以放大到页数倍32K 字节分摊之后单页就不会过早报废。从那以后我每次换 MCU 移植这套 C 驱动都会强制先做两遍例行检查第一遍读状态寄存器看 WIP 和 BP 位是否异常第二遍跑上面的回环自检。这两步救回过好几块被误判为“芯片坏了”的板子最后发现问题都在驱动时序和状态寄存器上。代码骨架按上面的思路拆开移植时替换掉延时函数和 GPIO 回调就可以直接跑希望帮到你。本文还有配套的精品资源点击获取