VPR (pronounced “Viper”) is Nordic
Semiconductor’s first
RISC-V processor, landing in the new
nRF54H and
nRF54L lines of SoCs after their
initial
announcements
in April and October of 2023 respectively. Readers of this blog are familiar
with my long-running obsession interest in RISC-V (see my RISC-V
Tips and RISC-V
Bytes series). However,
Nordic’s introduction of a RISC-V processor is particularly interesing to me as
their lineup of microcontrollers is extremely popular in low power wireless
domains, a common use case for Golioth customers.
Naturally, I was eager to look into the details of VPR as more information
became available. A basic description of registers and initialization can be
found in the nRF54L
documentation.
VPR is an RV32E processor, meaning that it uses 32-bit registers but implements
only the lower 16 registers required by the embedded (E) specification rather
than the 32 defined by the full 32-bit integer (RV32I) specification. It also
implements multiplication and division operations (M), as well as the compressed
instruction (C) extension, which adds support for 16-bit instruction variants to
improve code density. All instructions are executed in machine mode (M), which
is the only implemented privilege
level.
Alongside the Arm
Cortex-M33 application
processor, nRF54L MCUs include a single VPR processor, referred to as the fast
lightweight peripheral
processor
(FLPR, pronounced “flipper”), while the nRF54H20 includes both a FLPR and a
peripheral
processor
(PPR, pronounced “pepper”) alongside its dual Arm Cortex-M33 application and
network processors. The PPR is meant for peripheral handling with low power
consumption and it runs at 16 MHz. The FLPR runs at 320 MHz and is intended for
software-defined peripherals.
VPR processors must be configured and started by their controlling application
processor. This consists of setting the VPR’s program counter in the INITPC
register,
then writing to the CPURUN
register
to start the processor. On the nRF54H20, the PPR VPR executes code from slow
global RAM
(RAM3x).
Therefore, the controlling processor must copy code into the appropriate region,
before updating the program counter and starting the VPR processor.
I have previously written about the Zephyr boot
process, and
specifically about hardware
initialization.
Zephyr uses devicetree to describe the hardware
and peripherals of supported processors and boards. In the
nrf54h20.dtsi
devicetree include file, there is a node representing the PPR VPR processor.
cpuppr: cpu@d {
compatible = "nordic,vpr";
reg = <13>;
device_type = "cpu";
clocks = <&fll16m>;
clock-frequency = ;
riscv,isa = "rv32emc";
nordic,bus-width = <32>;
cpuppr_vevif_rx: mailbox {
compatible = "nordic,nrf-vevif-task-rx";
status = "disabled";
interrupt-parent = <&cpuppr_clic>;
interrupts = <0 NRF_DEFAULT_IRQ_PRIORITY>,
<1 NRF_DEFAULT_IRQ_PRIORITY>,
<2 NRF_DEFAULT_IRQ_PRIORITY>,
<3 NRF_DEFAULT_IRQ_PRIORITY>,
<4 NRF_DEFAULT_IRQ_PRIORITY>,
<5 NRF_DEFAULT_IRQ_PRIORITY>,
<6 NRF_DEFAULT_IRQ_PRIORITY>,
<7 NRF_DEFAULT_IRQ_PRIORITY>,
<8 NRF_DEFAULT_IRQ_PRIORITY>,
<9 NRF_DEFAULT_IRQ_PRIORITY>,
<10 NRF_DEFAULT_IRQ_PRIORITY>,
<11 NRF_DEFAULT_IRQ_PRIORITY>,
<12 NRF_DEFAULT_IRQ_PRIORITY>,
<13 NRF_DEFAULT_IRQ_PRIORITY>,
<14 NRF_DEFAULT_IRQ_PRIORITY>,
<15 NRF_DEFAULT_IRQ_PRIORITY>;
#mbox-cells = <1>;
nordic,tasks = <16>;
nordic,tasks-mask = <0xfffffff0>;
};
};
Previously mentioned attributes, such as the support for the rv32emc
RISC-V
ISA, as well as the 16 MHz (DT_FREQ_M(16)
) clock speed, are defined. There is
also a node under
soc
that defines the PPR as a coprocessor peripheral.
cpuppr_vpr: vpr@908000 {
compatible = "nordic,nrf-vpr-coprocessor";
reg = <0x908000 0x1000>;
status = "disabled";
#address-cells = <1>;
#size-cells = <1>;
ranges = <0x0 0x908000 0x1000>;
power-domains = <&gpd NRF_GPD_SLOW_ACTIVE>;
cpuppr_vevif_tx: mailbox@0 {
compatible = "nordic,nrf-vevif-task-tx";
reg = <0x0 0x1000>;
status = "disabled";
#mbox-cells = <1>;
nordic,tasks = <16>;
nordic,tasks-mask = <0xfffffff0>;
};
};
The compatible
property associates the PPR with the appropriate driver that
the controlling processor, in this case the nRF54H20 application processor
(cpuapp
), can use to configure and start it. In the miscellaneous drivers,
there is a
nordic_vpr_launcher
,
which defines its device
compatability.
#define DT_DRV_COMPAT nordic_nrf_vpr_coprocessor
The driver is relatively simple because it is only responsible for device
initialization. The initialization
function
performs the operations described in the documentation.
static int nordic_vpr_launcher_init(const struct device *dev)
{
const struct nordic_vpr_launcher_config *config = dev->config;
#if DT_ANY_INST_HAS_PROP_STATUS_OKAY(source_memory)
if (config->size > 0U) {
LOG_DBG("Loading VPR (%p) from %p to %p (%zu bytes)", config->vpr,
(void *)config->src_addr, (void *)config->exec_addr, config->size);
memcpy((void *)config->exec_addr, (void *)config->src_addr, config->size);
}
#endif
#if defined(CONFIG_SOC_NRF54L_CPUAPP_COMMON) && !defined(CONFIG_TRUSTED_EXECUTION_NONSECURE)
nrf_spu_periph_perm_secattr_set(NRF_SPU00, nrf_address_slave_get((uint32_t)config->vpr),
true);
#endif
LOG_DBG("Launching VPR (%p) from %p", config->vpr, (void *)config->exec_addr);
nrf_vpr_initpc_set(config->vpr, config->exec_addr);
nrf_vpr_cpurun_set(config->vpr, true);
return 0;
}
The various nrf_vpr_*
functions are provided as inline by
hal_nordic
.
The device configuration, which determines the memory source and destination
addresses used by the launcher, is instantiated using various DT_*
devicetree
macros,
which are performed for every compatible node using
DT_INST_FOREACH_STATUS_OKAY
.
/* obtain VPR address either from memory or partition */
#define VPR_ADDR(node_id) \
(DT_REG_ADDR(node_id) + \
COND_CODE_0(DT_FIXED_PARTITION_EXISTS(node_id), (0), (DT_REG_ADDR(DT_GPARENT(node_id)))))
#define NORDIC_VPR_LAUNCHER_DEFINE(inst) \
IF_ENABLED(DT_INST_NODE_HAS_PROP(inst, source_memory), \
(BUILD_ASSERT((DT_REG_SIZE(DT_INST_PHANDLE(inst, execution_memory)) <= \
DT_REG_SIZE(DT_INST_PHANDLE(inst, source_memory))), \
"Execution memory exceeds source memory size");)) \
\
static const struct nordic_vpr_launcher_config config##inst = { \
.vpr = (NRF_VPR_Type *)DT_INST_REG_ADDR(inst), \
.exec_addr = VPR_ADDR(DT_INST_PHANDLE(inst, execution_memory)), \
IF_ENABLED(DT_INST_NODE_HAS_PROP(inst, source_memory), \
(.src_addr = VPR_ADDR(DT_INST_PHANDLE(inst, source_memory)), \
.size = DT_REG_SIZE(DT_INST_PHANDLE(inst, execution_memory)),))}; \
\
DEVICE_DT_INST_DEFINE(inst, nordic_vpr_launcher_init, NULL, NULL, &config##inst, \
POST_KERNEL, CONFIG_NORDIC_VPR_LAUNCHER_INIT_PRIORITY, NULL);
DT_INST_FOREACH_STATUS_OKAY(NORDIC_VPR_LAUNCHER_DEFINE)
We can see the initialization in action by building applications for both the
application processor and the PPR. Zephyr’s
sysbuild
allows for building for multiple targets. The hello_world
sample
demonstrates this capability by building the same application, which just prints
Hello world from {target}
, for all specified targets.
west build -p -b nrf54h20dk/nrf54h20/cpuapp -T sample.sysbuild.hello_world.nrf54h20dk_cpuapp_cpuppr .
The -T
argument specifies one of the sample’s test
configurations,
which will automatically inject extra arguments into the build.
sample.sysbuild.hello_world.nrf54h20dk_cpuapp_cpuppr:
platform_allow:
- nrf54h20dk/nrf54h20/cpuapp
integration_platforms:
- nrf54h20dk/nrf54h20/cpuapp
extra_args:
SB_CONF_FILE=sysbuild/nrf54h20dk_nrf54h20_cpuppr.conf
hello_world_SNIPPET=nordic-ppr
The SB_CONF_FILE
specifies the sysbuild
configuration.
For the nRF24H20 development kit (DK), the configuration file only serves to
specify the board target for the remote
variant of the application, which is
the PPR.
SB_CONFIG_REMOTE_BOARD="nrf54h20dk/nrf54h20/cpuppr"
The hello_world_SNIPPET
specifies a snippet that should be included in the
build. It includes two devicetree overlay files.
One,
nordic-ppr.overlay
, enables the cpuppr_vpr
node for any boards that
incoporate a PPR.
&cpuppr_vpr {
status = "okay";
};
Observant readers will notice that it appears Nordic’s upcoming nRF9280 also
includes a PPR.
The
second,
nrf54h20_cpuapp.overlay
, enables the memory region that is shared between the
application processor and the PPR.
&cpuppr_ram3x_region {
status = "okay";
};
After build, the application processor image will reside in build/hello_world
,
while the PPR image will reside in build/remote
. We can use the respective Arm
and RISC-V toolchains from the Zephyr
SDK to inspect the ELF files and
understand how the configuration translates to instructions in the binary.
To disassemble the application processor image, use the arm-zephyr-eabi
toolchain’s objdump
.
$ZEPHYR_SDK_PATH/arm-zephyr-eabi/bin/arm-zephyr-eabi-objdump -D build/hello_world/zephyr/zephyr.elf
For the PPR image, the riscv64-zephyr-elf
variant can be used.
$ZEPHYR_SDK_PATH/riscv64-zephyr-elf/bin/riscv64-zephyr-elf-objdump -D build/remote/zephyr/zephyr.elf
In the previously shown nordic_vpr_launcher
driver initialization, the
DEVICE_DT_INST_DEFINE
macro is used to define the PPR peripheral device with a
POST_KERNEL
initialization level. This means that the device should be
initialized after the kernel boots. This is handled by a call to
z_sys_init_run_level
with level POST_KERNEL
.
static void z_sys_init_run_level(enum init_level level)
{
static const struct init_entry *levels[] = {
__init_EARLY_start,
__init_PRE_KERNEL_1_start,
__init_PRE_KERNEL_2_start,
__init_POST_KERNEL_start,
__init_APPLICATION_start,
#ifdef CONFIG_SMP
__init_SMP_start,
#endif /* CONFIG_SMP */
/* End marker */
__init_end,
};
const struct init_entry *entry;
for (entry = levels[level]; entry < levels[level+1]; entry++) {
const struct device *dev = entry->dev;
int result;
sys_trace_sys_init_enter(entry, level);
if (dev != NULL) {
result = do_device_init(entry);
} else {
result = entry->init_fn.sys();
}
sys_trace_sys_init_exit(entry, level, result);
}
}
We can see the disassembly in the application processor image.
0e0a9ca4 <z_sys_init_run_level>:
e0a9ca4: b538 push {r3, r4, r5, lr}
e0a9ca6: 4b09 ldr r3, [pc, #36] ; (e0a9ccc )
e0a9ca8: f853 4020 ldr.w r4, [r3, r0, lsl #2]
e0a9cac: 3001 adds r0, #1
e0a9cae: f853 5020 ldr.w r5, [r3, r0, lsl #2]
e0a9cb2: 42a5 cmp r5, r4
e0a9cb4: d800 bhi.n e0a9cb8 0x14 >
e0a9cb6: bd38 pop {r3, r4, r5, pc}
e0a9cb8: 6863 ldr r3, [r4, #4]
e0a9cba: b123 cbz r3, e0a9cc6 0x22 >
e0a9cbc: 4620 mov r0, r4
e0a9cbe: f003 f81f bl e0acd00
e0a9cc2: 3408 adds r4, #8
e0a9cc4: e7f5 b.n e0a9cb2 0xe >
e0a9cc6: 6823 ldr r3, [r4, #0]
e0a9cc8: 4798 blx r3
e0a9cca: e7fa b.n e0a9cc2 0x1e >
e0a9ccc: 0e0ae818 mcreq 8, 0, lr, cr10, cr8, {0}
The second instruction, ldr r3, [pc, #36]
loads the address at a 36 byte
offset from the current program counter (pc
), which is the last entry in the
function disassembly: 0e0ae818
.
Ignore the attempted instruction decoding by
objdump
for this address and
all stored memory addresses below.
This is the address of the levels
array, which contains init_entry
items.
0e0ae818 <levels.0>:
e0ae818: 0e0ad3cc cdpeq 3, 0, cr13, cr10, cr12, {6}
e0ae81c: 0e0ad3cc cdpeq 3, 0, cr13, cr10, cr12, {6}
e0ae820: 0e0ad40c cdpeq 4, 0, cr13, cr10, cr12, {0}
e0ae824: 0e0ad414 cfmvdlreq mvd10, sp
e0ae828: 0e0ad464 cdpeq 4, 0, cr13, cr10, cr4, {3}
e0ae82c: 0e0ad474 mcreq 4, 0, sp, cr10, cr4, {3}
e0ae830: 3566726e strbcc r7, [r6, #-622]! ; 0xfffffd92
e0ae834: 30326834 eorscc r6, r2, r4, lsr r8
e0ae838: 30406b64 subcc r6, r0, r4, ror #22
e0ae83c: 302e392e eorcc r3, lr, lr, lsr #18
e0ae840: 66726e2f ldrbtvs r6, [r2], -pc, lsr #28
e0ae844: 32683435 rsbcc r3, r8, #889192448 ; 0x35000000
e0ae848: 70632f30 rsbvc r2, r3, r0, lsr pc
e0ae84c: 70706175 rsbsvc r6, r0, r5, ror r1
e0ae850: 6c654800 stclvs 8, cr4, [r5], #-0
e0ae854: 77206f6c strvc r6, [r0, -ip, ror #30]!
e0ae858: 646c726f strbtvs r7, [ip], #-623 ; 0xfffffd91
e0ae85c: 6f726620 svcvs 0x00726620
e0ae860: 7325206d ; instruction: 0x7325206d
e0ae864: 6146000a cmpvs r6, sl
e0ae868: 64656c69 strbtvs r6, [r5], #-3177 ; 0xfffff397
e0ae86c: 206f7420 rsbcs r7, pc, r0, lsr #8
e0ae870: 6f626572 svcvs 0x00626572
e0ae874: 203a746f eorscs r7, sl, pc, ror #8
e0ae878: 6e697073 mcrvs 0, 3, r7, cr9, cr3, {3}
e0ae87c: 676e696e strbvs r6, [lr, -lr, ror #18]!
e0ae880: 646e6520 strbtvs r6, [lr], #-1312 ; 0xfffffae0
e0ae884: 7373656c cmnvc r3, #108, 10 ; 0x1b000000
e0ae888: 2e2e796c vnmulcs.f16 s14, s28, s25 ;
e0ae88c: 69000a2e stmdbvs r0, {r1, r2, r3, r5, r9, fp}
e0ae890: 322d6370 eorcc r6, sp, #112, 6 ; 0xc0000001
e0ae894: 0032312d eorseq r3, r2, sp, lsr #2
e0ae898: 2d637069 stclcs 0, cr7, [r3, #-420]! ; 0xfffffe5c
e0ae89c: 00332d32 eorseq r2, r3, r2, lsr sp
e0ae8a0: 736d6369 cmnvc sp, #-1543503871 ; 0xa4000001
e0ae8a4: 6f775f67 svcvs 0x00775f67
e0ae8a8: 00716b72 rsbseq r6, r1, r2, ror fp
The fourth entry, 0e0ad414
, is the init_entry
for the PPR peripheral device.
At that address, we’ll find the address of its init_function
(0e0ac7cb
) and a
pointer to the device structure (0e0ad474
).
0e0ad414 <__init___device_dts_ord_60>:
e0ad414: 0e0ac7cb cdpeq 7, 0, cr12, cr10, cr11, {6}
e0ad418: 0e0ad474 mcreq 4, 0, sp, cr10, cr4, {3}
As expected, the init_function
address is the location of the
nordic_vpr_launcher_init
function. Or, almost. There is a one byte offset in
the address stored in the init_entry
(0e0ac7cb
) from the address of the
launcher function (0e0ac7ca
). In a moment we’ll see why.
0e0ac7ca <nordic_vpr_launcher_init>:
e0ac7ca: b510 push {r4, lr}
e0ac7cc: 6844 ldr r4, [r0, #4]
e0ac7ce: 68e2 ldr r2, [r4, #12]
e0ac7d0: b11a cbz r2, e0ac7da 0x10 >
e0ac7d2: e9d4 0101 ldrd r0, r1, [r4, #4]
e0ac7d6: f000 fd56 bl e0ad286
e0ac7da: e9d4 3200 ldrd r3, r2, [r4]
e0ac7de: f8c3 2808 str.w r2, [r3, #2056] ; 0x808
e0ac7e2: 2201 movs r2, #1
e0ac7e4: 6823 ldr r3, [r4, #0]
e0ac7e6: 2000 movs r0, #0
e0ac7e8: f8c3 2800 str.w r2, [r3, #2048] ; 0x800
e0ac7ec: bd10 pop {r4, pc
z_sys_init_run_level
calls do_device_init
on the PPR peripheral.
static int do_device_init(const struct init_entry *entry)
{
const struct device *dev = entry->dev;
int rc = 0;
if (entry->init_fn.dev != NULL) {
rc = entry->init_fn.dev(dev);
/* Mark device initialized. If initialization
* failed, record the error condition.
*/
if (rc != 0) {
if (rc < 0) {
rc = -rc;
}
if (rc > UINT8_MAX) {
rc = UINT8_MAX;
}
dev->state->init_res = rc;
}
}
dev->state->initialized = true;
if (rc == 0) {
/* Run automatic device runtime enablement */
(void)pm_device_runtime_auto_enable(dev);
}
return rc;
}
It loads the init_function
address and device pointer from the passed
init_entry
into r3
and r4
respectively (ldrd r3, r4, [r0]
). Then, if the
device initialization function is not NULL
, invokes the function (blx r3
)
after moving the device pointer into r0
to be passed (mov r0, r4
).
0e0acd00 <do_device_init>:
e0acd00: b510 push {r4, lr}
e0acd02: e9d0 3400 ldrd r3, r4, [r0]
e0acd06: b933 cbnz r3, e0acd16 0x16 >
e0acd08: 2000 movs r0, #0
e0acd0a: 68e2 ldr r2, [r4, #12]
e0acd0c: 7853 ldrb r3, [r2, #1]
e0acd0e: f043 0301 orr.w r3, r3, #1
e0acd12: 7053 strb r3, [r2, #1]
e0acd14: bd10 pop {r4, pc}
e0acd16: 4620 mov r0, r4
e0acd18: 4798 blx r3
e0acd1a: 2800 cmp r0, #0
e0acd1c: d0f4 beq.n e0acd08 0x8 >
e0acd1e: 2800 cmp r0, #0
e0acd20: bfb8 it lt
e0acd22: 4240 neglt r0, r0
e0acd24: 28ff cmp r0, #255 ; 0xff
e0acd26: bfa8 it ge
e0acd28: 20ff movge r0, #255 ; 0xff
e0acd2a: 68e3 ldr r3, [r4, #12]
e0acd2c: 7018 strb r0, [r3, #0]
e0acd2e: e7ec b.n e0acd0a 0xa >
The use of the blx
instruction (“Branch with Link and Exchange”) is the reason
for the 1 byte offset in address. The Cortex-M33 implements the Armv8-M
architecture, which uses the T32 (formerly Thumb2) instruction set. Armv8
supports
“interworking”,
which allows dynamically switching between A32 and T32 instruction sets using
the least significant bit of the destination address for applicable interworking
instructions to indicate the ISA used by the callee. However, Armv8-M only
supports T32, so the least significant bit must always be a 1
. The Armv8-M
Architecture Reference Manual includes the following description for the blx
instruction.
Bit[0] complies with the Arm architecture interworking rules for switching
between the A32 and T32 instruction sets. However, Armv8-M only supports the
T32 instruction set, so bit[0] must be 1. If bit[0] is 0 the PE takes an
INVSTATE UsageFault exception on the instruction at the target address.
In the device_area
section of the application processor binary, there is a
device
structure for the PPR peripheral. Its address matches the second member of
the init_entry
that was passed to do_device_init
(0e0ad474
).
0e0ad474 <__device_dts_ord_60>:
e0ad474: 0e0ae95e ; instruction: 0x0e0ae95e
e0ad478: 0e0ae5e8 cfsh32eq mvfx14, mvfx10, #-8
e0ad47c: 00000000 andeq r0, r0, r0
e0ad480: 2f011404 svccs 0x00011404
e0ad484: 00000000 andeq r0, r0, r0
The second member of the device
is a pointer to the config
(0e0ae5e8
) for
the PPR peripheral.
0e0ae5e8 <config0>:
e0ae5e8: 5f908000 svcpl 0x00908000
e0ae5ec: 2fc00000 svccs 0x00c00000
e0ae5f0: 0e0e4000 cdpeq 0, 0, cr4, cr14, cr0, {0}
e0ae5f4: 0000f800 andeq pc, r0, r0, lsl #16
The nordic_vpr_launcher
defines the config
as follows.
struct nordic_vpr_launcher_config {
NRF_VPR_Type *vpr;
uintptr_t exec_addr;
#if DT_ANY_INST_HAS_PROP_STATUS_OKAY(source_memory)
uintptr_t src_addr;
size_t size;
#endif
};
In the nRF54H20 memory layout
documentation,
the slow global RAM (RAM3x) that the PRR executes from has address range from
2fc00000
to 2fc14000
. The exec_addr
matches the top of the range
2fc00000
. The src_addr
is 0e0e4000
, which resides in the
MRAM_10
address range (0e000000
to 0e100000
). MRAM_10 is non-volatile memory used
for storing firmware images. The VPR launcher copies the PPR firmware from
MRAM_10 to RAM3x, sets the PPR program counter to the start address of RAM3x,
then starts the PPR. In the dump of the PPR firmware image (now we’re looking at
RISC-V instructions), we can see that the start address corresponds to the
expected __start
symbol.
2fc00000 <__start>:
2fc00000: 00001297 auipc t0,0x1
2fc00004: 90028293 addi t0,t0,-1792 # 2fc00900 <_isr_wrapper>
2fc00008: 00328293 addi t0,t0,3
2fc0000c: 30529073 csrw mtvec,t0
2fc00010: 00000297 auipc t0,0x0
2fc00014: 0f028293 addi t0,t0,240 # 2fc00100 <_irq_vector_table>
2fc00018: 30729073 csrw 0x307,t0
2fc0001c: 0a50006f j 2fc008c0 <_vector_end>
However, the PPR firmware image needs to be present in the MRAM_10 region before
it can be copied. This is handled by sysbuild on west flash
as the flash order
is defined in the generated domains.yaml
.
default: hello_world
build_dir: zephyr/samples/sysbuild/hello_world/build
domains:
- name: hello_world
build_dir: zephyr/samples/sysbuild/hello_world/build/hello_world
- name: remote
build_dir: zephyr/samples/sysbuild/hello_world/build/remote
flash_order:
- remote
- hello_world
After successful programming, the application processor will boot, initialize
and start the PPR, then print to the configured console on /dev/ttyACM0
.
*** Booting nRF Connect SDK v2.8.0-a2386bfc8401 ***
*** Using Zephyr OS v3.7.99-0bc3393fb112 ***
Hello world from nrf54h20dk@0.9.0/nrf54h20/cpuapp
After being started by the application processor, the PPR will boot and also
print to its configured console on /dev/ttyACM1
.
*** Booting nRF Connect SDK v2.8.0-a2386bfc8401 ***
*** Using Zephyr OS v3.7.99-0bc3393fb112 ***
Hello world from nrf54h20dk@0.9.0/nrf54h20/cpuppr
It is exciting to see the use of RISC-V for domain specific operations alongside
the familiar Arm processors present in many microcontrollers. With the
incoporation of VPR processors across many of Nordic’s new SoCs, it is clear
that we’ll continue to see more heterogeneous compute resources in the coming
years. Understanding the architecture of the system and the interaction between
components allows us to more fully leverage the capabilities of these products.
As we continue exploring the PPR and FLPR VPR processors, we’ll see how they can
be used to improve performance and expand functionality.