The RPC facility, and the LIF gate behind the return direction¶
This is the channel the host uses to program the OCTEON TX coprocessor's fast path: what lives in the
rpc window, how a command is posted, and the one table that decides whether a frame arriving at a
front port is ever handed to the host.
Everything here was read out of opt/sophos/usfp/octtx/usfp_rh.ko as shipped on the appliance -
13,487,888 bytes, taken from npu-artifacts/rootfs.tar, release
rootfs-2026.0518-1247-1235-v22.0.Maint.060.Bali, which is the board's own build rather than the
v22.0.Maint.040.Luzon source drop. The module carries full DWARF, including .debug_macro, so the
structures and the constants are the vendor's own names and values rather than anything inferred:
1,212 .debug_macro sections, .debug_info, .debug_abbrev, .debug_str.
Nothing in this page has been sent to a coprocessor. It is a specification read from a binary, and the checks at the end are the reason to believe it.
Why it matters¶
A frame that arrives from a front port passes five gates that can drop it outright:
FPCNTR_FROM_WIRE_DROP_LIF_INDEX_INVALID
FPCNTR_FROM_WIRE_DROP_LIF_LU_NULL
FPCNTR_FROM_WIRE_DROP_LIF_INVALID
FPCNTR_FROM_WIRE_DROP_LIF_ADMIN_DISABLED
FPCNTR_FROM_WIRE_DROP_LIF_NOT_MY_MAC
A frame that arrives from the host passes none of them. Every FPCNTR_FROM_WIRE_TO_KN_* name is
a specific exception reason - MFLOW_NOT_ACTIVE, NON_ACCEL, LIF_OFFLOAD_DISABLED,
CONN_INVALID and so on - and there is no catch-all. So a received frame whose ingress port tag does
not resolve to a LIF is dropped before any transmit to the host is considered, and this driver's
OUT_PKT_CNT == 0 is correct behaviour rather than a fault. See issue #64.
LIFs are installed over this facility. They cannot be installed over SDP, and NetAgent has no operation that touches one.
The window¶
dev.octep.0.rpc, measured on the appliance:
size 1048576 (NPU_BARMAP_RPC_SIZE, 1 MB)
dbell_start 155
dbell_count 5
Five doorbells, against one for each of the other facilities, and the module says why:
RPC_LO_RINGS_MAX 1
RPC_HI_RINGS_MAX 4
One low-priority ring and four high-priority ones.
struct rpc_state - 232 bytes, at the head of the window¶
+0 u32 cfg_magic RPC_STATE_CFG_MAGIC = 0xD7D3AB00
+4 u16 cfg_revision
+6 u8 active_hi_rings
+7 u8 reconfig_done
+8 u64 zero_pad[8]
+72 struct rpc_ring ring_lo
+104 struct rpc_ring rings[4]
RPC_STATE_SIZE is 232, which is exactly 104 + 4 * 32.
Read on a freshly booted coprocessor, with the fast path running, the window holds one non-zero
word - +0x0000 0x0100000000000000, little-endian - which is cfg_magic = 0, cfg_revision = 0,
active_hi_rings = 0, reconfig_done = 1. Nothing is configured, and the far side is not
waiting on anything it has been asked for.
struct rpc_ring - 32 bytes¶
+0 u64 posted
+8 u64 done
+16 u32 ring_offset
+20 u32 desc_offset
+24 u32 desc_count
+28 union ring_hw_cfg r_cfg
and the configuration word carries the doorbell number, which is how a ring is tied to one of the five:
union ring_hw_cfg { /* 4 bytes */
+0 u8 ring_num
+1 u8 f_index RPC_FACILITY_PRIM_IDX = 0, RPC_FACILITIES_MAX = 2
+2 u8 dbell
+3 u8 shared
}
posted and done are free-running counters, not indices: the descriptor slot for a given count is
RPC_CMD_BAR_DESC_OFFSET_Q(ring, counter) =
(RPC_CMD_BAR_DESC_SIZE * (counter & (ring->desc_count - 1)) + ring->desc_offset) / 8
so desc_count is a power of two and the mask wraps the ring.
struct rpc_cmd_bar_desc - 16 bytes, in the window¶
One of these per posted command, at the offset above:
+0 u64 dma_buff_addr host physical address of the command buffer
+8 u16 payload_len
+10 u16 flags RPC_DESC_POST_FLAG 1, RPC_DESC_NO_AGG_DMA_FLAG 2
+12 u32 reserved
RPC_CMD_BAR_DESC_SIZE is 16.
The command buffer, in host memory¶
The descriptor points at a buffer whose head is:
struct rpc_cmd_buf_desc { /* 8 bytes, then the payload */
+0 u16 resp_buff_sz
+2 u8 reserved
+3 u8 cmd <- enum rpc_cmd_type
+4 u32 unused
+8 u8 payload[]
}
and whose answer is written back as:
struct rpc_resp_buf_desc { /* 8 bytes, then the payload */
+0 u16 rc see below: three encodings share these sixteen bits
+2 u8 descriptor_done
+3 u8 unused
+4 u16 magic_seed feeds rpc_generate_done_magic
+6 u16 payload_len
}
rc carries three encodings, by range, and reading it as one was a defect a review caught:
| range | meaning | who sets it |
|---|---|---|
| 0 | success | the handler |
| 1 .. 20 | an SP2FP_RC_* refusal code - 2 is INVALID_OPERATION, the index is occupied |
the handler |
| 21 .. | SP2FP_RC_MAX (20) plus a positive Linux errno: 31 is -EAGAIN, not ready yet, post again |
the handler, through SP2FP_RC_ENCODE() in sp2fp_helpers.h |
| bit 15 set | a transport errno: the command was never dispatched (reset_rpc, a ring fault) |
rpc_handler.c, and only there |
SP2FP_RC_MAX is not defined in any header this tree holds; the coprocessor's own usfp_rh fixes
it at 20 - ipsec_add compares the add's result with -11 and returns 0x1f - and the vendor's x86
host compares the same command's rc with 0x1f. The driver's rpc.last says which range an rc is
in, in words.
Sizes: RPC_BUF_DESC_SIZE 8, RPC_DATA_MAX_SIZE 4096, RPC_DATA_RESERVED_SIZE 32,
RPC_USERDATA_MAX_SIZE 4064, RPC_DMA_FROM_HOST_LIMIT 32768, RPC_DONE_MAGIC_SIZE 4,
RPC_DONE_MAGIC_SEED_MASK U16_MAX.
enum rpc_cmd_type - 51 commands¶
The read-only ones are the place to start, because they change nothing on a live fast path:
| value | command | |
|---|---|---|
| 36 | RPC_CMD_PLATFORM_READ |
what usfp_table_print.sh platform_info prints |
| 37 | RPC_CMD_LO_LIF_READ |
the LIF table |
| 38 | RPC_CMD_LO_CONN_READ |
|
| 39 | RPC_CMD_LO_NHOP_READ |
|
| 40 | RPC_CMD_LO_MFLOW_READ |
|
| 41 | RPC_CMD_LO_LUID_READ |
|
| 42 | RPC_CMD_LO_SA_READ |
|
| 43 | RPC_CMD_LO_WORKER_DBG_CNT_READ |
|
| 44 | RPC_CMD_LO_WORKER_SYS_CNT_READ |
the FPCNTR_* counters |
| 45 | RPC_CMD_LO_WORKER_PORT_CNT_READ |
the per-DPDK-port counters |
| 46 | RPC_CMD_LO_WORKER_DF_CNT_READ |
|
| 47-50 | the four matching _CLR commands |
these do change state |
The writing ones, for later:
| value | command |
|---|---|
| 0-2 | RPC_CMD_FW_STATE_REV_SET, RPC_CMD_FW_L3_FWD_STATE_REV_SET, RPC_CMD_FW_CFG_PARAMS_SET |
| 3 | RPC_CMD_LIF_ADD_UPDATE |
| 4 | RPC_CMD_LIF_DELETE |
| 5 | RPC_CMD_PPORT_UPDATE |
| 6, 7 | RPC_CMD_NHOP_PROGRAM, RPC_CMD_NHOP_UPDATE |
| 8, 9 | RPC_CMD_MFLOW_PROGRAM, RPC_CMD_MFLOW_INVALIDATE |
| 10-17 | the flow and connection commands |
| 18, 19 | RPC_CMD_ADD_LIVE_UID, RPC_CMD_DELETE_LIVE_UID |
| 20-26 | the QoS commands |
| 27-29 | the DoS commands |
| 30-35 | the IPsec SA commands - named and specified below |
| 51 | RPC_CMD_MAX |
The LIF, which is what the gate consults¶
struct usfp_lif_entry { /* 14 bytes */
+0 struct eth_addr my_mac
+6 uint16_t mtu
+8 uint16_t fwd_mode : 2
+8 uint16_t admin_disabled : 1
+8 uint16_t offload_disabled : 1
+8 uint16_t rep_pid_mlb : 12
+10 uint16_t df_enabled
+12 uint16_t reserved : 15
}
LIF_FWD_MODE_ENTRY_INVALID 0x0 LIF_FWD_MODE_L2 0x1
LIF_FWD_MODE_L3 0x2 LIF_FWD_MODE_BOTH 0x3
and the field mask an add-or-update carries, so a caller can set one field without the others:
LIF_M_MAC 0x0001 LIF_M_MTU 0x0002 LIF_M_FWD 0x0004
LIF_M_ADMIN_DISABLED 0x0008 LIF_M_OFFLOAD_DISABLED 0x0010 LIF_M_REPPID 0x0020
offload_disabled is the bit that makes the fast path punt. It is the difference between
FPCNTR_FROM_WIRE_TO_KN_LIF_OFFLOAD_DISABLED and a frame that stays on the coprocessor.
How a port tag becomes a LIF¶
usfp_pport2iface_table[tag] -> iface_id 65536 bytes, one u8 per tag
IFACE_VLAN_2_LIF_IDX(iface_id, vlan) = (iface_id << 12) | vlan
usfp_lif_table[lif_idx] -> struct usfp_lif_entry
with LIF_IFACE_ID_SHIFT 12, PLATFORM_MAX_IFACES 128, PLATFORM_MAX_PPORT_VAL 65535 and
MAX_LIF_ENTRIES(max_ifaces) = max_ifaces * MAX_NUM_VLANS. The reverse maps are
usfp_iface2pport_table and usfp_lif_idx2tag_map.
Every table the fast path publishes has a shared-memory symbol name, which is also how
usfp_table_print.sh reaches them: usfp_lif_table, usfp_conn_table, usfp_sys_cntrs,
usfp_port_cntrs, usfp_dbg_cntrs, usfp_df_cntrs, usfp_fw_state, usfp_ipsec_sadb, and a
_clr_ring beside each counter array.
Why this reading should be believed¶
Three independent checks, none of which was used to derive it.
The doorbell count. The window advertises 5. The module defines RPC_LO_RINGS_MAX 1 and
RPC_HI_RINGS_MAX 4, and struct rpc_state holds exactly one ring_lo and four rings.
The structure size. RPC_STATE_SIZE is a literal 232 in a macro, and the members add to 232.
The LIF dump. usfp_table_print.sh lif, captured from this board while SFOS was running it,
prints per entry: MAC, MTU, Representor PID MLB, FWD mode, Admin disabled, Offload
disabled, DF enabled - which is struct usfp_lif_entry field for field and in order. And its
entry numbering is LIF ID: 0, 4096, 8192 for iface_id 0, 1 and 2, which is
(iface_id << 12) | vlan exactly.
Every handler has the same signature, and that fixes the payload rules¶
The handlers in each *_rpc.c are registered with rpc_cmd_cb_reg(u8 cmd, rpc_cmd_cb cb, void *ctx),
and rpc_cmd_cb is:
int (*)(void *ctx, void *data, uint16_t *payload_len, uint16_t max_len)
data is the payload area, which is the command buffer past its eight-byte header. payload_len is
in and out - the request's length on the way in, the response's on the way out - so a command
answers into the same buffer it arrived in. max_len is the resp_buff_sz the host declared.
Every one of them is that shape: lif_add_update, lif_delete, lif_read, fp_sys_cnt_read,
fp_port_cnt_read, fp_dbg_cnt_read, fp_df_cnt_read and their four _clr counterparts. The
counter reads funnel into one read_common(rpc_h, cntr_type, data, payload_len, max_len), where
the counter type comes from which command it was rather than from the payload. The payload itself
is a range, and its shape is settled one level down:
int cntrs_fpop_read(struct cntrs_fpop_handle *fpop_h, int cntr_type,
struct usfp_fpop_req_table_read *req,
void *out_data, unsigned int out_data_len);
struct usfp_fpop_req_table_read { /* 12 bytes */
+0 uint32_t s_index first index wanted
+4 uint16_t num_entries
+6 uint16_t flags the I / C / D options usfp_table_print.sh passes
+8 uint32_t e_index last index wanted
}
which is usfp_table_print.sh's own interface - idx, start-, start-end, and the I, C and D
options - written as a structure.
The answer has no header of its own. out_data takes the raw entries and
rpc_resp_buf_desc.payload_len says how many bytes came back. There is no
usfp_fpop_resp_table_read anywhere in the module, and the usfp_fpop_req_* / usfp_fpop_resp_*
pairs that do exist - for the IPsec SA operations, for a connection reclaim - show that a response
structure is named when there is one.
The one write that matters: RPC_CMD_LIF_ADD_UPDATE¶
Its payload is struct usfp_lif_config, 16 bytes:
struct usfp_lif_config { /* 16 bytes */
+0 struct usfp_lif_index index /* 4 */
+4 struct fp_lif_info lif_info /* 10 */
+14 struct usfp_lif_fp_priv fp_priv /* 2 */
}
struct usfp_lif_index { /* 4 bytes, one u32 */
vlan_id : 12 bits 0-11
iface_id : 7 bits 12-18
reserved : 13
}
struct fp_lif_info { /* 10 bytes */
+0 struct eth_addr my_mac uint8_t mac_addr[6]
+6 uint16_t mtu
+8 uint16_t fwd_mode : 2 bits 0-1
+8 uint16_t admin_disabled : 1 bit 2
+8 uint16_t offload_disabled : 1 bit 3
+8 uint16_t rep_pid_mlb : 12 bits 4-15
}
struct usfp_lif_fp_priv { /* 2 bytes */
+0 uint8_t update_mask the LIF_M_* bits
+1 uint8_t df_enabled : 1
}
usfp_lif_index is a fourth independent confirmation of the addressing. Its vlan_id occupies
bits 0-11 and iface_id bits 12 upward, which is IFACE_VLAN_2_LIF_IDX(iface_id, vlan) =
(iface_id << 12) | vlan written as a bitfield, and LIF_IFACE_ID_SHIFT is 12. The seven bits for
iface_id also agree with PLATFORM_MAX_IFACES 128.
And update_mask runs in the same order as the fields it guards - LIF_M_MAC 0x0001 for my_mac,
LIF_M_MTU 0x0002 for mtu, then LIF_M_FWD, LIF_M_ADMIN_DISABLED, LIF_M_OFFLOAD_DISABLED,
LIF_M_REPPID for the four bitfields in order.
So a command, end to end¶
A counter read, which changes nothing:
buffer, in host memory, DMA-mapped:
+0 u16 resp_buff_sz = however much room is given for the answer
+2 u8 reserved = 0
+3 u8 cmd = 44 RPC_CMD_LO_WORKER_SYS_CNT_READ
+4 u32 unused = 0
+8 struct usfp_fpop_req_table_read, 12 bytes: s_index, num_entries, flags, e_index
descriptor, in the rpc window at RPC_CMD_BAR_DESC_OFFSET_Q(ring, ring->posted):
+0 u64 dma_buff_addr = that buffer
+8 u16 payload_len = 12
+10 u16 flags = RPC_DESC_POST_FLAG
+12 u32 reserved = 0
then ring->posted++ and the doorbell named in ring->r_cfg.dbell
the answer, written back over the same buffer:
+0 u16 rc RPC_RC_ERRNO_BIT (1<<15) marks an errno
+2 u8 descriptor_done
+4 u16 magic_seed
+6 u16 payload_len how many counter bytes follow
+8 the counters
and the LIF install, which does not:
+3 u8 cmd = 3 RPC_CMD_LIF_ADD_UPDATE
+8 struct usfp_lif_config, with offload_disabled set and update_mask covering it
payload_len = 16
One reading here is not certain: whether rpc_cmd_bar_desc.payload_len counts the eight-byte
header or only the payload. It is taken to be the payload alone, because the callback receives data
already past the header together with payload_len, and rpc_cmd_put(ctx, host_dma_buf, payload_len,
done, rc) passes the same quantity back. That is a reading of two signatures, not a measurement.
The configuration handshake, from the code¶
This needed the one thing DWARF does not give: control flow. It was read from a disassembly of the same module, with objdump resolving the call targets from the module's own relocations, so every function named below is the module's own name for it.
What triggers a reconfiguration¶
irq_handler, the doorbell handler, opens with a three-instruction test:
ae0: ldr x1, [x19, #4080] the host-visible struct rpc_state
ae4: ldr x3, [x19, #4088] the target's CACHED copy of the cfg word
ae8: ldr x2, [x1] the LIVE cfg word - rpc_state +0, the whole u64 union
aec: cmp x3, x2
af0: b.eq c18 unchanged: go and process rings
The trigger is one 64-bit word. The union at the head of rpc_state - cfg_magic,
cfg_revision, active_hi_rings, reconfig_done - is read as a single u64 and compared against a
cached copy. Any difference takes the handler down the reconfiguration path, which ends in
queue_work_on for refresh_cfg.
That fixes the host's ordering, and it is the ordinary publish-last rule. Nothing but the cfg word
is compared, so each ring's ring_offset, desc_offset, desc_count and r_cfg must be in place
before the cfg word is written, and the cfg word must be written as one store.
What the target then does, in order¶
refresh_cfg, by its call sequence:
printk |
announces the reconfiguration |
__ll_sc_atomic_add, then a spin on a bit |
per ring: takes a reference and waits for that ring to go quiet |
cancel_work_sync |
stops the low-priority worker |
rpc_free_hi_prio_buffers |
releases the old high-priority buffers |
ldrb w0, [x0, #6] |
reads active_hi_rings out of the live state and takes it as the new ring count |
mv_pci_get_dma_dev_count, mv_pci_get_dma_dev |
per ring, chosen from that ring's r_cfg.dbell |
dma_alloc_from_dev_coherent |
allocates that ring's buffers, on the target side |
mv_free_dbell_irq / mv_request_dbell_irq |
releases and re-requests the doorbell interrupt, on the path a ring takes when its r_cfg.shared is clear |
__ll_sc_atomic_sub |
drops the reference taken at the start |
queue_work_on |
restarts the low-priority worker |
rpc_handler_enable |
mv_dbell_enable(facility, dbell) for the low ring and each high ring, unwinding with mv_dbell_disable if one fails |
and then, only if rpc_handler_enable returned zero:
2598: ldr x0, [x20]
259c: ldr x1, [x0, #4080]
25a0: strb w23, [x1, #7] reconfig_done = 1, written into the host's window
25a4: ldr x1, [x0, #4080]
25a8: ldr x1, [x1] re-read the live cfg word
25ac: str x1, [x0, #4088] and cache it
25c0: ret
reconfig_done is written by the target and never by the host. It is the acknowledgement that a
configuration was applied, and the host polls it. rpc_handler_init sets it to 1 at module load and
seeds the cache in the same breath, which is why a freshly booted coprocessor with nothing configured
reads cfg_magic 0 with reconfig_done 1 - not "waiting for something" but "up, nothing pending".
A confirmation of the layout, from the code rather than the debug information¶
rpc_handler_init does:
29ec: str x0, [x25, #4080] remember where the state is
29f0: strb w3, [x0, #7] reconfig_done = 1
29fc: ldr x4, [x3], #72 read the cfg word, then advance the pointer by 72
2a00: str x4, [x25, #4088] cache the cfg word
2a04: str x3, [x25, #4192] remember where ring_lo is
The post-increment is 72, which is offsetof(struct rpc_state, ring_lo) exactly as DWARF gives
it. The disassembly and the debug information agree, and neither was used to derive the other.
One thing worth flagging in the vendor's code¶
refresh_cfg writes reconfig_done and then re-reads the live cfg word to cache it. A cfg word the
host wrote while the refresh was running would be cached as applied when it was not, and the next
doorbell would find no difference. The window is narrow and it is the vendor's to worry about, but a
host implementation should not lean on back-to-back reconfigurations: wait for reconfig_done before
writing a new cfg word.
Run on the appliance¶
contrib/octep/octep_rpc.c is the client, and it issues only the commands that read. It was run on
the XGS 3300 on 2026-09-28, and what follows is what came back rather than what was expected.
The configuration is accepted in seven milliseconds¶
octep0: rpc: wrote cfg 0x00000001d7d3ab00; ring_lo descs 8 at window+0x1000, dbell 0, shared 0
octep0: rpc: the target acknowledged after 7 ms; cfg now 0x01000001d7d3ab00
and the window then reads back:
cfg 0x01000001d7d3ab00
magic 0xd7d3ab00 (as expected) revision 1 active_hi_rings 0 reconfig_done 1
ring_lo
posted 0 done 0
ring_offset 0x0 desc_offset 0x1000 desc_count 8
r_cfg 0x00000000: ring_num 0 facility 0 dbell 0 shared 0
So the publish-last ordering is right, doorbell index 0 is right, and shared clear is right. The
target cleared nothing and acknowledged everything: reconfig_done was zeroed by the host with the
cfg word and came back as 1.
RPC_DESC_POST_FLAG means posted, and a command that wants an answer must not set it¶
This cost two runs and is the one thing the names got wrong on first reading. All three combinations,
same command, same buffer poisoned to 0x5a beforehand:
| descriptor flags | bytes in the buffer that are no longer poison |
|---|---|
1, POST |
20 - only the request this driver wrote |
2, NO_AGG_DMA |
24 - the request, plus a response header and a done magic |
| 3, both | 20 - as with 1 |
POST is posted in the PCIe sense: fire and forget, no completion. The target consumes the
descriptor and advances done and writes nothing back. Leave it clear and the answer arrives.
The response, laid out by measurement¶
Asking for four entries returned 32 bytes; asking for two hundred returned 1600. So an entry is a
64-bit word, num_entries in the request decides how many, and the reply is:
+0 struct rpc_resp_buf_desc, 8 bytes
+8 payload, payload_len bytes
+8 + payload_len the four-byte done magic
which is RPC_DONE_MAGIC_SIZE where the module says it should be. magic_seed in the header
increments by one per command - 2, 3, 4, 5 across four commands - and rc is 0 on success, an
SP2FP_RC_* code up to 20, an encoded errno above 20, or a transport errno under bit 15 (the
table under struct rpc_resp_buf_desc above).
RPC_CMD_PLATFORM_READ, checked against the board's own firmware¶
232 bytes came back. Decoded, against what usfp_table_print.sh platform_info printed on this same
board while SFOS was running it:
| offset | what came back over RPC | what SFOS printed |
|---|---|---|
| 0 | XGS_1US |
Platform Name : XGS_1US |
| 64 | UNKNOWN_VERSION |
Version : UNKNOWN_VERSION |
| 128 | AMDA0202-0004 |
Assembly Partno : AMDA0202-0004 |
| 192 | 02, 14, 80, 01, 0a |
ID 2, Proc_cores 20, Max interfaces 128, Num PFs 1, Mflow timeout 10 |
| 200 | 05, 0x1e8480 |
Conn not usable timeout 5, Max conn entries 2000000 |
| 208 | 0x10000, 0x10000 |
Max nhop entries 65536, Max firewall rule IDs 65536 |
| 224 | 0x3d0eaa |
Num mflows 4001450 |
Field for field. That is the check this whole reading needed: the same numbers, by a channel this project wrote from a binary, against a capture taken from the vendor's own firmware months of work earlier.
The counters, which are the point¶
RPC_CMD_LO_WORKER_SYS_CNT_READ with two hundred entries returns 1600 bytes, rc 0, and every
counter zero.
That is the right answer and it is worth saying why. Under the vendor's firmware the same array had
FPCNTR_RX_WIRE at 61,888 and FPCNTR_TX_KN at 61,874. Here the fast path is forwarding nothing,
which is what a fast path with no LIF does, and the counters say so. The instrument now exists and
reads zero for a reason this project understands.
The high-priority ring the board asks for, configured as the vendor's host does (2026-10-17)¶
The platform block's rpc_rings byte, at offset 195, is how many high-priority rings the board wants
- one on the XGS 3300. The vendor's host reads the block and then configures them
(
rpc_provider_set_high_prio_rings(pi->rpc_rings)in itsusfp_main.c), and so doesoctepnow: afterPLATFORM_READit writes the whole state block again with ring 1 in it - its descriptors after the low ring's, 256 of them at window offset0x20e8, doorbell 1 (the doorbell for high ring i ismin(i / (f_index + facility_count) + 1, dbells - 1), and the RPC facility has five), andactive_hi_rings1 in the configuration word. Nothing is in flight at that moment - every earlier command was waited for - so nothing is lost by the second configuration.
What goes on it is what the vendor posts without waiting: FLOW_CREATE_FP, and MFLOW_PROGRAM that
makes a microflow active. Each is copied into a 512-byte slot of a 128 KB buffer, its descriptor
written with RPC_DESC_POST_FLAG - posted, no answer - and the ring's posted count and doorbell
moved; the first descriptor of a batch also carries RPC_DESC_NO_AGG_DMA. A command that wants an
answer still goes on the low ring and drains the high ring first, so a read never overtakes a write.
rpc.hi_post (1) switches it, rpc.hi_ready, hi_posts, hi_full, hi_drains, hi_posted and
hi_done count it. Read off those counters the night it went in, and not photographed: 2,860 writes
posted and every one done, none in error; 836 of a thousand connections in hardware half a second after
they started and 999 at two and a half seconds. A board that asks for more than one ring gets the first and the log says the others are
not configured: only a board asking for one has been run.
What is not here¶
Whether cfg_magic is validated, and whether cfg_revision has to move. The comparison is on
the whole 64-bit word, so any change at all triggers a refresh and the revision is not needed to make
that happen. refresh_cfg was read end to end and never tests cfg_magic against
RPC_STATE_CFG_MAGIC, so either something above it does or the magic is there for whoever reads a
memory dump. Writing it is free either way.
Where the buffers come from. rpc_alloc_lo_prio_buffers and rpc_alloc_hi_prio_buffers are on
the target, and struct ring_context holds buf_dma_addr and prefetch_dma_base - so the target
allocates its own side. Whether the host's dma_buff_addr is read by the target's DMA engine or
prefetched into those buffers changes nothing about the descriptor, but it does decide whether a host
buffer has to stay mapped after the doorbell.
Nothing has been sent. Every line above is read from a binary. The first thing to send should be a counter read, because it changes nothing and its answer is checkable against the numbers already captured from this board under the vendor's firmware.
Neither gap needs hardware to close. Both are in the same module.
PPORT_UPDATE binds a port tag to an interface, in both directions¶
This one is worth its own section because it is the join between the two halves of the datapath, and it is four bytes:
The handler keeps two tables, and their names say what each is for:
struct pport_fpop_handle {
+0 struct ushmem_entry *iface2pport_shmem;
+8 struct ushmem_entry *pport2iface_shmem;
+16 u8 *pport2iface_tbl; /* one byte per port tag */
+24 u16 *iface2pport_tbl; /* one u16 per interface */
+32 unsigned int max_ifaces;
};
So a single four-byte command populates both directions of the map: iface2pport is what the
from-host path needs to turn an interface into a port tag, and pport2iface is what the from-wire
path needs to turn an arriving frame's port into the interface whose LIF it should be matched
against. The LIF table is indexed iface_id << 12 | vlan, which the vendor's own dump shows
directly - LIF IDs 0, 4096 and 8192 for interfaces 0, 1 and 2 - so the interface id from this table
is what reaches the LIF lookup.
Both tables live in shared memory (ushmem_entry), which is how the fast path's polling workers see
an update without an interrupt.
This driver has never sent this command. On the evidence it does not have to for the outbound
direction to work, and the inbound direction resolves a LIF as far as
FPCNTR_FROM_WIRE_TO_KN_LIF_OFFLOAD_DISABLED, so whatever default is in place is enough to be
matched. It is recorded here because it is the structure that joins a port to an interface, and any
attempt to give this driver more than one port will need it.
The crypto engine, specified¶
The coprocessor's crypto units are the reason this chip exists, and nothing in this project had used them deliberately. One thing had used them by accident: early on, every frame this driver posted was routed into IPsec encryption and dropped for want of a security association, because a filler byte in the metadata happened to ask for it.
So the engine answers. What was missing was the association. This section is that, read out of
usfp_rh.ko's DWARF and its own disassembly - the vendor's names, offsets and values, not inferred
shapes.
The whole command enum, by value¶
enum rpc_cmd_type, which replaces the partial table above:
0 FW_STATE_REV_SET 18 ADD_LIVE_UID 36 PLATFORM_READ
1 FW_L3_FWD_STATE_REV_SET 19 DELETE_LIVE_UID 37 LO_LIF_READ
2 FW_CFG_PARAMS_SET 20 QOS_ADD_METER 38 LO_CONN_READ
3 LIF_ADD_UPDATE 21 QOS_REMOVE_METER 39 LO_NHOP_READ
4 LIF_DELETE 22 QOS_QUERY_METER 40 LO_MFLOW_READ
5 PPORT_UPDATE 23 QOS_SET_SHAPER_RATE 41 LO_LUID_READ
6 NHOP_PROGRAM 24 QOS_GET_SHAPER_RATE 42 LO_SA_READ
7 NHOP_UPDATE 25 QOS_SET_DWRR_WEIGHT 43 LO_WORKER_DBG_CNT_READ
8 MFLOW_PROGRAM 26 QOS_GET_DWRR_WEIGHT 44 LO_WORKER_SYS_CNT_READ
9 MFLOW_INVALIDATE 27 DOS_SET_POLICY 45 LO_WORKER_PORT_CNT_READ
10 FLOW_CREATE_FP 28 DOS_ADD_BLACKLIST_ENTRY 46 LO_WORKER_DF_CNT_READ
11 CONN_CREATE_FP 29 DOS_CLEAR_BLACKLIST 47 LO_WORKER_DBG_CNT_CLR
12 CONN_MODIFY_FP 30 SA_ADD 48 LO_WORKER_SYS_CNT_CLR
13 CONN_MODIFY_FP_VERDICT 31 SA_DEL 49 LO_WORKER_PORT_CNT_CLR
14 CONN_TRACK_FP 32 SA_GET_STATS 50 LO_WORKER_DF_CNT_CLR
15 CONN_CFG_FP_TCP_SEQ_CHK 33 SA_REPLAY_UPDATE 51 MAX
16 CONN_RECLAIM_FP 34 SA_SEQ_UPDATE
17 CONN_GET_FP_TCP_STATE 35 SA_HOST_STAT_SYNC
And which handler each SA command reaches, read out of ipsec_rpc_init, which calls
rpc_cmd_cb_reg(number, handler, ctx) seven times:
| command | handler |
|---|---|
30 SA_ADD |
ipsec_add |
31 SA_DEL |
ipsec_del |
32 SA_GET_STATS |
ipsec_get_stats |
33 SA_REPLAY_UPDATE |
ipsec_replay_update |
34 SA_SEQ_UPDATE |
ipsec_seq_update |
35 SA_HOST_STAT_SYNC |
ipsec_host_stat_sync |
42 LO_SA_READ |
sa_read |
What SA_ADD carries¶
struct usfp_fpop_req_sa_add, 192 bytes, and the offsets here are from the start of the request
payload rather than from the inner structure:
+0 saidx uint32_t the index this association takes; the handle a microflow names is saidx + 1
+4 lif_index uint32_t which logical interface it belongs to
+8 cipher_key __be32[8] 32 bytes
+40 auth_key __be32[16] 64 bytes
+104 ctrl uint32_t bitfields, below
+108 opt uint32_t bitfields, below
+112 win_size uint32_t the anti-replay window
+116 spi __be32
+120 sequence uint64_t
+128 ip_src __be32[4] tunnel source, four words so IPv6 fits
+144 ip_dst __be32[4]
+160 nat_dport __be16 UDP encapsulation, when opt.udp_enable is set
+162 nat_sport __be16
+168 hard_lifetime_byte uint64_t
+176 hard_lifetime_pkt uint64_t
+184 rev_num uint16_t
ctrl, from the low bit up: hash 4 bits, cimode 4, cipher 4, mode 2, proto 2, dir 1,
ena_arw 1, ext_seq 1, 12 spare, valid 1.
opt: 16 spare, then inline_support 1, frag_check 1, bypass_DSCP 1, df_ctrl 2, ipv6 1,
udp_enable 1.
The other two requests are small: SA_DEL takes { uint32_t saidx; unsigned int free; } and
SA_GET_STATS takes a bare saidx.
SA_DEL is two stages, and free is which one. free = 0 invalidates the entry; free = 1
is the second stage, which stores the fast path's grace-period value for the index and frees the
entry's resources. Sent out of order - free = 1 to a still-valid entry - the handler answers 0,
frees nothing, and logs "Attempted to free a valid SA" on the coprocessor; the next SA_ADD on
that index is then refused with rc 2, the entry being occupied. Measured on the appliance
2026-10-07: add, read the statistics (0 bytes, 0 packets, created 0 s ago, labelled with the index
the command named), free = 0, free = 1, add again - 0 every time, six times in a row, and the
encoded -EAGAIN (rc 31) that ipsec_fpop_sa_add returns while the grace period is open never
appeared: with the workers idle it is over before the next command can arrive. That answer is
for a busy fast path, and the driver's retry on it is tested by its encoding, not by a refusal
caught in the act.
SA_GET_STATS answers struct usfp_fpop_resp_get_sa_stats { uint64_t bytes; uint64_t packets;
uint32_t seconds; } - 24 bytes on the wire with the trailing pad - and answers it for an empty
index too, with seconds counting from the coprocessor's boot.
The algorithm numbers, which are the vendor's own enums¶
ipsec_sa_dir 0 ENCRYPT / OUTBOUND 1 DECRYPT / INBOUND
ipsec_sa_mode 0 TRANSPORT 1 TUNNEL
ipsec_sa_cipher 0 NULL 1 3DES 2 AES128 3 AES192 4 AES256
5 AES128_NULL 6 AES192_NULL 7 AES256_NULL 8 CHACHA20
ipsec_sa_cipher_mode 0 ECB 1 CBC 2 CFB 3 OFB 4 CTR
ipsec_sa_hash_type 0 NONE 1 MD5_96 2 SHA1_96 3 SHA256_96 4 SHA384_96
5 SHA512_96 6 MD5_128 7 SHA1_80 8 SHA256_128
9 SHA384_192 10 SHA512_256 11 GF128_128 12 GF128_96
13 POLY1305_128
So AES-GCM is cipher AES128/256 with cimode CTR and hash GF128_128, and the 64-byte auth_key
field is a union: auth_key for a separate authentication key, or aead_salt as a single 32-bit
word when the algorithm is combined.
What the live half holds, and why it matters to a host¶
struct usfp_ipsec_sa is 1320 bytes: the 216-byte configuration above, then usfp_ipsec_sa_live -
sequence, byte_count, packet_count, host_byte_count and host_packet_count, the replay
window and its lock. Two of those names are the reason commands 33, 34 and 35 exist: the host and
the coprocessor each keep a sequence number and a byte count, and SA_SEQ_UPDATE,
SA_REPLAY_UPDATE and SA_HOST_STAT_SYNC are how they are reconciled. An offload that installs
associations and never reconciles them will fail a rekey.
And the whole path is already instrumented¶
tools/fpcntr-names.txt indices 98 to 140 are the crypto path, and every failure has its own
counter: CRYPTO_DROP_SA_UNAVAILABLE 121, CRYPTO_DROP_SADB_PRE_ERR 125, CRYPTO_DROP_REPLAY_OOW
127, CRYPTO_DROP_AUTH 133, CRYPTO_DROP_HW_ERR 135. Both directions exist:
FROM_WIRE_TO_IPSEC_DECR 71 and then FROM_IPSEC_DECR_TO_KERNEL 115 or
FROM_IPSEC_DECR_TO_WIRE 114, which is the inline case - a frame decrypted and forwarded
without the host seeing it at all.
Nothing in this section has been sent to a coprocessor. It is a specification read from a binary, and the bring-up that tests it is a separate step.
What happened when it was¶
An association installs and reads back, and a frame asked to use one is still refused before any association is consulted. Why, and what is left, is octeon-tx-crypto-path.md: the index the engine uses lives in a per-packet field written only from a flow, and a flow's identity is neither told to the host nor findable by reading.
The discussion of this page is held in the project's GitHub Discussions. Showing it here loads giscus from giscus.app and GitHub, and writing in it needs a GitHub account - what that means.