Memory, Number Formats and Reliability
Flash, SRAM and the stack, integer and fixed-point math, endianness, checksums and CRCs, and storing settings safely.
The kinds of memory
| Memory | Holds | Notes |
|---|---|---|
| Flash | Program code and constants | Non-volatile; limited write cycles (typically 10 000 to 100 000), erased in pages or sectors; slower than RAM, often with wait states |
| SRAM | Variables, the stack and the heap | Fast; lost at power-off; often the tightest limit on small chips |
| EEPROM or flash emulation | Settings and calibration that must survive power loss | Wear-limited (roughly 100 000 writes per cell for AVR EEPROM) |
| FRAM / MRAM | Non-volatile data with RAM-like writes | Billions of cycles; used for logging and counters |
| External flash, SD card | Logs, images, firmware updates | Large; written in blocks; needs a file system or wear leveling |
| Registers | Peripheral control and status | Memory-mapped; must be accessed as volatile |
Typical sizes: an ATmega328P has 32 KB of flash, 2 KB of SRAM and 1 KB of EEPROM; an RP2040 has 264 KB of SRAM and external flash (typically 2 MB); an STM32F4 has up to 1 MB of flash and 192 KB of SRAM; an ESP32 has about 520 KB of SRAM and external flash (4 MB or more).
Where RAM goes: data, heap and stack
- .data and .bss: global and static variables. Initialized ones (.data) are copied from flash at start-up and use both flash and RAM; uninitialized ones (.bss) are cleared to zero.
- The stack holds function arguments, return addresses and local variables; it grows with every call and shrinks on return. Deep call chains, recursion, large local arrays and interrupt nesting all deepen it.
- The heap is memory from
malloc/new. On a small MCU, avoid it: repeated allocation and freeing fragments the heap until an allocation fails, perhaps after weeks, for no obvious reason. Use static allocation, fixed-size pools or ring buffers. - Constants belong in flash. On an AVR use
PROGMEM(or theF()macro for strings), so that tables and text do not consume the scarce RAM; on ARMconstdata is placed in flash automatically.
Worked example: how deep does the stack go?
- A recursive routine uses a stack frame of about 24 bytes per call (return address, saved registers, locals). Recursing 8 levels deep uses 8 × 24 = 192 bytes; ten times deeper would overflow a 2 KB chip’s whole free RAM. Avoid recursion on small parts, or bound the depth.
- Each interrupt that can fire while the stack is deepest adds its own frame (about 35 bytes on an AVR with registers saved). Nested interrupts add up.
- Measure it, do not guess: fill the stack area with a known pattern (such as 0xAA) at start-up, run the worst-case workload, and see how far the pattern has been overwritten (the “high-water mark”). Keep at least 25 % free.
- A stack canary (a magic word at the bottom of the stack that is checked now and then) detects an overflow that would otherwise corrupt variables silently.
Integer types, overflow and padding
| Type | 8-bit AVR | 32-bit ARM / ESP32 | Note |
|---|---|---|---|
char | 8 bits | 8 bits | Signedness is implementation-defined |
int | 16 bits | 32 bits | Size depends on the platform: avoid in portable code |
long | 32 bits | 32 bits (64 on 64-bit Linux) | Avoid |
float | 32 bits (software) | 32 bits (hardware on M4F) | About 7 significant digits |
double | 32 bits on AVR! | 64 bits | Often only 32 bits on small MCUs |
uint8_t, int16_t, uint32_t… | exact width | exact width | Use these (from <stdint.h>) for portable, predictable code |
Worked example: overflow and promotion
- Unsigned wraps. A
uint8_tholding 200 plus 100 gives 300 mod 256 = 44. Unsigned wrap-around is defined and useful (it makesmillis() − lastwork across the roll-over). - Signed overflow is undefined. A
int16_tadding 30 000 and 10 000 should give 40 000, which is beyond 32 767; in practice it wraps to -25 536, but the compiler is allowed to assume it never happens and may optimize on that assumption. - Integer promotion. Arithmetic is done in at least
intwidth: on an AVR (16-bit int) the product of twouint16_tvalues 300 × 300 = 90 000 overflows; cast one operand touint32_tfirst:(uint32_t)a * b. - Comparing signed and unsigned mixes surprising results:
-1 < 1Uis false. Keep one type per quantity. - Structure padding. A struct with a
uint8_t, auint32_tand anotheruint8_ttakes 12 bytes on a 32-bit ARM (1 + 3 padding + 4 + 1 + 3 padding), but only 6 if packed. Padding wastes RAM and makes structs unsafe to send over a wire: serialize field by field.
Fixed-point math
Chips without a floating-point unit emulate floating-point in software: slow (tens to hundreds of cycles per operation) and large (several KB of code). Fixed-point stores a fraction as a scaled integer: a Qm.n number has m integer bits and n fractional bits, and the value is the integer ÷ 2n. Addition and subtraction are ordinary integer operations; multiplication needs a shift to remove the extra scale.
| Format | Storage | Resolution | Range |
|---|---|---|---|
| Q8.8 | signed 16-bit | 0.003906 | −128 to +127.9961 |
| Q1.15 | signed 16-bit | 0.000031 | −1 to +0.999969 |
| Q16.16 | signed 32-bit | 0.000015 | −32 768 to +32768.0000 |
| Q1.31 | signed 32-bit | 0.000000 | −1 to +1.000000 |
Worked example: Q8.8 math
- Convert: π in Q8.8 is round(3.1416 × 256) = 804, which represents 804 ÷ 256 = 3.140625 (an error of 0.000968, half an LSB at most is 0.00195).
- Multiply by 1.5 (which is 384 in Q8.8): 804 × 384 = 308 736 has 16 fractional bits, so shift right by 8: 308 736 >> 8 = 1 206 = 4.7109 (exact 4.7124). The intermediate product needs 32 bits: use a wider type for it.
- Divide by shifting the numerator left first: (a << 8) ÷ b.
- Choose n for the range and precision you need: Q1.15 is the standard for signals between −1 and +1 (audio, filters), Q16.16 for general values with five decimal places.
- Watch for overflow and saturate (clamp) instead of wrapping when a result can exceed the range; many ARM cores have saturating instructions.
typedef int16_t q88_t; // Q8.8: value = raw / 256
#define Q88(x) ((q88_t)((x) * 256.0 + ((x) >= 0 ? 0.5 : -0.5)))
static inline q88_t q88_mul(q88_t a, q88_t b) { return (q88_t)(((int32_t)a * b) >> 8); }
static inline q88_t q88_div(q88_t a, q88_t b) { return (q88_t)(((int32_t)a << 8) / b); }
q88_t gain = Q88(1.5); // 384
q88_t y = q88_mul(Q88(3.14159), gain); // about 4.71
Lookup tables
A table of precomputed values replaces a slow calculation (a sine, a square root, a thermistor curve, a gamma correction) with an array read. The cost is memory and accuracy: the table has finite resolution, and in between entries you can interpolate.
Worked example: a sine table
- A full cycle in 256 entries of 16 bits uses 512 bytes (put it in flash). The index is the top 8 bits of a 16-bit phase accumulator, which advances every sample: the output frequency is fs × step ÷ 65 536.
- Without interpolation the error is at most about 2π ÷ 256 = 0.0245 (2.45 % of full scale), from the step between neighboring entries.
- With linear interpolation between entries the worst-case error falls to 0.000075 (0.0075 % of full scale): about 13.7 bits of accuracy from only 256 entries.
- Quarter-wave tables use the symmetry of the sine to store only a quarter of the cycle, a quarter of the memory.
Byte order
A multi-byte number is stored with either its most significant byte first (big-endian, the network order) or its least significant byte first (little-endian, used by AVR, ARM Cortex-M and x86). The 32-bit value 0x12345678 is stored as bytes 12 34 56 78 in big-endian order and 78 56 34 12 in little-endian. When you send numbers over a bus or to another system, fix the byte order in the protocol and convert explicitly:
// Always serialise explicitly: independent of the CPU, the compiler and struct padding
void put_u32_be(uint8_t *p, uint32_t v) { p[0] = v >> 24; p[1] = v >> 16; p[2] = v >> 8; p[3] = v; }
uint32_t get_u32_be(const uint8_t *p) { return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3]; }
Modbus registers are big-endian 16-bit words; most sensors on I²C and SPI send the high byte first, but not all (some are little-endian): always check the datasheet.
Checksums and CRCs
A checksum or CRC (cyclic redundancy check) is a few bytes calculated from the data, sent or stored with it, and recalculated on arrival; a mismatch means the data was corrupted. A simple sum is cheap but weak; a CRC is much stronger for the same size, because it treats the data as a polynomial and divides it.
Worked example: sum against CRC
- The sum fails: the bytes 12 34 56 sum (mod 256) to 9C. Swapping the first two (34 12 56) gives the same sum 9C: the error is invisible.
- The CRC catches it: the CRC-16 (Modbus) of 12 34 56 is 3B47 and of 34 12 56 is 90BC, different.
- Strength: an n-bit CRC misses a random error with probability 2−n: 8-bit 0.3906 %, 16-bit 0.00153 %, 32-bit 0.000000023 %; and a CRC-16 detects all single-burst errors up to 16 bits.
- Check value: the standard test string “123456789” gives CRC-8 (poly 0x07) = 0xF4, CRC-16/CCITT-FALSE = 0x29B1 and CRC-16/Modbus = 0x4B37. Use these to test your implementation.
- A Modbus RTU frame: the request 01 03 00 00 00 02 has the CRC-16 0BC4, sent low byte first: C4 0B. (The Modbus Frame and CRC Calculator builds and checks frames.)
// CRC-16/Modbus, bitwise (small, no table); init 0xFFFF, polynomial 0xA001 (reflected)
uint16_t crc16_modbus(const uint8_t *p, size_t n) {
uint16_t crc = 0xFFFF;
while (n--) {
crc ^= *p++;
for (uint8_t i = 0; i < 8; i++)
crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : (crc >> 1);
}
return crc; // append low byte first on a Modbus frame
}
A table-driven CRC (256 entries) is about eight times faster at the cost of 512 bytes of flash. Many MCUs have a hardware CRC unit.
Storing settings safely
Settings, calibration and counters kept in EEPROM or flash must survive power loss, including a loss in the middle of a write, and must not wear out the memory.
Worked example: wear and power loss
- EEPROM wear. A cell is good for about 100 000 writes. Saving a counter to the same cell once a minute wears it out in 100 000 ÷ 1 440 per day = 69 days. Spreading the writes over 16 cells in turn (wear leveling) multiplies that by 16: 1 111 days, about 3.0 years.
- Flash is worse: about 10 000 erase cycles for a page on many chips; one write an hour wears it in 417 days, so log to a ring of pages or use a flash-friendly file system, and write only when something changed.
- Write only changes, and save counters on power-down (a brown-out interrupt with a capacitor that holds the supply for a few milliseconds) instead of continuously.
- Atomic update: keep two copies of the data, each with a sequence number and a CRC. Write the older copy, then the new one is valid only when its CRC is correct. At start-up use the valid copy with the higher sequence number. A power loss during a write leaves the other copy intact.
- Version the layout so that new firmware can read old settings or reset them to defaults.
Memory safety and defensive habits
- Bounds-check every array and buffer write. A buffer overflow corrupts the neighboring variables, the stack or the return address, and is the root of most strange firmware bugs (and many security holes).
- Initialize every variable; an uninitialized local contains whatever the stack held before.
- Avoid
printf-style formatting of large values on small chips (floating-point printf pulls in several KB); use integer formatting. - Use
constandstaticliberally, and keep globals few and named. - Turn on compiler warnings (
-Wall -Wextra) and static analysis (cppcheck, clang-tidy); fix the warnings, they find real bugs. - Use the MPU or MMU where available to protect code and the stack, so that a bug faults at once instead of corrupting silently.
- Read the map file after each build: it shows how much flash and RAM are used and by what.
- Plan for field failures: a watchdog, a fail-safe state and a log of resets (see Firmware Architecture, State Machines and RTOS).
Related
- Embedded Systems: the overview of this guide.
- Analog Input and Signal Processing: scaling readings in integers.
- UART, I²C, SPI and CAN in Firmware: framing and CRCs on serial buses.
- Wireless, Networking and Updates: firmware images, signatures and updates.
- Bitwise and Register Field Calculator and Programming Languages: bit operations and types.
FAQ
Should I avoid floating point on a microcontroller?
On a chip without an FPU, yes in fast loops: use integers or fixed-point. On a Cortex-M4F or ESP32 single-precision float is fast and convenient; just keep to float, not double, which falls back to software.
Why does my program crash after running for days?
Usually a slow leak: heap fragmentation, a buffer that grows, a counter that overflows, or a stack that is occasionally too deep. Measure the stack and heap, log the free memory over time, and avoid dynamic allocation.
How many writes can an EEPROM take?
About 100 000 per cell on an AVR, up to a million on some parts; flash is lower. Spread writes, write only when the value changes, and use FRAM for frequently updated data.
Which CRC should I use?
Whichever the protocol specifies (Modbus RTU uses CRC-16/Modbus). For your own data CRC-16/CCITT is a good general choice, and CRC-32 for large blocks such as firmware images.
References
- Koopman, P., “Cyclic Redundancy Code (CRC) Polynomial Selection for Embedded Networks”; Ross Williams, “A Painless Guide to CRC Error Detection Algorithms” (check values for common CRCs).
- Barr, M., “Embedded C Coding Standard” (Barr Group); MISRA C guidelines (integer types, overflow, safe constructs).
- Microcontroller datasheets (memory maps, flash and EEPROM endurance); Yates, R., “Fixed-Point Arithmetic: An Introduction”.
Last reviewed: October 2026. Sizes, endurance and cycle counts are typical: check your own chip’s datasheet.
