STM32 TCP/IP footprint: how much flash and RAM does Mongoose take?

"How big is it?" is one of the first questions people ask about any embedded TCP/IP stack. So I measured it. On an STM32 Nucleo-H723ZG, bare metal, the Mongoose TCP/IP stack with an HTTP server takes about 36 KB of flash and 10 KB of RAM, and each HTTP connection costs about 1.6 KB of RAM. Turn on the built-in TLS 1.3 and it's about 82 KB of flash, the same 10 KB of RAM, and about 14 KB of RAM per HTTPS connection.

Below is how I got those numbers, so you can repeat the test on your own board and not take my word for it.

Results at a glance

Build firmware.bin RAM used at boot RAM used with 1 connection
Blinky, no Mongoose 2,824 - -
Mongoose logging only, no networking 7,072 0 -
TCP/IP + HTTP server, no TLS 42,872 10,020 11,636
TCP/IP + HTTP/HTTPS server, built-in TLS 89,220 10,292 24,460

All values are in bytes. From these, the footprint of the networking part:

Flash RAM RAM per connection
TCP/IP stack, no TLS ~36 KB (35,800) ~10 KB ~1.6 KB (1,616)
TCP/IP stack with TLS ~82 KB (82,148) ~10 KB ~14 KB (14,168)

Board: STM32 Nucleo-H723ZG (Cortex-M7), Ethernet. Compiler: arm-none-eabi-gcc with -Os, newlib-nano. Software: Mongoose with its built-in TCP/IP stack, Ethernet driver and TLS. No RTOS, no CubeMX, no lwIP, no mbedTLS.

Two things to keep in mind. The "RAM used" figure is heap usage, I explain below exactly how it's measured. And the 12 KB of Ethernet DMA buffers is not included, because those live in a separate RAM region and you need them with any stack.

The setup

The firmware is the nucleo-h723zg/minimal tutorial from the Mongoose repo. Build it like this:

git clone https://github.com/cesanta/mongoose
cd mongoose/tutorials/stm32/nucleo-h723zg/minimal
make flash

make flash fetches the CMSIS headers from the ARM and ST repositories, builds firmware.bin and flashes it. Start a serial monitor and you'll see the board grab an IP address. Open that address in a browser and you get a plain text page:

Hi from Mongoose!
tick:     ...
ram used: ...
ram free: ...

tick is milliseconds since boot, and the two RAM numbers come from the firmware itself. That page is our measuring tool.

Why this firmware is good for measuring

It's about as small as firmware gets. main.c initialises the hardware, starts a Mongoose event manager, prints some debug info and runs a bare-metal super loop with two tasks: the network task and a blinky. The network task is a tiny web server that serves the page above.

The hardware layer is hal.c and hal.h, about 200 lines written by hand. It sets up the clock, the UART for debug output, the random number generator, the Ethernet pins and the LED pins. That's it. No Cube, no framework, nothing else that could eat flash and blur the numbers. Whatever grows between builds is Mongoose.

The method: four builds in a row

I built the same project four times and wrote down three numbers each time:

  1. Size of firmware.bin
  2. RAM used, printed to the serial console at startup
  3. RAM used, shown in the browser. At that point one HTTP connection is open, the one serving the page

Then the deltas give the answer: flash of the stack is the firmware.bin size minus the build without networking, and one connection is RAM used in the browser minus RAM used at boot.

The builds were:

  1. No Mongoose at all: I commented out mongoose.c in the Makefile and every Mongoose call in main.c, so only the blinky runs. firmware.bin is 2,824 bytes. No logs, so no RAM numbers. That's close to the smallest blinky you can have on this board.
  2. Mongoose, logging only: mongoose.c back in, but only the logging macros are used, plus the log redirect to the UART. firmware.bin is 7,072 bytes, RAM used is 0.
  3. TCP/IP stack and HTTP server, no TLS: in mongoose_config.h I set MG_TLS to MG_TLS_NONE. firmware.bin is 42,872 bytes, RAM used is 10,020 at boot and 11,636 with a connection.
  4. Same, with built-in TLS: details below.

When I refresh the page several times, tick changes but the RAM numbers don't move. So nothing leaks per request.

How "RAM used" and "RAM free" are calculated

This is worth understanding, because otherwise those numbers are just magic. Open link.ld, the linker script. It describes the memory layout:

