I built a small air-quality alert node on RT-Thread and wanted to share how I split it across threads, since the threading model is what made it clean. The idea is simple: read a gas sensor continuously, raise a red LED and log a warning when the reading crosses a threshold, and keep a heartbeat LED blinking so I always know the node is alive. Three jobs that should not block each other, which is exactly what threads are for.
The sensor is an analog MQ-style gas sensor wired to an ADC channel. The board has two LEDs — green for "alive", red for "alarm". Instead of polling everything in one loop, I gave each job its own thread and used a semaphore to signal between the sensor and the alarm.
I used 3 threads:
1 Sensor thread — reads the ADC every 500 ms, stores the latest value, and releases a semaphore when the reading goes above the threshold.
2 Alarm thread — blocks on that semaphore. It does nothing until the sensor signals, then turns on the red LED, prints the value, and clears after a couple of seconds.
3 Status thread — just blinks the green LED at 1 Hz as a heartbeat, completely independent of the rest.
The semaphore matters here. The alarm thread is not busy-waiting or polling a flag — it sleeps until there is actually something to do, so it costs nothing when air is clean. I also gave the alarm thread a higher priority than the sensor thread (lower number in RT-Thread), so an alarm preempts the next sensor read instead of waiting in line.
#include <rtthread.h>
#include <rtdevice.h>
#define GREEN_LED GET_PIN(F, 9)
#define RED_LED GET_PIN(F, 10)
#define GAS_THRESHOLD 600
static rt_sem_t alarm_sem;
static rt_adc_device_t adc_dev;
static volatile rt_uint32_t gas_level = 0;
/* Thread 1: read the gas sensor, signal on threshold */
static void sensor_thread_entry(void *parameter)
{
rt_adc_enable(adc_dev, 0);
while (1)
{
gas_level = rt_adc_read(adc_dev, 0);
if (gas_level > GAS_THRESHOLD)
rt_sem_release(alarm_sem);
rt_thread_mdelay(500);
}
}
/* Thread 2: wait for the alarm, drive the red LED */
static void alarm_thread_entry(void *parameter)
{
while (1)
{
rt_sem_take(alarm_sem, RT_WAITING_FOREVER);
rt_pin_write(RED_LED, PIN_HIGH);
rt_kprintf("ALARM: gas level %d\n", gas_level);
rt_thread_mdelay(2000);
rt_pin_write(RED_LED, PIN_LOW);
}
}
/* Thread 3: heartbeat so I know the node is alive */
static void status_thread_entry(void *parameter)
{
while (1)
{
rt_pin_write(GREEN_LED, PIN_HIGH);
rt_thread_mdelay(500);
rt_pin_write(GREEN_LED, PIN_LOW);
rt_thread_mdelay(500);
}
}
int gas_monitor_init(void)
{
rt_pin_mode(GREEN_LED, PIN_MODE_OUTPUT);
rt_pin_mode(RED_LED, PIN_MODE_OUTPUT);
adc_dev = (rt_adc_device_t)rt_device_find("adc1");
alarm_sem = rt_sem_create("alarm", 0, RT_IPC_FLAG_PRIO);
rt_thread_t t_sensor = rt_thread_create("sensor", sensor_thread_entry, RT_NULL, 1024, 12, 10);
rt_thread_t t_alarm = rt_thread_create("alarm", alarm_thread_entry, RT_NULL, 1024, 11, 10);
rt_thread_t t_status = rt_thread_create("status", status_thread_entry, RT_NULL, 512, 20, 10);
rt_thread_startup(t_sensor);
rt_thread_startup(t_alarm);
rt_thread_startup(t_status);
return RT_EOK;
}
MSH_CMD_EXPORT(gas_monitor_init, start the gas monitor);Calling gas_monitor_init from the msh shell starts all three threads. The green LED starts blinking immediately, and breathing on the sensor (or a lighter near it, carefully) pushes the reading over the threshold and trips the red LED.
This was a small project but a good one for getting the RTOS habits right — letting threads block instead of polling, and using priorities deliberately. I built it in RT-Thread Studio, which made the BSP and ADC setup painless.
One honest note on workflow: I drafted the thread scaffolding and the init function with ai augmented development tooling, which saved time on the boilerplate. It got the structure right but invented a couple of pin and ADC calls that do not exist, so I checked everything against the RT-Thread docs before it ran. Useful for the repetitive parts, not a substitute for reading the API.
Curious how others structure this kind of node — would you use a message queue from the start, or keep the semaphore until you actually need to pass data?