STM32H5 File System on Internal Flash: LittleFS + Mongoose

You can run a real writable file system on the internal flash of an STM32H5, with no SD card and no external SPI flash. The H5 has uniform 8 KB flash sectors, which is exactly what LittleFS wants. Mongoose ships a LittleFS integration that retargets the standard C file API, so fopen(), fprintf(), fread(), remove() and friends just work in your bare metal firmware. On top of that, Mongoose can serve, upload and delete those files over HTTP.

This article accompanies the video below. We take a NUCLEO-H563ZI board, create a bare metal CubeMX project, add Mongoose and LittleFS, write a file, and then manage files over a REST API with curl:

If you are not on STM32H5, or you want the platform independent details of how the integration works, read the Writable File System with LittleFS guide.

Why STM32H5, and not F7 or H7?

Most tutorials out there put FatFS on an SD card. That needs extra hardware. Internal flash is already on the chip, but there's a catch: flash must be erased a whole sector at a time before you can write to it. LittleFS copes with that nicely, but it needs a decent number of sectors, and they need to be the same size.

Here's how three popular Nucleo boards compare:

Board Flash Sector layout Good for LittleFS?
NUCLEO-F756ZG 1 MB 4 x 32 KB, 1 x 128 KB, 3 x 256 KB No, sectors are not uniform
NUCLEO-H723ZG 1 MB 8 x 128 KB Not really, see below
NUCLEO-H563ZI 2 MB 256 x 8 KB Yes

On the F756, the sectors are mixed sizes, so LittleFS is out.

On the H723, sectors are uniform, but there are only 8 of them. Say your firmware is 150 KB, so it takes the first two sectors. A file system needs at least four sectors, so it eats half of the flash. Now if you want OTA firmware updates, the new image is normally written into the second half of flash, which is where your file system lives. They collide.

The H563ZI has 256 sectors of 8 KB each, split into two 1 MB banks. A 128 KB file system is only 16 sectors at the very end of flash. A 180 KB firmware sits at the start, and there is still plenty of room for an OTA image in the second bank. Everybody's happy.

Some other STM32 families have lots of 1 KB or 2 KB sectors and can run LittleFS too. For networked devices we prefer the H5 anyway: 640 KB of RAM, a 250 MHz Cortex-M33 core and a built-in Ethernet MAC.

Step 1: Create a CubeMX project

We follow the STM32 integration guide. There is also a great step by step article on st.com, either one works.

In CubeMX, start a new project for STM32H563ZI, without TrustZone. Then:

Generate the code, open the folder in VS Code, and build. It should compile cleanly. We'll use the command line from now on, it's quicker:

cmake --build --preset Debug && \
  STM32_Programmer_CLI -c port=SWD -w build/Debug/h563.elf -rst

Step 2: Add Mongoose

You can enable the I-CUBE-Mongoose pack in CubeMX, but copying two files is just as easy. Create a mongoose/ directory and put these there:

Create mongoose/mongoose_config.h:

#pragma once
#define MG_ARCH MG_ARCH_CUBE

Add them to CMakeLists.txt:

target_sources(${CMAKE_PROJECT_NAME} PRIVATE
    ${CMAKE_SOURCE_DIR}/mongoose/mongoose.c
)

target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE
    ${CMAKE_SOURCE_DIR}/mongoose/
)

On STM32H7 you'd also need to edit the linker script so that Ethernet DMA buffers land in DMA-accessible RAM. On H5 the default RAM region is already reachable by the Ethernet DMA, so skip that.

Now Core/Src/main.c. Here are all the user code sections. Everything else is generated by CubeMX:

/* USER CODE BEGIN Includes */
#include "mongoose.h"
/* USER CODE END Includes */

...

/* USER CODE BEGIN 0 */
// Send Mongoose log output to USART3, which goes to the ST-LINK VCP
static void log_fn(char ch, void *param) {
  HAL_UART_Transmit(param, (unsigned char *) &ch, 1, HAL_MAX_DELAY);
}
/* USER CODE END 0 */