MEMORY {
  flash(rx) : ORIGIN = 0x08000000, LENGTH = 1024k
  dtcmram (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
  ram_d1 (rwx)  : ORIGIN = 0x24000000, LENGTH = 320K
  ram_d2 (rwx)  : ORIGIN = 0x30000000, LENGTH = 32K
  ram_d3 (rwx)  : ORIGIN = 0x38000000, LENGTH = 16K
  ...
}
_estack     = ORIGIN(ram_d1) + LENGTH(ram_d1);

The vector table, code (.text) and read-only data go to flash. Variables (.data) and uninitialised variables (.bss) go to RAM_D1, the main 320 KB block. After .bss there's an _end symbol. _estack points to the very end of RAM_D1, and the stack grows down from there.

The space between _end and the stack is the heap. The firmware uses newlib-nano, and its malloc() asks for memory through _sbrk(), which hal.c overrides:

extern unsigned char _end[];  // End of data section, start of heap
static unsigned char *s_current_heap_end = _end;

size_t hal_ram_used(void) {
  return (size_t) (s_current_heap_end - _end);
}

size_t hal_ram_free(void) {
  unsigned char endofstack;
  return (size_t) (&endofstack - s_current_heap_end);
}

s_current_heap_end is the "break" address, in Unix terms. It starts at _end, moves up when you allocate and down when you free. So "RAM used" is everything between _end and the break, which is what the stack and the connections have allocated. "RAM free" is the gap between the break and the current stack pointer. Since the stack pointer moves as functions are called, "RAM free" wobbles a bit depending on where you call it.

Why unused code costs nothing

You might ask: in build 2, mongoose.c is compiled in completely. Why isn't the TCP/IP stack in the binary? Because of these flags in the Makefile:

CFLAGS += -g3 -Os -ffunction-sections -fdata-sections
LDFLAGS ?= ... -Wl,--gc-sections

-ffunction-sections -fdata-sections put every function and every variable into its own ELF section. --gc-sections tells the linker to garbage collect the sections nobody references. If your code never calls the networking functions, they're simply not in the firmware. Same goes for any Mongoose feature you don't use: if you never call the MQTT client, it's not there.

The Makefile has a helper target to check this:

make show_symbols_sorted_by_size

It lists every symbol in the firmware with its size. In the logging-only build there are no networking symbols at all. The one exception is the Ethernet DMA buffers.

The Ethernet DMA buffers

The Ethernet controller on the H7 moves frames with DMA, and that DMA can't reach every RAM region. As far as I know it can't access DTCM, for example. So the linker script puts the buffers in RAM_D2:

  .eth_ram (NOLOAD) : { *(.eth_ram .eth_ram*) } > ram_d2

and mongoose_config.h tags them with that section:

#define MG_ETH_RAM __attribute__((section(".eth_ram")))

By default Mongoose uses 4 receive and 4 transmit descriptors, each holding about one Ethernet frame, around 1.5 KB. That's 6 KB for RX and 6 KB for TX, 12 KB of RAM_D2 in total. I left it out of the numbers above: it's not in RAM_D1, and any network stack on this chip needs DMA buffers of some kind.

Adding TLS

Now the TLS build. In mongoose_config.h, set TLS back to built-in:

#define MG_TLS MG_TLS_BUILTIN

In main.c, add an HTTPS listener with the same event handler, and set up TLS on every accepted HTTPS connection:

} else if (ev == MG_EV_ACCEPT && c->is_tls) {
  struct mg_tls_opts opts = {
    .cert = mg_str(TLS_CRT),
    .key = mg_str(TLS_KEY),
  };
  mg_tls_init(c, &opts);
}
mg_http_listen(&mgr, "http://0.0.0.0", http_ev_handler, NULL);
mg_http_listen(&mgr, "https://0.0.0.0", http_ev_handler, NULL);

For the certificate and key I used the Mongoose TLS helper: generate a self-signed CA, then a server key and certificate, and tick the option to show them as C constants. You can do the same with the OpenSSL commands listed on that page. Paste them in as TLS_CRT and TLS_KEY, then make flash.

Results: firmware.bin is 89,220 bytes. RAM used at boot is 10,292, a little more than before. Go to https:// and the board's IP, click through the browser warning about the self-signed certificate, and the page says 24,460.

So with TLS:

The per-connection jump from 1.6 KB to 14 KB is what TLS costs you. If you're tight on RAM, that's the number to watch: count how many HTTPS clients you expect at the same time, and multiply.

Bonus: how many requests per second?

While I had the board on the desk, I also checked throughput with siege, a simple HTTP benchmark tool: benchmark mode, 5 concurrent clients, 3 seconds, hitting the root URL.

First run: 89 transactions per second. Not great, but the console was flooded with debug logs, and UART logging is synchronous and slow. After changing the log level to MG_LL_INFO:

mg_log_set(MG_LL_INFO);

the same test gave about 1,600 transactions per second. Lesson: don't benchmark anything with debug logging on.

Summary

On a bare-metal STM32H723 with Mongoose:

And that's a whole stack in one library: driver, TCP/IP, HTTP and TLS. No lwIP + mbedTLS + httpd to glue together and size separately.

FAQ

How much flash does the Mongoose TCP/IP stack take on STM32?

About 36 KB on an STM32H723 built with -Os, including the Ethernet driver, TCP/IP stack and an HTTP server. With the built-in TLS 1.3 it's about 82 KB, including an embedded certificate and key.

How much RAM does the Mongoose TCP/IP stack need?

About 10 KB of heap with or without TLS, plus 12 KB of Ethernet DMA buffers with the default 4 RX and 4 TX descriptors. Each plain HTTP connection adds about 1.6 KB, each HTTPS connection about 14 KB.

Does unused Mongoose code end up in the firmware?

No, as long as you build with -ffunction-sections -fdata-sections and link with --gc-sections. The linker drops every function you don't call.

Do I need an RTOS, lwIP or mbedTLS for this?

No. The test runs in a bare-metal super loop, and Mongoose provides the Ethernet driver, TCP/IP stack and TLS itself. Mongoose can also run under FreeRTOS, Zephyr and others, or on top of lwIP if you already have it.

How can I measure RAM usage on my own board?

Override _sbrk() and track the heap break, as in hal.c of the minimal tutorial. Used RAM is the break minus _end, free RAM is the stack pointer minus the break.