CubeMX+STM32】SD卡U盘实现__USB+FATFS (F103 篇)

本篇,将使用CubeMX+Keil, 创建一个 USB + SD卡存储 + FatFS 的虚拟U盘读写工程。

约定:本篇所述SD卡,特指 TF卡。

目录

一、简述

[二、CubeMX 配置 (SDIO + DMA + FatFs + USB)](#二、CubeMX 配置 (SDIO + DMA + FatFs + USB))

[三、修正代码 bug](#三、修正代码 bug)

四、挂载FatFs、文件读写

五、添加USB支持、实现U盘模拟

六、实验效果


读写测试,实现效果:

U盘,识别效果:


一、简述

上几篇,已循序渐进讲解了SD、SDIO、基础读写、DMA读写、FatFs。

这里不再啰嗦,有兴趣的可翻看之前的篇章:

1、【CubeMX+STM32】SD卡 基础读写

2、【CubeMX+STM32】SD卡 DMA读写

3、【CubeMX+STM32】SD卡 文件系统 FatFs+SDIO+DMA

本篇,在之前的基础上,再向前一步:把SD卡模拟成U盘。

通过 CubeMX+Keil ,配置 SDIO+DAM+FatFs+USB,可以用代码进行文件读写,也可以插上USB线在电脑端进行文件读写。

对USB详细原理有兴趣的朋友,可参考大神的书籍《USB应用分析精粹》。

硬件要求 1:SD卡的接线、原画图

带SDIO的STM32芯片,都是一样的接法,不管是F1、F4。

对于第9脚:检测插入,文中另有详述。

​ ​

硬件要求 2:USB端口 接线、原理图

本篇所用电路, 如下图,也是STM32作为设备端的通用接法。

  • Type-C母座已连接STM32的PA11、PA12, 且PA12 (DP线) 经1.5K电阻上拉到3.3V。
  • 优点:电路简单;缺点:只能作设备端,不能作主机端。

注意:如果板子上有两个USB口,注意区分哪个是USB设备端口,哪个是USB转TTL口;

​​

软件要求 1:需要使用 CubeMX

新建工程部分,本篇略过,下面只讨论USB通信 + SD卡读写的实现 。

软件要求 2: 已配置好 printf

本篇示范,将会使用 printf 向电脑端串口助手输出调试信息,请提示为工程配置好UART1、printf重定向。

基础工程模板下载:STM32F103 工程模板 _ 已带 UART1+printf


二、CubeMX 配置 (SDIO + DMA + FatFs + USB)

1、启用 SDIO

  • Mode :工作模式。选择 SD 4 bits Wide bus , 即4位并行数据总线。
  • SDIOCLK Clock divide factor :SDIO 时钟分频系数。默认值是 0**, 改为:** 6 。

F103系列,SDIO时钟分频系数修改为 6 或 8,不然初始化会失败。F407系列可使用默认的 0 值。
​

​

2、SDIO 启用 DMA 传输

  • 点击 ADD 按钮,添加:SDIO。

F103系列,SDIO的 读、写 共用同一DMA通道。而F407则有RX、TX两个通道。

3、添加 FatFs 文件系统

  • **Mode:**打勾 SD Card
  • **参数 CODE_PAGE:Simplified Chinese GBK (**简体中文)
  • **参数 USE_LFN:Enabled with dynamic working buffer on the STACK (**长文件名称、缓存放在STACK,因此STACK得设置大一些,如>0x1000)

​​

4、配置 检测引脚

让我们先回看一下原理图:

​​SD卡座的第9脚,用于检测是否已插入SD卡。

如果已插入SD卡,CD脚会输出低电平。

不建议使用这个功能!

因为:SD卡与U盘不同,没有完善的保护电路,不应该进行热插拔!而且,做SD卡项目调试时,默认状态应该是一直插着SD卡的。如果项目有需要,也可以自己写几行引脚电平检测,当引脚电平为低时,再对SD卡进行操作,这样更灵活。

回到FatFs配置界面 ,下方配置里,可以设置是否使用检测引脚:

  1. 如果你的SD卡已连接检测引脚,想使用CubeMX生成管理,可以在这里指定引脚。
  2. 如果不需要这个检测功能,就让它默认空着即可。不管是否已连接此引脚,都可空着。

​​

CubeMX生成时注意:

当不指定检测引脚,在最后生成工程时,会有弹窗警告,不用管它,到时点击Yes即可,将会正常生成工程,代码上不会有任何影响。

如果不想有弹窗,可以随便指定一个空闲的引脚,这样在生成时就不会弹警告了,但是需要在生成的工程代码里,注释掉引脚检测功能。操作有点烦人,不建议此方法,如有需要,自行搜索操作的方法。

完成上面的配置后,工程已经能够使用代码进行:文件系统挂载、创建文件、读写文件 了。

5、启用 USB

6、启用 USB 类 (大容量存储)

​

7、配置中断、优先级

  • 系统时基 中断优先级,修改为 0 (如果不改,电脑端将无法识别成U盘)
  • SDIO global interrupt: 打勾
  • 修改下图四项的中断优先级; 中断值排序:Time base < SDIO < DMA < USB (值越小优先级越高)

​

8、时钟设置

进入时钟树配置页面,

如果工程没配置好SDIO、USB时钟,这时就会弹窗:是否自动配置所需时钟?

  • 选择:NO 。手动修改即可。

不推荐 Yes,因为它将针对已使能的SDIO、USB进行必须值的配置,而已设置好的系统时钟,将会被修改成其它值。

修改 USB 时钟分频系数:

  • 箭头所指 USB 分频系数:1.5

修改完成后,USB时钟是48MHz。

​​

9、修改 Stack 栈大小

  • Stack Size :0x1000 (三个零,也可以适当再大一些)

好了,SD卡 模拟成U盘 所需的 SDIO + FatFS + DMA + USB 已完成配置。

点击 生成工程,会有弹窗提示,因为我们没有指定SD卡的检测引脚。

点击 Yes 确认,继续生成即可!


三、修正代码 bug

CubeMX生成的SDIO初始化代码,有一个bug,需要手动修改,操作如下。

1、打开keil 工程,先重新编译一次。

  • 正常情况,编译是0 Error的。
  • 如果有Error, 应该是新建工程时,路径、名称有中文了,重新开建工程,改为英文即可。

2、重要修正:SD卡的初始化,需使用 1-bit 模式

  • 右击 main.c 文件中函数 MX_SDIO_SD_Init ( )
  • 弹出菜单,选中:Go To Ddfinition Of ...; 将跳转到SD卡初始化函数内部

跳转到 sdio.c文件的 MX_SDIO_SD_Init() 初始化函数内部后,

把下图位置中的 4B ,改为 1B ;

因为初始化时,需要低速率,1线通信即可。如果不修改,初始化过程会导致程序卡死。

++重要、重要、重要:CubeMX 每次重新生成后,这里都要手动修改一次++。


四、挂载FatFs、文件读写

SD卡的底层初始化、FatFs的初始化,CubeMX 已经帮我们生成好了。

我们使用时,只需调用相关函数实现对应功能即可。

1、FatFs挂载、文件读写

下面,我们编写一个示范函数,为程序挂载FatFs文件系统挂载,通过代码,读写SD卡内的文件。

大概操作流程如下:

  1. 判断是否需要格式化
  2. 格式化
  3. 挂载文件系统
  4. 创建文件
  5. 文件写入数据
  6. 文件读取数据

在**main()**的上方,/* USER CODE BEGIN 0 */ 注释块内,编写以下函数(建议直接复制)。

此函数已带详细中文注释、流程提示,通读两三次,即可理解SD卡的操作流程。

cpp 复制代码
/******************************************************************************
 * 函  数: FatFsTest
 * 功  能: SD卡的FatFs文件系统综合测试
 *          1、挂载:以读写方式挂载FatFs;若卡上没有文件系统(FR_NO_FILESYSTEM),则先格式化再重新挂载
 *          2、写入:打开(不存在则创建)SD卡根目录的 text.txt,写入测试字符串
 *          3、读取:重新打开 text.txt,读回数据并用printf打印
 * 参  数: 无
 * 返 回:  无; 挂载/格式化失败时直接return退出,错误信息经串口打印提示
 * 备  注: 1、测试会改写SD卡根目录的 text.txt; 若触发格式化,则整卡数据会被重建;
 *          2、FATFS/FIL/缓冲区等大对象均已用static修饰(存放在全局数据区),避免栈溢出;
 *          3、函数开头的HAL_Delay(1000)用于避开仿真器烧录期间的多次复位;
 *          4、测试结束后已用f_mount(NULL,...)取消挂载。
******************************************************************************/
void FatFsTest(void)
{
    static FATFS myFatFs;                                                 // FatFs 文件系统对象; 这个结构体占用598字节,有点大,需用static修饰(存放在全局数据区), 避免stack溢出
    static FIL myFile;                                                    // 文件对象; 这个结构体占用570字节,有点大,需用static修饰(存放在全局数据区), 避免stack溢出
    static FRESULT f_res;                                                 // 文件操作结果
    static uint32_t num;                                                  // 文件实际成功读写的字节数
    static uint8_t aReadData[1024] = {0};                                 // 读取缓冲区; 这个数组占用1024字节,需用static修饰(存放在全局数据区), 避免stack溢出
    static uint8_t aWriteBuf[] =  "测试; This is FatFs Test ! \r\n";      // 要写入的数据

    // 重要的延时:避免烧录期间的复位导致文件读写、格式化等错误
    HAL_Delay(50);                                                        // 重要:稍作延时再开始读写测试; 避免有些仿真器烧录期间的多次复位,短暂运行了程序,导致下列读写数据不完整。

    // 1、挂载测试:在SD卡挂载文件系统
    printf("\r\n\r\n");
    printf("1、挂载 FatFs 测试 ****** \r\n");
    f_res = f_mount(NULL, "0:", 1);                                       // 为了示范如何挂载,先注销(卸载)逻辑驱动器"0:", 因为CubeMX生成的底层代码在初始化时,为我们挂载了FatFs,并使用了0这个盘符。
    f_res = f_mount(&myFatFs, "0:", 1);                                   // 在SD卡上挂载文件系统; 参数:文件系统对象、驱动器路径、读写模式(0只读、1读写)
    if (f_res == FR_NO_FILESYSTEM)                                        // 检查是否已有文件系统,如果没有,就格式化创建创建文件系统
    {
        printf("SD卡没有文件系统,开始格式化......\r\n");
        f_res = f_mkfs("0:", 0, 0);                                       // 格式化SD卡; FatFs R0.11旧接口 f_mkfs(path, sfd, au):参数:驱动器路径、分区规则sfd(0-FDISK分区式,适用SD卡等存储卡)、簇大小au(0为自动选择); 格式化前必须先f_mount(x,x,1)读写方式挂载
        if (f_res == FR_OK)                                               // 格式化 成功
        {
            printf("SD卡格式化:成功 \r\n");
            f_res = f_mount(NULL, "0:", 1);                               // 格式化后,先取消挂载
            f_res = f_mount(&myFatFs, "0:", 1);                           // 重新挂载
            if (f_res == FR_OK)
                printf("FatFs 挂载成功 \r\n");                            // 挂载成功
            else
                return;                                                   // 挂载失败,退出函数
        }
        else
        {
            printf("SD卡格式化:失败 \r\n");                              // 格式化 失败
            return;
        }
    }
    else if (f_res != FR_OK)                                              // 挂载异常
    {
        printf("FatFs 挂载异常,返回值: %d\r\n", f_res);
        printf("检查1:是否已插入 TF 卡。\r\n");
        printf("检查2:MX_SDIO_SD_Init(), BusWide, 是否已修改为 1B 。\r\n");
        printf("检查3:MX_SDIO_SD_Init(), ClockDiv,是否已配置为 6。\r\n");

        return;
    }
    else                                                                  // 挂载成功
    {
        if (myFatFs.fs_type == 0x03)                                      // FAT32; 1-FAT12、2-FAT16、3-FAT32、4-exFat
            printf("SD卡已有文件系统:FAT32\r\n");
        if (myFatFs.fs_type == 0x04)                                      // exFAT; 1-FAT12、2-FAT16、3-FAT32、4-exFat
            printf("SD卡已有文件系统:exFAT\r\n");
        printf("FatFs 挂载成功 \r\n");                                    // 挂载成功
    }

    // 2、写入测试:打开或创建文件,并写入数据
    printf("\r\n");
    printf("2、写入测试:打开或创建文件,并写入数据 ****** \r\n");
    f_res = f_open(&myFile, "0:text.txt", FA_CREATE_ALWAYS | FA_WRITE);   // 打开文件; 参数:要操作的文件对象、路径和文件名称、打开模式;
    if (f_res == FR_OK)
    {
        printf("打开文件 成功 \r\n");
        printf("写入测试:");
        f_res = f_write(&myFile, aWriteBuf, sizeof(aWriteBuf), &num);     // 向文件内写入数据; 参数:文件对象、数据缓存、申请写入的字节数、实际写入的字节数
        if (f_res == FR_OK)
        {
            printf("写入成功  \r\n");
            printf("已写入字节数:%d \r\n", num);                         // printf 写入的字节数
            printf("已写入的数据:%s \r\n", aWriteBuf);                   // printf 写入的数据; 注意,这里以字符串方式显示,如果数据是非ASCII可显示范围,则无法显示
        }
        else
        {
            printf("写入失败 \r\n");                                      // 写入失败
            printf("错误编号: %d\r\n", f_res);                           // printf 错误编号
        }
        f_close(&myFile);                                                 // 不再读写,关闭文件
    }
    else
    {
        printf("打开文件 失败: %d\r\n", f_res);
    }

    // 3、读取测试:打开已有文件,读取其数据
    printf("3、读取测试:打开刚才的文件,读取其数据 ****** \r\n");
    f_res = f_open(&myFile, "0:text.txt", FA_OPEN_EXISTING | FA_READ);    // 打开文件; 参数:文件对象、路径和名称、操作模式; FA_OPEN_EXISTING:只打开已存在的文件; FA_READ: 以只读的方式打开文件
    if (f_res == FR_OK)
    {
        printf("打开文件 成功 \r\n");
        f_res = f_read(&myFile, aReadData, sizeof(aReadData), &num);      // 从文件中读取数据; 参数:文件对象、数据缓冲区、请求读取的最大字节数、实际读取的字节数
        if (f_res == FR_OK)
        {
            printf("读取数据 成功 \r\n");
            printf("已读取字节数:%d \r\n", num);                         // printf 实际读取的字节数
            printf("读取到的数据:%s\r\n", aReadData);                    // printf 实际数据; 注意,这里以字符串方式显示,如果数据是非ASCII可显示范围,则无法显示
        }
        else
        {
            printf("读取 失败  \r\n");                                    // printf 读取失败
            printf("错误编号:%d \r\n", f_res);                           // printf 错误编号
        }
    }
    else
    {
        printf("打开文件 失败 \r\n");                                     // printf 打开文件 失败
        printf("错误编号:%d\r\n", f_res);                                // printf 错误编号
    }

    f_close(&myFile);                                                     // 不再读写,关闭文件
    f_mount(NULL, "0:", 1);                                               // 不再使用文件系统,取消挂载文件系统
}

编写完成后,位置如下图:

2、SD卡 信息获取函数

在刚才函数的下方,编写一个SD卡信息获取函数(建议直接复制)。

这个函数的作用是:获取SD卡的基础信息、块数量 、块大小、卡容量。

此函数是额外的、非必须的函数,只用于示范。

cpp 复制代码
/******************************************************************************
 * 函  数: SDCardInfo
 * 功  能: 获取并打印SD卡信息:卡类型、卡版本、块数量、块大小、总容量
 * 参  数: 无
 * 返 回: 无
 * 备  注: 1、须在f_mount()挂载之后调用:FatFs的f_mount()内部才会完成SD卡的初始化,之前调用读不到有效信息;
 *          2、卡未插入或未就绪(状态非TRANSFER)时,本函数静默返回,不打印任何信息。
******************************************************************************/
void SDCardInfo(void)
{
    HAL_SD_CardInfoTypeDef pCardInfo = {0};                    // SD卡信息结构体
    uint8_t status = HAL_SD_GetCardState(&hsd);                // SD卡状态标志值
    if (status == HAL_SD_CARD_TRANSFER)
    {
        HAL_SD_GetCardInfo(&hsd, &pCardInfo);                  // 获取 SD 卡的信息
        printf("\r\n");
        printf("*** 获取SD卡信息 *** \r\n");
        printf("卡类型:%d \r\n", pCardInfo.CardType);         // 类型返回:0-SDSC、1-SDHC/SDXC、3-SECURED
        printf("卡版本:%d \r\n", pCardInfo.CardVersion);      // 版本返回:0-CARD_V1、1-CARD_V2
        printf("块数量:%d \r\n", pCardInfo.BlockNbr);         // 可用的块数量
        printf("块大小:%d \r\n", pCardInfo.BlockSize);        // 每个块的大小; 单位:字节
        printf("卡容量:%lluG \r\n", ((uint64_t)pCardInfo.BlockSize * pCardInfo.BlockNbr) / 1024 / 1024 / 1024);  // 计算卡的容量; 单位:GB
    }
}

3、在 main()函数内,调用刚才那两个函数

注意,这两个示范函数,都使用了 printf 输出调试信息,

因此,这两个函数的调用,必须放在UART1 初始化之后,并确保已做好 printf 重定向至 UART1 输出。

cpp 复制代码
FatFsTest();     // SD卡测试函数: FatFs文件系统挂、格式化、创建文件、读写文件
SDCardInfo();    // SD卡信息获取; 注意,本函数,必须在f_mount()执行之后再调用

调用位置,参考如下图:

至此,文件系统的测试编写完成!

4、阶段性测试

在继续添加 USB 功能之前,

我们先测试一下当前状态下工程的 文件系统 / 文件读写 是否正常使用。

  1. 板子断电,插入SD卡 / TF卡 (谨记,必须断电才能插拔卡)
  2. 打开串口助手
  3. 编译、烧录

程序运行后,如下图:FatFs挂载正常、读写文件正常 !!太好了,上面工作已顺丰验证。

串口助手效果:


五、添加USB支持、实现U盘模拟

现在,继续为工程添加USB的支持。

USB 枚举、通信等底层,CubeMX也生成好了。

我们只需在USB文件中,完成接口函数的实现。

1、 文件 sd_diskio.h,添加头文件引用

  • 先打开 sd_diskio.c ,文件空白位置右击,再跳转到头文件 sd_diskio.h

​

进入sd_diskio.h后, 可以看到:extern 这一行代码,带红色波浪线警告。

在约56行,extern 那一行之前,添加头文件引用:#include "ff_gen_drv.h"

**注意:**每次重新生成工程,刚添加的这一行,都会被CubeMX删除,需要在生成后重新写一次!

cpp 复制代码
#include  "ff_gen_drv.h"

添加后,extern这一行的红色波浪线就会消失!!

​

2、文件 usbd_storage_if.c ,接口实现

这个文件,CubeMX已生成了需要用到的接口空函数,目的是为了把USB大容量类的通信与SD卡的读写挂钩起来。

下面共需对 5处 位置作 修改、填充!

需要修改的位置,已用中文注释。没中文注释的,不用管,保持原样即可。

可对照修改,也可以直接复制替换原函数。

接口实现 5-1:

添加头文件引用

约在25行,

在/* USER CODE END INCLUDE */ 注释块内,添加:

cpp 复制代码
#include "sdio.h"          // 增加这行
#include "sd_diskio.h"     // 增加这行

完成后,位置如下图:

接口实现 5-2:

STORAGE_GetCapacity_FS() 函数,返回设备块数量、块大小

约在 195行。

STORAGE_GetCapacity_FS () 函数的核心作用是:响应 USB 主机(如 PC)发出的 SCSI ReadCapacity 命令,向主机报告你所模拟的"U盘"的总容量信息。即回复USB主机的询问:"你这个存储设备有多大?每个扇区是多少字节?"

  • 复制下面函数,替换原函数。也可以对照着注释旧代码、增加新代码行。
cpp 复制代码
int8_t STORAGE_GetCapacity_FS(uint8_t lun, uint32_t *block_num, uint16_t *block_size)
{
    /* USER CODE BEGIN 3 */
    // *block_num  = STORAGE_BLK_NBR;       // 注释掉
    // *block_size = STORAGE_BLK_SIZ;       // 注释掉

    *block_num = hsd.SdCard.BlockNbr;       // 增加这行; 获取SD卡的块数量
    *block_size = hsd.SdCard.BlockSize;     // 增加这行; 获取SD卡的块大小

    return (USBD_OK);
    /* USER CODE END 3 */
}

接口实现 5-3:

STORAGE_IsReady_FS ( ) 函数,返回设备当前是否就绪、可操作状态

约在 213行。

STORAGE_IsReady_FS() 函数的核心作用是向 USB 主机报告底层存储介质(如 SD 卡、NAND Flash)当前是否处于就绪、可操作的状态。

  • 复制下面函数,替换原函数。也可以对照着注释旧代码、增加新代码行。
cpp 复制代码
int8_t STORAGE_IsReady_FS(uint8_t lun)
{
    /* USER CODE BEGIN 4 */
    // return (USBD_OK);                  // 注释掉。不要总是返回 OK
        
    return (SD_Driver.disk_status(lun));  // 增加这行; 检查存储设备(如SD卡)指定逻辑单元的状态; 这行是非必需的,但增加后能更准确地反映设备的状态,避免在设备未准备好时进行读写操作,从而提高系统的可靠性和稳定性。
    
    /* USER CODE END 4 */
}

接口实现 5-4:

STORAGE_Read_FS() 函数,读取数据

约在 240行。

STORAGE_Read_FS 是 USB MSC 设备中真正执行数据读取的核心函数。

当主机(如 PC)需要从你的"U盘"里读取文件内容时,最终都会调用到这里。它的任务是将底层存储介质(SD卡、Flash等)中指定地址的数据,搬运到 USB 库提供的缓冲区中。

  • 复制下面函数,替换原函数。也可以对照着注释旧代码、增加新代码行。
cpp 复制代码
int8_t STORAGE_Read_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len)
{
    /* USER CODE BEGIN 6 */
    // return (USBD_OK);                                         // 注释掉。不要总是返回 OK
    
    return (SD_Driver.disk_read(lun, buf, blk_addr, blk_len));   // 增加这行
    
    /* USER CODE END 6 */
}

接口实现 5-5:

STORAGE_Write_FS () 函数,写入数据

约在 255行。

STORAGE_Write_FS () 是 USB MSC 数据通路的出口,负责将主机(PC)发来的数据写入底层存储介质。

它的角色与 STORAGE_Read_FS 正好相反:读函数是"取出",写函数是"存入"。

但它的实现远比读函数要复杂和"危险",因为它涉及擦除、对齐以及中断上下文限制等底层硬件的固有特性。

特别地:本篇所示范的写入处理,简单粗暴,没有保护机制。请自行按需调试,增加保护机制。

  • 复制下面函数,替换原函数。也可以对照着注释旧代码、增加新代码行。
cpp 复制代码
int8_t STORAGE_Write_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len)
{
    /* USER CODE BEGIN 7 */
    // return (USBD_OK);                                         // 注释掉。不要总是返回 OK
    
    return (SD_Driver.disk_write(lun, buf, blk_addr, blk_len));  // 增加这行
    /* USER CODE END 7 */
}