...

  /* USER CODE BEGIN WHILE */
  struct mg_mgr mgr;
  mg_mgr_init(&mgr);
  mg_log_set_fn(log_fn, &huart3);

  while (1)
  {
    mg_mgr_poll(&mgr, 1);
    /* USER CODE END WHILE */

The super loop now calls mg_mgr_poll() on every iteration, which drives the TCP/IP stack. Rebuild, reflash, and open a serial monitor. You'll see Mongoose bring the link up, run DHCP and print the IP address. You can ping the board. Nothing is listening yet, but the network works.

Step 3: Enable LittleFS

Add two lines to mongoose_config.h:

#pragma once
#define MG_ARCH MG_ARCH_CUBE
#define MG_ENABLE_LFS 1
#define MG_LFS_SIZE (128 * 1024)  // File system size, placed at the end of flash

The default MG_LFS_SIZE is 64 KB. We use 128 KB, which is 16 sectors on the H5.

The file system uses Mongoose's flash API, the same one the OTA code uses. So flash support has to be enabled. With MG_ARCH_CUBE on an H5 you get MG_OTA_STM32H5 automatically. On other setups, add #define MG_OTA MG_OTA_STM32H5 (or whatever matches your chip) yourself.

Build it. It fails, because there's no LittleFS code yet. Fair enough.

Create a littlefs/ directory and copy four files from the LittleFS repo: lfs.c, lfs.h, lfs_util.c, lfs_util.h. Update CMakeLists.txt:

target_sources(${CMAKE_PROJECT_NAME} PRIVATE
    ${CMAKE_SOURCE_DIR}/mongoose/mongoose.c
    ${CMAKE_SOURCE_DIR}/littlefs/lfs.c
    ${CMAKE_SOURCE_DIR}/littlefs/lfs_util.c
)

target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE
    ${CMAKE_SOURCE_DIR}/mongoose/
    ${CMAKE_SOURCE_DIR}/littlefs/
)

Build again. Now it complains that <dirent.h> is not supported. Newlib's dirent.h pulls in sys/dirent.h, and on bare metal ARM that header is just an #error. The fix is to create an empty mongoose/sys/dirent.h. Since mongoose/ is on the include path, our empty file wins over Newlib's, and Mongoose provides the directory functions itself.

Step 4: Fix syscalls.c

Build again, and you get a pile of "multiple definition" errors for _open, _lseek, _unlink, _stat and so on. This one needs a bit of explanation.

ARM GCC comes with Newlib, the C library that gives you strcmp(), memset(), printf() and also the file functions: fopen(), fread(), fprintf(), remove(), fstat(). Newlib doesn't know anything about your hardware, so all those functions end up calling a small set of low level functions called syscalls:

Newlib's own syscalls are weak symbols. A weak function is a default: if anything else in the program defines a function with the same name, the linker takes that one instead. So if your firmware defines _open(), every fopen() call ends up in your code.

That's what Mongoose does. LittleFS has its own API, lfs_file_open(), lfs_file_read() and so on, which nobody wants to learn. Mongoose defines _open(), _read(), _write(), _close(), _lseek(), _unlink(), _rename(), _stat(), _fstat() and mkdir() on top of the LittleFS API. This is called IO retargeting. Here's what _write() looks like:

int _write(int fd, char *ptr, int len) {
  struct mg_lfs_fd *f = find_fd(fd);
  return fd < 3 ? len
                : f == NULL ? -1 : lfs_file_write(&s_lfs, &f->file, ptr, len);
}

Descriptors 0-2 (stdin, stdout, stderr) are left alone. The full source is in src/lfs.c.

So where does the conflict come from? CubeMX generates Core/Src/syscalls.c with stub versions of those same syscalls, and some of them are not marked weak. Two strong definitions of _open() means a link error.

The fix: open Core/Src/syscalls.c and add __attribute__((weak)) to every syscall that doesn't have it already, e.g.:

__attribute__((weak)) int _close(int file)
__attribute__((weak)) int _fstat(int file, struct stat *st)
__attribute__((weak)) int _lseek(int file, int ptr, int dir)
__attribute__((weak)) int _open(char *path, int flags, ...)
__attribute__((weak)) int _unlink(char *name)
__attribute__((weak)) int _stat(const char *file, struct stat *st)
__attribute__((weak)) int _link(char *old, char *new)

Do the same for the rest (_isatty, _wait, _times, _fork, _execve and so on). Rebuild, and it links.

Step 5: Smoke test with fopen()

