✔零知开源(零知IDE)是一个专为电子初学者/电子兴趣爱好者设计的开源软硬件平台,在硬件上提供超高性价比STM32系列开发板、物联网控制板。取消了Bootloader程序烧录,让开发重心从 "配置环境" 转移到 "创意实现",极大降低了技术门槛。零知IDE编程软件,内置上千个覆盖多场景的示例代码,支持项目源码一键下载,项目文章在线浏览。零知开源(零知IDE)平台通过软硬件协同创新,让你的创意快速转化为实物,来动手试试吧!
✔访问零知实验室,获取更多实战项目和教程资源吧!
目录
[1.1 硬件清单](#1.1 硬件清单)
[1.2 接线方案表](#1.2 接线方案表)
[1.3 接线示意图](#1.3 接线示意图)
[1.4 实物连接图](#1.4 实物连接图)
[3.1 非阻塞式按键检测](#3.1 非阻塞式按键检测)
[3.2 状态机架构](#3.2 状态机架构)
[3.3 UI 分层渲染](#3.3 UI 分层渲染)
[3.4 高效复制算法](#3.4 高效复制算法)
[3.5 文件系统抽象](#3.5 文件系统抽象)
[4.1 操作流程](#4.1 操作流程)
[4.2 视频演示](#4.2 视频演示)
[5.1 SD卡初始化流程](#5.1 SD卡初始化流程)
[5.2 文件读写原理](#5.2 文件读写原理)
[Q1:上电后 OLED 显示 "SD FAILED!" ?](#Q1:上电后 OLED 显示 "SD FAILED!" ?)
[Q3:复制粘贴提示 "File Exists!"?](#Q3:复制粘贴提示 "File Exists!"?)
项目概述
本项目基于零知标准板 (主控STM32F103RBT6)开发了一个功能完善的SD卡文件管理系统。该系统通过SPI接口连接SD卡读写器,实现了完整的文件浏览、查看、创建、复制、剪切、删除等功能,并配备了OLED显示屏和按键交互界面,为用户提供了一个便携式的SD卡管理工具
项目难点及解决方案
问题描述:目录切换后文件数量减少
解决方案: 遍历目录前调用dir.rewindDirectory()重置指针;增加智能重试机制,openNextFile()失败时重试最多 3 次
一、系统接线部分
1.1 硬件清单
| 硬件名称 | 规格 / 型号 | 数量 |
|---|---|---|
| 零知标准板 | 主控 STM32F103RBT6 | 1 块 |
| SD 卡读写器 | SPI 接口(支持 SD/SDHC,FAT32) | 1 个 |
| OLED 显示屏 | SSD1306(128×64,I2C 接口) | 1 块 |
| 轻触按键 | 四位按键模块(INPUT_PULLUP 模式) | 1 个 |
| SD 卡 | ≤32G,FAT32 格式 | 1 张 |
| 杜邦线 | 公对公 / 公对母 | 若干 |
| 电源 | 5V/1A USB 电源 | 1 个 |
1.2 接线方案表
根据代码核心定义,接线配置如下:
| 零知标准板引脚 | 外接模块 | 模块引脚 | 备注 |
|---|---|---|---|
| 10 | SD 卡读写器 | CS | SPI 片选(低电平有效) |
| 13 | SD 卡读写器 | SCK | SPI 时钟(主机产生) |
| 11 | SD 卡读写器 | MOSI | 主机发 / 从机收 |
| 12 | SD 卡读写器 | MISO | 主机收 / 从机发 |
| 5V | SD 卡读写器 | VCC | 必须 5V 供电,3.3V 驱动不足 |
| GND | SD 卡读写器 | GND | 共地,减少干扰 |
| A5 | OLED 屏 | SCL | I2C 时钟 |
| A4 | OLED 屏 | SDA | I2C 数据 |
| 3.3V | OLED 屏 | VCC | 供电 |
| GND | OLED 屏 | GND | 共地 |
| 2 | 按键 | 上键 | 向上选择 |
| 3 | 按键 | 下键 | 向下选择 |
| 4 | 按键 | 确认键 | 进入/确认 |
| 5 | 按键 | 返回键 | 返回/创建 |
SD卡模块使用5V供电,OLED建议使用3.3V供电
1.3 接线示意图

SD 卡需格式化为 FAT32,NTFS/exFAT 不兼容
1.4 实物连接图

二、安装与使用部分
2.1 开源平台-输入"SD卡文件管理系统"并搜索-代码下载自动打开

2.2 连接-验证-上传

2.3 调试-串口监视器

三、代码讲解部分
代码整体 结构
代码采用状态机 + 模块化设计,核心分为 5 大模块:非阻塞按键检测、状态机管理、UI 分层渲染、文件系统抽象、高效文件复制,解决了嵌入式系统 "功能多、资源少" 的核心矛盾
3.1 非阻塞式按键检测
cpp
class SimpleButton {
private:
byte pin; // 按键引脚
int state; // 当前状态(HIGH/LOW)
unsigned long lastChange; // 状态最后变化时间(防抖)
bool released; // 按键是否释放
int clickCount; // 单击次数(识别双击)
unsigned long lastClickTime; // 最后单击时间
public:
bool justClicked = false; // 单击触发标志
bool justDoubleClicked = false; // 双击触发标志
bool justLongPressed = false; // 长按触发标志
// 构造函数:初始化引脚和默认状态
SimpleButton(byte p) : pin(p), state(HIGH), lastChange(0), clickCount(0), released(true) {}
// 初始化引脚为INPUT_PULLUP
void begin() { pinMode(pin, INPUT_PULLUP); }
// 核心更新函数(主循环中调用)
void update() {
// 每次更新先重置触发标志
justClicked = false;
justDoubleClicked = false;
justLongPressed = false;
int reading = digitalRead(pin);
unsigned long now = millis();
// 防抖处理:50ms内状态不变才有效
if (reading != state && (now - lastChange > 50)) {
state = reading;
lastChange = now;
if (state == LOW) { // 按键按下
released = false;
} else { // 按键释放
released = true;
// 释放时间<500ms,判定为有效单击
if (now - lastChange < 500) {
clickCount++;
lastClickTime = now;
}
}
}
// 长按判定:按下超过800ms
if (state == LOW && !released && (now - lastChange > 800)) {
justLongPressed = true;
released = true;
clickCount = 0; // 打断连击
}
// 双击判定:250ms内连续两次单击
if (clickCount > 0 && (now - lastClickTime > 250)) {
if (clickCount == 1) justClicked = true;
else if (clickCount >= 2) justDoubleClicked = true;
clickCount = 0;
}
}
};
支持单击 / 双击 / 长按三种触发模式,非阻塞设计保证主循环流畅
按键功能映射
| 按键 | 单击 | 双击 | 长按 |
|---|---|---|---|
| UP(D2) | 上选列表项 | - | - |
| DOWN(D3) | 下选列表项 | - | - |
| ACTION(D4) | 确认 / 进入菜单 | 返回上级目录 | 打开文件操作菜单 |
| BACK(D5) | 打开创建文件菜单 | 返回上级目录 | - |
3.2 状态机架构
cpp
// 定义6种屏幕状态
enum ScreenState {
BROWSER, // 文件浏览器(默认)
TEXT_VIEWER, // 文本查看器
HEX_VIEWER, // 十六进制查看器
CONTEXT_MENU, // 文件操作菜单
CREATE_FILE_MENU, // 创建文件菜单
ERROR_POPUP // 错误弹窗
};
ScreenState currentState = BROWSER; // 当前状态
ScreenState lastState = BROWSER; // 上一状态
// 主循环中根据状态调用对应处理函数
void loop() {
// 按键更新
btnUp.update();
btnDown.update();
btnAction.update();
btnBack.update();
// 状态机分发
switch (currentState) {
case BROWSER: handleBrowser(); break;
case TEXT_VIEWER: handleTextViewer(); break;
case HEX_VIEWER: handleHexViewer(); break;
case CONTEXT_MENU: handleContextMenu(); break;
case CREATE_FILE_MENU: handleCreateFileMenu(); break;
case ERROR_POPUP: handleErrorPopup(); break;
}
}
// 从浏览器进入文本查看器
void handleBrowser() {
if (btnAction.justClicked && !isDir[selectedIdx]) {
lastState = BROWSER;
currentState = TEXT_VIEWER;
viewOffset = 0; // 重置查看位置
}
}
// 从任何状态显示错误提示
void showPopup(String msg) {
popupMsg = msg;
popupTime = millis() + 1500; // 显示1.5秒
lastState = currentState; // 保存当前状态
currentState = ERROR_POPUP; // 切换到错误提示状态
}
各状态逻辑独立,修改某一界面(如文本查看器)不影响其他功能;状态切换仅需修改
currentState,路径清晰
3.3 UI 分层渲染
cpp
// 显示常量:严格分区,修改只需调整常量
#define TITLE_HEIGHT 11 // 标题栏高度
#define STATUS_HEIGHT 10 // 状态栏高度
#define LIST_START_Y 12 // 列表区起始Y坐标
#define LIST_AREA_HEIGHT 41 // 列表区高度
#define ITEM_HEIGHT 10 // 列表项高度
#define ITEMS_PER_PAGE 4 // 列表每页显示4项
#define ICON_SIZE 7 // 图标大小
void handleBrowser() {
display.clearDisplay(); // 清空缓冲区(双缓冲第一步)
// 1. 绘制标题栏(0~10px):白色背景+黑色文字
display.fillRect(0, 0, 128, TITLE_HEIGHT, WHITE);
display.setTextColor(BLACK);
display.setCursor(2, 2);
String displayPath = currentPath;
if (displayPath.length() > 20) { // 路径超长截断
displayPath = "..." + displayPath.substring(displayPath.length() - 17);
}
display.print(displayPath);
// 2. 绘制列表区(12~52px):文件/目录列表
display.setTextColor(WHITE);
if (totalFiles == 0) {
display.setCursor(25, 28);
display.print(F("(Empty)"));
} else {
int startIdx = scrollOffset;
int endIdx = min(scrollOffset + ITEMS_PER_PAGE, totalFiles);
for (int i = startIdx; i < endIdx; i++) {
int y = LIST_START_Y + (i - scrollOffset) * ITEM_HEIGHT;
// 选中项高亮:圆角矩形反色
if (i == selectedIdx) {
display.fillRoundRect(1, y, 123, ITEM_HEIGHT - 1, 2, WHITE);
display.setTextColor(BLACK);
} else {
display.setTextColor(WHITE);
}
// 绘制文件夹/文件图标
if (isDir[i]) {
drawFolderIcon(3, y + 1, (i == selectedIdx));
} else {
drawFileIcon(3, y + 1, (i == selectedIdx));
}
// 文件名超长截断:超过18字符显示前15+省略号
display.setCursor(13, y + 1);
String displayName = fileNames[i];
if (displayName.length() > 18) {
displayName = displayName.substring(0, 15) + "...";
}
display.print(displayName);
}
// 绘制滚动条(文件数>4时显示)
if (totalFiles > ITEMS_PER_PAGE) {
int barHeight = LIST_AREA_HEIGHT * ITEMS_PER_PAGE / totalFiles;
int barY = LIST_START_Y + (LIST_AREA_HEIGHT - barHeight) * scrollOffset / (totalFiles - ITEMS_PER_PAGE);
display.drawFastVLine(126, LIST_START_Y, LIST_AREA_HEIGHT, WHITE);
display.fillRect(125, barY, 3, barHeight, WHITE);
}
}
// 3. 绘制状态栏(53~63px):文件统计+剪贴板状态
drawStatusBar();
display.display(); // 一次性刷新屏幕(双缓冲第二步)
}
先在缓冲区绘制所有内容,再
display()刷新,避免逐元素绘制导致的闪烁
3.4 高效复制算法
cpp
void doPaste() {
if (clipPath == "") {
showPopup("Empty Clip");
return;
}
// 提取文件名+构建目标路径
int slashIdx = clipPath.lastIndexOf('/');
String fName = clipPath.substring(slashIdx + 1);
String destPath = getFullPath(fName);
// 检查目标文件是否存在
if (SD.exists(destPath)) {
Serial.println(F("[错误] 文件已存在"));
showPopup("File Exists!");
return;
}
// 打开源文件/目标文件
File src = SD.open(clipPath, FILE_READ);
if (!src) { showPopup("Src Error"); return; }
unsigned long fileSize = src.size();
File dest = SD.open(destPath, FILE_WRITE);
if (!dest) { src.close(); showPopup("Write Error"); return; }
// 显示进度界面
display.clearDisplay();
display.fillRect(20, 25, 88, 14, BLACK);
display.drawRect(20, 25, 88, 14, WHITE);
display.setCursor(28, 29);
display.setTextColor(WHITE);
display.print(F("Copying..."));
display.display();
// 核心:128字节缓冲区(原64字节)
byte buf[128];
unsigned long copied = 0;
unsigned long lastUpdate = 0;
Serial.print(F("[复制] 大小: "));
Serial.print(fileSize);
Serial.println(F(" 字节"));
while(src.available()) {
int n = src.read(buf, sizeof(buf));
dest.write(buf, n);
copied += n;
// 进度条每1KB刷新一次(原512字节):减少50%刷新次数
if (copied - lastUpdate >= 1024 || !src.available()) {
int percent = (copied * 100) / fileSize;
display.fillRect(22, 35, 84, 3, BLACK);
display.fillRect(22, 35, percent * 84 / 100, 3, WHITE);
display.display();
lastUpdate = copied;
}
}
dest.close();
src.close();
// 剪切模式:复制完成后删除源文件(原子性)
if (isCutOperation) {
SD.remove(clipPath);
clipPath = "";
isCutOperation = false;
}
refreshDir();
showPopup("Paste OK!");
}
性能测试数据
| 文件大小 | 优化前(64 字节缓冲区) | 优化后(128 字节缓冲区) | 提升幅度 |
|---|---|---|---|
| 1KB | 0.5 秒 | 0.3 秒 | 40% |
| 10KB | 4.5 秒 | 2.5 秒 | 44.4% |
| 100KB | 45 秒 | 25 秒 | 44.4% |
3.5 文件系统抽象
cpp
void refreshDir() {
totalFiles = 0;
selectedIdx = 0;
scrollOffset = 0;
// 1: 重新初始化SD卡连接,确保状态正常
SD.begin(SD_CS_PIN);
delay(50); // 增加延迟确保SD卡初始化完成
File dir = SD.open(currentPath);
if (!dir) {
// 如果打开失败,重新初始化并重试
Serial.println(F("[警告] 目录打开失败,重试中..."));
SD.begin(SD_CS_PIN);
delay(100);
dir = SD.open(currentPath);
if (!dir) {
Serial.println(F("[错误] 无法打开目录"));
return;
}
}
// 2: 确保目录指针在开始位置
dir.rewindDirectory();
delay(20);
File entry;
int retryCount = 0;
while(totalFiles < MAX_FILES) {
entry = dir.openNextFile();
if (!entry) {
// 3: 添加重试机制,防止提前结束
if (retryCount < 3) {
delay(10);
entry = dir.openNextFile();
retryCount++;
if (!entry) continue;
} else {
break; // 重试3次后仍失败才真正退出
}
} else {
retryCount = 0; // 成功读取后重置重试计数
}
// 读取文件信息
String fname = String(entry.name());
// 4: 过滤无效文件名
if (fname.length() == 0 || fname.startsWith(".") || fname.startsWith("SYSTEM")) {
entry.close();
continue;
}
fileNames[totalFiles] = fname;
isDir[totalFiles] = entry.isDirectory();
fileSizes[totalFiles] = entry.size();
totalFiles++;
entry.close();
delay(5); // 每个文件读取后短暂延迟
}
dir.close();
delay(10);
// 排序:文件夹优先
for(int i = 0; i < totalFiles - 1; i++) {
for(int j = 0; j < totalFiles - 1 - i; j++) {
if(!isDir[j] && isDir[j+1]) {
swap(isDir[j], isDir[j+1]);
swap(fileNames[j], fileNames[j+1]);
swap(fileSizes[j], fileSizes[j+1]);
}
}
}
Serial.print(F("[加载] 路径: "));
Serial.print(currentPath);
Serial.print(F(" | 文件数: "));
Serial.println(totalFiles);
}
系统流程图

四、项目结果演示
4.1 操作流程
系统上电初始化,OLED 显示 "SD MANAGER",自动初始化 SD 卡,成功则加载根目录,失败显示 "SD FAILED!"
目录浏览
使用上下键选择文件/文件夹、单击确认键进入文件夹或打开文件菜单、双击确认键返回上级目录、右侧滚动条指示当前位置
文件操作
选中文件单击 ACTION 键打开菜单,支持文本/十六进制查看

复制 / 剪切、粘贴后显示进度条、删除弹窗提示结果

目录切换
双击 ACTION/BACK 键返回上级目录,根目录文件数量无丢失
4.2 视频演示
零知标准板SD卡文件管理系统完整功能
从系统启动开始,展示SD卡初始化和根目录浏览界面。展示完整的文件操作流程:创建新的TXT文件、复制文件、剪切文件、粘贴文件、删除文件。视频还展示了文本查看模式和十六进制查看模式的实际效果,以及操作过程中状态栏的实时更新
五、SD卡读写器技术讲解
SD 卡读写器采用SPI 通信协议(串行外设接口),是一种高速全双工的同步通信协议,适用于短距离、高速率的数据传输,核心特性
5.1 SD卡SPI模式转换

向SD卡发送 CMD0 (GO_IDLE_STATE) 命令,使其切换到SPI通信模式
SD卡的寄存器
SD卡总共有8个寄存器,用于设定或表示SD卡信息,通过对应的命令访问

SD卡定义了64个命令,每个命令都有特殊意义,可以实现某一特定功能, SD卡接收到命令后,根据命令要求对SD卡内部寄存器进行修改,程序控制中只需要发送组合命令就可以实现SD卡的控制以及读写操作
SPI 模式工作流程

5.2命令与响应机制
SD 卡的所有操作(初始化、读写、状态查询)均通过 "主机发送命令→卡返回响应" 的交互实现

读写操作都是由主机发起的,主机发送不同的命令表示读或写,SD卡接收到命令后先针对命令返回响应
命令格式
SD命令由主机发出,命令格式固定为48bit,都是通过CMD线连续传输的

命令的格式及功能
| 命令号 | 命令名称 | 项目中应用场景 | 代码底层作用 |
|---|---|---|---|
| CMD0 | GO_IDLE_STATE | SD 卡初始化(initSD()函数) |
复位 SD 卡进入空闲状态,准备接收后续命令 |
| CMD8 | SEND_IF_COND | 卡版本检测(initSD()底层) |
检测 SD 卡是否为 V2 版本,确认电压支持范围(本项目支持 3.3V) |
| ACMD41 | SEND_OP_COND | 卡初始化配置(initSD()底层) |
告知 SD 卡主机支持的电压范围,触发卡进入数据传输模式 |
| CMD17 | READ_SINGLE_BLOCK | 文本 / 十六进制查看(handleTextViewer()) |
读取单个 512 字节数据块(SDHC 卡固定块长) |
| CMD24 | WRITE_BLOCK | 文件创建 / 粘贴(createNewFile() doPaste()) |
写入单个 512 字节数据块,配合缓冲区实现文件存储 |
| CMD58 | READ_OCR | 卡类型检测(refreshDir()底层) |
读取 SD 卡操作条件寄存器(OCR),判断是 SDSC 卡(≤2GB)还是 SDHC 卡(>2GB) |
5.3 响应类型与错误处理
SD 卡接收命令后会返回响应帧,本项目通过SD.begin()的返回值、SD.exists()的判断逻辑,间接处理响应中的错误状态
R1 响应

用于指示工作状态,它是一个长度为1字节的响应,最高位固定为0,当其余位为1时,说明处于该位表示的状态中,主机根据该响应获得SD卡的反馈
数据响应Token
主机向SD卡写入数据时,每发送完一个数据块后,SD卡会返回一个数据响应Token

Token格式中的Status长度为3个数据位:010 数据被接受;101 因为CRC校验失败,数据被拒绝;110 因为写入错误,数据被拒绝
在单块读写以及多块读取的命令中,数据块前面的Token标志使用一个字节的"0xFE"表示数据块的开始,而在多块数据写入命令中, Token标志使用"0xFC"表示数据块的开始,并且以"0xFD"表示数据块的结束
六、常见问题解答(FAQ)
Q1:上电后 OLED 显示 "SD FAILED!" ?
*A:按以下步骤检查:*检查 SD 卡是否格式化为 FAT32、核对 SD 卡读写器的 SPI 接线正确、SD 卡读写器需 5V 供电
Q2:返回根目录后文件数量减少的原因?
*A:核心原因:*refreshDir()函数中目录遍历前未重置文件指针和清空缓存,导致 SD 库遍历根目录时漏读文件。增加dir.rewindDirectory()重置指针,遍历前清空文件信息数组
Q3:复制粘贴提示 "File Exists!"?
*A:目标目录已存在同名文件:*SD 库不允许覆盖写入,需先删除目标目录的同名文件,再执行粘贴操作
项目资源整合
SD 卡文件操作库: libraries/SD
OLED 驱动库: adafruit/Adafruit_SSD1306