Static memory allocation in microcontrollers
Holmes: My dear, do not tell me where we are?
Shepherd: You are in a balloon !!!
Holmes: You must be a programmer.
Shepherd: Yes, but how did you guess?
Holmes: Only a programmer could give such an accurate and
yet so useless answer.
... excerpt from a famous joke
If you ever programmed for a microcontroller, it doesn’t matter using the Arduino IDE or working directly with a compiler for AVR, ARM, or ESP, you probably saw build completion reports like
Sketch uses 1,090 bytes (3%) of program storage space. Maximum is 30,720 bytes.
Global variables use 21 bytes (1%) of dynamic memory, leaving 2,027 bytes for local variables. Maximum is 2,048 bytes.
Or
text data bss dec hex filename
52136 1148 12076 65360 ff50 MyProjectSuch reports are really absolutely accurate ... That's just incomplete, and therefore not so useful. The problem is that only data that has been distributed statically is taken into account here. But everything that is allocated via new or malloc does not get into statistics. As a result, it is much more difficult to track the moments when the memory suddenly stops running out and the firmware starts to work incorrectly. But the memory in microcontrollers is usually not very much, and this parameter should be carefully monitored.
On vkidku, I did not recall a single example for junior and medium microcontrollers, where the use of dynamic memory allocation would really bejustified. As a rule, this is the allocation of a buffer or the creation of some objects at the very beginning of the firmware, after which these objects remain in memory until the next reset. And this is an occasion to allocate such a memory statically - today we will do it.
The article is intended for beginners (although I will not tell you very basic things - I expect the reader to have studied at least some kind of book on C ++). Go.
Static Variable Distribution
If you do not go deep into the details and descriptions of the various data segments, then the RAM in the microcontroller can be divided into 3 types:
- statically distributed memory - everything that the compiler knows about during the build phase of the project falls here: global variables and objects, static variables in functions and objects
- heap (heap) - a large area of memory from which the system (memory allocator) cuts pieces to anyone as needed. Functions like malloc and the new operator take memory from here.
- stack - a place where the processor stores registers and return addresses from functions, but also local variables in functions are placed on the stack.
Let's explore this in practice. In order not to steam up with the compiler and the build system, I will use the Arduino IDE for the target Arduino Nano platform.
Let's take another look at the report from above (from the header). As I said, only those objects that were distributed at the compilation stage fall into the statistics.
Global variables use 21 bytes (1%) of dynamic memory, leaving 2,027 bytes for local variables. Maximum is 2,048 bytes.Judging by the message of free memory, we simply heaps. But let's look at the code
int * buf;
void setup()
{
buf = new int[3000];
}
void loop() {}
The firmware is trying to allocate more memory than there is in the microcontroller itself. Of course, such code will not work. But, in my opinion. the biggest problem here is that the code compiles successfully , and the compiler didn’t shake hands on time and did not warn that the memory might run out. And it’s good if there is a distinct error handler. And if not?
Of course, you will say right now that they say the evil Pinocchio to himself! The report tells you that there is only 2kb, and you are trying to allocate 3000 elements of 2 bytes each. But let's think about how it would look in a real project. Most likely, the part would have already been occupied by some variables, the stack would have been used in some way, and some memory would have already been allocated dynamically. And then suddenly some rare event sets in that will require another piece of memory and ... oh, come.
What do I mean by static allocation and what do I want to achieve in general? I have nothing against dynamic memory allocation. But I don’t really understand why use dynamic allocation for objects, buffers, and arrays of a predetermined size. Why not just declare it as a global variable?
I have temporarily reduced the size of the array to a more sane size.
int buf[300];
void setup()
{
buf[0] = 1; //Avoid optimizing this array
}
void loop()
{
}
Now the compiler and linker know in advance about a certain array of 300 * 2 = 600 bytes in size, which should be placed in RAM. Moreover, the linker can allocate a fixed address to this array, which, if desired, can be viewed with the objdump utility (unless, of course, you find where the Arduino IDE puts the binar)
cd C:\Users\GrafAlex\AppData\Local\Temp\arduino_build_55567
"C:\Program Files (x86)\Arduino\hardware\tools\avr\bin\avr-objdump.exe" -x -D -S -s StaticMem.ino.elf
…
00800100 l O .bss 00000258 buf
…Here 0x00800100 is the address that the linker assigned to our buffer, 0x258 is its size (600 bytes).
Now let's try to return an inadequate size of 3000 elements and see what happens. We naturally get "fe" from the assembly system
Sketch uses 456 bytes (1%) of program storage space. Maximum is 30,720 bytes.
Global variables use 6,009 bytes (293%) of dynamic memory, leaving -3,961 bytes for local variables. Maximum is 2,048 bytes.
...
Not enough memory; see http://www.arduino.cc/en/Guide/Troubleshooting#size for tips on reducing your footprint.
Actually, this was the purpose of the exercise - the compiler and the linker even shook hands at the assembly stage and said that allocating such a large buffer is a bad idea.
Now experts in good coding practices will come and say that global variables are evil. I propose to leave the controversy on this score off-screen, and whom global variables confuse, I can offer several alternatives. In fact, all the approaches described below serve the same purpose - to tell the compiler that a certain object can be allocated in memory at the compilation / linking stage, to give it a predetermined address and place in RAM. As a bonus, we also get the lack of memory fragmentation, and also we do not have an overhead for dynamic allocation (in any case, for such objects).
Static Global Variable
You can declare our array / object / variable using the word static. This will limit the scope of the variable - it can be accessed only from this cpp file.
static int buf[300];
void setup()
{
buf[0] = 1; //Avoid optimizing this array
}
Now even if you create another cpp file and try to access our array, for example through extern, then nothing will work.
extern int buf[300];
void foo()
{
buf[0]++;
}The linker swears that he cannot find the buf variable.
C:\Program Files (x86)\Arduino\hardware\arduino\avr\cores\arduino/main.cpp:47: undefined reference to `buf'If in both files the buf variable is declared static, then each cpp file will have its own variable !
static int buf[300];
void foo()
{
buf[0]++;
}The output of objdump in this case will look like this
0080036a l O .bss 00000258 _ZL3buf.lto_priv.12
00800112 l O .bss 00000258 _ZL3buf.lto_priv.13
Nameless namespace
You can put our global variable in an unnamed namespace and get the same buns as in the previous paragraph. This is essentially a global variable with a limited scope.
namespace
{
int buf[300];
}
Static Class Members
Static class members are also allocated at compile time.
class A
{
static int buf[300];
public:
int * getBuf()
{
return buf;
}
};
int A::buf[300];
void setup() {}
void loop()
{
A a;
a.getBuf()[0] += 1;
}Despite the fact that there can be many instances of class A (for example, I create one instance each time I get into the loop () function), the buffer is declared as a static member of the class. And this means that it will exist in a single copy and distributed in memory at the linking stage.
00800100 l O .bss 00000258 A::bufStatic Variables in Functions
If you need to statically distribute a certain object, but only one function should know about it, then it is very convenient to use local static variables.
int getCounter()
{
static int counter = 0;
return ++counter;
}
void setup()
{
Serial.begin(9600);
}
void loop()
{
Serial.println(getCounter());
}
The variable counter is distributed statically. It will not be created every time the getCounter () function is called, but will have a fixed address in memory
00800116 l O .bss 00000002 getCounter()::counter
A side effect (and at the same time a useful feature) of such a distribution is that the value of the variables “survive” between function calls - with each next call to getCounter () the value of the variable will be increased by one.
Nevertheless, this method has several nuances. For example, a static distribution of arrays will work as it should
int * getBuf()
{
static int buf[300];
return buf;
}
The C ++ language standard states that the buf variable will be initialized the first time getBuf () is called, but since there is nothing to do to initialize, this array will simply be allocated somewhere in the .bss section (the memory area that is filled with zeros at the start of the firmware )
int * getBuf()
{
static int buf[300] = {1, 2, 3};
return buf;
}In this version, the compiler will also distribute this variable statically in RAM, but 600 more bytes will be added to the program memory, which will store the initial values for this array.
class Buf
{
public:
int buf[300];
Buf(int v)
{
buf[1] = v;
}
};
int * getBuf()
{
static Buf buf(1234);
return buf.buf;
}In this case, we have a non-trivial constructor that does something the first time the getBuf () function is called. For the AVR platform (ATMega), the compiler will generate a special flag that will regulate whether the constructor needs to be started or if it has already been started before. This must be borne in mind, because this flag consumes a bit of RAM, and there will also be an implicit check of the flag with each call to getBuf (), which may affect performance.
But on the ARM platform (for example, STM32), a very unexpected contraption is obtained. As soon as a non-trivial designer appears, the firmware immediately grows by about 60kb and may no longer fit in the microcontroller. This is due to the fact that the compiler for the ARM platform follows the C ++ standard more strictly and implements thread-safe initialization of static variables (in case several threads suddenly enter the getBuf () function at the same time).
Moreover, this code also checks to see if this function is called recursively during the initialization of our variable? And although it’s undefined behavior by standard, the g ++ name implementation throws a recursive_init_error exception. And since there is an exception, that is, a code that serves these exceptions. By the standards of large processors there are not very many (those same 60kb), but for a microcontroller this is very dofig.
The solution is to add the compiler key -fno-threadsafe-statics, it is just designed to disable this whole crap if we don’t have multithreading and we are sure that this code will be called strictly from one thread.
Singleton
Finally, you can use a singleton - an object that exists in memory in a single copy. In the general case, an object can be created dynamically, but in the case of microcontrollers it makes sense to distribute it at the compilation stage.
class Singleton
{
int buf[300];
public:
static Singleton & getInstance()
{
static Singleton instance;
return instance;
}
int * getBuf()
{
return buf;
}
};
void setup()
{
Serial.begin(9600);
Singleton::getInstance().getBuf()[42] = 10;
}
void loop()
{
Serial.println(Singleton::getInstance().getBuf()[42]);
}
Technically, this is a variation and combination of the points above - here we use static members of the class, as well as static local variables. Nevertheless, this option is good in that the methods / functions of access / processing / change encapsulated in one entity can also be attached to the data load. In this case, the data itself is distributed statically.
00800116 l O .bss 00000258 Singleton::getInstance()::instanceNote that the buf array itself is declared as a regular member of the class data. The instance instance that is contained inside our array is static. Unfortunately, objdump does not describe the data inside the object in great detail. If inside the Singleton class there were other fields besides buf, objdump would still blind them together and show them in one line.
Who wants hardcore and template magic, I propose to read this article . Just came out yesterday and more fully reveals the theme of singleton in relation to microcontrollers.
Conclusion
RAM in small microcontrollers, as a rule, is not very much. If you are lucky and you work with ARM (STM32, nRF, NXP) or ESP8266, then you have at your disposal up to several tens of kilobytes (depending on the "thickness" of the microcontroller). If you have AVR (as part of Arduino or by itself), then this is only 1-4kb. If you are out of luck and you have the MCS51 architecture, then you have only a couple of hundred bytes available.
And although the standard library allows you to use the functions of dynamic memory allocation (new, malloc), and these functions are used in many libraries (for example, which are part of the STM Cube, or are available in the Arduino world), it seems to me that dynamic memory allocation in microcontrollers creates more problems than good.
In this article, I propose to distribute long-lived objects statically in memory at the compilation stage. This will allow you to more closely monitor the distribution of memory and minimize the risk of a situation when the memory suddenly runs out.
In no case do I urge to rush and redo all objects in the program into global variables. The described method is suitable primarily for objects and buffers that are created at the beginning of the firmware and live all the time the program is running. Or, to organize a pool of objects, from where a special manager will distribute objects to anyone you need.
It is not necessary to use this method for short-lived objects; it is possible to place it on the stack much better. Also, this method will not work if you need some information that is not available at the compilation stage to create objects.
Thank you all for reading this article to the end. I will be glad to constructive criticism. I will also be interested to discuss the nuances in the comments.
UPD: The second part about the static distribution of FreeRTOS objects