Qt6 for Embedded HMI Development: Building Demo Applications for ARM Platforms
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.
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.