STM32单片机学习(2)

1. 常见处理器概念

1.1 MCU

  • 微 控 制 器 (Micro-Controller Unit , MCU) , 俗 称 单 片 机 。 之 所 以 称 之 为 单 片 机 (Single ChipMicrocomputer),是因为不同于其它处理器,它将CPU、RAM(随机存储器)、ROM(只读存储器)、I/O、中断系统、定时器等各种功能外设资源集中到一个芯片上。这个芯片就是一个完整的微型计算机,只需要供电或加上极少的外围电路即可工作。

1.2 MPU

微处理器(Micro-Processor Unit,MPU)。类似通用计算机的CPU,主要负责处理计算,需要外加RAM、Flash、电源等电路。
MCU和MPU的本质区别是因为应用场景的定位不同。MPU注重通过相对强大的运算/处理能力,执行复杂多样的大型程序,因此常需要外挂运行内存(RAM)、存储器(Flash)等。MCU注重功能较为单一、价格敏感的应用场景,不需要相对强大的运算/处理能力,更多的是对设备管理/控制。

  • MPU 和 MCU 结构对比图
    在这里插入图片描述

1.3 DSP

  • DSP的两个含义
    1.数字信号处理(Digital Signal Processing,DSP),是一门学科技术,使用数值计算的方式对信号进行加工处理的理论和技术。
    2.数字信号处理器(Digital Signal Processor,DSP),是一种专门用于数字信号处理领域的微处理器芯片。

1.4 FPGA

  • 现场可编程门阵列(Field-Programmable Gate Array,FPGA),由可编程互连连接的可配置逻辑块(CLB) 矩阵构成的半导体器件。通俗地说,FPGA就是一个可以通过编程改变内部硬件结构,实现所需功能的芯片。
  • MCU、DSP等都是硬件资源固定,只能通过修改软件实现所需功能。而FPGA是通过硬件描述语言或其它方式修改硬件,将FPGA变为CPU或专用芯片,来实现控制或算法。
  • MCU、DSP能够实现的功能,FPGA理论上都可以实现,反过来就不一定了。
  • FPGA主要有两大优势:高速和灵活。FPGA使用硬件处理数据,采用并发和流水技术,多个模块之间可以同时并行执行。FPGA可以根据现场情况配置器件功能,能够在技术和需求变化时重新配置,实现系统优化升级。

1.5 MCU 开发和MPU 开发

  • MCU开发就是我们常说的单片机开发,MPU通常运行嵌入式Linux,因此也称为ARM-Linux开发。
  • MCU开发主要涉及的内容包含:GPIO、UART、I2C、SPI、LCD等外设接口,时钟、中断、定时器、ADC/DAC、看门狗等内部资源,以及RTOS(FreeRTOS、RT-Thread、uCOS、LiteOS等)嵌入式实时操作系统。
  • MPU开发主要涉及的内容包含:Bootload移植、Linux内核移植、Linux设备驱动开发(GPIO、UART、I2C、SPI等)、Linux应用开发(文件I/O、多任务编程、进程间通信、网络编程、Qt界面设计等),甚至包括Android驱动、Android应用编程。
  • MCU开发和MPU开发的一些区别:MPU开发通常没有仿真器,也没有集成开发环境IDE
    工具,但MPU资源丰富,反而下载方式比MCU丰富。MCU可以使用仿真器在线调试,也可以使用串口打印调试,而MPU一般只能使用串口调试打印。MCU的启动过程比较简单,而MPU的复杂很多,就Bootloader部分就相当于一个大型MCU开发工程。两者互补。
  • 具体如下表:
硬件设备开发环境下载方式启动过程应用场景
MCU开发开发板;仿真器(下载器);串口转USB模块;集成开发环境IDE,比如Keil、IAR使用仿真器下载,或串口下载上电后设置堆栈等,跳到主函数main,实现系统启动中低端家电、可穿戴设备、工业控制
MPU开发开发板;网线;串口转USB模块;Linux下SDK包串口下载、USB下载、TFTP网络、SD卡烧录等类似PC启动方式,首先加载Bootloader引导Kernel,再加载根文件系统和用户程序消费电子、网络设备

1.6 开源硬件 Ardo uino 和 和 Raspberry Pi

  • 在开源硬件领域,Arduino和Raspberry Pi(树莓派)都是电子创意设计常用的开发平台和工具。它们使用简单的开发方式,使初学者能快速搭建其创意原型,有效降低了学习难度,缩短了开发周期。
  • 如下图:

在这里插入图片描述

  • Arduino是一款基于微控制器(MCU)的开发板,它可以运行一些相对比较简单的应用程序,成本低廉,适合用于与传感器、外围电路进行控制和通信。
  • Raspberry Pi是一款基于微处理器(MPU)的开发板,可以看作是一台小型计算机,板载RAM、HDMI接口、USB接口、音频接口、网络接口等。通常运行发行版Linux操作系统,可以运行功能更丰富的应用程序,适合用于一些需要较多运算和功能的项目,比如游戏虚拟机、网页服务器、机器人、家庭智能控制系统等。

2. 最小系统和嵌入式C语言

2.1 STM32 最小系统

  • 单片机最小系统是指用最少的电路组成单片机可以工作的系统,通常最小系统包含:电源电路、时钟电路、复位电路、调试/下载电路,对于STM32还需要启动选择电路。

