About Me
Welcome to my blog! This is where I collect my observations and notes on programming and technology. The main subjects range from implementation details to broader ideas about programming.
Main Topics
- Engineering Projects: Exploring implementation details and how technical systems work.
- C/C++: Notes on language features and programming techniques.
- The Programmer’s Perspective: Ideas about developing a career and a way of thinking as a programmer.
For more, visit the categories page.
Contact
If you have questions or would like to discuss something, please get in touch through the About page.
Thank you for reading and for your support. I hope these notes help you on your own technical journey!
Overview
This article follows a Navy application from its build into Nanos-lite’s ramdisk, through OS initialization and ELF loading, and finally into syscall handling. It also records the debugging work needed to connect formatted output and heap allocation to that runtime.
The Execution View
Navy builds the guest application for the Nanos-lite target. In Nanos-lite, resources.S defines ramdisk_start and ramdisk_end around the embedded image bytes. These symbols are addresses the OS can use to access the packaged executable data.
The resource assembly also includes the startup logo. An early printf implementation used a buffer too small for that output; increasing the buffer allowed startup debugging to proceed, though a bounded implementation is preferable to relying on a larger fixed size.
Loading then means reading the executable’s segment descriptions, copying file data to the specified guest addresses, zeroing the required memory, and jumping to the entry address.
init_device
This OS-level wrapper calls AM’s ioe_init to initialize the basic devices. The startup sequence builds from those lower-level facilities toward the OS’s own services.
init_ramdisk
The embedded image already contains its file data when the OS starts. At this stage, ramdisk initialization mainly reports information about that memory-backed image rather than initialize a physical disk’s contents.
init_irq
init_irq calls cte_init and registers do_event as the OS callback. AM handles the machine-specific trap entry, while the callback supplies OS-level policy.
init_proc and naive_uload
Before loading was implemented, startup eventually called yield, placing the runtime’s yield marker in a7 and executing ecall.
The modified path calls naive_uload, which invokes loader. The first simple version treated ramdisk_start as the source of one executable. loader returns the entry address; naive_uload converts it to a function pointer and calls it, transferring control into the guest program.
The Initial Loader
I first used readelf -h and readelf -l to inspect a known test executable. A minimal loader copied the described regions with ramdisk_read and zeroed extra space with memset.
A general loader would inspect the file’s program headers in a loop, select loadable segments, and obtain their destinations and lengths from the file itself. At this point, I had not yet generalized it to multiple images; the immediate focus was the trap and syscall path.
Exception Handling Across the Layers
The emulator’s exception-raising code records a machine trap cause and transfers control to the AM assembly entry, which saves a context and calls __am_irq_handle.
The trap cause identifies the hardware-level event, such as an environment call from a particular privilege mode. It is not the user program’s syscall number. For an environment-call event, the runtime examines its calling-convention register, a7 here, to distinguish yield from ordinary syscalls.
AM constructs an event such as EVENT_YIELD, EVENT_SYSCALL, or EVENT_ERROR, then calls the OS callback registered by cte_init.
The OS Handler
do_event dispatches on the event category. Syscalls proceed to do_syscall, which dispatches on the saved syscall number. The early implementation supplied yield and exit handling, with later services added to the same structure.
Return values are written into the saved register context so they become visible when execution resumes. The trap-return address must also reflect whether the triggering instruction should be retried or skipped.
Following write
The library call supplies a file descriptor, buffer pointer, and byte count. In the library version examined here, wrappers such as _write_r eventually reach _write, which invokes the actual syscall boundary.
The normal RISC-V calling convention places the first three arguments in a0, a1, and a2, with the syscall number in a7 for this interface. The original notes referred to the count as being in a3; the wrapper and kernel must agree on the actual register layout rather than rely on that mistaken label.
AM classifies the trap as a syscall, and do_syscall selects the write handler. The first implementation supported stdout only and rejected other descriptors. It emitted the requested bytes using putch and returned the number written. A general implementation also validates that the requested user-memory range is accessible.
An Unexpected Mapped-Memory Failure
The hello program produced an out-of-bounds mapped-memory access. Its apparent write-call chain looked reasonable, and differential testing did not report an instruction mismatch, so I investigated with gdb-multiarch.
The failing PC was 0x83004fb8. After selecting riscv:rv32 and disassembling with source information, I found the address inside fiprintf, although the application did not explicitly call that function.
Tracing from _start at 0x83004d30 led through call_main at 0x83004d38, main at 0x830000b4, and _write at 0x83004a28. There I found an assert(0) I had deliberately inserted to stop execution during development.
The assertion called __assert_func at 0x83004da8, which in turn called fiprintf. A stack-relative sw inside that diagnostic path attempted to access 0x7ffffffc.
Removing the intentional assertion let the program proceed, but that was a workaround, not a complete diagnosis of why the library assertion path had an invalid stack access.
It also explained why assertions behaved differently in the user library and in AM or the OS. The latter used project-controlled macros around output and halt operations; the former entered a deeper C-library routine whose implementation I had not yet followed.
Understanding the printf Path
The hello program calls printf, which reaches _vfprintf_r in this C library. The _r interfaces carry reentrancy state; they should not simply be equated with automatic thread safety.
The source organization uses vfprintf.c and an included implementation header. Following it eventually reaches the write wrappers and the syscall operation.
write returns _write_r’s result. _write_r calls _write and updates error state when appropriate. _write must return the result received from the actual syscall, rather than unconditionally return zero or execute a leftover exit stub.
I missed that return-value requirement initially and spent considerable time debugging repeated output attempts.
Why the Same Character Repeated
The C library’s attempt to allocate an output buffer failed because sbrk was not yet implemented. It fell back to one-character writes.
The write operation printed the first character, H, but reported no progress through the wrong return value. The formatting code therefore kept trying to output the same character. The buffer-allocation failure and the incorrect write result combined to produce the symptom.
Heap Management
The simple layout places text, initialized data, zero-initialized data, and then the heap at increasing addresses, with the stack growing down from a higher address. The linker symbol _end, after the static data regions, provides the initial heap boundary in this setup.
sbrk requests a change to the program break. The implementation tracks the current break and adjusts it by the requested increment, subject to the available memory range.
My _sbrk wrapper used SYS_brk with a custom two-argument arrangement: a pointer for a returned value and the increment. The kernel used _end to initialize its tracking and communicated the old boundary back to the caller.
This differs from the one-argument interface in the course description and from standard system interfaces. The important requirement for this experiment was that the wrapper and kernel implement the same contract; the choice should be documented rather than assumed to be universal.
If you like this blog or find it useful for you, you are welcome to comment on it. You are also welcome to share this blog, so that more people can participate in it. All the images used in the blog are my original works or AI works, if you want to take it,don't hesitate. Thank you !