If you have read some of my previous posts you’ll know that my QNAP TS-473A has been keeping me busy for a while. I added a serial port to it, replaced QuTS with Debian, wrote a custom kernel module qnap8528 to expose the embedded controllers features via standard Linux subsystems, I even wrote a custom GRUB module (repo) to set the status LED during boot. At this point, the NAS has more of my fingerprints on it than QNAPs.

The qnap8528 kernel module is the centerpiece of most of this work. It gives Linux proper control over the IT8528 Embedded Controller (EC) built into many QNAP devices, things like fan speeds, temperature sensors, LEDs, buttons, and the VPD (Vital Product Data) tables that identify the hardware are all accessed via the EC. The module works well, I have been running it in “production” on my NAS for a while now and others seem to enjoy it too.

Over the time I get the odd request to add a config, change behavior or just want to develop a new script that uses the EC. The task itself is usually quite simple, and most of the hard reverse engineering and implementation of the EC features are done already. However, I dont not have a test/development environment apart from the real hardware. Every time I make a small config change to support a new device (for example), I compile and test the changes on TS-473A hardware. I find this testing phase a little annoying, as kernel panic on the NAS means I have to reboot the NAS to continue development, or, leave a half dead zombie like kernel module loaded.

I decided to fix this development environment issue by building a QEMU device model for the IT8528 EC.

Why QEMU?

I’ll let you in on a secret, it’s a big one and I have never told it to anyone before, but I’m in love with QEMU. I have never felt the same feelings I have for QEMU with any other software (sorry Linux).

On a more serious note, QEMU is a natural choice here. It’s a widely-used open source machine emulator and virtualizer, it’s a backbone in many hypervisors and it has a well-defined internal API for adding new devices, known as the QEMU Object Model (QOM). Once a device is wired in, you can boot a regular Linux VM, load the qnap8528 kernel module, and have it interact with the emulated hardware exactly as you would expect it to on real hardware. No risk of panics on the real NAS, no need for physical button presses, and easy access to edge cases that would be hard to trigger otherwise (such as a fan failure).

There’s an additional benefit I hadn’t fully appreciated at the start, the emulator is also a great tool for future reverse engineering work. With the emulated device attached to a VM running QNAP’s own QTS operating system (should that ever be a useful experiment), it would be possible to trace every single register read and write that QTS makes to the EC, something that was much harder to do on physical hardware and required staticly-compiled GDB servers and lots of breakpoints on in and out instructions.

Reverse Engineering the Protocol

I want to be clear about something, this emulator does not run the original QNAP EC firmware (the binary instructions on the ECs internal 8051 MCU). Instead, the device is built entirely around the register map and command protocol that I reverse engineered while developing the qnap8528 kernel module.

The kernel module was written by observing how QNAP’s userspace tools interacted with the IT8528 EC using GDB and reverse engineering the library binaries. The protocol is not officially documented, so everything in both the kernel module and the emulator comes from that reverse engineering work.

This has an important implication for the emulator, if a new EC feature is discovered later (a new register, a new command, a new VPD field), the emulator needs to be updated manually to support it. It’s a consequence of building something from the outside in, rather than from a datasheet and known sources (which are proprietary and ITE (unfortunately) do not release).

How QEMU Devices Work

Before getting into the specifics of the IT8528 emulation, it’s worth briefly explaining how QEMU devices are structured, since it shaped a lot of the implementation decisions.

QEMU uses QOM (QEMU Object Model) as its device abstraction layer. Every device is a C struct that inherits from a base device type, and devices register themselves with a set of callbacks. Initialization, reset, property access, etc. Devices also register memory regions, which map the device’s register space into the guest’s memory or I/O address space. It’s very similar to how Linux handles devices, if you have written a kernel module before, it’s about the same.

QNAP IT8528 EC Communication 101

For the IT8528, there are two distinct I/O port interactions to understand. The first is the chip ID check. When the kernel module loads, it probes the standard SuperIO index/data ports at 0x2e/0x2f, writing 0x20 and 0x21 to the index port while reading back after each write, the two chip ID bytes are obtained from the IT8528. This is just a one-time sanity check to confirm an IT8528 is actually present before doing anything else (although, we do know QNAP also uses an ENE variant that does not report a proper chip ID).

