问题由来

       近日在公司项目中,现场遇到STM32L431设备启动起来之后设备屏幕显示异常,无法正常启动,对此故障原因进行分析定位,设备代码分为bootloader、与APP应用两部分,定位之后异常原因为设备启动进入bootloader之后,进行一系列操作,随后跳转APP应用,跳转之后APP无法启动,调试发现原因为APP部分全局变量初始化数值被异常更改。因此有了此次记录。

一、bootloader伪代码

出于保密原则,此处不变贴出全部代码,以伪代码代替;

typedef  void (*pFunction)(void);
#define ApplicationAddress (0x08004000)

void JumpToApp(uint32_t addr)
{
    pFunction Jump_To_Application;

    __IO uint32_t StackAddr;
    __IO uint32_t ResetVector;
    __IO uint32_t JumpMask;

    JumpMask = ~((MCU_SIZE - 1) | 0xD000FFFF);

    if (((*(__IO uint32_t *)addr) & JumpMask) == 0x20000000)
    {
        __set_PRIMASK(1);
        StackAddr = *(__IO uint32_t *)addr;
        ResetVector = *(__IO uint32_t *)(addr + 4);
        __set_MSP(StackAddr);
        Jump_To_Application = (pFunction)ResetVector;
        Jump_To_Application();
    }
}

int main()
{
    /* 初始化相关 */
    ...
    JumpToApp(ApplicationAddress);
}

二、原因分析

        在STM32应用中经常会需要基于用户程序的做代码更新、升级,即In Applicaton Programming,简称IAP。这个过程往往需要程序从不同执行区做跳转,最常见的自然是从启动区跳往应用程序区,即从BOOT区跳往APP区。

        为了保证在跳转过程不出异常,主要注意两点:

        第一点:即将跳转到程序区的内存地址、中断矢量表地址。在STM32库例程里,中断矢量表地址的修改一般采用基地址加偏移量的代码写法。

        第二点:在做跳转前,做好准备工作。执行跳转前,当前程序区不能存在尚未处理的中断请求。

        明确这两点之后,我们分析伪代码中跳转过程处理,可以发现在跳转APP之前,进行了两个汇编指令

        __set_PRIMASK(1);
        __set_MSP(StackAddr);

        其中这两句汇编的作用分别是禁止全局中断与设置栈顶指针MSP寄存器,重点是 __set_PRIMASK(1);此语句等价于__disable_irq()函数;该函数只是临时关闭中断响应,不会阻止中断事件的发生及相应中断标志的生成

        一般来讲,自己开启了哪些中断大致是清楚的。对于STM32用户来讲,如果基于CubeMx创建工程,SYSTICK定时器中断默认开启,担当Cube库函数的滴答时基,很多延时相关都使用到它。我们在做跳转前,需要将其计数器停掉或关闭它的中断请求能力。

        采用以下任意写法可以规避在跳转时出现的中断事件发生,规避数据被修改的风险。

SysTick->CTRL&=~SysTick_CTRL_TICKINT_Msk;

SysTick->CTRL&=~SysTick_CTRL_ENABLE_Msk;

SysTick->LOAD = 0 ; 