2.1.1 电源电路

  • 如图:
    在这里插入图片描述
    结合下表可得知上图的值
  • 工作电压参考表
    在这里插入图片描述
    ① V DD -V SS :标准工作电压;电压范围:2V~3.6V;从V DD1 ~ V DD11 ,V SS1 ~ V SS11 共有11组;需要11个100nF和1个4.7uF去耦电容;经过MCU内部Regulator电源管理,为CPU、存储器等供电;
    ② V DDA -V SSA :模拟工作电压;电压范围:2V~3.6V(未使用ADC) 2.4V~3.6V(使用ADC);需要1个10nF和1个1uF去耦电容;由V DDA 输入,为ADC、DAC等供电;
    ③V REF+ -V REF- :基准参考电压;电压范围:2.4V~ V DDA ;可以使用独立参考电压V REF (需10nF+1uF高频滤波电容),也可使用V DDA 输入,为ADC、DAC等作基准参考电压;
    ④V BAT :RTC备用电源;电压范围:1.8V~ 3.6V;通常使用纽扣电池外部供电,当主电源V DD 掉电后,V BAT 为实时时钟(Real-Time Clock,RTC)和备份寄存器供电(此时功耗超低)
  • 了解到以上知识后,再看原理图的MCU电源部分。
    左边的U1B(U1A和U1B共同组成U1,即MCU)是MCU所有电源相关引脚,可以看到V DDA 、V DD1 ~ V DD11 、 V REF+ 都直接接在了VDD_3V3上(3.3V),V SSA 、V SS1 ~ V SS11 、 V REF- 都接在了GND上,VBAT接在了VDD_BAT上。右边是一系列退耦电容,这些电容在Layout(PCB布局走线)时,需要均匀分布在每组电源引脚附近。
    在这里插入图片描述
  • 前面VDD_3V3需要外部提供给MCU,也就是通过电源适配器提供,而一般的电源适配器通常为5V或12V,因此还需要电源转换电路,将外部输入的12V或5V转换为3.3V。
  • 下图依次为为12V电源输入电路、12V转5V电路、5V转3.3V电路,在5V转3.3V电路中有一个红色LED灯,用于提示用户整个系统电源正常工作
  • 12V电源输入电路:
    在这里插入图片描述
  • 12V转5V电路:
    在这里插入图片描述
  • 5V转3.3V电路:
    在这里插入图片描述
  • 最后再看看RTC电源电路,在外部12V输入时,VDD_3V3为3.3V,而J5的纽扣电池约为3V,D5为肖特基二极管,此时VDD_3V3大于纽扣电池电压,因此由VDD_3V3供电。当外部12V无输入时,VDD_3V3为0V,而J5的纽扣电池约为3V,此时VDD_3V3小于纽扣电池电压,由纽扣电池供电,保证RTC继续运行,电路如下图所示:

在这里插入图片描述

2.1.2 时钟电路

  • MCU是一个集成芯片,由非常复杂的数字电路和其它电路组成,需要稳定的时钟脉冲信号才能保证正常工作。
  • 时钟如同人体内部的心脏一样,心脏跳动一下,推动血液流动一下。时钟产生一次,就推动处理器执行一下指令。除了CPU,芯片上所有的外设(GPIO、I2C、SPI等)都需要时钟,由此可见时钟的重要性。
  • 芯片运行的时钟频率越高,芯片处理的速度越快,但同时功耗也越高。为了功耗和性能兼顾,微处理器一般有多个时钟源,同时还将时钟分频为多个大小,适配不同需求的外设。
  • 下图是时钟树,但这里只讲解时钟源
    在这里插入图片描述
  • 可以看到一共有四个时钟源:
    ①HSI(High Speed Internal clock signal):
    HSI是内部的高速时钟信号,频率8MHz。因为是内部提供,可以降低成本,缺点是精度较差。
    ②HSE(High Speed External clock signal):
    HSE是外部的高速时钟信号,需要外部电路晶振,输入频率范围要求为4-16MHz。因为需要外部电路提
    供,成本会增加,但精度较好。
    ③LSE(Low Speed External clock signal):
    LSE是外部的低速时钟信号,需要外部电路晶振,输入频率范围要求为32.768KHz。一般用于RTC实时
    时钟。
    ④LSI(Low Speed Internal clock signal):
    LSI是内部的低速RC振荡器,频率40KHz。一般用于看门狗、RTC实时时钟等。

2.1.3 复位电路

  • 嵌入式系统中,由于外界环境干扰,难免出现程序跑飞或死机,这时就需要复位让MCU重新运行。
  • 下图所示复位电路。该电路将一个按键接在了NRST引脚,一旦按键按下,NRST就会接地,拉低NRST,实现复位。
    在这里插入图片描述
  • 再看看原理图上的复位电路,如下图所示。当开发板正常工作时,VDD_3V3上拉NRST,POWER_EN为前面12V转5V电源芯片的使能引脚,此时被电源芯片钳位在6.5V。当SW1被按下,D1为肖特基二极管,NRST和POWER_EN都会导通接地,拉低NRST和POWER_EN,使MCU复位,同时断开系统供电。
    在这里插入图片描述

2.1.4 调试/下载电路

  • 不同的MCU,调试/下载的方式可能不一样。比如51系列单片机,使用串口下载程序,同时也使用仿真调试。对于STM32,可以使用串口下载程序,也能使用串口打印进行简单调试,但STM32支持更高效的JTAG(Joint Test Action Group)调试接口和SWD(Serial Wire Debug)调试接口。
    在这里插入图片描述

2.2 嵌入式C语言基础知识

