欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

在STM32F103C8上实现一个简单的bootloader

程序员文章站 2024-02-02 10:38:52
...

在STM32F103C8上实现一个简单的bootloader

最近在琢磨单片机在线更新程序的事情,查资料查到在STM32上实现一个bootloader比较简单,废话不多说,动手尝试一下。

0、项目目标

为F103C8编写一个bootloader工程,占用flash地址为:0x08000000~0x08001FFF,共8KB。这个bootloader能够从0x08002000处运行代码。(后期可能会对bootloader进行升级,增加从某处接收固件的功能)

1、准备硬件

硬件用的是淘宝上随处可见的F103C8T6核心板,便宜,外设简单,用来做这个测试最好不过了。唯一的缺点就是FLASH有点小,才64KB(对于平时工作用的16位机来说,64KB好像也挺大了嚯)。核心板上通常带有一颗LED,用来指示状态或者调试也应该足够了。此外还需要准备一个STLINK烧录调试器,用来烧写程序和调试。

2、创建工程

本来打算用Stm32cubeIDE做的,但是在IDE上实在是没找到能够修改二进制文件起始地址的地方。因此使用stm32cubeMX生成MDK的工程,然后使用MDK进行编译。(吐槽一下MDK的编译HAL的速度,真的很慢)。

工程配置非常简单,使能外部时钟、配置LED引脚为输出。如下图:

在STM32F103C8上实现一个简单的bootloader
配置完成后直接生成工程即可。

3、编写代码

3.1、测试工程

先写个简单的程序测试一下生成的工程能否正常工作。在main函数的while循环里添加如下代码。功能非常简单,就是LED每隔1秒钟亮灭。

  /* USER CODE BEGIN WHILE */
  while (1)
  {
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
    HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
    HAL_Delay(1000);
  }
  /* USER CODE END 3 */

观察核心板上的LED状态,LED开始闪烁,说明工程暂时没有问题。接着开始进入正题。

3.2、理论知识

在正式写代码之前,先补充一点关于CM3启动的知识。(水平有限,不太专业,大概理解一下好了)

3.2.1、单片机上电后,从0x08000000地址运行程序

当单片机上电时,内核会检查BOOT引脚的情况,从而引导从哪个存储器启动。这里我们只讨论从主闪存存储器启动的情况,也就是上电后,从0x08000000地址运行程序。

观察MDK默认的编译配置可发现,我们写程序编译出来的二进制文件,是存放在0x08000000起始的闪存内。如下图:

在STM32F103C8上实现一个简单的bootloader
实际情况是这样的吗?我们可以查看编译后的Bin文件来验证一下(这里需要调用MDK的hex转bin插件,上网搜一下就行)。用UltraEdit打开刚才编写编译的Bin文件(节选),如下图所示:

在STM32F103C8上实现一个简单的bootloader
接着我们打开MDK,通过STLINK进入调试状态,从View->Memory Windows->Memory 窗口查看0x08000000地址的数据(节选)。如下图所示:
在STM32F103C8上实现一个简单的bootloader
两张图的数据完全相同,至此,可以验证上面的说法:上电后,单片机从0x08000000处开始启动用户程序。

3.2.2、单片机如何运行到main()函数

要弄懂单片机如何运行到main()函数的问题,实际上就是探讨单片机是如何启动的问题。我们可以用刚刚LED闪烁的工程来分析。我们生成上述工程的反汇编文件,结合工程里的startup_stm32f103xb.s文件,一起分析启动流程。

首先生成反汇编文件。在MDK的配置选项里面可以开启,添加插件后再编译一次即可得到*.asm反汇编文件。(上文所述的生成Bin文件也可以在这里配置)具体配置如下图所示,添加两句语句即可。
在STM32F103C8上实现一个简单的bootloader
重新编译,打开反汇编文件。同时打开startup_stm32f103xb.s一起分析。

先看中断向量表部分。如下图所示,左边为startup_stm32f103xb.s文件,右边为反汇编文件。

在STM32F103C8上实现一个简单的bootloader

…(漂亮的省略号)


在STM32F103C8上实现一个简单的bootloader
左边第61行到122行,即从标号Vectors 到 Vectors_End的区域称为中断向量表。(PS:实际上0x08000000这个地址存放的20000140不是中断向量,而是栈顶地址,这里不知道为什么要用Vectors 来标记)。

