Device Interfaces, Tracing, and Memory-Mapped I/O in NEMU

Engineering

Posted by Bruce Lee on 2024-09-01

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!


The Timer

When implementing __am_timer_uptime, I needed a reference time for startup. __am_timer_init is a natural place to establish it, with stored state available to the uptime operation.

The eight bytes at RTC_ADDR expose the timer value. Their contents are maintained by the timer’s I/O handler. The order of the two 32-bit reads matters if reading one half triggers a refresh; the device model and driver must agree on how to obtain a consistent value.

The handler is registered through add_mmio_map. A guest memory access reaches the physical-memory code, then the MMIO dispatch path when the address belongs to a mapped device. The registered callback can update the device’s backing registers before a read or react after a write.

Ultimately, the emulated timer derives time from host facilities through helpers such as get_time, get_time_internal, and gettimeofday. Device initialization registers the timer map. During execution, device_update performs periodic device work. That update loop and the timer’s register-access callback are related but distinct paths.

Where the Early Errors Came From

Around the same time, the native build segfaulted near my printf implementation even though examples still ran under NEMU. GDB showed an invalid memory access. My klib implementation was not fully compatible with the surrounding host-library expectations, so the native configuration needed closer isolation before it could be trusted as a klib test.

The demos also failed because functions such as memmove had not yet been implemented. Adding them was necessary, but did not resolve every failure.

With function tracing enabled for the final bf demo, a suspicious malloc call led me back to the allocator. I had omitted initialization of hbrk. Initializing it fixed the demo failures. I initially performed the initialization inside malloc, with the intention of moving it into a dedicated runtime initialization routine later.

The io_read Macro

The benchmark’s uptime helper uses:

1
io_read(AM_TIMER_UPTIME).us

The .us access works because the macro yields a value of type AM_TIMER_UPTIME_T. Token pasting joins the supplied register name with _T to declare a temporary __io_param.

The macro then calls ioe_read, passing the abstract register identifier and the temporary’s address. The identifier is also a numeric constant used to index a table of handler function pointers. The selected timer handler fills the temporary, and the macro’s final expression returns that structure value.

putch

putch uses outb to write a byte to SERIAL_PORT. From the guest driver’s viewpoint, that is a write to a fixed device address. The emulator supplies the behavior associated with that address.

Separating Native Libraries From klib

The native-only crashes suggested mismatches between my partial library and the host runtime. The conditional-compilation guards around the klib sources control when the custom implementations are included.

Adjusting those guards can deliberately keep the native build on glibc while testing other parts of the program. Such a build no longer validates the substituted klib functions themselves, so the chosen configuration must be clear.

Device Tracing

Logging every device access can produce an overwhelming amount of output. My initial policy logged a device when the target changed, suppressing repeated consecutive accesses to the same device. This reduces noise at the cost of losing individual accesses.

mmio_read and mmio_write delegate to map_read and map_write, making the mapping layer a useful shared place for the tracing hook.

The log belongs in the log file rather than on the terminal, where it could interfere with the program’s own text, graphics, or audio-related output. I first considered bundling it with instruction tracing, then separated it into DTRACE under the broader TRACE configuration so instruction logs could be disabled while device logs remained available.

A log_only wrapper reused log_write without also writing to the console.

Keyboard Events

A keyboard event has both a press/release indication and a key identifier. The driver separates the state bit from the code using the mask appropriate to the device’s encoding; in these notes, the experiment used a low-byte mask.

For combinations such as a held modifier plus another key, the consumer needs to remember the modifier’s current state across events. A stream of individual events does not itself represent a complete simultaneous-key state.

VGA

Configuration

Compile-time VGA configuration selects a default screen size, such as 800×600 or 400×300. Helpers such as screen_width and screen_height may obtain values from the AM GPU configuration when NEMU itself is built for that target.

It is important to distinguish the device model’s source of configuration from the guest driver’s register reads. A driver should report the configured device information, rather than create a circular dependency by asking a higher-level operation that depends on the same configuration.

The ranges around FB_ADDR in the platform definitions also help establish the available framebuffer region.

Drawing a Rectangle

AM_GPU_FBDRAW supplies a destination position and a rectangular pixel buffer. My first implementation copied pixels linearly and turned intended rectangles into lines.

