当前位置:首页 > 技术学院 > 技术前线
[导读]在嵌入式开发领域,有一句老生常谈的话:“内存玩得转,开发一半顺”。和PC端开发不同,嵌入式系统的RAM资源往往极其紧缺——很多MCU的RAM大小只有几KB到几十KB,高端嵌入式芯片也不过几百MB,远不如PC动辄几个GB的内存。同时,嵌入式内存的布局、分配、管理直接影响程序的稳定性、实时性,很多奇怪的bug比如程序跑飞、莫名其妙复位都和内存使用不当有关。能不能把有限的内存玩明白,是区分入门嵌入式工程师和资深工程师的核心标志之一。

嵌入式开发领域,有一句老生常谈的话:“内存玩得转,开发一半顺”。和PC端开发不同,嵌入式系统的RAM资源往往极其紧缺——很多MCU的RAM大小只有几KB到几十KB,高端嵌入式芯片也不过几百MB,远不如PC动辄几个GB的内存。同时,嵌入式内存的布局、分配、管理直接影响程序的稳定性、实时性,很多奇怪的bug比如程序跑飞、莫名其妙复位都和内存使用不当有关。能不能把有限的内存玩明白,是区分入门嵌入式工程师和资深工程师的核心标志之一。本文就从嵌入式内存的基础布局讲起,带你掌握内存优化、bug排查的实用技巧,玩转嵌入式内存。

一、先搞懂:嵌入式程序的内存到底是怎么分布的?

要玩转内存,第一步得先搞清楚,我们写的程序在嵌入式芯片的RAM里到底是怎么放的。很多人只知道写代码申请内存,却不知道不同类型的变量放在内存的哪个区域,自然也就谈不上优化和排错。

一个典型的嵌入式程序运行时,内存从低地址到高地址大概分成五个区域:

1. 代码段(.text段)

代码段存放的是程序的指令,也就是我们写的函数编译之后生成的机器码,通常放在ROM(Flash)里,大多数情况下运行时不会修改,所以是只读的。代码段的大小由我们写的代码量决定,代码写得越多,函数越多,代码段就越大。

2. 数据段(.data段)

数据段存放的是已经初始化的全局变量和静态变量。比如你写一句int count = 10;定义在函数外面,这个变量就会存在.data段,它是可读可写的,运行时存在RAM里。

3. BSS段(.bss段)

BSS段存放的是未初始化或者初始化为0的全局变量和静态变量。和.data段不一样的是,BSS段在程序加载的时候会被自动清零,不需要在Flash里保存初始值,所以只占用RAM空间,不占用Flash空间。比如你写int buffer;定义在全局,没有初始化,它就放在BSS段。

4. 栈区(Stack)

栈区存放的是函数的局部变量、函数调用的参数、返回地址以及中断现场,由编译器自动分配和释放,遵循“先进后出”的规则。栈的大小是在链接脚本里预先定义好的,一般MCU的栈大小配置从几KB到几十KB不等。栈最常见的问题就是栈溢出——比如你定义了一个太大的局部数组,或者函数递归调用层数太深,就会把栈占满,覆盖其他区域的数据,导致程序跑飞。

5. 堆区(Heap)

堆区是用来动态分配内存的区域,我们用malloc申请的内存就来自堆区,需要手动调用free释放,否则就会造成内存泄漏。堆区的大小也是链接脚本里预先定义的,在RAM里位于BSS段之后,栈之前。对于资源紧张的嵌入式系统,很多开发甚至会直接禁止使用动态内存分配,就是为了避免内存泄漏和内存碎片问题。

举个简单例子帮你理解:

// 全局变量,未初始化,放在BSS段

int uninit_buffer;

// 全局变量,已初始化,放在.data段

int init_count = 10;

void test_func(int param) {

// param存在栈区

// 局部变量,存在栈区

int local_arr;

// 动态分配,内存来自堆区

int *dyn_buffer = malloc(128 * sizeof(int));

// 静态局部变量,已初始化,放在.data段

static int static_count = 0;

}

这样一拆分,不同变量放在哪里就一目了然了。

二、嵌入式内存优化:几招榨干每一字节

嵌入式开发最常见的需求就是内存不够用,本来RAM就小,跑着跑着就爆了,这个时候就得靠优化技巧挤出空间。这里分享几个实际项目中常用的优化方法:

1. 全局变量不是万能药,优先用局部变量

很多新手觉得全局变量用起来方便,定义一次到处用,结果把大量不用的变量都放在RAM里占着位置。实际上,局部变量放在栈上,函数执行完就自动释放了,不用一直占着内存。如果变量不需要全局访问,就定义成局部变量,用完就释放,能省不少空间。当然要注意,局部数组不要定义太大,否则容易栈溢出,超过几百字节的大数组最好定义成静态或者全局。

2. 巧用const把常量放到Flash,不占RAM

