1.MCU 代码如何启动 首先我们需要澄清一个问题,什么是 Startup Code,什么是 Bootloader?因为总看到有同学混用这两个概念。 2 Q0 d/ Y' m! J' h8 W, @! D Bootloader 可以译为引导程序。早期的单片机是没有 Bootloader 这种概念的。如大家熟悉的 MCS51,最初芯片内是不能存储代码的,需要外挂EPROM,就是下面这种带个小玻璃窗的存储器。擦除 EPROM 中的代码需要用紫外线照射几分钟才行。$ S P: H7 q; @# B& S1 H/ ?- E, o! ~ - I/ L1 J$ \) a. B j. M 8 d: k0 G( C; \2 B 后来出现了 Flash 这种可电擦写的存储器,并集成在了单片机内部。但出厂的时候单片机的程序存储区仍然是空白的,没有任何代码。用户编译程序后,下载到单片机后才能运行。那么在产品发给用户后,如果发现有Bug怎么办呢?就得用编程器把新代码重新下载一次。这实在是有点儿麻烦,特别是如果客户距离很远的话。于是有聪明的程序猿想了一个办法,写一小段特殊的代码放在程序里,这段代码可以通过一定方式,比如用按键触发进入运行,它可以通过串口(早期的 PC 串口是标配)接收新的代码并写入Flash,从而在没有硬件编程器的情况下也能完成代码的更新。# a0 N, ?; q: F8 o2 x! Q8 c ' C! d9 M* R1 K. G0 ?1 E) N t 程序猿们也是现代历史前进的重要推动力啊! 9 _+ t& l' v! i4 q9 U# `) ] 后来,有芯片厂商把这种代码在出厂时就固化在芯片里,极大的方便了代码下载和程序更新。STM32F030内部就固化了Bootloader。当我们把一个引脚 BOOT0 拉高的同时,重新给芯片上电或复位,就会触发Boootloader进入运行。此时我们通过单片机的串口就可以把新程序发送给单片机,发送完后把 BOOT0 拉低,再复位单片机,新程序就会运行起来。2 |$ J: q$ U" _# N. o4 V+ V 6 @4 m- W9 c4 V" L Startup Code 可以译为启动代码。单片机上电或复位后最先执行的一段代码。一般主要会完成堆栈指针的设置,复位向量的获取和加载,然后初始化变量,最后跳转到用户代码。在详细看启动代码之前,我们先看一下 STM32F030 的内存映射。, Y+ c; w7 b$ d$ ?+ _3 T# ] 2.STM32F030内存映射(Memory Map)1 ~% k: ]5 u% I& N4 e 下面是 STM32F030 的内存映射,其它芯片会因为 Flash,SRAM 空间大小不同而略有不同。6 T @5 o- y: Y' P1 k' Z( r7 ` 因为是32位机,所以可寻址从 0x0000_0000 到 0xFFFF_FFFF 的总共 4G 空间。 这是采用32位机的好处,地址空间足够用。不像8位或16位机,很容易出现地址空间不够用,动不动就需要用 Page 来间接寻址。' Q" z- F9 v) y' f0 q) V+ W8 Q: B 2 W4 }8 J$ B0 `7 E' z; Z 我们从低地址到高地址逐段看一下:) {5 S+ I! z2 ^) A l* \) { 0x0000 0000 Virtual memory H* a3 T* f. f7 \( j" ~ + l. |. [8 @- R; B4 V 这段地址空间,会因为不同的 BOOT 模式而映射到不同的物理内存。 当芯片复位,或从 Standby 低功耗模式唤醒时: 1 z1 [: m [1 I4 l5 q 如果引脚 BOOT0 是被拉低的,将映射到 Flash memory。这是最常用的代码运行模式; 如果引脚 BOOT0 是被拉高的,且nBOOT1为 1 ,将映射到 System memory。进入bootloader模式;# ~: X+ T8 W+ U7 s0 N 2 Y L) U! K& s+ Q( r 如果引脚 BOOT0 是被拉高的,且nBOOT1为 0 ,将映射到 SRAM。 注:nBOOT1 为Flash寄存器中的一位,用户何以设置。" _1 ]! N/ v; x" H7 ~6 M " I; a9 I4 ?: W3 C 0x0800 0000 Flash memory4 m- j- u: L5 f 存放用户代码, T- ?" ]0 w. b: i6 M 0x1FFF EC00 System memory3 ]' K5 b) ~. H" J# T 存放 bootloader, 片内集成温度传感器的校正数据,和片内集成电压参考的校正数据 这些代码和数据是在工厂固化好的。 + V7 \5 }( w& l, _( T, Z" X 0x2000 0000 SRAM! W/ Y2 n( Q2 @7 d4 t: ] 存放用户变量,堆(Heap)和栈(Stack)。也可以把代码加载到 SRAM 运行。 0 Z% ]) h, Y, ^4 ^$ y b Z) b 0x4000 0000 Pheriperals 芯片集成的外设,如 USART, SPI, GPIO等的寄存器地址在这一区域。 0xE000 0000 Cortex-M0 internal pheriperals M0内核的外设映射到此区域。如 systick (System Tick),NVIC,Debug Registers。这些寄存器在芯片手册里是查不到的,需要到 ARM 的手册里查找。 3. 启动代码(Startup Code) 我们还是以下面这个最简单的GPIO翻转代码为例:+ r8 ^- m# t& f& O8 C6 H1 J STM32Cube_FW_F0_V1.11.0\Projects\STM32F030R8-Nucleo\ Examples\GPIO\GPIO_IOToggle\MDK-ARM\Project.uvprojx$ T7 V2 X" [* m+ T 把此工程下载到单片机后,用调试器观察下面两个地址的内容: 我们会发现0x0000_0000开始的区域, 和0x0800_0000开始的区域,内容完全相同。这说明Flash 区的内容映射到了 0x0000_0000起始的这一段地址区域。' x5 L; G. G* b9 j% |3 Y& n 5 w/ u& l- e: l ) h/ o4 M& A& d 注意STM32F030使用的是小端模式(Litlle Edian)。% C! M4 C* Q/ h- f/ F, q + J$ [& `9 Y0 u" {/ S2 Y 不同于 MCS51 在 0x0000 放的是复位向量,STM32F030 还有其它 ARM 芯片在零地址存放的是初始堆栈指针地址。 ! o/ t+ P1 I$ |$ X' F2 h 0x0000 0000: (0x2000 0428) 初始堆栈指针) T" N: s4 p4 R* i3 F) N % d8 P* H6 @0 A: r" e. R- T 0x0000 0004: (0x0800 00C9) 复位向量,上电或复位后最先加载入PC 注:单片机上电或复位后,堆栈指针初始化和 PC 初始值的加载总是从地址 0x0000_0000,0x0000_0004获取。在上面这种用户模式下,实际是从 Flash 区的 0x0800_0000,0x0800_0004 获取的。 ! z- b' q# N0 W8 `1 X# a 我们可以通过调试器观察一下芯片复位后 M0 内核的寄存器:. ?+ u3 T4 |( q. R @- o 细心的同学这时可能发现了一个问题。 * t3 \! Q; w9 W" Q& { ], ~/ l 堆栈指针 SP 的内容和前面存储器中的内容是对的上的。但是 PC 里的内容好像对不上啊?PC 里的值是 0x0800_00C8,存储器里明明是 0x0800_00C9 啊!( P+ J: }! a4 d/ K/ X6 v 6 U5 [4 U1 f7 H- Z- L6 [- b+ U 这里牵涉到了 ARM 体系里的两种工作状态 ARM 和 Thumb。ARM 状态下执行32位指令,Thumb状态下执行16位指令。那么如何在这两者之间切换呢,一个方法就是靠跳转地址的最低位(Bit0), 当 Bit0 设为 1 时进入 Thumb 状态,当 Bit0 设为 0 时进入 ARM 状态。 % Z! b: h& v# H4 N, ^! [. e 对于单片机来说,16位的 Thumb 指令就足够了,而且16位指令比32位指令能节省存储器空间。所以 M0 内核只支持 Thumb 指令。 到这里我们就可以理解复位向量为什么是 0x0800_00C9 了。0 h+ v, ?9 Q# q0 O* T9 t { 接下来我们来看复位向量 0x0800_00C8 指向的第一条指令:8 I1 w9 w. m( S6 L0 W6 h ! Q6 m! l$ K5 b. q" n/ @( O+ o r # o- Q6 n8 N# [ * ]* z; X1 m: { 单片机将要执行的第一条指令 0x4804,这是什么意思呢? : D! N0 l& t0 Y9 d5 T$ ?( y2 M 先说结论:它就是下图中,单片机复位后光标指向的这条指令:' ?* I9 ?9 \. e/ a2 M: T/ H, z/ G ' ?( d. ?$ N; F% c4 W LDR R0, =SystemInit 在这里详细解释一下 0x4804 这条指令:3 D8 j' Y Q% N' T+ K1 x 它对应的机器码是 0100100000000100 Bit15 to Bit11 (01001)为LDR(literal)指令,既从PC偏移地址取数据送至寄存器Rt。 7 g) N, A8 E8 ]! A Bit10 to Bit8 (000)表明目的寄存器Rt为 R0: Q9 h) Z6 z, c. f, U* ~1 V) W6 t- i Bit7 to Bit0 (00000100)表明相对于 PC 的偏移量为 0b10000,既0x10。; o& G! H* ]' F _# x9 L( F. p" n 7 Y/ y6 h g) ?' g 注意PC的值是当前地址+4。: R4 G. [! ?3 }' f* i3 J$ D 6 F0 e2 Q% Y) q 那么从 0x080000C8 + 0x4 + 0x10 = 0x080000DC 取出数据 0x0800092D 送至寄存器 R0。此地址是 SystemInit( )函数的地址。下一条语句 BLX R0 就是调用此系统初始化函数。$ z9 T5 j6 B* B( {9 K1 J: ]) x( G1 { SystemInit( ) 这个函数在 system_stm32f0xx.c 这个文件里,主要完成系统时钟的初始化。可以点进去看一下具体的内容。, q/ G: K; U7 ^ ( g5 L4 l s5 U0 d- u9 A! U $ ]' d7 x* z/ m0 _3 I3 b 函数 SystemInit( ) 执行完之后,程序跳转回来,取得 __main( ) 函数的地址,跳转到 __main() 函数执行。需要注意,这个函数不是我们用户代码里的 main( ) 函数。. \% \3 ^' c. n# t 4 t4 h# [& r* }8 S0 D( T __main() 函数是 Keil 的库提供的,我们看不到代码,它主要完成变量的初始化。这里不用太纠结,如果想进一步深究可以看一下 ARM Compiler User Guide 的 Reset and initialization 这一节。: g7 h- Z& Q R! B; _ __main() 函数执行完,基本工作就做完了,这才跳转到用户代码的 main( ) 函数。. l2 b" H7 f, x( s& P0 z% b3 Y$ | % m% l, j: f6 P: l0 y' m B! ` |