——STM32 HAL库开发系列<2>
在前面两篇教程中我们已经完成了对于STM32CubeMx+CLion的开发环境搭建和对于STM32F1系列芯片硬件结构的了解和嵌入式系统开发学习路线的确定,本文主要的内容和目的就是详细了解相关工程文件的结构和IDE的使用方法。
本页内容概览
1.STM32CubeMx+CLion完成Blink任务
在众多嵌入式开发工作学习人员和爱好者的交流中,“点个灯”一直是作为经典例程来执行的,标志着相关硬件平台和软件开发环境的搭建成功,这里的“点灯”任务即使开发板板载的发光LED按照程序设定的流程自动化闪烁。本次工程使用的开发板为STM32F103C8T6-BluePill最小系统板,在淘宝上非常热销且物美价廉,相关图片如下:
首先打开CLion并新建工程,创先STM32CubeMX工程,新建工程目录的最后一个文件夹的名字就是工程的名字,我们这里起名为blink。创建工程后等待CLion自动配置完毕,配置完毕后可以看到工程文件夹中只有一个可见文件:blink.ioc。这个文件就是STM32CubeMX开发环境的工程文件,可以看到这里需要对这个文件进行配置,点击“通过STM32CubeMX打开”,启动cubemx对ioc文件进行配置。
启动CubeMX后可以看到CLion为我们自动生成的ioc文件使用的芯片是F030F4Px,那么首先就需要更换芯片为我们使用的F103C8T6,点击CubeMX界面左上角的芯片标号打开替换芯片的搜索界面,在Part-Number一栏中输入我们的芯片编号搜索可以看到如果所示的芯片型号,单击选定后点击右上角的Start Project重新载入工程。
重新载入工程后可以看到芯片的图形化操作界面已经换成了F103C8的48引脚封装并且对各个引脚进行了说明,首先来配置芯片核心功能。我们这里主要完成一个点亮LED灯的任务,板载的LED接入在PC13引脚上所以需要配置PC13的GPIO为输出Output模式;同样我们使用ST-Link连接开发板进行下载程序与调试功能所以要配置Debug选项为Serial Wire串行总线调试,配置如下左图所示。完成核心功能的配置后点击RCC选项配置开发板使用外部高速晶振信号作为时钟,将HSE选项调整为Crystal代表石英晶振如下右图所示。
完成相关配置后点击上方的Clock Configuration标签页开始配置时钟信号,在前文的介绍中我们可以发现时钟配置事实上是比较复杂的,相关内容将在后面的文章中介绍,但是STM32CubeMX为我们提供了一种较为傻瓜式的配置方法,我们只需要将HCLK一栏填写为72MHz点击确定即可自动生成时钟树的配置,具体操作如下左图所示。随后在Project Manager标签页中完成工程文件位置与工程结构的配置,工程名称要与CLion中的工程名称完全一致,工程目录也要一致。至于IDE选项选择SW4STM32选项即可,完成一系列配置后点击Generate Code按钮自动生成基础驱动代码与工程结构,等配置完成后点击对话框的Close即可。
完成配置后回到CLion界面可以看到工程已经配置完毕,IDE已经完成了对Cube所生成代码的识别,等待右下角状态栏的Cmake工程加载完成后将弹出一个对话框令用户选择想要的开发板描述cfg文件,我们选择和开发板相匹配的blue pill文件并且选择粘贴到项目后使用,如下图所示,到这里就完成了整个工程的基础配置工作和代码生成工作。当然对于开发板描述cfg文件我们还需要进行一定的更改,具体代码与说明如下所示,更改左侧工程目录中的cfg文件即可
#以#开头的语句为说明性质的注释文件,可以忽略不计,重要代码只有以下四句
source [find interface/stlink.cfg]
#这一句用于寻找下载程序和调试内核使用的接口stlink
transport select hla_swd
#这一句用于配置计算机与MCU之间的通讯协议为SWD协议
set FLASH_SIZE 0x20000
#这一句用于配置芯片内部FLASH的大小为128K,在后文中会详细说明
source [find target/stm32f1x.cfg]
#这一句用于配置开发板板载芯片为stm32发f1系列芯片
接着我们将ST-Link与开发板末端的四根排针用杜邦线进行连接后插入到电脑USB接口上,如果在电脑的设备管理器(打开方式:右键此电脑》管理》设备管理器)中识别到STLink设备证明驱动没有问题,如果识别为一个无法正常工作的设备那么就需要安装相关驱动,驱动文件与相关连接图片如图所示:
STLINK-V2对Windows7、Windows8、Windows10的驱动程序下载链接
完成上述配置后从左侧工程文件结构中打开Src/main.c也就是我们工程的入口文件,在其中的主函数中的死循环while写入每1000ms就改变PC13引脚电平的代码就会实现每1s令LED闪烁一次的效果,相关代码片段如下所示,此处有死循环Warning为正常情况,工程没有Error即可。接着点击右上角的绿色”播放“按钮即可运行工程,即编译工程并将工程下载至MCU中。
/* USER CODE BEGIN WHILE */
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
HAL_Delay(1000);
HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_13);
}
/* USER CODE END 3 */
2.工程配置过程中有可能出现的BUG
- 无法在CLion中使用STM32CubeMX打开ioc文件,在Mac系统中十分常见
- CLion没有识别到STM32CubeMX程序或者OpenOCD开源调试上位机
- IDE没有识别Build、Run、Debug等功能对应的指令目标(Target)
- 在下载程序时出现错误Warn : UNEXPECTED idcode: 0x2ba01477
这些错误前三个多数是因为没有正确配置CLion、Cube、OpenOCD造成的,如果以下的调试步骤对您的状况而言没有帮助请参照STM32 HAL库开发系列<0>先将开发环境配置良好后再次尝试。第一个错误出现的原因大部分时候是因为Java运行环境配置有错误引起的,也就是JRE版本的错误。从原理上来讲CLion和Cube都是使用Java编写的软件,所以运行时都要调用JRE才能完成开启。一般状况上来讲二者的安装包都带有自带的JRE不会出现这种错误,但是从Clion打开Cube的时候就是使用CLion自带的JRE启动Cube,这就可能造成不同版本JRE的不兼容问题,一般来说是CLion的JRE版本对于Cube来说过新或者是过于老旧。简单的解决思路就是将两个软件同时更新至最新版本,如果还是不行参照JetBrains官方更换JRE的说明。
第二个错误有可能是因为未能够正确配置MinGW编译环境,OpenOCD与Cube位置引起的,需要在CLion的设置中进行更改,需要注意OpenOCD的安装目录最好不要出现空格和中文,因为其开发者并未考虑过调用路径中出现空格和非ASCII字符的状况,故而容易引起很多谜之BUG,完成目录的填写后点击测试按钮如果出现绿色对勾或者未出现报错则证明成功,配置图如下所示:
第三种错误大多数状况是因为在构建工程时未能够完成第二种问题中三个环境的配置引起的,需要手动重新配置编译目标(Target)以能够使用编译、运行、调试相关命令,其表现为这三个按钮不是灰色的。事实上这个配置非常重要,它决定了编译器、CMake以及各种相关自动化指令如何组合并且按照一定的顺序运行并作用于代码或者MCU。
第四种错误的出现比较诡异,这个并不是因为用户软件配置而产生的问题,而是用户购买开发板硬件的时候运气不佳。。。这个问题中报错所提到的0x2ba01477事实上是芯片的CPU-ID,这个序列号应当以0x01开头而不是0x02开头,引起这种错误的原因可能是国产山寨芯片、工程测试用芯片等等诡异的状况,不过不用担心在使用性能上出现问题,至少在一般的学习过程中这种芯片发挥仍然稳定。解决这个问题的方法很简单:原因是硬件的序列号和文件所记载的序列号不同,既然我们无法更改硬件序列号那么更改软件序列号即可完成Debug。上文提到的板子cfg文件中有target/stm32f1x.cfg,那么只需要在这个文件中将序列号更改即可,更改步骤和代码如下:
#文件位置位于 OpenOCD安装目录/share/openocd/scripts/target/stm32f1x.cfg
#例如我的文件位置为C:/OpenOCD/OpenOCD-20210729-0.11.0/share/openocd/scripts/target/stm32f1x.cfg
#用记事本打开文件将如下代码片段中的0x1ba01477更改为0x2ba01477即可生效
if { [info exists CPUTAPID] } {
set _CPUTAPID $CPUTAPID
} else {
if { [using_jtag] } {
# See STM Document RM0008 Section 26.6.3
set _CPUTAPID 0x3ba00477
} {
# this is the SW-DP tap id not the jtag tap id
set _CPUTAPID 0x1ba01477
}
}
完成更改后重新run一下工程一般来说就不会出现CPUID对不上的问题了,当然如果用户想要保全两种不同的CPUID最简单的做法就是新建一个文件stm32f1x_fake.cfg复制原本的文件内容后更改CPUID,下次改动时只需要在工程中改动板子cfg文件中的fing/target即可。以下提供blink例程压缩包,解压后导入IDE使用。
3.STM32CubeMX-SW4STM32工程结构详解
上图是工程结构的树状图,只列举了较为重要的一些内容,还有一些不是很重要的临时文件或者是备份文件或者由于没有开启相关功能而没有生成的文件,例如只有开启了USB功能和FreeRTOS功能后才会出现的Middleware文件夹就没有进行列举。首先,以project结尾的隐藏文件用于描述工程的组织方式,这部分文件不会对代码内容起到什么作用,只是指导IDE完成对工程目录结构的加载,同样xml文件也是辅助IDE构建工程的脚本文件。ioc文件在上文中提到过,主要是用于配置Cube的功能并且生成相关代码。
文件CMakeLists.txt是一个至关重要的文件,有cmake使用经验的开发者经常需要编写和阅读这一文件,我们知道多文件c编译的工作比较复杂,而CMake就是跨平台自动化编译的重要工具也是当今最受欢迎和使用最广泛的工具,这个txt文件的作用就是指导cmake生成makefile和自动化执行编译指令的。同样的可以看到camke-build-debug文件夹主要是存放一些临时文件和中间文件,例如编译过程中在进行了预处理后生成的.i文件,交叉编译后生成的.o文件,汇编结束后的.s文件和作为编译结果也就是需要烧录入MCU芯片中的所谓”固件“包括.bin.hex.elf三种文件格式。
startup文件夹中存放的是用于启动工程的汇编语言文件.s这个文件作为开发者其实绝大多数时间不需要去关注,直接使用ST官方提供的文件即可。而ld文件则是面对芯片的FLASH存储器的”烧录指导性文件“,我们编译完成的程序是从0x00000000开始执行的,但是STM32芯片的程序存储器地址是从0x08000000开始的因此就需要进行一个偏移,同样的也需要对SRAM的开始地址进行偏移,对SRAM的大小定义和FLASH大小的定义也存在于这个文件中,假设说这个文件规定FLASH大小为64K而你的程序编译后有89K大小那么这个文件就会包括警告你超出空间限制无法烧录。cfg文件是用于描述开发板资源的文件,在前文中给出了较为详细的说明与样例代码,主要定义了编译目标面对何种芯片,FLASH大小是多少,调试器使用什么协议按照何种方式烧录固件。
接下来将要说的就是真正的”代码“相关文件夹。Drivers文件夹中存放的就是HAL库的驱动代码,例如说我们引用一个延时函数HAL_Delay的时候我们并未定义这个函数,这个函数的定义和生命就是在Drivers文件夹中存放的。在上一篇教程中曾经提到,MCU芯片事实上可以分为两部分,一部分是ARM公司提供的CPU内核和调试接口,CMSIS文件夹中存放的就是这部分硬件的驱动代码;另一部分是ST官方集成在芯片中的外围设备和总线,控制这部分硬件的驱动代码就存放在HAL_Driver结尾的文件夹中,面对不同的产品这个文件夹的前缀也不同,而只有内核改变时CMSIS中存放的代码才会发生改动。
接下来就是除了”驱动“这种基础代码之外的”核心功能“代码部分了,正常来说这部分代码应该全部由程序员独立编写,但是CubeMX就通过Generate Code的功能为我们准备好了相对固定的底层代码以便程序员只编写距离核心应用层较近但是距离驱动层较远的代码。Inc文件夹存放了相关的.h头文件,而Src文件夹存放的是.c或者.cpp(使用C++编译时)源代码文件。这其中每一个不同的文件都有其功能,例如it结尾的文件通常定义和存放了MCU中断和嵌套中断向量表NVIC的内容,config结尾文件通常集成了对于驱动层代码的详细配置,而随着IIC,UART等等功能的加入这个文件夹中也会存放有相关功能的代码。这其中最为特殊的就是main.h和main.c文件,这两个文件是程序编译的”主线“,在main源码中书写了作为整个固件入口的函数main函数,并且在main函数中完成了一系列芯片初始化运作的工作。
如果日后我们创建了属于自己的库,属于自己的代码包或者轮子,我们可以通过编辑cmake文件的方式将我们的include路径和源代码路径包含到编译过程中,那么我们的代码就会和存放在Inc和Src中的代码一样代入到固件编译的过程中去。值得一提的是,在编写程序的过程中如果功能不是像例程一样简单,应当对每一个功能进行单独的文件封装,最后整合成为代码包半独立于工程存在,可以通过对cmake文件的快速编辑增加或者删除功能,在main等代码中仅仅写入对开发者自定义代码包API的调用,这样就增强了工程代码的复用性和可移植性,是非常好的工程构建习惯。
4.源代码main.c结构详解
#include "main.h"
void SystemClock_Config(void);
static void MX_GPIO_Init(void);
int main(void){
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
while (1){
HAL_Delay(1000);
HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_13);
}
}
void SystemClock_Config(void){
RCC_OscInitTypeDef RCC_OscInitStruct = {0};
RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};
RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE;
RCC_OscInitStruct.HSEState = RCC_HSE_ON;
RCC_OscInitStruct.HSEPredivValue = RCC_HSE_PREDIV_DIV1;
RCC_OscInitStruct.HSIState = RCC_HSI_ON;
RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE;
RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9;
if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK)
Error_Handler();
RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_SYSCLK
|RCC_CLOCKTYPE_PCLK1|RCC_CLOCKTYPE_PCLK2;
RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;
RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2;
RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;
if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2) != HAL_OK)
Error_Handler();
}
static void MX_GPIO_Init(void){
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOC_CLK_ENABLE();
__HAL_RCC_GPIOD_CLK_ENABLE();
__HAL_RCC_GPIOA_CLK_ENABLE();
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET);
GPIO_InitStruct.Pin = GPIO_PIN_13;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
}
void Error_Handler(void){
__disable_irq();
while (1){}
}
#ifdef USE_FULL_ASSERT
void assert_failed(uint8_t *file, uint32_t line){}
#endif
以上代码是main.c文件去掉不必要的空格和各类注释后的“压缩版”源代码,让我们来分析一下这份源代码文件。首先它的include部分只有一个main.h文件,这个文件中定义了HAL库必须的常量和宏,当然也包括用户在Cube图形界面中定义的Constant值,还有各种HAL底层驱动需要的库文件。如果我们使用了其他我们自己添加的库和代码或者是常量和宏定义应当也在这个部分进行代码编写和定义。
可以看到在main函数开始前定义了两个函数:SystemClock_Config函数用于配置时钟树,这也就是我们前文在Cube中设置的RCC使用HSE晶振和Clock Configuration中配置的时钟树的代码标识。开发者应当认识到对最终编译出来的固件具有影响的只有编译工具链和代码本身,Cube代表的图形化配置方法只是一种较为方便的“编写”代码的方式,并不能直接作用于最终固件本身,最终还是要通过代码编译实现想要的功能。MX_GPIO_Init函数用于配置芯片PC13的输出模式,也就是整个芯片GPIO的初始化函数。当然,当我们加入其他功能例如UART,IIC等功能后也会生成对应的Init函数。
使用过Arduino IDE的开发者对于这样一种代码组织形式肯定不陌生:首先执行一个setup函数后循环执行一个loop函数,这样就将MCU的工作分为两部分:准备部分和循环执行部分。可以注意到while(1)明显就是这种循环,但是为什么这是一个死循环呢?我们都知道一个函数执行完毕后就会向调用它的位置返回一个值,我们执行的很多应用程序在结束时就会向操作系统返回一个值;但是在单片机中我们进行的编程事实上不属于“应用程序“这一层级而是”操作系统“这一层级,如果操作系统执行结束那么将没有后续代码可以执行,也就是MCU陷入了死机状态。
观察上述代码在大循环while之前执行的部分,就可以将其看作setup的部分。在这个部分中首先执行的函数是HAL_Init,这个函数是用于配置并且初始化HAL驱动函数库的,只有执行了这个函数才能够调用HAL库中其他的功能,否则容易造成程序跑飞陷入宕机状态。随后首先执行的就是对系统时钟的配置,其实这很好理解,如果芯片内部的时序是混乱的或者是未激活的那么其他所有依赖于时序执行的程序将不可使用,事实上几乎没有指令不依赖于时序执行,所以它必须是最先配置的。执行完时钟配置后就可以对各个功能进行配置了,由于blink这个工程中只是使用了GPIO的功能,所以只是执行了对GPIO的初始化。当然很多功能同时工作时初始化顺序较为重要,例如USB的reset动作就需要在USB的初始化前执行,否则每次MCU复位重启后就需要重新插拔USB接口。
在main函数结束后还定义了两个错误处理函数:Error_Handler函数广泛的用于各种硬件接口和外设初始化或者调用出错时执行,可以看到这个函数执行了两个动作,首先disable掉了irq,也就是停止了所有的中断触发器,这很好理解,当代码执行出错后可能发生了软硬件结合的无法预料的错误后再通过中断使用某种功能就可能造成数据错误、Error无法定义甚至硬件损伤等严重后果。随后执行一个死循环事实上就是人工宕机掉了处理器以达到“保护现场,不求有功但求无过”的目的。这个函数其实只能解决“可以讲道理”的错误现象,因为他是被主动调用的而不是被动触发的。举个例子就是假设某保安每晚上每个小时巡视一次他所保护的财产,如果发现丢失或者破坏就报警,这就是这个函数的作用。但是假设我们作为一个机智的小偷在保安巡视的空档偷窃,那么显然这个机制是无法生效的。
其实要解决这个问题MCU中有一个很实用的功能就是看门狗Watch Dog,当然可以被继续细分其功能,这一点会在后续的文章中进行详细阐述。这个看门狗的主要功能是每过一段时间会产生一个被动触发的中断让芯片发出一个信号去喂狗,如果没有按时喂狗,那么说明程序执行出问题了。当然这种方法也不能保证百分之百的安全,事实上要解决这个问题工程师们做了很多努力例如协处理器或者冗余系统等等。另外一个错误处理函数是assert_faile,可以看到这个函数存在一个预处理的ifdef中,而我们并未定义所需要的宏所以这个函数可以看做未定义,这个函数一般只在Debug开发中作为测试程序错误的接口使用。
5.使用GDB结合OpenOCD进行调试
GDB是类Unix系统中用于进程调试的一个软件工具,它可以在程序中打入断点,监控程序的进程查看程序的内存,是非常强大且方便的调试工具,很多图形化的调试工具底层都是通过GDB或者类似GDB的方式去实现调试功能。在CLion中可以使用前文提到的调试按钮开启调试功能,在这之前最好要对程序打入断点,单机行号右侧的空白将对相应的语句打入断点,每当程序运行到这个位置就会自动停止,下图展示了程序中打入断点后的状态和查看并管理所有断点的界面。
在打入断点后可以如同其他IDE一样进行调试动作,例如:追踪变量值,单步执行等等,也可以查看FLASH中存储的数据,例如查看固件代码的二进制形式就可以从0x08000000开始查看,如果需要查看目前内存中的变量就可以从0x20000000开始查看,当然查看到的都是二进制数据,只不过以十六进制的方式来表示,下图展示了调试界面与FLASH查看界面。再次进行前文提到过的友情提示,如果OpenOCD的安装路径中有空格和中文则极可能导致GDB无法正常作用并且报错为超时或者exit code=1。