2.2.1 嵌入式C语言的基本结构及其特点:

  1. 所有的C语言程序都需要包含main()函数,代码从main()函数开始执行;这一条在嵌入式中不一定完全正确,在执行main()函数之前也有开发者可以操纵的空间,因而开始函数可以不是main(),例如也可以是myMain()这样的函数,而这所涉及到的知识已经超过基础知识的范围,会在后续详细说明;
  2. C语言的语句以用分号“;”结束;
  3. C语言的注释有行注释(“//”)和段注释(“//”);
  4. 函数是C语言的基本结构,每个C程序都是由至少一个函数组成;
  5. C语言的文件有两种格式:源文件.c文件和头文件.h文件,通常.c文件用于功能函数的实现,而.h文件用于预处理、宏定义和声明等;在嵌入式中,通常将某个硬件模块的功能实现函数及其声明和包含的全局变量声明分别处理到一个.c和.h文件中,例如led.c、hello.c和led.h、hello.h就分别对应于LED灯的功能函数及其声明和hello的功能函数及其声明;
  6. 我们将这种基于某个模块的独立设计称之为模块化设计,在一个系统中通常是由许许多多的模块共同组成的,因而模块化设计是一个非常科学且非常值得学习的程序设计方法;
  7. 除了模块化设计,通常嵌入式的编程设计还有层次化设计。在一个工程系统中,硬件驱动仅仅只是第一步,对硬件的应用则是一个功能丰富的系统的更进一步的设计,通常在这一块会设计到例如图像处理、数据处理等算法;我们可以笼统的将一个嵌入式工程系统分为驱动层和应用层。

2.2.2 数据类型

  • C语言数据类型有以下几种

在这里插入图片描述

2.2.3 const 用法

  • C语言中const关键字是constant的缩写,译为常量、常数等,但const关键字不仅仅是用于定义常量,还可以用于修饰数组、指针、函数参数等。
  • 修饰变量
    对变量声明为只读特性,并保护变量值以防被修改。示例:
const int i = 5;

需要注意的是,const定义变量的同时还必须对其初始化,const可以放在数据类型的前面或者后面,比如上述例子也可以写成:

int const i = 5;
  • 修饰数组
    C语言中const还可以修饰数组,例如:
const int array[5] = {0, 1, 2, 3, 4};
// 或
int const array[5] = {0, 1, 2, 3, 4};

const关键字修饰数组与修饰变量类似,表明此数组具有只读性,不可修改,一旦被更改程序会出错,例如上述例子如果:

array[1] = 10;

代码会显示错误。

  • 修饰指针
    C语言中const修饰指针需要特别注意,共有两种形式,一种是用来限定指向空间的值不可修改;另一种是限定指针不可修改,例如:
int i = 5;
int k = 10;
int const *p1 = &i;
int * const p2 = &k;

对于指针p1,const修饰的是p1,即p1指向的空间的值不可改变,例如p1 = 20;就是错误的用法;但是p1的值是可以改变的,例如p1 = &k;则没有任何问题。
对于指针p2,const修饰的是p2,即指针本身p2不可更改,而指针指向空间的值是可以改变的,例如*p2= 15;是没有问题的,而p2 = &i;则是错误的用法。

  • 修饰函数参数
    在C语言中const修饰函数参数对参数起限定作用,防止其在函数内部被意外修改,所限定的参数可以是普通变量也可以是指针变量,如:
void fun(const int x)
{
	...
	x = 10; // 对 x 的值进行了修改,错误
}

2.2.4 作用域与 static 用法

  • 了解static关键字的用法之前,我们需要先了解C语言中的作用域、局部变量和全局变量的概念。
    一个C变量的作用域可以是块作用域、函数作用域、函数原型作用域或文件作用域。
    块是用一对花括号“{}”括起来的代码区域,定义在块中的变量具有块作用域。块作用域的可见范围是从定义处到包含该定义的块的末尾。以前,具有块作用域的变量都必须声明在块的开头,C99标准放宽了这一限制,允许在块中的任意位置声明变量。例如不支持C99标准的的for循环需要这样写:
void fun1(void)
{
	int i = 0;
	for(i=0; i<10; i++)
	{
	...
	}
}
  • 在函数fun的开头定义了局部变量i,然后在for循环中调用此变量,变量i的作用域是函数fun内,当函数fun执行完毕之后变量i会被释放。而C99标准下可以这样写:
void fun2(void)
{
	for(int i = 0; i<10; i++)
	{
	...
	}
}
  • 这样写的话,变量i的作用域则在for循环体内,当循环结束后,变量就会被释放,可见其作用域缩小了,这样的好处是增加了安全性和灵活性。
    在函数fun1中,变量i被声明在函数体内,我们称这样的变量为局部变量,其有效范围是在被定义的函数内,函数执行完毕后变量即被释放;如果把这个变量定义在函数体外,如:
int k = 0;
void fun3(void)
{
	for(k=0; k<10; k++)
	{
	...
	}
}

我们则将定义在函数体外的变量称之为全局变量,其作用范围为当前源文件和工程若其它源文件想要调用用此变量需要在文件内使用关键字extern声明,如extern int k。

  • 简单的总结下局部变量和全局变量的特点:
    1. 局部变量会在每次声明的时候被重新初始化(如果在声明的时候有初始化赋值),不具有记忆能力,其作用范围仅在某个块作用域可见;
    1. 全局变量只会被初始化一次,之后会在程序的某个地方被修改,其作用范围可以是当前的整个源文件或者工程;
      鉴于两种变量的局限性,就引入了静态变量(静态局部变量和静态全局变量),使用关键字static来修饰。其中静态局部变量满足局部变量的作用范围,但是其拥有记忆能力,不会在每次生命的时候都初始化一次,这个作用在用来实现计数功能的时候非常方便,例如:
void cnt(void)
{
	static int num = 0;
	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的值来进行逻辑运算,那我们就可以向上述例子那样做,在源文件中定义一个静态全局变量,同时使用函数对其的值进行修改和获取,对外只提供函数接口即可,其它文件通过函数接口间接的使用这个变量。这样做同时也可以提高可移植性。
  • 静态全局变量只在本文件可见,因而其它文件也可以定义相同名字的静态局部变量,例如我们可以在source1.c里面定义static int k = 0;的同时也可以在source2.c里面也定义一个static int k = 0;这样做是不会有问题的,但是我们一点都不建议如此做,因为这不利于程序的可读性和可维护性,也容易让开发变得混乱。
  • 在C语言中static关键字除了用来修饰变量之外,还可以用来修饰函数,让函数仅在本文件可见,其它文件无法对其进行调用,例如在example1.c文件里面进行了如下定义:
static void gt_fun(void)
{
...
}

2.2.5 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 <stdio.h>
extern uint16_t 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”就是向编译器指明这个功能的语句。

2.2.6 volatile 用法

  • volatile原意是“易变的”,在嵌入式环境中用volatile关键字声明的变量,在每次对其值进行引用的时候都会从原始地址取值。由于该值“易变”的特性所以,针对其的任何赋值或者获取值操作都会被执行(而不会被优化)。由于这个特性,所以该关键字在嵌入式编译环境中经常用来消除编译器的优化,可以分为以下三种情景:
  1. 修饰硬件寄存器;
  2. 修饰中断服务函数中的非自动变量;
  3. 在有操作系统的工程中修饰会被多个应用修改的变量;
  • 修饰硬件寄存器
  • 以STM32F103的HAL库函数中GPIO的定义举例,如下为HAL库中GPIO寄存器定义:
/**
* @brief General Purpose I/O
*/
typedef struct
{
	__IO uint32_t CRL;
	__IO uint32_t CRH;
	__IO uint32_t IDR;
	__IO uint32_t ODR;
	__IO uint32_t BSRR;
	__IO uint32_t BRR;
	__IO uint32_t LCKR;
} GPIO_TypeDef;
  • 其中__IO的定义是:
#define __IO volatile /*!< Defines 'read / write' permissions */
  • 然后定义GPIO是:
#define GPIOA 		((GPIO_TypeDef *)GPIOA_BASE)
#define GPIOB 		((GPIO_TypeDef *)GPIOB_BASE)
#define GPIOC 		((GPIO_TypeDef *)GPIOC_BASE)
#define GPIOD 		((GPIO_TypeDef *)GPIOD_BASE)
#define GPIOE 		((GPIO_TypeDef *)GPIOE_BASE)
#define GPIOF 		((GPIO_TypeDef *)GPIOF_BASE)
#define GPIOG 		((GPIO_TypeDef *)GPIOG_BASE)

而GPIOx_BASE的定义是这样的:

#define GPIOA_BASE 		(APB2PERIPH_BASE + 0x00000800UL)
#define GPIOB_BASE 		(APB2PERIPH_BASE + 0x00000C00UL)
#define GPIOC_BASE 		(APB2PERIPH_BASE + 0x00001000UL)
#define GPIOD_BASE 		(APB2PERIPH_BASE + 0x00001400UL)
#define GPIOE_BASE 		(APB2PERIPH_BASE + 0x00001800UL)
#define GPIOF_BASE 		(APB2PERIPH_BASE + 0x00001C00UL)
#define GPIOG_BASE 		(APB2PERIPH_BASE + 0x00002000UL)
  • 其中APB2外设基地址的定义:
#define APB2PERIPH_BASE (PERIPH_BASE + 0x00010000UL)
  • 最后再来看外设基地址的定义:
#define PERIPH_BASE 0x40000000UL /*!< Peripheral base address in the alias region */
  • 综合起来,将宏定义一一展开,仅用GPIOA来看,其它的以此类推:
#define GPIOA ((GPIO_TypeDef *)(0x40000000UL + 0x00010000UL + 0x00000800UL))
  • 如此定义之后,那么GPIOA的CRL的地址就是:
(volatile uint32_t *)(0x40000000UL + 0x00010000UL + 0x00000800UL)
  • CRH的地址就是:
(volatile uint32_t *)(0x40000000UL + 0x00010000UL + 0x00000800UL + 2)
  • 后面的寄存器以此类推,因而在程序中使用:
GPIOA->CRH |= 0x01;
  • 那么实现的功能就是对GPIOA的CRH的寄存器的最低位拉高。如果在定义GPIO的寄存器结构体里面没有使用__IO uint16_t,而是仅使用uint16_t,那么在程序中再用语句:
GPIOA->CRH |= 0x01;
  • 就有可能会被编译器优化,不执行这一语句,从而导致拉高CRH的最低位这个功能无法实现;但是库函数中使用了volatile来修饰,那么编译器就不会对此语句优化,在每次执行这一语句的时候都会从CRH对应的内存地址里面去取值或者存值,保证了每次执行都是有效的。
  • 在有操作系统的工程中修饰会被多个任务修改的变量
    在嵌入式开发中,不仅仅有单片机裸机开发,也有带有操作系统的开发,通常两者使用C语言开发的较多。在有操作系统(比如RTOS、UCOS-II、Linux等)的设计中,如果有多个任务在对同一个变量进行赋值或取值,那么这一类变量也应使用volatile来修饰保证其可见性。所谓可见即:当前任务修改了这一变量的值,同一时刻,其它任务此变量的值也发生了变化。

2.2.7 struct 用法

  • 在多数情况下,使用简单的变量甚至数组都是不够的。C使用结构变量进一步增强了表示数据的能力。C的结构的基本形式就足以灵活的表示多种数据,并且能够创建新的形式。
    C的结构的声明格式如下:
struct [结构体名]
{
	类型标识符 成员名 1;
	类型标识符 成员名 2.
	.
	.
	类型标识符 成员名 n;
};
  • 此声明描述了一个由n个数据类型的成员组成的结构,它并未创建实际的数据对象,只描述了该对象由什么组成。分析一下结构体声明的细节,首先是struct关键字,它表明跟在其后的是一个结构,后面是一个可选的标记,后面的程序中可以使用该标记引用该结构,因而我们可以在后面的程序中可以这样声明:
struct [结构体名] 结构体变量;
  • 在结构体声明中用一对花括号括起来的是结构体成员列表。每个成员都用自己的声明来描述。成员可以是任意一种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 student{…};告诉编译器如何表示数据,但是它并未让编译器为数据分配空间;另一层含义是创建一个结构体变量,如上述例子的struct studentsstudent;编译器执行这行代码便创建了一个结构体变量student,编译器使用students模板为该变量分配空间:内含50个元素的char型数组1、50个元素的char型数组2,一个int型的变量和一个float的变量,这些存储空间都与一个名称为student结合在一起。
  • 在内存中这个结构中的成员也是连续存储的。在通常程序设计中,struct还会与typedef一起使用。

2.2.8 enum 用法

  • enum是C语言中用来修饰枚举类型变量的关键字。在C语言中可以使用枚举类型声明符号名称来表示整
    型常量,使用enum关键字可以创建一个新的“类型”并指定它可具有的值(实际上,enum常量是int类型,
    因此只要能使用int类型的地方就可以使用枚举类型)。枚举类型的目的是提高程序的可读性,其语法与结构
    的语法相同,如下:
enum [枚举类型名]
{
	枚举符 1,
	枚举符 2
	.
	.
	.
	枚举符 n,
};

例如:

enum color
{
red,
green,
blue,
yellow
};
  • enum常量
    在上面的例子中,red, greeb, blue, yellow 到底是什么?从技术层面来讲,它们是 int 类型的整型常量,例如可以这样使用:
printf("red=%d, green=%d", red, green);
  • 可以观察到最后打印的信息是:red=0,green=1。
  • red成为一个有名称的常量,代表整数0。类似的,其它的枚举符都是有名称的常量,分别代表1~3。只要是能使用整型常量的地方就可以使用枚举常量,例如,在声明数组的时候可以使用枚举常量表示数组的大小,在switch语句中可以把枚举常量作为标签。
  • enum默认值
    默认情况下,枚举列表中的常量都被赋予0,1,2等,因此下面的声明中,apple的值是2:
enum fruit{banana, grape, apple};
  • enum赋值
    在枚举类型中,可以为枚举常量指定整数值:
enum levels{low=90, medium=80, high=100};
  • 如果只给一个枚举常量赋值,没有对后面的枚举常量赋值,那么后面的常量会被赋予后续的值,例如:
enum feline{cat, lynx=10, puma, tiger};
  • 那么cat=0,lynx、puma、tiger的值分别是10、11、12。

2.2.9 typedef 用法

  • typedef工具是一个高级数据特性,利用typedef可以为某一类型自定义名称。这方面与#define类似,但是两者有三处不同:
  1. 与#define不同,typedef创建的符号只受限于类型,不能用于值;
  2. tyedef由编译器解释,不是预处理器;
  3. 在其受限范围内,typedef比#define更灵活;
  • 假设要用BYTE表示1字节的数组,只需要像定义个char类型变量一样定义BYTE,然后再定义前面加上关键字typedef即可:
typedef unsigned char BYTE;
  • 随后便可使用 BYTE 来定义变量:
BYTE x, y[10];
  • 该定义的作用域取决于typedef定义所在的位置。如果定义在函数中,就具有局部作用域,受限于定义所在的函数。如果定义在函数外面,就具有文件作用域。
  • 为现有类型创建一个名称,看起来是多此一举,但是它有时的确很有用。在前面的示例中,用BYTE代替unsigned char表明你打算用BYTE类型的变量表示数字而不是字符。使用typedef还能提高程序的可移植性。
  • 用typedef来命名一个结构体类型的时候,可以省略该结构的标签(struct):
typedef struct
{
	char name[50];
	unsigned int age;
	float score;
}student_info;
student_info student={“Bob”, 15, 90.5};
  • 这样使用typedef定义的类型名会被翻译成:
struct {char name[50]; unsigned int age; float score;}
student = {“Bob”, 15, 90.5};
  • 使用typedef的第二个原因是:tyedef常用于给复杂的类型命名,例如:
typedef void (*pFunction)(void);
  • 把pFunction声明为一个函数,该函数返回一个指针,该指针指向一个void型。
    使用typdef时要记住,typedef并没有创建任何新类型,它只是为某个已有的类型增加了一个方便使用的标签。

2.2.10 预处理器与预处理指令

预处理指令,它们是:

#define、#include、#ifdef、#else、#endif、#ifndef、#if、#elif、#line、#error、#pragma
  • 在这些指令中,#line、#error、#pragma在基础开发中比较少见,其它的都是在编程过程中经常遇到和经常使用的,所以我们在后面的章节将主要介绍这些常用的指令。
  • C语言建立在适当的的关键字、表达式、语句以及使用他们的规则上。然而C标准不仅描述C语言,还描述如何执行C预处理器。
  • 符号缩写替换成其表示的内容(#define)。预处理器可以包含程序所需的其它文件(#include),可以选择让编译器查看哪些代码(条件编译)。预处理器并不知道C,基本上它的工作是把一些文本转换成另外一些文本。
  • 由于预处理表达式的长度必须是一个逻辑行(可以把逻辑行使用换行符‘\’变成多个物理行),因而为了让预处理器得到正确的逻辑行,在预处理之前还会有个编译的过程,编译器定位每个反斜杠后面跟着换行符的示例,并删除它们,比如:
printf(“Hello, Chi\
na”);
  • 转换成一个逻辑行:
printf(“Hello, China”);
  • 另外,编译器把文本划分成预处理记号序列、空白序列和注释序列(记号是由空格、制表符或换行符分割的项),需要注意的是,编译器将用一个空格字符替换每一条注释,例如:
char/*这是一条注释*/str;

将变成:

char str;

这样编译处理后,程序就准备好进入预处理阶段,预处理器查找一行中以#号开始的预处理指令。然后我们就从#define指令开始讲解这些预处理指令。

2.2.11 define 与f #undef 用法

  • #define预处理器指令以#号作为一行的开始,到后面的第一个换行符为止。也就是说,指令的长度仅限于一行。然而在预处理开始前,编译器会把多行物理行处理为一行逻辑行,例如:
#define STRING “I am Chinese, \
I love China./* 反斜杠把该定义延续到下一行 */
  • 每行#define(逻辑行)都由3部分组成。第1部分是#define指令本身;第2部分是选定是缩写,也称为宏,有些宏代表值,例如:
#define PX printf(“x=%d”, x)
  • 这些宏被称为类对象宏。C语言还有类函数宏,在后面介绍。宏的名称中不允许有空格,而且必须严格遵循C变量的命名规则:只能使用字符、数字和下划线字符,且首字符不能是数字。第3部分称为替换列表或替换体。一旦预处理器在程序中找到宏的示例后,就会用替换体代替该宏。从宏变成最终替换文本的过程称为宏展开。例如上例我们如果使用:
int x=2;
PX;

就会被预处理器展开成:

int x=2;
printf(“x=%d”, x);

最后输出的结果是x=2。

  • 可以看到宏可以表示任意字符串常量,甚至可以表示整个C表达式,例如下面整个取绝对值的宏定义:
#define ABS(x) ((x>0)?(x):(-x))
  • 宏展开有个值得注意的点是:预处理器会严格按照替换体直接替换,不做计算不做优先级处理,例如下面求取平方值的宏定义:
#define sqr(x) x*x

我们假设这样使用:

printf(2 的平反:%d”, sqr(2));

输出的结果为4。
但是如果我们这样使用:

printf(2+2 的平方:%d”, sqr(2+2));
  • 那么与编译器就会这样展开:
printf(2+2 的平方:%d”, 2+2 * 2+2);

输出的结果为8。

  • 但是实际按照逻辑2+2的平方是16,得到8的结果是因为前面所说的预处理器不会做计算只会严格按照替换体的文本进行直接替换,因而为了避免类似的问题出现,我们应该这样改写平凡宏定义:
#define sqr(x) ((x)*(x))
  • 这样上述的2+2的平方的例子就会被展开成这样:
printf(2+2 的平反:%d”, ((2+2)*(2+2)));

就会得到正确的输出16。

  • 上述的求取绝对值的宏定义或者求取平方值的宏定义中我们可以看到其形式与函数类似,此类宏
    定义就是前面所说的类函数宏。类函数宏定义的圆括号可以有一个或多个参数,随后这些参数出现在替换体当中。
  • #undef指令用于取消已定义的#define指令。

2.2.12 文件包含 # include

  • 当预处理器发现#include预处理指令时,会查看后面的文件名并把文件的内容包含到当前文件中,即替换文件中的#include指令。这相当于把被包含文件的全部内容输入到源文件#include指令所在的位置。
  • #include指令有两种形式:
#include <stdio.h> // 文件名在尖括号内
#include “myfile.h” // 文件名在双引号内
  • 在UNIX中,尖括号<>告诉预处理器在标准系统目录中寻找该文件,双引号“”告诉预处理器首先在当前目录(或指定路径的目录)中寻找该文件,如果未找到再查找标准系统目录:
#include <stdio.h> // 在标准系统目录中查找 stdio.h 文件
#include “myfile.h” // 在当前目录中查找 myfile.h 文件
#include “/project/header.h” // 在 project 目录查找
#include “../myheader.h” // 在当前文件的上一级目录查找
  • 集成开发环境(IDE,比如开发板的开发环境keil)也有标准就或系统头文件的路径。许多集成环境提供菜单选项,指定用尖括号时的查找路径。
  • 为什么我们要包含文件?因为编译器需要这些文件中的信息,例如stdio.h中通常包含EOF、NULL、getchar()和putchar()的定义。此外,该文件还包含C的其它的I/O函数。而对于我们自定义的文件,对于嵌入式开发来说,可能这些文件就有需要使用到的某些引脚宏定义、简单的功能函数宏定义等,以及某个源文件的全局变量和函数的声明等。
  • C语言习惯用.h后缀表示头文件,这些文件包含需要放在程序顶部的信息。头文件经常包含一些预处理指令,有些头文件由系统提供,也可以自定义。
  • 下面是一个子自定义一个头文件的示例:gpio.h, main.c
/*gpio.h*/
#ifndef __GPIO_H
#define __GPIO_H
#include <stdio.h>
typedef struct
{
	uint8_t cnt;
	uint16_t sum;
	float result;
}MyStruct;
typedef enum
{
	GPIO_RESET = 0,
	GPIO_SET = 1,
}GPIO_STATE;
#define ABS(x) ((x>0) ? (x) : (-x))
#endif
/* main.c */
#include “gpio.h”
int main(void)
{
	MyStruct my_struct = {0, 25, 3.14};
	GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_SET);
	printf(“cnt=%d, sum=%d, result=%f\n\r”, my_struct.cnt, my_struct.sum, my_struct.result);
}
  • #include指令也不是只包含.h文件,它同样也可以包含.c文件。

2.2.13 条件编译

  • 可以使用预处理指令创建条件编译,即可以使用这些指令告诉编译器根据编译时的条件执行或忽略代码块。
    1. #ifdef、#else和#endif指令
      我们用一个示例来看这几个指令:
#ifdef HI /* 如果用#define 定义了符号 HI,则执行下面的语句 */
#include <stdio.h>
#define STR "Hello world"
#else /* 如果没有用#define 定义符号 HI,则执行下面的语句 */
#include "mychar.h"
#define STR "Hello China"
#endif
  • #ifdef指令说明,如果预处理器已定义了后面的标识符,则执行#else或#endif指令之前的所有指令并编译所有C代码,如果未定义且有#elif指令,则执行#else和#endif指令之间的代码。
  • #ifdef、#else和C和if else很像,两者的主要区别在于预处理器不识别用于标记块的花括号{},因此它使用#else(如果需要的话)和#endif(必须存在)来标记指令块。
    1. #ifndef指令
      #ifndef指令与#ifdef指令的用法类似,也可以和#else、#endif一起使用,但是它的逻辑和#ifdef指令相反。
    1. #if和#elif
      #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
  • 条件编译还有一个用途是让程序更容易移植。改变文件开头部分的几个关键的定义即可根据不同的系统设置不同的值和包含不同的文件。

2.2.14 指针用法

  • 什么是指针?从根本上看,指针是一个值为内存地址的变量。正如char类型变量的值是字符,int类型变量的值是整数,指针变量的值是地址。
  • 因为计算机或者嵌入式设备的硬件指令非常依赖地址,指针在某种程度上把程序员想要表达的指令以更接近机器的方式表达,因此,使用指针的程序更有效率。尤其是指针能够有效地处理数组,而数组表示法其实是在变相的使用指针,比如:数组名是数组首元素的地址。
  • 要创建指针变量,首先要声明指针变量的类型。假如想把ptr声明为储存int类型变量地址的指针,就要使用间接运算符*来声明。
  • 假设已知ptr指向bah,如下表示:
ptr = &bah;
  • 然后使用间接运算符*找出储存在bah中的值:value = *ptr;此运算符有时也被称为解引用运算符。语句ptr=&bah;value=*ptr;放在一起的效果等效于:value=bah;
  • 那么该如何声明一个指针变量呢?是这样吗:
pointer ptr; // 不能这样声明一个指针变量
  • 为什么不能这样声明一个指针变量呢?因为声明指针变量时必须指定指针所指向变量的类型,不同的变量类型所占据的储存空间是不同的,一些指针操作需要知道操作对象的大小。另外程序必须知道储存在指定地址的数据类型。例如:
int *pi; // pi 是指向 int 类型变量的指针
char *str; // str 是指向 char 类型变量的指针
float *pf, *pg; // pf, pg 都是只想 float 类型变量的指针

类型说明符表明了指针所指向对象的类型,解引用符号*表明声明的变量是一个指针。int *pi声明的意思是pi是一个指针,*pi是int类型.

1) 指针与数组
  • 前面提到可以使用地址运算符&获取变量所在的地址,而在数组中同样可以使用取地址运算符获取数组成员中任意成员的地址,例如:
int week[7] = {1, 2, 3, 4, 5, 6, 7};
int *pw;
pw = &week[2];
printf("week is: %d", *pw);
  • 输出的结果是:week is 3。对这段代码的释义参照上图 5.3.3。
2) 指针与函数
  • 指针在函数中的使用最简单的是作为函数的形参,比如:
int sum(int *pdata)
{
	int i = 0;
	int temp = 0;
	for(i=0;i<10;i++)
	{
	temp = temp + (*pdata);
	pdata++;
	}
	
	return temp;
}
  • 这个例子有几点值得讲解的地方:
  • 第1点指针pdata是作为函数的形参存在,指向一个储存int类型变量的地址;
  • 第2点指针pdata++;语句执行后,pdata只想的地址自增的不是1,而是int类型所占的大小,加入pdata最初的值是0,int类型占2个字节,那么pdata++;语句执行后,pdata的值就变成了2,而不是1,而*pdata的值是地址2所在的值不是地址1所在的值;
  • 第3点这个函数有个危险,即函数实现的是从pdata最初指向的地址开始往后的10个int类型变量的和,假如我们这样使用:
int data[5] = {1, 2, 3, -1, -2};
int x = sum(data);
  • 可以看到数组data的数组名即数组的首地址作为参数输入到函数sum里,而数组的大小只有5个int,函数sum计算的却是10个数的和,因而就会出现地址溢出,得不到正确的结果甚至于程序跑飞。为了避免这个问题,通常的解决方法是加一个数量形参:
int sum(int *pdata, int length)
{
	int i = 0;
	int temp = 0;
	for(i=0;i<length;i++)
	{
	temp = temp + (*pdata);
	pdata++;
	}
	
return temp;

}
x = sum(data, 5);