The actual EC communication is entirely separate and goes through a dedicated pair of ports. There is a command/status port at 0x6C and a data port at 0x68. The protocol is custom to QNAP’s EC firmware (and I might write about that at some point). To read a register, the kernel writes 0x88 to the command port signalling it wants to set a register address, then sends the 16-bit register address as two bytes to the data port, waits for the Output Buffer Full bit (bit 0, “OBF”) of the command port to be set, and reads the result byte from the data port. To write, the same sequence is used but the register address is OR’d with 0x8000 (setting the MSB) to flag it as a write operation, and after the address bytes the data byte to be written is also sent to the data port. Between each step, the module polls the Input Buffer Full bit (bit 1, “IBF”) of the command port and waits for it to clear before writing, so as not to overrun the EC. The QEMU device model should implement all of this, both the SuperIO chip ID probe and the command/data port traffic.

The EC register space holds all of the device state, firmware version, fan RPMs, temperature sensor readings, LED states, button states, PWM values, and more. In the emulated device, I decided to implement this as flat register file/space which is backed by a flat binary file, this file can be pre-populated with a default state using a helper script I wrote. This avoids hardcoding values directly into the device and allows “booting” an EC is specific states (that can also be invalid). The EC register space is about 0x800 bytes in size as the highest register I have witnessed is in the 0x7xx area.

The EC also has contains the VPD, this contains information such as the devices serial number, a nickname and vendor information. More importantly, the VPD contains two important fields that I name the mainboard code and the backplane code. These codes are used by the qnap8528 module to figure out what hardware features the device supports, how many fans it has, how many disks, etc. On the QNAP EC, the VPD is stored and accessed as 4 tables, each 256 bytes in size. Table 0 is the mainboards information (motherboard), table 1 is the backplane information (the disks controller) and table 2 and 3 are usually empty, however, they may contain information about an additional backplane or possible network cards (they are not used by the qnap8528 module). This is also implemented as an array of bytes, similar to the register space and is loaded from a file, a utility script to generate default data exist for this too.

Building the Device Model

The Device State Struct and QOM Registration

Every QEMU device starts with a state struct holding all runtime data, and a TypeInfo that registers the device type with QOM. For the IT8528, the state struct (QNAPIT8528State) holds everything the device needs:

/* The IT8528 state structure */
struct QNAPIT8528State {
    ISADevice parent_obj;       /* The parent device, an ISA bus */
 
    MemoryRegion sio_io;        /* SuperIO chip ID ports */
    uint8_t sio_index;          /* Current SIO index for reading */
    uint16_t sio_chip_id;       /* Chip ID - can be changed by user */
 
    MemoryRegion cmd_io;        /* EC command/status port */
    MemoryRegion data_io;       /* EC data port */
    QNAPIT8528ECPhase phase;    /* Current EC phase (explained later) */
    uint8_t status;             /* The status port (OBF/IBF) */
    uint8_t output;             /* The current EC output data buffer */
    uint16_t cmd;               /* The current command (register index) */
 
    uint8_t regs[QNAP_IT8528_REG_FILE_SIZE];              /* Register space */
    uint8_t vpd_tables[QNAP_IT8528_VPD_NUM_TABLES][512];  /* 4x 512-byte VPD tables (they are actually 256 bytes long)*/
    uint16_t vpd_offsets[QNAP_IT8528_VPD_NUM_TABLES];     /* Current VPD seek pointer per table  (explained later)*/
 
    /* disk leds explained later */
    uint32_t led_disk_present;  /* Bitmask: which disk slots have green LED on */
    uint32_t led_disk_active;   /* Bitmask: which disk slots have activity blink */
    uint32_t led_disk_error;    /* Bitmask: which disk slots have red LED on */
    uint32_t led_disk_locate;   /* Bitmask: which disk slots have locate blink */
 
    char *regs_path;            /* Path to use supplied register baking file */
    char *vpd_path;             /* Path to user supplied VPD tables */
 
    QEMUTimer *button_timer;    /* Timer for pressing virtual buttons */
    uint8_t buttons;            /* Button mask of currently pressed buttons */
};

/* The IT8528 TypeInfo */
static const TypeInfo qnap_it8528_type_info = {
    .name = TYPE_QNAPIT8528,
    .parent = TYPE_ISA_DEVICE,
    .instance_size = sizeof(QNAPIT8528State),
    .class_init = qnap_it8528_class_init /*  The IT8528 device init function*/
};

