The Paho MQTT C library is the standard choice when you need a small, dependency-light MQTT client on embedded Linux. In this tutorial you will build Paho MQTT C with OpenSSL, write a TLS publisher and a subscriber, cross-compile for an ARM board, and run the result as a resilient systemd service connected to a cloud broker.
Why Paho MQTT C for Embedded Linux
Eclipse Paho C is plain C99 with one optional dependency, OpenSSL, for TLS. It runs on anything from a Raspberry Pi to an industrial gateway with a few megabytes of RAM, supports MQTT 3.1, 3.1.1 and 5, and is packaged by most distributions and Yocto layers. If you are choosing between languages and libraries, the overview of MQTT client libraries puts it in context; if you prefer C++, Paho also offers a C++ wrapper on top of this library.
The library ships four variants. Pick the right one at link time:
| Library | API style | TLS | Typical use |
|---|---|---|---|
paho-mqtt3c |
Synchronous MQTTClient_* |
No | Simple tools on trusted networks |
paho-mqtt3cs |
Synchronous MQTTClient_* |
Yes | Most embedded publishers and subscribers |
paho-mqtt3a |
Asynchronous MQTTAsync_* |
No | Event-driven apps |
paho-mqtt3as |
Asynchronous MQTTAsync_* |
Yes | Event-driven apps with TLS |
This tutorial uses paho-mqtt3cs: the synchronous API with TLS. "Synchronous" refers to connect and publish calls blocking until complete; incoming messages still arrive on a background thread through callbacks.
Installing or Building the Library
On Debian, Ubuntu or Raspberry Pi OS the packaged version is usually enough:
sudo apt update
sudo apt install -y build-essential libpaho-mqtt-dev libssl-dev ca-certificates
To get the latest release, or to cross-compile, build from source:
git clone https://github.com/eclipse-paho/paho.mqtt.c.git
cd paho.mqtt.c
cmake -B build -DPAHO_WITH_SSL=TRUE -DPAHO_BUILD_SAMPLES=FALSE -DPAHO_BUILD_STATIC=FALSE -DCMAKE_BUILD_TYPE=Release
cmake --build build -j"$(nproc)"
sudo cmake --install build
sudo ldconfig
The official Paho C documentation documents every structure and function used below.
A Paho MQTT C Publisher Over TLS
This program connects to broker.connectmq.com on port 8883, verifies the server certificate against the system CA bundle, and publishes a reading every five seconds. It works anonymously against the public MQTT test broker, and reads credentials from environment variables when you move to a private broker.
publisher.c:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <time.h>
#include <unistd.h>
#include "MQTTClient.h"
#define ADDRESS "ssl://broker.connectmq.com:8883"
#define CA_BUNDLE "/etc/ssl/certs/ca-certificates.crt"
#define QOS 1
#define TIMEOUT 10000L
static volatile sig_atomic_t running = 1;
static void on_signal(int sig) { (void)sig; running = 0; }
static void connection_lost(void *ctx, char *cause) {
(void)ctx;
fprintf(stderr, "connection lost: %s\n", cause ? cause : "unknown");
}
static int message_arrived(void *ctx, char *topic, int len, MQTTClient_message *msg) {
(void)ctx; (void)len;
MQTTClient_freeMessage(&msg);
MQTTClient_free(topic);
return 1;
}
static int connect_broker(MQTTClient client) {
MQTTClient_connectOptions opts = MQTTClient_connectOptions_initializer;
MQTTClient_SSLOptions ssl = MQTTClient_SSLOptions_initializer;
ssl.trustStore = CA_BUNDLE;
ssl.enableServerCertAuth = 1;
ssl.verify = 1; /* check the hostname too */
ssl.sslVersion = MQTT_SSL_VERSION_TLS_1_2;
opts.keepAliveInterval = 30;
opts.cleansession = 1;
opts.ssl = &ssl;
opts.username = getenv("MQTT_USERNAME"); /* NULL = anonymous */
opts.password = getenv("MQTT_PASSWORD");
int rc = MQTTClient_connect(client, &opts);
if (rc != MQTTCLIENT_SUCCESS)
fprintf(stderr, "connect failed, rc=%d\n", rc);
return rc;
}
int main(void) {
const char *client_id = getenv("MQTT_CLIENT_ID");
const char *prefix = getenv("MQTT_TOPIC_PREFIX");
char id_buf[64], topic[256], payload[128];
if (!client_id) {
snprintf(id_buf, sizeof id_buf, "paho-c-%d", (int)getpid());
client_id = id_buf;
}
if (!prefix) prefix = "connectmq/demo/paho-c-4821";
snprintf(topic, sizeof topic, "%s/gateway1/temperature", prefix);
signal(SIGINT, on_signal);
signal(SIGTERM, on_signal);
MQTTClient client;
int rc = MQTTClient_create(&client, ADDRESS, client_id,
MQTTCLIENT_PERSISTENCE_NONE, NULL);
if (rc != MQTTCLIENT_SUCCESS) {
fprintf(stderr, "create failed, rc=%d\n", rc);
return EXIT_FAILURE;
}
MQTTClient_setCallbacks(client, NULL, connection_lost, message_arrived, NULL);
int backoff = 1;
while (running && connect_broker(client) != MQTTCLIENT_SUCCESS) {
sleep(backoff);
if (backoff < 60) backoff *= 2;
}
printf("connected as %s, publishing to %s\n", client_id, topic);
srand((unsigned)time(NULL));
while (running) {
if (!MQTTClient_isConnected(client)) {
if (connect_broker(client) != MQTTCLIENT_SUCCESS) { sleep(5); continue; }
}
double temp = 20.0 + (rand() % 100) / 10.0;
int n = snprintf(payload, sizeof payload,
"{\"ts\":%ld,\"temperature\":%.1f}", (long)time(NULL), temp);
MQTTClient_message msg = MQTTClient_message_initializer;
MQTTClient_deliveryToken token;
msg.payload = payload;
msg.payloadlen = n;
msg.qos = QOS;
msg.retained = 0;
rc = MQTTClient_publishMessage(client, topic, &msg, &token);
if (rc == MQTTCLIENT_SUCCESS)
rc = MQTTClient_waitForCompletion(client, token, TIMEOUT);
if (rc != MQTTCLIENT_SUCCESS)
fprintf(stderr, "publish failed, rc=%d\n", rc);
else
printf("sent %s\n", payload);
sleep(5);
}
MQTTClient_disconnect(client, TIMEOUT);
MQTTClient_destroy(&client);
return EXIT_SUCCESS;
}
Build and run:
gcc -Wall -O2 -o publisher publisher.c -lpaho-mqtt3cs
./publisher
A few lines deserve explanation. ssl.verify = 1 turns on hostname verification, which is off by default; without it, any valid certificate for any host would be accepted. Setting MQTTClient_setCallbacks before connecting puts the client in multithreaded mode, so the library runs keep-alive and receive processing on its own thread and connection_lost tells you when the link drops. The publish loop checks MQTTClient_isConnected and reconnects with its own retry, because the synchronous API does not reconnect automatically.
A Subscriber With Callbacks
The subscriber reuses the same connection logic and does its work in message_arrived. Replace main with this (keeping the helpers above):
static int on_message(void *ctx, char *topic, int len, MQTTClient_message *msg) {
(void)ctx; (void)len;
printf("%s: %.*s\n", topic, msg->payloadlen, (char *)msg->payload);
MQTTClient_freeMessage(&msg);
MQTTClient_free(topic);
return 1; /* 1 = message handled; 0 asks the library to redeliver */
}
int main(void) {
const char *prefix = getenv("MQTT_TOPIC_PREFIX");
char filter[256];
if (!prefix) prefix = "connectmq/demo/paho-c-4821";
snprintf(filter, sizeof filter, "%s/+/temperature", prefix);
signal(SIGINT, on_signal);
signal(SIGTERM, on_signal);
MQTTClient client;
MQTTClient_create(&client, ADDRESS, "paho-c-sub-4821",
MQTTCLIENT_PERSISTENCE_NONE, NULL);
MQTTClient_setCallbacks(client, NULL, connection_lost, on_message, NULL);
while (running) {
if (!MQTTClient_isConnected(client)) {
if (connect_broker(client) == MQTTCLIENT_SUCCESS) {
MQTTClient_subscribe(client, filter, QOS);
printf("subscribed to %s\n", filter);
} else {
sleep(5);
continue;
}
}
sleep(1);
}
MQTTClient_disconnect(client, TIMEOUT);
MQTTClient_destroy(&client);
return 0;
}
Freeing both the message and the topic string is mandatory; forgetting either is the most common memory leak in Paho C programs. Keep the callback short. If processing is slow (writing to flash, calling an HTTP API), copy the payload to a queue and return, because the callback runs on the library's receive thread.
You can also watch the publisher with no code at all:
mosquitto_sub -h broker.connectmq.com -p 1883 -i watcher-4821 -t "connectmq/demo/paho-c-4821/#" -v
Cross-Compiling for an ARM Board
For Yocto or Buildroot images, Paho C has ready recipes. For a quick manual cross build, point CMake at your toolchain and a cross-compiled OpenSSL:
cmake -B build-arm \
-DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc \
-DCMAKE_SYSTEM_NAME=Linux \
-DPAHO_WITH_SSL=TRUE \
-DOPENSSL_ROOT_DIR=/opt/sysroot/usr \
-DPAHO_BUILD_SHARED=TRUE -DPAHO_BUILD_SAMPLES=FALSE \
-DCMAKE_INSTALL_PREFIX=/opt/paho-arm -DCMAKE_INSTALL_LIBDIR=lib
cmake --build build-arm -j"$(nproc)"
cmake --install build-arm
aarch64-linux-gnu-gcc -O2 -o publisher publisher.c \
-I/opt/paho-arm/include -L/opt/paho-arm/lib -lpaho-mqtt3cs -lssl -lcrypto -lpthread
# Copy the binary and the shared library to the board
scp publisher /opt/paho-arm/lib/libpaho-mqtt3cs.so* root@gateway:/usr/local/lib/
On the board, move publisher to /usr/local/bin and run ldconfig. Paho can also be built as a static library (-DPAHO_BUILD_STATIC=TRUE) to avoid shipping a separate .so, but then you own OpenSSL updates for that binary. Check that the target image contains a CA bundle; minimal images often do not, and TLS fails with a certificate verification error even though the code is correct. If the same gateway also drives a local touchscreen, the Qt MQTT client tutorial shows how to build the HMI side in C++, and testing MQTT clients covers how to put this code under CI.
QoS, Persistence and Offline Buffering
Gateways lose connectivity, so decide up front what happens to data during an outage. The client above uses MQTTCLIENT_PERSISTENCE_NONE, which keeps in-flight QoS 1 messages in memory only; if the process restarts mid-delivery, those messages are gone. Paho can instead persist in-flight messages to disk: pass MQTTCLIENT_PERSISTENCE_DEFAULT and a directory path as the last two arguments of MQTTClient_create, connect with cleansession = 0, and the library resends anything not yet acknowledged after a reconnect. On flash storage, weigh that against write wear, because persisting every message on a cheap SD card shortens its life.
Persistence only covers messages already handed to the library. Readings taken while the link is down never reach MQTTClient_publishMessage in the example, because the loop skips publishing until it reconnects. If you need every reading, write them to a local ring buffer (a file or an SQLite database) and drain it after reconnecting, oldest first, with the original timestamp in the payload so the back end can place each value correctly.
QoS choice follows the data. Periodic telemetry that is replaced every few seconds is fine at QoS 0 or 1; commands and alarms deserve QoS 1. QoS 2 adds an extra round trip and is rarely needed on gateways. The free ConnectMQ Serverless plan supports QoS up to 1, and paid plans add QoS 2. The MQTT QoS levels guide explains the delivery guarantees in detail.
Running as a systemd Service
/etc/systemd/system/mqtt-publisher.service:
[Unit]
Description=Paho MQTT C publisher
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/publisher
Environment=MQTT_CLIENT_ID=YOUR_CLIENT_ID
Environment=MQTT_USERNAME=YOUR_USERNAME
Environment=MQTT_PASSWORD=YOUR_PASSWORD
Environment=MQTT_TOPIC_PREFIX=acme/plant1/gateway1
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now mqtt-publisher
journalctl -u mqtt-publisher -f
The credentials above come from your private broker. With a managed MQTT broker like ConnectMQ, you create the broker in the portal and receive a generated username, password and client ID; client IDs are bound to those credentials, so give each gateway its own set. For anything beyond a handful of devices, move secrets into an EnvironmentFile readable only by root.
Return Codes and Troubleshooting
| Return code / symptom | Meaning | What to check |
|---|---|---|
-1 (MQTTCLIENT_FAILURE) on connect with TLS |
Handshake or certificate failure | CA bundle path, system clock (TLS fails if the date is wrong), hostname |
4 |
Bad username or password | Credentials, trailing newline in env file |
5 |
Not authorised | Client ID does not match issued credentials |
2 |
Identifier rejected | Empty or duplicate client ID |
| Connect hangs on 8883 | Firewall blocks the port | Test with openssl s_client -connect broker.connectmq.com:8883 |
| Memory grows over days | Messages or topics not freed | MQTTClient_freeMessage and MQTTClient_free in every callback path |
The clock issue is worth stressing for embedded Linux: boards without a real-time clock boot in 1970, and every certificate looks "not yet valid" until NTP syncs. Order your service after time-sync.target if that bites you. More TLS failure patterns are covered in MQTT TLS handshake failed.
Get Started with ConnectMQ
Your C gateway is ready for a real broker. Create a free Serverless broker at app.connectmq.com, no credit card needed, drop the generated credentials into the systemd unit, and watch messages arrive in the portal's live topic explorer. When the fleet grows, the same binary connects to paid, dedicated or BYOC brokers without code changes.
Frequently Asked Questions
Which Paho C library should I link against for TLS?
Link paho-mqtt3cs for the synchronous API with TLS, or paho-mqtt3as for the asynchronous API with TLS. The variants without the trailing s have no SSL support.
Does the Paho MQTT C client reconnect automatically?
The synchronous MQTTClient API does not, so check MQTTClient_isConnected and reconnect yourself. The asynchronous MQTTAsync API offers an automaticReconnect option.
Does Paho C support MQTT 5?
Yes. Create the client with MQTTClient_createWithOptions and MQTTVERSION_5, then use the MQTTClient_connect5 and MQTTClient_publish5 family of functions; ConnectMQ accepts both MQTT 3.1.1 and 5.0.
How much memory does Paho MQTT C need?
It is small enough for typical embedded Linux gateways, but the exact footprint depends on your build options and OpenSSL. Measure on your target with the variant you actually link.