如果不太了解底层的话,可能不清楚中断向量表有什么用。这里简单解释一下(水平有限,不一定对)。中断向量表里面保存着中断向量,说到底就是某个内存区域里面存放着一个地址(函数指针)。当某个中断触发时,单片机可以根据中断号,来定位到这个内存区域,进而得到这个内存区域中存放的函数指针,然后通过这个指针跳转到对应中断服务函数里面。

举个例子。上图左边第62行表示0x08000004到0x08000007这个内存区域存放着Reset_Handler函数的函数指针0x08000101。当复位中断(Reset)触发时,单片机会从0x08000004~0x08000007这个内存区域取出一个函数指针0x08000101,然后将PC指针跳转到0x08000101,从而完成一次中断处理。如果要问,为啥子单片机会知道从这个地址取处函数指针?这得问ST公司了,他们就是这样做的233333。

了解完中断向量表,接下来可以继续探讨启动流程了。

如上文所述,当单片机上电后,会触发复位中断,单片机就从0x08000004~0x08000007这个内存区域中取出地址0x08000101,然后将PC指针跳转到这个地址去继续执行程序。顺理成章地,我们就可以去看0x08000101这个地址开始的内存区域中都存放了那些代码。

直接查看startup_stm32f103xb.s中Reset_Handler标号所在位置的源代码。当然如果头比较铁,也可以从反汇编文件中定位0x08000101地址的反汇编代码。源代码的阅读性当然比反汇编代码的好,所以我就不头铁了。源代码如下图,在startup_stm32f103xb.s的129行开始:

在STM32F103C8上实现一个简单的bootloader
129~132行不需要理会,是伪代码,只做一些标记或解释作用,实际上编译后不产生机器代码。

133:将SystemInit标号代表的内存地址赋值给R0。如果想深究SystemInit标号代表的内存地址到底是多少,可以看反汇编文件。这里就不展开了。

134:跳转到R0,也就是跳转到SystemInit。

我们先不看SystemInit,我们先把剩下的两行代码看完。135到136的形式跟133到135基本相同,就是跳转到__main。

到此为止,我们知道了一件事情。单片机上电后,运行Reset_Handler。而Reset_Handler主要是运行了SystemInit和__main。(这里有个前提,那就是SystemInit运行结束后返回了。实际上就是返回了)。

那接下来探讨SystemInit干了些什么。那问题发生了,在startup_stm32f103xb.s中找不到SystemInit。我们再仔细观察前面的代码(此时我们都是列文虎克,哈哈啊哈),发现132行是不是有个IMPORT SystemInit?这行代码的意思是:导出SystemInit标号。也就是外部可以使用startup_stm32f103xb.s文件的SystemInit标号。那行,我们找找其他文件。全局搜索一下,发现在system_stm32f1xx.c文件里面。(谢天谢地,终于到C语言了。为啥子Typora不支持插入asm代码呢,截图老费劲了)。SystemInit是个函数,如下所示:

/**
  * @brief  Setup the microcontroller system
  *         Initialize the Embedded Flash Interface, the PLL and update the 
  *         SystemCoreClock variable.
  * @note   This function should be used only after reset.
  * @param  None
  * @retval None
  */
void SystemInit (void)
{
#if defined(STM32F100xE) || defined(STM32F101xE) || defined(STM32F101xG) || defined(STM32F103xE) || defined(STM32F103xG)
  #ifdef DATA_IN_ExtSRAM
    SystemInit_ExtMemCtl(); 
  #endif /* DATA_IN_ExtSRAM */
#endif 

  /* Configure the Vector Table location -------------------------------------*/
#if defined(USER_VECT_TAB_ADDRESS)
  SCB->VTOR = VECT_TAB_BASE_ADDRESS | VECT_TAB_OFFSET; /* Vector Table Relocation in Internal SRAM. */
#endif /* USER_VECT_TAB_ADDRESS */
}

可以看到,是一大串的宏定义。实际的代码只有下面两行。

SystemInit_ExtMemCtl(); 
SCB->VTOR = VECT_TAB_BASE_ADDRESS | VECT_TAB_OFFSET;

而且上面的宏定义都不成立!!(扶额叹气),所以仅剩的这两行代码亦不会运行。也就是说,SystemInit啥也没干,进去就出来了。