或者给出指针范围:

int sum(int *pStart, int *pEnd)
{
	int i = 0;
	int temp = 0;
	int length = (pEnd - pStart)/2; // 假设一个 int 占 2 个字节
	for(i=0;i<length;i++)
	{
	temp = temp + (*pdata);
	pdata++;
}

return temp;

}
x = sum(data, &data[4]);
  • 指针与函数的关系除了指针作为函数形参外还有另一个重要的应用,那边是函数指针,比如在typedef用法章节的那个例子:
typedef void (*pFunction)(void);
  • 在这个例子中,首先*表明pFunction是一个指针变量,其次前面的void表示这个指针变量返回一个void类型的值,最后括号里面的void表明这个函数指针的形参是void类型的。如何使用函数指针调用函数呢?
  • 看下面这个例子:
int max(int a, int b)
{
	return ((a>b)?a:b);
}

int main(void)
{
	int (*pfun)(int, int);
	int a=-1, b=2, c=0;
	pfun = max;
	c=pfun(a, b);
	printf("max: %d", c);
	
	return 0;
	
}

输出的结果是:2。

3) 指针与硬件地址
  • 指针与硬件地址的联系在volatile用法章节的例子中惊鸿一现,没有详细介绍,下面做详细说明。比如在STM32F103ZET6中内部SRAM的基地址是0x20000000,我们想对这片空间的前256个字节写入数据,就可以使用指针指向这个基地址,然后开始写:
volatile unsigned char *pData = (volatile unsigned char *)(0x20000000);

int main(void)
{
	int i = 0;
	for(i=0; i<256; i++)
	{
	pData[i] = i+10;
	}
	
	return 0;
	
}
  • 除了内存地址,还可以指向硬件外设的寄存器地址,操作方式与上述例子类似。
  • 指针应用的基本原则:
    首先必须要指定指针的类型;
    如果是普通指针变量,非函数形参或者函数指针,必须要给指针变量指定地址,避免成为一个“野
    指针”;

2.2.15 回调函数

  • 在C语言中回调函数是函数指针的高级应用。所谓回调函数,一个笼统简单的介绍就是一个被作为参数传递的函数。从字面上看,回调函数的意思是:一个回去调用的函数,如何理解这句话呢?从逻辑上分析,要“回去”,必然存在着一个已知的目的地,然后在某一个时刻去访问;那么回调函数就是存在一个已知的函数体A,将这个函数体A的地址即函数名“A”(函数名即是这个函数体的函数指针,指向这个函数的地址)告知给另外某个函数B,当那个函数B执行到某一步的时候就会去执行函数A。
  • 回调函数的应用有很多,因之后的程序都是在STM32的HAL库下编写的,因而此处我们仅从HAL库出发来看其中的回调函数。
  • 我们仅以GPIO的HAL库函数来看,文件名“stm32f1xx_hal_gpio.c”。我们用逆分析的方法来看这个回调函数。
  • 首先是GPIO的回调函数声明:
__weak void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
  • 可以看到其函数名是:HAL_GPIO_EXTI_Callback,形参是GPIO_Pin表示引脚号(Px0~Px15, x=A,B,C,D,E,F,G),从这个函数的名称出发,可以大致明确这是一个引脚的外部中断(EXTI)的回调函数。
  • 然后大家看到前面还有个“__weak”,这是“弱函数”的修饰符,告诉编译器如果用户在其它地方用voidHAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)重新定义了此回调函数那么优先调用用户定义的,否则调用这个弱函数修饰的回调函数。
  • 紧接着我们来看此回调函数是在哪里被调用的:
void HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin)
{
	/* EXTI line interrupt detected */
	if (__HAL_GPIO_EXTI_GET_IT(GPIO_Pin) != 0x00u)
	{
	__HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin);
	HAL_GPIO_EXTI_Callback(GPIO_Pin);
	}
}
  • 可以看到是在GPIO的外部中断服务函数中被调用的,与前面所说的这是一个外部引脚中断回调函数印证一致了。
  • GPIO的回调函数到此就说完了。其实STM32的HAL库中其它大多数的外设的回调函数基本都是如此,用户如果设计需求,就自己重定义需求的回调函数,然后在中断中被调用。

2.2.16 位运算

  • 位运算是指二进制位之间的运算。在嵌入式系统设计中,常常要处理二进制的问题,例如将某个寄存器中的某一个位置1或者置0,将数据左移5位等,常用的位运算符如表所示。
