1.MCU 代码如何启动
首先我们需要澄清一个问题,什么是 Startup Code,什么是 Bootloader?因为总看到有同学混用这两个概念。
Bootloader 可以译为引导程序。早期的单片机是没有 Bootloader 这种概念的。如大家熟悉的 MCS51,最初芯片内是不能存储代码的,需要外挂EPROM,就是下面这种带个小玻璃窗的存储器。擦除 EPROM 中的代码需要用紫外线照射几分钟才行。
后来出现了 Flash 这种可电擦写的存储器,并集成在了单片机内部。但出厂的时候单片机的程序存储区仍然是空白的,没有任何代码。用户编译程序后,下载到单片机后才能运行。那么在产品发给用户后,如果发现有Bug怎么办呢?就得用编程器把新代码重新下载一次。这实在是有点儿麻烦,特别是如果客户距离很远的话。于是有聪明的程序猿想了一个办法,写一小段特殊的代码放在程序里,这段代码可以通过一定方式,比如用按键触发进入运行,它可以通过串口(早期的 PC 串口是标配)接收新的代码并写入Flash,从而在没有硬件编程器的情况下也能完成代码的更新。
程序猿们也是现代历史前进的重要推动力啊!
后来,有芯片厂商把这种代码在出厂时就固化在芯片里,极大的方便了代码下载和程序更新。STM32F030内部就固化了Bootloader。当我们把一个引脚 BOOT0 拉高的同时,重新给芯片上电或复位,就会触发Boootloader进入运行。此时我们通过单片机的串口就可以把新程序发送给单片机,发送完后把 BOOT0 拉低,再复位单片机,新程序就会运行起来。
Startup Code 可以译为启动代码。单片机上电或复位后最先执行的一段代码。一般主要会完成堆栈指针的设置,复位向量的获取和加载,然后初始化变量,最后跳转到用户代码。在详细看启动代码之前,我们先看一下 STM32F030 的内存映射。
2.STM32F030内存映射(Memory Map)
下面是 STM32F030 的内存映射,其它芯片会因为 Flash,SRAM 空间大小不同而略有不同。
因为是32位机,所以可寻址从 0x0000_0000 到 0xFFFF_FFFF 的总共 4G 空间。
这是采用32位机的好处,地址空间足够用。不像8位或16位机,很容易出现地址空间不够用,动不动就需要用 Page 来间接寻址。
我们从低地址到高地址逐段看一下:
0x0000 0000 Virtual memory
这段地址空间,会因为不同的 BOOT 模式而映射到不同的物理内存。
当芯片复位,或从 Standby 低功耗模式唤醒时:
如果引脚 BOOT0 是被拉低的,将映射到 Flash memory。这是最常用的代码运行模式;
如果引脚 BOOT0 是被拉高的,且nBOOT1为 1 ,将映射到 System memory。进入bootloader模式;
如果引脚 BOOT0 是被拉高的,且nBOOT1为 0 ,将映射到 SRAM。
注:nBOOT1 为Flash寄存器中的一位,用户何以设置。
0x0800 0000 Flash memory
存放用户代码
0x1FFF EC00 System memory
存放 bootloader, 片内集成温度传感器的校正数据,和片内集成电压参考的校正数据
这些代码和数据是在工厂固化好的。
0x2000 0000 SRAM
存放用户变量,堆(Heap)和栈(Stack)。也可以把代码加载到 SRAM 运行。
0x4000 0000 Pheriperals
芯片集成的外设,如 USART, SPI, GPIO等的寄存器地址在这一区域。
0xE000 0000 Cortex-M0 internal pheriperals
M0内核的外设映射到此区域。如 systick (System Tick),NVIC,Debug Registers。这些寄存器在芯片手册里是查不到的,需要到 ARM 的手册里查找。
3.启动代码(Startup Code)
我们还是以下面这个最简单的GPIO翻转代码为例:
STM32Cube_FW_F0_V1.11.0ProjectsSTM32F030R8-Nucleo
ExamplesGPIOGPIO_IOToggleMDK-ARMProject.uvprojx
把此工程下载到单片机后,用调试器观察下面两个地址的内容:
我们会发现0x0000_0000开始的区域, 和0x0800_0000开始的区域,内容完全相同。这说明 Flash 区的内容映射到了 0x0000_0000起始的这一段地址区域。
注意STM32F030使用的是小端模式(Litlle Edian)。
不同于 MCS51 在 0x0000 放的是复位向量,STM32F030 还有其它 ARM 芯片在零地址存放的是初始堆栈指针地址。
0x0000 0000: (0x2000 0428) 初始堆栈指针
0x0000 0004: (0x0800 00C9) 复位向量,上电或复位后最先加载入PC
注:单片机上电或复位后,堆栈指针初始化和 PC 初始值的加载总是从地址 0x0000_0000,0x0000_0004获取。在上面这种用户模式下,实际是从 Flash 区的 0x0800_0000,0x0800_0004 获取的。
我们可以通过调试器观察一下芯片复位后 M0 内核的寄存器:
细心的同学这时可能发现了一个问题。
堆栈指针 SP 的内容和前面存储器中的内容是对的上的。但是 PC 里的内容好像对不上啊?PC 里的值是 0x0800_00C8,存储器里明明是 0x0800_00C9 啊!
这里牵涉到了 ARM 体系里的两种工作状态 ARM 和 Thumb。ARM 状态下执行32位指令,Thumb状态下执行16位指令。那么如何在这两者之间切换呢,一个方法就是靠跳转地址的最低位(Bit0), 当 Bit0 设为 1 时进入 Thumb 状态,当 Bit0 设为 0 时进入 ARM 状态。
对于单片机来说,16位的 Thumb 指令就足够了,而且16位指令比32位指令能节省存储器空间。所以 M0 内核只支持 Thumb 指令。
到这里我们就可以理解复位向量为什么是 0x0800_00C9 了。
接下来我们来看复位向量 0x0800_00C8 指向的第一条指令:
单片机将要执行的第一条指令 0x4804,这是什么意思呢?
先说结论:它就是下图中,单片机复位后光标指向的这条指令:
LDR R0, =SystemInit
在这里详细解释一下 0x4804 这条指令:
它对应的机器码是 0100100000000100
Bit15 to Bit11 (01001)为LDR(literal)指令,既从PC偏移地址取数据送至寄存器Rt。
Bit10 to Bit8 (000)表明目的寄存器Rt为 R0
Bit7 to Bit0 (00000100)表明相对于 PC 的偏移量为 0b10000,既0x10。
注意PC的值是当前地址+4。
那么从 0x080000C8 + 0x4 + 0x10 = 0x080000DC 取出数据 0x0800092D 送至寄存器 R0。此地址是 SystemInit( )函数的地址。下一条语句 BLX R0 就是调用此系统初始化函数。
SystemInit( ) 这个函数在 system_stm32f0xx.c 这个文件里,主要完成系统时钟的初始化。可以点进去看一下具体的内容。
函数 SystemInit( ) 执行完之后,程序跳转回来,取得 __main( ) 函数的地址,跳转到 __main() 函数执行。需要注意,这个函数不是我们用户代码里的 main( ) 函数。
__main() 函数是 Keil 的库提供的,我们看不到代码,它主要完成变量的初始化。这里不用太纠结,如果想进一步深究可以看一下 ARM Compiler User Guide 的 Reset and initialization 这一节。
__main() 函数执行完,基本工作就做完了,这才跳转到用户代码的 main( ) 函数。
参考资料:
STM32F030 Datasheet
STM32F030 Reference Manual
ARM Compiler User Guide
ARM®v6-M Architecture Reference Manual