/* User supplied properties */
static const Property qnap_it8528_properties[] = {
    DEFINE_PROP_STRING("vpd", QNAPIT8528State, vpd_path),
    DEFINE_PROP_STRING("regs", QNAPIT8528State, regs_path),
    DEFINE_PROP_UINT16("chip-id", QNAPIT8528State, sio_chip_id, QNAP_IT8528_DEFAULT_CHIP_ID),
};

/* */
static void qnap_it8528_class_init(ObjectClass *oc, const void *data) {
    DeviceClass *dc = DEVICE_CLASS(oc);
    dc->realize = qnap_it8528_realize;          /* "On-Load" function */
    dc->unrealize = qnap_it8528_unrealize;      /* "On-Unload" function */
    dc->vmsd      = &qnap_it8528_vmstate;       /* Used to support savevm/loadvm commands, not needed */
    dc->desc = "QNAP IT8528 Embedded Controller";
    device_class_set_props(dc, qnap_it8528_properties);
    set_bit(DEVICE_CATEGORY_MISC, dc->categories);
}

The device TyeInfo registers itself as a child of TYPE_ISA_DEVICE, since thse IO ports are associated with the ISA bus, this gives us access to isa_register_ioport for claiming the I/O port ranges for the EC. The TypeInfo, class_init and others are all standard QOM boilerplate. The device properties, that can be supplied by the user, such as regs, vpd, and chip-id are declared with DEFINE_PROP_STRING and DEFINE_PROP_UINT16 macros and handled automatically by QOM’s property system.

Three I/O Port Regions

In qnap_it8528_realize, three separate MemoryRegions are registered, not two. Each gets its own MemoryRegionOps struct with dedicated read and write callbacks:

/* SuperIO index/data ports: 0x2E-0x2F (2 bytes) */
memory_region_init_io(&s->sio_io, OBJECT(s), &qnap_it8528_sio_ops, s, "qnap-it8528-sio", 2);
isa_register_ioport(isa, &s->sio_io, QNAP_IT8528_SIO_INDEX_PORT);  /* 0x2E */
 
/* EC command/status port: 0x6C (1 byte) */
memory_region_init_io(&s->cmd_io, OBJECT(s), &qnap_it8528_cmd_ops, s, "qnap-it8528-cmd", 1);
isa_register_ioport(isa, &s->cmd_io, QNAP_IT8528_EC_CMD_PORT);     /* 0x6C */
 
/* EC data port: 0x68 (1 byte) */
memory_region_init_io(&s->data_io, OBJECT(s), &qnap_it8528_data_ops, s, "qnap-it8528-data", 1);
isa_register_ioport(isa, &s->data_io, QNAP_IT8528_EC_DATA_PORT);   /* 0x68 */

The SuperIO handler is simple, its SuperSimple!. The kernel module writes 0x20 or 0x21 to the index port (0x2e) to read back the high or low byte of the chip ID from the data port (0x2f). The handler stores the index byte on write and returns the appropriate half of sio_chip_id on read. Any other index value returns 0xff. That’s the entire chip detection path, handled in a handful of lines.

The command port and data port handlers are where the real work happens.

The EC Protocol State Machine

The EC communication protocol is inherently stateful, a single register access spans multiple sequential I/O port writes, so the device must track exactly where in the exchange it currently sits. This is modeled as an explicit four-phase enum:

typedef enum {
    EC_PHASE_IDLE,
    EC_PHASE_CMD_HIGH,
    EC_PHASE_CMD_LOW,
    EC_PHASE_WRITE_DATA
} QNAPIT8528ECPhase;

The transitions work as follows. When the guest writes 0x88 to the command port, the device transitions from IDLE to CMD_HIGH and clears IBF (bit 1 of status). Any other value written to the command port is an error, the EC only recognizes this one opcode.

From CMD_HIGH, the guest writes the high byte of the 16-bit register address to the data port. The device stores it in the upper byte of cmd and moves to CMD_LOW.

From CMD_LOW, the guest writes the low byte. Now cmd is complete. This is where the read/write branch happens, handled in qnap_it8528_process_cmd:

static void qnap_it8528_process_cmd(QNAPIT8528State *s) {
    if (s->cmd & 0x8000) {
        s->cmd &= ~0x8000;
        s->phase = EC_PHASE_WRITE_DATA;
        s->status &= ~BIT(1);
    } else {
        s->output = qnap_it8528_read_register(s, s->cmd);
        s->status |= BIT(0);
        s->status &= ~BIT(1);
        s->phase = EC_PHASE_IDLE;
    }
}

