Looking at Firmware
Ryan Torvik - April 17, 2023 - 3 minute read
A major part of building a fully emulated system is figuring out how the peripheral devices work. In this blog post, we are going to walk through some firmware that interacts with a peripheral device we will create later.
Peripheral devices
In broad terms, a peripheral device is anything attached to a CPU that moves data into or out of a computer. Peripheral devices include, but are certainly not limited to, keyboards, mice, monitors, cameras, WiFi antennas, bluetooth controllers, and audio cards.
There are a lot of nuances to how devices are connected to the main processor and how the data transference actually happens. For this example, we are going to focus on memory-mapped IO devices. Instructions interact with these devices by reading or writing to a specific memory range.
The Firmware
Let’s look at an example. Below we have a piece of code that interacts with a peripheral device.
#include <stdint.h>
volatile uint32_t *register_addr = (void*)0x20000000;
volatile uint32_t *function_addr = (void*)0x20000004;
#define ADD_IDX 1
#define SUB_IDX 2
#define MUL_IDX 3
#define DIV_IDX 4
void load_register(uint32_t value) {
*register_addr = value;
}
uint32_t read_result() {
return *register_addr;
}
void select_add() {
*function_addr = ADD_IDX;
}
void select_sub() {
*function_addr = SUB_IDX;
}
void select_mul() {
*function_addr = MUL_IDX;
}
void select_div() {
*function_addr = DIV_IDX;
}
#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_register(nums[i]);
select_add();
nums[i+1] = read_result();
}
return nums[LEN - 1];
}
You’ll notice that this code compiles just fine, but trying to run it segfaults. It’s writing directly to a memory location that hasn’t been allocated! Remember, this is intended to be a device driver that runs in the kernel. These memory addresses correspond to what are called “device registers”.
volatile uint32_t *register_addr = (void*)0x20000000;
volatile uint32_t *function_addr = (void*)0x20000004;
In main
we do some looping and then we call load_register
, select_add
, and read_result
.
for (int i = 0; i < LEN - 1; i++) {
load_register(nums[i]);
select_add();
nums[i+1] = read_result();
}
Each one of these methods interacts with the device through the device registers.
In load_register, we write a value to 0x20000000
.
void load_register(uint32_t value) {
*register_addr = value;
}
This stores the given value somewhere in the device.
In select_add()
, we assign the value 1
to 0x20000004
void select_add() {
*function_addr = ADD_IDX;
}
Then in read_register()
we read from 0x20000000
uint32_t read_result() {
return *register_addr;
}
Since we are designing BOTH the firmware and the device for this entire example, we know what each device register does and what values to send and what to do with those values. If we didn’t have the source for the firmware and were trying to create a device, we would use an interactive disasembler like IDAPro, BinaryNinja, or Ghidra.
If we compile this code with gcc, we can open it in cloud.binary.ninja. Here’s what the select_add()
function looks like.
We have turned the binary machine code into human readable assembly instructions. We move the value 0x20000004
into rax
. Then we dereference rax
and move a value into it.
Looking at the assembly will help us as we try to run and debug our device later. If we had just a firmware, we might not have any source code to go along with it. So, we would lean on IDAPro, BinaryNinja, or Ghidra to read the disassembly. These tools also provide a decompiler to get an idea of what the source code might looked like. ChatGPT is even being used to generate possible source code from a set of assembly instructions.
Conclusion
Accurately modeling the behavior of peripheral devices is crucial to getting a full-system emulated properly. By examining the firmware that interacts with a peripheral device, we can get a good idea of how that device works. Reading spec sheets, reviewing technical documentation, and interacting with real hardware, are also ways of determining how a device works. But, once we get the device modeled correctly, we can subject our firmware to a wide variety of test scenarios. Accurate software modeling allows us to test how the hardware will behave before it is built. It also gives us the opportunity to scale our testing efforts well beyond our current capabilities.