继续分析__main。同样地,先找一下这个标号在哪里。发现,没找到!!!嗨呀,咋没找到咧。查看反汇编文件,发现的确没有。上网查询一下,网上说这是C库文件,主要是配置C语言运行的环境,然后跳转到C语言的main()函数。这个据说跟编译器有关,是编译器添加的代码。

一般到这也就算了,毕竟我们也分析到运行main()函数了。但是啊,__main()到底做了什么事情难道不好奇吗?(此处配音,千反田:我很好奇)。那我们就再分析一下反汇编文件。无论这部分代码是由什么东西添加的,最终经过编译后,一定是在反汇编文件中有体现的,毕竟单片机最终跑的就是二进制文件。

首先找到Reset_Handler的反汇编代码,如下,留意红框部分:
在STM32F103C8上实现一个简单的bootloader
红框部分就是__main标号代表的地址,0x080000ed。那我们接着找0x080000ed。这里有一个小问题,上文我们知道Reset_Handler=0x08000101,但是实际上Reset_Handler在0x08000100。所以这时候我们不应该找0x080000ed,而是找0x080000ec。

找到0x080000ec处代码如下:

在STM32F103C8上实现一个简单的bootloader
这段代码中间穿插了比较多注释类型的文本,看着有一点点乱。其实仔细看,还是比较好看懂的。

首先是第118行,这是将__lit_00000000赋值给sp指针。

__lit_00000000在134行有所描述,再往下找,找到139行,发现这个值等于20000410。对中断向量表还有点印象的话就会发现,这个值跟中断向量表的第一个向量的值是一样的。这里可以理解为:编译器将中断向量表的第一个向量复制到了0x080000fc,并且在之后将其赋值给了sp指针。

接着来到121行,0x080000f0,这里是跳转到__scatterload去执行一些加载数据段的东西,这里不展开。

接着是129行和130行。129行是将当前PC指针指向的值+0,然后赋值给r0,接着130跳转到r0。这里可能会稍微迷惑一下,129行指向的值不是一条指令吗?怎么能加0后赋值到PC?其实不是的,仔细看129行后面的注释,实际上在执行129行时,PC指针并不是129行的0x080000f4,而是0x080000f8。这其实是流水线架构的问题,取指->译指->运行是同时进行的,也就是说,取值(PC变化)要比当前运行的地址要快2个指令。因此运行129行(0x080000f4)的指令时,PC指针已经取指到132行(0x080000f8)了。所以130行代码其实是跳转到0x08000aa1。

接下来我们跳转到0x08000aa1看一下是什么,如下图:
在STM32F103C8上实现一个简单的bootloader
到这里就是main()函数了,也就是main()函数的第一行代码HAL_Init();

至此,单片机从启动到运行main()函数的过程就分析完成了。

好的,那就先总结一下单片机启动都做了什么。

  1. 上电。
  2. 通过中断向量表,运行Reset_Handler
  3. Reset_Handler中运行SystemInit和__main
  4. SystemInit中什么都没做,跳进去马上就跳出来了。
  5. __main中将sp指针设置为0x08000000地址的值,然后加载数据段数据,最后跳转到C语言的main()函数。

就这五步,总结起来还是很简单的。

3.2.3、编写Bootloader需要做什么

我们编写bootloader的目标就是让单片机上电后,能够从0x08002000启动。现在我们都知道了,单片机上电默认是从0x08000000启动的,那怎么跳转到0x08002000呢?这就需要我们手动进行跳转了。

手动进行跳转,实际上就是模拟一次上电的过程。在真实上电时,程序从0x08000000开始跑。在我们完成bootloader的工作后,可以为单片机准备一个类似刚上电的环境,然后将PC指针指向APP程序的起始地址即可。

综上所述,编写bootloader进行程序跳转,需要以下几步:

  1. 准备新的中断向量表,将其从0x08000000偏移到0x08002000。
  2. 将SP指针设置为0x08002000内存区域的值。
  3. 将PC指针指向0x08002004即可(也可以理解为程序跳转到0x08002004)。

核心步骤就三步,当然,还可以加一步校验。我们知道APP程序的前四个字节是SP指针了,它是指向一个RAM地址的,因此一定是0x2开头的,可以稍微检查一下程序是否已经正确加载了进来。

3.3、编写程序跳转函数

