-
Notifications
You must be signed in to change notification settings - Fork 10
User Guide
piplin supports several of Clojure's literals: booleans, numbers, keywords. piplin adds bit literals, with the following syntax for the bits in 0xf3
: #b1111_00_11
. The #b
prefix starts a bit literal. _
is skipped over when reading to permit easier visual grouping.
piplin adds several new types: bit arrays, enums with controllable bitwise representation, bundles (aka structures), tagged unions, and unsigned integers of fixed bit width that modulo on over/underflow.
All types can be used as functions on their value to create a new typed piplin object. For example, to represent a vector of 0s and 1s as a bit array, use ((bits 4) [0, 0, 1, 1])
. Some types, like bundles and unions, may instead need to be casted, like (cast my-bundle {:a 1, :b 2})
if the values must be recursively converted to appropriate types.
To get the type of an object, use typeof
. To get the bit width of a type, use bit-width-of
on the type (not the object). Types are regular Clojure records and are thus fully introspective.
In piplin, boolean values are represented with Clojure and Java's true
and false
. Their type is (anontype :boolean)
.
To create a bits type instance n
bits wide, use (bits n)
. (bit-slice bit-inst low high)
where low is inclusive and high is exclusive returns a subrange of the given bits. (bit-cat & bits)
returns the concatenation of any number of bits. (serialize obj)
will convert any object to its bits representation if it is serializable. (deserialize type bits)
returns bits deserialized according to the given type. serialize
and deserialize
are used at the boundaries of piplin programs to meet binary protocols.
(enum #{:x, :y, :z})
creates an enum from the given set (#{...}
is a set literal in Clojure, :foo
is a keyword named foo, like Ruby). enum
s can also be specified with a map, which is useful for decoding symbolic data (e.g. (enum {:add #b001, :sub #b010, :noop #b000})
to decode an enum from incoming bits.
(def my-enum (enum #{:a, :b, :c})) ;define an enum called my-enum
(my-enum :a) ;this returns an object with type my-enum and value :a
(= (my-enum :a) :b) ;returns false because :b's type is inferred as my-enum
(= (my-enum :a) :foo) ;throws an exception because :foo is not a member of my-enum
;define my-union, which will be 4 bits wide (bits 3) + 1 bit tag needed
(def my-union (union {:foo (bits 3),
:bar (anontype :boolean)}))
(cast my-union {:foo 2}) ;works --needs cast to recursively convert 2
(cast my-union {:baz 3}) ;throws exception
(def my-union-var (cast my-union {:foo 0}))
(union-match my-union-var ;pattern match on the above var
(:foo x x) ;if it's tagged :foo, bind value to x, return x
(:bar q (mux2 q ;if it's tagged :bar, bind value to q, if q is true
1 2))) ;return 1 else 2
;define my-bundle, which will be 5 bits wide
(def my-bundle (bundle {:slot1 (bits 3) :slot2 (enum #{:foo :bar :baz})))
;note that these types are composable, anonymously or bound to functions.
(def my-bund-instance (cast my-bundle {:slot1 4, :slot2 :baz})) ;works
;supports Clojure's destructuring binding
(let [{bit :slot1, key :slot2} my-bund-instance]
;in here, bit is a (bits 3) and key is an (enum #{:foo, :bar, :baz})
)
Note that bundles and unions are fully composable with pattern matching:
(defn maybe [t] (union {:not nil, :just t})) ;use Clojure functions to make parameterized types
(def opcode (enum #{:add, :sub, :mul, :div}))
(def op (bundle {:op opcode, :src1 (bits 8), :src2 (bits 8)})
(def datum (cast (maybe op) {:just {:op :add, :src1 #b0000_1111, :src2 #b1010_0101}}))
(union-match datum
(:not _ ;ignore binding
false) ;return false
(:just {:keys [op src1 src2]} ;use Clojure's destructuring on values
(mux2 (= src1 src2) ;return src1 == src2
true false)))
(uintm n)
constructs an unsigned integer of bit width n
that does modulo on over/underflow, just like in C/Java. This is valid: (+ ((uintm 8) 3) ((uintm 8) 254))
and it equals ((uintm 8) 1)
due to the overflow. (+ ((uintm 2) 1) 1)
is equal to ((uintm 2) 2)
, but (+ ((uintm 2) 1) + 22)
is an error because 22 cannot be converted directly to a (uintm 2)
. The initial value construction rules are stricter than the usage rules.
Signed, saturating integer. On overflow or underflow in clamps to the min or max value.
TODO: document usage and pitfals
Signed, saturing fixed point number. This is good for signal processing--like an sints
, but manages the fractional part automatically.
TODO: document usage and pitfalls.
=
and not=
work on all types.
and
, or
, numeric predicates, and other helpers/carryovers need to be documented.
The following functions operate on uintm
: +
, -
, *
, >
, <
, >=
, <=
, inc
, dec
The following functions operate on both uintm
and bits
: bit-and
, bit-or
, bit-xor
not
negates any boolean. mux2
behaves just like if
(but if
is a reserved word in Clojure). cond
and condp
behave like Clojure's eponymous functions, but condp
doesn't support the :>>
syntax.
union-match
explained above is the only way to safely access a union's members.
get
gets a key from a bundle. assoc
and assoc-in
change keys in a bundle or a nested bundle, respectively, just as in Clojure.
bit-cat
concatenates bits and bit-slice
slices them. serialize
and deserialize
convert other types to and from bits. See the earlier section on bits
for details.
Piplin code is organized into modules. Modules have 3 sections: inputs, outputs, and feedback. Inputs contain the values from the beginning of each cycle, incoming from the container. Feedbacks contain the value they were set to last cycle--that is, they're registers. Outputs are the same as feedbacks, but they also are exposed to the containing module. The submodule system is complete, but it needs lots of documentation.
Our hello world module is an up counter. Let's start by looking at the most basic form:
(module [:outputs [x ((uintm 8) 0)]]
(connect x (inc x))
This constructs a module with one output, named x
, which is initialized to a ((uintm 8) 0
, and thus has type (uintm 8)
. After the bindings is the body, which is made of logic and connections. In this case, only one connection is made, which connects x
to x
+ 1. This forms the basis of our counter. It returns the instantiated counter module with a random, unique name. If you'd like to assign a name, use:
(module counter [:outputs [x ((uintm 8) 0)]]
(connect x (inc x))
This will also return an instance with name counterXXXXX, where XXXXX is a unique number. Usually, you'll want to define a parameterizable module for reuse:
(defmodule counter [n] [:outputs [x ((uintm n) 0)]]
(connect x (inc x))
In this example, we use defmodule
to create a new var called counter
, which is a function of 1 argument (defmodule
takes an arglist, in this case [n]
). (counter 8)
returns an 8 bit counter, while (counter 3)
returns a 3 bit counter.
Suppose we wanted our counter to switch directions every time it overflows. We can add a feedback, dir
, that will configure the direction of counting. We'll also need to change the direction each time it overflows:
(def dir-sym (enum #{:up :down}))
(defmodule bouncer [n] [:outputs [x ((uintm n) 1)]
:feedback [dir (dir-sym :up)]]
(assert (pos? n)) ;can but normal Clojure code here for definition-time logic
(connect x (mux2 (= dir :up) (inc x) (dec x)))
(connect dir (cond
(= x 0) :up
(= (serialize x) ;convert x to bits
(reduce bit-cat ;repeatedly concatenate bits
(take n (repeat #b1))) ;get n single set bits
:down
:else dir ;no change))
That looks really far to the right. How can we make it nicer? All conditionals support defining a connection along all of their branches. This includes union-match
, cond
, condp
, and mux2
. Also, multiple connections can be defined on each branch (not shown below):
(def dir-sym (enum #{:up :down}))
(defmodule bouncer [n] [:outputs [x ((uintm n) 1)]
:feedback [dir (dir-sym :up)]]
(assert (pos? n)) ;can but normal Clojure code here for definition-time logic
(mux2 (= dir :up)
(connect x (inc x))
(connect x (dec x)))
(cond
(= x 0) (connect dir :up)
(= (serialize x) ;convert x to bits
(reduce bit-cat ;repeatedly concatenate bits
(take n (repeat #b1)))) ;get n single set bits
(connect dir :down)
:else
(connect dir dir) ;no change))
Much more readable!
Currently the only debugging function is (pr-trace args... the-value)
, which takes any number of arguments that are concatenated into a tracing string, and the-value, which is printed every cycle. It works on all types.
There is also the more generate trace
, which can be used to dump data into any format, such as VCD for a waveform viewer. To dump to VCD, use spit-trace
to write it to a file, or use trace->gtkwave
to automatically open the trace in GTKWave after dumping. Traces can be acquired
A trace is a seq of maps from register names to their values in that cycle.
You can get a trace for a given set of registers by using trace-keys
, defined in piplin.sim
. trace-keys
transforms the fns
returned by make-sim
into a new set of fns
and a trace atom, which will contain the trace after running the sim. To get a seq of all registers in a module hierarchy, use get-all-registers
. See module->verilog+testbench
in piplin.test.verilog
for example usage.
See test/piplin/test/math.clj for examples of compiling and simulating a module. (make-sim mod)
is used to make an initial-state and set of simulation function callbacks. Use (exec-sim state fns cycles)
to simulate the system of fns, starting at the given state, for cycles
cycles (i.e. this is a positive integer). The return value of exec-sim
is a new state object, which is map-like. See the referenced code for examples of inspecting the state to verify assertions.
Use module->verilog
to convert a module instance to Verilog. It returns a println
able string. If you want to convert an entire module hierarchy, use modules->verilog
, which returns a list of pairs where the first element is the module's name and the second element is the module's verilog. You can then write these to a series of files. Verilog also supports defining multiple modules in the same file. This is easiest, so you can just use modules->all-in-one
to get everything in one file.
Use make-testbench
to make a testbench for a module in Verilog. It returns a println
able string. Its arguments are the module to test and a trace. It will then generate a Verilog program that will run that test and verify that the module under simulation produced the expected output with the given input.