Qt for embedded systems

Qt runs on Linux-based embedded systems (i.MX6, i.CORE, Pi) via Qt for Device Creation and the eglfs or linuxfb backends — no X11 required. It also supports bare-metal MCUs (STM32, NXP) via Qt for MCUs (QUL), though QUL uses a subset of the API.

This article focuses on Qt for Linux-based embedded HMIs: an industrial panel, a touchscreen controller, or an embedded PC. The signal-slot architecture and threading patterns are the same as desktop Qt.


Signal-slot fundamentals

Qt’s signal-slot mechanism is the primary communication channel between objects. Unlike callbacks or function pointers, signals and slots decouple sender from receiver — the sender doesn’t know who’s listening.

 1// sensor_worker.h
 2class SensorWorker : public QObject {
 3    Q_OBJECT
 4public:
 5    explicit SensorWorker(QObject* parent = nullptr);
 6
 7public slots:
 8    void startReading();     // callable from any thread via QMetaObject::invokeMethod
 9
10signals:
11    void readingReady(float temperature, float humidity);
12    void error(QString message);
13};
14
15// sensor_worker.cpp
16void SensorWorker::startReading() {
17    float temp, humid;
18    if (!readI2cSensor(&temp, &humid)) {
19        emit error("Sensor read failed");
20        return;
21    }
22    emit readingReady(temp, humid);
23}

Connection in the UI:

1SensorWorker* worker = new SensorWorker;
2MainWindow*   window = new MainWindow;
3
4QObject::connect(worker, &SensorWorker::readingReady,
5                 window, &MainWindow::updateDisplay);
6
7QObject::connect(worker, &SensorWorker::error,
8                 window, &MainWindow::showError);

MainWindow::updateDisplay(float, float) is called whenever readingReady is emitted. Neither object holds a pointer to the other; the connection is the only coupling.


Keeping the UI responsive — background workers

Never run blocking I/O, sensor reads, or serial communication on the main thread. The Qt event loop must stay unblocked to process user input and repaint.

Pattern: QObject worker on a QThread

 1// main.cpp or mainwindow.cpp
 2QThread*      workerThread = new QThread;
 3SensorWorker* worker       = new SensorWorker;
 4
 5worker->moveToThread(workerThread);  // worker now lives on workerThread
 6
 7// Start the worker when the thread starts
 8QObject::connect(workerThread, &QThread::started, worker, &SensorWorker::start);
 9
10// Clean up
11QObject::connect(workerThread, &QThread::finished, worker, &QObject::deleteLater);
12QObject::connect(workerThread, &QThread::finished, workerThread, &QObject::deleteLater);
13
14workerThread->start();

After moveToThread, any slot invoked on worker runs on workerThread, not on the calling thread. Signal-slot connections across threads use Qt::QueuedConnection automatically — the call is posted to the receiver’s thread event queue rather than called directly.

1// This is safe — readingReady is emitted on workerThread,
2// updateDisplay runs on the main thread via queued connection
3QObject::connect(worker,  &SensorWorker::readingReady,
4                 window, &MainWindow::updateDisplay);

Periodic sensor reads with QTimer

 1class SensorWorker : public QObject {
 2    Q_OBJECT
 3    QTimer* timer_ = nullptr;
 4
 5public:
 6    void start() {
 7        timer_ = new QTimer(this);
 8        connect(timer_, &QTimer::timeout, this, &SensorWorker::readSensor);
 9        timer_->start(250);  // 250 ms interval
10    }
11
12private slots:
13    void readSensor() {
14        float temp = readI2cSensor();
15        emit readingReady(temp);
16    }
17
18signals:
19    void readingReady(float temperature);
20};

QTimer lives on the worker thread (created in start() which runs on the worker thread). Its timeout signal fires on the worker thread, readSensor runs on the worker thread — no cross-thread issues, no manual sleep loops.


Sending commands to the worker

To send data from the UI thread to the worker, use a queued connection or QMetaObject::invokeMethod:

1// UI button press — set fan speed
2connect(fanSlider, &QSlider::valueChanged, this, [this, worker](int value) {
3    // Posts setFanSpeed(value) call to workerThread's event queue
4    QMetaObject::invokeMethod(worker, "setFanSpeed",
5                              Qt::QueuedConnection, Q_ARG(int, value));
6});

Or with a lambda signal-slot:

1// Declare in SensorWorker
2public slots:
3    void setFanSpeed(int percent);
4
5// Connect — Qt automatically uses QueuedConnection across threads
6connect(ui->fanSlider, &QSlider::valueChanged,
7        worker, &SensorWorker::setFanSpeed);

