(Possibly) Emulate PinePhone with Unicorn Emulator

đź“ť 24 Feb 2023

Emulating Arm64 Machine Code in Unicorn

Emulating Arm64 Machine Code in Unicorn

Unicorn is a lightweight CPU Emulator Framework based on QEMU.

(Programmable with C, Rust, Python and many other languages)

We’re porting a new operating system Apache NuttX RTOS to Pine64 PinePhone. And I wondered…

To make PinePhone testing easier… Can we emulate Arm64 PinePhone with Unicorn Emulator?

Let’s find out! In this article we’ll call Unicorn Emulator to…

We’ll do all this in basic Rust, instead of classic C.

(That’s because I’m too old to write meticulous C… But I’m OK to get nagged by Rust Compiler if I miss something!)

We begin by emulating some machine code…

1 Emulate Arm64 Machine Code

Suppose we wish to emulate this Arm64 Machine Code…

// Start Address: 0x10000

// str  w11, [x13], #0
AB 05 00 B8

// ldrb w15, [x13], #0
AF 05 40 38

// End Address: 0x10008

With these Arm64 Register Values…

RegisterValue
X110x12345678
X130x10008
X150x33

Which means…

  1. Store X11 (value 0x12345678)

    Into the address referenced by X13

    (Address 0x10008)

  2. Load X15 as a Single Byte

    From the address referenced by X13

    (Address 0x10008)

  3. Which sets X15 to 0x78

    (Because 0x10008 contains byte 0x78)

    (X Registers are 64-bit, W Registers are 32-bit)

This is how we call Unicorn Emulator to emulate the Arm64 Machine Code: main.rs

use unicorn_engine::{Unicorn, RegisterARM64};
use unicorn_engine::unicorn_const::{Arch, Mode, Permission};

fn main() {
  // Arm64 Memory Address where emulation starts
  const ADDRESS: u64 = 0x10000;

  // Arm64 Machine Code for the above address
  let arm64_code: Vec<u8> = vec![
    0xab, 0x05, 0x00, 0xb8,  // str w11,  [x13], #0
    0xaf, 0x05, 0x40, 0x38,  // ldrb w15, [x13], #0
  ];

We begin by defining the Arm64 Machine Code.

Then we initialise the emulator…

  // Init Emulator in Arm64 mode
  let mut unicorn = Unicorn::new(
    Arch::ARM64,
    Mode::LITTLE_ENDIAN
  ).expect("failed to init Unicorn");

  // Magical horse mutates to bird
  let emu = &mut unicorn;

Unicorn needs some Emulated Memory to run our code.

We map 2MB of Executable Memory…

  // Map 2MB of Executable Memory at 0x10000
  // for Arm64 Machine Code
  emu.mem_map(
    ADDRESS,          // Address is 0x10000
    2 * 1024 * 1024,  // Size is 2MB
    Permission::ALL   // Read, Write and Execute Access
  ).expect("failed to map code page");

And we populate the Executable Memory with our Arm64 Machine Code…

  // Write Arm64 Machine Code to emulated Executable Memory
  emu.mem_write(
    ADDRESS,     // Address is 0x10000
    &arm64_code  // Arm64 Machine Code
  ).expect("failed to write instructions");

We set the Arm64 Registers: X11, X13 and X15…

  // Register Values
  const X11: u64 = 0x12345678;    // X11 value
  const X13: u64 = ADDRESS + 0x8; // X13 value
  const X15: u64 = 0x33;          // X15 value
  
  // Set the Arm64 Registers
  emu.reg_write(RegisterARM64::X11, X11)
    .expect("failed to set X11");
  emu.reg_write(RegisterARM64::X13, X13)
    .expect("failed to set X13");
  emu.reg_write(RegisterARM64::X15, X15)
    .expect("failed to set X15");

We start the emulator…

  // Emulate Arm64 Machine Code
  let err = emu.emu_start(
    ADDRESS,  // Begin Address is 0x10000
    ADDRESS + arm64_code.len() as u64,  // End Address is 0x10008
    0,  // No Timeout
    0   // Unlimited number of instructions
  );

  // Print the Emulator Error
  println!("err={:?}", err);

Finally we read Register X15 and verify the result…

  // Read the X15 Register
  assert_eq!(
    emu.reg_read(RegisterARM64::X15),  // Register X15
    Ok(0x78)  // Expected Result
  );
}

