This content was presented June 6th, 2020
Introduction
Welcome to firmware 104! By now you should have some experience writing simple programs that could run on our controller boards.
In this lesson we’ll be covering:
SPI and I2C communication
The CAN protocol
Code generation
Recap
Here’s a quick rundown of the last lesson:
We have multiple hardware libraries that handle interactions with the hardware, including GPIO, PWM, ADCs, and GPIO interrupts.
Our schematics are primarily stored on Altium 365 at https://university-of-waterloo-solar-car-team.365.altium.com; slack Ryan Dancy to get added
You have to look at the schematic to understand how to talk to an integrated circuit (what pins do what)
You need to look at the datasheet of a integrated circuit to understand how to use it (what commands do what)
We write driver programs to talk to integrated circuits
SPI and I2C communication
Something we briefly touched on last lesson but didn’t go into detail of is how we actually talk to these integrated circuits. Since these chips can have a huge range of functionality, we need a way to send a variety of commands using a limited number of GPIO pins.
For example, here’s the command list for our solar power maximizer chips (MPPTs, or Maximum Power Point Trackers). We could encode each command as a GPIO pin and each bit of data as another pin, but that would already be 10+ pins just for this.
The general solution to this problem is what’s called serial communication. You may have heard of this before in USB: Universal Serial Bus. By encoding binary commands into a series of constant-length signal pulses, we can send much more data than simple pin toggles. For reference, USB only has 4 wires, but manages to send huge amounts of data.
The protocols we use are I2C and SPI, or inter-integrated circuit (pronounced i-squared-c) and serial peripheral interface. These are fairly complex so I highly recommend reading through the following tutorials, but I’ll do my best to go in depth enough that you can use them in midnight sun.
SPI: https://www.circuitbasics.com/basics-of-the-spi-communication-protocol
I2C: https://www.circuitbasics.com/basics-of-the-i2c-communication-protocol/
SPI Protocol
SPI communication happens over four wires between a ‘master’ chip (our controller board) and a ‘slave’ chip (the integrated circuit):
Let’s go over them.
SCLK (slave clock) provides the clock pulses to tell the slave what the timing is of bits.
MOSI (master-out-slave-in) is toggled on and off to represent the bits the master wants to send to the slave
MISO (master-in-slave-out) is toggled on and off to represent the bits the slave sends back to the master
SS (slave-select) is used if multiple slaves share the same SCLK, MOSI, and MISO wires. The SS line for whichever slave is to be selected goes low (off) during transmission.
For an example, see the following diagram:
Here, the master sends the byte 0x53 to the slave, and the slave returns the byte 0x46.
There are also a couple universal SPI settings called CPOL (clock polarity) and CPHA (clock phase). Check the SPI wikipedia page for more info on these, but you should be able to find these on the datasheet.
We have a SPI driver to handle most of this, with a few important notes:
The STM32 we use has two SPI ports, which are designated pins for using SPI with. You’ll need to read the schematic to determine which SPI port to use.
The SPI mode is based on CPHA and CPOL which you’ll find in the datasheet.
Baudrate is the clock rate, you’ll also find acceptable values for this in the datasheet
The above code is the settings we pass into our `spi_init()` function.
In general, we use the following function for SPI messages:
Tx stands for ‘transmit’, and Rx stands for ‘receive’. The `*rx_data` pointer is written with the received bits.
For example code on using SPI, check out the ‘smoke_spi’ project: https://github.com/uw-midsun/firmware_xiv/blob/master/projects/smoke_spi/src/main.c
Although the SPI protocol is universal (ish), ICs tend to implement their own set of commands to use. Again going back to our MPPTs as an example:
Here for example, to read the current, we’d send the byte 0x04 (tx_len = 1), and read the 10 bits of return into two bytes (rx_len = 2).
I2C Protocol
I2C is a little more complex, but happens over only two lines:
SDA (serial data) is the data line for both the master to send and receive data as well as the slave to send and receive data
SCL (serial clock) pulses the clock signal
Messages take the following form:
A start condition (don’t need to worry about this)
An address frame. I2C devices can be connected in series like a chain, so the address is required. It can be found on the datasheet.
A read/write bit. 0 means the master wants to write, 1 means the master wants to read.
Multiple ACK/NACK bits. This is hidden by our library interface, but basically an I2C read/write can fail, meaning you might have to try again.
Data frames. These are the bits of data the master or slave is sending. There can be any number, but they must be 8 bits.
Here’s a more detailed view of an I2C command:
Our header file is pretty simple and self explanatory:
There are multiple clock speed options. Similar to SPI, there are two I2C ports on the STM. You’ll have to check the schematic to determine which one to use. For more info on using I2C commands, check out our ‘smoke_i2c’ project: https://github.com/uw-midsun/firmware_xiv/blob/master/projects/smoke_i2c/src/main.c .
The CAN protocol
So now we know how to talk to chips on the same board as the STM, but what if we want to talk to other boards and other STM chips? To do this, we use CAN, or a Controller Area Network. The CAN protocol is actually really complex, so for this lesson I’ll focus on how we implement it and how we use it.
The bus
CAN messages are transmitted over two wires. Devices on a CAN network are connected in a ‘bus’. Physically, this goes as follows:
Two CAN wires, high and low, are wired through the car, and each ‘node’ (controller board) is connected to both wires. Each wire is ‘terminated’ on both ends by a resistor. Because it would be inconvenient to actually run two long wires through the whole car, each controller board has two CAN ports, pictured below:
The ports are connected within the circuit board. This way, we can run a CAN wire between pairs of devices to form the bus rather than one long wire.
Since the whole bus is connected, there’s no way to send a message just to one device - any message sent on the bus can be observed by all devices on the bus. To deal with this in software, any reaction to a message has to explicitly state what message it’s reacting to. We’ll come back to this in the code generation section.
Messages
Ignoring how they’re sent for now, CAN messages take one of the two following forms:
This is a lot, but fortunately we don’t need to worry about most of it. The key parts are the identifier and the data field. The identifier is an 11 bit number denoting the ID of the CAN message; this is user-defined and generally should be universal across all devices on the bus. Optionally, the identifier can be extended by 18 bits. These are called extended IDs. We only use standard IDs for our own messages, but other things may use extended IDs, for example the off-the-shelf car charger we use.
The data field is an arbitrary structure that’s at most 64 bits or 8 bytes long, also user defined. For example, it could be two uint32_ts, or 4 uint16_ts.
CAN messages can also be ‘acknowledged’ by other devices on the bus. The implementation of this is complex, and more details can be found in the homework on how we implement this.
The core of our CAN library consists of four functions:
can_init() must be called with the following settings struct. The CanStorage object should be initialized to
{ 0 }
when it’s passed in. You can just check other projects for how to fill out the fields.Example fields:
Notes: device_id, rx_event, tx_event, and fault_event should be defined somewhere in your project, something like this:
can_register_rx_handler() is used to add a handler to a specific message. This callback is called whenever that message is received. Note that we rarely if ever define a default handler.
can_transmit() takes in a message and ack pointer. The ack pointer will usually be NULL; in most circumstances an acknowledgement is not required.
Since multiple messages could be received at once, we store them in a queue and call can_process_event() to process the most recent one. Don’t worry about how this works, importantly the implementation of your infinite loop in your main() function should be the following:
This will ensure the CAN message callbacks are called appropriately.
The CanMessage struct takes the following form:
If you’re unfamiliar with unions, this is a good explanation: https://www.geeksforgeeks.org/union-c/
This is all fine and good, but it could get pretty annoying to pack the right values into the right places every time you want to send a message, especially if you’re mixing data types like having two uint16_ts and one uint32_t. To facilitate this, we use something called code generation.
Code Generation
We actually define all our CAN messages in a separate repo called codegen-tooling-msxiv, found here: https://github.com/uw-midsun/codegen-tooling-msxiv/blob/master/can_messages.asciipb . Have a quick look at this file. In general, we define messages similar to the following:
This file stores the master definitions for all our CAN messages. From before, you can see the ID and the data fields defined here.
Once we have these definitions, we generate CAN_TRANSMIT, CAN_PACK, and CAN_UNPACK functions for every message. For detailed instructions on generating these messages you can check the repo README, or this writeup Codegen Tooling .
Once the code is generated, it’s placed in the firmware_xiv repo under libraries/codegen-tooling/inc. Here’s the definitions generated from the above example:
Don’t worry too much about these. The gist of it is that you should call CAN_TRANSMIT_… with the appropriate arguments, then in your callback that you registered you should have variables defined with the message parameters, then call CAN_UNPACK_… with the appropriate pointers.
Using code generation makes it much easier and cleaner to work with CAN.
Conclusion
Congrats, you made it through the meat of the firmware tutorials! There’s a couple lessons left, but if you’ve finished this lesson you should be able to take on some small projects. Ask your lead for work!
Here’s what you should now have an understanding of:
How we use SPI and I2C
How the CAN bus works
How we make CAN easier to work with in code
In the next tutorial, we’ll be covering our finite state machine and event queue libraries, as well as how to architect firmware projects and some best practices to follow.
Homework
All three parts should be submitted in the same project, based on previous firmware 10x submission procedures. Put parts 1 and 2 in a separate file from part 3. You don’t need to run parts 1 and 2.
Part 1: I2C
Based on the MSXII pedal board schematics, write a function that writes 0b0010010010100110 to the configuration register, then reads the conversion register.
Hint: you’ll need to look at the schematics for the pedal board and the datasheet for the ADC.
Part 2: SPI
Based on the MSXII charger interface board schematics, write a function that initializes SPI then writes the following information to the CANCTRL register of the CAN controller IC:
Set loopback mode
Do not request abort of transmit buffers
Enable one shot mode
Disable CLKOUT pin
Set the CLKOUT prescaler to System Clock / 2.
Then, send the READ STATUS instruction and print the TXB1CNTRL[3] bit from the return.
Part 3: CAN Communication
Write a function that periodically sends a CAN message with id 0xA and a random uint16_t as the body, and requests an ACK with an OK status. Write another function that periodically sends a CAN message with id 0xB and a different random uint16_t as the body.
Then, register callbacks for both that print the data, but only ACK the 0xA message.
Run the program in two terminals at the same time, and send a screenshot of the output to your lead.