【树莓派】GPIO驱动代码编写

阅读芯片手册
一、树莓派GPIO对应的寄存器介绍
二、代码的编写

三、代码整合

阅读芯片手册

由于我们要进行引脚驱动的开发,所以我们直接查看第六章的内容。

查看树莓派CPU型号
要编写对IO口进行操控,我们首先需要去阅读芯片手册,我使用的是树莓派 3B,所以查看的手册是BCM2835,查看CPU型号可以用这个指令来查看:

cat /proc/cpuinfo

在这里插入图片描述
具体的引脚也可通过官方手册查找

一、树莓派GPIO对应的寄存器介绍

Register View

在这里插入图片描述

注意:这里的地址是总线地址不是物理地址要分清

GPFSEL0是pin0 ~ pin9的配置寄存器,GPFSEL1是pin10 ~ pin19的配置寄存器,以此类推,GPFSEL5就是pin50~pin53的配置寄存器。

RegisterDescription
GPFSEL0GPIO Function Select 0: 功能选择输入或输出
GPSET0GPIO Pin Output Set 0: 输出0
GPSET1GPIO Pin Output Set 1: 输出1
GPCLR0GPIO Pin Output Clear 0: 清零

下图给出第九个引脚的功能选择示例,对寄存器的29-27进行配置,进而设置相应的功能。 根据图片下方的register 0表示0~9使用的是
register 0这个寄存器。
在这里插入图片描述

GPIO Pin Output Set Registers (GPSETn)

概要(SYNOPSIS):
输出集寄存器用于设置GPIO管脚。SET{n}字段定义,分别对GPIO引脚进行设置,将“0”写入字段没有作用。如果GPIO管脚为在输入(默认情况下)中使用,那么SET{n}字段中的值将被忽略。然而,如果引脚随后被定义为输出,那么位将被设置根据上次的设置/清除操作。分离集和明确功能取消对读-修改-写操作的需要。GPSETn寄存器为了使IO口设置为1,set4位设置第四个引脚,也就是寄存器的第四位
在这里插入图片描述

GPIO Pin Output Clear Registers (GPCLRn)

概要(SYNOPSIS):
输出清除寄存器用于清除GPIO管脚。CLR{n}字段定义要清除各自的GPIO引脚,向字段写入“0”没有作用。如果的在输入(默认),然后在CLR{n}字段的值是忽略了。然而,如果引脚随后被定义为输出,那么位将被定义为输出根据上次的设置/清除操作进行设置。分隔集与清函数消除了读-修改-写操作的需要。GPCLRn是清零功能寄存器
在这里插入图片描述

二、代码的编写

编写驱动程序时,首先要知道它的地址,IO口空间的起始地址是0x3f00 0000(文档的起始地址是错误的),加上GPIO的偏移量0x200 0000,所以GPIO的物理地址应该是0x3f20 0000开始的,然后在这个基础上进行Linux系统的MMU内存虚拟化管理,映射到虚拟地址上。

在编写Linux内核驱动程序时,了解硬件寄存器的物理地址以及进行适当的内存映射是至关重要的。在你的情况下,GPIO的物理地址起始于0x3f200000

