-
Notifications
You must be signed in to change notification settings - Fork 10
01 Lucid language
This page exists to document (and in some cases explain) featured of the Lucid language. It is not intended as a tutorial on the language, but rather as a glossary/quick reference.
An event declaration has the form event foo(type1 id1, type2 id2, ..., typeN idN);
. This declares a kind of event named foo
, which takes N
arguments of types type1, type2, ..., typeN
.
Event values are created by "calling" the name of an event like a function, e.g. event x = foo(arg1, arg2, ..., argN)
creates an event value representing a foo
event, containing the values of arg1, ..., argN
, and stores that value in the variable x
.
Events are generated by generate statements that serializes events and put them into queues destined for different locations. There are three kinds of generate statements:
-
generate_port (n, x);
takes an eventx
and an integern
, serializesx
into a packet, and places the packet into the outbound queue for portn
of the switch. -
generate_ports (g, x);
takes an eventx
and a groupg
of ports (see Groups below) and sendsx
through each port in the group. -
generate(x);
queuesx
to be handled at the current switch by sendingx
to the switch's recirculation port. It is syntactic sugar forgenerate(recirculation_port, x);
.
Marking an event declaration with the packet
keyword streamlines its serialized packet format. For instance, packet event foo(type 1 id1, type 2 id2, ... typeN idN);
packs the foo
event's arguments (id1, id2, ..., idN
) directly into the packet, omitting extra wrappers or tags.
This keyword enables Lucid programs to create events resembling various packet types, understandable by non-Lucid systems. An example is packet event eth_ip(eth_hdr_t eth, ip_hdr_t ip, Payload.t pl);
, which allows Lucid to generate standard IP packets.
However, packet
events require custom parsers, as Lucid won't automatically generate code to deserialize these packets back into events. Refer to the Parsers section for details on writing these parsers.
The parser of a Lucid program defines how to extract packet
events from packets. A Lucid program that defines packet events should always contain a parser, otherwise the packet events will never be handled.
A parser is declared with a name, arguments, and a parser block. A program can declare multiple parsers, and a parser can call any previously declared parsers. A program with parsers must declare a main
parser, which is where parsing begins. The main parser has the following restrictions:
1. The `main` parser must have a single argument of type `bitstring`.
1. The `main` parser must begin by extracting an ethernet header from the packet, followed by a match statement that branches the builtin parser `do_lucid_parsing` when the ethertype field (i.e., the last 16-bits extracted) has a value equal to the builtin `LUCID_ETHERTY`. `do_lucid_parsing` calls Lucid's auto-generated parser for all non-packet events.
Here is an example parser:
parser main(bitstring pkt) {
int<48> d = read(pkt);
int<48> s = read(pkt);
int<16> t = read(pkt);
match t with
| LUCID_ETHERTY -> {do_lucid_parsing(pkt);}
| _ -> {
skip(32, pkt);
int<16> csum = hash<16>(checksum, d, s, t);
generate(eth_with_csum(csum, d, s, t, Payload.parse(pkt))); }
}
This parser takes an unparsed packet bitstring; reads the three fields of an ethernet header; and then matches on the extracted ethertype field, branching to either the parser "do_lucid_parser" or a parser block that skips the next 32 bits of the packet, calculates a checksum of the ethernet headers, and then exits by generating an event eth_with_csum, which carries the checksum, ethernet header fields, and finally a Payload.t
value containing the rest of the packet.
A parse action is one of three commands:
-
read(pkt)
, which extracts some number of bits from the bitstringpkt
. The number of bits is determined by the output type, so for example inint<48> d = read(pkt)
, read extracts the first 48 bits ofpkt
intod
). Bits that are extracted byread
get popped off of the front ofpkt
. -
skip(n, pkt)
, which popsn
bits off of the front of the bitstringpkt
without returning them. -
hash<16>(checksum, a1, a2, ..., an);
, which computes a 16-bit ip checksum over the variablesa1, a2, ..., an
Each parser block ends with a step, a mandatory control flow statement. A step is one of the following:
- a generate statement, where
generate(foo(a1, a2, ..., an));
produces the eventfoo
and exists parsing (i.e., it causes the parser to jump to the handler forfoo
). - a call to another previously declared parser, e.g.,
my_parser(a1, a2, ..., an);
- the drop command,
drop;
, which drops the packet without generating an event - a
match
statement that branches to multiple parse blocks.
-
bitstring
values represents an unparsed bitstring. They are used as parser arguments, as arguments to parser actions, and an argument to thePayload.parse
function, which converts abitstring
into aPayload.t
value. - A
Payload.t
value stores the unparsed tail of a packet. Events that carry payloads must have aPayload.t
as their last argument. Note that while events can carry payloads, there is currently no way to construct them besides with thePayload.parse
function in the parser.
The Lucid frontend runs a "slot analysis" pass that places certain restrictions on how variables extracted from a packet can be used as event arguments, to ensure that the Lucid compiler can generate a parser that does not need to copy data.
At a high level, each variable set by a read
command in the parser gets assigned to a "slot", representing a memory location. When we use a variable x
as the nth argument of an event, we are really just declaring that the nth
argument of the event is the slot containing x
.
The slot analysis pass ensures that:
- Each event argument only references a single slot across the entire parser. For example, if we have
generate(foo(x1, ...));
in some branch of the parser, we may not havegenerate(foo(x2, ...))
in another branch. - no parameters in an event reference the same slot. For example, we cannot have
generate(foo(x1, ..., x1));
orgenerate(foo(x1, ...));
in one branch andgenerate(foo(..., x1));
in another branch. (Note that you can work around this restriction by duplicating events by hand, e.g., we could havefoo(x1, ...);
in one branch andfoo_alt(..., x1)
in another branch.)
Every event must have a corresponding handler, which defines the actions to take when that event is received. A handler definition has the form handle foo(type1 id1, type2 id2, ..., typeN idN) { ... }
, and requires that an event with name foo
and identical argument types has already been declared.
An event may be declared simultaneously with its handler using the syntax event foo(type1 id1, type2 id2, ..., typeN idN) { ... }
. Doing this behaves identically to declaring the event then immediately defining its handler.
To define an event that is intended to never be processed by the Lucid program (for example, to represent a message to a network control server), use the skip;
statement, which is a noop -- event foo(type1 id1, type2 id2, ..., typeN idN) {skip;}
.
A normal event handler triggers when an event arrives at the switch. Egress handlers are functions that transform events as they leave the switch. Denote a handler as an egress handler by tagging it with @egress
.
Egress handlers are designed to run after the queueing subsystem of a switch, and as such they have the following restrictions:
- egress handlers must use diffrent globals than ingress handlers.
- an egress handler cannot change the destination of an event, and it can only generate 1 event per control flow, with the base
generate
statement.
Finally, please note that egress handlers are an experimental feature. While they are implemented and tested in both the interpreter and compiler, they may be replaced by a higher level or more general abstraction in the future.
The Lucid interpreter supports the following additional event features, which may be depreciated in the future.
Each event value also contains a delay, which represents an amount of time (in nanoseconds) to wait after generation before placing the event in its outbound queue. The delay is 0 by default, but may be changed using the builtin function Event.delay
. Specifically, the expression Event.delay(e, n)
returns a new event with the same arguments as e
but with the delay field set to n
.
generate_switch (n, x);
takes an event x
and an integer n
, and generates x
the switch with id n
. If n
is the current switch, the event is queued at the current switch's recirculation port; otherwise, the event must be delivered by an implementation specific mechanism that determines the output queue.
Globals (also known as global variables) represent mutable state in a program that persists across event handler executions and is accessible from any event handler or function. Global declarations have the form global <ty> foo = <constructor>(<args>)
. Currently, there are four builtin global type: Arrays, Paired Arrays, Counters, and Tables. Users can also define their own global types (see the User Types section of this document).
The declaration global Array.t<<size>> arr = Array.create(length)
declares a new array with length
entries, each of which is a size
-bit integer. Arrays of non-integer types are not supported. The following builtin Array operations exist:
-
Array.get(arr, idx)
returns the value stored in indexidx
of arrayarr
. -
Array.set(arr, idx, v)
stores the valuev
at indexidx
of arrayarr
. -
Array.getm(arr, idx, getop, getarg)
is likeArray.get
, but applies the memopgetop
to the stored value before returning it, usinggetarg
as the second argument. -
Array.setm(arr, idx, setop, setarg)
is likeArray.set
, but applies the memopsetop
to the stored value and stores the result, usingsetarg
as the second argument. -
Array.update(arr, idx, getop, getarg, setop, setarg)
combinesArray.getm
andArray.setm
: if the stored valued isv
, it returnsgetop(v, getarg)
and replacesv
with the result ofsetop(v, setarg)
. -
Array.update_complex(arr, idx, memop, arg1, arg2, default)
is similar toArray.update
, but takes a three-argument memop instead of 2 two-argument memops. For full details, see the section on memops.
The declaration global PairArray.t<<size>> arr = PairArray.create(init)
declares a new paired array of length init
, which contains two values of size size
at each index. There is only one operation on paired arrays:
-
PairArray.update(arr, idx, memop, arg1, arg2, default)
takes a 4-argument memop and applies it to the values stored in the array atidx
, along with local valuesarg1
andarg2
.default
is the value to return if the memop does not return a value.
The declaration global Counter<<size>> counter = Counter.create(init)
declares a new counter, whose value has size size
and has initial value init
. Counters have only a single operation:
-
Counter.add(c, i)
addsi
to the value stored in counterc
, and returns the original stored value.
Tables are like match statements that you can update dynamically at runtime by adding new branches.
A table is a data structure that stores a list of records. Each record in a table contains a key pattern, a data value, and a special type of function called an action.
The primary operation on a table is a lookup:
ret_ty result = Table.lookup(tbl, key, arg);
This table lookup call will iterate over the records in tbl
, finding the first one with a key pattern that matches key
. That matching record will contain an action function and, optionally, some data. Table.lookup will call the action function, passing it the data and the arg
parameter. If no action is found, the table will run a default action.
As psuedocode:
def lookup(tbl, key, arg):
for i in range(len(tbl.records)):
entry = tbl.records[i]
if (key == entry.key): # match!
data = entry.data
action = entry.action
return action(data, arg)
# no matches, run default action
return tbl.default_action(tbl.default_data, arg)
The Table type has 4 arguments.
Table.t<<key_ty, data_ty, arg_ty, ret_ty>>
-
key_ty
is the type of the key that the table matches on. -
data_ty
is the type of a data value that each entry in the table stores and passes to its action when lookup is called. -
arg_ty
is the type of the argument to the table's actions. -
ret_ty
is the return type of the table's actions.
All of a table's actions must have the same data, arg, and return types.
Table.create
creates a table. The declaration:
global Table.t<<key_ty, data_ty, arg_ty, ret_ty>> tbl = Table.create(sz, actions, default_action, default_data);
Creates the table tbl
of type Table.t<<key_ty, data_ty, arg_ty, ret_ty>>
, with sz
entries that each store a key value, data value, and action function value of type data_ty -> arg_ty -> ret_ty
. Action functions have a slightly different syntax -- see the section on Action functions for more details.
The table will also have a default entry that stores default_action
and default_data
, which is applied on Table.lookup
if no other entries match.
Table.install(tbl, key, acn, data);
This installs an entry into tbl
that matches on key
and calls acn
with first argument data
.
Table.install_ternary(tbl, key, mask, acn, data);
This installs a masked entry into tbl
. A masked entry only considers the masked bits of the key at lookup time. In other words, if Table.lookup(tbl, k, arg);
is called, the masked entry installed above will match when key && mask == k && mask
.
There are also two other ways to update a table. First, the interpreter also supports a table install command event. For example, including the following event in the interpreter's input event list will install the same entry as the above command:
{"type": "command", "name":"Table.install", "args":{"table":"tbl", "key":["0<<32>>"], "mask":["3<<32>>"], "action":"tbl.acn", "args":[5]}},
See the section on interpreter commands for more details.
Finally, for hardware targets like the Tofino, Lucid does not provide a builtin way to install entries into tables. This is because table installation on a hardware pipeline must be done by a control CPU using a vendor-provided driver.
For hardware targets, the programmer must write their own control program to install table entries as needed. To facilitate this, the Lucid compiler generates a globals directory. The entry in the globals directory for a table tbl
provides all the information that a user needs about the implementation of tbl
in the compiler-generated code in order to write a custom control plane program to update it.
For example, the Tofino compiler generates the following entry for table tbl
in its globals.json
, which tells the programmer the names and type information of the P4 objects that implement tbl
and its actions.
"tbl": {
"type": "table",
"compiled_name": "pipe.ingress_hdl.tbl",
"length": 1024,
"keys": [
{ "compiled_name": "tbl_0_key", "size": 32, "matchtype": "ternary" },
{ "compiled_name": "tbl_1_key", "size": 32, "matchtype": "ternary" },
{
"compiled_name": "$MATCH_PRIORITY",
"size": 32,
"matchtype": "exact"
}
],
"actions": [
{
"name": "action_constr",
"compiled_name": "ingress_hdl.action_constr",
"install_arg_sizes": [ 32 ]
}
]
}
See the Section on the globals directory for more information.
Since global variables are stored in memory in the packet processing pipeline, there's a natural order in which they must be used -- once a packet has passed the stage storing the global, it cannot access that global without recirculation. Since handlers are processed in a single pass of the pipeline, this means that each handler must access the global variables in a consistent order. Furthermore, each global can only be accessed once, since the packet moves to the next stage immediately afterward.
Lucid makes the assumption that globals will be laid out in memory in the order they are declared. The type system will automatically verify that each handler and function accesses globals in that order, and at most once. For the purposes of this system, an "access" is any call to the functions defined in the previous two parts.
When and event or function is declared, it may optionally be annotated with a list of constraints: these lists have the form [x < y; ...; z < w]
, and occur immediately after the parameters to the event/function are declared. The identifiers x, y, z, w
must be either parameters with a global type, or the names of previously-declared global variables. These constraints specify the ordering relationship between the arguments; i.e. the constraint x < y
means that x
comes before y
in the order.
Because Lucid functions are non-recursive, we are able to easily infer all necessary constraints for function bodies. Thus constraint annotations on function declarations are optional -- they serve only to document the code or to artificially restrict which globals may be passed as arguments to the function. The exception is function declarations in module interfaces, which must have all constraints explicitly spelled out.
Handlers are more complicated. Instead of inferring the constraints, we rely on the user to supply all necessary information. Most handlers do not require any constraints (including all events which take no global arguments); however, if a handler does use multiple global variables in its body then the user is responsible for ensuring that all necessary constraints are included.
Lucid supports integers of varying size, specified using the <<size>>
syntax. For example, 3<<16>>
represents the value 3 as a 16-bit integer. Similarly, the type int<<8>>
is the type of 8-bit integers.
A size may have three forms:
- An integer (e.g.
<<16>>
) - A user-defined size (e.g.
<<my_size>>
); see Size Declarations below - A sum of sizes (e.g
<<my_size + 7>>
)
If a size is omitted, it usually defaults to <<32>>
. However, note that integer values typically do not need sizes; e.g. int<<16>> x = 3
will store a 16-bit representation of 3 in x
.
Sizes can be turned into regular integers using the special operator size_to_int
, e.g. size_to_int(a)
returns an integer whose value is the same as a
. The reverse operation is not possible, since integers may be dynamic and sizes must be known at compile time.
Lucid allows users to declare size variables at top level. There are two ways of doing this:
- Constant size declarations have the form
size foo = ...;
, where...
is any valid size. - Symbolic size declarations have the form
symbolic size foo;
.
Symbolic sizes must be specified in a .symb
file prior to compilation [TODO: link]. Since we expect users may want to tweak the values of their size variables between runs of a program, both types of size variable will be treated as different from every other size. For example, the following snippet will not compile:
size a = 16;
int<<a>> x = 3;
int<<16>> y = 17;
x + y; // Type error: size a is not considered the same as 16!
Memops (Memory Operations) are special functions designed to fit into a single stateful ALU. They are declared using the syntax memop foo(int<<'a>> id1, int<<'a>> id2, ...) { ... }
. Note that memops cannot be called directly, and exist only to be used as arguments to Array operations.
Memops have several syntactic restrictions to ensure that they can fit into an ALU. Each memop takes either 2, 3, or 4 arguments, and the number of arguments determines the restrictions on the body. In all memops, only the following operations are allowed: +, -, &, |, =, !=, <, >, &&, ||, !
The simplest kind of memop are used as parameters to Array.update
, and take two arguments. The first argument is the value of the array cell, and the second is a local variable passed in as an argument to Array.update
. Two-argument memops have two forms: a single return statement, or a single if statement with a single return in each branch. They have the additional restriction that each expression may use memval
and localval
at most once each.
memop foo(int memval, int localval) {
return <e>;
}
memop foo(int memval, int localval) {
if (<e>) then { return <e>; } else { return <e>; }
}
The most general type of memop is used for accessing paired arrays via PairArray.update
, and takes four arguments: the first two are the values at the array index, and the second two are local values passed as additional arguments to PairArray.update
. It also has two built-in variables named cell1
and cell2
; the final value of these variables will be written back to the first and second cells at the array index, respectively. Four-argument memops should always have the following form, except for omissions described in the comments:
Memop foo(int memval1, int memval2, int localval1, int localval2) {
bool b1 = <boolexp>; // May be omitted
bool b2 = <boolexp>; // May be omitted
// May be omitted entirely, or just the else branch may be omitted
if (<cond>) { cell1 = <return_exp> } else
{ if (<cond>) { cell1 = <return_exp> }
// May be omitted entirely, or just the else branch may be omitted
if (<cond>) { cell2 = <return_exp> } else
{ if (<cond>) { cell2 = <return_exp> }
// May be omitted. No else branch is permitted
If (<cond>) { return <local_exp> }
// Default return value is passed as a parameter to PairArray.update
}
Each expression type may only use one of memval1
and memval2
, and only once. The same is true for localval1
and localval2
. The different kinds of expression are:
- : A comparison
- <return_exp>: An arithmetic operation
- : A boolean combination of
b1
andb2
. - <local_exp>: Must be one of the variables
cell1
,cell2
,memval1
, ormemval2
.
Three-argument memops are used as arguments to Array.update_complex
. They are more general than two-argument memops, and take three arguments: the value of the array cell, and two local variables. Their bodies are identical to those of four-argument memops, except that the memval2
argument does not appear and hence may not be used. The value assigned to cell1
will be written back to the array at the index; the value of cell2
is ignored, except that it may be returned in the final return statement.
Functions in Lucid are declared using the syntax fun rty foo(type1 idN, ..., typeN idN) { ... }
, which declares a function foo
with return type rty
. Functions may contain arbitrary statements in their bodies. Functions may also be polymorphic; see the section "Polymorphic types and sizes" for more details.
Functions which return global types are not currently supported.
Actions in Lucid are special functions that users do not call directly, similar to memops. An action is essentially an expression with arguments: the body of an action may only have a single return statement.
In Lucid, actions are not constructed directly. Instead, programmers declare action constructors. For example:
action_constr mk_my_acn(bool x) = {
return action res_t _ (int a) {
return {val = a; is_hit = x};
};
};
This creates an action constructor named mk_hit_acn
which takes a single parameter x and returns an anonymous (un-named) action that takes a single integer a
as its argument and returns a record of type res_t
. The name of the returned action does not matter, because user-code cannot ever reference it.
Notice that the argument of the action constructor, x
, is available within the body of the action that it creates. This is the entire point of action constructors -- it provides the flexibility to bind some of the variables defined in the action at install time (when an action is installed into a table) and other variables at match time (when an action is match during packet processing).
Action constructors are passed as arguments to table_create
, to define the domain of actions that the table supports and the table's default action, and also passed to table_install
, to construct new actions.
Match statements in Lucid represent TCAM tables with static rules. They have the form match (e1, ..., eN) with | pat1 -> { ... } | pat2 -> { ... } | ...
. Here, e1
through eN
are integer expressions, and each pattern should have N
entries (or be the single wildcard pattern _
).
The valid patterns are:
- Integers: An integer (e.g.
10
or0b1010
). Integers match this pattern only if they have the exact listed value. - Wildcard: An underscore (
_
). Integers always match this pattern. - Bitstring: A string of bits where some bits are replaced with an asterisk, e.g.
0b00*0
or0b**10
. Integers match this pattern if they have the same non-asterisk bits. - Variable: A constant or symbolic variable. Integers match this pattern if they have the same value as the variable. Using local variables in patterns is not supported.
The match statement executes the first branch where ALL components of the pattern match the corresponding input integer. If no branch matches, the program will error.
Constants are defined using the syntax const <type> foo = ...;
. They represent unchanging values that are defined throughout the program. In a constant definition, <type>
must not be global; if this is desired, a global declaration should be used instead.
Externs are defined using the syntax extern <type> foo;
. They represent constant values which are not baked into the program. When the program is simulated or compiled, a value for each extern must be supplied. If the program runs on multiple switches, each switch may have different values for each extern. Externs cannot have a global type.
Symbolics are defined using the syntax symbolic <type> foo;
. They are identical to externs, except their values must be provided in a .symb
file [TODO: link] rather than an interpreter or compiler specification file.
The following types and associated values exist in Lucid:
-
void
: No associated values -
bool
: Booleans -
int<<size>>
:size
-bit integers. Note thatint
is an alias forint<<32>>
-
event
andmevent
: Single- and multi-cast events, respectively -
memop<<size1, size2>>
: Memops. The first argument isint<<size1>
, the second isint<<size2>
, and the return type isint<<size1>>
-
group
: Multicast groups -
Array.t<<size>>
: Arrays ofsize
-bit integers -
Counter.t<<size>>
: Counter containing asize
-bit value -
{type1 label1; ...; typeN labelN}
: Ordered records. -
type[size]
: A vector of lengthsize
containing values of typetype
.
A type is global if it is Array.t or Counter.t, or if it contains a global type.
If the user doesn't wish to type out a type, or wishes to write a polymorphic function, they may either use the auto
keyword or type a polymorphic identifier instead of a regular type. These identifiers begin with a tick ' (e.g. 'a
, 'foo
), and represent a "hole" that the typechecker will attempt to fill automatically. For example: 'a x = true;
is equivalent to writing bool x = true;
, since the typechecker will infer that x
must be a boolean.
This strategy may also be applied to sizes: for example, int<<'a>> x = 10<<16>>;
is equivalent to int<<16>> = 10<<16>>;
. However, int<<'a>> x = 10;
will create an integer of unknown size, which may be refined later depending on how the variable is used.
If polymorphic types or sizes are used in a function declaration, and the typechecker determines that they may take any value, the function itself is called polymorphic and may be applied to any types which match the declaration. For example, the definition fun int<<'a>> add1(int<<'a>> x) { return x + 1; }
creates a polymorphic function add1
which may be applied to integers of any size.
Lucid allows users to define their own types to facilitate abstraction and code re-use. For examples of creating and using a type, see here. This is done with the syntax type foo = ...
, where ...
is any valid type. Record types must be defined this way before they are used.
Users may also define constructors, using the syntax constr <type> foo(<args>) = <expression>
. Constructors are necessary for creating new instances of global types. Once a constructor is defined, it may be used to create a global value just like a builtin constructor: global my_type x = constr_name(<<args>>)
.
Multicast groups are sets of ports which are used in the generate_ports
statement. There are two ways to define a multicast group:
- The expression
{0,4,7}
specifies a group value with the entries 0, 4 and 7. Any number of entries are allowed, but all entries must be constant integers (i.e. the syntax{0, 4, port_id}
is not allowed) - The expression
flood x
takes an integerx
and generates a group corresponding to every port except x. Unlike group value expressions,x
is allowed to be computed dynamically (i.e.flood port_id
is allowed).
Lucid permits users to declare ordered records using the syntax {type1 label1; ...; typeN labelN}
. Each record is a collection of fields indexed by several strings (or labels). The example syntax above has fields label1
through labelN
of types type1
through typeN
, respectively. If any entries are global, their relative order is equal to their declaration order.
Record values may be created from scratch by supplying values for each field, with the syntax {label1 = exp1; ...; labelN = expN}
. If foo
is a record value, the syntax {foo with label3 = exp3'; label4 = exp4'}
will create a new record which has the same entries as foo
, except that label3
now maps to exp3'
and similarly for label4
.
Records are immutable; there is no way to modify the fields of an existing record.
The fields of a record may be projected using the #
operator; for example, foo#label2
retrieves the field of foo
corresponding to label2
.
Lucid permits users to declare fixed-length vectors using the syntax <type>[<size>]
; for example, int[4]
is type of vectors of length 4 whose entries are int
s. Any valid size expression may be used, including size variables and polymorphic sizes (e.g. int['a]
).
Vectors of a known size may be created by writing a vector value directly, e.g. [0; 3; 5; 7]
creates a vector of type int[4]
. Vectors of any size may be created with a comprehension, using the syntax [exp for var < size]
. This creates a vector of length size
, where each entry is obtained by evaluating exp
with the variable var
set to the current index. Like records, vectors are immutable.
Vector entries may be accessed with the syntax v[idx]
to retrieve the value corresponding to idx
from vector v
, where idx
is a size (not just any integer). However, in order to ensure there are no out-of-bounds errors, the compiler must be able to statically determine that idx
is a valid index. Currently, this means one of two things is true:
-
v
has known length andidx
is a constant integer, or -
idx
was bound using the syntaxidx < size
as part of a loop or comprehension, wheresize
is the length ofv
.
Lucid programs may contain for loops using the following syntax: for (var < size) { ... }
. This will execute the body of the loop size
times, with the size var
bound to 0 in the first iteration, then 1, etc. Recall that var
may be turned into an integer using the size_to_int
operator, which may be useful if the user wishes to e.g. test if this is the first iteration or not.
Lucid contains the following operators:
- +, -: Integer addition and subtraction. Both arguments must have the same size.
- |+|, |-|: Saturating integer addition and subtraction. Both arguments must have the same size.
- <, >, <=, >=: Integer comparison. Both arguments must have the same size.
- &, |, ~, ^^: Integer bitwise and, or, not, and xor, respectively. All arguments must have the same size.
- <<<, >>>: Integer bit-shifting. The arguments may be different sizes
- <, >, <=, >=: Integer comparison. Both arguments must have the same size.
- ==, !=: Equality checking. Both sides must have the same type.
- &&, ||, !: Boolean operations.
- ^: Integer concatenation.
x ^ y
is an integer containing all ofx
's bits followed by all ofy
's bits. - (int<>): Casting.
(int<<size>>)x
casts the integerx
to havesize
bits. - [m:n]: Integer slicing.
x[m:n]
slices returns the integer formed from the mth through nth bits (both inclusive) of the integerx
, where m and n are sizes.m
must be less than the size ofx
, andn
must be "obviously less than"m
. This means that eitherm
andn
are both constant integers, orm
has the formn + k
for some sizek
.
Lucid currently has several builtin variables. There are two kinds: global builtins have the same value throughout a program (though they may vary if the same program is run on multiple switches), while local builtins are bound at the beginning of each handler.
-
self
: An integer containing the id of the switch the program is running on. -
recirculation_port
: An integer containing the id of the recirculation port.
-
this
: The event value which spawned the handler with its original arguments (and delay 0). -
ingress_port
: An integer storing the port the current event came in on.
The printf statement is mainly for use in the interpreter. It uses relatively standard printf formatting: the first argument is a string, which may contain several instances of %d
(integers) or %b
(booleans) in its body. For each %d
it expects to get one integer as an argument, and similarly for %b%. The statement prints the string to the console, replacing instances of
%dwith its integer arguments and
%b` with its boolean arguments.
For example, printf("Is %d > 10? Answer: %b", 3, 3 > 10)
will print the string "Is 3 > 10" Answer: false" to the console.
The hash
expression has the form hash<<size>>(seed, arg1, arg2, ...)
. The seed
argument should be an integer; there may be any number of other arguments, which may be of any type. The expression performs a seeded hash of its inputs, and returns a size
-bit integer.
A 16-bit hash expression that uses the builtin seed checksum
, e.g., hash<16>(checksum, arg1, arg2, ...)
, computes the standard ipv4 ones-compliment checksum.
Checksums can be used in parsers and handlers. In the Lucid-Tofino compiler, you can implement a checksum that executes in the Tofino's deparser block by inlining the checksum expression into the generate statement. See this example for more details.
Finally, note that checksums are not currently implemented in the Lucid interpreter.
There are currently two system functions in Lucid:
-
Sys.time()
returns the current time since the program started running, in nanoseconds -
Sys.random()
returns a random 32-bit integer. -
Sys.dequeue_depth()
returns the depth of the queue that a generated event visited. This is only useable in egress handlers, and not currently implemented in the Lucid interpreter.
Lucid allows users to declare modules to create useful abstractions and separate concerns. A module is declared using the syntax module Foo { <declarations> }
. Identifiers defined inside the module may be referenced outside the module by prefixing them with the module name, e.g. Foo.x
. For an example, see here.
Modules may also specify an interface, which is a more abstract list of some or all of the things defined in the module. Interfaces are also demonstrated in the above example. If no interface is specified, then every entry in the module will be added to the global environment; otherwise, only the information contained in the interface will be added.
Most interface entries are the same as the corresponding declaration, except with the body removed. Some exceptions:
- Global type declarations may optionally hide the list of labels for that type, in which case users will be unable to directly access those labels from outside the module. If the list of labels remains, then users can use the projection operator as usual.
- Function declarations in an interface must be annotated with all relevant ordering information, including the start and endpoints (likely in terms of the arguments). These constraints must provide a total order between all global arguments to the function, and may include the constraints
start <= x
andend y
. The latter constraints mean that the function will expect the global object x to be unused before it executes, and ends having consumed all global arguments through y (inclusive). If the function does not consume any global arguments, then starting and ending constraints should (and must) be omitted.
If two modules have the same interface, Lucid allows users to select between them at compile time, using the syntax
module Foo = Bar if b else Baz
. Here, b
must be a symbolic or constant boolean. If b
is true, then module Foo
will be treated as identical to Bar
; otherwise, Foo
will be the same as Baz
.