(此图由GorgonMeducer借助GPT4进行一系列关键词调校后生成)
【说在前面的话】
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) //�ж�SPָ��λ��
{
StackAddr = *(__IO uint32_t*)addr;
ResetVector = *(__IO uint32_t *)(addr + 4);
__set_MSP(StackAddr);
Jump_To_Application = (pFunction)ResetVector;
Jump_To_Application();
}
}
向量表是一个由 32bit 数据构成的数组 数组的第一个元素是 uintptr_t 类型的指针,保存着复位后主栈顶指针(MSP)的初始值。 从数组第二个元素开始,保存的是 (void (*)(void)) 类型的异常处理程序地址(BIT0固定为1,表示异常处理程序使用Thumb指令集进行编码) 数组的第二个元素保存的是复位异常处理程序的地址(Reset_Handler)
从理论上说,要想保证APP能正常执行,Bootloader通常要在跳转前“隐藏自己存在过的事实”——需要“对房间进行适度的清理”,并模拟芯片硬件的一些行为——假装芯片复位后是直接从APP开始执行的。总结来说,Bootloader在跳转到App之前需要做两件事:
清理房间——仿佛Bootloader从未执行过一样 模拟处理器的硬件的一些复位行为——假装芯片从复位开始就直接从APP开始执行
一般来说,做到上述两点,就可以实现App将Bootloader视作黑盒子的效果,从而带来极高的兼容性。甚至在App注入了“跳床(trumpline)”的情况下,实现App既可以独立开发、调试和运行,也可以不经修改的与Bootloader一起工作的奇效。
从APP的向量表中读取MSP的初始值并以此来初始化MSP寄存器; 从APP的向量表中读取Reset_Handler的值,并跳转到其中去执行——完成从Bootloader到APP的权利交接。
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. 通过函数指针完成从Bootloader到App的跳转:
Jump_To_Application = (pFunction)ResetVector;
Jump_To_Application();
【C语言基础设施是什么】
函数调用 函数间的参数传递 分配局部变量 暂时保存通用寄存器中的内容 ……
由于Cortex-M处理器会在复位时由硬件完成对C语言基础设施(也就是栈顶指针MSP)的初始化,因此无论是理论上还是实践中,从复位异常处理程序Reset_Handler开始用户就可以完全可以使用C语言进行开发了,而整个启动代码(startup)也可以全然不涉及任何汇编; 由于Cortex-M的向量表是一个完全由 32位整数(uintptr_t)构成的数组——保存的都是地址而非具体代码,可以使用C语言的数据结构直接进行描述——因此也完全不需要汇编语言的介入。
【C语言编译器的约定】
StackAddr = *(__IO uint32_t*)addr;
ResetVector = *(__IO uint32_t *)(addr + 4);
__set_MSP(StackAddr);
Jump_To_Application = (pFunction)ResetVector;
Jump_To_Application();
/**
\brief Set Priority Mask
\details Assigns the given value to the Priority Mask Register.
\param [in] priMask Priority Mask
*/
__STATIC_FORCEINLINE void __set_PRIMASK(uint32_t priMask)
{
__ASM volatile ("MSR primask, %0" : : "r" (priMask) : "memory");
}
从这点来看,上述代码的确打破了这份约定。即便如此,很多小伙伴会心理倔强的认为:我就这么改了,怎么DE了吧?!
【问题的分析】
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) //�ж�SPָ��λ��
{
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
注意这里,StackAddr、ResetVector是两个局部变量,由编译器在栈中进行分配。汇编指令将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) //�ж�SPָ��λ��
{
...
}
对应:
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]
void JumpToApp(uint32_t addr);
可知,r0 中保存的就是形参 addr 的值。所以第一句汇编的意思就是:根据 (addr + 0)作为地址读取一个uint32_t型的数据保存到r1中。
对于局部变量 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语言编译器这么自由的么?”
“在我使用sp之前把栈顶指针更新了?!”
上述“骚操作”的后果是:保存在r0中的Reset_Handler地址值被保存到了新栈中(MSP + 4)的位置。这立即带来两个潜在后果:
由于MSP指向的是栈存储器的末尾(栈是从数值较大的地址向数值较小的地址生长),因此 (MSP+4)实际上已经超出栈的合法范围了。这一操作与其说是会覆盖栈后续的存储空间,倒不如说风险主要体现在BusFault上——因为相当一部分人习惯将栈放到SRAM的最末尾,而MSP+4直接超出SRAM的有效范围。 我们以为的ResetVector其实已经不在原本C编译器所安排的地址上了。
精彩的还在后面:
Jump_To_Application = (pFunction)ResetVector;
对应的翻译是:
00001e b002 ADD sp,sp,#8
000020 4700 BX r0
通过前面的分析,我们知道,此时r0中保存的是Reset_Handler的地址,因此 BX r0 能够成功完成从Bootloader到APP的跳转——也许你会松一口气——好像局部变量ResetVector的错位也没引起严重的后果嘛。
00001e b002 ADD sp,sp,#8
它与一开始局部变量的分配形成呼应:
000000 b082 SUB sp,sp,#8
...
00001e b002 ADD sp,sp,#8
好借好还,再借不难。但此sp非彼sp了呀!
【宏观分析】
从原理上说,将关键信息保存在依赖栈的局部变量中,然后在编译器不知情的情况下替换了栈所在的位置,此后只要产生对相关局部变量的访问就有可能出现“刻舟求剑”的数据错误。这种问题是“系统性的”、“原理性的”。
(此图由GorgonMeducer借助GPT4进行一系列关键词调校、配上台词后获得)
不同编译器、同一编译器的不同版本、同一版本的不同优化选项都有可能对同一段C语言代码产生不同的编译结果,因此哪怕我们经过上述分析得出某一段汇编代码似乎不会产生特别严重的后果,在严谨的工程实践上,这也只能算做是“侥幸”,是埋下了一颗不知道什么时候以什么方式引爆的定时炸弹。
根据用户Bootloader代码在修改 MSP 前后对局部变量的使用情况不同、考虑到用户APP行为的不确定性、由上述缺陷代码所产生的Bootloader与APP之间配合问题的组合多种多样、由于涉及到用户栈顶指针位置的不确定性以及新的栈存储器空间中内容的随机性,最终体现出来的现象也是完全随机的。用人话说就是,经常性的“活见鬼”
【解决方案】
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) //�ж�SPָ��λ��
{
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 修饰关键局部变量的方法可以救一时之疾(“只怪老板催我催得紧,莫怪我走后洪水滔天”),也算不得妥当。
第二个思路:既然问题出在局部变量上,我用静态(或者全局)变量不就可以了?修改源代码为:
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) //�ж�SPָ��λ��
{
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都要全数消灭的团队来说,上述方案多半也是不可容忍的。
第三个思路:完全用汇编来处理从Bootloader到App的最后步骤。对此我只想说:稳定可靠,正解。只不过需要注意的是:这里整个函数都需要用纯汇编打造,而不只是在C函数内容使用在线汇编。原因很简单:既然我们已经下定决心要追求极端确定性,就不应该使用线汇编这种与C语言存在某些“暧昧交互”的方式——因为它仍然会引入一些意想不到的不确定性。本着一不做二不休的态度,完全使用汇编代码来编写跳转代码才是万全之策。
【说在后面的话】
请务必 “点赞、收藏、转发” 三连,这对我很重要!谢谢!
欢迎订阅 裸机思维