以下是编写驱动程序时可能涉及的基本步骤,假设你的驱动程序与GPIO硬件进行交互:

  1. 获取GPIO的物理地址: 确定GPIO硬件寄存器的物理地址。在你的情况下,它应该是0x3f200000

  2. 内存映射: 在Linux内核中,可以使用 ioremap 函数将硬件寄存器的物理地址映射到内核虚拟地址空间。这个映射的过程负责使用MMU将物理地址映射到相应的虚拟地址上。

    #include <linux/io.h>
    
    // 在初始化驱动程序时进行内存映射
    void *gpio_base;
    
    static int __init mydriver_init(void) {
        gpio_base = ioremap(0x3f200000, sizeof(struct gpio_registers));
        if (!gpio_base) {
            printk(KERN_ERR "Failed to map GPIO registers\n");
            return -ENOMEM;
        }
    
        // 其他初始化代码
    
        return 0;
    }
    

    在上述示例中,sizeof(struct gpio_registers) 取决于你的GPIO硬件寄存器的大小。确保 ioremap 成功。

  3. 使用 GPIO 寄存器: 通过虚拟地址可以访问GPIO硬件寄存器。在访问时,确保使用 readlwritel 等函数来进行合适的内存读写操作。

    #include <asm/io.h>
    
    // 读取GPIO寄存器的值
    unsigned int value = readl(gpio_base + GPIO_REGISTER_OFFSET);
    
    // 写入GPIO寄存器的值
    writel(new_value, gpio_base + GPIO_REGISTER_OFFSET);
    

    这里的 GPIO_REGISTER_OFFSET 是相对于gpio_base的偏移量,表示你想要访问的特定GPIO寄存器。

  4. 内存解映射: 在驱动程序退出时,使用 iounmap 函数解映射已映射的内存区域。

    static void __exit mydriver_exit(void) {
        // 其他清理代码
    
        // 解映射GPIO寄存器的虚拟地址
        iounmap(gpio_base);
    }
    

这是一个简单的例子,具体的驱动程序可能需要更多的初始化和清理工作,取决于你的硬件和驱动的需求。确保在访问硬件寄存器时采取适当的同步和错误处理措施。

ioremap函数 将物理地址映射到内核的虚拟地址空间

在Linux内核编程中,ioremap 函数用于将物理地址映射到内核的虚拟地址空间。这个函数在处理驱动程序中需要与硬件进行直接交互的情况下非常有用,例如访问I/O端口、寄存器或其他硬件资源。

void *ioremap(resource_size_t offset, unsigned long size);
  • offset:要映射的物理地址的起始偏移量。
  • size:要映射的地址空间大小。

ioremap 返回映射后的虚拟地址,或者在映射失败时返回 NULL

以下是一个简单的示例,演示如何在内核中使用 ioremap

#include <linux/io.h>

void __iomem *my_mapped_address;

static int __init mydriver_init(void) {
    // 0x3f200000 是要映射的物理地址,sizeof(struct gpio_registers) 是要映射的大小
    my_mapped_address = ioremap(0x3f200000, sizeof(struct gpio_registers));

    if (!my_mapped_address) {
        pr_err("Failed to map GPIO registers\n");
        return -ENOMEM;
    }

    // 在这里可以使用 my_mapped_address 访问 GPIO 寄存器

    return 0;
}

static void __exit mydriver_exit(void) {
    // 在退出时解除映射
    iounmap(my_mapped_address);
}

module_init(mydriver_init);
module_exit(mydriver_exit);

在这个例子中,my_mapped_address 将保存 ioremap 返回的虚拟地址。一旦你完成对硬件的操作,记得使用 iounmap 来解除映射,以防止内存泄漏。

要注意的是,直接映射硬件寄存器可能会有一些风险,因为它涉及到对硬件资源的直接访问。在实际的驱动程序开发中,你可能需要采取一些额外的措施来确保对硬件的安全访问,如使用适当的同步机制和错误处理。

iounmap函数 解除通过ioremap函数映射关系

iounmap 是Linux内核提供的函数之一,用于取消对通过 ioremap 函数映射的物理地址的映射,即解除映射关系。这是在使用直接I/O访问硬件寄存器或设备内存时非常重要的一步。

函数原型如下:

void iounmap(volatile void __iomem *addr);
  • addr:通过 ioremap 映射的虚拟地址。

以下是一个简单的例子,演示了 ioremapiounmap 的使用:

#include <linux/io.h>

void *my_mapped_address;

void my_function(void)
{
    // ioremap 将物理地址映射到内核虚拟地址
    my_mapped_address = ioremap(0x3f200000, 4);

    // 在这里执行对硬件寄存器的读写操作,例如:
    unsigned int value = readl(my_mapped_address);
    writel(new_value, my_mapped_address);

    // 解除映射
    iounmap(my_mapped_address);
}