For a read: the register is read immediately, the result is latched into output, OBF (bit 0) is set in status, and the phase goes back to IDLE. The guest then reads from the data port, which returns output and clears OBF.

For a write: the 0x8000 flag is stripped from cmd (leaving just the real address), the phase moves to WRITE_DATA. The guest then writes the data byte to the data port, which calls qnap_it8528_write_register and returns to IDLE.

Since there’s no real processing delay in the emulator, IBF is always cleared immediately after each step. The kernel module polls it between every port write, so the emulator just keeps it clear and the module never actually waits, it might be useful to add a “jitter"or block here for checking how the kernel module handles these as an edge case test.

The Register Space and Write-Side Effects

qnap_it8528_read_register and qnap_it8528_write_register are the two functions that everything funnels into. For most registers, reads just return s->regs[reg] and writes just set s->regs[reg] = val. The register space being a flat array makes this trivial.

The write path has two interesting side effects beyond just updating the backing array.

The first is disk LED bitmask tracking. The kernel module controls individual disk slot LEDs by writing a disk EC index (0–31) to one of eight dedicated registers. The registers are present-on, present-off, error-on, error-off, locate-on, locate-off, active-on, active-off. The value written to the register is just an LED disk slot index (1, 2 etc.), The actual state of the LED is not saved, it’s “fire and forget”. The write handler maintains four uint32_t bitmasks in real time, setting or clearing the bit corresponding to the EC index value written s when the EC state is inspected later, we know what the LEDs state should actually be:

if (val < 32) {
    switch (reg) {
    case 0x15a: s->led_disk_present |=  BIT(val); break;  /* present on */
    case 0x15b: s->led_disk_present &= ~BIT(val); break;  /* present off */
    case 0x15c: s->led_disk_error   |=  BIT(val); break;  /* error on */
    /* ... and so on ...*/
    }
}

The second side effect is fan PWM to RPM coupling. On real hardware, when the kernel module writes a PWM percentage to one of the four fan bank registers, the hardware takes over and and the fans spin up or down depending on the value of the PWM registers. Since the PWM and RPM register are separate, sensors would show whatever RPM value is in the RPM registers that was not changed. To fix this, the emulator automatically recalculates and updates the RPM registers for every fan in that bank, using a simple linear scale (rpm = val * 5000 / 100). This allows the kernel module to read a different RPM value simulating the fan spinning up or down based on the PWM value.

The third is VPD Indirect Access. VPD data is not accessed by direct register reads. The kernel module uses a seek-and-read protocol. Tt writes a 16-bit byte offset split across two consecutive EC registers, then reads from a third register to get one byte from the corresponding VPD table. There are four such register triplets, one per VPD table:

static const uint16_t vpd_regs[QNAP_IT8528_VPD_NUM_TABLES][3] = {
    {0x56, 0x57, 0x58},   /* Mainboard VPD */
    {0x59, 0x5a, 0x5b},   /* Backplane VPD */
    {0x5c, 0x5d, 0x5e},   /* (unused) */
    {0x60, 0x61, 0x62}    /* (unused) */
};

The device maintains a vpd_offsets[4] array in the state struct, one current seek position per table. When the write handler detects a write to one of these address registers, it updates the corresponding offset:

  • Write to position 0 (high byte): vpd_offsets[table] = val << 8
  • Write to position 1 (low byte): vpd_offsets[table] |= val

When the read handler detects a read from position 2 (the data register), it returns vpd_tables[table][vpd_offsets[table]] directly from the in-memory VPD buffer rather than from regs space. All writes still also go to regs[reg] since VPD writing is not implemented yet.

Button Simulation and VMState

Button presses are handled by setting the appropriate bit in regs[0x143] (the button input register), arming a QEMUTimer, and clearing the bit when the timer fires. The timer uses QEMU_CLOCK_VIRTUAL so it respects VM pauses and time scaling correctly.

