Skip to content

Speaking CAN: Request and Response

Controller Area Networks (CANs, for short) are used for the communication between electronic control units (ECUs) in cars, trucks, tractors, harvesters, construction machines, packaging lines and e-bikes. Qt provides the Qt CAN Bus API as part of its QtSerialBus module. Qt CAN Bus provides a clean and simple API to connect to a CAN bus, to send and receive CAN frames over the CAN bus.

When we build an HMI application to control a machine, we need a lot more functionality than the Qt CAN Bus API should offer. The HMI application must not send CAN frames too fast to avoid buffer overflows in the CAN controller. When the HMI application receives, say, more than 200 CAN frames per second and tries to display the changes, it will freeze. The HMI application must be able to recover from CAN bus errors. We do not want to write the code for serialising and deserialising thousands of CAN messages manually, but want to generate this code automatically.

I’ll show solutions to these problems in a loose series of blog posts called Speaking CAN. In this first post, I’ll explain how an HMI application running on a display computer retrieves the value of a parameter from an ECU. The terminal sends a request to the ECU over CAN and receives a response from the ECU over CAN.

Setup

The next diagram shows the hardware needed for CAN communication.

Setup for CAN communication
Hardware setup for CAN communication between terminal app and ecu app

In the red corner, we have a display computer (a.k.a. terminal), which runs the terminal app on a custom Linux system and on an i.MX6 single-board computer (SBC) like the BD-SL-i.MX6. We can replace the i.MX6 by any ARM Cortex-A SoC as long as the SoC has a CAN connector.

In the blue corner, we have a standard PC, which runs the ecu app on a standard Linux like Ubuntu 16.04 LTS or newer and on an Intel processor. The standard PC can be any laptop or desktop PC running Linux natively or in a virtual machine. The ecu app simulates the communication part of a real ECU.

We plug one end of a CAN cable with termination resistors (e.g. Peak-System’s PCAN-Cable 2) into the display computer and the other end into the CAN-USB adapter (e.g. Peak-System’s PCAN-USB FD adapter). The USB end of the adapter goes into the PC.

We could run the terminal app on a PC as well. Then, we would need a second CAN-USB adapter and would connect the two adapters with a CAN cable. On the PC, we could use Windows instead of Linux. We would then have to install the Windows driver for the CAN variant (e.g. PCAN) used. Linux desktop kernels come with SocketCAN drivers preinstalled.

If we use systemd to control the Linux start-up on the display computer or on the PC, we must provide a service file for each CAN bus. The service file can0.service to configure and start the network interface for CAN bus can0 looks as follows.

[Unit]
Description=can0

[Service]
ExecStartPre=/sbin/ip link set can0 type can bitrate 250000 restart-ms 100
ExecStart=/sbin/ifconfig can0 up txqueuelen 10
Restart=on-failure
RestartSec=1

[Install]
WantedBy=multi-user.target

The command ExecStartPre sets the type of the network interface can0 to can, sets the bitrate to 250,000 bits per second and restarts the CAN controller 100ms after a bus-off error occurred. We must install the iproute2 package for the ip command to work. The command ExecStart sets the network interface can0 up and running and sets the length of the write queue to 10 CAN frames.

I use a small buffer size on purpose, because I want to demonstrate a buffer overflow in one of the next posts. In real-life applications, we must find a buffer size that works fine for most usage scenarios without an overflow. I have found 64 or 128 to be good buffer sizes.

Of course, we could manually enter the commands for ExecStartPre and ExecStart in a Linux terminal to bring up the network interfaces. However, we would have to repeat the commands after every reboot.

If our system uses init.d for system startup, we add the following lines to the configuration file /etc/network/interfaces.

auto can0
iface can0 inet manual
    pre-up /sbin/ip link set $IFACE type can bitrate 250000 restart-ms 100
    up /sbin/ifconfig $IFACE up txqueuelen 10
    down /sbin/ifconfig $IFACE down

Ubuntu 16.04 LTS uses this method as well.

Connecting to the CAN bus

The main function in terminal/main.cpp creates the main model TerminalModel. The constructor of class TerminalModel connects to CAN bus can0 using the socketcan plugin of Qt CAN bus. If an error occurs, the constructor emits the error text errorStr with the Qt signal logMessage. It delays the signal emission with Qt::QueuedConnection until the event loop is up and running. Otherwise, the signal would get lost.

// TerminalModel constructor    
auto errorStr = QString{};
m_can0.reset(CanBus::setUp(QStringLiteral("socketcan"),
                           QStringLiteral("can0"), errorStr));
if (!errorStr.isEmpty()) {
    QMetaObject::invokeMethod(
        this,
        [this, errorStr]() { emit logMessage(errorStr); },
        Qt::QueuedConnection);
}

The third line calls the function CanBus::setup (see the files ecucan/canbus.h and ecucan/canbus.cpp), which does the heavy lifting.

QCanBusDevice *setUp(const QString &plugin, 
                     const QString &interface, 
                     QString &errorStr)
{
    auto device = QCanBus::instance()->createDevice
                      (plugin, interface, &errorStr);
    if (device != nullptr && !device->connectDevice()) {
        errorStr = device->errorString();
    }
    return device;
}

