type
status
slug
date
summary
tags
category
password
icon
begin’s begin
gdwarf-2
是GCC编译器的一个选项,它用于指定生成的调试信息的格式。GCC编译器可以生成多种格式的调试信息,包括DWARF、stabs和COFF。
-gdwarf-2
选项表示生成DWARF 2格式的调试信息。DWARF是一种用于描述程序的源代码和执行状态的数据格式,它被广泛用于Unix和Linux系统上的调试器,例如GDB。DWARF 2是DWARF的一个版本,它添加了一些新的特性,例如C++和Fortran 90的支持。
-MMD
:这个选项告诉GCC生成一个依赖文件(.d
文件),其中包含了源文件(.c
文件)和它所依赖的头文件(.h
文件)的信息。这个文件可以被Makefile用来确定何时需要重新编译源文件。-MP
:这个选项告诉GCC在依赖文件中为每个头文件生成一个空的规则。这可以防止当一个头文件被删除时,由于在依赖文件中仍然存在对它的引用而导致的错误
-MF"$(@:%.o=%.d)"
:这个选项指定了生成的依赖文件的名字。$(@:%.o=%.d)
是一个Makefile的模式替换表达式,它将目标文件($@
)的扩展名从.o
替换为.d
。例如,如果目标文件是main.o
,那么生成的依赖文件的名字就是main.d
。
-lc
:链接C标准库。C标准库包含了C语言的基本函数,例如printf
、malloc
等。-lm
:链接数学库。数学库包含了一些常用的数学函数,例如sin
、cos
、sqrt
等
-lnosys
:链接一个名为nosys
的库。在嵌入式系统编程中,nosys
库通常提供了一些基本的系统调用的空实现,例如_sbrk
、_write
等。这些函数在使用标准库的一些功能(例如动态内存分配)时可能会被调用,但在没有操作系统的环境中通常没有实现。链接nosys
库可以避免链接错误。specs=nano.specs
:这个选项告诉链接器使用“nano.specs”规格文件。这个文件通常包含了一些编译和链接选项,用来生成更小的代码。这对于资源有限的嵌入式系统非常有用。
T$(LDSCRIPT)
:这个选项指定了链接器脚本的路径。链接器脚本控制了程序的内存布局。
$(LIBS)
:这是一个Makefile变量,用来指定需要链接的库。
Wl,-Map=$(BUILD_DIR)/$(TARGET).map,--cref
:这个选项告诉链接器生成一个映射文件,其中包含了程序的内存布局信息。-cref
选项生成一个交叉引用表,列出了每个符号的定义和引用。
-Wl,--gc-sections
:这个选项告诉链接器删除未使用的代码和数据段,以减小生成的可执行文件的大小。
这行Makefile代码使用了
vpath
指令来指定搜索源文件(.c
文件)的路径。vpath
指令告诉make
在哪里查找源文件。在这个例子中,%.c
是一个模式,匹配任何.c
文件。$(sort $(dir $(C_SOURCES)))
是一个Makefile函数调用,它的作用是获取$(C_SOURCES)
变量中所有文件的目录部分,然后去除重复的目录。在Makefile中,
sort
函数的作用是对列表中的单词进行排序,并删除重复的单词。堆栈和堆部分开始
从ram和flash的大小看起
取自ld文件,那么首先看看这个文件有什么用:
每一个链接过程都由链接脚本(linker script, 一般以lds作为文件的后缀名) 控制。链接脚本主要用于规定如何把输入文件内的section放入输出文件内, 并控制输出文件内各部分在程序地址空间内的布局。但你也可以用连接命令做一些其他事情. 连接器有个默认的内置连接脚本, 可用ld --verbose查看。
-T
选项用以指定自己的链接脚本, 它将代替默认的连接脚本。GCC 的-T
选项用于指定链接器脚本。链接器脚本是一个控制链接器行为的文件,它可以用来指定程序的内存布局,定义符号,以及控制程序的链接过程。例如,你可以使用-T
选项来指定一个链接器脚本,如下所示:gcc -T link.ld your_program.c
在这个例子中,link.ld
是链接器脚本的文件名,your_program.c
是你的程序的源代码。链接器脚本通常用于嵌入式系统的开发,因为在这种情况下,你需要精确地控制程序的内存布局。在一般的应用程序开发中,你通常不需要自己编写链接器脚本,因为 GCC 会使用默认的链接器脚本。
注意最后的
_etext = .;
众所周知的,
.
指的是当前位置,所以这里定义了一个叫做_etext的全局符号,它的值就是.text
段,也就是代码段的结束位置,ok,我们还需要从链接文件中补充一点内容:
0x20000000 + 0x18000 = 0x20018000
在嵌入式系统中,
_estack
通常用于指定堆栈的顶部。在程序启动时,堆栈指针会被设置为 _estack
的值。这意味着堆栈从 RAM
的顶部开始向下增长。这一点待会儿我们的验证上会看到ORIGIN
和 LENGTH
是 GNU 链接器脚本中的内置函数。ORIGIN(memory_region)
:这个函数返回指定内存区域的起始地址。在你的例子中,ORIGIN(RAM)
返回RAM
内存区域的起始地址。
LENGTH(memory_region)
:这个函数返回指定内存区域的长度。在你的例子中,LENGTH(RAM)
返回RAM
内存区域的长度。
这两个函数通常用于计算内存区域的结束地址,或者在内存区域中分配特定的节。例如,
_estack = ORIGIN(RAM) + LENGTH(RAM);
计算了 RAM
内存区域的结束地址,并将其赋值给 _estack
符号。在链接器脚本中,
MEMORY
是一个关键字,用于定义程序的内存布局。在
MEMORY
块中,你可以定义一个或多个内存区域。每个内存区域都有一个名字(例如 RAM
和 FLASH
),一个起始地址(ORIGIN
),和一个长度(LENGTH
)。再留意一些
_Min_Heap_size
和_Min_Stack_Size
我们后面会用到. = ALIGN(8);
:这是一个对齐指令,它确保 ._user_heap_stack
节的开始地址是 8 字节对齐的。这是因为某些处理器(如 ARM Cortex-M)要求堆栈对齐到它们的自然边界。PROVIDE ( end = . );
和 PROVIDE ( _end = . );
:这两行定义了两个全局符号 end
和 _end
,它们的值都是当前的地址(即 ._user_heap_stack
节的开始地址)。这些符号通常用于表示堆的结束位置,可以在 C/C++ 程序中使用,例如,用于实现 malloc
函数。(这个小trick我们后面会用实验验证). = . + _Min_Heap_Size;
:这行将当前地址向前移动 _Min_Heap_Size
字节。这相当于在 ._user_heap_stack
节中分配了 _Min_Heap_Size
字节的空间用于堆。返回来看这里,这个链接文件也很有意思,我们用debug下来看
在链接脚本中定义的变量(或者叫符号)只有地址,没有值;
在源码中定义的变量既有地址又有值。
这个链接文件里面定义的所有符号,都是地址,这句话你可能没法理解到,我换个说法:比如这一行
_etext = .; /* define a global symbols at end of code */
_etext是一个符号,.代表当前的位置,那这里是吧当前的地址的值赋给符合_etext吗?不是的,是把_etext这个符号的地址赋为.当前地址,至于它的值是什么,那取决于这个地址对应的存储区域的内容,如下图的A就是这样的一个符号,而B就是我们常用的一个变量:
不过debug的时候你会有点困惑,我拿出来说吧:
_sdata我们startup.s里面是用到了的,所以你看到,直接由地址得到的内容没啥意义,然后你可以看到它的地址是由意义的,就是data段的起始地址(如下):
这个时候,如果你多探索一点点,同样的手法去试试_etext,嘿嘿,出问题了
诶,好像,它取地址和不取都是一样的,打印的不是地址里面的值,我只有把它当做地址并且需要取它的类型再解引用才能得到它的地址值对应的内容,这里其实是一个偏向于未定义的情况,我们先探究一下这个_etext和其它的链接符号有什么区别:
区别就在这里,汇编里面把他们的地址放入了一个32位的空间里,这样它们才有实际的意义,不然这个符号还处于没有容身之地的时候(这是gnu里面限制的),所以你会发现对于_text加上取地址,或者解引用,都没有区别,都不会变化的,因为还处于一个符号的阶段,你取不了地址,其实这里就是一个规范,你要是用这个链接符号,那汇编文件里面你就得先
.word 符号名称
然后才能只对这个符号用取地址的方法使用,如果是.c里面,就通过extern type 符号名
然后后续取地址用,可以做个试验看看。合理了吧。
ok,基本吃透了,我们联系着elf的节来看看:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .isr_vector PROGBITS 08000000 001000 000194 00 A 0 0 1
[ 2] .text PROGBITS 080001a0 0011a0 003fa4 00 AX 0 0 16
[ 3] .rodata PROGBITS 08004144 005144 0002c0 00 A 0 0 4
[ 4] .ARM ARM_EXIDX 08004404 005404 000008 00 AL 2 0 4
[ 5] .init_array INIT_ARRAY 0800440c 00540c 000004 04 WA 0 0 4
[ 6] .fini_array FINI_ARRAY 08004410 005410 000004 04 WA 0 0 4
[ 7] .data PROGBITS 20000000 006000 000068 00 WA 0 0 4
[ 8] .bss NOBITS 20000068 006068 0002d4 00 WA 0 0 4
[ 9] ._user_heap_stack NOBITS 2000033c 00633c 000604 00 WA 0 0 1
[10] .ARM.attributes ARM_ATTRIBUTES 00000000 006068 000030 00 0 0 1
[11] .debug_info PROGBITS 00000000 006098 00ba25 00 0 0 1
[12] .debug_abbrev PROGBITS 00000000 011abd 00245a 00 0 0 1
[13] .debug_loc PROGBITS 00000000 013f17 006784 00 0 0 1
[14] .debug_aranges PROGBITS 00000000 01a69b 000820 00 0 0 1
[15] .debug_ranges PROGBITS 00000000 01aebb 000740 00 0 0 1
[16] .debug_line PROGBITS 00000000 01b5fb 0075de 00 0 0 1
[17] .debug_str PROGBITS 00000000 022bd9 0027f1 01 MS 0 0 1
[18] .comment PROGBITS 00000000 0253ca 000044 01 MS 0 0 1
[19] .debug_frame PROGBITS 00000000 025410 001cf0 00 0 0 4
[20] .debug_line_str PROGBITS 00000000 027100 0001a8 01 MS 0 0 1
[21] .symtab SYMTAB 00000000 0272a8 002b10 10 22 437 4
[22] .strtab STRTAB 00000000 029db8 00137c 00 0 0 1
[23] .shstrtab STRTAB 00000000 02b134 0000fe 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
y (purecode), p (processor specific)
在链接器脚本中,
NOBITS
是一种节(section)类型,用于描述不需要占用文件空间的数据。例如,未初始化的全局变量就会被放在 NOBITS
类型的节中。在程序加载到内存时,这些节会被创建并初始化为零,但在文件中并不占用实际空间。这有助于节省存储空间,特别是在嵌入式系统中。
挺工整的,
有几个数据我们算一算:
.text 起始:080001a0 offset指的是哪里能找到它(这个后面细说一下) 大小 003fa4 算算.text的末尾应该是多少?8004144,看看之前的试实验对上了吗?
下图熟悉吗,这些数字(来自于elf文件直接读取)
还有个小trick,你看看1段的offset加上size是不是就是二段的offset呢?yes,很紧凑的,但是,
[ 6] .fini_array FINI_ARRAY 08004410 005410 000004 04 WA 0 0 4
[ 7] .data PROGBITS 20000000 006000 000068 00 WA 0 0 4
这里不对劲了,中间空了,而且在到0x006000之前,这些值都是0,也就是里面没有内容,这里是故意隔开的,可以观察到,这里是08000000flash区到20000000ram区之间的转变,所以,好像能解释通这里隔开,别担心这个浪费,因为elf不是加载到单片机上运行的,实际运行的是bin文件,而查看这个文件你会发现,所有数据都很紧。这里给一个这两种文件的解释:
- ELF 文件:ELF 是一种非常复杂的格式,它包含了很多元数据,这些元数据描述了如何将程序加载到内存中,以及如何执行它。ELF 文件包含了程序的代码、数据、符号表、重定位信息等等。ELF 文件可以是可执行文件、可重定位的目标文件,或者共享库。ELF 是 Unix 和 Unix-like 系统(包括 Linux)的标准二进制文件格式。
- BIN 文件:BIN 文件通常是一种非常简单的格式,它只包含了程序的原始二进制代码,没有任何元数据或者文件结构。BIN 文件通常不能直接在操作系统上运行,因为它们没有包含操作系统需要的信息,如何加载和运行程序。然而,BIN 文件在嵌入式系统中非常常见,因为这些系统通常没有操作系统,程序直接在裸机上运行。
总的来说,ELF 文件比 BIN 文件更复杂,但也更强大。ELF 文件包含了更多的信息,可以支持更复杂的功能,如动态链接、共享库、调试信息等。而 BIN 文件更简单,更小,更适合资源受限的环境,如嵌入式系统。
那么最开那里的4k是啥,elf头啦
再算几个吧:
.data的起始:20000000不用多说,就是_sdata,那.data的结尾我们加上大小得到:20000068,熟悉吗,看看之前的,这个就是_edata
比较难搞的是最开始_sidata这个东西,它是啥?我们是需要把在flash里面的data内容搬运到ram里面,那么data内容在flash的什么位置呢?
上面这个链接脚本通过LOADADDR得到data段的LMA(Load Memory Address)(与之对比的有一个VMA),来提一下
在链接过程中,每个段都会被分配两个地址:一个是 LMA(Load Memory Address),另一个是 VMA(Virtual Memory Address)。VMA 是段在运行时的地址,也就是它在虚拟内存中的位置。而 LMA 是段在可执行文件中的位置,也就是它在磁盘上的位置。
在大多数情况下,LMA 和 VMA 是相同的,因为大多数程序都是直接从磁盘加载到内存的相同位置。然而,在一些特殊的情况下,LMA 和 VMA 可能会不同。例如,在嵌入式系统中,程序的代码和数据通常存储在 Flash 内存中,但在程序运行时,数据需要在 RAM 中进行修改,因此需要在程序启动时将数据从 Flash(LMA)复制到 RAM(VMA)。
这里就给出了位置,而这个位置我们还能根据前面elf的信息来得到,且看:
[ 6] .fini_array FINI_ARRAY 08004410 005410 000004 04 WA 0 0 4
[ 7] .data PROGBITS 20000000 006000 000068 00 WA 0 0 4
这里就给了,虽然这里的.data的Addr是在内存中被加载的地址,但是我们从他上一个节08004410并且大小为0x4可以知道,在flash里面它是紧跟在前一个段的,所以很容易就能计算出它在falsh里面的LMA为:0x8004414。不过这样不是很严谨,还是调用链接脚本的函数LOADADDR()来得直接。
ok,链接这部分我觉得差不多了
结合startup.s和反汇编代码来看
andcs r8, r1, r0
r1
r0
r8
傻逼了,之前想了半天这个旁边指令是啥,这个是反汇编的输出,把这个.isr_vector节也给搞成指令来反汇编了,所以这个是机器码对应的指令,但这个机器码对应的应该是地址,所以毫无意义,不用管。
这里的20018000熟悉吧,前面算过了,这个就是栈顶地址,往下看,wow,这个地址你可以搜搜看,(别搜08002e95应该是08002e94,why?因为arm处理器架构说了用lsb来鉴别thumb和arm指令集,我们这里是thumb,ld文件第一句就说了)
so,这个第一条地址就是
毫无悬念
看点有意思的
我们钻到startup.s里面,先看整体介绍:
这里来自startup.s的Reset_Handler部分,这里需要gdb手操来调试看看了。
先自己分析一下:
ldr r0, =_sdata
:加载_sdata
的地址到寄存器r0
。_sdata
是数据段在 SRAM 中的开始地址。
ldr r1, =_edata
:加载_edata
的地址到寄存器r1
。_edata
是数据段在 SRAM 中的结束地址。
ldr r2, =_sidata
:加载_sidata
的地址到寄存器r2
。_sidata
是数据段的初始值在 Flash 内存中的开始地址。
movs r3, #0
:将寄存器r3
的值设置为 0。r3
用作偏移量,用于遍历数据段的每个字(4 字节)。
b LoopCopyDataInit
:无条件跳转到LoopCopyDataInit
标签。adds r4, r0, r3
:将寄存器r0
和r3
的值相加,结果存储到寄存器r4
。这个值表示当前正在复制的字在 SRAM 中的地址。cmp r4, r1
:比较寄存器r4
和r1
的值。这是为了检查是否已经复制完数据段的所有字。(实际是执行r4 - r1然后根据结果更新条件标志位)bcc CopyDataInit
:如果r4
小于r1
(也就是还没有复制完数据段的所有字)(这个时候减法会溢出,产生carry为0),则跳转到CopyDataInit
标签。否则,继续执行下一条指令(这段代码中没有给出)。 bcc的意思是Branch on Carry Clear,功能是如果C(Carry)标志位清零,则跳转到指定地址执行代码,否则继续执行下一行
ldr r4, [r2, r3]
:从地址r2 + r3
加载一个字(arm架构里面一个字32位,注意x86是16位喔)到寄存器r4
。这个地址是在 Flash 内存中的数据段初始值。(这段代码写的很有逻辑,想想为什么没有利用原来r4的值)
str r4, [r0, r3]
:将寄存器r4
的值存储到地址r0 + r3
。这个地址是在 SRAM 中的数据段。
adds r3, r3, #4
:将寄存器r3
的值加 4。这是因为一个字的大小是 4 字节,所以每次复制一个字后,偏移量需要增加 4。
debug的时候有用的查看工具:
Q1:最开始的SP会有内容吗?程序一开始
ldr sp, =_estack /* set stack pointer */
这条指令还没有执行,sp是不是0呢?,那如果这样最开始中断向量表第一行的东西有用吗?Q2:为什么ldr.w sp,[pc,#52]命令执行完成后,msp寄存器也变了
A1:
的确会从中断向量表第一行的地方取值放入sp以及msp(映射),不过因为我们这里reset_handler所以即使你这里乱写也没影响了(看起来是这样的),可以做实验看看。
A2:
ldr.w sp, [pc, #52]
这条指令的作用是从程序计数器(PC)指向的地址加上偏移量 52 的位置加载一个字(word)到堆栈指针(SP)。在 ARM Cortex-M 系列的微控制器中,
sp
是一个特殊的寄存器,它可以表示两个不同的堆栈指针:主堆栈指针(MSP)和进程堆栈指针(PSP)。具体表示哪个堆栈指针取决于当前的堆栈指针选择位(CONTROL.SPSEL)的设置。当微控制器复位时,堆栈指针选择位被设置为使用 MSP,所以在复位后的早期阶段,sp
实际上就是 msp
。因此,ldr.w sp, [pc, #52]
这条指令会改变 msp
的值。如果后续的代码改变了堆栈指针选择位以使用 PSP,那么 sp
将会表示 psp
,并且对 sp
的操作将不会影响 msp
的值。关于这两个状态的选择,来自于一个control寄存器,这里我给出一个博客:
摘要:
Cortex-M3、M4及的CONTROL寄存器如下,其中:
nPRIV在Cortex-M0中不存在,在 Cortex-M0+中可选
具有浮点单元的Cortex-M4处理器的CONTROL寄存器中有1位表示当前是否使用浮点单元。
各个位的解释如下:
位 描述
nPRIV 用于定义线程模式中的特权等级:
0(默认):处于线程模式模式中的特权等级
1:处于线程模式模式中的非特权等级
SPSEL 用于定义栈指针的选择:
0(默认):线程模式使用主栈指针(MSP)
1:线程模式使用进程栈指针
处理模式下该位始终为0,且无法写入
FPCA 只存在于具有浮点单元的Cortex-M4中。异常处理机制通过该位确定异常产生时浮点单元相应寄存器是否需要保存。
0:当前的上下文不使用浮点指令
1:当前的上下文使用浮点指令(此时产生异常自动保存相应浮点寄存器)。
该寄存器复位后默认为0
只有特权状态才能修改(写操作)CONTROI寄存器。(读操作则不需要特权状态)
同样的,使用MRS/MSR指令访问该寄存器:
ok,继续继续
这里比之前的部分还简单:
ldr r2, =_sbss
:将 bss 段的开始地址加载到寄存器 r2 中。
ldr r4, =_ebss
:将 bss 段的结束地址加载到寄存器 r4 中。
movs r3, #0
:将寄存器 r3 的值设置为 0。这是我们将要写入 bss 段的值。
中断部分
中断向量表中存放的就是中断服务处理函数的入口地址,从表中可以看到,对于ARM v7架构的芯片有8个中断入口地址,对于ARM Cortex-M系列的芯片内核来说,往往是在中断向量表中列举出一款芯片的所有中断入口地址,例如:GPIO、UART和定时器等中断处理服务函数入口地址,但是对于ARM Cortex-A系列的SoC来说,则是有一定的区别,从上面的中断向量表中可以看到IRQ interrupt,ARM Cortex-A系列的SoC上的外设中断都属于这个IRQ interrupt,芯片内部的中断控制器使用了中断号的机制,每个中断源对应着一个中断号,当任意一个中断发生后,都会触发IRQ interrupt,然后调用IRQ interupt的中断服务处理函数,在该函数中就可以读取指定的寄存器获取中断号,然后判断所发生的具体中断,从而做出相应的中断处理。
对于ARM v7架构的CPU最多支持的中断号可以到达1020个,为ID0~ID1019,但是对于实际的芯片厂商并不会全部使用完这些中断号,例如:对于NXP研发的I.MX6UL芯片就只使用了160个中断号,为ID0~ID159,其中前32个用于SoC内核私有,并没有用到芯片外设上,ID32~ID159这些中断号就使用到了芯片外设上,部分IRQ号和中断源的描述如下:
最后几条命令
SystemInit函数在system_stm32f4xx.c文件里面
还有
__libc_init_array这个函数是C运行时库提供的函数
__libc_init_array
是一个由 C 运行时库提供的函数,它的主要作用是在程序启动时调用所有的全局构造函数。全局构造函数是一种特殊的函数,它们在
main
函数之前执行。这些函数通常用于初始化全局或静态对象。例如,C++ 中的全局对象和静态类成员的构造函数就是全局构造函数。__libc_init_array
函数会遍历一个函数指针数组,这个数组由链接器生成,并包含所有的全局构造函数。__libc_init_array
会依次调用这些函数,从而完成全局构造函数的调用。在给出的汇编代码中,
__libc_init_array
函数首先计算出函数指针数组的大小(数组的开始和结束地址由 r5
和 r4
寄存器保存),然后使用 r6
寄存器作为索引,遍历整个数组,并通过 blx r3
指令调用每一个函数。最后,终于进入main
埋一个问题:
关于开关中断那里的操作
看到r3是用来保持cpu_sr的,下一个指令,str r3,[r7,#4],那么就把这个值存到内容的r7+4这个地址,而r7是什么?是栈,前面sub指令给了64位(8字节)空间,然后+4是只用到第一个4字节(因为栈向下增长)
msr basepri,r0
是 ARM 汇编语言的一条指令。这条指令的含义是将寄存器 r0
的内容移动到特殊寄存器 basepri
这里的
msr
是 "move to special register" 的缩写,表示这是一个移动操作。r0
是源寄存器,其内容将被移动。
basepri
寄存器用于实现优先级屏蔽。当 basepri
设置为一个优先级值时,所有优先级低于(数值更大)这个值的中断都会被屏蔽。
mrs r1,basepri
是 ARM 汇编语言的一条指令。这条指令的含义是将特殊寄存器 basepri
的内容移动到寄存器 r1
。这里的
mrs
是 "move from special register" 的缩写,表示这是一个移动操作。basepri
是源特殊寄存器,其内容将被移动。DSB
和 ISB
是 ARM 汇编语言中的两条指令,它们用于同步和序列化指令的执行。DSB
是 Data Synchronization Barrier 的缩写。这条指令确保在它之前的所有内存访问(读取和写入)在它之后的任何指令开始执行之前都已经完成。这对于确保共享数据的一致性非常有用。ISB
是 Instruction Synchronization Barrier 的缩写。这条指令刷新处理器的指令流水线,强制处理器在执行它之后的任何指令之前重新获取指令。这在修改了可能影响指令执行的系统状态(例如修改了系统控制寄存器或者使能/禁止中断)之后非常有用,确保这些改变立即生效。能解决,因为一般不会出现在同一个函数开开关关,跳转到别的函数,cpu_sr作为局部变量,压栈位置不一样,所以可以。
- 作者:liamY
- 链接:https://liamy.clovy.top/article/school/stm32F4/startup
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。