Creating a Device

Ryan Torvik - May 11, 2023 - 5 minute read

Tulip Tree Technology

Last time, we took our little firmware and tried running it in our full-system emulator. We were able to run it, but when we encountered an interaction with our calculator device (which doesn’t exist yet), it halted. This time we are going to create our device, plug it into Emerson, and rerun our firmware.

Memory Mapped I/O Device

You’ll remember from our previous discussion that a peripheral device is one that does some work for the main processor. These can do some significant data processing like another CPU or a GPU. They can also bring in data from outside the system like an ethernet controller or a keyboard. Even a computer’s Random Access Memory (RAM) bank is treated like a peripheral device. In most cases, the main processor communicates with these devices by doing what is called Memory Mapped I/O (MMIO) communication.

For MMIO, the processor uses standard LOAD and STORE operations to send read and write signals to specific memory addresses. Each attached device is assigned a range of addresses. There are multiple ways to get the actual signal to a specific device. These are beyond the depth of this article. Regardless, the appropriate device gets the read or write on the masked address.

The address corresponds to what is called a device register. The device will then take an action based on which register is read from or written to.

Let’s look at our example

Our Firmware and Device

#include <stdint.h>
#define ACCUMULATOR_ADDRESS ((volatile uint32_t*)0x20000000)
#define OPERATION_ADDRESS ((volatile uint32_t*)0x20000004)

#define ADD_INDEX 1
#define SUBTRACT_INDEX 2
#define MULTIPLY_INDEX 3
#define DIVIDE_INDEX 4

void load_accumulator(uint32_t value) {
    *ACCUMULATOR_ADDRESS = value;
}

uint32_t read_accumulator() {
    return *ACCUMULATOR_ADDRESS;
}

void select_add() {
    *OPERATION_ADDRESS = ADD_INDEX;
}
void select_subtract() {
    *OPERATION_ADDRESS = SUBTRACT_INDEX;
}
void select_muliply() {
    *OPERATION_ADDRESS = MULTIPLY_INDEX;
}
void select_divide() {
    *OPERATION_ADDRESS = DIVIDE_INDEX;
}

#define COUNT 10

int main(void) {
    int nums[10] = {1};
    int last = 0, current = 1;
    const int LEN = sizeof(nums)/sizeof(int);
    for (int i = 0; i < LEN - 1; i++) {
        load_accumulator(nums[i]);
        select_add();
        nums[i+1] = read_accumulator();
    }

    return nums[LEN - 1];
}

Let’s look at the code that interacts with the device.

This first snippet writes a value to address 0x2000_0000 which will correspond to a device mapped in at 0x2000_0000 and device register 0x0.

#define ACCUMULATOR_ADDRESS ((volatile uint32_t*)0x20000000)

void load_accumulator(uint32_t value) {
    *ACCUMULATOR_ADDRESS = value;
}

This snippet writes a 0x1 to 0x2000_0004 which will correspond to a device mapped in at 0x2000_0000 and device register 0x4.

#define OPERATION_ADDRESS ((volatile uint32_t*)0x20000004)
#define ADD_INDEX 1

void select_add() {
    *OPERATION_ADDRESS = ADD_INDEX;
}

This reads a value from 0x2000_0000 which will correspond to a device mapped in at 0x2000_0000 and device register 0x0.

#define ACCUMULATOR_ADDRESS ((volatile uint32_t*)0x20000000)

uint32_t read_accumulator() {
    return *ACCUMULATOR_ADDRESS;
}

So, we have some idea of what our device will need to do.

Create the Device

Here’s a python script that we’ll put in our tenkey project directory. It implements all the required functionality in the way the Emerson will need it done.

from emerson import DevicePlugin, Bus
from typing import Optional, List, Callable
from ctypes import c_uint32, c_uint64