在上述示例中,ioremap 用于映射物理地址 0x3f200000 到内核虚拟地址,并且 iounmap 用于解除这个映射关系。这样,驱动程序在不再需要硬件寄存器的访问时,可以释放相应的内存映射。

copy_from_user函数 从用户空间复制数据到内核空间

copy_from_user 函数用于从用户空间复制数据到内核空间。在Linux内核编程中,它是一个关键的函数,因为它允许内核访问用户空间的数据。

函数原型如下:

unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);
  • to:目标缓冲区,即数据将被复制到该缓冲区。
  • from:源缓冲区,即数据将被复制自该缓冲区。
  • n:要复制的字节数。

返回值是未能复制的字节数。如果返回值为零,表示所有数据都成功复制。

下面是一个简单的例子,演示如何在内核中使用 copy_from_user

#include <linux/uaccess.h>

void my_kernel_function(void)
{
    char kernel_buffer[100];
    char __user *user_buffer = (char __user *)0x12345678;  // 示例用户空间缓冲区地址

    unsigned long bytes_not_copied = copy_from_user(kernel_buffer, user_buffer, sizeof(kernel_buffer));

    if (bytes_not_copied > 0) {
        // 处理未能复制的情况
        printk(KERN_ERR "Failed to copy %lu bytes from user space\n", bytes_not_copied);
    } else {
        // 成功复制数据
        printk(KERN_INFO "Data successfully copied from user space\n");
    }
}

在这个例子中,copy_from_user 函数将尝试从用户空间复制数据到内核缓冲区。如果有字节无法复制,将会打印错误消息。这是一种在内核中从用户空间获取数据的常见方式。在实际的驱动程序或内核模块中,你可能会在读取文件或处理系统调用等情况下使用此函数。

copy_to_user函数 将数据从内核空间复制到用户空间

copy_to_user 函数是Linux内核中的一个函数,用于将数据从内核空间复制到用户空间。这个函数通常在驱动程序中用于将内核中的数据传递给用户空间的应用程序。

函数原型如下:

unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);
  • to:目标用户空间缓冲区,即数据将被复制到该缓冲区。
  • from:源内核空间缓冲区,即数据将被复制自该缓冲区。
  • n:要复制的字节数。

返回值是未能复制的字节数。如果返回值为零,表示所有数据都成功复制。

以下是一个简单的例子,演示如何在内核中使用 copy_to_user

#include <linux/uaccess.h>

void my_kernel_function(void)
{
    char kernel_buffer[100];
    char __user *user_buffer = (char __user *)0x12345678;  // 示例用户空间缓冲区地址

    // 在这里填充内核缓冲区的数据

    unsigned long bytes_not_copied = copy_to_user(user_buffer, kernel_buffer, sizeof(kernel_buffer));

    if (bytes_not_copied > 0) {
        // 处理未能复制的情况
        printk(KERN_ERR "Failed to copy %lu bytes to user space\n", bytes_not_copied);
    } else {
        // 成功复制数据
        printk(KERN_INFO "Data successfully copied to user space\n");
    }
}

在这个例子中,copy_to_user 函数将尝试将数据从内核缓冲区复制到用户空间。如果有字节无法复制,将会打印错误消息。这是一种在内核中向用户空间传递数据的常见方式。在实际的驱动程序或内核模块中,你可能会在写入文件或处理系统调用等情况下使用此函数。

在这里插入图片描述
上图尾部的偏移量是正确的,根据gpio的物理地址0x3f200 0000得到

GPFSEL0 0x3f20 0000 //IO口的初始的物理地址,而并不是手册里面的那个总线地址
GPSET0 0x3f20 001c  //地址通过查找芯片手册里面的对应的GPSET0 的总线地址的后两位决定是1c
GPCLR0 0x3f20 0028 //地址是查找GPCLR0在芯片手册里的总线地址确定的28,所以地址后两位是28

1. 首先在原来的驱动框架上添加寄存器的定义

volatile unsigned int* GPFSEL0 = NULL;
volatile unsigned int* GPSET0  = NULL;
volatile unsigned int* GPCLR0  = NULL;