The device also has a full VMStateDescription for save/load support, covering all meaningful state: sio_index, sio_chip_id, phase, status, output, cmd, the full regs array, all four vpd_tables, vpd_offsets, the disk LED bitmasks, buttons, and the button timer. One minor QOM quirk is that VMSTATE_* macros don’t handle enums directly, so phase is serialized via a raw vmstate_info_uint32 entry with an explicit offsetof, bypassing the normal type checking. It works, but it’s the kind of thing that makes you feel slightly dirty.

Register Name Lookup for Logging

One small but useful addition is a register name lookup system used in the log output. There’s a static table mapping known register addresses to human-readable names and descriptions, plus dynamic name generation for temperature sensor and fan RPM registers (which have too many addresses to enumerate statically):

QNAP-IT8528: Write LED_STATUS (reg=0x0155 val=03)
QNAP-IT8528: Read  FAN0_RPM_H (reg=0x0624 val=04)
QNAP-IT8528: Read  TEMP_SENSOR_0 (reg=0x0600 val=2d)

Without this, the log would just be a stream of hex addresses and values with no obvious meaning — not very useful when trying to correlate what the kernel module is doing with what the emulator is returning. When a register has no entry in the table the name shows as (null), which is a deliberate signal that this is an access to a register that hasn’t been catalogued yet and might be worth investigating.

Generating The Register Space and VPD

The EC exposes a flat register space that holds all of the device state: firmware version, fan RPMs, temperature sensor readings, LED states, button states, PWM values, and more. In the emulator, this register space is backed by a flat binary file that can be pre-populated using a helper script I wrote called regsutil.py.

# Create a register file with default values (mimics a TS-473A)
python3 scripts/regsutil.py create regs.bin
 
# Override specific values
python3 scripts/regsutil.py create regs.bin fw_ver=ABCDEFGH fan0_rpm=1200 temp0=45
 
# Inspect the contents
python3 scripts/regsutil.py dump regs.bin
 
# Amend an existing file
python3 scripts/regsutil.py amend regs.bin temp0=99

One slightly tricky part here is fan ID indexing. The qnap8528 kernel module uses 1-based fan IDs, just like QNAPs original FW. In its device configuration table (for hwmon channel matching), but the register address calculation inside the module is 0-based. This means the emulator needs to pre-populate fan ID 0 internally, even though the config file says “fan 1”.

For the VPD (Vital Product Data) area, the four 256-byte tables are managed by a second helper script vpdutil.py that is similar to the rgisters utilility:

# Create a VPD file with defaults (mimics a TS-473A)
python3 scripts/vpdutil.py create vpd.bin

# Override fields
python3 scripts/vpdutil.py create vpd.bin mb_model=70-0Q07D0250 enc_nick=MyNAS

# Inspect the contents
python3 scripts/vpdutil.py dump vpd.bin

Wiring It Into QEMU

The device itself is written in C and integrates with QEMU’s QOM API. To add the device to QEMU, the source files need to be copied into the QEMU source tree and a small set of patches applied to wire the device into the build system and register it.

First, the source files and headers I wrote go into QEMUs hw/misc direcotry. I then added to the meson.build (in the same directory) and entry system_ss.add(when: 'CONFIG_QNAP_IT8528', if_true: files('qnap_it8528.c')) to include my source in the compilation process. To set CONFIG_QNAP_IT8528, I added it to the Kconfig (again, in the same directory) a small seciton for my model (which is enabld by default):

config QNAP_IT8528
    bool
    default y
    depends on ISA_BUS

I didn’t want to maintain a full fork of QEMU for this, so the project is structured as an out-of-tree patch set. The Makefile in the repo handles everything:

# Clone, patch, configure, and build QEMU with the device
git clone https://github.com/youruser/qemu-qnap-it8528.git
cd qemu-qnap-it8528
make

Under the hood, make fetches the QEMU 10 source, copies the device files into the right place, applies the patches to wire the device into the build and Kconfig system, runs configure, and builds QEMU with the new device included. The default QEMU version is 10, since QOM’s API changes between versions and that’s the version this device was written for (well, its the version I decided to end up with). The version can be overridden with a parameter.

Once built, using the device is a single -device flag:

qemu-system-x86_64 -device qnap-it8528,regs=regs.bin,vpd=vpd.bin <other QEMU options>

Without regs and vpd, the device starts with all registers zeroed, maybe useful for testing what happens when the kernel module encounters a completely blank EC, but not much use for most test scenarios.

QEMU Monitor Commands