'''
Must define an init() function that returns an initialized Plugin Device object
--------
Plugin Devices should extend the emerson.EmersonPlugin base class
--------
State Functions
- `reset(self) -> None: ...`
- `serialized_attr_list() -> List[str]: ...`

Optional state functions
# Override the following if custom serialization is required
# Overriding them means `serialized_attr_list()` function can be ignored
- `serialize(self) -> Optional[bytes]: ...`
- `deserialize(self, _:bytes) -> None: ...`

--------
Memory methods are all technically optional, defining them will inform the plugin loader
that this plugin supports specific read/write sizes. The emulator will automatically
reject any access size that isn't defined by one of the following functions. Reads
can optionally return None on invalid requests, Writes return True/False indicating
success.

# Read Slice
- `read(self, device_register: int, size: int) -> Optional[bytes]: ...`
# Write Slice
- `write(self, device_register: int, values: bytes) -> bool: ...`

# Integer Reads
- `read8(self,  device_register: c_uint64) -> Optional[c_uint8]: ...`
- `read16(self, device_register: c_uint64) -> Optional[c_uint16]: ...`
- `read32(self, device_register: c_uint64) -> Optional[c_uint32]: ...`
- `read64(self, device_register: c_uint64) -> Optional[c_uint64]: ...`

# Integer Writes
- `write8(self,  device_register: c_uint64, value: c_uint8) -> bool: ...`
- `write16(self, device_register: c_uint64, value: c_uint16) -> bool: ...`
- `write32(self, device_register: c_uint64, value: c_uint32) -> bool: ...`
- `write64(self, device_register: c_uint64, value: c_uint64) -> bool: ...`

--------
'''

operations:List[Callable[[int, int], int]] = [
    lambda _,value: value,                          # X
    lambda accumulator,value: accumulator + value,  # A + X
    lambda accumulator,value: accumulator - value,  # A - X
    lambda accumulator,value: accumulator * value,  # A * X
    lambda accumulator,value: accumulator / value,  # A / X
]

VOID_INDEX = 0
ADD_INDEX = 1
SUB_INDEX = 2
MUL_INDEX = 3
DIV_INDEX = 4

class TestPlugin(DevicePlugin):
    accumulator: int = 0
    selected_operation: int = 0

    def __init__(self):
        super().__init__()
        self.reset()

    def serialized_attr_list(self) -> List[str]:
        '''
        Return a list of strings naming the attributes that should be serialized.
        '''
        return [ "accumulator", "selected_operation" ]

    def reset(self):
        '''
        Reset the device to its default state
        '''
        self.accumulator = 0
        self.selected_operation = 0

    def tick(self, bus: Bus):
        '''
        Execute an action based on a timer
        Do NOT store the bus object internally
        '''
        pass

    def read32(self, device_register: c_uint64) -> Optional[c_uint32]:
        '''
        Process a read to this device
        - Device register 0 will return the value in the accumulator
        - Device register 4 will return the index of the selected operation
          - index 0 is a straight assignment
          - index 1 is add
          - index 2 is subtract
          - index 3 is multiply
          - index 4 is divide
        '''
        if device_register == 0:
            return self.accumulator
        elif device_register == 4:
            return self.selected_operation
        else:
            return None

    def write32(self, device_register: c_uint64, value: c_uint32) -> bool:
        '''
        Process a write to this device
        - Device register 0 will take the given value and combine it with the accumulator using the selected operation
        - Device register 4 will set the selected operation
          - index 0 is a straight assignment
          - index 1 is add
          - index 2 is subtract
          - index 3 is multiply
          - index 4 is divide
        '''
        if device_register == 0:
            if self.selected_operation >= len(operations) or self.selected_operation == DIV_INDEX:
                # TODO HANDLE divide by zero
                pass
            self.accumulator = operations[self.selected_operation](self.accumulator, value)
            self.selected_operation = 0
            return True
        elif device_register == 4 and value < len(operations):
            self.selected_operation = value
            return True
        else:
            return False

def init() -> TestPlugin:
    return TestPlugin()

There’s a lot to unpack here. But focus on the read32 and write32. They implement the read and write functionality based on a given device register.

Conclusion

Fortunately, we have the source code that interacts with the device and we are creating the device according to our own specifications. Reverse engineering a firmware and creating a device based on those results is much more complicated and time intensive. We utilize datasheets and any other written documentation found, in addition to reviewing the disassembled firmware.

This extremely simple device doesn’t require any input and output from outside the system. It also doesn’t do any independent activity based on a timer. Most of the real world devices you are going to run into are going to require those kinds of things. But, the basics of communicating with a processor are the same.

Next time we’ll hook our device into our Emerson project and see what happens!

Previous Post Next Post

Tulip Tree Technology
Learn Deep | Dream Big

© 2024 Tulip Tree Technology