Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/fpy/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ Available macros:
* `iabs(value: I64) -> I64`: returns the absolute value of a signed 64-bit integer.
* `fabs(value: F64) -> F64`: returns the absolute value of a 64-bit float.
* `log(message: String, severity: Fw.LogSeverity = Fw.LogSeverity.ACTIVITY_HI)`: pushes the severity, message, and message size onto the stack, then emits a `PopEventDirective`. Both arguments must be compile-time constants.
* `pop(port, value)`: pushes `value` onto the stack and emits a `PopSerializableDirective` that pops those bytes and sends them out the sequencer's `serialOut[port]` port. `port` must be a compile-time constant (typically a `Svc.Fpy.SerialPortIndex` enum constant) validated at compile time to be in `[0, MAX_SERIAL_PORTS)`; `value` may be any constant-size expression and its serialized size becomes the directive's `size` field.

## Type constructors
Structs, arrays, and `Fw.TimeValue` expose constructors whose callable name is the fully qualified type name. Their arguments correspond to the members in declaration order (struct fields by name, array elements as `e0`, `e1`, ..., and `Fw.TimeValue` with `timeBase: TimeBase`, `timeContext: U8`, `seconds: U32`, `useconds: U32`). A constructor call serializes the provided values into a new instance of that type. `Fw.Time` is an alias for `Fw.TimeValue`, and `Fw.TimeInterval` is an alias for `Fw.TimeIntervalValue`.
Expand Down
27 changes: 27 additions & 0 deletions src/fpy/bytecode/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1136,3 +1136,30 @@ If this is called without the seed being manually set beforehand, then the seed
| ------------------|-------------|
| U32 | The next pseudorandom 32-bit value from the sequencer's internal PRNG |

## POP_SERIALIZABLE (78)
Pops `size` bytes of serialized data from the stack and sends them to an external component via the sequencer's `serialOut` output port array.

Exposed to sequences via the `pop(port, value)` builtin, where `port` is a compile-time constant (typically a `Svc.Fpy.SerialPortIndex` enum constant) and `value` is any constant-size expression. The compiler pushes `value` onto the stack and emits `POP_SERIALIZABLE` with `size` set to the serialized size of `value`'s type.

**Preconditions:**
- `port_index < MAX_SERIAL_PORTS` (from the `Svc.Fpy.SerialPortIndex` enum in `FpySequencerCfg`)
- `serialOut[port_index]` is connected to a target component
- `len(stack) >= size`

**Semantics:**
1. Validate the port index is within bounds.
2. Check that the specified port is connected.
3. Pop `size` bytes from the stack.
4. Send the popped bytes to the target component via `serialOut[port_index]`.

**Error Conditions:**
- If `port_index >= MAX_SERIAL_PORTS`: `SERIAL_PORT_INVALID_INDEX`
- If `serialOut[port_index]` is not connected: `SERIAL_PORT_NOT_CONNECTED`
- If `len(stack) < size`: `STACK_UNDERFLOW`

| Arg Name | Arg Type | Source | Description |
|------------|---------------|------------|-------------|
| port_index | FwIndexType | hardcoded | Index of the `serialOut` port array to use. |
| size | StackSizeType | hardcoded | Number of bytes to pop and send. |
| value | bytes | stack | Serialized data to send (popped from stack). |

14 changes: 14 additions & 0 deletions src/fpy/bytecode/directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
FwChanIdType = FpyType(TypeKind.U32, "U32")
FwPrmIdType = FpyType(TypeKind.U32, "U32")
FwOpcodeType = FpyType(TypeKind.U32, "U32")
FwIndexType = FpyType(TypeKind.I16, "I16")


ArrayIndexType = I64
Expand Down Expand Up @@ -66,6 +67,7 @@ def update_configurable_types_from_dict(type_defs: dict[str, FpyType]) -> None:
_update_configurable_type(FwChanIdType, type_defs, "FwChanIdType")
_update_configurable_type(FwPrmIdType, type_defs, "FwPrmIdType")
_update_configurable_type(FwOpcodeType, type_defs, "FwOpcodeType")
_update_configurable_type(FwIndexType, type_defs, "FwIndexType")
_update_configurable_type(FwSizeStoreType, type_defs, "FwSizeStoreType")


Expand Down Expand Up @@ -172,6 +174,7 @@ class DirectiveId(Enum):
POP_EVENT = 75
SET_SEED = 76
PUSH_RAND = 77
POP_SERIALIZABLE = 78


# ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -403,6 +406,17 @@ class PopEventDirective(Directive):
_FIELD_TYPES: ClassVar[dict[str, FpyType]] = {}


@dataclass
class PopSerializableDirective(Directive):
opcode: ClassVar[DirectiveId] = DirectiveId.POP_SERIALIZABLE
portIndex: int
size: int
_FIELD_TYPES: ClassVar[dict[str, FpyType]] = {
"portIndex": FwIndexType,
"size": StackSizeType,
}


# ─────────────────────────────────────────────────────────────────────────────
# Arithmetic stack op directives (no fields)
# ─────────────────────────────────────────────────────────────────────────────
Expand Down
4 changes: 3 additions & 1 deletion src/fpy/bytecode/grammar.lark
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ goto_tag: NAME ":"
| "store_abs_const_offset" _int _uint
| "get_field" _uint _uint
| "return" _uint _uint
| "pop_serializable" _int _uint

!op_with_no_args: "no_op"
| "siext_8_64"
Expand Down Expand Up @@ -78,8 +79,9 @@ goto_tag: NAME ":"
| "push_time"
| "push_rand"
| "set_seed"
| "call"
| "call"
| "peek"
| "pop_event"
_uint: [UNARY_IDENTITY] DEC_NUMBER
_int: NEG_DEC_NUMBER
_float: FLOAT_NUMBER
Expand Down
35 changes: 30 additions & 5 deletions src/fpy/codegen.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
IntegerZeroExtend8To64Directive,
OrDirective,
PeekDirective,
PopSerializableDirective,
FloatMultiplyDirective,
GetFieldDirective,
IntAddDirective,
Expand Down Expand Up @@ -1060,12 +1061,36 @@ def emit_AstFuncCall(self, node: AstFuncCall, state: CompileState):
assert const_val is not None, f"const arg {i} of {func.name} should have been validated by semantics"
const_arg_values[i] = const_val

# put non-const arg values on stack
for i, arg_node in enumerate(node_args):
if i not in func.const_arg_indices:
dirs.extend(self._emit_func_arg(arg_node, state))
# Special case for pop: push value arg, then emit directive
if func.name == "pop":
value_arg = node_args[1]
dirs.extend(self._emit_func_arg(value_arg, state))
value_type = state.contextual_types[value_arg]

# Get the port index from const args
# Semantics has already validated this is int or enum
port_val = const_arg_values[0]
if isinstance(port_val.val, int):
port_int = port_val.val
elif isinstance(port_val.val, str) and port_val.type.kind == TypeKind.ENUM:
# Enum constant: look up the integer value
port_int = port_val.type.enum_dict[port_val.val]
else:
# This should never happen - semantics validates port type
assert False, f"Invariant violation: port should be int or enum after semantics validation, got {port_val}"

# Get size from the value's type
# Semantics has validated this is a concrete type with computable max_size
size = value_type.max_size

dirs.append(PopSerializableDirective(portIndex=port_int, size=size))
else:
# put non-const arg values on stack
for i, arg_node in enumerate(node_args):
if i not in func.const_arg_indices:
dirs.extend(self._emit_func_arg(arg_node, state))

dirs.extend(func.generate(node, const_arg_values))
dirs.extend(func.generate(node, const_arg_values))
elif is_instance_compat(func, TypeCtorSymbol):
# put arg values onto stack in correct order for serialization
for arg_node in node_args:
Expand Down
11 changes: 11 additions & 0 deletions src/fpy/macros.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
ExitDirective,
FloatLogDirective,
PopEventDirective,
PopSerializableDirective,
PushTimeDirective,
SetSeedDirective,
PushValDirective,
Expand Down Expand Up @@ -211,4 +212,14 @@ def generate_randf(node: Ast, const_args: dict[int, FpyValue]) -> list[Directive
],
const_arg_indices=frozenset({0, 1}),
),
# Serial pop builtin — compile-time port index, any constant-size value
# The generate function is a placeholder; codegen.py handles this specially
"pop": BuiltinFuncSymbol(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't think pop is a clear name for what this does. Remember that the user of Fpy has no idea that there is a stack, that is an implementation detail. And pop is a stack term.

I would suggest calling this write_to_port or output_port or something.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just taking in from what nasa#5327 had, which was pop(). I also considered serial_pop(), but not sure if we wanted to go that route

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is okay for the bytecode to reference the stack, but the high level language shouldn't really know that it exists. In LLVM it won't exist anyways, so pop just won't make any sense. Plus, when I hear pop, I think: what are we popping, and what are we going to do with it? I think write_to_port or output_on_port is much more clear.

"pop", NOTHING, [
("port", None, None), # None means "any constant-size type"
("value", None, None), # None means "any constant-size type"
],
lambda n, c: [], # Placeholder; codegen.py emits the directive
const_arg_indices=frozenset({0}), # port must be compile-time constant
),
}
16 changes: 15 additions & 1 deletion src/fpy/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ConstCmdDirective,
Directive,
PopEventDirective,
PopSerializableDirective,
FwOpcodeType,
PeekDirective,
ExitDirective,
Expand Down Expand Up @@ -136,6 +137,8 @@ class DirectiveErrorCode(Enum):
STACK_UNDERFLOW = 15
INVALID_ARG = 16
CMD_FAIL = 17
SERIAL_PORT_NOT_CONNECTED = 18
SERIAL_PORT_INVALID_INDEX = 19


