STM32裸机编程指南-3
系列目录
- STM32裸机编程指南-1,存储和寄存器相关知识
- STM32裸机编程指南-2,更易读的外设寄存器编程
- STM32裸机编程指南-3,启动代码和向量表
- STM32裸机编程指南-4,Makefile构建自动化
- STM32裸机编程指南-5,闪烁LED
- STM32裸机编程指南-6,用SysTick中断实现闪烁
- STM32裸机编程指南-7,添加串口调试输出
- STM32裸机编程指南-8,重定向
printf()
到串口 - STM32裸机编程指南-9,用Segger Ozone进行调试
- STM32裸机编程指南-10,供应商CMSIS头文件
- STM32裸机编程指南-11,配置时钟
- STM32裸机编程指南-12,带设备仪表盘的网络服务器
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
文件,并写入下面的内容:
1 | // Startup code |
对于 _reset()
函数,我们使用了GCC编译器特定的 naked
和 noreturn
属性,这意味着标准函数的进入和退出不会被编译器创建,这个函数永远不会返回。
void (*tab[16 + 91])(void)
这个表达式的意思是:定义一个16+91个指向没有返回也没有参数的函数的指针数组,每个这样的函数都是一个中断处理程序,这个指针数组就是向量表。
我们把 tab
向量表放到一个独立的叫作 .vectors
的区段,后面需要告诉链接器把这个区段放到固件最开始的地址,也就是flash存储区最开始的地方。前2个入口分别是:堆栈指针和固件入口,目前先把向量表其它值用0填充。
编译
我们来编译下代码,打开终端并执行:
1 | $ arm-none-eabi-gcc -mcpu=cortex-m4 main.c -c |
成功了!编译器生成了 main.o
文件,包含了最小固件,虽然这个固件程序什么都没做。这个 main.o
文件是ELF二进制格式的,包含了多个区段,我们来具体看一下:
1 | $ arm-none-eabi-objdump -h main.o |
注意现在所有区段的 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
,然后把一下内容拷进去:
1 | ENTRY(_reset); |
下面分段解释下:
1 | ENTRY(_reset); |
这行是告诉链接器在生成的ELF文件头中 “entry point” 属性的值。没错,这跟向量表重复了,这个的目的是为像 Ozone 这样的调试器设置固件起始的断点。调试器是不知道向量表的,所以只能依赖ELF文件头。
1 | MEMORY { |
这是告诉链接器有2个存储区空间,以及它们的起始地址和大小。
1 | _estack = ORIGIN(sram) + LENGTH(sram); /* stack points to end of SRAM */ |
这行告诉链接器创建一个 _estack
符号,它的值是RAM区的最后,这也是初始化堆栈指针的值。
1 | .vectors : { KEEP(*(.vectors)) } > flash |
这是告诉链接器把向量表放在flash区最前,然后是 .text
区段(固件代码),再然后是只读数据 .rodata
。
1 | : { |
这是 .data
区段,告诉链接器创建 _sdata
和 _edata
两个符号,我们将在 _reset()
函数中使用它们将数据拷贝到RAM。
1 | .bss : { |
.bss
区段也是一样。
启动代码
现在我们来更新下 _reset
函数,把 .data
区段拷贝到RAM,然后把 .bss
区段初始化为0,再然后调用 main()
函数,在 main()
函数有返回的情况下进入无限循环:
1 | int main(void) { |
下面的框图演示了 _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
固件了:
1 | $ arm-none-eabi-gcc -T link.ld -nostdlib main.o -o firmware.elf |
再次检验 firmware.elf
中的区段:
1 | $ arm-none-eabi-objdump -h firmware.elf |
可以看到,.vectors
区段在flash的起始地址0x8000000,.text
紧随其后。我们在代码中没有创建任何变量,所以没有 .data
区段。
烧写固件
现在可以把这个固件烧写到板子上了!
先把 firmware.elf
中各个区段抽取到一个连续二进制文件中:
1 | $ arm-none-eabi-objcopy -O binary firmware.elf firmware.bin |
然后使用 st-link
工具将 firmware.bin
烧入板子,连接好板子,然后执行:
1 | $ st-flash --reset write firmware.bin 0x8000000 |
这样就把固件烧写到板子上了。