And we’re done!

Remember to add unicorn-engine to the dependencies: Cargo.toml

[dependencies]
unicorn-engine = "2.0.0"

When we run our Rust Program…

→ cargo run --verbose

Fresh cc v1.0.79
Fresh cmake v0.1.49
Fresh pkg-config v0.3.26
Fresh bitflags v1.3.2
Fresh libc v0.2.139
Fresh unicorn-engine v2.0.1
Fresh pinephone-emulator v0.1.0
Finished dev [unoptimized + debuginfo] target(s) in 0.08s
Running `target/debug/pinephone-emulator`

err=Ok(())

Unicorn is hunky dory!

Let’s talk about Memory-Mapped Input / Output…

Memory Access Hook for Arm64 Emulation

Memory Access Hook for Arm64 Emulation

2 Memory Access Hook

To emulate our gadget (like PinePhone), we need to handle Memory-Mapped Input / Output.

(Like for printing to the Serial or UART Port)

We do this in Unicorn Emulator with a Memory Access Hook that will be called to intercept every Memory Access.

Here’s a sample Hook Function that will be called to intercept every Arm64 Read / Write Access: main.rs

// Hook Function for Memory Access.
// Called once for every Arm64 Memory Access.
fn hook_memory(
  _: &mut Unicorn<()>,  // Emulator
  mem_type: MemType,    // Read or Write Access
  address:  u64,    // Accessed Address
  size:     usize,  // Number of bytes accessed
  value:    i64     // Write Value
) -> bool {         // Always return true

  // TODO: Emulate Memory-Mapped Input/Output (UART Controller)
  println!("hook_memory: mem_type={:?}, address={:#x}, size={:?}, value={:#x}", mem_type, address, size, value);

  // Always return true, value is unused by caller
  // https://github.com/unicorn-engine/unicorn/blob/dev/docs/FAQ.md#i-cant-recover-from-unmapped-readwrite-even-i-return-true-in-the-hook-why
  true
}

Our Hook Function prints every Read / Write Access to the Emulated Arm64 Memory.

This is how we attach the Memory Hook Function to Unicorn Emulator: main.rs

// Add Hook for Arm64 Memory Access
let _ = emu.add_mem_hook(
  HookType::MEM_ALL,  // Intercept Read and Write Access
  0,           // Begin Address
  u64::MAX,    // End Address
  hook_memory  // Hook Function
).expect("failed to add memory hook");

When we run this, we see the Read and Write Memory Accesses made by our Emulated Arm64 Code…

hook_memory: 
  mem_type=WRITE, 
  address=0x10008, 
  size=4, 
  value=0x12345678

hook_memory: 
  mem_type=READ, 
  address=0x10008, 
  size=1, 
  value=0x0

(Value is not relevant for Memory Reads)

Later we’ll implement UART Output with a Memory Access Hook. But first we intercept some code…

Code Execution Hook for Arm64 Emulation

Code Execution Hook for Arm64 Emulation

3 Code Execution Hook

Can we intercept every Arm64 Instruction that will be emulated?

Yep we can attach a Code Execution Hook to Unicorn Emulator.

Here’s a sample Hook Function that will be called for every Arm64 Instruction emulated: main.rs

// Hook Function for Code Emulation.
// Called once for each Arm64 Instruction.
fn hook_code(
  _: &mut Unicorn<()>,  // Emulator
  address: u64,  // Instruction Address
  size: u32      // Instruction Size
) {
  // TODO: Handle special Arm64 Instructions
  println!("hook_code: address={:#x}, size={:?}", address, size);
}

