MCU 启动和向量表
当 STM32F429 MCU 启动时,它会从 flash 存储区最前面的位置读取一个叫作 “向量表” 的东西。“向量表” 的概念所有 ARM MCU 都通用,它是一个包含 32 位中断处理程序地址的数组。对于所有 ARM MCU,向量表前 16 个地址由 ARM 保留,其余的作为外设中断处理程序入口,由 MCU 厂商定义。越简单的 MCU 中断处理程序入口越少,越复杂的 MCU 中断处理程序入口则会更多。
STM32F429 的向量表在数据手册表 62 中描述,我们可以看到它在 16 个 ARM 保留的标准中断处理程序入口外还有 91 个外设中断处理程序入口。
在向量表中,我们当前对前两个入口点比较感兴趣,它们在 MCU 启动过程中扮演了关键角色。这两个值是:初始堆栈指针和执行启动函数的地址(固件程序入口点)。
所以现在我们知道,我们必须确保固件中第 2 个 32 位值包含启动函数的地址,当 MCU 启动时,它会从 flash 读取这个地址,然后跳转到我们的启动函数。
最小固件
现在我们创建一个
main.c
文件,指定一个初始进入无限循环什么都不做的启动函数,并把包含 16 个标准入口和 91 个 STM32 入口的向量表放进去。用你常用的编辑器创建
main.c
文件,并写入下面的内容:
// Startup code
__attribute__((naked, noreturn)) void _reset(void) {
for (;;) (void) 0; // Infinite loop
}
extern void _estack(void); // Defined in link.ld
// 16 standard and 91 STM32-specific handlers
__attribute__((section(".vectors"))) void (*tab[16 + 91])(void) = {
_estack, _reset
};
对于
_reset()
函数,我们使用了 GCC 编译器特定的
naked
和
noreturn
属性,这意味着标准函数的进入和退出不会被编译器创建,这个函数永远不会返回。
void (*tab[16 + 91])(void)
这个表达式的意思是:定义一个 16+91 个指向没有返回也没有参数的函数的指针数组,每个这样的函数都是一个中断处理程序,这个指针数组就是向量表。
我们把
tab
向量表放到一个独立的叫作
.vectors
的区段,后面需要告诉链接器把这个区段放到固件最开始的地址,也就是 flash 存储区最开始的地方。前 2 个入口分别是:堆栈指针和固件入口,目前先把向量表其它值用 0 填充。
编译
我们来编译下代码,打开终端并执行:
$ arm-none-eabi-gcc -mcpu=cortex-m4 main.c -c
成功了!编译器生成了
main.o
文件,包含了最小固件,虽然这个固件程序什么都没做。这个
main.o
文件是 ELF 二进制格式的,包含了多个区段,我们来具体看一下:
$ arm-none-eabi-objdump -h main.o
...
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000002 00000000 00000000 00000034 2**1
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000000 00000000 00000000 00000036 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 00000000 00000000 00000036 2**0
ALLOC
3 .vectors 000001ac 00000000 00000000 00000038 2**2
CONTENTS, ALLOC, LOAD, RELOC, DATA
4 .comment 0000004a 00000000 00000000 000001e4 2**0
CONTENTS, READONLY
5 .ARM.attributes 0000002e 00000000 00000000 0000022e 2**0
CONTENTS, READONLY
注意现在所有区段的 VMA/LMA 地址都是 0,这表示
main.o
还不是一个完整的固件,因为它没有包含各个区段从哪个地址空间载入的信息。我们需要链接器从
main.o
生成一个完整的固件
firmware.elf
。
.text
区段包含固件代码,在上面的例子中,只有一个
_reset()
函数,2 个字节长,是跳转到自身地址的
jump
指令。
.data
和
.bss
(初始化为 0 的数据) 区段都是空的。我们的固件将被拷贝到偏移 0x8000000 的 flash 区,但是数据区段应该被放到 RAM 里,因此
_reset()
函数应该把
.data
区段拷贝到 RAM,并把整个
.bss
区段写入 0。现在
.data
和
.bss
区段是空的,我们修改下
_reset()
函数让它处理好这些。
为了做到这一点,我们必须知道堆栈从哪开始,也需要知道
.data
和
.bss
区段从哪开始。这些可以通过 “链接脚本” 指定,链接脚本是一个带有链接器指令的文件,这个文件里存有各个区段的地址空间以及对应的符号。
链接脚本
创建一个链接脚本文件
link.ld
,然后把一下内容拷进去:
ENTRY(_reset);
MEMORY {
flash(rx) : ORIGIN = 0x08000000, LENGTH = 2048k
sram(rwx) : ORIGIN = 0x20000000, LENGTH = 192k /* remaining 64k in a separate address space */
}
_estack = ORIGIN(sram) + LENGTH(sram); /* stack points to end of SRAM */
SECTIONS {
.vectors : { KEEP(*(.vectors)) } > flash
.text : { *(.text*) } > flash
.rodata : { *(.rodata*) } > flash
.data : {
_sdata = .; /* .data section start */
*(.first_data)
*(.data SORT(.data.*))
_edata = .; /* .data section end */
} > sram AT > flash
_sidata = LOADADDR(.data);
.bss : {
_sbss = .; /* .bss section start */
*(.bss SORT(.bss.*) COMMON)
_ebss = .; /* .bss section end */
} > sram
. = ALIGN(8);
_end = .; /* for cmsis_gcc.h */
}
下面分段解释下:
ENTRY(_reset);
这行是告诉链接器在生成的 ELF 文件头中 “entry point” 属性的值。没错,这跟向量表重复了,这个的目的是为像 Ozone 这样的调试器设置固件起始的断点。调试器是不知道向量表的,所以只能依赖 ELF 文件头。
MEMORY {
flash(rx) : ORIGIN = 0x08000000, LENGTH = 2048k
sram(rwx) : ORIGIN = 0x20000000, LENGTH = 192k /* remaining 64k in a separate address space */
}
这是告诉链接器有 2 个存储区空间,以及它们的起始地址和大小。
_estack = ORIGIN(sram) + LENGTH(sram); /* stack points to end of SRAM */
这行告诉链接器创建一个
_estack
符号,它的值是 RAM 区的最后,这也是初始化堆栈指针的值。
.vectors : { KEEP(*(.vectors)) } > flash
.text : { *(.text*) } > flash
.rodata : { *(.rodata*) } > flash
这是告诉链接器把向量表放在 flash 区最前,然后是
.text
区段(固件代码),再然后是只读数据
.rodata
。
.data : {
_sdata = .; /* .data section start */
*(.first_data)
*(.data SORT(.data.*))
_edata = .; /* .data section end */
} > sram AT > flash
_sidata = LOADADDR(.data);
这是
.data
区段,告诉链接器创建
_sdata
和
_edata
两个符号,我们将在
_reset()
函数中使用它们将数据拷贝到 RAM。
.bss : {
_sbss = .; /* .bss section start */
*(.bss SORT(.bss.*) COMMON)
_ebss = .; /* .bss section end */
} > sram
.bss
区段也是一样。
启动代码
现在我们来更新下
_reset
函数,把
.data
区段拷贝到 RAM,然后把
.bss
区段初始化为 0,再然后调用
main()
函数,在
main()
函数有返回的情况下进入无限循环:
int main(void) {
return 0; // Do nothing so far
}
// Startup code
__attribute__((naked, noreturn)) void _reset(void) {
// memset .bss to zero, and copy .data section to RAM region
extern long _sbss, _ebss, _sdata, _edata, _sidata;
for (long *src = &_sbss; src < &_ebss; src++) *src = 0;
for (long *src = &_sdata, *dst = &_sidata; src < &_edata;) *src++ = *dst++;
main(); // Call main()
for (;;) (void) 0; // Infinite loop in the case if main() returns
}
下面的框图演示了
_reset()
如何初始化
.data
和
.bss
:
firmware.bin
文件由 3 部分组成:
.vectors
(中断向量表)、
.text
(代码)、
.data
(数据)。这些部分根据链接脚本被分配到不同的存储空间:
.vectors
在 flash 的最前面,
.text
紧随其后,
.data
则在那之后很远的地方。
.text
中的地址在 flash 区,
.data
在 RAM 区。例如,一个函数的地址是
0x8000100
,则它位于 flash 中。而如果代码要访问
.data
中的变量,比如位于
0x20000200
,那里将什么也没有,因为在启动时
firmware.bin
中
.data
还在 flash 里!这就是为什么必须要在启动代码中将
.data
区段拷贝到 RAM。
现在我们可以生成完整的
firmware.elf
固件了:
$ arm-none-eabi-gcc -T link.ld -nostdlib main.o -o firmware.elf
再次检验
firmware.elf
中的区段:
$ arm-none-eabi-objdump -h firmware.elf
...
Sections:
Idx Name Size VMA LMA File off Algn
0 .vectors 000001ac 08000000 08000000 00010000 2**2
CONTENTS, ALLOC, LOAD, DATA
1 .text 00000058 080001ac 080001ac 000101ac 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
...
可以看到,
.vectors
区段在 flash 的起始地址 0x8000000,
.text
紧随其后。我们在代码中没有创建任何变量,所以没有
.data
区段。
烧写固件
现在可以把这个固件烧写到板子上了!
先把
firmware.elf
中各个区段抽取到一个连续二进制文件中:
$ arm-none-eabi-objcopy -O binary firmware.elf firmware.bin
然后使用
st-link
工具将
firmware.bin
烧入板子,连接好板子,然后执行:
$ st-flash --reset write firmware.bin 0x8000000
这样就把固件烧写到板子上了。