三、其他风险

        让我们回到一开始的伪代码,为了读懂这段代码,需要一些从事Cortex-M开发所需的“热知识”:

  • 向量表是一个由 32bit 数据构成的数组

  • 数组的第一个元素是 uintptr_t 类型的指针,保存着复位后主栈顶指针(MSP)的初始值。

  • 从数组第二个元素开始,保存的是 (void (*)(void)) 类型的异常处理程序地址(BIT0固定为1,表示异常处理程序使用Thumb指令集进行编码)

  • 数组的第二个元素保存的是复位异常处理程序的地址(Reset_Handler

  •         从理论上说,要想保证APP能正常执行,Bootloader通常要在跳转前“隐藏自己存在过的事实”——需要“对房间进行适度的清理”,并模拟芯片硬件的一些行为——假装芯片复位后是直接从APP开始执行的。总结来说,Bootloader在跳转到App之前需要做两件事:

    1. 清理房间——仿佛Bootloader从未执行过一样

    2. 模拟处理器的硬件的一些复位行为——假装芯片从复位开始就直接从APP开始执行

            一般来说,做到上述两点,就可以实现AppBootloader视作黑盒子的效果,从而带来极高的兼容性。甚至在App注入了“跳床(trumpline)”的情况下,实现App既可以独立开发、调试和运行,也可以不经修改的与Bootloader一起工作的奇效。

            这里,“清理房间”的步骤与Bootloader具体“弄脏了什么”(或者说使用了什么资源)有关;而“模拟处理器硬件的一些复位行为”就较为简单和具体:即,从Bootloader跳转到App前的最后两个步骤为:

    1. 从APP的向量表中读取MSP的初始值并以此来初始化MSP寄存器;

    2. 从APP的向量表中读取Reset_Handler的值,并跳转到其中去执行——完成从Bootloader到APP的权利交接。

            结合前面的例子代码,值得我们关注的部分是:

    1. 使用自定义的函数指针类型 pFunction 定义一个局部变量

    pFunction Jump_To_Application;

    2. 根据向量表的首地址 addr 读取第一个元素——作为MSP的初始值暂时保存在局部变量 StackAddr 中:

    StackAddr = *(__IO uint32_t*)addr;

    3. 根据向量表的首地址 addr 读取第二个元素——将Reset_Handler的首地址保存到局部变量 ResetVector 中:

    ResetVector = *(__IO uint32_t *)(addr + 4);

    4. 设置栈顶指针MSP寄存器:

    __set_MSP(StackAddr);

    5. 通过函数指针完成从BootloaderApp的跳转:

        Jump_To_Application = (pFunction)ResetVector;
        Jump_To_Application();

            其实,无论具体的代码如何,只要实现步骤与上述类似,就存在一个隐藏较深的漏洞,而漏洞的“触发与否”则完全“看脸”——简单来说:只要你是按照上述方法来实现从Bootloader到App的跳转的,那么就一定存在问题——而“似乎可以正常工作”就只是你运气较好,或者“由此引发的问题暂时未能引发注意”罢了。

    3.1  C语言基础设施是什么

            嵌入式系统的信息安全(Security)建立在基础设施安全(Safety)的基础之上。由于“确保信息安全的很多机制”本质上是一套建立在“基础设施能够正常工作”这一前提之上的规则和逻辑,因此很多针对信息安全的攻击往往会绕开信息安全的“马奇诺防线”,转而攻击基础设施。

            芯片数字逻辑的基础设施是时钟源、供电、总线时序、复位时序等等,因此,针对硬件基础设施的攻击通常也就是针对时钟源、电源、总线时序和复位时序的攻击。

            固件一般由C语言进行编写,那么C语言所依赖的基础设施又是什么呢?

    对C语言编译器来说,栈的作用是无可替代的:

  • 函数调用

  • 函数间的参数传递

  • 分配局部变量

  • 暂时保存通用寄存器中的内容

  • ……

  •         可以说,离开了栈C语言寸步难行。因此对很多芯片来说,复位后为了执行用户使用C语言编译的代码,第一个步骤就是要实现栈的初始化

    3.2  Cortex-M的一个"冷知识"

            作为一个有趣的“冷知识”,Cortex-M在宣传中一直强调自己“支持完全使用C语言进行开发”,这让很多人“丈二和尚摸不着头脑”甚至觉得“非常可笑”——因为这年月连51都支持用户使用C语言进行开发了,你这里说的“Cortex-M支持使用C语言进行开发”有什么意义呢?

            其实门道就在这里:

  • 由于Cortex-M处理器会在复位时由硬件完成对C语言基础设施(也就是栈顶指针MSP)的初始化,因此无论是理论上还是实践中,从复位异常处理程序Reset_Handler开始用户就可以完全可以使用C语言进行开发了,而整个启动代码(startup)也可以全然不涉及任何汇编;

  • 由于Cortex-M的向量表是一个完全由 32位整数(uintptr_t)构成的数组——保存的都是地址而非具体代码,可以使用C语言的数据结构直接进行描述——因此也完全不需要汇编语言的介入。

  •         这种从复位一开始就完全不需要汇编介入的友好环境才是Cortex-M声称自己“支持完全使用C语言进行开发”的真实意义和底气。从这一角度出发,只要某个芯片架构复位后必须要通过软件来初始化栈顶指针,就不符合“从出生的那一刻就可以使用C语言”的基本要求。

    3.3 C语言编译器的约定

            栈对C语言来说如此重要,以至于编译器一直有一条默认的约定,即:

            栈必须完全交由C语言编译器进行管理(或者用户对栈的操作必须符合对应平台所提供的调用规约,比如ArmAAPCS规约)。

            简而言之,如果你“偷偷摸摸”的修改了栈顶指针,C语言编译器是会“假装”完全不知道的,而此时所产生的后果C语言编译器会默认自己完全不用负责。 回头再看这段代码:

        __set_PRIMASK(1);
        StackAddr = *(__IO uint32_t*)addr;
        ResetVector = *(__IO uint32_t *)(addr + 4);
    
        __set_MSP(StackAddr); 
    
        Jump_To_Application = (pFunction)ResetVector;
        Jump_To_Application();

            虽然我们觉得自己“正大光明”的使用了 __set_MSP() 来修改了栈顶指针,但它实际上是一段C语言编译器并不理解其具体功能的在线汇编——在编译器看来,无论是谁提供的 __set_MSP(),只要是在线汇编,这就算是用户代码——是编译器管不到的地带。

            或者说:C语言编译器一般情况下会默认你“无论如何都不会修改栈顶指针”——它不仅管不着,也不想管

            从这点来看,上述代码的确打破了这份约定。

    3.4 问题的分析

            从原理上说,开篇那个Bootloader跳转代码所存在的问题已经昭然若揭:

    typedef  void (*pFunction)(void);
    
    void JumpToApp(uint32_t addr)
    {
        pFunction Jump_To_Application;
    
        __IO uint32_t StackAddr;
        __IO uint32_t ResetVector;
        __IO uint32_t JumpMask;
    
        JumpMask = ~((MCU_SIZE - 1) | 0xD000FFFF);
    
        if (((*(__IO uint32_t *)addr) & JumpMask) == 0x20000000)
        {
            __set_PRIMASK(1);
            StackAddr = *(__IO uint32_t *)addr;
            ResetVector = *(__IO uint32_t *)(addr + 4);
            __set_MSP(StackAddr);
            Jump_To_Application = (pFunction)ResetVector;
            Jump_To_Application();
        }
    }
    

            我们不妨结合上述代码反汇编的结果进行深入解析:

    AREA ||i.JumpToApp||, CODE, READONLY, ALIGN=2
                      JumpToApp PROC
    000000  b082              SUB      sp,sp,#8
    000002  4909              LDR      r1,|L2.40|
    000004  9100              STR      r1,[sp,#0]
    000006  6802              LDR      r2,[r0,#0]
    000008  400a              ANDS     r2,r2,r1
    00000a  2101              MOVS     r1,#1
    00000c  0749              LSLS     r1,r1,#29
    00000e  428a              CMP      r2,r1
    000010  d107              BNE      |L2.34|
    000012  6801              LDR      r1,[r0,#0]
    000014  9100              STR      r1,[sp,#0]
    000016  6840              LDR      r0,[r0,#4]
    000018  f3818808          MSR      MSP,r1
    00001c  9001              STR      r0,[sp,#4]
    00001e  b002              ADD      sp,sp,#8
    000020  4700              BX       r0
                      |L2.34|
    000022  b002              ADD      sp,sp,#8
    000024  4770              BX       lr
                              ENDP
    
    000026  0000              DCW      0x0000
                      |L2.40|
                              DCD      0x2fff0000

            注意这里,StackAddrResetVector是两个局部变量,由编译器在栈中进行分配。汇编指令将SP指针向栈底挪动8个字节就是这个意思:

    000000  b082              SUB      sp,sp,#8

            虽然 JumpMask 也是局部变量,但编译器根据自己判断认为它“命不久矣”,因此直接将它分配到了通用寄存器r2中,并配合r1和sp完成了后续运算。这里:

    __IO uint32_t JumpMask;
    JumpMask = ~((MCU_SIZE-1)|0xD000FFFF);
    
    if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) 
      {
          ...
      }

            对应:

    000002  4909              LDR      r1,|L2.40|
    000004  9100              STR      r1,[sp,#0]
    000006  6802              LDR      r2,[r0,#0]
    000008  400a              ANDS     r2,r2,r1
    00000a  2101              MOVS     r1,#1
    00000c  0749              LSLS     r1,r1,#29
    00000e  428a              CMP      r2,r1
    000010  d107              BNE      |L2.34|
    ...
    |L2.34|
    000022  b002              ADD      sp,sp,#8
    000024  4770              BX       lr
    ENDP
    
    000026  0000              DCW      0x0000
    |L2.40|
    DCD      0x2fff0000

            考虑到JumpMask的内容与本文无关,不妨暂且跳过。

            接下来就是重头戏了:

            编译器按照用户的指示读取栈顶指针MSP的初始值,并保存在StackAddr中:

    ​StackAddr = *(__IO uint32_t*)addr;

            对应的汇编是:

    000012  6801              LDR      r1,[r0,#0]
    000014  9100              STR      r1,[sp,#0]

            根据Arm的AAPCS调用规约,编译器在调用函数时会使用R0~R3来传递前4个符合条件的参数(这里的条件可以简单理解为每个参数的宽度要小于等于32bit)。根据函数原型

    void JumpToApp(uint32_t addr);

            可知,r0 中保存的就是形参 addr 的值。所以第一句汇编的意思就是:根据 (addr + 0)作为地址读取一个uint32_t型的数据保存到r1中。

            第二句汇编中,栈顶指针sp此时实际上指向局部变量 StackAddr,因此其含义就是将通用寄存器r1中的值保存到局部变量 StackAddr 中。

            对于局部变量 ResetVector 的读取操作,编译器的处理如出一辙:

    ResetVector = *(__IO uint32_t *)(addr + 4);

            对应:

    000016  6840              LDR      r0,[r0,#4]
    00001c  9001              STR      r0,[sp,#4]

            其实就是从 (addr + 4) 的位置读取 32bit 整数,然后保存到r0里,并随即保存到sp所指向的局部变量 ResetVector 中。到这里,细心地小伙伴会立即跳起来说“不对啊,原文不是这样的!”。是的,这也是最有趣的地方。实际的汇编原文如下:

    000016  6840              LDR      r0,[r0,#4]
    000018  f3818808          MSR      MSP,r1
    00001c  9001              STR      r0,[sp,#4]

            作为提醒,它对应的C代码如下:

        ResetVector = *(__IO uint32_t *)(addr + 4);
        __set_MSP(StackAddr);

            后面的 __set_MSP(StackAddr) 所对应的汇编代码 MSR MSR,r1 居然插入到了ResetVector赋值语句的中间?!

            先别激动,还记得我们和C语言编译器之间的约定么?C语言编译器默认我们在任何时候都不应该修改栈顶指针。因此在他看来,

    “你 MSR 指令操作的是r1,关我sp和r0啥事”?

    “我就算随意更改顺序应该对你一毛钱影响都没有!(因为我不关心、也没法知道用户线汇编语句的具体效果,因此我只关心涉事的通用寄存器是否存在冲突)”

            上述“骚操作”的后果是:保存在r0中的Reset_Handler地址值被保存到了新栈中(MSP + 4)的位置。这立即带来两个潜在后果:

  • 由于MSP指向的是栈存储器的末尾(栈是从数值较大的地址向数值较小的地址生长),因此 (MSP+4)实际上已经超出栈的合法范围了。这一操作与其说是会覆盖栈后续的存储空间,倒不如说风险主要体现在BusFault上——因为相当一部分人习惯将栈放到SRAM的最末尾,而MSP+4直接超出SRAM的有效范围

  • 我们以为的ResetVector其实已经不在原本C编译器所安排的地址上了。

  •         精彩的还在后面:

    Jump_To_Application = (pFunction)ResetVector;
    Jump_To_Application(); 

    对应的翻译是:

    00001e  b002              ADD      sp,sp,#8
    000020  4700              BX       r0

            通过前面的分析,我们知道,此时r0中保存的是Reset_Handler的地址,因此 BX r0 能够成功完成从BootloaderAPP的跳转——也许你会松一口气——好像局部变量ResetVector的错位也没引起严重的后果嘛。

            看似如此,但真正吓人的是C语言编译器随后对局部变量的释放:

    00001e  b002              ADD      sp,sp,#8

            它与一开始局部变量的分配形成呼应:

    000000  b082              SUB      sp,sp,#8
    ...
    00001e  b002              ADD      sp,sp,#8

            好借好还,再借不难。但此sp非彼sp了呀!

    这里由于JumpToApp没有加上__NO_RETURN的修饰,因此C编译器并不知道这个函数是有去无回的,因此仍然会像往常一样在函数退出时释放局部变量。

            就像刚才分析的那样:由于MSP指向的是栈存储器的末尾(栈是从数值较大的地址向数值较小的地址生长),因此 (MSP+8)实际上已经超出栈存储空间的合法范围了。考虑到相当一部分人习惯将栈放到SRAM的最末尾,而MSP+8直接超出SRAM的有效范围,即便刚跳转到APP的时候还不会有事,但凡APP用了任何压栈操作,(无论是BusFault还是地址空间绕回)就很有可能产生灾难性的后果。

    3.5 宏观分析

            就事论事的讲,单从汇编分析来看,上述代码所产生的风险似乎是可控的,甚至某些人会觉得可以“忽略不计”。但最可怕的也就在这里,原因如下:

  • 从原理上说,将关键信息保存在依赖栈的局部变量中,然后在编译器不知情的情况下替换了栈所在的位置,此后只要产生对相关局部变量的访问就有可能出现“刻舟求剑”的数据错误。这种问题是“系统性的”、“原理性的”。

  • 不同编译器、同一编译器的不同版本、同一版本的不同优化选项都有可能对同一段C语言代码产生不同的编译结果,因此哪怕我们经过上述分析得出某一段汇编代码似乎不会产生特别严重的后果,在严谨的工程实践上,这也只能算做是“侥幸”,是埋下了一颗不知道什么时候以什么方式引爆的定时炸弹。

  • 根据用户Bootloader代码在修改 MSP 前后对局部变量的使用情况不同、考虑到用户APP行为的不确定性、由上述缺陷代码所产生的Bootloader与APP之间配合问题的组合多种多样、由于涉及到用户栈顶指针位置的不确定性以及新的栈存储器空间中内容的随机性,最终体现出来的现象也是完全随机的。用人话说就是,经常性的“活见鬼”

  • 3.6 解决方案

            既然我们知道不能对上述缺陷代码抱有侥幸心理,该如何妥善解决呢?

            第一个思路:既然问题是由栈导致的,那么直接让编译器用通用寄存器来保存关键局部变量不就行了?修改代码为:

    
    typedef  void (*pFunction)(void);
    
    void JumpToApp(uint32_t addr)
    {
      pFunction Jump_To_Application;
    
      register uint32_t StackAddr;
      register uint32_t ResetVector;
      register uint32_t JumpMask;
    
      JumpMask = ~((MCU_SIZE-1)|0xD000FFFF);
    
      if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) 
      {
        __set_PRIMASK(1);
        StackAddr = *(__IO uint32_t*)addr;
        ResetVector = *(__IO uint32_t *)(addr + 4);
    
        __set_MSP(StackAddr); 
        Jump_To_Application = (pFunction)ResetVector;
        Jump_To_Application(); 
      }
    }

            相同编译环境下得出的结果为:

    AREA ||i.JumpToApp||, CODE, READONLY, ALIGN=2
                      JumpToApp PROC
    
    000002  6801              LDR      r1,[r0,#0]
    000004  4011              ANDS     r1,r1,r2
    000006  2201              MOVS     r2,#1
    000008  0752              LSLS     r2,r2,#29
    00000a  4291              CMP      r1,r2
    00000c  d104              BNE      |L2.24|
    
    00000e  6801              LDR      r1,[r0,#0]
    000010  6840              LDR      r0,[r0,#4]
    000012  f3818808          MSR      MSP,r1
    
    000016  4700              BX       r0
                      |L2.24|
    000018  4770              BX       lr
                              ENDP
    
    00001a  0000              DCW      0x0000
                      |L2.28|
                              DCD      0x2fff0000

            可见,上述汇编中半个 sp 的影子都没看到,问题算是得到了解决。然而,需要注意的是 register 关键字对编译器来说只是一个“建议”,它听不听你的还不一定。加之上述例子代码本身相当简单,涉及到的局部变量数量有限,因此问题似乎得到了解决。倘若编译器发现你大量使用 register 关键字导致实际可用的通用寄存器数量入不敷出,大概率还是会用栈来进行过渡的——此时,哪些局部变量用栈,哪些用通用寄存器就完全看编译器的心情了。进一步的,不同编译器、不同版本、不同优化选项又会带来大量不可控的变数。因此就算使用 register 修饰关键局部变量的方法可以救一时之疾(“只怪老板催我催得紧,莫怪我走后洪水滔天”),也算不得妥当

    第二个思路:既然问题出在局部变量上,我用静态(或者全局)变量不就可以了?修改源代码为:

    
    #include "cmsis_compiler.h"
    
    typedef  void (*pFunction)(void);
    
    __NO_RETURN
    void JumpToApp(uint32_t addr)
    {
      pFunction Jump_To_Application;
    
      static uint32_t StackAddr;
      static uint32_t ResetVector;
      register uint32_t JumpMask;
    
      JumpMask = ~((MCU_SIZE-1)|0xD000FFFF);
    
      if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) 
      {
        __set_PRIMASK(1);
        StackAddr = *(__IO uint32_t*)addr;
        ResetVector = *(__IO uint32_t *)(addr + 4);
    
        __set_MSP(StackAddr); 
        Jump_To_Application = (pFunction)ResetVector;
        Jump_To_Application(); 
      }
    }

            这种方法看似稳如老狗,实际效果可能也不差,但还是存在隐患,因为它“没有完全杜绝编译器会使用栈的情况”,只要我们还会通过 __set_MSP() 在C语言编译器不知道的情况下更新栈顶指针,风险自始至终都是存在的。对某些连warning都要全数消灭的团队来说,上述方案多半也是不可容忍的。

            第三个思路:完全用汇编来处理从BootloaderApp的最后步骤。对此我只想说:稳定可靠,正解。只不过需要注意的是:这里整个函数都需要用纯汇编打造,而不只是在C函数内容使用在线汇编。原因很简单:既然我们已经下定决心要追求极端确定性,就不应该使用线汇编这种与C语言存在某些“暧昧交互”的方式——因为它仍然会引入一些意想不到的不确定性。本着一不做二不休的态度,完全使用汇编代码来编写跳转代码才是万全之策。

    写在后面的话

            bootloader运行时总会产生各种各样的问题,经常在大费周章的一通分析和调试后,发现问题的罪魁祸首就是跳转代码。可怕的是,几乎每个故障的具体现象都各不相同,表现出的随机性也常常让人怀疑是不是硬件本身存在问题,亦或是产品工作现场的电磁环境较为恶劣。最要命的当数那种“偶尔出现”而复现条件颇为玄学的情形,甚至在办公室环境下完全无法重现的也大有人在。问题的代码大多使用函数指针来实现跳转——而用局部变量来保存函数指针又成了大家自然而然的选择。加之此前很多文章都曾大规模科普上述技巧,甚至是直接包含一些存在缺陷的Bootloader范例代码,实际受影响的范围真是“细思恐极”。

            洋洋洒洒近万字,如果对读者有所帮助,也属荣幸;若此文章有所不足之处,欢迎各位进行探讨,共同学习。

    作者:蒋蒋~~

    物联沃分享整理
    物联沃-IOTWORD物联网 » 浅谈STM32 bootloader异常

    发表回复