很多人会把大的查表数据比如字库、正弦表、校参数都定义成全局数组,结果直接占了好几KB的RAM,非常浪费。实际上这些数据运行的时候不会修改,只要加上const修饰,编译器就会把它们放到Flash里,不占用RAM空间,一举多得。比如:

// 错误写法:放在RAM,占1KB空间

uint8_t font_lib = { ... };

// 正确写法:const修饰,放在Flash,不占RAM

const uint8_t font_lib = { ... };

这个技巧是最容易上手,效果最明显的,改一改关键字就能省出大量RAM。

3. 动态内存慎用,但合理复用能省空间

如果你的程序有多个功能模块,不同模块是分时运行的,比如第一个模块运行完才会运行第二个模块,那就可以复用同一块内存缓冲区,不需要给每个模块都分配单独的缓冲区。比如你做串口接收和ADC采样,两个不会同时进行,就可以定义一块全局缓冲区,两个模块轮流用,比分开分配省一半空间。

当然如果用动态分配,用完一定要立刻释放,避免内存泄漏。对于资源紧张的MCU,建议尽量少用动态分配,最好固定分配好,复用内存,避免碎片问题。

4. 压缩变量类型,不用大材小用

很多人习惯不管什么变量都用int,其实很多变量根本不需要32位:比如计数变量最大不超过255,用uint8_t就够了,不需要int;最大不超过65535,用uint16_t就够。1个int占4字节,1个uint8_t只占1字节,积少成多,几十个变量就能省出上百字节,对于小RAM的MCU来说非常可观。

5. 用链接脚本看内存占用,精准优化

编译完程序之后,一定要看编译输出的内存占用报告,或者用大小工具查看各个段的大小,知道哪里占空间最多,再针对性优化。比如我常用的ARM-GCC,编译完会输出:

text data bss dec hex filename

12345 256 4096 16697 4139 main.elf

这里text是代码段大小,data是初始化数据段大小,bss是未初始化数据段,RAM总占用是data + bss,Flash总占用是text + data,一眼就能看出用了多少,还剩多少,优化起来心里有数。

三、常见内存bug排查:搞定程序跑飞、莫名复位

嵌入式开发中大部分奇怪的bug都和内存有关,掌握几个常见问题的排查方法,能帮你节省大量debug时间:

1. 栈溢出:最常见的内存问题

栈溢出是嵌入式开发排名第一的内存bug,表现就是程序莫名其妙跑飞、复位,找不到原因。栈溢出常见原因有两个:一是局部数组定义太大,比如在函数里定义int buffer,栈总共才1KB,一下子就满了;二是递归调用层数太深,递归每次调用都会压栈,层数多了直接爆栈。

排查栈溢出最简单的方法就是“栈染色法”:程序启动的时候把整个栈区域都填充成固定的标记值比如0xAA,然后运行一段时间,停下来看栈区域被用到了多少,最大用到哪里,就能知道栈开的够不够。如果标记值都被覆盖了,说明栈肯定不够用,需要加大栈大小。很多嵌入式IDE都自带栈使用率统计功能,直接就能看。

解决方法也简单:大数组不要定义在函数里,改成全局或者静态;递归能不用就不用,改成循环实现;如果确实需要大栈,就在链接脚本里适当加大栈的大小,留出余量。

2. 野指针:最隐蔽的内存bug

野指针就是指向了非法内存区域的指针,比如指针没有初始化就用,或者指向已经释放的内存,然后修改指针指向的内容,就会覆盖合法区域的数据,导致各种奇怪的问题,有时候程序跑几个小时才会出问题,非常难查。

避免野指针的方法就是:定义指针的时候立刻初始化为NULL,释放内存之后也把指针置为NULL;使用之前先判断指针是不是NULL,不要直接解引用;不要返回栈上局部变量的指针,函数执行完栈就释放了,那个指针就变成野指针了。

3. 内存泄漏:积累出来的bug

如果你的程序运行动态分配内存,但是用完不释放,堆内存就会越来越少,慢慢把内存耗光,最后分配失败,程序崩溃。这种问题一般程序跑几个小时甚至几天才会出问题,调试起来很麻烦。

排查方法很简单:统计每次malloc和free的次数,看运行一段时间之后,分配次数减去释放次数是不是越来越大,如果是,肯定哪里漏释放了。对于嵌入式系统,最好的解决方法就是尽量不用动态分配,开机的时候一次性分配好所有需要的内存,运行的时候不再分配也不释放,从根源上避免内存泄漏。

4. 内存碎片:慢慢积累的隐形问题

如果频繁分配和释放不同大小的动态内存,就会产生很多不连续的小块空闲内存,想要分配一块大的连续内存的时候,虽然总剩余空间够,但找不到连续的块,就会分配失败,这就是内存碎片。

解决内存碎片的方法,除了少用动态分配,还可以用内存池技术:开机的时候提前分配好固定大小的内存块,需要的时候申请块,不用的时候放回池子,不会产生碎片,分配释放速度也比malloc快很多,非常适合嵌入式实时系统。

