
5.1 如何看原理图 以100ASK_STM32F103_MINI的原理图为例,介绍如何看原理图,读者只需要掌握几个要点,就能基本理解原理图的标识含义。100ASK_STM32F103_MINI原理图在“4_硬件资料”文件夹里,读者打开《100ASK_STM32F103_MINI原理图.pdf》(后简称,《原理图》)配合本手册阅读。 《原理图》由开发板最小系统,加上外设模块和一些接口组成。在原理图上,除了用连线表示两个器件有连接外,更多的时候使用网络标号来表示连接。如图 5.1.1 所示,红色文字是网络标号,只要两者网络标号相同,则表示它们是连接的。网络标号LED一个连接主控芯片,一个连接D2发光二极管,它们之间没有任何连接,但因为网络标号都是LED,因此它们表示电气上是连通的,即主控芯片的PA1脚控制发光二极管。 ![]() 图 5.1.1 原理图网络标号连接 原理图上的器件类型繁多,但依旧遵循“二八原则”,80%都是常见的器件,因此我们只需熟悉常见器件即可。每一类器件,通常使用英文名称简写标记,比如电阻通常标记为Rn(n为数字),常见元件如下表 5.1.1 所示。 ![]() 表 5.1.1 原理图常见元件 对于一个单片机系统,首先要保证最小系统功能正常,其次再实现与各个外设模块的控制或数据传输。因此看原理图也是,首先重点是最小系统,其次是各个外设模块。对于外设模块的外围电路,需要什么电源、怎么接线,电阻/电容如何取值,读者也不用太担心,通常都能找到参考电路,然后根据芯片手册说明,仔细核对,最后调试验证即可。 【总结】 后面分析STM32最小系统的时候,再感受如何具体的分析原理图细节,对于原理图基础部分,读者暂时理解以下内容即可: 1. 理清原理图板块; 2. 理解网络标号; 3. 熟悉常见元件; 5.2 STM32最小系统 单片机最小系统是指用最少的电路组成单片机可以工作的系统,通常最小系统包含:电源电路、时钟电路、复位电路、调试/下载电路,对于STM32还需要启动选择电路。 5.2.1 电源电路 不同的MCU的工作电压可能是不一样的,比如51单片机通常为5V,而STM32单片机通常为3.3V。因此,通常需要查阅该MCU的数据手册才能确定工作电压和规范。 打开“2_原厂资料”的《1_STM32F103x8数据手册【重要】.pdf》(后简称,《数据手册》),找到“5.1.6 Power supply scheme”电源方案小结,可以看到如图 5.2.1 框图。 ![]() 图 5.2.1 STM32F103x8电源方案 结合《数据手册》“5.3.1 General operating conditions”的表 5.2.1 ,可得知上图中的各电压值大小。 ![]() 表 5.2.1 工作电压参考 ①VDD-VSS:标准工作电压;电压范围:2V~3.6V;从VDD1 ~ VDD3,VSS1 ~ VSS3共有3组;经过MCU内部Regulator电源管理,为CPU、存储器等供电; ②VDDA-VSSA:模拟工作电压;电压范围:2V~3.6V(未使用ADC) 2.4V~3.6V(使用ADC);由VDDA输入,为ADC、DAC等供电; ③VREF+-VREF-:基准参考电压;电压范围:2.4V~ VDDA;可以使用独立参考电压VREF(需10nF+1uF高频滤波电容),也可使用VDDA输入,为ADC、DAC等作基准参考电压; ④VBAT:RTC备用电源;电压范围:1.8V~ 3.6V;通常使用纽扣电池外部供电,当主电源VDD掉电后,VBAT为实时时钟(Real-Time Clock,RTC)和备份寄存器供电(此时功耗超低); 从数据手册了解到以上知识后,再来看看原理图的MCU电源部分。开发板采用USB供电,通常USB都为5V,因此需要将5V转换成3.3V,使用AMS1117-3.3电源芯片即可实现,如图 5.2.2 所示。 ![]() 图 5.2.2 电压转换 通常USB口由其它电子设备提供,还需要考虑其保护。这里使用自恢复保险丝F1,避免USB口短路大电流烧毁;使用TVS二极管E1和E2,避免静电损坏USB信号引脚。 使用转换后的3.3V提供给VDD1 ~ VDD3和VDDA;VREF+和VREF-在引脚数大于100脚的芯片才会有,因此STM32F103C8T6不涉及;VBAT这里直接连接3.3V,简化不使用对应功能。 5.2.2 时钟电路 MCU是一个集成芯片,由非常复杂的数字电路和其它电路组成,需要稳定的时钟脉冲信号才能保证正常工作。时钟如同人体内部的心脏一样,心脏跳动一下,推动血液流动一下。时钟产生一次,就推动处理器执行一下指令。除了CPU需要时钟,芯片上所有的外设(GPIO、I2C、SPI等)也都需要时钟,由此可见时钟的重要性。 芯片运行的时钟频率越高,芯片处理的速度越快,但同时功耗也越高。为了功耗和性能兼顾,微处理器一般有多个时钟源,同时还将时钟分频为多个大小,适配不同需求的外设。 由《数据手册》可得到如图 5.2.7 所示的时钟树,这里只关心时钟源,时钟的分配使用放在后面讲解。 ![]() 图 5.2.3 STM32F103x8时钟树 可以看到一共有四个时钟源: ①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实时时钟等。 对于STM32F103系列的MCU,都需要一个高速时钟和一个低速时钟,而这两个时钟可以选择使用内部时钟源节约成本,也可以选择外部时钟源输入提高精度。如果使用内部时钟源,则无需设计外部电路,反之,则需要时钟电路。 继续查看《数据手册》,可以看到如图 5.2.8 和图 5.2.9 所示的外部时钟输入参考电路。手册上提到对于HSE,当晶振为8MHz时,CL1和CL2的容值范围为5pF~25pF。REXT用于产生负反馈,保证放大器工作在高增益的线性区,同时也起到限流作用,通常在兆欧级,具体由晶振决定。 ![]() 图 5.2.4 HSE典型应用(8MHz) 对于LSE,当晶振为32.768KHz时,CL1和CL2的容值范围为5pF~15pF。之所以选择32.768KHz,是因为32768=215,分频设置寄存器通常为2n的形式,这样经过15次分频就很容易得到1Hz的频率。 ![]() 图 5.2.5 LSE典型应用(32.768KHz) 从数据手册了解到以上知识后,再来看看原理图的时钟电路部分,如图 5.2.10 所示。高速时钟由外部提供,低速时钟由内部提供。 晶振旁的负载电容,应选择高质量陶瓷电容(NPO),以满足高频率场合。在Layout(PCB布局走线)时,晶振和负载电容,应尽可能的靠近MCU,以减少输出失真和启动时的稳定时间,保证振荡器可靠工作。 ![]() 图 5.2.6 时钟电路 5.2.3 复位电路 嵌入式系统中,由于外界环境干扰,难免出现程序跑飞或死机,这时就需要复位让MCU重新运行。查看《参考手册》,可见如图 5.2.11 所示复位电路。该电路将一个按键接在了NRST引脚,一旦按键按下,NRST就会接地,拉低NRST,实现复位。 ![]() 图 5.2.7 复位参考电路 再来看看原理图上的复位电路,如图 5.2.12 所示。当开发板正常工作时,3V3上拉NRST,当K1被按下, NRST会导通接地,拉低NRST,使MCU复位。 ![]() 图 5.2.8 复位电路 5.2.4 调试/下载电路 不同的MCU,调试/下载的方式可能不一样。比如51系列单片机,使用串口下载程序,部分也使用串口仿真调试。对于STM32,可以使用串口下载程序,也可以使用串口打印的方式进行简单调试,如果想仿真单步调试,就需要JTAG(Joint Test Action Group)调试接口或SWD(Serial Wire Debug)调试接口。 本开发板支持串口和SWD下载,如表 5.2.2 所示,日常直接使用串口下载,无需额外设备,方便快捷。需要单步仿真调试时,使用SWD接口,功能强大。 ![]() 表 5.2.2 调试/下载接口 串口自动下载电路涉及BOOT启动选择,当BOOT0和BOOT1引脚都为低电平时,MCU正常启动;当BOOT1引脚为低电平,BOOT0引脚为高电平时,进入串口下载模式(In System Programing,ISP)。串口打印调试/下载电路如图 5.2.9 所示,上电后,CH340G的RTS和DTR都输出高电平,电脑上位机软件控制DTR引脚输出低,PMOS管导通,电容C2充能,BOOT0逐渐变为高,此时三极管Q2导通,复位拉低,MCU复位。随后控制DTR输出高,PMOS管关闭,C2放电,BOOT1会保持一段时间高电平,此时三极管Q2截止,RESET上拉,开发板启动,进入串口下载模式。下载完后,跳到下载程序位置,运行下载程序。 ![]() 图 5.2.9 串口打印调试/下载电路 开发板除了用于单步仿真调试/下载SWD接口,也可以作为ST-Link去调试下载其它SWD接口。这里把两个接口做在了一起,实现了两个板子直连,一个作为ST-Link去调试/下载,一个作为开发板被调试/下载。所涉及的引脚参考表 3.4.1,原理图如图 5.2.13 所示。 ![]() 图 5.2.10 SWD接口调试/下载电路 【总结】 本小结带领读者学习了如何读原理图,也对STM32的最小系统有了一些了解。后面的电路众多,在分析某个电路时,结合相关手册的参考电路和说明,加上不断积累,相信读者也能灵活分析原理图。 5.3 嵌入式C语言 5.3.1 基础知识 嵌入式C语言和普通C语言在语法上几乎没有差别,其主要差别在于普通C语言的运行环境是OS之上,有很多的标准库函数支撑调用,分配的内存是电脑的内存,其处理器就是电脑的CPU;而在嵌入式环境中,会涉及到底层的硬件,而硬件本身是没有标准库可以调用的,因而就需要开发者使用C语言编程调试硬件,使其可以工作,对于开发某一款芯片,有针对的编译器(或者交叉编译环境),可以分配的内存则是芯片的RAM、Flash,处理器则是芯片自身带的MCU,例如ARM、DSP等。 例如C语言编程的入门课:打印“Hello World!”,在普通C语言编程中,直接调用printf()函数即可在PC上打印出;而在嵌入式中,则需要开发者使用C语言去将芯片的串口调试成功,然后将printf()函数重新实现,方可调用打印。 嵌入式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) 除了模块化设计,通常嵌入式的编程设计还有层次化设计。在一个工程系统中,硬件驱动仅仅只是第一步,对硬件的应用则是一个功能丰富的系统的更进一步的设计,通常在这一块会设计到例如图像处理、数据处理等算法;我们可以笼统的将一个嵌入式工程系统分为驱动层和应用层。 5.3.2 数据类型 在C语言中,数据类型指的是用于声明不同类型的变量或函数的一个广泛的系统,变量的类型决定了变量存储占用的空间以及如何解释存储的位模式。 在嵌入式系统中,芯片的容量是有限的,且对比于PC机容量通常都是比较小的,因而了解变量所占用的存储空间是嵌入式开发者应当掌握的一项技能,所以对于不同数据类型在不同位数的芯片中(例如STM32xxx就表示此款芯片是32bit的芯片,STM8xxx表示此款芯片是8bit的芯片)的长度开发者也应该掌握。 C语言中的数据类型有以下几种: ![]() 图 5.3.1 C语言数据类型 就以STM32F103C8这一款芯片为例,这是一块32bit的MCU,基本数据类型在此款芯片中的数据长度,以及在HAL库函数中的定义(stdint.h文件中的定义,采用C99标准)如图 5.3.2 所示。这里建议开发者在开发过程中使用库定义的数据类型,来定义变量或函数,比如unsigned char a,使用uint8_t a。 ![]() 图 5.3.2 本手册芯片涉及数据类型 5.3.3 const用法 C语言中const关键字是constant的缩写,译为常量、常数等,但const关键字不仅仅是用于定义常量,还可以用于修饰数组、指针、函数参数等。 1) 修饰变量 C语言中使用const修饰变量,功能是对变量声明为只读特性,并保护变量值以防被修改。例如:
这个例子表明整形变量i具有只读性,不能够被修改;若想对其重新赋值,例如i=10则是错误的用法。需要注意的是,const定义变量的同时还必须对其初始化,const可以放在数据类型的前面或者后面,比如上述例子也可以写成:
此外,const修饰变量还起到了节约空间的目的,通常编译器并不给普通const只读变量分配空间,而是将它们保存在符号列表中,无需读写内存操作,程序执行效率也会提高。 2) 修饰数组 C语言中const还可以修饰数组,例如:
const关键字修饰数组与修饰变量类似,表明此数组具有只读性,不可修改,一旦被更改程序会出错,例如上述例子如果:
则程序将会提示错误。 3) 修饰指针 C语言中const修饰指针需要特别注意,共有两种形式,一种是用来限定指向空间的值不可修改;另一种是限定指针不可修改,例如:
对于指针p1,const修饰的是*p1,即p1指向的空间的值不可改变,例如*p1 = 20;就是错误的用法;但是p1的值是可以改变的,例如p1 = &k;则没有任何问题。 对于指针p2,const修饰的是p2,即指针本身p2不可更改,而指针指向空间的值是可以改变的,例如*p2 = 15;是没有问题的,而p2 = &i;则是错误的用法。 4) 修饰函数参数 在C语言中const修饰函数参数对参数起限定作用,防止其在函数内部被意外修改,所限定的参数可以是普通变量也可以是指针变量,如:
5.3.4 作用域与static用法 在了解static关键字的用法之前,我们需要先了解C语言中的作用域、局部变量和全局变量的概念。 一个C变量的作用域可以是块作用域、函数作用域、函数原型作用域或文件作用域。 块是用一对花括号“{}”括起来的代码区域,定义在块中的变量具有块作用域。块作用域的可见范围是从定义处到包含该定义的块的末尾。以前,具有块作用域的变量都必须声明在块的开头,C99标准放宽了这一限制,允许在块中的任意位置声明变量。例如不支持C99标准的的for循环需要这样写:
在函数fun的开头定义了局部变量i,然后在for循环中调用此变量,变量i的作用域是函数fun内,当函数fun执行完毕之后变量i会被释放。而C99标准下可以这样写:
这样写的话,变量i的作用域则在for循环体内,当循环结束后,变量就会被释放,可见其作用域缩小了,这样的好处是增加了安全性和灵活性。 在函数fun1中,变量i被声明在函数体内,我们称这样的变量为局部变量,其有效范围是在被定义的函数内,函数执行完毕后变量即被释放;如果把这个变量定义在函数体外,如:
我们则将定义在函数体外的变量称之为全局变量,其作用范围为当前源文件和工程,若其它源文件想要调用用此变量需要在文件内使用关键字extern声明,如extern int k。 简单的总结下局部变量和全局变量的特点: 1) 局部变量会在每次声明的时候被重新初始化(如果在声明的时候有初始化赋值),不具有记忆能力,其作用范围仅在某个块作用域可见; 2) 全局变量只会被初始化一次,之后会在程序的某个地方被修改,其作用范围可以是当前的整个源文件或者工程; 鉴于两种变量的局限性,就引入了静态变量(静态局部变量和静态全局变量),使用关键字static来修饰。其中静态局部变量满足局部变量的作用范围,但是其拥有记忆能力,不会在每次生命的时候都初始化一次,这个作用在用来实现计数功能的时候非常方便,例如:
在这个函数中,变量num就是静态局部变量,在第一次进入cnt函数的时候被声明,然后执行自加操作,num的值就等于1;当第二次进入cnt函数的时候,num不会被重新初始化变成0,而是保持1,再自增则变成了2,以此类推,其作用域仍然是cnt这个函数体内。 静态全局变量则将全局变量的作用域缩减到了只当前源文件可见,其它文件不可见,简单例子如下:
静态全局变量的优势是增强了程序的安全性和健壮性,因为对于变量k而言,我们假设我们不期望其它的文件有修改变量k的能力,但是其它的文件又需要变量k的值来进行逻辑运算,那我们就可以向上述例子那样做,在源文件中定义一个静态全局变量,同时使用函数对其的值进行修改和获取,对外只提供函数接口即可,其它文件通过函数接口间接的使用这个变量。这样做同时也可以提高可移植性。 静态全局变量只在本文件可见,因而其它文件也可以定义相同名字的静态局部变量,例如我们可以在source1.c里面定义static int k = 0;的同时也可以在source2.c里面也定义一个static int k = 0;这样做是不会有问题的,但是我们一点都不建议如此做,因为这不利于程序的可读性和可维护性,也容易让开发变得混乱。 在C语言中static关键字除了用来修饰变量之外,还可以用来修饰函数,让函数仅在本文件可见,其它文件无法对其进行调用,例如在example1.c文件里面进行了如下定义:
那么gt_fun这个函数就只能在example1.c中被调用,在example2.c中就无法调用这个函数。而如果不使用static来修饰这个函数,那么只需要在example2.c中使用extern关键字写下语句extern void gt_fun(void);即可调用gt_fun这个函数。 在嵌入式C语言编程中,static是一个非常灵活非常好用的关键字,它可以让程序更简洁、更安全、更具有可移植性,在嵌入式系统中这三点都是非常重要的编程思想,需要认真掌握。 5.3.5 extern用法 在上一小节有提到过extern这个关键字,那么这节就来详细说一说这个关键字。在C语言中,extern关键字用于指明函数或变量定义在其它文件中,提示编译器遇到此函数或者变量的时候到其它模块去寻找其定义,这样被extern声明的函数或变量就可以被本模块或其它模块使用。因而,extern关键字修饰的函数或者变量是一个声明而不是定义,例如:
extern关键字还有一个重要的作用,就是如果在C++程序中要引用C语言的文件,则需要用以下格式:
这段代码的含义是,如果当前是C++环境(_cplusplus是C++编译器中定义的宏),要编译花括号{}里面的内容需要使用C语言的文件格式进行编译,而extern “C”就是向编译器指明这个功能的语句。 5.3.6 volatile用法 volatile原意是“易变的”,在嵌入式环境中用volatile关键字声明的变量,在每次对其值进行引用的时候都会从原始地址取值。由于该值“易变”的特性所以,针对其的任何赋值或者获取值操作都会被执行(而不会被优化)。由于这个特性,所以该关键字在嵌入式编译环境中经常用来消除编译器的优化,可以分为以下三种情景: 1) 修饰硬件寄存器; 2) 修饰中断服务函数中的非自动变量; 3) 在有操作系统的工程中修饰会被多个应用修改的变量; 修饰硬件寄存器 以STM32F103的HAL库函数中GPIO的定义举例,如下为HAL库中GPIO寄存器定义:
其中__IO的定义是:
然后定义GPIO是:
而GPIOx_BASE的定义是这样的:
其中APB2外设基地址的定义:
最后再来看外设基地址的定义:
综合起来,将宏定义一一展开,仅用GPIOA来看,其它的以此类推:
如此定义之后,那么GPIOA的CRL的地址就是:
CRH的地址就是:
后面的寄存器以此类推,因而在程序中使用:
那么实现的功能就是对GPIOA的CRH的寄存器的最低位拉高。如果在定义GPIO的寄存器结构体里面没有使用__IO uint16_t,而是仅使用uint16_t,那么在程序中再用语句:
就有可能会被编译器优化,不执行这一语句,从而导致拉高CRH的最低位这个功能无法实现;但是库函数中使用了volatile来修饰,那么编译器就不会对此语句优化,在每次执行这一语句的时候都会从CRH对应的内存地址里面去取值或者存值,保证了每次执行都是有效的。 在有操作系统的工程中修饰会被多个任务修改的变量 在嵌入式开发中,不仅仅有单片机裸机开发,也有带有操作系统的开发,通常两者使用C语言开发的较多。在有操作系统(比如RTOS、UCOS-II、Linux等)的设计中,如果有多个任务在对同一个变量进行赋值或取值,那么这一类变量也应使用volatile来修饰保证其可见性。所谓可见即:当前任务修改了这一变量的值,同一时刻,其它任务此变量的值也发生了变化。 5.3.7 struct用法 设计程序最重要的一个步骤就是选择一个表示数据的好方法。在多数情况下,使用简单的变量甚至数组都是不够的。C使用结构变量进一步增强了表示数据的能力。C的结构的基本形式就足以灵活的表示多种数据,并且能够创建新的形式。 C的结构的声明格式如下:
此声明描述了一个由n个数据类型的成员组成的结构,它并未创建实际的数据对象,只描述了该对象由什么组成。分析一下结构体声明的细节,首先是struct关键字,它表明跟在其后的是一个结构,后面是一个可选的标记,后面的程序中可以使用该标记引用该结构,因而我们可以在后面的程序中可以这样声明: struct [结构体名] 结构体变量; 在结构体声明中用一对花括号括起来的是结构体成员列表。每个成员都用自己的声明来描述。成员可以是任意一种C的数据类型,甚至可以是其它结构。右花括号后面的分号是声明所必需的,表示该结构布局定义结束,例如:
可以把结构的声明放在所有函数的外部,也可以放在一个函数的内部。如果把一个结构声明在一个函数的内部,那么它的标记就只限于函数内部使用;如果把结构声明在所有函数的外部,那么该声明之后的所有函数都能使用它的标记。 结构有两层含义,一层含义是“结构布局”,如上述例子的struct student{…};告诉编译器如何表示数据,但是它并未让编译器为数据分配空间;另一层含义是创建一个结构体变量,如上述例子的struct students student;编译器执行这行代码便创建了一个结构体变量student,编译器使用students模板为该变量分配空间:内含50个元素的char型数组1、50个元素的char型数组2,一个int型的变量和一个float的变量,这些存储空间都与一个名称为student结合在一起,如图 5.3.3 所示。 ![]() 图 5.3.3 结构体存储示意 在内存中这个结构中的成员也是连续存储的。在通常程序设计中,struct还会与typedef一起使用,具体的会在后面的《typedef用法》一节介绍。 5.3.8 enum用法 enum是C语言中用来修饰枚举类型变量的关键字。在C语言中可以使用枚举类型声明符号名称来表示整型常量,使用enum关键字可以创建一个新的“类型”并指定它可具有的值(实际上,enum常量是int类型,因此只要能使用int类型的地方就可以使用枚举类型)。枚举类型的目的是提高程序的可读性,其语法与结构的语法相同,如下:
例如:
enum常量 在上面的例子中,red, greeb, blue, yellow到底是什么?从技术层面来讲,它们是int类型的整型常量,例如可以这样使用:
可以观察到最后打印的信息是:red=0,green=1。 red成为一个有名称的常量,代表整数0。类似的,其它的枚举符都是有名称的常量,分别代表1~3。只要是能使用整型常量的地方就可以使用枚举常量,例如,在声明数组的时候可以使用枚举常量表示数组的大小,在switch语句中可以把枚举常量作为标签。 enum默认值 默认情况下,枚举列表中的常量都被赋予0,1,2等,因此下面的声明中,apple的值是2:
enum赋值 在枚举类型中,可以为枚举常量指定整数值:
如果只给一个枚举常量赋值,没有对后面的枚举常量赋值,那么后面的常量会被赋予后续的值,例如:
那么cat=0,lynx、puma、tiger的值分别是10、11、12。 5.3.9 typedef用法 typedef工具是一个高级数据特性,利用typedef可以为某一类型自定义名称。这方面与#define类似,但是两者有三处不同: 1) 与#define不同,typedef创建的符号只受限于类型,不能用于值; 2) tyedef由编译器解释,不是预处理器; 3) 在其受限范围内,typedef比#define更灵活; 假设要用BYTE表示1字节的数组,只需要像定义个char类型变量一样定义BYTE,然后再定义前面加上关键字typedef即可:
随后便可使用BYTE来定义变量:
该定义的作用域取决于typedef定义所在的位置。如果定义在函数中,就具有局部作用域,受限于定义所在的函数。如果定义在函数外面,就具有文件作用域。 为现有类型创建一个名称,看起来是多此一举,但是它有时的确很有用。在前面的示例中,用BYTE代替unsigned char表明你打算用BYTE类型的变量表示数字而不是字符。使用typedef还能提高程序的可移植性。 用typedef来命名一个结构体类型的时候,可以省略该结构的标签(struct):
这样使用typedef定义的类型名会被翻译成:
使用typedef的第二个原因是:tyedef常用于给复杂的类型命名,例如:
把pFunction声明为一个函数,该函数返回一个指针,该指针指向一个void型。 使用typdef时要记住,typedef并没有创建任何新类型,它只是为某个已有的类型增加了一个方便使用的标签。 5.3.10 预处理器与预处理指令 本节将简单介绍C语言的预处理器及其预处理指令。首先是预处理指令,它们是:
在这些指令中,#line、#error、#pragma在基础开发中比较少见,其它的都是在编程过程中经常遇到和经常使用的,所以我们在后面的章节将主要介绍这些常用的指令。 C语言建立在适当的的关键字、表达式、语句以及使用他们的规则上。然而C标准不仅描述C语言,还描述如何执行C预处理器。 C预处理器在执行程序之前查看程序,因而被称之为预处理器。根据程序中的预处理指令,预处理器把符号缩写替换成其表示的内容(#define)。预处理器可以包含程序所需的其它文件(#include),可以选择让编译器查看哪些代码(条件编译)。预处理器并不知道C,基本上它的工作是把一些文本转换成另外一些文本。 由于预处理表达式的长度必须是一个逻辑行(可以把逻辑行使用换行符‘\’变成多个物理行),因而为了让预处理器得到正确的逻辑行,在预处理之前还会有个编译的过程,编译器定位每个反斜杠后面跟着换行符的示例,并删除它们,比如:
转换成一个逻辑行:
另外,编译器把文本划分成预处理记号序列、空白序列和注释序列(记号是由空格、制表符或换行符分割的项),需要注意的是,编译器将用一个空格字符替换每一条注释,例如:
将变成:
这样编译处理后,程序就准备好进入预处理阶段,预处理器查找一行中以#号开始的预处理指令。然后我们就从#define指令开始讲解这些预处理指令。 5.3.11 #define与#undef用法 #define预处理器指令以#号作为一行的开始,到后面的第一个换行符为止。也就是说,指令的长度仅限于一行。然而在预处理开始前,编译器会把多行物理行处理为一行逻辑行,例如:
每行#define(逻辑行)都由3部分组成。第1部分是#define指令本身;第2部分是选定是缩写,也称为宏,有些宏代表值,例如:
这些宏被称为类对象宏。C语言还有类函数宏,在后面介绍。宏的名称中不允许有空格,而且必须严格遵循C变量的命名规则:只能使用字符、数字和下划线字符,且首字符不能是数字。第3部分称为替换列表或替换体。一旦预处理器在程序中找到宏的示例后,就会用替换体代替该宏。从宏变成最终替换文本的过程称为宏展开。例如上例我们如果使用:
就会被预处理器展开成:
最后输出的结果是x=2。 可以看到宏可以表示任意字符串常量,甚至可以表示整个C表达式,例如下面整个取绝对值的宏定义:
宏展开有个值得注意的点是:预处理器会严格按照替换体直接替换,不做计算不做优先级处理,例如下面求取平方值的宏定义:
我们假设这样使用:
输出的结果为4。 但是如果我们这样使用:
那么与编译器就会这样展开:
输出的结果为8。 但是实际按照逻辑2+2的平方是16,得到8的结果是因为前面所说的预处理器不会做计算只会严格按照替换体的文本进行直接替换,因而为了避免类似的问题出现,我们应该这样改写平凡宏定义:
这样上述的2+2的平方的例子就会被展开成这样:
就会得到正确的输出16。 上述的求取绝对值的宏定义或者求取平方值的宏定义中我们可以看到其形式与函数类似,此类宏定义就是前面所说的类函数宏。类函数宏定义的圆括号可以有一个或多个参数,随后这些参数出现在替换体当中。 #undef指令用于取消已定义的#define指令。 5.3.12 文件包含#include 当预处理器发现#include预处理指令时,会查看后面的文件名并把文件的内容包含到当前文件中,即替换文件中的#include指令。这相当于把被包含文件的全部内容输入到源文件#include指令所在的位置。
在UNIX中,尖括号<>告诉预处理器在标准系统目录中寻找该文件,双引号“”告诉预处理器首先在当前目录(或指定路径的目录)中寻找该文件,如果未找到再查找标准系统目录:
集成开发环境(IDE,比如开发板的开发环境keil)也有标准就或系统头文件的路径。许多集成环境提供菜单选项,指定用尖括号时的查找路径。 为什么我们要包含文件?因为编译器需要这些文件中的信息,例如stdio.h中通常包含EOF、NULL、getchar()和putchar()的定义。此外,该文件还包含C的其它的I/O函数。而对于我们自定义的文件,对于嵌入式开发来说,可能这些文件就有需要使用到的某些引脚宏定义、简单的功能函数宏定义等,以及某个源文件的全局变量和函数的声明等。 C语言习惯用.h后缀表示头文件,这些文件包含需要放在程序顶部的信息。头文件经常包含一些预处理指令,有些头文件由系统提供,也可以自定义。 下面是一个子自定义一个头文件的示例:gpio.h, main.c
#include指令也不是只包含.h文件,它同样也可以包含.c文件。 5.3.13 条件编译 可以使用预处理指令创建条件编译,即可以使用这些指令告诉编译器根据编译时的条件执行或忽略代码块。 1) #ifdef、#else和#endif指令 我们用一个示例来看这几个指令:
#ifdef指令说明,如果预处理器已定义了后面的标识符,则执行#else或#endif指令之前的所有指令并编译所有C代码,如果未定义且有#elif指令,则执行#else和#endif指令之间的代码。 #ifdef、#else和C和if else很像,两者的主要区别在于预处理器不识别用于标记块的花括号{},因此它使用#else(如果需要的话)和#endif(必须存在)来标记指令块。 2) #ifndef指令 #ifndef指令与#ifdef指令的用法类似,也可以和#else、#endif一起使用,但是它的逻辑和#ifdef指令相反。 3) #if和#elif #if指令很像C语言中的if。#if后面紧跟整型常量表达式,如果表达式为非零,则表达式为真,可以在指令中使用C的关系运算符和逻辑运算符:
条件编译还有一个用途是让程序更容易移植。改变文件开头部分的几个关键的定义即可根据不同的系统设置不同的值和包含不同的文件。 5.3.14 指针用法 什么是指针?从根本上看,指针是一个值为内存地址的变量。正如char类型变量的值是字符,int类型变量的值是整数,指针变量的值是地址。 因为计算机或者嵌入式设备的硬件指令非常依赖地址,指针在某种程度上把程序员想要表达的指令以更接近机器的方式表达,因此,使用指针的程序更有效率。尤其是指针能够有效地处理数组,而数组表示法其实是在变相的使用指针,比如:数组名是数组首元素的地址。 要创建指针变量,首先要声明指针变量的类型。假如想把ptr声明为储存int类型变量地址的指针,就要使用间接运算符*来声明。 假设已知ptr指向bah,如下表示:
然后使用间接运算符*找出储存在bah中的值:value = *ptr;此运算符有时也被称为解引用运算符。语句ptr=&bah;value=*ptr;放在一起的效果等效于:value=bah; 那么该如何声明一个指针变量呢?是这样吗:
为什么不能这样声明一个指针变量呢?因为声明指针变量时必须指定指针所指向变量的类型,不同的变量类型所占据的储存空间是不同的,一些指针操作需要知道操作对象的大小。另外程序必须知道储存在指定地址的数据类型。例如:
类型说明符表明了指针所指向对象的类型,解引用符号*表明声明的变量是一个指针。int *pi声明的意思是pi是一个指针,*pi是int类型,如图 5.3.4 所示。 ![]() 图 5.3.4 声明并使用指针 这仅仅是指针的简单使用,实际指针的世界千变万化,丰富多彩,纵使多年C语言开发的老手,有时在面对指针的使用也会出错,后继者更应谨慎求索,后面将会对指针常见的应用和注意事项进行介绍。 1) 指针与数组 前面提到可以使用地址运算符&获取变量所在的地址,而在数组中同样可以使用取地址运算符获取数组成员中任意成员的地址,例如:
输出的结果是:week is 3。对这段代码的释义参照上图 5.3.3。 2) 指针与函数 指针在函数中的使用最简单的是作为函数的形参,比如:
这个例子有几点值得讲解的地方,第1点指针pdata是作为函数的形参存在,指向一个储存int类型变量的地址;第2点指针pdata++;语句执行后,pdata只想的地址自增的不是1,而是int类型所占的大小,加入pdata最初的值是0,int类型占2个字节,那么pdata++;语句执行后,pdata的值就变成了2,而不是1,而*pdata的值是地址2所在的值不是地址1所在的值;第3点这个函数有个危险,即函数实现的是从pdata最初指向的地址开始往后的10个int类型变量的和,假如我们这样使用:
可以看到数组data的数组名即数组的首地址作为参数输入到函数sum里,而数组的大小只有5个int,函数sum计算的却是10个数的和,因而就会出现地址溢出,得不到正确的结果甚至于程序跑飞。为了避免这个问题,通常的解决方法是加一个数量形参:
或者给出指针范围:
指针与函数的关系除了指针作为函数形参外还有另一个重要的应用,那边是函数指针,比如在typedef用法章节的那个例子:
在这个例子中,首先*表明pFunction是一个指针变量,其次前面的void表示这个指针变量返回一个void类型的值,最后括号里面的void表明这个函数指针的形参是void类型的。如何使用函数指针调用函数呢?看下面这个例子:
输出的结果是:2。 3) 指针与硬件地址 指针与硬件地址的联系在volatile用法章节的例子中惊鸿一现,没有详细介绍,下面做详细说明。比如在STM32F103C8T6中内部SRAM的基地址是0x20000000,我们想对这片空间的前256个字节写入数据,就可以使用指针指向这个基地址,然后开始写:
除了内存地址,还可以指向硬件外设的寄存器地址,操作方式与上述例子类似。 指针应用的基本原则: 首先必须要指定指针的类型; 如果是普通指针变量,非函数形参或者函数指针,必须要给指针变量指定地址,避免成为一个“野指针”; 5.3.15 回调函数 在C语言中回调函数是函数指针的高级应用。所谓回调函数,一个笼统简单的介绍就是一个被作为参数传递的函数。从字面上看,回调函数的意思是:一个回去调用的函数,如何理解这句话呢?从逻辑上分析,要“回去”,必然存在着一个已知的目的地,然后在某一个时刻去访问;那么回调函数就是存在一个已知的函数体A,将这个函数体A的地址即函数名“A”(函数名即是这个函数体的函数指针,指向这个函数的地址)告知给另外某个函数B,当那个函数B执行到某一步的时候就会去执行函数A。 回调函数的应用有很多,因之后的程序都是在STM32的HAL库下编写的,因而此处我们仅从HAL库出发来看其中的回调函数。 我们仅以GPIO的HAL库函数来看,文件名“stm32f1xx_hal_gpio.c”。我们用逆分析的方法来看这个回调函数。 首先是GPIO的回调函数声明:
可以看到其函数名是:HAL_GPIO_EXTI_Callback,形参是GPIO_Pin表示引脚号(Px0~Px15,x=A,B,C,D,E,F,G),从这个函数的名称出发,可以大致明确这是一个引脚的外部中断(EXTI)的回调函数。然后大家看到前面还有个“__weak”,这是“弱函数”的修饰符,告诉编译器如果用户在其它地方用void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)重新定义了此回调函数那么优先调用用户定义的,否则调用这个弱函数修饰的回调函数。 紧接着我们来看此回调函数是在哪里被调用的:
可以看到是在GPIO的外部中断服务函数中被调用的,与前面所说的这是一个外部引脚中断回调函数印证一致了。 GPIO的回调函数到此就说完了。其实STM32的HAL库中其它大多数的外设的回调函数基本都是如此,用户如果设计需求,就自己重定义需求的回调函数,然后在中断中被调用。 5.3.16 位运算 位运算是指二进制位之间的运算。在嵌入式系统设计中,常常要处理二进制的问题,例如将某个寄存器中的某一个位置1或者置0,将数据左移5位等,常用的位运算符如表 5.3.1 所示。 ![]() 表 5.3.1 位运算符 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 对应的内存地址为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,其它位保持不变的效果,实现了单独控制对应引脚电平输出高。 |
【2025·STM32峰会】GUI解决方案实训分享5-调通板载的NRF24L01 SPI接口并使用模块进行无线通信(发送和接收)
【2025·STM32峰会】GUI解决方案实训分享2-编译运行TouchGFX咖啡机例程(含桌面仿真)
实战经验 | Keil工程使用NEAI库的异常问题
STM32 ISP IQTune:真正零门槛的免费ISP调整软件
【经验分享】STM32 新建基于STM32F40x 固件库的MDK5 工程
意法半导体MCU双供应链策略,打消中国客户后顾之忧
2024意法半导体工业峰会:赋能智能电源和智能工业,构筑可持续未来
ST推出灵活、面向未来的智能电表通信解决方案,助力能源转型
意法半导体 x Qu-Bit Electronix:推动新一轮的数字声音合成革命
从STM32 MPU产品看嵌入式系统中微处理器的新变化