Pcb-rs
A library to easily wite Software Emulated Hardware
This library provides two macros Chip
and pcb
which can be used to write software emulated hardware components. Chip
is a derive macro which can be used on structs to automatically implement the necessary interfaces for the struct to be treated as a Hardware Chip, and you only need to implement the tick function which will be called on each clock cycle to run the logic of your chip. pcb
macro is used to define a PCB , where you can connect multiple chips, and it will manage connecting pins of chips, verifying the connections and passing the data on the connected chip.
One of the aims of this library is modularity and reusability, thus the pcb created can be further used as chips in some other pcb ans so on.
There are some finer points to be noted when creating chips, and are listed after the explanations.
For examples showing use of these for implementing various chips, please check https://github.com/YJDoc2/pcb-rs-examples .
Motivation
The main motivation behind this is to help people who wish to explore hardware and low-level systems to do so easily. Learning about hardware design can be tricky for several reason : it is not easily accessible, you usually need breadboards, chips and stuff to implement basic circuits, and the more complex circuits you want to implement more complex it gets. For doing it in software, VHDL is a very powerful alternative : you can describe your hardware in it, and it can synthesize and run tests on your hardware. It can even convert it into a format which can be used directly with FPGAs to realize your circuit into actual hardware. But it is complex, a bit verbose and (personally) a bit scary to get into.
This library aims to provide a simpler way to enter into the hardware realm, by allowing you to write it in the comfort of Rust, but also giving you taste of considerations one has to do while designing hardware. This sacrifices a lot of power VHDL gives, but it is a trade-off, and as this does not aim to "replace" or even be a substitute for something like VHDL, it is fine.
Chip Macro
This is a derive macro, which will implement the necessary traits for the struct to be used as a Hardware Chip. Here a hardware chip means that it can be used in a pcb-macro generated pcb as a component chip. You will need to annotate the struct members which are to be exposed as pins, and then implement the HardwareChip interface, which has the required tick
function. This function is where the processing logic of the chip should reside. If used in a pcb generated by pcb-macro, this function will be called on each emulated clock-tick.
Example usage
This is a dummy example showing how you can use this macro to make a struct into a Chip.
use pcb_rs::*;
// Add the derive(Chip), so it will implement the necessary interfaces for your struct
#[derive(Chip)]
struct MyChip{
// This indicates that this chip has a pin named 'pin1' of type 'u8'
// and which is an input pin
#[pin(input)]
pub pin1 :u8,
// This indicates that this chip has a pin named 'pin2' of type 'bool'
// and which is an output pin
#[pin(output)]
pub pin2 :bool,
// This indicates that this chip has a pin named 'pin3' of type 'String'
// and which is an io pin, and its status is indicated by the
// 'io_latch' member of this struct
// Why this needs to be an option is explained after this codeblock
#[pin(io,io_latch)]
pub pin3 : Option<String>,
// Members not marked by #[pin(...)] are not exposed as pins
// Thus the following are essentially an internal state for this chip
io_latch:bool,
some_data1:u8,
some_data2:String
}
impl Chip for MyChip{
fn tick(&mut self){
// here you can implement the logic of the chip
// this takes &mut self, so you can read values set for input pins
// and set values for the output pins, so they can be sent to
// other connected chips in a pcb
}
}
There are few things to note here :
Data type of pins
Data type of pins is allowed to be any type. This is done so one can choose their own difficulty level :
- you want to have 8
bool
pins, sure. - want one
u8
instead ? easy. - Wan to send opcode in String format? Umm ok...
please do not mis-use this! Try to keep the pin-data-type to simple inbuilt data types such as u8 etc. or at worst String or such owning data-types. If you HAVE to make pin data type a struct or make sure to think once again, and then implement Clone
on it. Enums are also fair game, as long as their components obey the above. Make sure to implement clone
on it as well.
In case you intend the chip to be used by others, pick one data type name representation, and stick to it. See note on pin type to understand why. The way I recommend is to use fully qualified path for rust std type, and just the type names for custom types which are also exposed by your lib. Although this is not a hard requirement, so as long as everyone using the chips agree on how the type name is qualified, it is ok. Although you chip might be used with other chips which can cause issues, and I really don't want to do an xkcd-standards thing.
IO pins
data type
When you need a pin of io type, for eg, data bus of a RAM, it must be set tristatable. What it meas is explained in detail in the notes section, but in short, if you want to connect multiple output pins to the same input pin, they MUST be tristatable. A trisatable pin is indicated when the outermost wrapping type is Option, and it kind-of color marks the pin, where it can only be connected to other tristatable pins, not non-tristatble pins. Theoratically there is One case where an io pin can be connected to multiple pins, without needing to be tristatable, when all other pins are of input type. This case can be reduced to a simpler case - make the io pin just output pin, as when io pin is also in input state, nothing will happen, as there is no input pin in the group. Thus one should change io to just output pin in such cases, and this library thus assumes all io pins would be connected to multiple input and output pins, and thus HAVE to be tristatable.
The actual data sent/received by io pins would be the type enclosed in the Option, i.e. T in Option<T> . Thus if you want the data-type to be Option<T> itself, read the data types of pins point once again, and if you are still sure, wrap it in option, so the final type for the data pin will be Option<Option<T>>.
IO latch
Every IO type pins has to have an associated io latch to indicate if the pin is in input state or output state. This must be a chip struct member, and of type bool. This is used by the pcb generated by pcb macro to make sure at runtime that the io pin is in correct state. See the pcb macro section for more details. The value indicates that :
- if true, then the io pin is in input mode, thus a connected pin can be in output mode, in which case its value will be given to this pin
- if false, then the io pin is in output mode, thus value of the io pin will be given to connected io pins which are in input state.
The pcb check at runtime that at most one pin is in output mode.
PCB macro
This is a functional macro, and can be used to specify and get an implementation of multiple chip connections. This basically takes in a simple textual information of what chips are in the pcb, how they are connected, and what pins are exposed out of the pcb and creates a builder which logic to verify the chips given and a PCB struct, which implements the required traits.
The chips are given and verified at runtime by the builder to produce the struct.
use pcb_rs::*;
// This will create two structs : 'MyPCBBuilder' and 'MyPCB'
// both will be public ( with 'pub' access specifier).
pcb!(MyPCB{
// These comments are allowed
/* and these */
// but not doc comments
// fist list all the chips that will be used in this pcb
// the names are not required to be same as the actual chip struct
chip c1;
chip c2;
chip c3;
// now declare the connections
// pin names MUST be same as the chip struct member names
c1::pin1 - c2::pin2;
c2::pin3 - c3::p1;
c3::p2 - c1::pin2;
// Now list the pins which should be exposed by the pcb
expose c1::pin3 as pin1;
expose c2::pin1,c3::p4 as p5;
});
fn main(){
// these var names can be anything, I prefer to keep them same
// as the chip names in the above declaration for simplicity
let c1 = Box::new(MyChip1::default());
let c2 = Box::new(MyChip2::default());
let c3 = Box::new(MyChip3::default());
let temp = MyPCBBuilder::new();
// first param to add_chip is the chip name,
// as declared in the pcb!(..)
let pcb = temp.add_chip("c1",c1)
.add_chip("c2",c2)
.add_chip("c3",c3)
.build().unwrap();
// do stuff with the pcb, generally put in a loop and call tick()
}
The builder struct provides the add_chip(name_str,boxed_chip)
function to add the chip to the pcb. In the build()
call, it verifies the added chips, and validates that :
- all the listed chips are added
- The chip has the required pins as the the connections and exposed pins
- The connected pins are of correct data type, and are of compatible types. See pin-types and exposed pins. The compatible type here means that input pins can be connected to either output or io pins, output pins can be connected to either input or io pins , and io pins can be connected to io, input or output pins. Input to input and output to output connections are invalid.
- The exposed pins are in correct setup, again see exposed pins
The first chips section is mandatory, and one of the pin-connection or exposed pins section is necessary. See pcb syntax for more details.
The PCB struct generated will itself implement the Chip
trait, and thus can be used as a Chip in some other pcb.
Note about pin value transfer
The basic way pin values are transferred for connected pins is that in the tick function of pcb, it iterates over the added chips, and calls the tick function of them. Then it takes the value of pins which are connected and passes them to the connected pins. Note that the order of chips is not determinate, and should not be relied upon.
The only guarantee this tick implementation makes is that after one call to tick of chips, values of connected pins will be given to the respective connected pins before the next call of the tick. There is no exact guarantee of when or in which order the values will be set.
Another thing to note is that there will be exactly one clock-cycle delay for passing of the values from one chip to another, from the point of view of chips. Thus the values set to output pins in the clock-cycle ti will be seen by the connected chip at the clock-cycle ti+1. If you are expecting the data from another chip, such as cpu giving address to RAM and getting data back from it, it will necessarily take 2 clock-cycles to get the data on the data pins of ram, i.e. at tick ti the address will be set on the address pin by the cpu, it will be seen by the ram in the ti+1 the tick and it will place the data on its data pins in that tick function, which will be seen by the cpu on its data pins in the next tic, i.e. ti+2.
As mentioned before, the chips themselves should not directly depend on the non-deterministic order of calling tick function, in case you specifically want race-conditions, a better option is to make a chip which will emulate this non-deterministic behavior, and wrap the chip which need the non-deterministic behavior inside this chip. Similar way should be used when you need chips which are to be ran at slower clock-speeds.
Library exposed traits and PCB interfaces
This library primarily exposed following traits, which are usually implemented by the macros, but can be manually implemented if required.
ChipInterface
This trait marks a struct to be a Chip, which can be used in a pcb. This is usually derived by the Chip macro.
pub trait ChipInterface {
/// gives a mapping from pin name to pin metadata
fn get_pin_list(&self) -> HashMap<&'static str, PinMetadata>;
/// returns value of a specific pin, typecasted to Any
fn get_pin_value(&self, name: &str) -> Option<Box<dyn Any>>;
/// sets value of a specific pin, from the given reference
fn set_pin_value(&mut self, name: &str, val: &dyn Any);
// The reason to include it in Chip interface, rather than anywhere else,
// is that I couldn't find a more elegant solution that can either directly
// implement on pin values which are typecasted to dyn Any. Thus the only way
// that we can absolutely make sure if a pin is tristated or not is in the
// Chip-level rather than the pin level. One major issue is that the data of
// which type the pin is is only available in the Chip derive macro, and cannot be
// used by the encompassing module in a way that will allow its usage in user programs
// which does not depend on syn/quote libs.
/// This is used to check if a tristatable pin is tristated or not
fn is_pin_tristated(&self, name: &str) -> bool;
/// This returns if the io pin is in input mode or not, and false for other pins
fn in_input_mode(&self, name: &str) -> bool;
}
Chip
This has the actual logic of the cihp, and is always supposed to be manually implemented in case of chips. for pcb, the pcb! macro implemented this for the chip.
pub trait Chip {
/// this will be called on each clock tick by encompassing module (usually derived by pcb! macro)
/// and should contain the logic which is to be "implemented" by the chip.
///
/// Before calling this function the values of input pins wil be updated according to
/// which other pins are connected to those, but does not guarantee
/// what value will be set in case if multiple output pins are connected to a single input pin.
///
/// After calling this function, and before the next call of this function, the values of
/// output pins will be gathered by the encompassing module, to be given to the input pins before
/// next call of this.
///
/// Thus ideally this function should check values of its input pins, take according actions and
/// set values of output pins. Although in case the chip itself needs to do something else, (eg logging etc)
/// it can simply do that and not set any pin to output in its struct declaration.
fn tick(&mut self) -> ();
}
HardwareModule
This is just a marker trait to indicate that a struct can be used as a chip. This is auto implemented for any type that implements ChipInterface
,Chip
and Downcast
so, it is not needed to be manually implemented to anything.
pub trait HardwareModule: ChipInterface + Chip + Downcast {}
PCB Builder interface
The builder struct generated by the pcb! macro has following public functions :
new() -> Builder
Creates a new builder
add_chip(chip_name,boxed_hardware_module) -> ()
This adds a chip to the pcb. The name must be same as in the chip list defined in the pcb!(...) and boxed_hardware_module is the actual chip, which implements the HardwareModule trait, in a Box.
build(mut self)->std::result::Result<pcb, error>
This validates the chips added, and if correct, returns the pcb struct containing the chips and functioning logic.
PCB interface
The pcb struct generated by the pcb! macro has the following public functions :
get_chip(&self,chip_name)->Option<&T>
Returns immutable reference to the component chip with given name, if any.
get_chip_mut(&mut self,chip_name)->Option<&mut T>
Returns mutable reference to the component chip of given name, if any.
Note : For both of the above, as the pcb cannot know which chip type it is, one must manually type annotate the variable in which the returned chip is stored, i.e. :
let t :&MyChip1 = pcb.get_chip("chip1").unwrap();
let t :&mut MyChip2 = pcb.get_chip_mut("chip2").unwrap();
Apart from these, the PCB also implements the ChipInterface, so the functions of that are also available. See the examples in https://github.com/YJDoc2/pcb-rs-examples for using the get_value and set_value methods, which might be used frequently.
Notes
Alas, this is just a hardware simulating library, and thus has some edges where it cannot exactly simulate the real-world hardware. These notes show quirks of this library.
Trisatable pins
In real pcb, the individual pins can only transfer voltages, (thus bits), and are connected to each other. Here we allow pins to have more complex data types, at expense of possible runtime panics. (as technically the types are resolved at file level, two files can have two diff types which have same name, and while generating macros, types are not resoled, so we do not have enough information until runtime if both types are same or not. )
If we allow only single connection per pin, it can not only get complicated to implement chips which connect to multiple devices, but also it might not be possible to establish shared connection at all. For eg : in a particular system RAM must be connected to both CPU and a DMA module. Now if we don't allow multiple connections, we cannot connect data pins of RAM to both CPU and DMA. That means either only one can access the RAM, or we have to add a layer of indirection between RAM and other components such that CPU and DMA will request to this component and the pins of this component will be connect to RAM. Even then, in that component we cannot connect the data pin granting pin of that indirection chip to both, due to the same issue. That means we will need one pin for each connected component, and, some priority based method to tie brake if multiple components request access to data pin. This can turn quite inefficient as the number of components to be connected grows.
In real world, such issue is solved by two methods : see this for a good explanation. In this library, we use tristating. That way ideally only one of the connected chip will have a valid output (High or Low) and others will be in High-Z mode, where essentially that pin acts as if it is not connected at all. Although in case multiple chips connected to same pin do go in non-high-z state at the same point, it will cause issues, potentially burning of real chips. Also see this.
In case of this library, we use rust std::option::Option to indicate that a pin is tristatable, and multiple pins are allowed to connect to same pin only if all are tristatable. The case of multiple tristatable pins have Some(_) at the same time, this is equivalent to multiple pins going high/low at the same time, and thus the code will panic at runtime, equivalent to the chip burning.
The tristatable pin must have type wrapped in std::option::Option, and the std::option::option can be used with fully qualified path (std::option::Option / ::std::option::Option), or option::Option (using use std::option
before) or directly Option. any other way to use will not be currently counted as a tristatable pin.
They way this is implemented is not the best or elegant way, but that was the only feasible way I could find.
Note on pin tristating
The input pins must never be tristated (set to None), unless you want the input to be ignored. Tristating the input type tristatable pins will make the pcb not send them the value of connected output pins, as in real life tristating acts as if the pin is removed from the board, and the skipping, therefore, is meant to be similar to that behavior
Syntax of the pcb!
Note that the pin names cannot be rust keyword.
The pcb! macro has three sections, and must be listed in the specific order. The semicolons are significant and required. There can be //
comments and /**/
comments in the macro, but not ///
comments (doc-comments).
- First list of chip declaration in format
chip <chip-name>;
. This is a required section, as a pcb without chips is not sensible. - Then the list of pin connection in format
<chip-name>::<pin-name> - <chip-name>::<pin-name>;
thechip-name
correspond to the name by which chips are declared in the first section. Thepin-name
MUST be the same as the name of struct member which corresponds to that pin. - Finally the exposed pins in format
expose <chip-name>::<pin-name>(,<chip-name>::<pin-name>)* as <pin-name>
. Here at least one<chip-name>::<pin-name>
is needed afterexpose
and multiple pins can be specified here as comma separated values. The<pin-name>
afteras
will be used as the name of the pin exposed by the pcb, and should be used if this pcb is used as a chip in other pcbs.
Out of this, either one of connection list or exposed pins MUST be specified, or both can be specified.
See the exposed pins section of notes to see exact semantics of specifying multiple pins to be exposed as a single pin.
Note on pin types
Unfortunately as the type information is not resolved at macro expansion time (and there is not type to represent type (yet)), we use the type-string, to represent types in PinMetadata. Unfortunately that means that for connected pin of the chips, the types must be exactly same when treaded as string :
-
both u8 is valid, but
-
std::option::Option and Option would not be treated as he same type
As their string representations are different. Note that this can result in errors at runtime (i.e. after building the pcb) as Option
can mean two different types in two different files. I don't know how to solve that currently, so better to use fully explicit types except for primitive datatypes.
Note on use of io pins
This lib is a bit opinionated when it comes to io type of pins. One should declare a pin IO type only when it will be used for both input and output. Do not use io for every pin, and the latch variable of each io pin should be kept as a non-pin member of the struct, and it should not be exposed. It should strictly be of bool type.
Note on exposed pin shorting
pcb! allows exposing multiple pins as a single pin to mimic shorting of pins while exposing in real hardware. This is useful in cases such as when an input to a gate is exposed, and same value is connected to a not gate and then output of not gate is given to some other gate (in case of D flip-flop) ; or you might want the same input to be given to multiple chips.
That said, there are some rules that must be followed when exposing multiple chips as a single one, ans if not followed, it is undefined behavior.
-
Only input types pins are allowed to be shorted when exposing. Output type pin shorting does not make sense, as the two outputs might be different, and there is not confirm way to decide which of those two values should be given as value of that shorted single exposed pin, without some arbitrary rule or restrictions, Thus only input type pins are allowed to be shorted and exposed.
-
If multiple pins are shorted and exposed, those pins must not be connected to any other pins in the PCB. This is done as :
- if the internal pin to which any of the shorted pin is connected , is input type and not connected to any other pin, it should be also exposed in the same way
- Else the pin is either output type or io type. Connecting to output type is incorrect because :
- for output pins, it would mean that the value of the output pin should be given to the input pins, but we expose the input pins and short them so that the value can be set from outside, thus similar issue of tie breaking as point 1.
- for io pins, the exposed pins must be also tristated, and when the io pins will be in output more same issue as above occurs
-
The exposed tristated pins will be set to the value only if they are not in tristated state (Some(...)) in their chips, else will be ignored when setting values received from outside.
-
As much as possible, tristated pins should not be shorted if possible, as in edge cases they might give undefined behavior
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.