有了以上的知识,接下来就可以开始编写程序了。(这里的程序参考了https://www.cnblogs.com/jiuliblog-2016/p/11411887.html这位大佬的博文)

1、中断向量表偏移。

SCB->VTOR = app_addr;	//app_addr为新程序的起始地址

2、设置SP指针

__asm void MSR_MSP(uint32_t addr)
{
    MSR MSP, r0;
    BX r14;
}

3、跳转函数的完整代码

typedef void (*APP_FUNC)();     //函数指针类型定义

/*
 * 设置SP指针函数
 */
__asm void MSR_MSP(uint32_t addr)
{
    MSR MSP, r0;
    BX r14;
}

/*
 * 跳转到APP程序函数
 */
void run_app(uint32_t app_addr)
{
	uint32_t reset_addr = 0;
    APP_FUNC jump2app;

    /* 栈顶地址是否合法(这里sram大小为8k) */
    if(((*(uint32_t *)app_addr)&0x2FFFE000) == 0x20000000)
    {
		/* 跳转之前关闭相应的中断 */
		NVIC_DisableIRQ(SysTick_IRQn);

		/* 中断向量表偏移 */
		SCB->VTOR = app_addr;
			
        /* 设置栈指针 */
        MSR_MSP(app_addr);
			
        /* 获取复位地址 */
        reset_addr = *(uint32_t *)(app_addr+4);
			
		/* 跳转到APP地址 */
        jump2app = ( APP_FUNC )reset_addr;
        jump2app();
    }
    else
    {
        //printf("APP Not Found!\n");
    }
}

这样跳转函数就写完啦。只要在合适的时候调用run_app()函数,单片机就会去执行APP程序。至于接收固件,或者自行刷写固件等功能,就等以后再完善了。

4、验证

工程写好了,当然要验证一下能不能跑啦。

4.1、简单验证

最简单的验证就是:写一个LED闪烁的程序,闪烁的速度与Bootloader的闪烁速度不相同。然后将APP工程的flash起始地址设置为:0x08002000,ROM大小设置为0x0000D000即可,如下图所示:
在STM32F103C8上实现一个简单的bootloader
设置好,编译程序,用MDK自带的烧录工具或者STM32 ST-LINK Utility工具都可以把hex文件烧录到0x08002000。值得注意的是,烧录时不要勾选擦除整片flash,不然会把bootloader程序擦除掉。

都烧录完毕后,应该就能看到核心板上的LED按照APP程序设定的频率闪烁了。

4.2、完整验证

上述的简单验证过于简单,就算没有设置中断向量表偏移都能跑(因为APP和Bootloader的Systick中断代码一样)。

为了验证中断是否能够正常工作那就写一个带中断的工程吧。简单一点,使能TIM3,再TIM3更新中断里反转LED。仍旧使用stm32cubeMX生成MDK工程,配置如图。对了,注意一点,记得在SYS子菜单下使能SW调试。之前忘记使能,烧写老麻烦了。
在STM32F103C8上实现一个简单的bootloader
生成工程,在stm32f1xx_it.c中的定时器3中断服务函数添加如下代码:

/**
  * @brief This function handles TIM3 global interrupt.
  */
void TIM3_IRQHandler(void)
{
  /* USER CODE BEGIN TIM3_IRQn 0 */
  static unsigned int sui1sCnt = 0;
  sui1sCnt++;
  if(sui1sCnt >= 1000)
  {
    sui1sCnt = 0;
    HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
  }
  /* USER CODE END TIM3_IRQn 0 */
  HAL_TIM_IRQHandler(&htim3);
  /* USER CODE BEGIN TIM3_IRQn 1 */

  /* USER CODE END TIM3_IRQn 1 */
}

值得注意的是,需要把定时器3初始化为1ms更新。

接下来在main()函数中使能定时器3基本计时(带中断),调用以下函数即可:

HAL_TIM_Base_Start_IT(&htim3);

这里可以先不改flash起始地址,先把程序烧写进单片机看运行正不正常。正常的话,修改flash起始地址为0x08002000,然后重新烧写Bootloader和APP,复位,LED1秒闪烁一次,验证完成。

当然,如果有好奇小宝宝如果想知道,如果没有偏移中断向量表的话,这个程序还能不能跑。那就把偏移中断向量表的代码注释掉,再编译烧写试试看。(剧透:跑不了了哦,而且程序会跑乱~)

相关标签: 单片机