序号运算符含义
1&按位与
2/按位或
3~按位取返
4<<左移
5>>右移
1) 按位与运算符(&)
  • 参与运算的两个操作数,每个二进制位进行“与”运算,若两个都为1,结果为1,否者为0。
    例如,1011&1001,第一位都为1,结果为1;第二位都为0,结果为0;第三位一个为1,一个为0,结果为0;第四位都为1,结果为1。最后结果为1001。
2) 按位或运算符(|)
  • 参与运算的两个操作数,每个二进制位进行“或”运算,若两个都为0,结果为1,否者为1。
    例如,1011 | 1001,第一位都为1,结果为1;第二位都为0,结果为0;第三位一个为1,一个为0,结果为1;第四位都为1,结果为1。最后结果为1011。
3) 按位取反运算符(~)
  • 按位取反运算符用于对一个二进制数按位取反。
    例如,~1011,第一位为1,取反为0;第二位为0,取反为1;第三位为1,取反为0,结果为1;第四位为1,取反为0。最后结果为0100。
4) 左移(<<)和右移(>>)运算符
  • 左移(<<)运算符用于将一个数左移若干位,右移(>>)运算符用于将一个数右移若干位。
    例如,假设val为unsigned char型数据,对应的二进制数为10111001。若val=va<<3,表示val左移3位,然后赋值给val,左移过程中,高位移出去后被丢弃,低位补0,最后val结果为11001000;若val=val>>3,表示val右移3位,然后赋值给val,右移过程中,低位移出去后被丢弃,高位补0,最后val结果为00010111。
5) 清0或置1
  • 在嵌入式中,经常使用位预算符实现清0或置1。
    例如,MCU的ODR寄存器控制引脚的输出电平高低,寄存器为32位,每位控制一个引脚的电平。假设需要控制GPIOB的1号引脚输出电平的高低,设置该寄存器第0位为1,输出高电平,设置该寄存器第0位为0,输出低电平。
#define GPIOB_ODR (*(volatile unsigned int *)(0x40010C0C))
GPIOB_ODR &= ~(1<<0);
GPIOB_ODR |= (1<<0);
  • 第一行:使用#define定义了GPIOB_ODR 对应的内存地址为0x40010C0C。该地址为MCU的ODR寄存器地址。
  • 第三行:GPIOB_ODR &= ~(1<<0)实际是GPIOB_ODR = GPIOB_ODR & (1<<0),先将GPIOB_ODR和(1<<0)的进行与运算,运算结果赋值给GPIOB_ODR。1<<0的值为00000000 00000000 00000000 00000001,再取反为11111111 11111111 11111111 11111110,则GPIO_ODR的第0位和0与运算,结果必为0,其它位和1运算,由GPIO_ODR原来的值决定结果。这就实现了,只将GPIO_ODR的第0位清0,其它位保持不变的效果,实现了单独控制对应引脚电平输出低。
  • 第四行:GPIOB_ODR |= (1<<0)实际是GPIOB_ODR = GPIOB_ODR | (1<<0),先将GPIOB_ODR和(1<<0)的进行或运算,运算结果赋值给GPIOB_ODR。1<<0的值为00000000 00000000 00000000 00000001,则GPIO_ODR的第0位和0或运算,结果必为1,其它位和0运算,由GPIO_ODR原来的值决定结果。
  • 这就实现了,只将GPIO_ODR的第0位置1,其它位保持不变的效果,实现了单独控制对应引脚电平输出高。
  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值