至此,USB通信需要实现的接口,都已修改好了。


六、实验效果

**约定:**本节所述 "USB线",特指 USB通信线 (PA11、PA12)!! 并非DAP烧录器的 USB线。

编译、烧录程序,串口助手数据如下图:

​​

插上USB线,稍等三四秒,电脑将会识别并弹出U盘,参考如下图。

电脑U盘识别效果:

重要:每次烧录程序后,需要手动插拔一次USB线,才能被重新识别。

​

如有错漏 ,望指正~~~!

相关推荐
jianqiang.xue37 分钟前
3倍向量算力:Arm给最小MCU装上本地AI
arm开发·人工智能·单片机
蜀黍@猿2 小时前
ARM 汇编入门(十二):MMU、页表与虚拟内存全解析
汇编·arm开发·stm32·mcu
无敌最俊朗-14 小时前
【电感专题2:电感的感抗、电路的阻抗、电流的滞后】
单片机·嵌入式硬件·元器件·电感
wuyk55519 小时前
嵌入式故障诊断-Bug002-局部大数组压栈高温必死机
stm32·单片机·嵌入式硬件·mcu·物联网·51单片机·嵌入式实时数据库
Zwawa1 天前
嵌入式OLED表驱动菜单:用一张表、一个栈和一个窗口重写表驱动菜单
c语言·单片机·嵌入式
Dev_XH1 天前
【成品设计】基于沁恒单片机的电动汽车充电站智慧调度系统
stm32·单片机·嵌入式硬件·物联网
shimly1234561 天前
(done) 嵌入式硬件电路图 (5) 重要的元器件特性之二极管三极管
嵌入式硬件
Zw-awa1 天前
嵌入式OLED表驱动菜单:用一张表、一个栈和一个窗口重写表驱动菜单
stm32
minglie11 天前
esp32c3+atshell测试ov7670
单片机·嵌入式硬件