The slider fires on the main thread; setFanSpeed executes on the worker thread — safe, no manual synchronisation.


Safe shared data — avoid it

The temptation when sharing data between threads: std::mutex around a shared variable. This works but creates contention and couples the threads.

Prefer signal-slot copies: pass data by value through signals. Qt copies the arguments into the queued call. For small types (floats, ints, structs) this is cheaper than locking.

1signals:
2    void readingReady(float temp, float humid);   // two floats — 8 bytes
3    void frameReady(QByteArray data);             // Qt copies the byte array

For large data: use std::shared_ptr — Qt queued connections transfer shared ownership atomically:

1signals:
2    void frameReady(std::shared_ptr<DataFrame> frame);

The producer emits a shared_ptr — the consumer receives it. No raw pointer lifetime issues, no copy of large buffers.


QML for the display layer

For modern embedded HMIs, QML separates the visual layer from C++ logic:

 1// main.qml
 2import QtQuick 2.15
 3
 4Rectangle {
 5    width: 800; height: 480
 6    color: "#1a1a1a"
 7
 8    Text {
 9        id: tempLabel
10        text: sensorModel.temperature.toFixed(1) + "°C"
11        color: "#f0a030"
12        font.pixelSize: 48
13    }
14
15    Text {
16        text: sensorModel.humidity.toFixed(0) + "%"
17        color: "#80c0ff"
18        font.pixelSize: 32
19    }
20}

The sensorModel is a C++ QObject exposed to QML:

 1class SensorModel : public QObject {
 2    Q_OBJECT
 3    Q_PROPERTY(float temperature READ temperature NOTIFY temperatureChanged)
 4    Q_PROPERTY(float humidity    READ humidity    NOTIFY humidityChanged)
 5
 6public:
 7    float temperature() const { return temperature_; }
 8    float humidity()    const { return humidity_; }
 9
10public slots:
11    void updateReading(float temp, float humid) {
12        temperature_ = temp;
13        humidity_    = humid;
14        emit temperatureChanged();
15        emit humidityChanged();
16    }
17
18signals:
19    void temperatureChanged();
20    void humidityChanged();
21
22private:
23    float temperature_ = 0.0f;
24    float humidity_    = 0.0f;
25};

Expose to QML in main.cpp:

 1SensorModel model;
 2SensorWorker worker;
 3
 4// Worker signal → model slot (both on correct threads via queued connection)
 5QObject::connect(&worker, &SensorWorker::readingReady,
 6                 &model,  &SensorModel::updateReading);
 7
 8QQmlApplicationEngine engine;
 9engine.rootContext()->setContextProperty("sensorModel", &model);
10engine.load(QUrl("qrc:/main.qml"));

QML binds to sensorModel.temperature — when temperatureChanged fires, QML re-evaluates the binding and updates the Text label. The UI thread, C++ model, and worker thread are fully decoupled.


Touchscreen input handling

Qt handles touchscreen input via QTouchEvent and its abstraction through QQuickItem::MouseArea (QML) or QWidget::mousePressEvent:

 1// QML touch button
 2Rectangle {
 3    width: 120; height: 60
 4    color: pressed ? "#f0a030" : "#333333"
 5    radius: 8
 6
 7    property bool pressed: false
 8
 9    MouseArea {
10        anchors.fill: parent
11        onPressed:  { parent.pressed = true;  backend.onFanToggle(); }
12        onReleased: { parent.pressed = false; }
13    }
14}

backend.onFanToggle() is a Q_INVOKABLE slot on a C++ object exposed to QML — it runs on the main thread and can post to the worker thread via QMetaObject::invokeMethod.


Deployment on embedded Linux

1# Cross-compile Qt for your target
2./configure -device linux-rasp-pi4-oe-g++ -device-option CROSS_COMPILE=arm-linux-gnueabihf-
3make -j4
4
5# Deploy your app (rsync or scp)
6rsync -avz myapp root@192.168.1.10:/home/user/
7
8# Run on target with eglfs backend (no X11)
9./myapp -platform eglfs

For production, add a systemd service that starts the HMI on boot and restarts it on crash. Pass -platform eglfs or configure it in qt.conf.


Summary

  • Signal-slot: decouple sensor/device code from UI — emitter knows nothing about receivers
  • Worker thread: moveToThread — sensor reads and serial I/O off the main thread
  • QTimer in worker: periodic reads with no sleep loops
  • Queued connections: cross-thread signal-slot is safe by default — Qt posts to the event queue
  • Pass data by value or shared_ptr through signals — avoid shared mutable state
  • QML + Q_PROPERTY + NOTIFY: reactive UI that updates when C++ data changes

What’s next