I was recently experimenting with USB-C Power Delivery in Zephyr, with the goal of requesting a higher voltage than the usual 5V from a USB-C power supply. I had an OtterPill board lying around, which has everything on board to accomplish this, including a FUSB302B USB-PD controller IC, which manages low level details of the power delivery communications. Zephyr does not yet support this chip, so I’m currently writing a driver for it (here is the draft PR for my driver). While writing and debugging the driver however, I came across a talk about peripheral emulators in Zephyr, which motivated me to try writing an emulator for the FUSB302B (also hoping it would help me debug an issue where USB-PD worked only in one cable-orientation…).

Emulator bus API

Emulators in Zephyr live below the device driver level, behind a specific bus api. The emulator subsystem implements a bus driver (such as I2C or SPI), which allows the device driver to run unmodified, and then forwards requests and calls to the emulator instead of a hardware-specific bus driver using the bus api.

The bus API for I2C for example is quite simple and looks a lot like the API implemented by actual I2C bus drivers:

typedef int (*i2c_emul_transfer_t)
    (const struct emul *target, struct i2c_msg *msgs, int num_msgs, int addr);

struct i2c_emul_api {
	i2c_emul_transfer_t transfer;
};

The emulator must then implement this API to mimic the actual device behavior.

Currently, emulators can only be written for devices which are instantiated on a bus. The bus acts as the interface between the device driver and the emulator, and such a separation can not be made for drivers which communicate with the device in some special way. See the chapter below for an exemption to that.

Emulator backend APIs

Each emulator can optionally implement a backend API. The backend API is used to interact with the emulator from the application or test. For my use case, I wanted to test the VBUS sensor, so I created a new backend API for VBUS devices which allows specifying the connected VBUS voltage. The emulator can then be used to verify that the driver correctly measures that voltage.

Defining the new backend API was as easy as defining the API interface struct:

__subsystem struct usbc_vbus_emul_driver_api {
       int (*set_vbus_voltage)(const struct emul *emul, int mV);
};

And a function for the test to call, which calls the emulator implementing the API:

static inline int emul_usbc_vbus_set_vbus_voltage(const struct emul *target, int mV)
{
       const struct usbc_vbus_emul_driver_api *backend_api =
	       (const struct usbc_vbus_emul_driver_api *)target->backend_api;

       return backend_api->set_vbus_voltage(target, mV);
}

Specifics about the FUSB302B and the USB-C subsystem

The USB-C subsystem in Zephyr is designed such that it separates the tasks of PD-Communication (which is done by the " USB Type-C Port Controller (TCPC)") and bus voltage (VBUS) measurement (done by a “USB-C VBUS” device, which could just be an ADC pin). The FUSB302B however, can do both those tasks, and is wired to do so on the board I have at hand. Lacking a proper method of multi-function devices in Zephyr at the time of writing, I chose to create a driver for the VBUS device, that requires a reference to the FUSB302B TCPC driver and measures the voltage through that.

Omitting some details, this is how that looks like in the devicetree:

/ {
	vbus1: fusb302b-vbus {
		compatible = "fcs,fusb302b-vbus";
		fusb302b = <&fusb>;
	};
};

&i2c2 {
	fusb: fusb302b@22 {
		compatible = "fcs,fusb302b";
		status = "okay";
		reg = <0x22>;
	};
};

This defines a FUSB302B chip with address 0x22 on the I2C bus 2, and a VBUS driver which references the FUSB302B device and performs all operations through it. Observe that the vbus1 node is not on any bus.

When writing the emulator however (I wanted to start with the VBUS part, to start small), it was necessary to move the vbus device to a bus as well. The vbus driver doesn’t interact with the bus at all, so an actual implementation was not required. The only thing the VBUS emulator does, is forwarding the backend API to the TCPC emulator. This is done by storing the struct emulator* for the TCPC, which can be obtained using EMUL_DT_GET and the fusb302b devicetree property, just as one would do for device pointers:

// The VBUS emulator configuration
struct fusb_vbus_emul_cfg {
    // Pointer to the FUSB302B (TCPC) emulator
	const struct emul *fusb_emul;
};
// Implementation of the backend API, forwarding to the TCPC emulator
int fusb_vbus_emul_set_voltage(const struct emul *emul, int mV)
{
	const struct fusb_vbus_emul_cfg *cfg = emul->cfg;
	set_vbus(cfg->fusb_emul, mV);
	return 0;
}

The BUS API implementation stays empty, because the VBUS driver never accesses the bus… The EMUL_DT_INST_DEFINE macro however needs a bus API pointer, so the emulator pretends to be an I2C bus device, without implementing any of the functions in the API. This also requires placing the devicetree node on an I2C bus:

&i2c2 {
    vbus1: fusb302b-vbus@22 {
        compatible = "fcs,fusb302b-vbus";
        fusb302b = <&fusb>;
        reg = <0x22>;
    };

	fusb: fusb302b@22 {
		compatible = "fcs,fusb302b";
		status = "okay";
		reg = <0x22>;
	};
};

Conclusion

Writing an emulator and interacting with it through backend APIs in Zephyr is straightforward, and the most time is spent on actually writing the device specific code instead of boilerplate. It allows easy testing of device drivers without access to the hardware, since it runs on the native_posix platform. For an overview of the emulation system, I recommend watching the talk about peripheral emulators in Zephyr by Aaron Massey at ZDS@EOSS 2023.