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:
- Project Manager: set the toolchain to CMake, minimum heap to
0x10000(64 KB) and minimum stack to0x2000(8 KB) - Security: enable RNG. It is used by TLS. We don't use TLS here, but it doesn't hurt
- Connectivity -> USART3: Asynchronous mode, on pins PD8 (TX) and PD9 (RX). CubeMX picks PB10 and PC4 by default, so change them. On Nucleo boards USART3 is wired to the on-board ST-LINK, which exposes it as a virtual COM port on your workstation. That's our log output
- Connectivity -> ETH: RMII mode. The H5 has a built-in Ethernet MAC, and the board has a LAN8742 PHY chip connected over 9 RMII pins. CubeMX gets most pins right, but on this board you need to fix PG11 and PG13. See board pinouts for the full list
- Do not enable the ETH interrupt in NVIC. That turns on Cube's Ethernet driver, and we use Mongoose's own driver instead
- Clock Configuration: set HCLK to 250 MHz, the maximum for H5
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:
fopen(),open()->_open()fread(),read()->_read()fwrite(),fprintf(),fputs()->_write()remove()->_unlink()- and so on
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:
- LED controls that toggle the board LEDs
- A settings page. The settings live in
/fs/settings.jsonon LittleFS, so they survive reboots. You can evencurlthat file directly, or overwrite it and watch the dashboard pick up the new values - A web file manager for uploading, downloading and deleting files on the device
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.