? 本篇博客是笔者对于stm32存储器/寄存器映射,led流水灯实现学习的笔记。读者可根据需要进行选择性阅读。因为笔者为初学者,如果有差错请批评指正!不胜感激!
? 涉及的源码在点亮led灯与流水灯章节末尾。
存储器映射和寄存器映射原理
? 在计算机中,程序运行时将指令存入内存,当程序执行时,根据CP寄存器中的指令进行取指令操作,再将运行过程中产生的各种变量,常量存入内存。总之,程序的运行离不开内存。
在将编译好的程序烧录到stm32中后,程序执行时一样需要内存。单片机中封装着一个存储器。这个存储器就可以实现上述的功能。
存储器映射
? 如果你有过在计算机上编程的经验你就知道,计算机的最小内存单元是比特(0/1,bit),每八个比特组成一个字节(byte)。每个字节有自己的编号,叫做地址,用十六进制数表示,如:0x4095648a。对于单片机的存储器,它自己不具备地址信息,由芯片厂家或者用户分配。给存储器分配地址的过程就叫存储器映射。
? 如上图所示,以stm32指南者为例,它的存储器有4GB大,地址编号从0x00000000编号到0xffffffff(内存大小16的8次方比特,也就是2的32次方,除以1G,也就是2的30次方,得到4,就是4GB)。这个存储器每512M被分成一小块,一共八块,每一块有自己的名称与功能。下面挑常用的几块来讲解。
第一块:Block0
? 从0x00000000到0x1fffffff是第一块,用途是用来设计flash,flash就是用来存储我们烧录的代码的。由上图可知,在Block0中又被分割成很多小块,这些小块的功能也各不相同。具体功能可以查阅野火官方的《零死角玩转STM32—F103指南者》,我们着重了解flash。
? flash地址范围是0x08000000到0x0807ffff,大小为512kb,用于存储bin文件。当我们烧录的hex文件大小大于512kb时,程序不一定会出错。对于这一点,我参考了博客https://blog.csdn.net/he__yuan/article/details/78036073。
总之你要记住的就一点:烧录的代码存在512kb的flash中!
第二块:Block1
? block1作为第二个内存块,地址从0x20000000到0x3fffffff,用于设计SRAM。由上图可知,Block1又被划分成两部分:一部分64kb大,它就是SRAM,通俗地讲叫做“运存”,程序运行时栈帧,变量就是基于它开销;剩下的部分时预存部分。
总之你要记住的就一点:指南者单片机运存64kb,在Block1中!
第三块:Block2
? block2作为第三个内存块,地址从0x40000000到0x5fffffff,用于设计外设。
? 什么是单片机的外设?
? 在stm32芯片里面封装着很多对象,可以分为两部分:内核与外设。内核是单片机的CPU,除此之外的东西就是外设,例如串口,GPIO口(暂时理解为引脚)等等。
? 在实际的开发过程中,我们常常会通过对某些内存的值进行操作,来操纵单片机工作。一个简单的例子:
//只是例子,真的代码不是这样的
LED1 = 0 //LED1是系统设置的变量名,对标Block2中某个地址。当它里面数据为0时点亮第一个led,否则不亮
? 这段代码虽然与实际情况不太一样,但可大致说明我们编程的时候是什么样子。就是对Block2中的某些地址通过系统写好的变量名进行访问,修改其中的数据。单片机运行时根据相应地址中的值执行不同操作。
总之你要记住的就一点:Block2里面的东西与单片机各种器件,外设有关;我们打代码操纵这些地址中的数据来操纵单片机。
寄存器映射
? 在上文介绍Block2的时候以及说过,我们要操纵单片机,就要编写代码对Block2中的对应地址中的值进行修改。
? 在Block2中,每4个字节(32位)为一个单元(一个外设的操纵区)。在每个单元中,又被分为不同的功能模块:例如从第一位到第二位表示输出速率,第三位到第四位表示工作模式…第三十一到第三十二位又表示什么什么什么。
? 设想一下,假设地址0x4001 0C0C开始对应的这个单元是我们要操纵的。假设对于这个单元,我们的需求是要将它的低16位置1,即将地址0x4001 0C0C里的数据设置为0xffff。我们首先想到的可以这样写:
*(unsigned int*)(0x4001 0C0C) = 0xffff
? 若使高16位不变,就这样写:
*(unsigned int*)(0x4001 0C0C) |= 0xffff
? 看得出来,这样写非常麻烦。要先写地址的数值,再转换成指针,再*操作。对于很多编程功底薄弱的人来说,这种写法非常地不友好。
? 我们可以这样写:
#define LED1_SETTING *(unsigned int*)(0x4001 0C0C)
LED1_SETTING = 0xffff//等价前面的代码
? 显而易见,这种编写方式不用在意地址数值,只需要记住stm32给它们取的名字,以及它们内部每一位的功能就行。
? 下面是一个例子:
//假设内存单元SETTING的低十六位表示A的工作速率,高十六位表示B的工作速率。
//这段代码让A速率为0,B速率为1
SETTING = 0x00010000//高十六位为1,低十六位为0
//这段代码让A速率为256,B速率为512
SETTING = 0x0200 0100//高十六位为512(0x200),低十六位为256(0x100)
? 但是每次调用写这么一个define语句,依旧很麻烦。可以建立一个头文件stm32f10x.h,将多个地址与别名写在里面,每个项目引用一下就行。一劳永逸。
/*本文件用于添加寄存器地址及结构体定义*/
/*片上外设基地址 */
#define PERIPH_BASE ((unsigned int)0x40000000)
/*APB2 总线基地址 */
#define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)
/* AHB总线基地址 */
#define AHBPERIPH_BASE (PERIPH_BASE + 0x20000)
/*GPIOB外设基地址*/
#define GPIOB_BASE (APB2PERIPH_BASE + 0x0C00)
/* GPIOB寄存器地址,强制转换成指针 */
#define GPIOB_CRL *(unsigned int*)(GPIOB_BASE+0x00)
#define GPIOB_CRH *(unsigned int*)(GPIOB_BASE+0x04)
#define GPIOB_IDR *(unsigned int*)(GPIOB_BASE+0x08)
#define GPIOB_ODR *(unsigned int*)(GPIOB_BASE+0x0C)
#define GPIOB_BSRR *(unsigned int*)(GPIOB_BASE+0x10)
#define GPIOB_BRR *(unsigned int*)(GPIOB_BASE+0x14)
#define GPIOB_LCKR *(unsigned int*)(GPIOB_BASE+0x18)
/*RCC外设基地址*/
#define RCC_BASE (AHBPERIPH_BASE + 0x1000)
/*RCC的AHB1时钟使能寄存器地址,强制转换成指针*/
#define RCC_APB2ENR *(unsigned int*)(RCC_BASE+0x18)
? 注意的是,上面的头文件仅仅为一小部分单元起了名字。要根据手册和官方指南查阅自行添加。
与51单片机不同,51单片机提供了一个头文件,里面已经有了所有的寄存器映射。stm32没有提供这种头文件,需要自己制作。
最好保存一下上面的头文件,下面做实验项目要用到。
? 那什么是寄存器映射呢?根据功能,给内存单元分配别名,这就叫寄存器映射。所谓的寄存器,其实就是取的别名。
? 详细的讲解再野火的官方教程里面写的很详细,这里不再说明。
使用野火指南者实现流水灯
? 什么是流水灯?想象一下你有多个led灯,让它们逐个亮起来,先亮第一个;再熄灭,点亮第二个;以此类推,点亮最后一个时,熄灭后再重新点亮第一个,这就叫流水灯。
? 在单片机学习中,点亮一个led相当于单片机的“Hello,World”程序,流水灯只是一个稍微进阶版。
? 如果你以前学过51单片机,那么你应该不会感到陌生。在51单片机中有8个LED灯,当P2(51单片机的一个内存单位,里面是8位,一个字节)的第i位为0时,第i个LED就会亮。因此在51单片机中,点亮LED与实现流水灯都非常简单:
//点灯实验
P2_0 = 0 //点亮第0个LED灯
//流水灯实验
int i;
unsigned char u = 0x01;
//将u取反后赋值给P2,例如00000001取反赋值11111110;每次点灯后将1左移一位再取反赋值
for (i = 1 , P2 = ~u ; i <= 8 ; P2 = ~(u<<i),i++){
Delay1000ms();//这个函数要自己实现
}
//以上代码是main函数全部内容,单片机会循环执行main
? 那么在stm32中是不是也是这样呢?很遗憾,不是。
? 在stm32中,实现流水灯有三个步骤:1、开启端口时钟,2、设置GPIO输入输出模式,3、设置GPIO电平控制灯亮灭。
? 在开始打代码之前,要知道一些相应的硬件信息。
LED灯
? LED灯叫做发光二极管,是一种在导通时会发光的二极管。当阳极电位高于阴极0.7V时,二极管导通发光。
以指南者为例,它的板子上有一个大的LED灯,里面封装了三个LED,颜色是红,绿,蓝。
? 上图是野火板子里面三个led灯的结构。由图所示,它们阳极接着3.3V电压,阴极接在PB5,PB0,PB1上。根据TTL协议,高电平是3.3V或者5V,低电平是0V。当某个阴极是低电平时,对应的二极管就会导通发亮。
GPIO口
? GPIO口就是输入输出端口,是可控制的芯片引脚。通过内存单位名称访问内存地址,可以设置每个引脚的端口的输入输出模式以及输出的高低电平。
? GPIO口有A,B,C,D,E五组,每组16个引脚。
? 最基本的功能就是控制输出高低电平,实现类似“开关”的功能。就像上图LED灯的阴极,三个LED阴极就是由B组第5个,第0个,第1个GPIO口决定。
工作模式
? GPIO口一共有八种工作模式,可分为两类。通过端口配置寄存器来为GPIO设置工作模式。
? 使用端口配置寄存器的时候,你要确认你要操纵的GPIO口编号是哪一组,并且是0-7号还是8-15号。
配置寄存器名字为GPIOx_CR(H/L),x表示你的GPIO组号(A-E),位于该组的编号小于8时,最后一个字母为L,否则为H。
? 根据GPIO口组号编号确定配置寄存器名字后,再根据编号确定它的配置信息在寄存器中的位置。文字描述较为复杂,请仔细看图。
? 例如我要开LED灯,将B组端口的第0个端口配置成“推挽输出模式”,输出速率为10MHz。
? 那么根据上图,就是把GPIOB_CRL最低四位写成0001。
? 代码这样写:
GPIOB_CRL = 0x1 //这段代码将最低位置1,最低2位表示PB0输出速率,01表示10MHz;第3-4位是00,表示推挽输出
? 详细的配置过程图中已经详细给出。配置时注意A-E组号,以及0-7,8-15编号。
? 下面介绍各种工作模式。
输入模式
? 在输入模式时,输出被禁止,可通过输入数据寄存器GPIOx_IDR读取I/O状态(x为A/B/C/D/E)。其中输入模式,可设置为上拉、下拉、浮空和模拟输入四种。
? 所谓上拉和下拉就是说默认电平由上拉还是下拉决定;浮空表示输入电平不确定,一般接按键时使用;模拟用于ADC采集。
点灯不需要这个知识点,你可以跳过。
输出模式
? 输出模式有四种:推挽输出,开漏输出,复用推挽输出,复用开漏输出。
? 前两种输出模式没有使用复用,输出由输出数据寄存器GPIOx_ODR决定。在是输出模式中,引脚输出是高/低电平;开漏输出时,引脚输出为高阻态/低电平。
? 使用复用时,与前面两种不同之处是引脚输出不由GPIOx_ODR决定,由其他外设决定。
? 上图就是输出数据寄存器,X表示组号A-E。寄存器低16位的0/1表示该组在输出时引脚的低电平或者(高电平/高阻态)。只有非复用输出模式时,本寄存器才生效。
在本实验中,使用普通的推挽输出模式,即用B组输出数据寄存器GPIOB_ODR的对应位设置LED灯阴极低电平。
开启外设时钟
? 在前面的章节,笔者已经讲了配置GPIO的工作模式以及高低电平控制LED。
首先配置B组端口对应GPIO位为普通推挽输出模式,速度不是00(那就成输入模式了)就行,再用B组端口输出寄存器控制对应GPIO口成低电平。现在讲剩下的一步:开启外设时钟。
? 前面提到过,stm32中有很多外设,包括各种GPIO端口组,各种串口等等。这些外设为了减小功耗,在开启stm32单片机时是默认关闭的。要使用这些外设,例如点灯要用的B端口,你要在外设时钟寄存器RCC_APB2ENR中开启对应的外设。
将对应位设置1即可开启外设,B组端口的开关是图中标出来的
RCC_APB2ENR |= (1<<3);//其他位不变,将倒数第四位(B组端口)置1
? 至此,三个步骤全部讲完,现在可以点灯了。
点亮led灯
? 创建项目的过程可以去看笔者以前的博客,注意要给项目添加.c文件,以及添加CMSIS/CORE与DEVICE/STARTUP。
? 假设你已经创建了main.c文件,并引入了前面寄存器映射的头文件。我们按三个步骤一次进行。
? 1、开启外设时钟,这里先开启B组端口对应的时钟,位于时钟寄存器的倒数第4位。
RCC_APB2ENR |= (1<<3)
? 使用或运算,使其他位保持不变;1左移三位编程二进制的1000,与寄存器与运算,将倒数第四位置1,开启端口B。
? 2、设置GPIO口工作模式,这里以B组0号作为例子,设置为普通推挽输出(倒数第三第四位全0),设置输出速度为10MHz(最低二位为01)
? 根据前面对配置寄存器的分析我们知道,一个配置寄存器配置8位GPIO,每位占寄存器4位。最低4位用来配置0号(或者9号0),往左移4位就是配置1号(10号)。
GPIOB_CRL = 0x01;//最低四位0001,B组0号普通推挽输出+10MHz
可以将以上代码写成通式:
GPIOx_CRL = (1<<i)//将x组第i号配置为普通推挽输出+10MHz
GPIOx_CRH = (1<<i)//将x组第i+8号配置为普通推挽输出+10MHz
? 3、设置对应阴极引脚低电平
? 我们操纵的是B组0号,就在B组的输出寄存器中将最低位置0。
GPIOB_ODR &= ~1 //将最低位置0,其他不变
? 通式:
GPIOx_ODR &= ~(1<<i) //将倒数第i+1位置0,其他不变
? 完整代码如下:
#include "stm32f10x.h"
int main(){
//todo1.开启外设时钟
RCC_APB2ENR |= (1<<3);
//todo2.将B端口的低配置寄存器最低位置1,其他位全0
//这表示B组端口0普通推挽输出,以及输出速度10MHz
GPIOB_CRL = 0x01;
//todo3.B组输出寄存器,0号输出低电平
GPIOB_ODR &= ~1;
while(1);
}
? 注意这个头文件,内容在前面介绍寄存器映射的时候已经给出。
注意配置生成hex文件。编译生成hex文件后,烧录到stm32单片机中。这个程序改的是B组0号GPIO口,可以看看这个GPIO口对应的LED灯是什么颜色的。
? 可以看见B组0号的LED灯是绿色的。现在还有两个led灯:B组1号与B组5号。根据通式修改上面的代码:
#include "stm32f10x.h"
int main(){
//todo1.开启外设时钟
RCC_APB2ENR |= (1<<3);
//todo2.将B端口的低配置寄存器最低位置1,其他位全0
//这表示B组端口0普通推挽输出,以及输出速度10MHz
GPIOB_CRL = (0x01<<4);
//todo3.B组输出寄存器,0号输出低电平
GPIOB_ODR &= ~(0x01<<1);
while(1);
}
? 在GPIOB_CRL中一个GPIO口占4位,左移4位就是将相同的配置赋值给1号端口。再将GPIOB_ODR中1号口的相应位置0。
B组1号端口接着的LED灯是蓝色。现在还剩一个5号端口的LED灯。继续修改代码:
#include "stm32f10x.h"
int main(){
//todo1.开启外设时钟
RCC_APB2ENR |= (1<<3);
//todo2.将B端口的低配置寄存器最低位置1,其他位全0
//这表示B组端口0普通推挽输出,以及输出速度10MHz
GPIOB_CRL = (0x01<<4*5);
//todo3.B组输出寄存器,0号输出低电平
GPIOB_ODR &= ~(0x01<<5);
while(1);
}
? 原本代码是对0号端口进行的配置,在GPIOB_CRL中将初始配置左移4*i位并且在GPIOB_ODR中将初始配置左移i位就可以完成对该组第i号端口的配置。
? 由图可见,B组5号端口接的LED是红色的。
根据RGB模型,当不同颜色的LED灯一起亮的时候可以实现其他颜色。例如红+绿=黄,红+蓝=品红等等,这里不再演示。
源码如下:
main.c:
#include "stm32f10x.h"
int main(){
int i = 0//将B组第i个GPIO设置为低电平
//todo1.开启外设时钟
RCC_APB2ENR |= (1<<3);
//todo2.将B端口的低配置寄存器最低位置1,其他位全0
//这表示B组端口0普通推挽输出,以及输出速度10MHz
GPIOB_CRL = (0x01<<4*i);
//todo3.B组输出寄存器,0号输出低电平
GPIOB_ODR &= ~(0x01<<i);
while(1);
}
stm32f10x.h
/*本文件用于添加寄存器地址及结构体定义*/
/*片上外设基地址 */
#define PERIPH_BASE ((unsigned int)0x40000000)
/*APB2 总线基地址 */
#define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)
/* AHB总线基地址 */
#define AHBPERIPH_BASE (PERIPH_BASE + 0x20000)
/*GPIOB外设基地址*/
#define GPIOB_BASE (APB2PERIPH_BASE + 0x0C00)
/* GPIOB寄存器地址,强制转换成指针 */
#define GPIOB_CRL *(unsigned int*)(GPIOB_BASE+0x00)
#define GPIOB_CRH *(unsigned int*)(GPIOB_BASE+0x04)
#define GPIOB_IDR *(unsigned int*)(GPIOB_BASE+0x08)
#define GPIOB_ODR *(unsigned int*)(GPIOB_BASE+0x0C)
#define GPIOB_BSRR *(unsigned int*)(GPIOB_BASE+0x10)
#define GPIOB_BRR *(unsigned int*)(GPIOB_BASE+0x14)
#define GPIOB_LCKR *(unsigned int*)(GPIOB_BASE+0x18)
/*RCC外设基地址*/
#define RCC_BASE (AHBPERIPH_BASE + 0x1000)
/*RCC的AHB1时钟使能寄存器地址,强制转换成指针*/
#define RCC_APB2ENR *(unsigned int*)(RCC_BASE+0x18)
流水灯
? 已知在stm32中封装着3个RGB颜色的LED灯,前面已经介绍了LED灯点亮的方法。写在试着尝试一点高级的:流水灯。
? 实验现象:stm32中封装的三个灯依次点亮,点亮顺序是红->绿->蓝->黄->青->品红->白。
? 思路如下:
int main{
//开启B端口时钟
//配置B组0,1,5号GPIO口的工作模式,配置成普通推挽输出,速率不为0
for (int i = 0;i<7;i++){
如果是第一次,点红灯,延迟后熄灭
如果是第二次,点绿灯,延迟后熄灭
如果是第三次,点蓝灯,延迟后熄灭
如果是第四次,点红灯,点绿灯,延迟后熄灭
如果是第五次,点蓝灯,点绿灯,延迟后熄灭
如果是第六次,点蓝灯,点红灯,延迟后熄灭
如果是第七次,全部点亮,延迟后熄灭
}
}
? 为了实现本实验,要对之前点灯的代码做一点修改。
? 1、将点灯封装成函数,便于主函数阅读
void openRed(){
//将对应位置0,其他位不变
GPIOB_ODR &= ~(0x01<<5);
}
void openGreen(){
GPIOB_ODR &= ~(0x01<<0);
}
void openBlue(){
GPIOB_ODR &= ~(0x01<<1);
}
? 使用与运算是为了在修改某个位置为低电平时不影响其他位的电平状态,这样才能实现同时点亮多个灯。
? 2、延时函数与熄灭函数
void closeAll(){
//关灯,将所有引脚输出高电平
GPIOB_ODR = 0xffff;
}
void Delay_wxc( volatile unsigned int t)
{
unsigned int i;
while(t--)
for (i=0;i<800;i++);
}
? 关灯函数:输出寄存器仅使用到低16位,将它们全部置1,使三个led阴极电平全部抬高,led熄灭。
? 延时函数:参数传入20000时,大概为延时1秒。
3、主函数
int main(){
while(1){
int i = 0;
//todo1.开启外设时钟
RCC_APB2ENR |= (1<<3);
//todo2.配置相应GPIO工作模式
GPIOB_CRL = 0;
GPIOB_CRL |= (0x01<<4*5);
GPIOB_CRL |= (0x01<<4*0);
GPIOB_CRL |= (0x01<<4*1);
//todo3.执行流水灯
GPIOB_ODR = 0xffff;
for (i = 0; i < 7 ; i++){
if (i == 0) {openRed();Delay_wxc(10000);}
else if (i == 1) {openGreen();Delay_wxc(10000);}
else if (i == 2) {openBlue();Delay_wxc(10000);}
else if (i == 3) {openRed();openGreen();Delay_wxc(10000);}
else if (i == 4) {openGreen();openBlue();Delay_wxc(10000);}
else if (i == 5) {openRed();openBlue();Delay_wxc(10000);}
else if (i == 6) {openRed();openGreen();openBlue();Delay_wxc(10000);}
closeAll();
}
}
}
? 主函数与前面思路基本一直,但是有以下几点要注意:
1、在配置GPIO工作模式与开灯的时候,要注意不要影响其他无关位的数据。
2、在执行循环前,将B组所有GPIO置高电平,否则会导致第一次点灯出现白灯
? 完整代码如下:
? main.c:
#include "stm32f10x.h"
void openRed(){
//将对应位置0,其他位不变
GPIOB_ODR &= ~(0x01<<5);
}
void openGreen(){
GPIOB_ODR &= ~(0x01<<0);
}
void openBlue(){
GPIOB_ODR &= ~(0x01<<1);
}
void closeAll(){
//关灯,将所有引脚输出高电平
GPIOB_ODR = 0xffff;
}
void Delay_wxc( volatile unsigned int t)
{
unsigned int i;
while(t--)
for (i=0;i<800;i++);
}
int main(){
while(1){
int i = 0;
//todo1.开启外设时钟
RCC_APB2ENR |= (1<<3);
//todo2.配置相应GPIO工作模式
GPIOB_CRL = 0;
GPIOB_CRL |= (0x01<<4*5);
GPIOB_CRL |= (0x01<<4*0);
GPIOB_CRL |= (0x01<<4*1);
//todo3.执行流水灯
GPIOB_ODR = 0xffff;
for (i = 0; i < 7 ; i++){
if (i == 0) {openRed();Delay_wxc(1);}
else if (i == 1) {openGreen();Delay_wxc(1);}
else if (i == 2) {openBlue();Delay_wxc(1);}
else if (i == 3) {openRed();openGreen();Delay_wxc(1);}
else if (i == 4) {openGreen();openBlue();Delay_wxc(1);}
else if (i == 5) {openRed();openBlue();Delay_wxc(1);}
else if (i == 6) {openRed();openGreen();openBlue();Delay_wxc(1);}
closeAll();
}
}
}
? stm32f10x.h:
/*本文件用于添加寄存器地址及结构体定义*/
/*片上外设基地址 */
#define PERIPH_BASE ((unsigned int)0x40000000)
/*APB2 总线基地址 */
#define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)
/* AHB总线基地址 */
#define AHBPERIPH_BASE (PERIPH_BASE + 0x20000)
/*GPIOB外设基地址*/
#define GPIOB_BASE (APB2PERIPH_BASE + 0x0C00)
/* GPIOB寄存器地址,强制转换成指针 */
#define GPIOB_CRL *(unsigned int*)(GPIOB_BASE+0x00)
#define GPIOB_CRH *(unsigned int*)(GPIOB_BASE+0x04)
#define GPIOB_IDR *(unsigned int*)(GPIOB_BASE+0x08)
#define GPIOB_ODR *(unsigned int*)(GPIOB_BASE+0x0C)
#define GPIOB_BSRR *(unsigned int*)(GPIOB_BASE+0x10)
#define GPIOB_BRR *(unsigned int*)(GPIOB_BASE+0x14)
#define GPIOB_LCKR *(unsigned int*)(GPIOB_BASE+0x18)
/*RCC外设基地址*/
#define RCC_BASE (AHBPERIPH_BASE + 0x1000)
/*RCC的AHB1时钟使能寄存器地址,强制转换成指针*/
#define RCC_APB2ENR *(unsigned int*)(RCC_BASE+0x18)
? 实验现象:
参考资料
- 野火官方的《零死角玩转STM32—F103指南者》
- https://blog.csdn.net/he__yuan/article/details/78036073
|