The correct copy accounts for both row strides: locate the rectangle’s first destination pixel, then use nested loops or row copies to advance through each source row and the corresponding framebuffer row.

Guest Accesses to Fixed Addresses

AM can directly access numeric addresses such as FB_ADDR because it is running as guest code. Its loads and stores are instructions interpreted by NEMU, which routes them to memory or device mappings.

Size and Synchronization Registers

The VGA control allocation contains two 32-bit words. The first packs the width and height into its upper and lower halves. The second is the synchronization register, exposed at VGACTL_ADDR + 4.

Writing 1 to the synchronization address requests a display update. In the device model, I checked the backing word directly as vgactl_port_base[1], rather than wrap it in another named accessor. The registers are still present even if the implementation represents them simply as elements of an allocated array.

Following the Mapping From End to End

The apparent contrast is that NEMU initializes hardware through a chain of functions, while an AM driver seems to access a register merely by dereferencing a fixed address. The mapping machinery connects the two views.

Device-Side Allocation

init_device initializes the mapping infrastructure and devices in the required order. In configurations that run NEMU on AM, some host-side I/O initialization also goes through AM, so it is especially important to keep the host and guest layers distinct.

init_map allocates a large I/O backing region using IO_SPACE_MAX. io_space identifies its beginning, and p_space tracks allocations within it.

In an AM-based host, allocator state can ultimately originate from heap.start, backed by linker symbols such as _heap_start. The linker aligns that heap boundary to a page-sized address. In an ordinary native host, the host allocator provides the backing memory instead.

new_space reserves part of the I/O region and advances p_space, rounding allocation sizes to page boundaries as required. The result is a host-accessible backing pointer, not the fixed guest-visible device address.

Registering a Device

For the serial device, init_serial obtains backing space and calls add_mmio_map with the device name, guest address, backing pointer, length, and callback.

The callback implements the effect of register accesses. For example, a serial write can call a host output routine to emit the character. Timer callbacks update the allocated timer-register values.

The mapping registration checks for address conflicts and capacity limits, then records the guest interval, host backing pointer, and callback in the maps array. nr_map tracks the number of registered mappings, while NR_MAP limits their capacity.

The interval is represented by its low and high guest addresses. The backing allocation’s address is a separate quantity and must not be confused with those guest addresses.

Periodic Updates

After monitor initialization, execution reaches the CPU loop. Device updates use elapsed host time to decide when to perform work such as refreshing the VGA window. Those routines inspect the device’s allocated backing state and interact with the host display or audio library.

Driver-Side Reads

The RTC test calls io_read(AM_TIMER_UPTIME) and reads its us field. The abstract-register dispatch selects the timer driver, which reads the guest-visible RTC_ADDR.

That load executes in the emulator. Physical-address handling selects mmio_read; fetch_mmio_map finds the mapping whose guest interval contains the address; map_read checks the range and computes an offset.

Finally, host_read reads from map->space + offset for the requested length. The register value therefore comes from the actual backing allocation even though the guest used a fixed device address. Writes follow the corresponding path and invoke the registered behavior.

Ordinary guest RAM is backed by pmem; device registers have their own mapped backing spaces. It is useful to think of both as host-managed storage, but they are not necessarily all inside the same pmem array.

Linker Addresses and the Guest Image

The AM platform header defines the guest-visible device addresses. Its physical-memory constants depend on _pmem_start, which the linker setup places at the configured base, 0x80000000 in this example.

An application is linked with its runtime and supporting libraries. From NEMU’s viewpoint, that combined result is a sequence of guest instructions and data. Runtime code and application code both execute through the same instruction, memory, and device mechanisms.

This makes the direct register accesses less mysterious: the compiled guest loads and stores are exactly the operations that NEMU interprets and routes. The driver does not need direct access to NEMU’s internal maps array.

Audio and VGA Debugging

Audio Hardware Model

The control registers include frequency, channel count, sample count, stream-buffer size, initialization state, and buffered-byte count. An enum names their indices in audio_base. The stream buffer was allocated with a size of 0x10000.

Accessing the mapped control region invokes its handler. My handler initialized SDL audio, opened the device, and changed its pause state. A static guard prevented initialization from being repeated on every access.

