今天是参加百问网7天智能家居项目的第二天,感谢黄老师的教学。通过今天的学习,再次温习了之前学习的基础知识,同时对MCU也有了新的理解和学习的方法。
单片机的最小系统是指用最少的电路组成单片机可以工作的电路,通常最小系统包含:电源电路,时钟电路,复位电路,调试/下载电路,对于STM32还需要启动电路。
下面以STM32F103C8T6进行分析最小电路
不同的MCU的工作电压可能是不一样的,比如51单片机通常为5V,而STM32单片机通常为3.3V。因此,通常需要查阅该MCU的数据手册才能确定工作电压和规范。如下图,在数据手册中找到“Power supply scheme”即是我们最小系统的电源电路
所以最小系统的电源是由VDD与VSS(VSS接地就是0V)决定的,那么我们找到对应的设置值如下:
所以VDD的范围是2V~3.6V,这里原理图设置的为3.3V符合要求。
MCU是一个集成芯片,由非常复杂的数字电路和其它电路组成, 需要稳定的时钟脉冲信号才能保证正常工作。时钟如同人体内部的心脏一样,心脏跳动一下,推动血液流动一下。时钟产生一次,就推动处理器执行一下指令。CPU,芯片上所有的外设(GPIO、 I2C、 SPI等)都需要时钟,由此可见时钟的重要性。
芯片运行的时钟频率越高,芯片处理的速度越快,但同时功耗也越高。为了功耗和性能兼顾,微处理器一般有多个时钟源,同时还将时钟分频为多个大小,适配不同需求的外设。
查看数据手册中的时钟树。如下
嵌入式系统中, 由于外界环境干扰, 难免出现程序跑飞或死机, 这时就需要复位让MCU重新运行。
查看数据手册,复位电路图如下
该电路将一个按键接在NRST引脚,一旦按键按下,NRST就会接地,拉低NRST,实现复位。
再分析原理图如下
当开发板正常工作时,3.3V上拉NRST,当K1被按下,NRST会导通接地,拉低NRST,使MCU复位。
不同的MCU,调试/下载的方式可能不一样。比如51系列单片机,使用串口下载程序,部分也使用串口仿真调试。对于STM32,可以使用串口下载程序,也可以使用串口打印的方式进行简单调试,如果想仿真单步调试,就需要JTAG( Joint Test Action Group)调试接口或SWD( Serial Wire Debug)调试接口。
以STM32F103_MIN这款开发板为例,支持串口和SWD下载。日常直接使用串口下载,无需额外设备,方便快捷。需要单步仿真调试时,使用SWD接口,功能强大。
串口自动下载电路涉及BOOT启动选择,当BOOT0和BOOT1引脚都为低电平时, MCU正常启动;当BOOT1引脚为低电平, BOOT0引脚为高电平时,进入串口下载模式( In System Programing, ISP)。
串口打印调试/下载电路如下所示
上电后, CH340G的RTS和DTR都输出高电平,电脑上位机软件控制DTR引脚输出低, PMOS管导通,电容C2充能, BOOT0逐渐变为高,此时三极管Q2导通,复位拉低, MCU复位。随后控制DTR输出高, PMOS管关闭, C2放电, BOOT1会保持一段时间高电平,此时三极管Q2截止, RESET上拉,开发板启动,进入串口下载模式。下载完后,跳到下载程序位置,运行下载程序。
此款开发板除了用于单步仿真调试/下载SWD接口,也可以作为ST-Link去调试下载其它SWD接口。把两个接口做在了一起,实现了两个板子直连,一个作为ST-Link去调试/下载,一个作为开发板被调试/下载。,原理图如下
嵌入式C语言和普通C语言在语法上几乎没有差别,其主要差别在于普通C语言的运行环境是OS之上,有很多的标准库函数支撑调用,分配的内存是电脑的内存,其处理器就是电脑的CPU;而在嵌入式环境中,会涉及到底层的硬件,而硬件本身是没有标准库可以调用的,因而就需要开发者使用C语言编程调试硬件,使其可以工作,对于开发某一款芯片,有针对的编译器(或者交叉编译环境),可以分配的内存则是芯片的RAM、 Flash,处理器则是芯片自身带的MCU,例如ARM、 DSP等。
例如C语言编程的入门课:打印“ Hello World!”,在普通C语言编程中,直接调用printf()函数即可在
PC上打印出;而在嵌入式中,则需要开发者使用C语言去将芯片的串口调试成功,然后将printf()函数重新实
现,方可调用打印。
嵌入式C语言的基本结构及其特点:
在C语言中,数据类型指的是用于声明不同类型的变量或函数的一个广泛的系统,变量的类型决定了变量存储占用的空间以及如何解释存储的位模式。
在嵌入式系统中,芯片的容量是有限的,且对比于PC机容量通常都是比较小的,因而了解变量所占用的存储空间是嵌入式开发者应当掌握的一项技能,所以对于不同数据类型在不同位数的芯片中(例如STM32xxx就表示此款芯片是32bit的芯片, STM8xxx表示此款芯片是8bit的芯片)的长度开发者也应该掌握。
C语言中的数据类型有以下几种:
就以STM32F103C8这一款芯片为例,这是一块32bit的MCU,基本数据类型在此款芯片中的数据长度,以及在HAL库函数中的定义( stdint.h文件中的定义,采用C99标准)如下图所示
C语言中const关键字是constant的缩写,译为常量、常数等,但const关键字不仅仅是用于定义常量,还可以用于修饰数组、指针、函数参数等。
C语言中使用const修饰变量,功能是对变量声明为只读特性,并保护变量值以防被修改。 例如:
const int i=10;
这个例子表明整形变量i具有只读性,不能够被修改;若想对其重新赋值,例如i=10则是错误的用法。需要注意的是, const定义变量的同时还必须对其初始化, const可以放在数据类型的前面或者后面,比如上述例子也可以写成:
int const i=10;
此外, const修饰变量还起到了节约空间的目的,通常编译器并不给普通const只读变量分配空间,而是将它们保存在符号列表中,无需读写内存操作,程序执行效率也会提高。
C语言中const还可以修饰数组,例如:
const int array[5]={0,1,2,3,4};
//或者
int const array[5]={0,1,2,3,4};
const关键字修饰数组与修饰变量类似,表明此数组具有只读性,不可修改,一旦被更改程序会出错。
C语言中const修饰指针需要特别注意,共有两种形式,一种是用来限定指向空间的值不可修改(意思是指着指向的内容只读);另一种是限定指针不可修改(意思是指向的地址不能变)。例如
int i=5;
int s=10;
int const *p=&i;
int *const q=&s;
对于指针p, const修饰的是* p,即p指向的空间的值不可改变,例如 * p= 20;就是错误的用法;但是p的值是可以改变的,例如p = & i;则没有任何问题。(p的地址所指向的内容不变)
对于指针q, const修饰的是q,即指针本身q不可更改,而指针指向空间的值是可以改变的,例如*q= 15;是没有问题的,而q = & s;则是错误的用法。(q所表示的地址不能变)
在C语言中const修饰函数参数对参数起限定作用,防止其在函数内部被意外修改,所限定的参数可以是普通变量也可以是指针变量,如:
void fun(const int a){
a=10;//对a的值进行了修改,错误
}
void fun2(const int *p){
(*p)++;//对p指向的内容进行了修改,错误
}
在了解static关键字的用法之前,我们需要先了解C语言中的作用域、局部变量和全局变量的概念。
一个C变量的作用域可以是块作用域、函数作用域、函数原型作用域或文件作用域。块是用一对花括号“ {}”括起来的代码区域,定义在块中的变量具有块作用域。块作用域的可见范围是从定义处到包含该定义的块的末尾。以前,具有块作用域的变量都必须声明在块的开头, C99标准放宽了这一限制,允许在块中的任意位置声明变量。
局部变量会在每次声明的时候被重新初始化(如果在声明的时候有初始化赋值),不具有记忆能力,其作用范围仅在某个块作用域可见;
全局变量只会被初始化一次,之后会在程序的某个地方被修改,其作用范围可以是当前的整个源文件或者工程;如果其他源文件想要调用此变量需要再文件内使用extern进行声明。
鉴于两种变量的局限性,就引入了静态变量(静态局部变量和静态全局变量),使用关键字static来修饰。其中静态局部变量满足局部变量的作用范围,但是其拥有记忆能力,不会在每次生命的时候都初始化一次,这个作用在用来实现计数功能的时候非常方便,例如:
void cnt(void){
static int num=1;
num++;
}
在这个函数中,变量num就是静态局部变量,在第一次进入cnt函数的时候被声明,然后执行自加操作,num的值就等于1;当第二次进入cnt函数的时候, num不会被重新初始化变成0,而是保持1,再自增则变成了2,以此类推, 其作用域仍然是cnt这个函数体内。
静态全局变量则将全局变量的作用域缩减到了只当前源文件可见,其它文件不可见。示例如下:
static int k=0;
void set_k(void){
k=1;
}
void reset_k(void){
k=0;
}
int get_k(void){
return k;
}
静态全局变量的优势是增强了程序的安全性和健壮性,因为对于变量k而言,我们假设我们不期望其它的文件有修改变量k的能力,但是其它的文件又需要变量k的值来进行逻辑运算,.
在C语言中static关键字除了用来修饰变量之外,还可以用来修饰函数,让函数仅在本文件可见, 其它文件无法对其进行调用。如果要让其他文件调用就使用extern关键字进行声明**。
在C语言中, extern关键
字用于指明函数或变量定义在其它文件中,提示编译器遇到此函数或者变量的时候到其它模块去寻找其定义,这样被extern声明的函数或变量就可以被本模块或其它模块使用。因而, extern关键字修饰的函数或者变量是一个声明而不是定义。
/* example.c */
uint16_t a = 0;
uint16_t max(uint16_t i, uint16_t j)
{
return ((i>j)?i:j);
}
/* main.c */
#include
extern uint16_t a;//使用的是example.c中的变量a.
extern uint16_t max(uint16_t i, uint16_t j);
void main(void)
{
printf("a=%d\r\n", a);
printf("Max number between 5 and 9: %d\r\n", max(5, 9));
}
extern关键字还有一个重要的作用,就是如果在C++程序中要引用C语言的文件,则需要用以下格式:
#ifdef __cplusplus
extern "C"{
#endif /* #ifdef __cplusplus */
......
#ifdef __cplusplus
}
#endif /* #ifdef __cplusplus */
这段代码的含义是,如果当前是C++环境( _cplusplus是C++编译器中定义的宏),要编译花括号{}里面的内容需要使用C语言的文件格式进行编译,而extern “C”就是向编译器指明这个功能的语句。
volatile原意是“易变的”,在嵌入式环境中用volatile关键字声明的变量,在每次对其值进行引用的时候都会从原始地址取值。由于该值“易变”的特性所以,针对其的任何赋值或者获取值操作都会被执行(而不会被优化)。由于这个特性,所以该关键字在嵌入式编译环境中经常用来消除编译器的优化,可以分为以下
三种情景:
设计程序最重要的一个步骤就是选择一个表示数据的好方法。在多数情况下,使用简单的变量甚至数组都是不够的。 C使用结构变量进一步增强了表示数据的能力。 C的结构的基本形式就足以灵活的表示多种数据,并且能够创建新的形式。
C的结构的声明格式如下:
struct [结构体名]
{
类型标识符 成员名 1;
类型标识符 成员名 2;
.
.
.
类型标识符 成员名 n;
};
在结构体声明中用一对花括号括起来的是结构体成员列表。每个成员都用自己的声明来描述。成员可以是任意一种C的数据类型, 甚至可以是其它结构。右花括号后面的分号是声明所必需的,表示该结构布局定义结束,例如:
struct students
{
char name[50];
char sex[50];
int age;
float score;
};
int main(void)
{
struct students student;
printf("Name: %s\t",student.name[0]);
printf("Sex: %s\t", student.sex);
printf("Age: %d\t", student.age);
printf("Score: %f\r\n", student.score);
return 0;
}
在内存中这个结构中的成员也是连续存储的。 在通常程序设计中, struct还会与typedef一起使用。
enum是C语言中用来修饰枚举类型变量的关键字。在C语言中可以使用枚举类型声明符号名称来表示整型常量,使用enum关键字可以创建一个新的“类型”并指定它可具有的值(实际上, enum常量是int类型,因此只要能使用int类型的地方就可以使用枚举类型)。枚举类型的目的是提高程序的可读性,其语法与结构
的语法相同,如下:
enum [枚举类型名]
{
枚举符 1,
枚举符 2
.
.
.
枚举符 n,
};
enum color
{
red,
green,
blue,
yellow
};
在上面的例子中, red, greeb, blue,yellow 到底是什么?从技术层面来讲,它们是 int 类型的整型常量。
默认情况下,枚举列表中的常量都被赋予0, 1, 2等,因此下面的声明中, apple的值是2:
enum fruit{banana, grape, apple};
在枚举类型中,可以为枚举常量指定整数值:
enum levels{low=90, medium=80, high=100};
如果只给一个枚举常量赋值,没有对后面的枚举常量赋值,那么后面的常量会被赋予后续的值,例如:
enum feline{cat, lynx=10, puma, tiger};
那么cat=0, lynx、 puma、 tiger的值分别是10、 11、 12。
typedef工具是一个高级数据特性,利用typedef可以为某一类型自定义名称。 这方面与#define类似,但是两者有三处不同:
typedef unsigned char BYTE;
BYTE x, y[10];
该定义的作用域取决于typedef定义所在的位置。如果定义在函数中,就具有局部作用域,受限于定义所在的函数。 如果定义在函数外面,就具有文件作用域。
C语言的预处理器及其预处理指令。首先是预处理指令,它们是:
#define、 #include、 #ifdef、 #else、 #endif、 #ifndef、 #if、 #elif、 #line、 #error、 #pragma
在这些指令中, #line、 #error、#pragma在基础开发中比较少见,其它的都是在编程过程中经常遇到和经常使用的,所以我们在后面的章节将主要介绍这些常用的指令。
C预处理器在执行程序之前查看程序,因而被称之为预处理器。根据程序中的预处理指令,预处理器把符号缩写替换成其表示的内容( #define)。预处理器可以包含程序所需的其它文件( #include),可以选择让编译器查看哪些代码(条件编译)。预处理器并不知道C,基本上它的工作是把一些文本转换成另外一些文本。
另外,编译器把文本划分成预处理记号序列、空白序列和注释序列(记号是由空格、制表符或换行符分割的项),需要注意的是,编译器将用一个空格字符替换每一条注释。
#define预处理器指令以#号作为一行的开始,到后面的第一个换行符为止。
每行#define(逻辑行)都由3部分组成。 第1部分是#define指令本身;第2部分是选定是缩写,也称为宏,
最后宏代表值。
宏展开有个值得注意的点是:预处理器会严格按照替换体直接替换,不做计算优先级处理比如
#define sqr(x) x*x
printf(“2+2 的平方: %d”, sqr(2+2));
这里使用了宏,结果就是8,因为被转换为 2+22+2进行计算。
sqr(x) xx 即x=2+2,但是预处理会直接替换,不会做计算优先级处理,所以就是2+2 * 2+2=8
#undef指令用于取消已定义的#define指令。
当预处理器发现#include预处理指令时,会查看后面的文件名并把文件的内容包含到当前文件中,即替换文件中的#include指令。这相当于把被包含文件的全部内容输入到源文件#include指令所在的位置。
#include指令有两种形式:
#include // 文件名在尖括号内
#include “myfile.h” // 文件名在双引号内
在UNIX中,尖括号<>告诉预处理器在标准系统目录中寻找该文件,双引号“”告诉预处理器首先在当前目录(或指定路径的目录)中寻找该文件,如果未找到再查找标准系统目录。
可以使用预处理指令创建条件编译,即可以使用这些指令告诉编译器根据编译时的条件执行或忽略代码块。
#ifdef HI /* 如果用#define 定义了符号 HI,则执行下面的语句 */
#include
#define STR "Hello world"
#else /* 如果没有用#define 定义符号 HI,则执行下面的语句 */
#include "mychar.h"
#define STR "Hello China"
#endif
#ifndef指令与#ifdef指令的用法类似,也可以和#else、 #endif一起使用,但是它的逻辑和#ifdef指令相反。
#if指令很像C语言中的if。 #if后面紧跟整型常量表达式,如果表达式为非零,则表达式为真,可以在指令中使用C的关系运算符和逻辑运算符:
#if MAX==1
printf("1");
#endif
可以按照 if else 的形式使用#if #elif:
#if MAX==1
printf("1");
#elif MAX==2
printf("2");
#endif
条件编译还有一个用途是让程序更容易移植。改变文件开头部分的几个关键的定义即可根据不同的系统设置不同的值和包含不同的文件。
从根本上看,指针是一个值为内存地址的变量。正如char类型变量的值是字符, int类型变量的值是整数,指针变量的值是地址。指针就是一个内存的地址。
因为计算机或者嵌入式设备的硬件指令非常依赖地址,指针在某种程度上把程序员想要表达的指令以更接近机器的方式表达,因此,使用指针的程序更有效率。尤其是指针能够有效地处理数组,而数组表示法其实是在变相的使用指针,比如:数组名是数组首元素的地址。
指针的声明
int *pi; // pi 是指向 int 类型变量的指针
char *str; // str 是指向 char 类型变量的指针
float *pf, *pg; // pf, pg 都是只想 float 类型变量的指针
所谓回调函数,一个笼统简单的介绍就是一个被作为参数传递的函数。
从字面上看,回调函数的意思是:一个回去调用的函数,如何理解这句话呢?从逻辑上分析,要“回去”,必然存在着一个已知的目的地,然后在某一个时刻去访问;那么回调函数就是存在一个已知的函数体A,将这个函数体A的地址即函数名“ A”(函数名即是这个函数体的函数指针,指向这个函数的地址)告知给另外某个函数B,当那个函数B执行到某一步的时候就会去执行函数A。
位运算是指二进制位之间的运算。在嵌入式系统设计中,常常要处理二进制的问题,例如将某个寄存器中的某一个位置1或者置0,将数据左移5位等,常用的位运算符如下
清0和置1
在嵌入式中,经常使用位预算符实现清0或置1。方法如下
#define GPIOB_ODR (*(volatile unsigned int *)(0x40010C0C))
GPIOB_ODR &= ~(1<<0);
GPIOB_ODR |= (1<<0);
编写代码如下,使STM32F03_MIN开发板LED每个1秒闪烁
main.c
#include "stm32f1xx_hal.h"
#include "stm32f1xx_clk.h"
int main(void){
HAL_Init();
SystemClock_Config();
//定义GPIO结构体变量
GPIO_InitTypeDef GPIO_InitStruct={0};
//使能
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitStruct.Pin=GPIO_PIN_1;// 选择LED的引脚
GPIO_InitStruct.Pull=GPIO_PULLUP;// 默认上拉
GPIO_InitStruct.Mode=GPIO_MODE_OUTPUT_PP;// 设置为推挽输出模式
GPIO_InitStruct.Speed=GPIO_SPEED_FREQ_LOW;// 引脚输出速度设置为慢
HAL_GPIO_Init(GPIOA,&GPIO_InitStruct);
while(1){
HAL_GPIO_WritePin(GPIOA,GPIO_PIN_1,GPIO_PIN_RESET);
HAL_Delay(1000);
HAL_GPIO_WritePin(GPIOA,GPIO_PIN_1,GPIO_PIN_SET);
HAL_Delay(1000);
}
}
void Error_Handler(void){
while(1){
}
}