在STM32F103C8上实现一个简单的bootloader

在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_第1张图片
配置完成后直接生成工程即可。

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_第2张图片
实际情况是这样的吗?我们可以查看编译后的Bin文件来验证一下(这里需要调用MDK的hex转bin插件,上网搜一下就行)。用UltraEdit打开刚才编写编译的Bin文件(节选),如下图所示:

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

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

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

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

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

在STM32F103C8上实现一个简单的bootloader_第6张图片

…(漂亮的省略号)


在STM32F103C8上实现一个简单的bootloader_第7张图片
左边第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_第8张图片
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_第9张图片
红框部分就是__main标号代表的地址,0x080000ed。那我们接着找0x080000ed。这里有一个小问题,上文我们知道Reset_Handler=0x08000101,但是实际上Reset_Handler在0x08000100。所以这时候我们不应该找0x080000ed,而是找0x080000ec。

找到0x080000ec处代码如下:

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

首先是第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_第11张图片
到这里就是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_第12张图片
设置好,编译程序,用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_第13张图片
生成工程,在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秒闪烁一次,验证完成。

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

联系方式:[email protected]

你可能感兴趣的:(单片机)