And this is how we call Unicorn Emulator to attach the Code Hook Function: main.rs

// Add Hook for emulating each Arm64 Instruction
let _ = emu.add_code_hook(
  ADDRESS,  // Begin Address
  ADDRESS + arm64_code.len() as u64,  // End Address
  hook_code  // Hook Function for Code Emulation
).expect("failed to add code hook");

When we run this with our Arm64 Machine Code, we see the Address of every Arm64 Instruction emulated (and its size)…

hook_code:
  address=0x10000,
  size=4

hook_code:
  address=0x10004,
  size=4

We might use this to emulate Special Arm64 Instructions.

If we don’t need to intercept every single instruction, try the Block Execution Hook…

4 Block Execution Hook

Is there something that works like a Code Execution Hook…

But doesn’t stop at every single Arm64 Instruction?

Yep Unicorn Emulator supports Block Execution Hooks.

This Hook Function will be called once when executing a Block of Arm64 Instructions: main.rs

// Hook Function for Block Emulation.
// Called once for each Basic Block of Arm64 Instructions.
fn hook_block(
  _: &mut Unicorn<()>,  // Emulator
  address: u64,  // Block Address
  size: u32      // Block Size
) {
  // TODO: Trace the flow of emulated code
  println!("hook_block: address={:#x}, size={:?}", address, size);
}

This is how we attach the Block Execution Hook: main.rs

// Add Hook for emulating each Basic Block of Arm64 Instructions
let _ = emu.add_block_hook(hook_block)
  .expect("failed to add block hook");

Block Execution Hooks are less granular (called less often) than Code Execution Hooks…

hook_block: address=0x10000, size=8
hook_code:  address=0x10000, size=4
hook_code:  address=0x10004, size=4

Which means that Unicorn Emulator calls our Hook Function only once for the entire Block of two Arm64 Instructions.

(What’s a Block of Arm64 Instructions?)

How is this useful?

The Block Execution Hook is super helpful for monitoring the Execution Flow of our emulated code. We can…

This is how we do it…

5 Unmapped Memory

What happens when Unicorn Emulator tries to access memory that isn’t mapped?

Unicorn Emulator will call our Memory Access Hook with mem_type set to READ_UNMAPPED…

hook_memory:
  address=0x01c28014,
  size=2,
  mem_type=READ_UNMAPPED,
  value=0x0

(Source)

The log says that our Arm64 Machine Code attempted to read address 01C2 8014, which is unmapped.

This is how we map the memory: rust.rs

// Map 16 MB at 0x0100 0000 for Memory-Mapped I/O by Allwinner A64 Peripherals
// https://github.com/apache/nuttx/blob/master/arch/arm64/src/a64/hardware/a64_memorymap.h#L33-L51
emu.mem_map(
  0x0100_0000,       // Address
  16 * 1024 * 1024,  // Size
  Permission::READ | Permission::WRITE  // Read and Write Access
).expect("failed to map memory mapped I/O");

We’ll see this later when we handle Memory-Mapped Input / Output.

Can we map Memory Regions during emulation?

Yep we may use a Memory Access Hook to map memory regions on the fly.

(Like this)

Running Apache NuttX RTOS in Unicorn

Running Apache NuttX RTOS in Unicorn

6 Apache NuttX RTOS in Unicorn

We’re ready to run Apache NuttX RTOS in Unicorn Emulator!

We’ve compiled Apache NuttX RTOS for PinePhone into an Arm64 Binary Image: nuttx.bin

This is how we load the NuttX Binary Image into Unicorn: main.rs

// Arm64 Memory Address where emulation starts
const ADDRESS: u64 = 0x4008_0000;

// Arm64 Machine Code for the above address
let arm64_code = include_bytes!("../nuttx/nuttx.bin");

(Rustle… Whoosh!)

We initialise the emulator the same way…

// Init Emulator in Arm64 mode
let mut unicorn = Unicorn::new(
  Arch::ARM64,
  Mode::LITTLE_ENDIAN
).expect("failed to init Unicorn");