The SDL callback copies the requested amount of audio into the provided stream. The first implementation used a fixed 4096-byte transfer chosen to match the test, which was only an initial simplification.

Audio Driver

Macros define the guest addresses of the control registers. I initially added device-register writes to __am_audio_init, but in the configuration being tested that function ran before the device mapping had been registered. The resulting access failed the mapping bounds check, so I removed that early access while sorting out initialization order.

The configuration operation reports available buffer capacity. The control operation writes frequency, channels, and sample settings. The simplest playback implementation waits for the count to become zero, copies a test-sized block into the stream buffer, and updates the count. The device callback consumes data when the count is nonzero.

A general streaming implementation needs to handle the actual producer and consumer sizes rather than depend on that one test block size.

Failures Encountered

Playback initially worked but ran too fast. During debugging, I also saw:

1
*** buffer overflow detected*** terminate

The buffer-overflow report led to a faulty strcpy implementation exposed by device tracing. Disabling tracing hid that failure, but fixing the string operation was the real requirement.

Other symptoms suggested deeper heap corruption. One experiment added this assertion around memset:

1
2
//uint32_t s_len = strlen(s);
//assert(n <= s_len + 1);

That assertion made some demos fail. It was intended to compare the requested write length with a string’s size, but memset operates on arbitrary storage, which need not contain a string; strlen is therefore not a valid general capacity check.

A second, intermittently reproducible failure with VGA and audio enabled was:

1
malloc(): corrupted top size

Another session showed repeated creation and destruction of threads:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
src/device/io/mmio.c:51 add_mmio_map] Add mmio map 'vmem' at [0xa1000000, 0xa10752ff]
[New Thread 0x7fffeacf0640 (LWP 48391)]
[New Thread 0x7fffea4ef640 (LWP 48392)]
[New Thread 0x7fffe9cee640 (LWP 48393)]
[New Thread 0x7fffe94ed640 (LWP 48394)]
[New Thread 0x7fffe8cdc640 (LWP 48395)]
[New Thread 0x7fffd3fff640 (LWP 48396)]
[Thread 0x7fffd3fff640 (LWP 48396) exited]
[Thread 0x7fffe8cdc640 (LWP 48395) exited]
[New Thread 0x7fffe8cdc640 (LWP 48397)]
[New Thread 0x7fffd3fff640 (LWP 48398)]
[Thread 0x7fffd3fff640 (LWP 48398) exited]
[Thread 0x7fffe8cdc640 (LWP 48397) exited]
[New Thread 0x7fffe8cdc640 (LWP 48399)]
[New Thread 0x7fffd3fff640 (LWP 48400)]
[Thread 0x7fffd3fff640 (LWP 48400) exited]
[Thread 0x7fffe8cdc640 (LWP 48399) exited]
[New Thread 0x7fffe8cdc640 (LWP 48401)]
[New Thread 0x7fffd3fff640 (LWP 48402)]

Using GDB’s TUI mode located that activity around SDL initialization. I also encountered an XCB/Xlib diagnostic:

1
2
3
4
5
[xcb] Unknown sequence number while appending request
[xcb] You called XInitThreads, this is not your fault
[xcb] Aborting, sorry about that.
riscv32-nemu-interpreter: ../../src/xcb_io.c:157: append_pending_request: Assertion !xcb_xlib_unknown_seq_number' failed.
make[1]: *** [/home/bruce/project-a/git-project/ysyx-workbench/nemu/scripts/native.mk:49: run] Aborted (core dumped)

Enabling the memory-checking configuration made the heap-corruption problem reproducible. These observations were debugging clues, not proof that the graphics or audio library itself was the cause.

Further Experiments

An alternative playback implementation, left commented below the working code, still attempted to access 0xa1210000 despite repeated checks of its loops and call sites. The test input itself was not that large, so this remained an unresolved bounds issue in these notes.

When running NEMU on NEMU, disabling function tracing also required removing the host-oriented -lelf link option from the applicable AM build path.

Finally, sanitizer output exposed a mismatch between my assumptions about SDL’s sample count and the actual callback byte length. With 2048 samples and AUDIO_S16SYS, I observed a 2048-byte callback buffer in that test configuration. Adjusting the copy to the supplied length improved playback. The callback’s actual len, format, and channel configuration must determine how much memory is accessed.


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 !