FTDI chips are commonly used for FPGA boards and microcontroller programmer boards. Apart from basic UART, advanced FTDI chips also support synchronous IO using the MPSSE mode.
Unfortunately, examples for MPSSE are outdated, Windows-only or otherwise limited. This tutorial therefore explains the MPSSE basics and presents a simple SPI communication example for an FT232H controller using libftdi.
Installing Libraries
We first need to install libftdi-devel
. Use the following commands for Fedora 41:
# If you're running in a new container, you won't have these installed
sudo dnf install gcc g++
# libftdi itself
sudo dnf install libftdi-devel
Helper Code
Now we can start programming our application. First, let’s include required headers:
#include <ftdi.h>
// For usleep
#include <unistd.h>
To make our life a little easier, let’s also define an enforce
function that checks error codes and throws exceptions on unexpected results:
#include <stdexcept>
#include <string.h>
using std::string;
void enforce(bool condition, string msg = "Enforcement failed")
{
if (!condition)
throw std::runtime_error(msg);
}
Better error handling is needed for real applications, but this code at least ensures that we will not miss any errors.
I will put all code in a SPIDriver
class, so let’s define it here.
Let’s also declare the _ftdi
member, a handle for the libftdi
FTDI device:
class SPIDriver
{
private:
struct ftdi_context _ftdi;
public:
SPIDriver() {}
}
To finally compile the examples in this article, use this command:
g++ main.cpp -o main -lftdi1 `pkg-config libftdi1 --cflags`
Opening the FTDI Device
Before we can send commands to the FTDI device, we have to open a handle to it.
Let’s define an open
function in the SPIDriver
class, following the usual libftdi
tutorials:
#define VENDOR 0x0403
#define PRODUCT 0x6014
void open()
{
auto status = ftdi_init(&_ftdi);
enforce(status == 0, "Failed to initialize libftdi");
status = ftdi_usb_open(&_ftdi, VENDOR, PRODUCT);
enforce(status == 0, "Failed to open device: " + string(ftdi_get_error_string(&_ftdi)));
Next, we reset the FTDI device and choose the MPSSE mode.
enforce(ftdi_usb_reset(&_ftdi) == 0, "ftdi_usb_reset failed");
enforce(ftdi_set_interface(&_ftdi, INTERFACE_ANY) == 0, "ftdi_set_interface failed");
enforce(ftdi_set_bitmode(&_ftdi, 0, 0) == 0, "ftdi_set_bitmode failed");
enforce(ftdi_set_bitmode(&_ftdi, 0, BITMODE_MPSSE) == 0, "ftdi_set_bitmode failed");
Finally, clear the internal buffers. Just in case the device was not properly reset.
enforce(ftdi_tcioflush(&_ftdi) == 0, "ftdi_tcioflush failed");
usleep(50000);
}
Closing the FTDI Device
Let’s also add a close
function to clean up the FTDI handle on exit:
void close()
{
ftdi_usb_reset(&_ftdi);
enforce(ftdi_usb_close(&_ftdi) == 0, "ftdi_usb_close failed");
}
Configuring the MPSSE
There’s unfortunately little documentation for the MPSSE engine in general and there’s no documentation for the MPSSE mode in libftdi
at all.
If you want to check the sources for the information here, refer to App Notes AN2232C-01 Command Processor and FTDI MPSSE Basics.
Before we can transfer data using the MPSSE, we have to do some initial configuration. But before we can do that, we need to define which pins we want to use:
// ADBUS0, 1, 2
#define PIN_SCK 0
#define PIN_MOSI 1
#define PIN_NSS 2
#define OUTPUT_PINMASK ((1 << PIN_SCK) | (1 << PIN_MOSI) | (1 << PIN_NSS))
As described in FTDI MPSSE Basics, the pins for the SCK
, MOSI
and MISO
signals are fixed.
The NSS
signal however is handled manually and can be assigned to most pins.
Here, I actually assign it to the MISO
pin, a very specific use-case that will be explained in a follow-up post.
For more common use-cases, you should use any other pin here.
As for all MPSSE commands, the command data is then written using ftdi_write_data
:
void setupMPSSE()
{
// Configure Clock: 1 MHz
uint8_t buf[] = {TCK_DIVISOR, 0x05, 0x00,
// Disable adaptive and 3 phase clocking
DIS_ADAPTIVE,
DIS_3_PHASE,
// Set ADBUS: All SPI pins as output, NSS initial high
SET_BITS_LOW, (1 << PIN_NSS), OUTPUT_PINMASK
};
// Write the setup to the chip.
enforce(ftdi_write_data(&_ftdi, buf, sizeof(buf)) == sizeof(buf), "FTDI setup failed");
}
The code has been formatted so that each command is on a single line.
TCK_DIVISOR
configures the clock rate for the data transfers. The first argument byte is basically the low byte of a clock divider value, the second byte the high byte. The exact formula for the frequency is given in AN2232C-01 Command Processor asTCK/SK period = 12MHz / (( 1 +[(0xValueH * 256) OR 0xValueL] ) * 2
.The
DIS_ADAPTIVE
andDIS_3_PHASE
commands take no arguments and disable adaptive and 3 phase clocking features. Refer to the linked sources for details.The
SET_BITS_LOW
command is used to set the initial value of theADBUS
pins. The first argument specifies as a bitmask all pins that should be set high, in this case onlyNSS
. The second bitmask specifies which pins are configured as outputs. Here, theSCK
,MOSI
andNSS
pins will be configured as outputs. Ultimately,NSS
will therefore be set high andSCK
andMOSI
will be set low.
The initial value of the SCK
pin will define the initial clock state.
For each data bit transferred, the MPSSE will then simply toggle the SCK
line twice.
Single Bit SPI Transfers
The MPSSE can write data as blocks of bytes or as bits. If we want to write an amount of bits not divisible by 8, we need to use the bit write command:
void writeTrigger(bool on)
{
uint8_t tbit = on ? 0b1 : 0b0;
uint8_t buf[] = {
// Set NSS low
SET_BITS_LOW, 0x00, OUTPUT_PINMASK,
// Write one Bit
MPSSE_DO_WRITE | MPSSE_WRITE_NEG | MPSSE_LSB | MPSSE_BITMODE, 0x00, tbit,
// Add some artitifical delay before toggling NSS
SET_BITS_LOW, 0, OUTPUT_PINMASK,
// Set NSS high
SET_BITS_LOW, (1 << PIN_NSS), OUTPUT_PINMASK
};
enforce(ftdi_write_data(&_ftdi, buf, sizeof(buf)) == sizeof(buf), "FTDI write failed");
}
We again send MPSSE commands using ftdi_write_data
.
Here’s how we implement an SPI transfer that writes a single bit of data:
SET_BITS_LOW
: First set theNSS
pin (and other output pins) low, as explained before.MPSSE_DO_WRITE
performs the actual data transfer and can be combined with flags to configure the transfer behavior.MPSSE_WRITE_NEG
writes data on the falling clock edge,MPSSE_LSB
writes data starting from the LSB to the MSB andMPSSE_BITMODE
specifies that the length argument specifies the number of bits, not bytes.In
MPSSE_BITMODE
, theMPSSE_DO_WRITE
command uses one argument as the number of bits (minus one) and the data is in the second byte argument. Specifying length0
therefore transfers 1 bit of data, the LSB of thetbit
variable.The next
SET_BITS_LOW
command simply sets all bits low. It is used to introduce an artificial delay before theNSS
signal goes back to high state. Please note that unlikeMPSSE_DO_WRITE
,SET_BITS_LOW
does not toggleSCK
. Accordingly, there won’t be any additional data transfers caused by this command.Finally,
SET_BITS_LOW
sets theNSS
signal back to the high level, the idle state.
Byte based SPI Transfers
Writing whole bytes works similarly and can be mixed with writing bits. Here’s an example:
void writeConfig(bool trigger, uint8_t adsr_ai, uint16_t osc_count, uint8_t filter_a)
{
uint8_t tbit = trigger ? 0b1 : 0b0;
uint8_t buf[] = {
SET_BITS_LOW, 0x00, OUTPUT_PINMASK,
MPSSE_DO_WRITE | MPSSE_WRITE_NEG | MPSSE_LSB | MPSSE_BITMODE, 0x00, tbit,
MPSSE_DO_WRITE | MPSSE_WRITE_NEG | MPSSE_LSB, 0x01, 0x00, adsr_ai, (uint8_t)(osc_count & 0xff),
MPSSE_DO_WRITE | MPSSE_WRITE_NEG | MPSSE_LSB | MPSSE_BITMODE, 0x03, (uint8_t)((osc_count >> 8) & 0x0f),
MPSSE_DO_WRITE | MPSSE_WRITE_NEG | MPSSE_LSB, 0x00, 0x00, filter_a
};
enforce(ftdi_write_data(&_ftdi, buf, sizeof(buf)) == sizeof(buf), "FTDI write failed");
usleep(3125);
uint8_t buf2[] = {
SET_BITS_LOW, (1 << PIN_NSS), OUTPUT_PINMASK
};
enforce(ftdi_write_data(&_ftdi, buf2, sizeof(buf2)) == sizeof(buf2), "FTDI write failed");
}
The first set of commands does the following:
SET_BITS_LOW
sets NSS low to start the transaction.MPSSE_DO_WRITE
withMPSSE_BITMODE
writes a single bit.MPSSE_DO_WRITE
writes 2 bytes of data. When in byte mode, the length is encoded as two bytes, the first being the low byte. Again, the length value written to the command stream is reduced by one.MPSSE_DO_WRITE
withMPSSE_BITMODE
writes four bits. Combined with the previous command, this shows how you can conveniently write a 12 bit data field.- The last command
MPSSE_DO_WRITE
writes another byte of data.
Note that at the end of the command, we do not release NSS
yet.
It’s perfectly fine to do this in another command, as shown here.
This will of course affect the timing, but sometimes this is intentional.
In this special case, I want the NSS
signal to be low for at least 3.125 ms.
I therefore added a usleep
call after writing the initial command stream.
The final SET_BITS_LOW
to release NSS
is then issued in another ftdi_write_data
call, after the usleep
.
Bringing it all Together
With all functions prepared, we can not write the main function:
int main(int argc, char* argv[])
{
auto dev = new SPIDriver();
dev->open();
dev->writeTrigger(true);
dev->writeConfig(true, 0, 0, 0);
dev->close();
return 0;
}
And that’s it, this code should be all you need to send SPI data using a FT232H. If you need to read data as well, refer to the linked documents. The process is however essentially the same.