volatile关键字的作用:确保指令不会因编译器的优化而省略,且要求每次直接读值,在这里的意思就是确保地址不会被编译器更换。

2. 然后在pin4_drv_init这个函数里面添加寄存器地址的配置

GPFSEL0 = (volatile unsigned int *)ioremap(0x3f200000, 4);
GPSET0  = (volatile unsigned int *)ioremap(0x3f20001C, 4);
GPCLR0  = (volatile unsigned int *)ioremap(0x3f200028, 4);

ioremap将物理地址转换为虚拟地址

我们前面讲到了在内核里代码和上层代码访问的是虚拟地址(VA),而现在设置的是物理地址,所以必须把物理地址转换成虚拟地址

3. 配置引脚4为输出引脚,为了不影响其他引脚,需要使用与运算或运算。

按位运算符、逻辑运算符
在这里插入图片描述
根据图片可知14-12bit需配置成001.

31 30 ······14 13 12 11 10 9 8 7 6 5 4 3 2 1 
0    0  ······ 0   0   1   0   0  0 0 0 0 0 0 0 0 0
 
 //配置pin4引脚为输出引脚      bit 12-14  配置成001  
  *GPFSEL0 &= ~(0x6 << 12); // 把bit13 、bit14置为0  
 //0x6是110  <<12左移12位 ~取反 &按位与
  *GPFSEL0 |= (0x1 << 12); //把12置为1   |按位或

4. 让引脚拉高

if (userCmd == 1) {
        printk("set 1\n");
        *GPSET0 |= (0x1 << 4); 
       // 写1左移4位是让寄存器    开启置1  让bit4为高电平
    }
    else if (userCmd == 0) {
        printk("set 0\n");
        *GPCLR0 |= (0x1 << 4); 
     // 写1左移4位是让清0寄存器 开启置0 让bit4为低电平
    }
    else {
        printk("nothing undo\n"); 
    }

补充:ioremap用法

开始映射:void* ioremap(unsigned long phys_addr, unsigned long size, unsigned long flags)
// 用map映射一个设备意味着使用户空间的一段地址关联到设备内存上,这使得只要程序在分配的地址范围内进行读取或写入,实际上就是对设备的访问。
phys_addr:要映射的起始的IO地址
size:要映射的空间的大小
flags:要映射的IO空间和权限有关的标志
 
第二个参数怎么定?
这个由你的硬件特性决定。
比如,你只是映射一个32位寄存器,那么长度为4就足够了。
(这里树莓派IO口功能设置寄存器、IO口设置寄存器都是32位寄存器,所以分配四个字节就够了)
 
比如:GPFSEL0 = (volatile unsigned int *)ioremap(0x3f200000, 4);
      GPSET0 = (volatile unsigned int *)ioremap(0x3f20001C, 4);
      GPCLR0 = (volatile unsigned int *)ioremap(0x3f200028, 4);
这三行是设置寄存器的地址,volatile的作用是作为指令关键字
确保本条指令不会因编译器的优化而省略,且要求每次直接读值
ioremap函数将物理地址转换为虚拟地址,IO口寄存器映射成普通内存单元进行访问。
 
解除映射:void iounmap(void* addr)// 取消ioremap所映射的IO地址
比如:
        iounmap(GPFSEL0);
        iounmap(GPSET0);
        iounmap(GPCLR0); // 卸载驱动时释放地址映射

函数copy_from_user用法

 函数copy_from_user原型:
 copy_from_user(void *to, const void __user *from, unsigned long n)
 
返回值:失败返回没有被拷贝成功的字节数,成功返回0
参数详解:
1. to 将数据拷贝到内核的地址,即内核空间的数据目标地址指针
2. from 需要拷贝数据的地址,即用户空间的数据源地址指针
3. n 拷贝数据的长度(字节)
也就是将@from地址中的数据拷贝到@to地址中去,拷贝长度是n

三、代码整合

驱动代码