// Magical horse mutates to bird
let emu = &mut unicorn;

Based on the NuttX Memory Map for PinePhone, we map two Memory Regions for NuttX…

// Map 128 MB Executable Memory at 0x4000 0000 for Arm64 Machine Code
// https://github.com/apache/nuttx/blob/master/arch/arm64/include/a64/chip.h#L44-L52
emu.mem_map(
  0x4000_0000,        // Address
  128 * 1024 * 1024,  // Size
  Permission::ALL     // Read, Write and Execute Access
).expect("failed to map code page");

// Map 512 MB Read/Write Memory at 0x0000 0000 for
// Memory-Mapped I/O by Allwinner A64 Peripherals
// https://github.com/apache/nuttx/blob/master/arch/arm64/include/a64/chip.h#L44-L52
emu.mem_map(
  0x0000_0000,        // Address
  512 * 1024 * 1024,  // Size
  Permission::READ | Permission::WRITE  // Read and Write Access
).expect("failed to map memory mapped I/O");

We load the NuttX Machine Code into Emulated Memory…

// Write Arm64 Machine Code to emulated Executable Memory
emu.mem_write(
  ADDRESS,    // Address is 4008 0000
  arm64_code  // NuttX Binary Image
).expect("failed to write instructions");

And we run NuttX RTOS!

// Omitted: Attach Code, Block and Memory Hooks
...
// Emulate Arm64 Machine Code
let err = emu.emu_start(
  ADDRESS,  // Begin Address
  ADDRESS + arm64_code.len() as u64,  // End Address
  0,  // No Timeout
  0   // Unlimited number of instructions
);

Unicorn happily boots Nuttx RTOS (yay!)…

→ cargo run 
hook_block:  address=0x40080000, size=8
hook_block:  address=0x40080040, size=4
hook_block:  address=0x40080044, size=12
hook_block:  address=0x40080118, size=16
...

(See the Arm64 Disassembly)

But our legendary creature gets stuck in mud. Let’s find out why…

Emulating the UART Controller

Emulating the UART Controller

7 Wait for UART Controller

Unicorn gets stuck in a curious loop while booting NuttX RTOS…

hook_code:   address=0x400801f8, size=4
hook_code:   address=0x400801fc, size=4
hook_block:  address=0x400801f4, size=12
hook_code:   address=0x400801f4, size=4
hook_memory: address=0x01c28014, size=2, mem_type=READ, value=0x0

hook_code:   address=0x400801f8, size=4
hook_code:   address=0x400801fc, size=4
hook_block:  address=0x400801f4, size=12
hook_code:   address=0x400801f4, size=4
hook_memory: address=0x01c28014, size=2, mem_type=READ, value=0x0
...

(Source)

See the pattern? Unicorn Emulator loops forever at address 4008 01F4…

While reading the data from address 01C2 8014.

What’s at 4008 01F4?

Here’s the NuttX Arm64 Disassembly at address 4008 01F4: nuttx.S

SECTION_FUNC(text, up_lowputc)
  ldr   x15, =UART0_BASE_ADDRESS
  400801f0:	580000cf 	ldr	x15, 40080208 <up_lowputc+0x18>