四、实用进阶技巧:用好内存提升程序稳定性

最后分享两个进阶的小技巧,能帮你提升程序的稳定性:

第一个技巧是内存越界检查。很多MCU的链接脚本会在各个段之间留出一小块隔离区域,填充固定值,如果内存越界就会覆盖这些固定值,程序运行的时候定时检查这些值有没有被修改,就能提前发现内存越界问题,避免更严重的错误。

第二个技巧是堆栈方向利用。很多人不知道,大部分嵌入式芯片的栈是从高地址往低地址增长的,堆是从低地址往高地址增长,所以如果栈溢出,第一个影响的就是堆,所以我们一般会在堆和栈之间留出一块隔离区,降低溢出影响。

另外,对于需要高可靠性的场景,我们可以把关键变量放在RAM的固定地址,加校验和,定时检查,一旦被破坏就自动恢复,能大幅提升程序的抗干扰能力。

嵌入式内存开发,核心就是“精打细算”四个字——资源有限,所以要合理分配、优化布局,同时还要防范各种内存问题,保证程序稳定运行。从理解内存布局,到学会优化技巧,再到搞定常见bug,一步步积累下来,你就能把嵌入式内存玩得明明白白,写出更稳定、更高效的嵌入式程序。

很多人觉得内存只是底层细节,不需要花太多心思,但实际上,正是这些底层细节决定了嵌入式程序的上限。把内存玩明白,你在嵌入式开发路上就能少踩90%的坑,走得更稳更远。

本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除( 邮箱:macysun@21ic.com )。
换一批
延伸阅读

在智能硬件遍地开花的今天,物联网、嵌入式、单片机这三个高频出现的技术名词,常常让不少入门学习者混淆边界。很多人会简单把它们等同起来,觉得“做物联网就是写单片机代码”,但实际深入行业就会发现,三者是一套从底层硬件到上层应用...

关键字: 物联网 嵌入式

随着消费电子、可穿戴设备、微型物联网终端及高密度嵌入式系统向小型化、集成化迭代,空间受限设计已成为硬件研发的主流场景。这类设计的核心矛盾集中在有限物理体积与高集成、高性能、低功耗需求的冲突,传统功率控制方案依赖大体积散热...

关键字: 物联网 嵌入式 电源模块

在嵌入式开发的日常工作里,几乎每个工程师都曾和串口通信打过无数交道。当系统需要频繁输出传感器数据、调试日志或者控制指令时,大家第一反应往往是把阻塞式的查询发送换成DMA传输——毕竟所有人都知道,DMA能把CPU从逐字节搬...

关键字: 嵌入式 串口

在嵌入式产品开发中,兼容性问题是最容易被忽视却影响深远的“隐形陷阱”:同一套软件在首批芯片上运行正常,更换批次就出现不定期死机;在开发板上调试完美,换到量产PCB就功能异常;用A编译器编译运行稳定,升级编译器版本就出现启...

关键字: 嵌入式 兼容性

不管是新手入门还是开发多年的工程师,理解程序运行时的内存分布,都是读懂底层运行逻辑、排查内存问题、写出高效代码的基础。很多人只知道写代码申请变量,却不知道这些变量在内存里到底放在哪里,不同区域的特性有什么区别,遇到内存越...

关键字: 内存 Linux

康佳特嵌入式模块与软件技术栈开发与支持流程已获认证

关键字: 嵌入式 自动化 机器人

在嵌入式开发领域,C语言始终是绝对的主流,而指针则是C语言最核心、最灵活也最容易踩坑的特性。对于嵌入式开发来说,我们需要直接操作硬件寄存器、管理内存缓冲区、处理网络数据包、回调驱动事件,几乎所有核心功能都离不开指针。而随...

关键字: C语言 嵌入式

在STM32嵌入式开发中,精确延时是非常基础但又极其关键的功能。无论是驱动单总线传感器(比如DS18B20)、控制LCD屏幕时序、还是生成精确的脉冲信号,都需要用到微秒级甚至纳秒级精度的延时。很多新手刚开始使用STM32...

关键字: STM32 嵌入式

随着半导体测试向更高复杂性与并行度演进,多工位自动测试设备(ATE)和SiC/GaN测试对电感、电容和电阻(LCR)测量的需求不断提升。然而,传统的外接台式LCR仪表和基于线缆的设置难以扩展,而且会降低可重复性。本文介绍...

关键字: 半导体 电阻 嵌入式

智能高尔夫球追踪系统是一项创新的嵌入式电子项目,旨在展示如何将紧凑型物联网硬件集成到体育科技应用中。在体育领域,高尔夫球扮演着主要角色,但在现代时代,所有设备都变得更加智能化,高尔夫球也由此演变为智能高尔夫球。本项目结合...

关键字: 嵌入式 物联网 NRF无线技术
关闭