#include <linux/fs.h>         // file_operations声明
#include <linux/module.h>     // module_init  module_exit声明
#include <linux/init.h>       // __init  __exit 宏定义声明
#include <linux/device.h>     // class  devise声明
#include <linux/uaccess.h>    // copy_from_user 的头文件
#include <linux/types.h>      // 设备号  dev_t 类型声明
#include <asm/io.h>           // ioremap iounmap的头文件

static struct class *pin4_class;
static struct device *pin4_class_dev;

static dev_t devno;                // 设备号
static int major = 231;             // 主设备号
static int minor = 0;               // 次设备号
static char *module_name = "pin4";  // 模块名--这个模块名到时候是在树莓派的/dev底下显示相关驱动模块的名字

//volatile关键字的作用:确保指令不会因编译器的优化而省略,且要求每次直接读值,在这里的意思就是确保地址不会被编译器更换
volatile unsigned int *GPFSEL0 = NULL;
volatile unsigned int *GPSET0 = NULL;
volatile unsigned int *GPCLR0 = NULL;

// led_open函数
static int pin4_open(struct inode *inode, struct file *file)
{
    printk("pin4_open\n");  // 内核的打印函数和printf类似    
    
    // 由于pin4在 14-12位,所以将14-12位分别置为001即为输出引脚,所以下面的那两个步骤分别就是将14,13置为0,12置为1
    *GPFSEL0 &= ~(0x6 << 12); // 把13,14位 置为0
    *GPFSEL0 |= (0x1 << 12);  // 把12位 置为1 
    
    return 0;
}

// led_write函数
static ssize_t pin4_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos)
{
    int userCmd;
    int copy_cmd;
    
    printk("pin4_write\\n");
    
    // copy_from_user(void *to, const void __user *from, unsigned long n)
    
    copy_cmd = copy_from_user(&userCmd, buf, count); // 函数的返回值是,如果成功的话返回0,失败的话就是返回用户空间的字节数
    
    if (copy_cmd != 0)
    {
        printk("fail to copy from user\n");
    }

    if (userCmd == 1)
    {
        printk("set 1\n");
        *GPSET0 |= (0x1 << 4); // 这里的1左移4位的目的就是促使寄存器将电平拉高,即变为HIGH
    }
    else if (userCmd == 0)
    {
        printk("set 0\n");
        *GPCLR0 |= (0x1 << 4); // 这里的1左移4位也是一样只是为了让寄存器将电平拉低,即变为LOW
    }
    else
    {
        printk("nothing undo\n"); 
    }
    
    return 0;
}

static ssize_t pin4_read(struct file *file, const char __user *buf, size_t count, loff_t *ppos)
{
    printk("pin4_read\n");
    return 0;    
}

static struct file_operations pin4_fops = {
    .owner = THIS_MODULE,
    .open  = pin4_open,
    .write = pin4_write,
    .read  = pin4_read,
};

int __init pin4_drv_init(void)   // 设备驱动初始化函数(真实的驱动入口)
{
    int ret;
    
    devno = MKDEV(major, minor);  // 创建设备号
    ret = register_chrdev(major, module_name, &pin4_fops);  // 注册驱动  告诉内核,把这个驱动加入到内核驱动的链表中
 
    pin4_class = class_create(THIS_MODULE, "myfirstdemo"); // 这个是让代码在/dev目录底下自动生成设备,自己手动生成也是可以的
    pin4_class_dev = device_create(pin4_class, NULL, devno, NULL, module_name);  // 创建设备文件
    
    // 由于以下的地址全是物理地址,所以我们要将物理地址转换成虚拟地址 
    GPFSEL0 = (volatile unsigned int *)ioremap(0x3f200000, 4); // 由于寄存器是32位的,所以是映射4个字节,一个字节为8位
    GPSET0  = (volatile unsigned int *)ioremap(0x3f20001c, 4);
    GPCLR0  = (volatile unsigned int *)ioremap(0x3f200028, 4);
    
    return 0;
}