The function QCanBus::createDevice creates a QCanBusDevice for the given CAN plugin (here: socketcan) and for the given network interface (here: can0). If the plugin name does not exist, the function returns nullptr. If the network interface isn’t yet up and running, the function returns a non-null pointer to a QCanBusDevice and returns an error message through &errorStr.

When device is non-null, the setup function tries to connect to the CAN bus device. Connecting to a device is nothing else but opening a CAN socket. If the connection fails, errorStr is set to the error message. The setup function returns the newly created QCanBusDevice to the TerminalModel constructor. As the created QCanBusDevice may be null, all subsequent functions must be able to deal with m_can0 possibly being null.

When the main function exits, it will call the TerminalModel destructor. The destructor calls the function CanBus::tearDown.

CanBus::tearDown(m_can0.get());

The tearDown function disconnects the QCanBusDevice stored in m_can0, if the CAN bus device is connected. Disconnecting an unconnected device yields an error and doesn’t close the device properly.

void tearDown(QCanBusDevice *device)
{
    if (device != nullptr && 
        device->state() == QCanBusDevice::ConnectedState) {
        device->disconnectDevice();
    }
}

The ecu app does exactly the same – only in the class EcuModel instead of TerminalModel.

A Full Round Trip

The next figure shows the terminal app on the left and the ecu app on the right. When we press the button Tx 10 in the terminal, the terminal sends 10 requests to read the values of the parameters with IDs from 1 to 10 and displays each request in its GUI. Let us look at the round trip for requesting the value of parameter 3.

  • Step 1. The terminal sends the request for parameter 3 via CAN bus can0 to the ecu and displays Trm/Send: Read(0x0003, 0x00000000) in its GUI. The value in the request is irrelevant and hence set to 0.
  • Step 2. The ecu app receives the request and displays Ecu/Send: Read(0x0003, 0x00000000) in its GUI.
  • Step 3. The ecu looks up the value of parameter 3 as 0x0306b1d3, sends the response via CAN bus can0 to the terminal and displays Ecu/Send: Read(0x0003, 0x0306b1d3).
  • Step 4. The terminal receives the response and displays Trm/Recv: Read(0x0003, 0x0306b1d3).
The terminal (left) sends 10 read-parameter requests. The ECU (right) fills in the parameter values and sends back a response.

Step 1 – Trm/Send: Read(0x0003, 0x00000000)

Let us now look under the hood of the GUI. When we press the Tx 10 button, the QML engine calls the button’s event handler:

onReleased: gTerminal.simulateTxBufferOverflow(10)

The global property gTerminal is bound to the C++ object TerminalModel, which is created in the main function of terminal. The onReleased handler calls the function TerminalModel::simulateTxBufferOverflow.

void TerminalModel::simulateTxBufferOverflow(int count)
{
    for (quint16 i = 1; i <= count; ++i) {
        m_a2Proxy->sendReadParameter(i);
    }
}

The for-loop calls the function EcuProxy::sendReadParameter with the parameter IDs from 1 to 10, as count is 10.

void EcuProxy::sendReadParameter(quint16 pid, quint32 value)
{
    emitReadParameterMessage(QStringLiteral("Trm/Send"), 
                             pid, value);
    canBus()->writeFrame(
        QCanBusFrame(0x18ef0201U, encodeReadParameter(pid, value))
    );
}

The function sendReadParameter has two arguments pid (parameter ID) and value, where value is default-initialised to 0U. So, the call for parameter ID 3 becomes sendReadParameter(3U, 0U), which explains the log message Trm/Send: Read(0x0003, 0x00000000).

The function QCanBusDevice::writeFrame takes the QCanBusFrame created in place and writes it to CAN bus can0. A CAN frame consists of a frame ID (here: 0x18ef0201U) and a payload (here: encodeReadParameter(pid, value)).

The frame ID 0x18ef0201U conforms to the SAE J1939 standard, which is widely used in agricultural, construction and commercial vehicles. It denotes a low-priority command request (0x18ef) sent from the device with CAN ID 0x01 (the terminal) to the device with the CAN ID 0x02 (the ecu). Although the frame ID seems to define a peer-to-peer message, all CAN devices will see the message. CAN messages are always broadcasted on the CAN bus.

The payload of the CAN frame is a byte array. The array size ranges from 0 to 8 bytes. The J1939 standard uses a fixed size of 8 bytes. The function encodeReadParameter serialises the ID 1 for the read-parameter command, the pid and the value of a parameter into an array of 8 bytes.

QByteArray EcuBase::encodeReadParameter(quint16 pid, quint32 value)
{
    QByteArray payload(8, 0x00);
    qToLittleEndian(quint8(1), payload.data());
    qToLittleEndian(pid, payload.data() + 1);
    qToLittleEndian(value, payload.data() + 3);
    return payload;
}

