Repository navigation
[HELP] Improve NuttX bloated size #20414
Copy link
Copy link
Open
Labels
Community: QuestionFurther information is requestedFurther information is requested
Description
Description
I'm investigation why NuttX became so big in the last years and here are some info:
┌────────────────────────────────────────────────┬────────┬────────┬────────┐
│ Build (code + data) │ 2016 │ Today │ Growth │
├────────────────────────────────────────────────┼────────┼────────┼────────┤
│ ARM stm32f103-minimum:nsh │ 38.8 │ 52.4 │ +35% │
│ │ KB │ KB │ │
├────────────────────────────────────────────────┼────────┼────────┼────────┤
│ ARM minimal NSH (2016 minnsh vs today's │ 13.0 │ 31.6 │ ×2.4 │
│ smallest possible) │ KB │ KB │ │
├────────────────────────────────────────────────┼────────┼────────┼────────┤
│ AVR moteino-mega:nsh (16-bit int, like the │ 49.0 │ 73.1 │ +49% │
│ 1802) │ KB │ KB │ │
└────────────────────────────────────────────────┴────────┴────────┴────────┘
And there are the small changes in the last years:
┌─────────────────────────────────────────────────────────────────┬─────────┐
│ Change (year) │ Bytes │
├─────────────────────────────────────────────────────────────────┼─────────┤
│ Semaphores rewritten with atomic fast/slow paths (2024) │ 2,839 │
├─────────────────────────────────────────────────────────────────┼─────────┤
│ Every task's file setup goes through posix_spawn file actions │ 2,474 │
│ (2023) │ │
├─────────────────────────────────────────────────────────────────┼─────────┤
│ Directories opened through the file layer (opendir) │ 2,148 │
├─────────────────────────────────────────────────────────────────┼─────────┤
│ Full _assert and panic path │ 1,973 │
├─────────────────────────────────────────────────────────────────┼─────────┤
│ All I/O goes through readv/writev vectors (2024) │ 1,942 │
├─────────────────────────────────────────────────────────────────┼─────────┤
│ Growable file-descriptor table (2021) │ 1,911 │
├─────────────────────────────────────────────────────────────────┼─────────┤
│ Delayed-free list in the heap (2023) │ 1,032 │
├─────────────────────────────────────────────────────────────────┼─────────┤
│ Read/write lock for the file-system tree (2024) │ 749 │
├─────────────────────────────────────────────────────────────────┼─────────┤
│ Memory-mapping tables (2022) │ 279 │
├─────────────────────────────────────────────────────────────────┼─────────┤
│ Total │ ≈15.3 │
│ │ KB │
└─────────────────────────────────────────────────────────────────┴─────────┘
Verification
- I have verified before submitting the report.
Reacted by Alin Jerpelea
Reacted by Alin Jerpelea and Xiang Xiao
Metadata
Metadata
Assignees
Labels
Community: QuestionFurther information is requestedFurther information is requested
Activity
arnavsharma990 commentedon Oct 1, 2026
I did a linked-image size investigation on
stm32f103-minimum:nshusing
arm-none-eabi-gcc 16.2.0,arm-none-eabi-size, andnm --print-size. Baseline re-verified after each test, tree clean,all source stubs reverted:
51608 .text / 480 .data / 2952 .bss / 52088 B flash
Isolated flash deltas:
CONFIG_DISABLE_MOUNTPOINT=y: -2812 B (includes removedmount/NSH commands, not just the dir layer)
CONFIG_ARCH_STACKDUMP=n: -1320 BCONFIG_FDCLONE_STDIO=y: -24 Bfdlist_extend()to-EMFILE: -196 B(upper bound only, reverted)
(negative result, reverted)
The interesting discrepancy is that current linked-image results don't match
several historical figures. E.g. the growable FD table is reported as
~1.9 KB, while removing
fdlist_extend()from the current linked imagechanges it by only ~196 B.
Could you share the baseline commit/configuration and size command used
for the historical measurements? I'd like to reproduce the original
deltas under the same conditions and trace which changes account for the
reported growth.
acassis commentedon Oct 2, 2026
@arnavsharma990 I think there are others missing pieces on this puzzle.
A way to spot these difference is using the nm to list all functions from a 2016 build and the current build, then you can spot the size changes and the new added functions.
I used it initially to spot the biggest functions on Nutt, then I was able to create the minnsh that was really small, but it was just a PoC because it removed even the VFS that remove support to device files, so we cannot have application using drivers in the Unix way.
https://acassis.wordpress.com/2015/04/27/how-to-verify-which-functions-are-eating-space-in-an-elf-file/
arnavsharma990 commentedon Oct 2, 2026
2016 → current forensic comparison
As suggested by @acassis, I completed the 2016 → current forensic comparison using nm plus isolated parent→child builds (same GCC 16.2.0 for both sides, so the delta is source/config rather than toolchain; era toolchains would shift the absolutes slightly).
Note
For
stm32f103-minimum:nsh, the linked image grew from 38,872 B in 2016 to 52,088 B currently (+13,216 B / ~34%).Beyond the features originally listed, the comparison attributes most of the rest to NSH evolution, serial/termios functionality, libc time/getopt, configuration enablement (e.g. FS commands unlinked in 2016, linked now), and smaller VFS/heap/workqueue changes.
Reproduced with parent→child builds rather than inferred from symbols
getopt_long()refactorls -ltimestamp supportgmtime_r/strftimechainImportant
Net of overlaps, the ~13.2 KB is accounted for down to ~100–150 B of scatter plus alignment/exidx effects.
One side observation: the historical per-feature estimates don't map 1:1 onto current linked deltas (e.g. FD table ~1.9 KB claimed vs ~200 B upper bound measured today), so the historical comparison seems more informative than summing today's symbol sizes.
I have the
nmlists and pair-build logs saved — happy to share them or dig into whichever subsystem you'd find most useful next.jingfei195887 commentedon Oct 9, 2026
I got the same result on ARM stm32f103-minimum:nsh with a 34% system image increase. Thank you @acassis for your analysis breakdown. I share similar conclusions and have investigated the "directories opened through the file layer (~2.1 KB)" item.
About the "directories opened through the file layer (~2.1 KB)" item
here is the mechanism behind the growth, and a concrete proposal to let size-constrained configs opt back out. Measured on the final linked image,
stm32f103-minimum:nsh, both sides built with the same arm-none-eabi-gcc 10.3.1.The architectural change
2016 (7.19) — directories were a self-contained leaf subsystem under
fs/dirent/.opendir()was roughly:No file descriptor, no
struct file, no VFS open/read/close path.readdir/closedir/seekdir/rewinddirwere standalone functions operating directly onfs_dirent_s.Current — a directory is a first-class file descriptor.
opendir()now lives in libc:readdir()→read(fd)→file_readv→dir_read();closedir()→close(fd). A newfs/vfs/fs_dir.cbolts directories onto the file layer through a syntheticg_dir_inode+g_dir_fileops, withdir_open/close/read/seek/ioctlanddir_allocate.Where the ~2 KB actually goes (the non-obvious part)
The visible directory symbols actually shrank:
opendir+readdir+closedirwent 530 B → 170 B. So the growth is not in the dir calls themselves. It is in the machinery the dir path now forces to be linked:fs_dir.cglue itself: ~790 B.text+ 40 B RAM (g_dir_inode)open()/ fd-allocation path, thestruct filelifecycle, the vectoredfile_readvread path, andfd_pathconstruction (inode_getpath+ asprintf + a PATH_MAX scratch buffer)In a configuration that also opens regular files, most of this machinery is shared and effectively free. In a directory-centric minimal image it is paid for by directories — which is why this shows up as a standalone item.
Why it is deliberate, not a regression
Making directories real descriptors is exactly what provides
dirfd(),fdopendir(),O_DIRECTORY,fchdir(), and/proc/self/fdconsistency. A wholesale revert to the 2016 leaf implementation would drop those POSIX behaviours, so the bulk of this item is the cost of a conformance feature, not fat to trim.Proposal: an opt-out CONFIG for a lightweight DIR implementation
Since the bulk of the ~2.1 KB is the descriptor machinery rather than the directory logic, the natural lever is to let size-constrained builds opt back out of it with a Kconfig — e.g.
CONFIG_FS_DIRENT_LITE(name TBD):opendir()resolves the inode and allocates afs_dirent_sdirectly (the 2016 leaf model), andreaddir/closedir/seekdir/rewinddiroperate on that struct instead of going throughopen()/read(fd)/close(fd). No syntheticg_dir_inode, no forcedfile_readv/fd-lifecycle pull-in, nofd_pathconstruction.What this reclaims and what it costs:
fs_dir.cglue plus the dir-only portion of the forced file-layer path — i.e. the bulk of this item, for configs that only needopendir/readdir/closedir.dirfd(),fdopendir(),O_DIRECTORYreturning a usable dir fd,fchdir()on a dir fd, and dir fds appearing in/proc/self/fd. These would compile out under the lite path.Mountpoint filesystems are unaffected either way: both the legacy and current paths dispatch directory I/O through
inode->u.i_mops->{opendir,readdir,...}, so no per-filesystem changes are needed — the opt-out is confined to the VFS dir layer.The open question for maintainers is whether carrying two dir implementations behind a CONFIG is acceptable maintenance surface for the size win. If it is, I can prototype
CONFIG_FS_DIRENT_LITE, measure the exact linked-image delta onstm32f103-minimum:nsh, and runopendir/readdir/dup(dirfd)onsim:nshto confirm the conformance boundary behaves as documented.Bottom line: the ~2.1 KB is overwhelmingly the price of directories-as-descriptors (a POSIX feature), not waste. It is not meaningfully reclaimable by local cleanups; the only lever that recovers most of it is an explicit opt-out CONFIG that trades the dir-fd POSIX behaviours for size — which is why it belongs behind a Kconfig rather than as an unconditional change.
acassis commentedon Oct 9, 2026
Hi @jingfei195887 thank you for you comments, I think a CONFIG_FS_DIRENT_LITE should be a good idea!
Maybe we could add too a CONFIG_VFS_LITE to create a low footprint solution for boards with small amount of flash memory.
jingfei195887 commentedon Oct 10, 2026
Code size analysis for stm32f103-minimum:nsh by modules
Good suggestion @acassis . I'm looking into simplifying VFS. Before that, I think we'd better to sync up code size growth. so I broke down the problem from a module perspective based on the
stm32f103-minimum:nshgrowth from NuttX 7.19 (2016) to current master, down to the object level, with the historically quoted per-feature numbers reconciled against what the final linked image actually shows.Both sides built with the same arm-none-eabi-gcc 10.3.1: 7.19 =
02146758ef+ apps5291d3e9bc; current =cc2edca964+ apps1f8cac8b87.Method
Derived from the linker
.map, not from summingnmsymbols: attribute each placed section to its object file (not the section-name suffix, else-ffunction-sections.text.<symbol>fragments are dropped); count only addr != 0 sections (skip--gc-sectionsdiscards that linger at addr=0, size>0); cross-check againstsize(text+data) as a correctness gate.Validation: current side placed-vs-ELF error 0.29%, 7.19 side 0.76% (fixed unattributed overhead - padding, vector table,
.ARM.exidx- is a larger fraction of a 39 KB image). The 7.19 map placed total is 38,872 B, byte-for-byte the historical 2016 figure in this issue. Per-module deltas also agree line-for-line with an independentnm-based pass.Totals
The per-library table below sums to the map-placed +13,401; the ~150 B gap to the ELF +13,257 is the unattributed overhead noted above. Per-module figures are map-placed, the headline Flash figure is ELF-measured.
Three cross-cutting corrections (object-level de-duplication)
Object diffs have the same "a new symbol is really a renamed/relocated/merged old one" trap as symbol sums:
assert.o+690 in libsched is a relocation, not new code - 7.19's assert was inarch/.../up_assert.c(libarch); it moved tosched/misc/assert.c. This almost exactly explains why libarch shrank 679.nsh_command.ovs currentnsh_command.c.<path>_1.o, zero shared keys. Matched by logical name,nsh_commandgrew only +127, not the +1782 a raw diff shows.nx*refactor made signals smaller, directly contradicting the "signal atomic fast/slow ~2.8 KB growth" line in the original list.libsched.a +4,314
Largest mover - and not because of a few named features. Every object is partitioned into exactly one group below, so the column sums to the full +4,314. The headline is that the scheduler got broadly heavier: core/task/pthread/clock/group/env together add ~2.75 KB, more than all the named new features combined.
sched_*,nx_start/os_start)sem_rwrwlock)assert.ois +690)task_*)timer_initialize/release)kwork_*merged intokwork_thread)pthread_*)clock_*)group_*)env_*)mq_*)wd_*)sig_*/nxsig)nx*refactor shrank itSo libsched's +4,314 is dominated not by the named new features (semaphore +814, timer +524, workqueue +469, which net against signal -517 / watchdog -467 / mqueue -198) but by broad, small per-object growth across the scheduler core, task management, pthread, clock, group and env (~2.75 KB combined), plus the assert relocation from arch (+754, offset by libarch -679).
libfs.a +3,517
One theme: directories, read/write and ioctl all routed through the file layer / vectored path. Full partition, sums to +3,517.
fs_files)fs_inodesearch)inode_search_setuprework (incl. FS-tree rwlock)fs_ioctl)FIOC_FILEPATH)fs_read+fs_write)struct uiofs_open)fs_dir+ old dirent)fs_umount2)fs_uio)fs_dir.o(+832) absorbs oldfs_opendir+fs_readdir+fs_closedir; the visible dir symbols actually shrank (530->170 B). The growth is the file-layer machinery the dir path now forces to link (opendir->open(O_DIRECTORY)-> fd ->file_readv->dir_read), which buysdirfd()/fdopendir()/fchdir()//proc/self/fd. Nearly all of libfs is implementation bloat from interface unification - the subsystem with the most reduction leverage.libapps.a +2,545
Almost entirely NSH feature growth. Matched by logical name (object naming scheme changed between versions). Full partition, sums to +2,545.
nsh_fscmdsnsh_parsensh_mntcmdsnsh_syscmdsreadline(fd+common)nsh_envcmdsnsh_commandThe growing objects are features users asked for (config-gated via
CONFIG_NSH_DISABLE_*); the large -1,379 "other" shows a lot of the 2016 command set was simultaneously slimmed, so the net is much smaller than the gross additions.libc.a +2,397
ls -ltime formatting, getopt_long, termios - not printf. Full partition, sums to +2,397.ls -ltime (strftime+gmtime_r)getopt_long(getopt_common, net of oldgetopt)cfspeed)libvsprintf+vasprintf)utsname)The core printf engine grew only +92 (1131->1223) - not a major contributor, contrary to the usual assumption. The real growth is the
ls -ltime chain (+910) andgetopt_long(+380, net of the removed oldgetopt), both individually config-selectable.libdrivers.a +1,391
Essentially just serial vectoring + termios. Full partition, sums to +1,391.
serial.o+1,413,serial_io+250)uart_read/write->uart_readv/writev(struct uio*); adds baud table,tcdrain/tcflush/cfsetspeedsyslog_device/putc/streamremoved ->syslog_write+syslog_channel) - net shrink, not ~0dev_nulletc. removedThe driver-side growth is entirely serial, mirroring the VFS
readv/writevmove; vectoring and termios can't be cleanly split because both landed inserial.o. The syslog refactor and other objects actually net down 272 B.libmm.a +656
Allocator core heavier, plus locking and mmap. Full partition, sums to +656.
mm_lockumm_sem)mm_mapumm_*user-heap wrappersmm_addfreechunk/mm_size2ndx/umm_semmerged awayThe historical "delayed-free ~1,032 B" over-counts: measured net for delayed-free + lock is ~458 B. The allocator core growth is partly offset by small objects merged away (-347).
Historical estimate vs measured net
Features spanning multiple
.aare netted across the whole image:sem_rwrwlock_assert/ panic pathCONFIG_ARCH_STACKDUMP=n-> -1,320, per @arnavsharma990)mm_lockinode_searchreworkmm_mapTakeaways:
_assert/panic barely grows in the final image (-59)libsched (+4.3 K) and libfs (+3.5 K) are the two largest movers and where a size effort has the most leverage; libapps/libc are mostly feature growth; libarch shrank. Reproduction data (maps, configs, member-level JSON) available - happy to drill further into any single library.
Swatantra-66 commentedon Oct 13, 2026
@acassis @jingfei195887 @arnavsharma990
Regarding the forensic breakdown across subsystems—particularly
libfs.a(+3,517 B) andlibsched.a(+4,314 B)—and building on theDEFAULT_SMALLprecedent in draft PR #20628:The directory layer in
fs_dir.caccounts for ~2.1 KB of the growth. Routing directories through the file layer (open(O_DIRECTORY)->file_readv->dir_read) was introduced for POSIX descriptor conformance (dirfd,fdopendir,fchdir,/proc/self/fd). However, on memory-constrained targets likestm32f103-minimum:nshand 16-bit AVR architectures, pulling in the entire descriptor apparatus just to support directory traversal imposes an unnecessary footprint penalty.Introducing
CONFIG_FS_DIRENT_LITE(configured asdefault !DEFAULT_SMALL) provides the appropriate opt-out mechanism:<dirent.h>):Under
CONFIG_FS_DIRENT_LITE,DIRwraps an internal handle/pointer tostruct fs_dirent_sdirectly, rather than storing an integer file descriptorfd.opendir(),readdir(), andclosedir()resolve the target inode directly and invoke the filesystem operations (inode->u.i_mops->readdir) or pseudo-root traversals. This completely bypasses:fs_files.c)g_dir_inode) and operations table (g_dir_fileops)struct uiovectored I/O path (file_readv)fd_path/inode_getpath)APIs that strictly depend on directory descriptors (
dirfd(),fdopendir(),fchdir()on directory fds) are conditionally compiled out under this option.I can assist with the following:
libs/libc/dirent/andfs/vfs/fs_dir.c..text,.data,.bss) onstm32f103-minimum:nsh.sim:nshacross basic directory commands (ls,cd, nested path traversals).fs/vfs: Add CONFIG_FS_DIRENT_LITE lightweight directory API
fs/vfs: Add CONFIG_FS_DIRENT_LITE and CONFIG_DISABLE_DIRECTORY_OPERAT…
fs/vfs: Add CONFIG_FS_DIRENT_LITE lightweight directory API