class FpySequencerModel:
Expand Down Expand Up @@ -488,7 +491,7 @@ def handle_store_abs_const_offset(self, dir: StoreAbsConstOffsetDirective):
return None

def handle_push_val(self, dir: PushValDirective):
if len(self.stack) + 8 > self.max_stack_size:
if len(self.stack) + len(dir.val) > self.max_stack_size:
return DirectiveErrorCode.STACK_OVERFLOW
self.push(dir.val)
return None
Expand Down Expand Up @@ -1305,3 +1308,14 @@ def handle_pop_event(self, dir: PopEventDirective):
if debug:
print(f"POP_EVENT [{severity_name}]: {message.decode('utf-8')}")
return None

def handle_pop_serializable(self, dir: PopSerializableDirective):
# Check stack has enough bytes
if len(self.stack) < dir.size:
return DirectiveErrorCode.STACK_UNDERFLOW
# Pop the bytes (in model, we just discard them like pop_event does)
popped_bytes = self.pop(type=bytes, size=dir.size)
if debug:
print(f"POP_SERIALIZABLE port={dir.portIndex} size={dir.size} bytes={popped_bytes.hex()}")
# In the model we don't have actual serial ports, so we just discard the data
return None
152 changes: 152 additions & 0 deletions src/fpy/semantics.py
Original file line number Diff line number Diff line change
Expand Up @@ -1943,6 +1943,11 @@ def resolve_args(
if value_expr not in state.synthesized_types:
continue

# Skip type check if arg_type is None (sentinel for "any type")
# Used by builtins like pop that accept any constant-size type
if arg_type is None:
continue

unconverted_type = state.synthesized_types[value_expr]
if not self.can_coerce_type(unconverted_type, arg_type):
return CompileError(
Expand Down Expand Up @@ -2002,6 +2007,9 @@ def visit_AstFuncCall(self, node: AstFuncCall, state: CompileState):
# These will be coerced when the function definition is visited.
if value_expr not in state.synthesized_types:
continue
# Skip coercion if target_type is None (sentinel for "any type")
if target_type is None:
continue
assert self.coerce_expr_type(value_expr, target_type, state)

state.synthesized_types[node] = func.return_type
Expand Down Expand Up @@ -2529,6 +2537,150 @@ def visit_AstFuncCall(self, node: AstFuncCall, state: CompileState):
)
return

# Special validation for pop after confirming args are const

@zimri-leisher zimri-leisher Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is hacky. What we actually want is for the normal semantic machinery of the compiler to handle this builtin for us, without having to special case it. Basically for any feature ever this should always be the case. But here, the existing semantic machinery can't express what we want. So the question is what things can we add to the language as a whole that can enable this builtin to work properly, without ever having a special case for it.

We already have the ability to define builtin functions, but the problem here is that we can't express the type of the value argument, as it should accept any type. So the obvious solution is to just add an Any type to Fpy. Then we can declare the builtin like pop(FwIndexType, Any) and be done with it.

However, there is one problem with this. We don't actually want to accept any time, we just want to accept any type which is serializable (which has a binary form). Some types, like the INTEGER or FLOAT type, have no binary representation (they only exist at compile time and cannot be written into the output file, instead they are coerced at compile time to a serializable type like I64 or F64). So instead what we actually have to do, I think, is define a Serializable type. It should not be accessible to the user, just an internal compiler thing.

Actually, even more specifically, it must be a Serializable type whose size we know at compile time. For instance, you could not push the value of a string telemetry channel out this port. So for now, let's call this a SizedType, meaning A) it has a binary representation and B) it has a statically known size of that binary representation. Anything except non-constant strings should be rejected. So make sure you can still output "asdf" for instance.

if func.name == "pop":
port_val = arg_values[0]
value_arg = resolved_args[1]

# Extract integer value from port_val
# Validate port value type - must be integer (reject float, Decimal, bool)
# Python bool is a subclass of int, so check bool first
if isinstance(port_val.val, bool):
state.errors.append(
CompileError(
"pop port argument must be an integer or enum constant, not bool",
resolved_args[0] if is_instance_compat(resolved_args[0], Ast) else node,
)
)
return
elif isinstance(port_val.val, (Decimal, float)):
state.errors.append(
CompileError(
f"pop port argument must be an integer or enum constant, not float/Decimal",
resolved_args[0] if is_instance_compat(resolved_args[0], Ast) else node,
)
)
return
elif isinstance(port_val.val, int):
# Integer value - OK (includes abstract INTEGER type from literals)
port_int = port_val.val
elif isinstance(port_val.val, str):
# Enum constant: look up the integer value from the enum dict
if port_val.type.kind == TypeKind.ENUM and hasattr(port_val.type, 'enum_dict'):
port_int = port_val.type.enum_dict.get(port_val.val)
if port_int is None:
state.errors.append(
CompileError(
f"Unknown enum value '{port_val.val}' in {port_val.type.name}",
resolved_args[0] if is_instance_compat(resolved_args[0], Ast) else node,
)
)
return
else:
state.errors.append(
CompileError(
"pop port argument must be an integer or enum constant",
resolved_args[0] if is_instance_compat(resolved_args[0], Ast) else node,
)
)
return
else:
# Reject any other type
state.errors.append(
CompileError(
f"pop port argument must be an integer or enum constant, got {type(port_val.val).__name__}",
resolved_args[0] if is_instance_compat(resolved_args[0], Ast) else node,
)
)
return

# Get MAX_SERIAL_PORTS from the dictionary enum
serial_port_enum = state.type_defs.get("Svc.Fpy.SerialPortIndex")
if serial_port_enum is None:
state.errors.append(
CompileError(
"Svc.Fpy.SerialPortIndex enum not found in dictionary",
node,
)
)
return

max_ports = serial_port_enum.enum_dict.get("MAX_SERIAL_PORTS")
if max_ports is None:
state.errors.append(
CompileError(
"MAX_SERIAL_PORTS not found in Svc.Fpy.SerialPortIndex enum",
node,
)
)
return

if not (0 <= port_int < max_ports):
state.errors.append(
CompileError(
f"pop port index {port_int} out of range [0, {max_ports})",
resolved_args[0] if is_instance_compat(resolved_args[0], Ast) else node,
)
)
return

# Validate value is constant-sized
if is_instance_compat(value_arg, FpyValue):
value_type = value_arg.type
else:
value_type = state.synthesized_types.get(value_arg)
if value_type is None:
state.errors.append(
CompileError(
"Cannot determine type of pop value argument",
value_arg,
)
)
return

# Reject abstract INTEGER/FLOAT types (bare literals without a concrete type)
# These have no computable max_size and would crash codegen
if value_type.kind in (TypeKind.INTEGER, TypeKind.FLOAT):
state.errors.append(
CompileError(
f"pop value must have a concrete type (e.g., U32(42) not 42). "
f"Bare numeric literals are not allowed.",
value_arg if is_instance_compat(value_arg, Ast) else node,
)
)
return

# Reject anonymous/void kinds that are not serializable and would
# crash codegen (anon struct/array literals, ranges, void values).
# INTERNAL_STRING is intentionally excluded here so that bare
# string literals fall through to the constant-size gate below and
# produce the "constant-sized" error message users expect.
if value_type.kind in (
TypeKind.ANON_STRUCT,
TypeKind.ANON_ARRAY,
TypeKind.RANGE,
TypeKind.NOTHING,
):
state.errors.append(
CompileError(
"pop value must be a concrete, serializable value "
"(a variable, a type constructor like Ref.SignalPair(...), "
"or a cast); anonymous literals, ranges, and void "
"expressions are not allowed.",
value_arg if is_instance_compat(value_arg, Ast) else node,
)
)
return

if not is_type_constant_size(value_type):
state.errors.append(
CompileError(
f"pop value must be constant-sized (no strings), found {value_type.display_name}",
value_arg if is_instance_compat(value_arg, Ast) else node,
)
)
return

if unknown_value:
# we will have to calculate this at runtime
state.const_expr_values[node] = None
Expand Down
Loading
Loading