burgiclab Logoburgiclab

Qt6 for Embedded HMI Development: Building Demo Applications for ARM Platforms

by Sani Saša BurgićDevelopment

Practical guide to developing embedded HMI applications with Qt6, C++, and GStreamer on ARM Cortex-M platforms, including performance optimization and integration patterns for industrial systems.

QtQt6HMIEmbeddedGStreamerC++

Qt has evolved into one of the most powerful frameworks for embedded Human-Machine Interface (HMI) development. At TTControl, I've been using Qt6 with C++ and GStreamer to create demo applications for complex multi-core ARM systems. Here's what I've learned about building efficient, maintainable embedded HMI applications.

Why Qt for Embedded Systems?

The Qt Advantage

Cross-Platform Development

  • Write once, deploy on multiple platforms
  • Consistent API across desktop and embedded
  • Easy prototyping on development machines

Rich Widget Library

  • Pre-built UI components
  • Touch-optimized controls
  • Customizable styling (QML/Qt Quick)

Hardware Acceleration

  • OpenGL ES support
  • Efficient rendering pipeline
  • GPU utilization on ARM platforms

Proven in Industry

  • Automotive dashboards
  • Industrial HMIs
  • Medical devices
  • Aviation systems

Qt6 on ARM Cortex-M4: The NXP iMX8QM Experience

The NXP iMX8QM SoC presents interesting challenges for Qt development:

  • Multiple ARM Cortex-M4 processors
  • Limited resources compared to application processors
  • Real-time constraints
  • Integration with safety-critical systems

Architecture Overview

+-----------------------------------+
|     Application Processor         |
|    (Cortex-A53/A72 - Linux)       |
|                                   |
|  +---------------------------+    |
|  |      Qt6 Application      |    |
|  |    (QML/C++ Frontend)     |    |
|  +---------------------------+    |
|              |                    |
|              v                    |
|  +---------------------------+    |
|  |   GStreamer Pipeline      |    |
|  |   (Video Processing)      |    |
|  +---------------------------+    |
+-----------------------------------+
              |
              v (IPC/RPC)
+-----------------------------------+
|     Real-Time Processor           |
|    (Cortex-M4 - FreeRTOS)         |
|                                   |
|  +---------------------------+    |
|  |   Control Logic           |    |
|  |   Sensor Processing       |    |
|  +---------------------------+    |
+-----------------------------------+

Setting Up Qt6 for Embedded Development

Cross-Compilation Environment

# Install Qt6 for embedded Linux
wget https://download.qt.io/official_releases/qt/6.5/6.5.3/...
./qt-unified-linux-x64-online.run

# Configure for ARM target
mkdir build && cd build
cmake .. \
    -DCMAKE_TOOLCHAIN_FILE=../toolchain-arm.cmake \
    -DQT_HOST_PATH=/opt/Qt/6.5.3/gcc_64 \
    -DQT_QMAKE_TARGET_MKSPEC=linux-arm-gnueabi-g++ \
    -DCMAKE_INSTALL_PREFIX=/opt/qt6-arm

Minimal Qt Configuration

For embedded systems, minimize footprint:

# CMakeLists.txt for embedded Qt
cmake_minimum_required(VERSION 3.16)
project(EmbeddedHMI VERSION 1.0.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(Qt6 REQUIRED COMPONENTS
    Core
    Gui
    Widgets
    Multimedia
    # Omit unused modules
)

# Optimize for size
add_compile_options(-Os -ffunction-sections -fdata-sections)
add_link_options(-Wl,--gc-sections)

add_executable(hmi_app
    main.cpp
    mainwindow.cpp
    videowidget.cpp
)

target_link_libraries(hmi_app
    Qt6::Core
    Qt6::Gui
    Qt6::Widgets
    Qt6::Multimedia
)

Building a Demo Application: Industrial HMI

Main Application Structure

// main.cpp
#include <QApplication>
#include <QWidget>
#include "mainwindow.h"

int main(int argc, char *argv[]) {
    // Enable platform-specific optimizations
    qputenv("QT_QPA_PLATFORM", "eglfs");
    qputenv("QT_QPA_EGLFS_INTEGRATION", "eglfs_kms");

    // Disable desktop-specific features
    qputenv("QT_QPA_EGLFS_DISABLE_INPUT", "0");

    QApplication app(argc, argv);

    // Set application-wide style
    app.setStyle("Fusion");

    MainWindow window;
    window.showFullScreen();

    return app.exec();
}

Main Window Implementation

// mainwindow.h
#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include <QTimer>
#include <QLabel>
#include <QPushButton>
#include "videowidget.h"
#include "dataacquisition.h"

class MainWindow : public QMainWindow {
    Q_OBJECT

public:
    explicit MainWindow(QWidget *parent = nullptr);
    ~MainWindow();

private slots:
    void updateSensorData();
    void handleEmergencyStop();
    void toggleVideoFeed();

private:
    void setupUI();
    void setupConnections();

    QLabel *m_statusLabel;
    QLabel *m_sensorDisplay;
    QPushButton *m_emergencyStopBtn;
    VideoWidget *m_videoWidget;
    QTimer *m_updateTimer;
    DataAcquisition *m_dataAcq;
};

#endif
// mainwindow.cpp
#include "mainwindow.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QFont>

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
    , m_dataAcq(new DataAcquisition(this))
{
    setupUI();
    setupConnections();

    // Start periodic updates
    m_updateTimer = new QTimer(this);
    connect(m_updateTimer, &QTimer::timeout,
            this, &MainWindow::updateSensorData);
    m_updateTimer->start(100); // 10 Hz update rate
}

void MainWindow::setupUI() {
    QWidget *central = new QWidget(this);
    QVBoxLayout *mainLayout = new QVBoxLayout(central);

    // Status bar
    m_statusLabel = new QLabel("System Ready", this);
    m_statusLabel->setStyleSheet(
        "QLabel { "
        "   background-color: #2ecc71; "
        "   color: white; "
        "   padding: 10px; "
        "   font-size: 18pt; "
        "   font-weight: bold; "
        "}"
    );
    mainLayout->addWidget(m_statusLabel);

    // Video feed
    m_videoWidget = new VideoWidget(this);
    m_videoWidget->setMinimumSize(800, 600);
    mainLayout->addWidget(m_videoWidget);

    // Sensor display
    m_sensorDisplay = new QLabel("Sensor Data: --", this);
    QFont font = m_sensorDisplay->font();
    font.setPointSize(14);
    m_sensorDisplay->setFont(font);
    mainLayout->addWidget(m_sensorDisplay);

    // Emergency stop button
    m_emergencyStopBtn = new QPushButton("EMERGENCY STOP", this);
    m_emergencyStopBtn->setStyleSheet(
        "QPushButton { "
        "   background-color: #e74c3c; "
        "   color: white; "
        "   font-size: 20pt; "
        "   font-weight: bold; "
        "   padding: 20px; "
        "   border-radius: 10px; "
        "}"
        "QPushButton:pressed { "
        "   background-color: #c0392b; "
        "}"
    );
    m_emergencyStopBtn->setMinimumHeight(100);
    mainLayout->addWidget(m_emergencyStopBtn);

    setCentralWidget(central);
}

void MainWindow::setupConnections() {
    connect(m_emergencyStopBtn, &QPushButton::clicked,
            this, &MainWindow::handleEmergencyStop);

    connect(m_dataAcq, &DataAcquisition::dataReady,
            this, &MainWindow::updateSensorData);
}

void MainWindow::updateSensorData() {
    SensorData data = m_dataAcq->getCurrentData();

    QString displayText = QString(
        "Temperature: %1°C | "
        "Pressure: %2 bar | "
        "Speed: %3 RPM"
    ).arg(data.temperature, 0, 'f', 1)
     .arg(data.pressure, 0, 'f', 2)
     .arg(data.speed);

    m_sensorDisplay->setText(displayText);

    // Update status based on sensor readings
    if (data.temperature > 80.0 || data.pressure > 10.0) {
        m_statusLabel->setText("WARNING: Parameters Exceeded");
        m_statusLabel->setStyleSheet(
            "QLabel { background-color: #e67e22; "
            "color: white; padding: 10px; }"
        );
    } else {
        m_statusLabel->setText("System Normal");
        m_statusLabel->setStyleSheet(
            "QLabel { background-color: #2ecc71; "
            "color: white; padding: 10px; }"
        );
    }
}

void MainWindow::handleEmergencyStop() {
    m_updateTimer->stop();
    m_videoWidget->stopFeed();

    m_statusLabel->setText("EMERGENCY STOP ACTIVATED");
    m_statusLabel->setStyleSheet(
        "QLabel { background-color: #c0392b; "
        "color: white; padding: 10px; }"
    );

    // Send stop command to control system
    m_dataAcq->sendEmergencyStop();
}

MainWindow::~MainWindow() {
    m_updateTimer->stop();
}

GStreamer Integration for Video

Video Widget with GStreamer Pipeline

// videowidget.h
#ifndef VIDEOWIDGET_H
#define VIDEOWIDGET_H

#include <QWidget>
#include <gst/gst.h>
#include <gst/video/videooverlay.h>

class VideoWidget : public QWidget {
    Q_OBJECT

public:
    explicit VideoWidget(QWidget *parent = nullptr);
    ~VideoWidget();

    void startFeed(const QString &source);
    void stopFeed();

protected:
    void resizeEvent(QResizeEvent *event) override;

private:
    void initializeGStreamer();
    void createPipeline(const QString &source);

    GstElement *m_pipeline;
    GstElement *m_videoSink;
    WId m_windowId;
};

#endif
// videowidget.cpp
#include "videowidget.h"
#include <QDebug>

VideoWidget::VideoWidget(QWidget *parent)
    : QWidget(parent)
    , m_pipeline(nullptr)
    , m_videoSink(nullptr)
{
    // Enable native video rendering
    setAttribute(Qt::WA_NativeWindow);
    setAttribute(Qt::WA_PaintOnScreen);

    m_windowId = winId();
    initializeGStreamer();
}

void VideoWidget::initializeGStreamer() {
    gst_init(nullptr, nullptr);
}

void VideoWidget::createPipeline(const QString &source) {
    // Create GStreamer pipeline
    QString pipelineStr = QString(
        "v4l2src device=%1 ! "
        "video/x-raw,width=800,height=600,framerate=30/1 ! "
        "videoconvert ! "
        "videoscale ! "
        "video/x-raw,width=800,height=600 ! "
        "waylandsink"
    ).arg(source);

    GError *error = nullptr;
    m_pipeline = gst_parse_launch(pipelineStr.toUtf8().constData(), &error);

    if (error) {
        qCritical() << "GStreamer pipeline error:" << error->message;
        g_error_free(error);
        return;
    }

    // Get video sink for window embedding
    m_videoSink = gst_bin_get_by_interface(
        GST_BIN(m_pipeline),
        GST_TYPE_VIDEO_OVERLAY
    );

    if (m_videoSink) {
        gst_video_overlay_set_window_handle(
            GST_VIDEO_OVERLAY(m_videoSink),
            m_windowId
        );
    }
}

void VideoWidget::startFeed(const QString &source) {
    if (m_pipeline) {
        gst_element_set_state(m_pipeline, GST_STATE_NULL);
        gst_object_unref(m_pipeline);
    }

    createPipeline(source);

    if (m_pipeline) {
        gst_element_set_state(m_pipeline, GST_STATE_PLAYING);
    }
}

void VideoWidget::stopFeed() {
    if (m_pipeline) {
        gst_element_set_state(m_pipeline, GST_STATE_NULL);
    }
}

void VideoWidget::resizeEvent(QResizeEvent *event) {
    QWidget::resizeEvent(event);

    if (m_videoSink) {
        gst_video_overlay_set_render_rectangle(
            GST_VIDEO_OVERLAY(m_videoSink),
            0, 0, width(), height()
        );
    }
}

VideoWidget::~VideoWidget() {
    stopFeed();
    if (m_pipeline) {
        gst_object_unref(m_pipeline);
    }
}

Data Acquisition and IPC

Communication with Real-Time Core

// dataacquisition.h
#ifndef DATAACQUISITION_H
#define DATAACQUISITION_H

#include <QObject>
#include <QThread>

struct SensorData {
    double temperature;
    double pressure;
    double speed;
    quint64 timestamp;
};

class DataAcquisition : public QObject {
    Q_OBJECT

public:
    explicit DataAcquisition(QObject *parent = nullptr);
    ~DataAcquisition();

    SensorData getCurrentData() const;
    void sendEmergencyStop();

signals:
    void dataReady(const SensorData &data);
    void errorOccurred(const QString &error);

private slots:
    void processIncomingData();

private:
    void initializeIPC();
    void readSharedMemory();

    SensorData m_currentData;
    QThread *m_workerThread;
    int m_shmFd;
    void *m_shmAddr;
};

#endif
// dataacquisition.cpp
#include "dataacquisition.h"
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>

#define SHM_NAME "/sensor_data"
#define SHM_SIZE sizeof(SensorData)

DataAcquisition::DataAcquisition(QObject *parent)
    : QObject(parent)
    , m_shmFd(-1)
    , m_shmAddr(nullptr)
{
    initializeIPC();

    // Start worker thread for continuous monitoring
    m_workerThread = new QThread(this);
    connect(m_workerThread, &QThread::started,
            this, &DataAcquisition::processIncomingData);
    moveToThread(m_workerThread);
    m_workerThread->start();
}

void DataAcquisition::initializeIPC() {
    // Open shared memory
    m_shmFd = shm_open(SHM_NAME, O_RDWR, 0666);
    if (m_shmFd < 0) {
        emit errorOccurred("Failed to open shared memory");
        return;
    }

    // Map shared memory
    m_shmAddr = mmap(nullptr, SHM_SIZE, PROT_READ | PROT_WRITE,
                     MAP_SHARED, m_shmFd, 0);

    if (m_shmAddr == MAP_FAILED) {
        emit errorOccurred("Failed to map shared memory");
        close(m_shmFd);
        m_shmFd = -1;
    }
}

void DataAcquisition::processIncomingData() {
    while (true) {
        readSharedMemory();
        QThread::msleep(50); // 20 Hz polling rate
    }
}

void DataAcquisition::readSharedMemory() {
    if (!m_shmAddr) return;

    // Read sensor data from shared memory
    std::memcpy(&m_currentData, m_shmAddr, sizeof(SensorData));

    emit dataReady(m_currentData);
}

SensorData DataAcquisition::getCurrentData() const {
    return m_currentData;
}

void DataAcquisition::sendEmergencyStop() {
    // Trigger emergency stop via IPC mechanism
    // (Implementation depends on system architecture)
}

DataAcquisition::~DataAcquisition() {
    if (m_workerThread) {
        m_workerThread->quit();
        m_workerThread->wait();
    }

    if (m_shmAddr) {
        munmap(m_shmAddr, SHM_SIZE);
    }

    if (m_shmFd >= 0) {
        close(m_shmFd);
    }
}

Performance Optimization for Embedded

1. Reduce Memory Footprint

// Use static plugins to reduce library dependencies
#include <QtPlugin>

Q_IMPORT_PLUGIN(QWaylandEglPlatformIntegrationPlugin)
Q_IMPORT_PLUGIN(QWaylandIntegrationPlugin)

2. Optimize Rendering

// Enable layer backing for smoother animations
export QT_QPA_EGLFS_LAYER_BACKING=1

// Use scene graph precompilation
export QSG_RENDER_LOOP=threaded

3. Resource Management

// Lazy load heavy resources
class ResourceManager {
public:
    QPixmap getIcon(const QString &name) {
        if (!m_iconCache.contains(name)) {
            m_iconCache[name] = QPixmap(
                QString(":/icons/%1.png").arg(name)
            );
        }
        return m_iconCache[name];
    }

private:
    QHash<QString, QPixmap> m_iconCache;
};

Best Practices for Embedded Qt Development

1. Use EGLFS for Direct Rendering

Skip X11/Wayland overhead when possible

2. Profile Early and Often

# Use Qt's built-in profiling
QT_LOGGING_RULES="qt.qpa.*=true" ./hmi_app

# GStreamer debugging
GST_DEBUG=3 ./hmi_app

3. Design for Touch

  • Minimum 44x44 pixel touch targets
  • Visual feedback for all interactions
  • Gesture support where appropriate

4. Handle Resource Constraints

  • Limit concurrent animations
  • Unload unused resources
  • Use appropriate image formats (WebP for smaller size)

5. Test on Target Hardware Early

Desktop performance ≠ embedded performance

Conclusion

Qt6 provides a powerful foundation for embedded HMI development, but success requires understanding platform constraints and optimization techniques. The combination of Qt6, C++, and GStreamer enables creation of sophisticated, responsive interfaces even on resource-constrained ARM platforms.

Key takeaways:

  • Choose the right Qt modules - minimize footprint
  • Leverage hardware acceleration - EGLFS, OpenGL ES
  • Optimize for embedded - memory, CPU, power
  • Integrate properly - IPC with real-time cores
  • Test on target - early and continuously

Whether building industrial HMIs, automotive displays, or medical interfaces, Qt6 offers the tools needed for professional embedded development.


This guide reflects practical experience developing Qt6 applications for NXP iMX8QM ARM platforms. The patterns and techniques shown are production-tested in industrial environments where reliability and performance are critical.