nuttx/arch/arm64/src/chip/a64_lowputc.S:89
  early_uart_ready x15, w2
  400801f4:	794029e2 	ldrh	w2, [x15, #20]
  400801f8:	721b005f 	tst	w2, #0x20
  400801fc:	54ffffc0 	b.eq	400801f4 <up_lowputc+0x4>  // b.none
nuttx/arch/arm64/src/chip/a64_lowputc.S:90
  early_uart_transmit x15, w0
  40080200:	390001e0 	strb	w0, [x15]
nuttx/arch/arm64/src/chip/a64_lowputc.S:91
  ret
  40080204:	d65f03c0 	ret

Which comes from this NuttX Source Code: a64_lowputc.S

/* Wait for A64 UART to be ready to transmit
 * xb: Register that contains the UART Base Address
 * wt: Scratch register number
 */
.macro early_uart_ready xb, wt
1:
  ldrh  \wt, [\xb, #0x14] /* UART_LSR (Line Status Register) */
  tst   \wt, #0x20        /* Check THRE (TX Holding Register Empty) */
  b.eq  1b                /* Wait for the UART to be ready (THRE=1) */
.endm

NuttX is printing something to the UART Port?

Yep! NuttX prints Debug Messages to the (Serial) UART Port when it boots…

And it’s waiting for the UART Controller to be ready, before printing!

(As explained here)

What’s at 01C2 8014?

01C2 8014 is the UART Line Status Register (UART_LSR) for the Allwinner A64 UART Controller inside PinePhone.

Bit 5 needs to be set to 1 to indicate that the UART Transmit FIFO is ready. Or NuttX will wait forever!

(As explained here)

How to fix the UART Ready Bit at 01C2 8014?

This is how we emulate the UART Ready Bit with our Input / Output Memory: main.rs

// Allwinner A64 UART Line Status Register (UART_LSR) at Offset 0x14.
// To indicate that the UART Transmit FIFO is ready:
// Set Bit 5 to 1.
// https://lupyuen.github.io/articles/serial#wait-to-transmit
emu.mem_write(
  0x01c2_8014,  // UART Register Address
  &[0b10_0000]  // UART Register Value
).expect("failed to set UART_LSR");

And Unicorn Emulator stops looping!

Unicorn continues booting NuttX, which fills the BSS Section with 0…

hook_block:  address=0x40089328, size=8
hook_memory: address=0x400b6a52, size=1, mem_type=WRITE, value=0x0
hook_block:  address=0x40089328, size=8
hook_memory: address=0x400b6a53, size=1, mem_type=WRITE, value=0x0
...

(Source)

But we don’t see any NuttX Boot Messages. Let’s print the UART Output…

Emulating UART Output in Unicorn

Emulating UART Output in Unicorn

8 Emulate UART Output

We expect to see Boot Messages from NuttX…

How do we print the UART Output?

NuttX RTOS will write the UART Output to Allwinner A64’s UART Transmit Holding Register (THR) at 01C2 8000…

To emulate the UART Output, we use Unicorn’s Memory Access Hook…

In our Memory Access Hook, we intercept all writes to 01C2 8000 and dump the bytes written: main.rs

// Hook Function for Memory Access.
// Called once for every Arm64 Memory Access.
fn hook_memory(
  _: &mut Unicorn<()>,  // Emulator
  mem_type: MemType,    // Read or Write Access
  address: u64,  // Accessed Address
  size: usize,   // Number of bytes accessed
  value: i64     // Write Value
) -> bool {      // Always return true

  // If writing to UART Transmit Holding Register (THR):
  // Print the output
  // https://lupyuen.github.io/articles/serial#transmit-uart
  if address == 0x01c2_8000 {
    println!("uart output: {:?}", value as u8 as char);
  }

  // Always return true, value is unused by caller
  // https://github.com/unicorn-engine/unicorn/blob/dev/docs/FAQ.md#i-cant-recover-from-unmapped-readwrite-even-i-return-true-in-the-hook-why
  true
}

When we run this, we see a long chain of UART Output…

→ cargo run | grep uart
uart output: '-'
uart output: ' '
uart output: 'R'
uart output: 'e'
uart output: 'a'
uart output: 'd'
uart output: 'y'
...

(Source)

Which reads as…

- Ready to Boot CPU
- Boot from EL2
- Boot from EL1
- Boot to C runtime for OS Initialize

(Similar to this)

Yep NuttX RTOS is booting on Unicorn Emulator! But we have a problem…

Debugging an Arm64 Exception

Debugging an Arm64 Exception

9 Emulator Halts with MMU Fault

NuttX RTOS boots OK on Unicorn Emulator?

Not quite. Unicorn Emulator halts with an Arm64 Exception at address 4008 0EF8…

hook_block:  address=0x40080eec, size=16
hook_code:   address=0x40080eec, size=4
hook_code:   address=0x40080ef0, size=4
hook_code:   address=0x40080ef4, size=4
hook_code:   address=0x40080ef8, size=4
err=Err(EXCEPTION)

(See the Complete Log)

Here’s the NuttX Arm64 Disassembly at address 4008 0EF8: nuttx.S

nuttx/arch/arm64/src/common/arm64_mmu.c:544
  write_sysreg((value | SCTLR_M_BIT | SCTLR_C_BIT), sctlr_el1);
    40080ef0:	d28000a1 	mov	x1, #0x5                   	// #5
    40080ef4:	aa010000 	orr	x0, x0, x1
    40080ef8:	d5181000 	msr	sctlr_el1, x0

Which comes from this NuttX Source Code: arm64_mmu.c

// Enable the MMU and data cache:
// Read from System Control Register EL1
value = read_sysreg(sctlr_el1);

// Write to System Control Register EL1
write_sysreg(  // Write to System Register...
  value | SCTLR_M_BIT | SCTLR_C_BIT,  // Enable Address Translation and Caching
  sctlr_el1    // System Control Register EL1
);

The code above sets these flags in Arm64 System Control Register EL1 (SCTLR_EL1)…

Thus the Address Translation (or Caching) has failed in our Emulated Arm64 Memory Management Unit, inside enable_mmu_el1…

Call Graph for Apache NuttX RTOS

(Source)

We won’t chase the Unicorn into the Rabbit Hole, but the details are covered here…

Apache NuttX RTOS on PinePhone

Apache NuttX RTOS on PinePhone

10 Emulation Concerns

So are we happy with Unicorn Emulator?

Yep! Unicorn Emulator is sufficient for Automated Daily Build and Test for NuttX on PinePhone. (Via GitHub Actions)

Which will be similar to this BL602 setup, except that we’ll boot the Daily Build on Unicorn Emulator (instead of Real Hardware)…

Also Unicorn Emulator has produced a Call Graph for NuttX on PinePhone, which is extremely valuable for troubleshooting…

But our PinePhone Emulator doesn’t handle Console Input…

Yeah we’ll do that later. We have a long wishlist of features to build: Interrupts, Memory Protection, Multiple CPUs, Cortex A53, GIC v2, …

NuttX runs graphical apps on PinePhone right?

Yep someday we’ll render the NuttX Graphics Framebuffers in Unicorn.

(Maybe with a Rust GUI Library. Or Unicorn.js and WebAssembly)

What about emulating other operating systems: Linux / macOS / Windows / Android?

Check out the Qiling Binary Emulation Framework…

How about other hardware platforms: STM32 Blue Pill and ESP32?

Check out QEMU Emulator…

11 What’s Next

Check out the next article…

This has been a fun educational exercise. Now we have a way to run Automated Daily Tests for Apache NuttX RTOS on PinePhone… Kudos to the Maintainers of Unicorn Emulator!

Many Thanks to my GitHub Sponsors for supporting my work! This article wouldn’t have been possible without your support.

Got a question, comment or suggestion? Create an Issue or submit a Pull Request here…

lupyuen.github.io/src/unicorn.md

12 Appendix: Map Address to Function with ELF File

Check out the new article…

Our Block Execution Hook now prints the Function Name and the Filename…

hook_block:  
  address=0x40080eb0, 
  size=12, 
  setup_page_tables, 
  arch/arm64/src/common/arm64_mmu.c:516:25

hook_block:  
  address=0x40080eec, 
  size=16, 
  enable_mmu_el1, 
  arch/arm64/src/common/arm64_mmu.c:543:11

err=Err(EXCEPTION)

(Source)

Our Hook Function looks up the Address in the DWARF Debug Symbols of the NuttX ELF File, like so: main.rs

/// Hook Function for Block Emulation.
/// Called once for each Basic Block of Arm64 Instructions.
fn hook_block(
  _: &mut Unicorn<()>,  // Emulator
  address: u64,  // Block Address
  size: u32      // Block Size
) {
  print!("hook_block:  address={:#010x}, size={:02}", address, size);

  // Print the Function Name
  let function = map_address_to_function(address);
  if let Some(ref name) = function {
    print!(", {}", name);
  }

  // Print the Source Filename
  let loc = map_address_to_location(address);
  if let Some((ref file, line, col)) = loc {
    let file = file.clone().unwrap_or("".to_string());
    let line = line.unwrap_or(0);
    let col = col.unwrap_or(0);
    print!(", {}:{}:{}", file, line, col);
  }
  println!();
}

We map the Block Address to Function Name and Source File in map_address_to_function and map_address_to_location: main.rs

/// Map the Arm64 Code Address to the Function Name by looking up the ELF Context
fn map_address_to_function(
  address: u64         // Code Address
) -> Option<String> {  // Function Name
  // Lookup the Arm64 Code Address in the ELF Context
  let context = ELF_CONTEXT.context.borrow();
  let mut frames = context.find_frames(address)
    .expect("failed to find frames");

  // Return the Function Name
  if let Some(frame) = frames.next().unwrap() {
    if let Some(func) = frame.function {
      if let Ok(name) = func.raw_name() {
        let s = String::from(name);
        return Some(s);
      }
    }    
  }
  None
}

/// Map the Arm64 Code Address to the Source Filename, Line and Column
fn map_address_to_location(
  address: u64     // Code Address
) -> Option<(      // Returns...
  Option<String>,  // Filename
  Option<u32>,     // Line
  Option<u32>      // Column
)> {
  // Lookup the Arm64 Code Address in the ELF Context
  let context = ELF_CONTEXT.context.borrow();
  let loc = context.find_location(address)
    .expect("failed to find location");

  // Return the Filename, Line and Column
  if let Some(loc) = loc {
    if let Some(file) = loc.file {
      let s = String::from(file)
        .replace("/private/tmp/nuttx/nuttx/", "")
        .replace("arch/arm64/src/chip", "arch/arm64/src/a64");  // TODO: Handle other chips
      Some((Some(s), loc.line, loc.column))
    } else {
      Some((None, loc.line, loc.column))
    }
  } else {
    None
  }
}

To run this, we need the addr2line, gimli and once_cell crates: Cargo.toml

[dependencies]
addr2line = "0.19.0"
gimli = "0.27.2"
once_cell = "1.17.1"
unicorn-engine = "2.0.0"

At startup, we load the NuttX ELF File into ELF_CONTEXT as a Lazy Static: main.rs

use std::rc::Rc;
use std::cell::RefCell;
use once_cell::sync::Lazy;

/// ELF File for mapping Addresses to Function Names and Filenames
const ELF_FILENAME: &str = "nuttx/nuttx";

/// ELF Context for mapping Addresses to Function Names and Filenames
static ELF_CONTEXT: Lazy<ElfContext> = Lazy::new(|| {
  // Open the ELF File
  let path = std::path::PathBuf::from(ELF_FILENAME);
  let file_data = std::fs::read(path)
    .expect("failed to read ELF");
  let slice = file_data.as_slice();

  // Parse the ELF File
  let obj = addr2line::object::read::File::parse(slice)
    .expect("failed to parse ELF");
  let context = addr2line::Context::new(&obj)
    .expect("failed to parse debug info");

  // Set the ELF Context
  ElfContext {
    context: RefCell::new(context),
  }
});

/// Wrapper for ELF Context. Needed for `Lazy`
struct ElfContext {
  context: RefCell<
    addr2line::Context<
      gimli::EndianReader<
        gimli::RunTimeEndian, 
        Rc<[u8]>  // Doesn't implement Send / Sync
      >
    >
  >
}

/// Send and Sync for ELF Context. Needed for `Lazy`
unsafe impl Send for ElfContext {}
unsafe impl Sync for ElfContext {}