The function initialises payload with eight zeros. CAN expects all numbers to be transferred in little-endian format. We use the Qt function qToLittleEndian to convert the command ID 1, the pid and the value into little-endian format and to store them at position 0, at positions 1 and 2, and at positions 3 to 6, respectively, in the payload array.

The function sendReadParameter creates the CAN frame

    18 ef 02 01 # 01 03 00 00 00 00 00 00

from the frame ID and the payload returned by encodeReadParamter and writes the frame on the CAN bus can0.

Step 2 – Ecu/Recv: Read(0x0003, 0x00000000)

The ecu app runs on a different device, which is connected to the terminal device with a CAN cable. It listens to incoming CAN frames in classic Qt fashion with a signal-slot connection .

// EcuBase constructor in ecucan/ecubase.cpp
connect(m_canBus.get(), &QCanBusDevice::framesReceived,
        this, &EcuBase::onFramesReceived);

The slot EcuBase::onFramesReceived is called whenever a CAN frame arrives on the CAN bus.

void EcuBase::onFramesReceived()
{
    ...
    auto count = canBus()->framesAvailable();
    for (qint64 i = count; i > 0; --i) {
        auto frame = canBus()->readFrame();
        if (isReadParameter(frame)) {
            receiveReadParameter(frame);
        }
    }
}

The for-loop iterates over all CAN frames currently available. It reads a frame and removes the frame from its internal queue. If the frame is a read-parameter frame, it calls the function Ecu::receiveReadParameter to deserialise the CAN frame.

void Ecu::receiveReadParameter(const QCanBusFrame &frame)
{
    quint16 pid = 0U;
    quint32 value = 0U;
    std::tie(pid, value) = decodeReadParameter(frame);
    emitReadParameterMessage(QStringLiteral("Ecu/Recv"),
                             pid, value);
    sendReadParameter(pid, QRandomGenerator::global()->generate());
}

The function decodeReadParameter performs the inverse conversion of encodeReadParameter from Step 1. It converts the payload bytes 1 and 2 from little-endian format into the integer pid and the bytes 3-6 into the integer value.

std::tuple<quint16, quint32> EcuBase::decodeReadParameter(const QCanBusFrame &frame)
{
    const auto &payload = frame.payload();
    quint16 pid = qFromLittleEndian<qint16>(payload.data() + 1);
    quint32 value = qFromLittleEndian<qint32>(payload.data() + 3);
    return std::make_tuple(pid, value);
}

The function qFromLittleEndian works only with qint16, qint32 or qint64. We force the conversion to quint16 for pid and quint32 for value by declaring pid and value explicitly as quint16 and quint32 instead of using auto.

The function decodeReadParameter returns pid and value to the calling function receiveReadParameter. The latter function displays the log message Ecu/Recv: Read(0x0003, 0x00000000) and calls the function Ecu::sendReadParameter with the extracted pid and a random number as the value. A real ECU would read the value of the parameter from EEPROM or RAM.

Step 3 – Ecu/Send: Read(0x0003, 0x0306b1d3)

The function Ecu::sendReadParameter sends the response to a read-parameter request back to the terminal device. It first displays the message Ecu/Send: Read(0x0003, 0x0306b1d3) in its log window. It then creates the response frame and writes the frame to the CAN bus can0.

void Ecu::sendReadParameter(quint16 pid, quint32 value)
{
    emitReadParameterMessage(QStringLiteral("Ecu/Send"), pid, value);
    canBus()->writeFrame(QCanBusFrame(0x18ef0102U, 
                         encodeReadParameter(pid, value)));
}

The frame ID is nearly the same as on the terminal side except that the target ID is now 0x01 for terminal and the source ID is now 0x02 for ecu. The function encodeReadParameter serialises pid and value into a byte array as described in Step 1 above. The ecu devices sends the response frame

    18 ef 01 02 # 01 03 00 d3 b1 06 03 00

over CAN bus can0 to the terminal device.

Step 4: Trm/Recv: Read(0x0003, 0x0306b1d3)

When the terminal app receives the response frame from the ecu app, it calls the slot EcuBase::onFramesReceived. This is the same slot the ecu app calls in Step 2. This time, however, the slot is called on an EcuProxy object instead of an Ecu object. Hence, the slot calls the function EcuProxy::receiveReadParameter to handle the response.

void EcuProxy::receiveReadParameter(const QCanBusFrame &frame)
{
    quint16 pid = 0U;
    quint32 value = 0U;
    std::tie(pid, value) = decodeReadParameter(frame);
    emitReadParameterMessage(QStringLiteral("Trm/Recv"), pid, value);
}

This function is nearly the same as the function Ecu::receiveReadParameter of Step 2 – except that it need not prepare a response. The round trip for reading a parameter value ends here with displaying the message Trm/Recv: Read(0x0003, 0x0306b1d3) in the log window of the terminal.

Getting the Example Code

The example code is available on GitHub. You get it with the following commands.

$ git clone https://github.com/bstubert/embeddeduse.git
$ cd embeddeduse
$ git checkout can-comm-1
$ cd BlogPosts/CanComm

CanComm is a normal CMake-based Qt project.

Leave a Reply

Your email address will not be published. Required fields are marked *