Write a file, read it back and print the result. Here's the full USER CODE BEGIN WHILE section:

  /* USER CODE BEGIN WHILE */
  struct mg_mgr mgr;
  mg_mgr_init(&mgr);
  mg_log_set_fn(log_fn, &huart3);

  // Write a file using the standard C API
  mkdir("/fs", 0755);
  FILE *fp = fopen("/fs/a.txt", "w");
  if (fp != NULL) {
    fprintf(fp, "hello world\n");
    fclose(fp);
  }

  // Read it back
  char buf[100] = "";
  size_t n = 0;
  fp = fopen("/fs/a.txt", "r");
  if (fp != NULL) {
    n = fread(buf, 1, sizeof(buf) - 1, fp);
    fclose(fp);
  }
  MG_INFO(("fp=%p, read %lu bytes: %s", fp, (unsigned long) n, buf));

  while (1)
  {
    mg_mgr_poll(&mgr, 1);
    /* USER CODE END WHILE */

On first boot, the log shows Mongoose erasing sectors, formatting and mounting the file system, then our line with a non-NULL fp and "hello world". Reset the board and the file is still there. The H5 now has a normal POSIX style file system on internal flash, like Linux or Windows would.

Step 6: Manage files over HTTP

Next, start a web server that exposes the file system as a REST API: list directories, download, upload and delete files. This builds on the your first web server example from the docs.

Full main.c user code sections:

/* USER CODE BEGIN Includes */
#include "mongoose.h"
/* USER CODE END Includes */

...

/* USER CODE BEGIN 0 */
// Send Mongoose log output to USART3, which goes to the ST-LINK VCP
static void log_fn(char ch, void *param) {
  HAL_UART_Transmit(param, (unsigned char *) &ch, 1, HAL_MAX_DELAY);
}

// HTTP handler: /api/tick returns uptime, everything else is file access
static void ev_handler(struct mg_connection *c, int ev, void *ev_data) {
  if (ev == MG_EV_HTTP_MSG) {
    struct mg_http_message *hm = (struct mg_http_message *) ev_data;
    if (mg_match(hm->uri, mg_str("/api/tick"), NULL)) {
      mg_http_reply(c, 200, "Content-Type: application/json\r\n",
                    "{%m:%lu}\n", MG_ESC("tick"), (unsigned long) HAL_GetTick());
    } else {
      struct mg_http_serve_opts opts = {
          .root_dir = "/",
          .fs = &mg_fs_posix,
          .enable_dir_listing = true,
          .enable_upload = true,
          .enable_delete = true,
      };
      mg_http_serve_dir(c, hm, &opts);
    }
  }
}
/* USER CODE END 0 */

...

  /* USER CODE BEGIN WHILE */
  struct mg_mgr mgr;
  mg_mgr_init(&mgr);
  mg_log_set_fn(log_fn, &huart3);
  mg_http_listen(&mgr, "http://0.0.0.0:80", ev_handler, NULL);

  while (1)
  {
    mg_mgr_poll(&mgr, 1);
    /* USER CODE END WHILE */

mg_fs_posix tells Mongoose to use the standard file API, which, thanks to the retargeting above, is LittleFS. The three enable_* flags turn on directory listing, POST/PUT uploads and DELETE.

Rebuild, reflash, and try it. Replace the IP with the one from your log:

# List the root directory. It contains our "fs" directory
curl http://192.168.2.3/

# List /fs. You'll see a.txt from the smoke test
curl http://192.168.2.3/fs/

# Download a file
curl http://192.168.2.3/fs/a.txt

# Delete it
curl -X DELETE http://192.168.2.3/fs/a.txt

# Upload a new one
curl --data-binary "hi" http://192.168.2.3/fs/a.txt

# Upload a web page and open it in the browser
curl --data-binary @index.html http://192.168.2.3/fs/index.html

Directory listings come back as JSON. Any HTML file you upload is served like from a normal web server, so you can update the device's web UI without reflashing.

This demo has no authentication, so anyone on your network can delete your files. Keep it simple for learning, but protect these endpoints before shipping. See the JWT authorization guide.

Step 7: A full device dashboard

The last part of the video shows a ready-made project from the Mongoose repo: tutorials/stm32/nucleo-h563zi/cubemx. It's the same setup as above (Mongoose, LittleFS, the sys/dirent.h trick, the syscalls fix), plus a production style device dashboard built with the Mongoose dashboard API.

cd mongoose/tutorials/stm32/nucleo-h563zi/cubemx
cmake --build --preset Debug
STM32_Programmer_CLI -c port=SWD -w build/Debug/nucleo-h563zi.elf -rst

Open the board's IP in a browser and you get:

FAQ

Can I use LittleFS on STM32 internal flash without an SD card? Yes, if the chip has enough uniform, small sectors. STM32H5 (256 x 8 KB on 2 MB parts) is a good fit. Chips with a few large or mixed-size sectors, like STM32F7 or STM32H7, are a poor fit.

Can I use fopen() and fprintf() on bare metal STM32? Yes. With MG_ENABLE_LFS 1, Mongoose implements the Newlib syscalls (_open, _read, _write, etc.) on top of LittleFS. Standard C file functions go straight to flash.

Why do I get "multiple definition of _open" errors? CubeMX's syscalls.c defines some syscalls without the weak attribute. Add __attribute__((weak)) to them, so the linker picks Mongoose's versions.

Why do I get "dirent not supported"? Newlib's bare metal sys/dirent.h is an #error stub. Put an empty sys/dirent.h on your include path.

Where in flash does the file system live? At the very end of flash, MG_LFS_SIZE bytes long. The default is 64 KB.

Why does the file system need OTA flash support? LittleFS talks to flash through Mongoose's struct mg_flash API (erase sector, write, flash size), which is the same code OTA uses. On Cube H5 projects it's enabled automatically. Elsewhere, set MG_OTA to your chip.

Can I have LittleFS on flash and FatFS on an SD card at the same time? Yes, using the same retargeting trick to build a small virtual file system. See the LittleFS guide.