文章目录
- 1、引出栈空间问题
- 2、解决问题
- 2.1、RAM空间
- 2.2、RAM空间具体分布
- 2.3、关于栈空间的使用
- 2.4、栈溢出
- 2.5、变量的消亡
- 2.6、回到关键字static
- 2.7、合法性的判断
1、引出栈空间问题
从static
关键字引出该部分内容。
为什么能从static
引出来?
在使用该关键字的时候:
我们需要知道什么时候使用该关键字?
什么使用关键字?
并且我们知道函数执行的时候是在栈空间,但是我们的static
修饰的关键字变量是在data段或者bss段。
还有就是我们的程序是从FLASH里面烧写的,那就是意味着所有的变量以及函数都是先出现在FLASH里面也就是ROM空间。
以上这些疑问接下来通过按键开源项目例程主意分析。
2、解决问题
2.1、RAM空间
我们知道RAM里面有栈空间、堆空间、bss、data段。
-
栈空间(Stack):存储函数调用的局部变量、参数、返回地址等,由系统自动管理,从高地址向下生长。
-
堆空间(Heap):用于动态内存分配(如
malloc
),由程序员手动管理,从低地址向上生长。 -
.bss 段:存储未初始化的全局变量和静态变量,程序启动时由系统自动清零。
-
.data 段:存储已初始化的全局变量和静态变量,程序启动时从 Flash 复制初始值到 RAM。
但是需要声明的是在裸机开发中一般不使用堆空间,
并且函数的执行都是在==栈空间==,那说到这里还记不记得有一个栈顶空间,对的,这个栈顶空间就是给一个上限,因此栈空间的特殊性,是从上到下的,也就是高字节到低字节分配,
- 栈是一种线性数据结构,仅允许在栈顶(Top)进行插入(入栈)和删除(出栈)操作。类似一摞盘子,最后放上的盘子最先被取走。
这是因为在_main函数到mainARM内核还有一段代码需要执行,因此留出来的是这一段空间,然后才是我们自己写的main函数栈顶地址,就这后面的栈顶空间就可以循环利用了。
我们首先需要知道栈顶地址是怎么得到的?
2.2、RAM空间具体分布
这是整个RAM的空间:
栈是RAM顶部的最后一个区域,符合典型设计。这句话至关重要。
Exec Addr Load Addr Size Type Attr Idx E Section Name Object0x20000000 COMPRESSED 0x00000024 Data RW 39 .data main.o0x20000024 COMPRESSED 0x00000040 Data RW 110 .data modbus_app.o0x20000064 COMPRESSED 0x000000b5 Data RW 183 .data mb.o0x20000119 COMPRESSED 0x00000003 PAD0x2000011c COMPRESSED 0x0000000c Data RW 267 .data mbrtu.o0x20000128 COMPRESSED 0x00000008 Data RW 372 .data modbus_slave.o0x20000130 COMPRESSED 0x00000024 Data RW 581 .data key_drv.o0x20000154 COMPRESSED 0x00000024 Data RW 618 .data led_drv.o0x20000178 COMPRESSED 0x00000008 Data RW 668 .data ntc_drv.o0x20000180 COMPRESSED 0x00000006 Data RW 810 .data rh_drv.o0x20000186 COMPRESSED 0x00000002 PAD0x20000188 COMPRESSED 0x0000000c Data RW 964 .data systick.o0x20000194 COMPRESSED 0x0000001c Data RW 1010 .data usb2com_drv.o0x200001b0 COMPRESSED 0x00000002 Data RW 1133 .data portevent.o0x200001b2 COMPRESSED 0x00000002 PAD0x200001b4 COMPRESSED 0x00000018 Data RW 1168 .data portserial.o0x200001cc COMPRESSED 0x00000004 Data RW 3445 .data mc_w.l(stderr.o)0x200001d0 COMPRESSED 0x00000004 Data RW 3734 .data mc_w.l(stdout.o)0x200001d4 - 0x00000100 Zero RW 265 .bss mbrtu.o0x200002d4 COMPRESSED 0x00000004 PAD0x200002d8 - 0x00000030 Zero RW 580 .bss key_drv.o0x20000308 - 0x00000014 Zero RW 666 .bss ntc_drv.o0x2000031c COMPRESSED 0x00000004 PAD0x20000320 - 0x00000400 Zero RW 3383 STACK startup_gd32f30x_hd.o
通过工程的map
文件可以看出在栈空间确定之前,首先确定的是data、bss
数据占用的RAM空间,最后确定出栈空间的最低地址是多少。通过代码可以看出是0x20000320
,大小是0x00000400
,其中栈的大小是可以自己设定的。那么两者相加就是0x20000320 + 0x00000400 = 0x20000720
。
pxMBFrameCBByteReceived 0x2000007c Data 4 mb.o(.data)pxMBFrameCBTransmitterEmpty 0x20000080 Data 4 mb.o(.data)pxMBPortCBTimerExpired 0x20000084 Data 4 mb.o(.data)pxMBFrameCBReceiveFSMCur 0x20000088 Data 4 mb.o(.data)pxMBFrameCBTransmitFSMCur 0x2000008c Data 4 mb.o(.data)__stderr 0x200001cc Data 4 stderr.o(.data)__stdout 0x200001d0 Data 4 stdout.o(.data)ucRTUBuf 0x200001d4 Data 256 mbrtu.o(.bss)__initial_sp 0x20000720 Data 0 startup_gd32f30x_hd.o(STACK)
从最后一行代码也可以看出该工程的栈顶地址是0x20000720
。
并且也符合图片中的顺序。
我们现在是在裸机层面考虑,所以先不考虑堆空间。
2.3、关于栈空间的使用
ARM Cortex-M启动流程与栈初始化,在芯片上电或复位后,硬件自动执行以下步骤:
初始化主堆栈指针(MSP):从向量表的第一个表项(地址0x00000000或0x08000000)加载MSP初始值,指向栈顶(高地址)。也就是我们常说的这一步:
参考链接ARM单片机启动流程(一)(详细解析)-CSDN博客
读取了栈顶地址以后,接着就是进入Rest_Handler
复位函数地址,然后从这里开始执行程序,这里需要说明但是SP
指向的地方。
首先,栈顶(SP)已指向预设的栈空间顶端(例如0x20000428),但尚未为任何函数分配栈帧。栈顶地址本身并不存储_ _main
函数的入口地址,而是由硬件直接设置SP寄存器的值。
接着需要引入一个:栈帧概念:
栈帧的创建:
当Reset_Handler调用__main
时,会在栈上为__main
创建栈帧,保存返回地址(LR)和寄存器上下文。 栈顶(SP)此时指向__main
栈帧的顶部(低地址);
需要注意的是__main
的低地址也就是main
函数的高地址。
也就是下述这个例子。
__main
函数:
- 将已初始化的全局变量(.data段)从Flash复制到RAM。 这个地方就解决了我们所疑惑的代码烧写到ROM里面,但是那些全局变量什么的又会到RAM里面。
- 清零未初始化的全局变量(.bss段)。
- 初始化C运行时环境(堆、栈、库函数)。
- 最终调用用户main()函数。
用户main()及其调用的子函数共享同一栈空间,通过SP的移动动态分配/释放栈帧,实现内存高效利用。
也就是说在整个RAM空间(不考虑堆空间),能循环利用的地方也就是栈空间,更具体来讲就是main下面的。
因为在最下面是data、bss段,往上就是堆空间,接着就是我们的栈空间了。而栈空间又分为最上面的栈顶空间是用来存__main
这个栈帧空间的,接下来就是main
以及可以循环利用的栈空间,全靠SP移动高效复用内存。
特性 | 通用系统(如 Linux) | 嵌入式系统(无 OS) |
---|---|---|
**main() 行为** | 单次执行后退出 | 无限循环,永不退出 |
**__main 栈帧生命周期** | main() 返回后立即释放 | 永久保留(因 main() 不退出) |
栈溢出风险 | 递归过深导致 | 循环内局部变量过大或递归未限制 |
退出处理 | 调用 atexit() 、析构全局对象 | 进入 halt 或复位 |
-
__main
栈帧是“永久居民”**:因main()
永不返回,它作为程序生命周期的基石始终存在栈底。 -
子栈帧是“流动工人”:在
main()
的循环中动态轮转,通过 SP 移动高效复用内存。 -
循环缺失 = 系统失控:嵌入式环境中,
main()
退出即程序终结,栈帧管理失去意义。
2.4、栈溢出
在裸机嵌入式系统中,栈溢出可能覆盖bss段和data段,尤其是当栈与静态存储区相邻且无保护时。
先不考虑堆空间。
-
栈(Stack):从高地址向低地址增长(向下增长)。
-
堆(Heap):从低地址向高地址增长(向上增长)。
-
静态存储区:
- data段:存放已初始化的全局变量和静态变量。
- bss段:存放未初始化的全局变量和静态变量(程序启动时清零)。
-
bss段优先被覆盖:
- bss段通常紧邻堆区,位于栈的下方(低地址方向)。
- 若栈溢出量较大,最先覆盖的是bss段(因其位置更靠近栈底)。
- 案例:
在Jflash下载算法中,栈溢出导致.bss
段变量被覆盖,引发Flash写入错误(如数据被篡改)。
-
data段可能被覆盖:
- 若bss段被完全覆盖且溢出持续,栈会进一步向下覆盖data段。
- data段存储已初始化变量,覆盖可能导致程序逻辑错误或数据损坏(如配置参数丢失)。
若内存布局中堆区较大或存在保护间隙(Guard Region),栈溢出可能仅覆盖堆区,未触及bss/data段。
某些链接脚本(Linker Script)会隔离栈与其他段,例如在栈底预留保护区。
通常bss段最先被覆盖,其次是data段(因位置更接近栈底)
并且可以通过链接脚本隔离、哨兵检测、MPU保护或静态分析,可有效预防覆盖风险。
哨兵检测(Sentinel Detection) 是一种通过监控特定内存值来识别栈溢出的软件方法。其核心原理是在栈空间边界预设一个特殊标记值(哨兵值),通过定期检查该值是否被篡改来判断是否发生溢出。
设置哨兵值
在栈空间的顶部或底部(根据栈增长方向)预留一个位置,写入特定的哨兵值(如 0xDEADBEEF
)。栈通常从高地址向低地址增长(如ARM Cortex-M),哨兵值需放置在栈顶(低地址边界)。
#define STACK_SENTINEL_VALUE 0xDEADBEEF
volatile uint32_t stack_sentinel __attribute__((section(".stack"))) = STACK_SENTINEL_VALUE;
定期检查哨兵值
在系统空闲任务、定时器中断或关键任务周期中调用检测函数,验证哨兵值是否被覆盖:
void check_stack_overflow(void) {if (stack_sentinel != STACK_SENTINEL_VALUE) {// 栈溢出处理handle_overflow_error();}
}
并且哨兵检测具有滞后性、漏检风险等局限性
- 只能在溢出发生后检测,无法预防溢出,结合栈着色(Stack Coloring)技术,填充全栈空间并计算高水位线,提前预警。
- 若溢出未覆盖哨兵值(如局部变量过大但未触及边界),可能漏检。可在函数入口处增加栈指针范围检查。
2.5、变量的消亡
- 静态存储区:
- data段:存放已初始化的全局变量和静态变量。
- bss段:存放未初始化的全局变量和静态变量(程序启动时清零)。
需要说明的是:bss和data不会释放的,会一直占用。
局部变量在函数栈帧(Stack Frame) 中分配空间。当函数被调用时,编译器会移动栈指针(如 sub rsp, N
指令),为所有局部变量一次性分配内存。
函数返回时,通过指令(如 add rsp, N
或 mov rsp, rbp
)将栈指针移回函数调用前的位置,整个栈帧的内存被标记为“可复用”,局部变量的存储空间随之释放
- 释放操作是高效的指针移动,而非数据擦除(内存中可能残留原值)。
- 若后续函数调用覆盖该栈帧,残留数据会被新数据替换。
局部变量的生命周期与其所属函数的栈空间紧密绑定,其存储空间确实随着函数栈帧的销毁而被释放。
栈空间释放后,局部变量的地址立即失效,但数据可能暂时残留。访问这些地址会导致未定义行为(如野指针操作)。
这里也就解释了前面学习指针内容中,为什么我们要对指针指向明确的地址,就是防止野指针发生,因为有时候可能恰好就会指向我们刚好释放的栈帧空间,那不就导致数据错误了。 产生程序崩溃(段错误)、数据污染(覆盖其他变量)。
是不是这里又豁然开朗了,简直是太妙了!!!!!!!!!!
2.6、回到关键字static
使用了static就说明这个变量不会随着函数栈帧的内存被标记为“可复用”而消失。
我们使用static
关键字主要有两个方面
1、控制作用域和封装
- 限制作用域:
static
将变量作用域限定在当前文件内,其他文件无法通过extern
访问这些变量。这避免了全局变量的“污染”,防止其他模块意外修改按键状态。 - 封装性:按键操作逻辑(如扫描、消抖)通常集中在同一文件中。
static
变量使所有相关操作内聚,符合“高内聚、低耦合”的设计原则。
2、模块化设计与协作开发 - 避免命名冲突:全局变量可能被多人协作时的其他文件同名变量覆盖,而
static
变量仅在当前文件有效,彻底消除冲突风险。 - 简化调试与维护:开发者只需关注当前文件内的逻辑,无需追踪全局变量的跨文件调用链,降低认知负担。
3、内存与生命周期管理 - 生命周期相同,但更安全:
static
变量与全局变量均存储在静态数据区,生命周期均为整个程序运行期。但static
通过作用域限制,提供了自动的内存隔离,避免全局变量的无约束访问。 - 初始化保障:
static
变量默认初始化为0
(如未显式初始化),与全局变量一致,但仅在首次加载时初始化一次。
特性 | static Button btn1, btn2; | 全局变量 Button btn1, btn2; |
---|---|---|
作用域 | 仅当前文件 | 整个程序(所有文件) |
跨文件访问 | 不可访问 | 可通过 extern 访问 |
命名冲突风险 | 几乎为零 | 高(需靠命名约定管理) |
内存位置 | 静态数据区(与全局变量相同) | 静态数据区 |
初始化 | 默认 0 ,仅初始化一次 | 默认 0 ,程序启动时初始化 |
适用场景 | 模块内共享数据,无需外部暴露 | 需跨模块共享的全局数据 |
因此在这里我们使用static关键字。
2.7、合法性的判断
编程思想的严谨性在这里需要体现。
即使 static
变量地址有效,若函数通过参数接收外部指针(如 button_init(&btn1, ...)
),仍需检查该参数是否为空:
因此初始化的时候首先要进行检测的就是判断地址的合法性。
void button_init(Button* handle, ...) {if (!handle) return; // 必须检查,避免外部误传 NULL
}
文章源码获取方式:
如果您对本文的源码感兴趣,欢迎在评论区留下您的邮箱地址。我会在空闲时间整理相关代码,并通过邮件发送给您。由于个人时间有限,发送可能会有一定延迟,请您耐心等待。同时,建议您在评论时注明具体的需求或问题,以便我更好地为您提供针对性的帮助。
【版权声明】
本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议。这意味着您可以自由地共享(复制、分发)和改编(修改、转换)本文内容,但必须遵守以下条件:
署名:您必须注明原作者(即本文博主)的姓名,并提供指向原文的链接。
相同方式共享:如果您基于本文创作了新的内容,必须使用相同的 CC 4.0 BY-SA 协议进行发布。
感谢您的理解与支持!如果您有任何疑问或需要进一步协助,请随时在评论区留言。