-
Notifications
You must be signed in to change notification settings - Fork 3
Serial Interfaces
Most of the peripherals we use which are external to the micrcontroller are connected via a serial interface. We make use of three different serial interfaces, each with its own strengths and weaknesses. The SAM D21 we are using has six instances of a flexible SERCOM peripheral which can be configured to be a USART, SPI master, SPI slave, I2C master or I2C slave. The details of using the SERCOM hardware to implement each serial interface are abstracted over by the serial interface drivers used in the avionics codebase. This document is intended to provide a basic introduction to how each of the interfaces we use works and to be a guide to using the serial interface drivers. For more information on how the SERCOM hardware works see sections 25 through 28 of the SAM D21 Datasheet.
Asynchronous serial is a form of serial communication in which there is no clock signal shared between the two endpoints. We make use of a simple form of asynchronous serial which only uses two wires, one for communication from the microcontroller to an external device and one for communication from the external device to the microcontroller. The two connections are entirely independent and do not necessarily share any clocks or other hardware. Below is a diagram which shows how a peripheral is connected to the microcontroller for asynchronous serial communication:

The hardware used to implement asynchronous serial is usually called a Universal Asynchronous Receiver/Transmitter or UART (in the case of the SAM D21, we use the UART mode of the SERCOM peripheral). You may also encounter the term USART, which stands for Universal Synchronous/Asynchronous Receiver/Transmitter. A USART is also capable of implementing basic serial interfaces which make use of a shared clock line.
Since there is no common clock between the endpoints, they must each know ahead of time how fast data will be transmitted. This is specified in bits per second and is known as the baud rate. Each device will have a local clock running at the baud rate (or a multiple thereof for oversampling) which allows it to know when to send and receive bytes. Since these clocks will not be in phase (and probably won't even be at the exact same frequency) the data signal must include a way that the transmitter can indicate to the received when it is transmitting valid data. This is accomplished by transmitting a start period (logic low) before each byte and a stop period (logic high) afterwords. The start period is one bit period long, the stop period may be arbitrary long, but must be at least one bit in length. This diagram shows what transmitting two bytes of data looks like on an asynchronous serial interface:

It is possible to use different numbers of start and stop bits, different numbers of bits per transfer and to transfer data MSB first as long as the transmitter and receiver are both using the same settings. The settings shown above are the most popular though and we are unlikely to be using any peripherals which use different settings.
SPI is probably the simplest serial interface we use. It uses four signals, a clock, a signal to transmit data from master to slave, a signal to transmit data from slave to master and a signal to select a certain slave if more than one is connected to the same SPI bus. The connections required for SPI are shown below:

The clock line is called SCK. Master Out Slave In (MOSI) is the line on which data is transmitted from master to slave. Master In Slave Out (MISO) is the line on which data is transmitted from slave to master. The Chip Select (CS) pin allows a certain slave to be enabled (it is indicated as nCS on the diagram since it is most often an active low signal).
Because SPI uses a clock signal to keep the timing synchronized between master and slave, it can run much faster than asynchronous serial. Since the clock signal is always generated by the master, SPI is a master centric bus. The slave cannot transmit data unless the master is providing a clock signal, and if the master is providing a clock both master and slave are transmitting.
Often multiple slaves will be connected to the same SCK, MOSI and MISO lines since microcontrollers usually have a limited number of SPI interfaces. In order to share the bus, each slave will have a chip select line which allows the slave's SPI interface to be enabled and disabled by the master. A chip's CS signal must be asserted for the duration of any SPI transaction with the chip.
SPI can be configured in several different modes. In the one we use bits are read on the rising edge of the clock and updated on the falling edge. This diagram shows a one byte long SPI transfer:

I2C (sometimes written I2C or IIC, pronounced "eye-squared-see", "eye-two-see" or "eye-eye-see") is a more complicated serial protocol than asynchronous serial or SPI. It only uses two signals, but can allow for many devices to be connected to a single master without the need for chip select pins. I2C uses a clock line and a single data line for transfers in both directions.
I2C is faster than asynchronous serial, but generally slower than SPI. The I2C standard defines 5 possible speeds, 100 kBit/s Standard Mode, 400 kBit/s Fast Mode, 1 MBit/s Fast Mode Plus, 3.4 MBit/s High-speed Mode and a 5 MBit/s Ultra-fast Mode. The SAM D21 supports speeds up to 3.4 MBit/s, but we generally use Fast Mode since it is the fastest speed supported by most of the peripherals that we use.
The I2C lines are open-drain, which means that they have external pull-up resistors. The master and slave never pull the I2C lines high, they only ever pull them low or allow them to be pulled high by the resistors. The topology of an I2C bus with a single master and two slaves is show below:

In order to share the bus between multiple slaves, each slave is given a seven bit long address. Transfers on the I2C bus are split into transactions and each transaction contains one or more messages. Each message begins with a start condition, which is a falling edge on the data line while the clock is high, followed the slave address and a bit indicating whether the master intends to read from or write to the slave. The addressed slave will respond by sending and acknowledgment (ACK) or not-acknowledgment (NACK) to indicate whether it is able to proceed with the transaction. If the transaction proceeds, the address will be followed by zero or more data packets, each packet consisting of eight bytes transmitted by either the master or slave followed by an ACK or NACK from the receiving device. The master can start new message within the same transaction by sending another start condition, refered to as a repeated start, followed by an address. The master can end the transaction by sending a stop condition which is a rising edge on the data line while the clock is high.
A transaction with a single two byte long message is show below:

We have drivers available which abstract over using the SERCOM hardware to implement any of the above serial interfaces and facilitate the sharing of serial busses between multiple peripherals and their corresponding software modules.
The asynchronous serial driver is the easiest of the serial interface drivers to use. Each instance of the driver has two circular buffers, one for input and one for output. Data to be written is queued in the output buffer and copied to the SERCOM hardware by DMA or interrupts. As data is received it is placed in the input buffer, functions are provided by the driver to copy data out of the buffer.
The init function for the driver is defined in sercom-uart.h as follows:
extern void init_sercom_uart(struct sercom_uart_desc_t *descriptor, Sercom *sercom,
uint32_t baudrate, uint32_t core_freq,
uint32_t core_clock_mask, int8_t dma_channel,
uint8_t echo);The following arguments are required:
| Argument | Description |
|---|---|
descriptor |
A pointer to a struct sercom_uart_desc_t to be used to store the information associated with this instance of the asynchronous serial driver |
sercom |
A pointer to the register space for the SERCOM hardware instance to be used by the driver instance |
baudrate |
The baudrate to be used in bits per second |
core_freq |
The frequency of the clock to be used as the SERCOM core clock, used in calculating baudrate settings |
core_clock_mask |
Bitmask to select the Generic Clock Generator to be used to supply the SERCOMs core clock |
dma_channel |
The DMA channel to be used for transmitting data, can be negative to indicate that interrupt driven transmission should be used |
echo |
If a non-zero value is passed for this paramater, any printing characters received will be echoed and receiving backspace characters will cause the last received character to be dropped and the VT100 codes for moving the cursor back a character and clearing the current character to be echoed |
There are five different functions for copying data to the output buffer.
The first three are non-blocking:
// Copy a null terminated string into the output buffer
// (not including the null character)
extern uint16_t sercom_uart_put_string(struct sercom_uart_desc_t *uart,
const char *str);
// Copy length bytes to the output buffer
extern uint16_t sercom_uart_put_bytes(struct sercom_uart_desc_t *uart,
const uint8_t *bytes, uint16_t length);
// Copy a single character to the output buffer
extern void sercom_uart_put_char (struct sercom_uart_desc_t *uart, char c);All of these functions return as soon as they are finished copying. When they return the data may not have finished transmitting (in fact it probably hasn't even started yet).
If the output buffer becomes full while copying, the sercom_uart_put_string and sercom_uart_put_bytes functions will stop copying. Both functions return the number of bytes which were successfully copied. The calling code must check that the expected number of bytes were actually copied and be able to handle any case where not all of the data can be copied in a single function call. Note that it is not sufficient to call one of the put functions in a loop as this will cause unnecessary blocking.
If the output buffer is full when sercom_uart_put_char is called the character least recently added to the output buffer will be overwritten. This is usually not desirable behavior.
The other two functions to write data to the output buffer are as follows:
// Copy a null terminated string into the output buffer
// (not including the null character)
extern void sercom_uart_put_string_blocking(struct sercom_uart_desc_t *uart,
const char *str);
// Copy length bytes to the output buffer
extern void sercom_uart_put_bytes_blocking(struct sercom_uart_desc_t *uart,
const uint8_t *bytes, uint16_t length);These functions work similarly to sercom_uart_put_string and sercom_uart_put_bytes except that if there is not enough space to copy all of the data into the buffer they will wait until space becomes available. Asynchronous serial is rather slow, so if these functions are used and the buffer becomes full they can take a long time to return. Note that the data is still not fully sent when these functions return, only fully copied to the output buffer to be sent in the background later.
Finally, there is a helper function provided to indicate whether there is outstanding data in the output buffer. It is declared as follows:
extern uint8_t sercom_uart_out_buffer_empty (struct sercom_uart_desc_t *uart);Data can be read from the buffer one character at a time or one string (of a fixed length) at a time using the following functions:
extern char sercom_uart_get_char (struct sercom_uart_desc_t *uart);
extern void sercom_uart_get_string (struct sercom_uart_desc_t *uart, char *str,
uint16_t len);sercom_uart_get_char returns the least recently received character from the input buffer. If the input buffer is empty it will return a null character.
sercom_uart_get_string provides a null terminated string of up to len bytes from the input buffer. If there is are fewer than len bytes available to be read the string will contain all of the available bytes followed by a null character. If there are len or more bytes available to be read the string will contain len - 1 bytes followed by a null character.
Data can also be read from the input buffer on a per line basis with the following functions:
extern uint8_t sercom_uart_has_line (struct sercom_uart_desc_t *uart, char delim);
extern void sercom_uart_get_line (struct sercom_uart_desc_t *uart, char delim,
char *str, uint16_t len);The sercom_uart_has_line function iterates over the input buffer and checks if the delim is present.
The sercom_uart_get_line function is similar to sercom_uart_get_string except that it will only copy up to the first instance of the delimiter character. The delimiter character will be popped from the input buffer but will not be copied, instead it will be replaced with a null character. sercom_uart_get_line will still only ever copy a maximum of len bytes.
If the delimiter is not present in the input buffer and sercom_uart_get_line is used it will act exactly the same as sercom_uart_get_string and you will not be able to determine whether a full line was read. For this reason, if you want to accept input one line at a time (and expect lines to be shorter than the buffer size or expect to try and read lines more often than they are received) it is best to always check that a line is avaliable before trying to copy the line into a buffer.
The SPI driver's init function is declared as follows:
extern void init_sercom_spi(struct sercom_spi_desc_t *descriptor, Sercom *sercom,
uint32_t core_freq, uint32_t core_clock_mask,
int8_t tx_dma_channel, int8_t rx_dma_channel);The following arguments are required:
| Argument | Description |
|---|---|
descriptor |
A pointer to a struct sercom_spi_desc_t to be used to store the information associated with this instance of the SPI driver |
sercom |
A pointer to the register space for the SERCOM hardware instance to be used by the driver instance |
core_freq |
The frequency of the clock to be used as the SERCOM core clock, used in calculating clock speed settings |
core_clock_mask |
Bitmask to select the Generic Clock Generator to be used to supply the SERCOMs core clock |
tx_dma_channel |
The DMA channel to be used for transmitting data, can be negative to indicate that interrupt driven transmission should be used |
rx_dma_channel |
The DMA channel to be used for receiving data, can be negative to indicate that interrupt driven reception should be used |
The SPI driver is transaction based. In order to transfer data on the SPI bus, a transaction is added to the SPI queue. The transaction contains details of how much data should be transmitted and how much data should be received. The following function is used to enqueue an SPI transaction:
extern uint8_t sercom_spi_start(struct sercom_spi_desc_t *spi_inst,
uint8_t *trans_id, uint32_t baudrate,
uint8_t cs_pin_group, uint32_t cs_pin_mask,
uint8_t *out_buffer, uint16_t out_length,
uint8_t * in_buffer, uint16_t in_length);The start function returns 0 if the transaction is enqueued successfully. If it returns any other value the transaction could not be enqueued, most likely the queue is full.
The following information is required to enqueue an SPI transaction:
| Argument | Description |
|---|---|
trans_id |
A pointer to memory in which the ID of the new enqueued transaction can be stored |
baudrate |
The clock speed to be used for the transaction in Hz |
cs_pin_group |
The port for the chip select pin for the peripheral used in this transaction, 0 for port A, 1 for port B |
cs_pin_mask |
Mask for the chip select pin for the peripheral used in this transaction |
out_buffer |
Pointer to memory from which data should be written, can be null iff out_length is 0 |
out_length |
Number of bytes to be sent in the transaction |
in_buffer |
Pointer to memory in which received data should be stored, can be null iff in_length is 0 |
in_length |
Number of bytes to be received in the transaction |
Every SPI transaction is executed in the following steps:
- CS pin is set low
-
out_lengthbytes are transmitted fromout_buffer -
in_lengthbytes are received intoin_buffer - CS pin is set high
The following functions are used to manage transaction IDs provided by the start function:
extern uint8_t sercom_spi_transaction_done(struct sercom_spi_desc_t *spi_inst,
uint8_t trans_id);
extern uint8_t sercom_spi_clear_transaction(struct sercom_spi_desc_t *spi_inst,
uint8_t trans_id);sercom_spi_transaction_done is used to check if a transaction has completed. sercom_spi_clear_transaction removes a transaction from the queue and must be called for every transaction once it has completed.
The sercom_spi_clear_transaction function can clear a transaction before it has started or after it is done. If the transaction is in progress the function will return a non-zero value and the transaction will not be stoped.
The I2C driver's init function is declared as follows:
extern void init_sercom_i2c(struct sercom_i2c_desc_t *descriptor, Sercom *sercom,
uint32_t core_freq, uint32_t core_clock_mask,
enum i2c_mode mode, int8_t dma_channel);The following arguments are required:
| Argument | Description |
|---|---|
descriptor |
A pointer to a struct sercom_spi_desc_t to be used to store the information associated with this instance of the I2C driver |
sercom |
A pointer to the register space for the SERCOM hardware instance to be used by the driver instance |
core_freq |
The frequency of the clock to be used as the SERCOM core clock, used in calculating clock speed settings |
core_clock_mask |
Bitmask to select the Generic Clock Generator to be used to supply the SERCOMs core clock |
mode |
The speed mode for the driver one of I2C_MODE_STANDARD (100 kHz), I2C_MODE_FAST (400 kHz), I2C_MODE_FAST_PLUS (1 MHz) or I2C_MODE_HIGH_SPEED (3.4 MHz) |
dma_channel |
The DMA channel to be used for sending and receiving data, can be negative to indicate that interrupt driven transmition and reception should be used |
Unlike the SPI and asynchronous serial drivers the I2C driver has a service function that needs to be called in each iteration of the main loop.
The I2C driver is transaction based, much like the SPI driver. It is much more complex than the SPI driver though: there are four different transaction types and transactions can fail. With SPI, it is always possible to complete a transaction even if there is no device on the other end. With I2C the device address and every byte sent must be acknowledged which means that transactions do not always succeed.
The possible transaction types are as follows:
| Transaction Type | Description |
|---|---|
| Generic | A generic I2C transaction works in the same way as an SPI transaction. The caller provides input and output buffers, a number of bytes to be written and a number of bytes to be read. |
| Register Write | A register write is a special case of a generic transaction which is designed to accommodate the way in which most I2C devices perform register operations. A one byte register address is written to the device followed by a specified number of bytes from a buffer. |
| Register Read | A register read is a special case of a generic transaction which is designed to accommodate the way in which most I2C devices perform register operations. A one byte register address is written to the device then a specified number of bytes are read from the device to a buffer. |
| Bus Scan | This is a special transaction which tries every possible I2C address and records which addresses are acknowledged. This provides a list of the addresses of all devices which are connected to the I2C bus and listening. |
The following functions are used to start transactions:
extern uint8_t sercom_i2c_start_generic(struct sercom_i2c_desc_t *i2c_inst,
uint8_t *trans_id, uint8_t dev_address,
uint8_t const* out_buffer,
uint16_t out_length, uint8_t *in_buffer,
uint16_t in_length);
extern uint8_t sercom_i2c_start_reg_write(struct sercom_i2c_desc_t *i2c_inst,
uint8_t *trans_id,
uint8_t dev_address,
uint8_t register_address,
uint8_t *data, uint16_t length);
extern uint8_t sercom_i2c_start_reg_read(struct sercom_i2c_desc_t *i2c_inst,
uint8_t *trans_id, uint8_t dev_address,
uint8_t register_address,
uint8_t *data, uint16_t length);
extern uint8_t sercom_i2c_start_scan(struct sercom_i2c_desc_t *i2c_inst,
uint8_t *trans_id);For all of the above functions trans_id is a pointer to memory in which the transaction ID for the enqueued transaction can be stored. The functions return 0 if the transaction cannot be enqueued (probably because the queue is full). For the functions that take a device address it should be the right aligned 7 bit address for the device.
A number of functions exist to get the result or status of an I2C transaction:
extern uint8_t sercom_i2c_transaction_done(struct sercom_i2c_desc_t *i2c_inst,
uint8_t trans_id);
extern enum i2c_transaction_state sercom_i2c_transaction_state(
struct sercom_i2c_desc_t *i2c_inst,
uint8_t trans_id);
extern uint8_t sercom_i2c_device_avalaible(struct sercom_i2c_desc_t *i2c_inst,
uint8_t trans_id, uint8_t address);The sercom_i2c_transaction_done function works the same way as its SPI equivalent. Unlike SPI, we also have the sercom_i2c_transaction_state function which provides more detailed information on the I2C transaction. The state is most useful for determining whether the transaction completed successfully. The possible values of the transaction state are as follows:
| State | Description |
|---|---|
I2C_STATE_PENDING |
Initial state, transaction has not yet started |
I2C_STATE_REG_ADDR |
Sending device address for register read or write transaction |
I2C_STATE_TX |
Transmitting data to slave |
I2C_STATE_WAIT_FOR_RX |
Waiting for the bus to become idle before starting recieve stage |
I2C_STATE_RX |
Receiving data from slave |
I2C_STATE_WAIT_FOR_DONE |
Waiting for the bus to become idle before ending transaction |
I2C_STATE_DONE |
Transaction finished |
I2C_STATE_BUS_ERROR |
Error occured on I2C bus, transaction aborted |
I2C_STATE_ARBITRATION_LOST |
Lost arbitration on I2C bus, transaction aborted |
I2C_STATE_SLAVE_NACK |
The slave did not ACK it's address or a another byte which was sent to it, transaction aborted |
When an I2C transaction is finished, one should always check if it was aborted prematurely. If sercom_i2c_transaction_done returns true and sercom_i2c_transaction_state returns anything other than I2C_STATE_DONE the transaction failed and should be retried.
The result of a bus scan transaction can be queried with the sercom_i2c_device_available function. When called with the transaction ID of a bus scan transaction, sercom_i2c_device_available indicates whether any device with a certain address was found to be present on the bus.
The last function in the I2C driver is:
extern uint8_t sercom_i2c_clear_transaction(struct sercom_i2c_desc_t *i2c_inst,
uint8_t trans_id);The sercom_i2c_clear_transaction works in the same way as its SPI equivalent. All I2C transactions should be cleared as soon as they are confirmed to be done and any information about their state that is needed has been read from the transaction. Note that once sercom_i2c_clear_transaction is called on a transaction ID sercom_i2c_transaction_state and sercom_i2c_device_avaliable will not return meaningful values.