void __exit pin4_drv_exit(void)  // 卸载驱动,即将驱动从驱动链表中删除掉 
{
    iounmap(GPFSEL0);
    iounmap(GPSET0);
    iounmap(GPCLR0);
    
    device_destroy(pin4_class, devno);
    class_destroy(pin4_class);
    unregister_chrdev(major, module_name);  // 卸载驱动
}

module_init(pin4_drv_init);  // 真正的入口
module_exit(pin4_drv_exit);  // 卸载驱动
MODULE_LICENSE("GPL v2");

上层代码

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
    int fd;
    int userCmd;

    fd = open("/dev/pin4", O_RDWR);

    if (fd < 0)
    {
        printf("Failed to open /dev/pin4\n");
        perror("Reason: ");
    }
    else
    {
        printf("Successfully opened /dev/pin4\n");
    }

    printf("Please input 1 for HIGH or 0 for LOW:\n");
    scanf("%d", &userCmd);

    write(fd, &userCmd, sizeof(userCmd)); // 这里userCmd是一个整型数,所以写的是4个字节

    close(fd);

    return 0;
}

【树莓派】内核开发 说明 下载代码 编译 替换内核
【树莓派】高级开发概述、开发环境搭建(内核镜像拷贝至树莓派并启动新内核)

树莓派初始引脚

在这里插入图片描述

运行代码后

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • 21
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
基于STM32F103C8T6的UART驱动代码编写如下: ```c #include "stm32f10x.h" #define USART_RX_BUF_SIZE 128 uint8_t USART_RX_BUF\[USART_RX_BUF_SIZE\]; uint8_t USART_RX_CNT = 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) //接收中断 { uint8_t r = USART_ReceiveData(USART1); //读取接收到的数据 if (USART_RX_CNT < USART_RX_BUF_SIZE) { USART_RX_BUF\[USART_RX_CNT\] = r; USART_RX_CNT++; } USART_ClearITPendingBit(USART1, USART_IT_RXNE); } if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) //接收完成后进入空闲中断 { USART1->SR; //先读SR,再读DR才能完成idle中断的清零,否则会一直进入中断 USART1->DR; if (USART_RX_CNT < USART_RX_BUF_SIZE) { // 接收完成标志位 } else { // 升级程序标志 } USART_RX_CNT = 0; } } ``` 定时器驱动代码编写如下: ```c #include "stm32f10x.h" void TIM6_Configuration(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM6, ENABLE); TIM_TimeBaseStructure.TIM_Period = 1000 - 1; //定时器溢出时间为1ms TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; //定时器时钟频率为72MHz TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM6, &TIM_TimeBaseStructure); TIM_ITConfig(TIM6, TIM_IT_Update, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = TIM6_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); TIM_Cmd(TIM6, ENABLE); } void TIM6_IRQHandler(void) { if (TIM_GetITStatus(TIM6, TIM_IT_Update) != RESET) { // 定时器中断处理代码 TIM_ClearITPendingBit(TIM6, TIM_IT_Update); } } ``` 按键扫描驱动代码编写如下: ```c #include "stm32f10x.h" #define KEY_GPIO_PORT GPIOA #define KEY_GPIO_PIN GPIO_Pin_0 void Key_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = KEY_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(KEY_GPIO_PORT, &GPIO_InitStructure); } uint8_t Key_Scan(void) { if (GPIO_ReadInputDataBit(KEY_GPIO_PORT, KEY_GPIO_PIN) == RESET) { // 按键按下 return 1; } else { // 按键未按下 return 0; } } ``` 以上是基于STM32F103C8T6的UART驱动代码、定时器驱动代码和按键扫描驱动代码编写。请根据实际需求进行适当的修改和调整。 #### 引用[.reference_title] - *1* *2* [【嵌入式07.1】STM32F103C8T6开发板+CubeMX采用定时器实现周期性串口输出和LED闪烁](https://blog.csdn.net/qq_58869016/article/details/127582403)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insert_down28v1,239^v3^insert_chatgpt"}} ] [.reference_item] - *3* [基于STM32F103C8T6串口IAP升级](https://blog.csdn.net/weixin_43527703/article/details/129275189)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insert_down28v1,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

咖喱年糕

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值