Copyright (c) 2026 Muhammad Anisur Rahman. All rights reserved.
TUWA-RTOS loads relocatable binaries at run time - placing them in memory, relocating them, resolving their references to the kernel, and calling into them. Three container formats are supported, and a kernel is built to load one of them.
The rule is to match the toolchain that built the kernel.
| Kernel built by | Build flag | Loads | Extension |
|---|---|---|---|
| gcc (ELF target) | -DTUWA_MODFMT_ELF | ELF shared objects | .so |
| mingw gcc | -DTUWA_MODFMT_PE | PE DLLs | .dll |
| kcpp | neither | picfmt2 | .twa |
This is not a preference. A module and the kernel have to agree about calling convention, name decoration and address width, and coming from one toolchain makes them agree by construction rather than by inspection. A port therefore carries exactly one parser, not three.
.twa is three characters because a FAT 8.3 name allows no more. The original .tuwa could not be created on TUWA-RTOS's own FAT12 ram disk at all - the filesystem refuses a name it cannot express rather than truncating it.
long h = TuwaLoadModuleFromFile("/root/oslib/mymod.twa", NULL);
if (h < 0)
{
TuwaPrintConsole(ModLoadErrorText(h)); /* say WHICH failure */
return;
}
TUWA_ADDR result;
TuwaCallModuleFunc(h, "DoWork", &result);
TuwaUnloadModule(h);
TuwaLoadModule(image, size, name) does the same from memory, for a module compiled into the image or received over a link.
Always print ModLoadErrorText(). About thirty distinct causes are distinguished, and "could not load" throws that away.
Loading from a path needs a filesystem. Without one the call returns MODERR_NO_FILESYSTEM, which names the real problem instead of reporting a missing file and sending you to look for it. See 02-build-guide.md.
long TuwaCallModuleFunc(long handle, char *name, TUWA_ADDR *result);
Use this rather than taking an export's address and calling it. For a module that reaches its globals through a base register, entering it directly does not fault - it silently reads every global from the wrong address. TuwaCallModuleFunc installs the base register for the duration of the call and restores it afterwards.
On a port with no base-register convention the call is refused with MODERR_NO_ENTRY_ABI rather than attempted. Refusing is deliberate: running it anyway is the silent-wrong-answer case above.
TuwaModuleExportAddr() is still useful for asking whether a symbol exists.
A module may reference kernel functions. They are resolved by name at load time against the kernel's export table, so a module carries no addresses and works wherever it is placed.
The kernel publishes its table with TuwaInstallKernelExports(), and a module importing a name that is not in it is refused at load with MODERR_UNRESOLVED rather than loaded and left to crash on first call.
The exported set is deliberately small and hand-curated - console, memory, strings, heap, task identity. It is an interface, so it grows on purpose.
A module may export one optional entry, which the loader calls at the two moments it cannot see for itself:
long TuwaModuleMain(long handle, long reason);
reason | When | Return |
|---|---|---|
TUWA_MOD_ATTACH | After the module is resident, relocated and bound | 0 to proceed. Non-zero aborts the load |
TUWA_MOD_DETACH | Before the memory is released | ignored |
long TuwaModuleMain(long handle, long reason)
{
if (reason == TUWA_MOD_ATTACH)
{
if (!MyDeviceIsPresent()) return 1; /* refuse to load */
MySetup();
return 0; /* proceed */
}
if (reason == TUWA_MOD_DETACH)
MyTeardown();
return 0;
}
Zero means success, which is the opposite of DllMain. Everything else in TUWA-RTOS returns 0 for success, and one call with the inverted sense would be a trap on both sides. The entry is deliberately not named DllMain so the old convention does not travel across with the old name.
A failed attach is never followed by a detach. The module never finished initialising, so tearing down state that was never built is worse than doing nothing. The loader tracks this rather than trusting the module.
Return long, not int. The result is read as a full-width value, and a narrower return leaves the top half of the register unspecified on some targets - which reads back as a spurious refusal from a module that succeeded. long is address-width on LP64 and returned in a register whose upper half is defined on LLP64, so it is correct on both.
The entry is optional. A module exporting neither reason loads and unloads exactly as before.
gcc, for an ELF kernel:
aarch64-none-elf-gcc -fPIC -fvisibility=hidden -O2 -shared -nostdlib \
-Wl,-z,max-page-size=16 -o mymod.so mymod.c
-fvisibility=hidden plus __attribute__((visibility("default"))) on the API keeps the export table to what you meant to export. max-page-size=16 collapses gcc's default 64KB segment alignment, which would otherwise become real memory once the module is loaded.
mingw, for a PE kernel: build a DLL against an import library generated from the kernel's exported names. mingw refuses undefined symbols when linking a DLL - unlike ELF -shared - so the import library is not optional.
kcpp, for a picfmt2 kernel:
kcpp -cpu32 -pic mymod.c
miskasm -nolink mymod.asm
klink -pic -dll -import TuwaPrintConsole -export modmain -o mymod.kx mymod.obj
# -> also writes mymod.twa
-import is what lets a reference to a kernel symbol link at all: without it an outside reference is a hard error, deliberately, so a typo is caught at the command line rather than at load time.
module load <file> load, relocate, bind and attach
module list what is loaded
module call <id> <func> call an export
module unload <id> detach and free
module drives this loader. The older dll command drives the previous format and is not the same thing.
A position-independent module may need its load address aligned - on AArch64, adrp works in 4KB pages, so a module loaded on a smaller boundary computes addresses in the wrong page. The loader reads the requirement from the module and over-allocates to satisfy it. Nothing is required of the caller, but it is worth knowing why a module's memory is larger than its image.