One of the nicer parts of QEMU is the HMP (Human Monitor Protocol) console, which allows you to interact with a running VM from outside. The device exposes two HMP commands.

qnap_it8528_info prints a full dump of the current EC state, firmware version, CPLD version, EC phase, all LED states, button states, temperature readings, fan RPMs, and fan PWM bank values. It’s useful for a quick sanity check that the kernel module is reading things correctly and that thigs are written correctly.

qnap_it8528_press simulates a physical button press. The three available buttons are CHASSIS, COPY, and RESET. The command takes a button name and a duration in milliseconds, sets the corresponding register bit, and then clears it automatically after the specified time:

(qemu) qnap_it8528_press COPY 3000    # Simulate holding COPY for 3 seconds
(qemu) qnap_it8528_press RESET 10000  # Simulate holding RESET for 10 seconds

This is genuinely useful for testing the userspace scripts and kernel module code paths that handle button events, things that would normally require physically walking over to the NAS and holding a button for a specific duration.

Currently, pressing a second button while another is still “held” will reset the press timer of the first button rather than tracking both independently, I did not think this was to important to implement, and might change this later.

To add the commands, I first implemented in my device file, the info command is comlex, as it parses many registers, but the button press command is simple so I’ll add it here so you understand the general idea of a command fucntion. The function takes a monitor instance and a QDict which contains the parametes. The parameters are extracted and the button timer is set.

/* Internal "button press" function */
static void qnap_it8528_press_button(QNAPIT8528State *s, uint8_t mask, int duration) {
    /* Add the button pressed mask to the actual buttons status register */
    s->regs[0x143] |= mask;
    /* Update global mask of pressed button */
    s->buttons = mask;
    /* Start a timer to release the button */
    timer_mod(s->button_timer, qemu_clock_get_ms(QEMU_CLOCK_VIRTUAL) + duration);
}

/* Command funcion */
void qnap_it8528_hmp_press(Monitor *mon, const QDict *qdict)
{
    /* Get button name */
    const char *button = qdict_get_str(qdict, "button");
    /* Get press duration */
    int duration = qdict_get_int(qdict, "duration");
    uint8_t mask = 0;

    if (!qnap_it8528_global) {
        monitor_printf(mon, "QNAP IT8528 device not present\n");
        return;
    }

    /* Check the button type and set it's mask bit so we know what to release */
    if (!strcasecmp(button, "CHASSIS")) mask = BIT(0);
    else if (!strcasecmp(button, "COPY")) mask = BIT(1);
    else if (!strcasecmp(button, "RESET")) mask = BIT(2);
    else { monitor_printf(mon, "Unknown button '%s'\n", button); return; }

    /* "Press" the button */
    qnap_it8528_press_button(qnap_it8528_global, mask, duration);
}

I then added my commands to the include/monitor/hmp.h file:

void qnap_it8528_hmp_info(Monitor *mon, const QDict *qdict);
void qnap_it8528_hmp_press(Monitor *mon, const QDict *qdict);

and wired the commands into the QEMUs monitor by adding them to hmp-commands.hx

{
        .name       = "qnap_it8528_info",
        .args_type  = "",
        .params     = "",
        .help       = "Show QNAP IT8528 EC status (fans, temps, LEDs, buttons, disks)",
        .cmd        = qnap_it8528_hmp_info,
    },
    {
        .name       = "qnap_it8528_press",
        .args_type  = "button:s,duration:i",
        .params     = "button duration",
        .help       = "Simulate button press on QNAP IT8528 (CHASSIS|COPY|RESET) for duration ms",
        .cmd        = qnap_it8528_hmp_press,
    },

Future Work

I am in the process of adding a simple QMP processor that will report the EC state so an external “dashboard” can be used to simulate the fornt panel. The device also needs polishing in it’s integration into QEMUs format, naming convention and adding CONFIG checks in other places. In addition, I would like to make the registers file be persistent across QEMU runs (writeback). Other small fixes and features can be added but these are not as important as the core I presented in this post.

Do It Yourself

The project is up on GitHub at https://github.com/0xGiddi/qemu-qnap-it8528 if you want to take a look. The companion kernel module lives at https://github.com/0xGiddi/qnap8528. Between the two, you can run a reasonably complete QNAP EC test environment without ever touching the real hardware.

And yes, I could not help myself and also booted QNAPs Q(u)TS OS on QEMU, and I am in the process of writing that story in another post.