Embedded systems are at the heart of modern innovation, powering everything from smart wearables to industrial automation. This project demonstrates how to bridge sensors and displays on the RT-Spark Development Board using embedded C. By reading temperature and humidity data from the AHT21 sensor and presenting it on the LCD, the activity highlights the essential workflow of sensing, processing, and visualizing information in real time.
The importance of this activity lies in its practical relevance: environmental monitoring is a cornerstone of IoT applications, and the ability to port existing sensor and display libraries ensures faster development cycles and greater code reusability. This approach not only saves time but also teaches the critical skill of adapting modular drivers to new hardware platforms.
Beyond its immediate functionality, the project reflects the broader impact of embedded programming—enabling devices to interact with the physical world and communicate meaningful data to users. It’s a foundational exercise that prepares developers to tackle more complex integrations, ultimately contributing to smarter, more connected technologies.
Step 1: Setting upBefore diving into coding and integration, it is essential to prepare the materials required for the project. The foundation begins with the RT-Spark STM32F407ZGT6 development board, which serves as the hardware platform for sensor interfacing and LCD output. Alongside this, a laptop equipped with STM32CubeIDE provides the necessary environment for writing, compiling, and debugging the embedded C program. Having these tools ready ensures a smooth start and establishes the groundwork for successful implementation.
Step 2: IOC ConfigurationFSMC Setup:Enable Bank 1, SRAM 3. Make sure to set the Memory Data Width to 8 bits. Leaving it at the default 16 bits will cause the LCD color data to display incorrectly.
GPIO Setup:Configure PE0 and PE1 as Output Open-Drain. This setup is required for I²C simulation, since it lets the master pull the line low while allowing the resistor to pull it high when released—accurately replicating the I²C physical layer behavior.
Developing this project required building custom logic rather than simply relying on existing headers. The LCD driver posed a particular challenge because the ST7789 controller is designed for a 16‑bit interface, while the hardware available was limited to 8‑bit. To address this, I created a wrapper function that splits each 16‑bit color value into two parts. The high byte is shifted and sent first, followed by the low byte after masking. This approach allows the STM32 to reconstruct full RGB565 colors correctly over the narrower bus, ensuring accurate display output.
On the sensor side, the AHT21 driver demanded a full implementation of the I²C protocol. I coded the essential operations—Start, Stop, WaitAck, and SendByte—to establish proper communication. To initiate a measurement, the driver issues the command 0xAC (Trigger Measure), followed by a carefully timed 80ms blocking delay to accommodate the sensor’s physical measurement process. Only after this delay is the 6‑byte data packet read, guaranteeing reliable acquisition of temperature and humidity values.
Here is the complete code used for this project:
#include "main.h"
#include "drv_lcd.h"
#include "drv_aht21.h"
#include <stdlib.h>
// --- CONFIGURATION MACROS ---
/**
* @brief Filter Window Size
* @why A size of 10 provides a good balance between smoothing out noise and
* keeping the display responsive.
* - Size 5: Still too jittery.
* - Size 50: Introduces noticeable lag when temperature changes.
*/
#define FILTER_SIZE 10
SRAM_HandleTypeDef hsram1;
void SystemClock_Config(void);
static void MX_GPIO_Init(void);
static void MX_FSMC_Init(void);
/**
* SECTION 1: CUSTOM DISPLAY FUNCTIONS
* We implemented custom drawing and printing functions to avoid using the standard
* <stdio.h> library (sprintf/printf), which is very heavy on Flash memory (~20KB).
* Our custom implementation uses less than 1KB.
*/
// Draws a hollow rectangle (used for UI borders)
void LCD_DrawRect(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color) {
LCD_DrawPixel(x1, y1, color);
LCD_DrawPixel(x2, y2, color);
// Draw Horizontal Lines
for (uint16_t x = x1; x <= x2; x++) {
LCD_DrawPixel(x, y1, color);
LCD_DrawPixel(x, y2, color);
}
// Draw Vertical Lines
for (uint16_t y = y1; y <= y2; y++) {
LCD_DrawPixel(x1, y, color);
LCD_DrawPixel(x2, y, color);
}
}
// Recursive function to print integers digit by digit
void LCD_ShowNum(uint16_t x, uint16_t y, int num, uint16_t color, uint16_t back_color) {
char buf[12]; int i = 0;
if (num == 0) { LCD_ShowChar(x, y, '0', color, back_color); return; }
// Handle negative numbers
if (num < 0) { LCD_ShowChar(x, y, '-', color, back_color); x += 8; num = -num; }
// Extract digits into buffer
while (num > 0) { buf[i++] = (num % 10) + '0'; num /= 10; }
// Print in reverse order (MSB first)
while (--i >= 0) { LCD_ShowChar(x, y, buf[i], color, back_color); x += 8; }
}
/**
* @brief Manually renders a float value to the screen.
* @why Handling floats usually requires the FPU and heavy libraries.
* Here, we simply split the float into two Integers:
* 1. The Integer Part (e.g., 25)
* 2. The Decimal Part (e.g., .43)
* Then we print them separately with a dot in between.
*/
void LCD_ShowFloatManual(uint16_t x, uint16_t y, float val, uint16_t color, uint16_t back_color) {
// 1. Extract Integer Part
int intPart = (int)val;
LCD_ShowNum(x, y, intPart, color, back_color);
// Calculate cursor offset based on number length (for the decimal point)
int offset = 8;
if (intPart < 0) offset += 8;
if (abs(intPart) > 9) offset += 8;
if (abs(intPart) > 99) offset += 8;
// 2. Print Decimal Point
LCD_ShowChar(x + offset, y, '.', color, back_color);
// 3. Extract Decimal Part (2 decimal places)
// Multiply by 100 to convert .43 to 43
int decPart = (int)((val - (float)intPart) * 100.0f);
if (decPart < 0) decPart = -decPart;
// Handle leading zeros (e.g., .05 should not print as .5)
if (decPart < 10) {
LCD_ShowChar(x + offset + 8, y, '0', color, back_color);
LCD_ShowNum(x + offset + 16, y, decPart, color, back_color);
} else {
LCD_ShowNum(x + offset + 8, y, decPart, color, back_color);
}
}
/*
* SECTION 2: SIGNAL PROCESSING (Digital Filter)
* Raw sensor data is often noisy due to electrical interference or airflow.
* This Moving Average Filter smooths the data for a stable display.
*/
float Filter_Value(float new_val, float *buffer, uint8_t *index, uint8_t *count) {
// STEP 1: SPIKE REJECTION (Sanity Check)
// If the sensor reads >100C or <-40C, it is physically impossible.
// This is likely an I2C bit error or electrical glitch. Ignore it.
if (new_val > 100.0f || new_val < -40.0f) {
if (*count > 0) {
// Return the previous valid average instead
float sum = 0;
for(int i=0; i<*count; i++) sum += buffer[i];
return sum / *count;
}
return new_val; // Fallback if it's the very first reading
}
// STEP 2: UPDATE BUFFER
buffer[*index] = new_val;
*index = (*index + 1) % FILTER_SIZE; // Circular buffer wrap-around
if (*count < FILTER_SIZE) (*count)++;
// STEP 3: CALCULATE AVERAGE
float sum = 0;
for (uint8_t i = 0; i < *count; i++) {
sum += buffer[i];
}
return sum / (float)(*count);
}
// --- MAIN APPLICATION LOOP ---
int main(void)
{
// 1. Initialize Core Hardware
HAL_Init();
SystemClock_Config();
MX_GPIO_Init(); // Configures Pins
MX_FSMC_Init(); // Configures External Memory Bus for LCD
// 2. Initialize LCD
LCD_Init();
LCD_Clear(WHITE);
// 3. Draw User Interface (Static Elements)
LCD_DrawRect(5, 5, 235, 40, BLUE);
LCD_ShowString(40, 15, "RT-Spark AHT21", BLUE, WHITE);
LCD_ShowString(20, 80, "Temp:", BLACK, WHITE);
LCD_ShowString(20, 140, "Hum:", BLACK, WHITE);
LCD_ShowString(20, 200, "Sensor Active ", GREEN, WHITE);
// 4. Initialize Sensor (Sends Calibration Command 0xBE)
AHT21_Init();
// Variables for Data Processing
float raw_temp = 0.0f;
float raw_hum = 0.0f;
float final_temp = 0.0f;
float final_hum = 0.0f;
// Circular Buffers for Filtering
float temp_history[FILTER_SIZE] = {0};
float hum_history[FILTER_SIZE] = {0};
uint8_t temp_idx = 0, hum_idx = 0;
uint8_t temp_cnt = 0, hum_cnt = 0;
uint8_t count = 0;
while (1)
{
// 5. Acquire Data
uint8_t status = AHT21_Read(&raw_temp, &raw_hum);
if (status == 1)
{
// 6. Process Data (Apply Filter)
final_temp = Filter_Value(raw_temp, temp_history, &temp_idx, &temp_cnt);
final_hum = Filter_Value(raw_hum, hum_history, &hum_idx, &hum_cnt);
// 7. Refresh Display
// Note: We overwrite the old value area with spaces to "clear" it
// before printing the new value to prevent flickering.
LCD_ShowString(90, 100, " ", WHITE, WHITE);
LCD_ShowString(90, 160, " ", WHITE, WHITE);
// Print Temperature (Red for Heat)
LCD_ShowFloatManual(90, 100, final_temp, RED, WHITE);
LCD_ShowString(160, 100, "C", RED, WHITE);
// Print Humidity (Blue for Water)
LCD_ShowFloatManual(90, 160, final_hum, BLUE, WHITE);
LCD_ShowString(160, 160, "%", BLUE, WHITE);
// 8. System Heartbeat
// Blinks a single pixel to show the main loop is running and not stuck
if (count++ % 2) LCD_DrawPixel(230, 230, RED);
else LCD_DrawPixel(230, 230, WHITE);
}
else
{
// Error Handling: If I2C fails, show error and try to reset sensor
LCD_ShowString(120, 200, "Read Err", RED, WHITE);
HAL_Delay(100);
AHT21_Init();
}
// Update Rate: 1Hz (Every 1000ms)
// This is fast enough for environmental data but slow enough to be readable.
HAL_Delay(1000);
}
}
/*
* SECTION 3: PERIPHERAL CONFIGURATION (Auto-Generated Logic)
*/
void SystemClock_Config(void) {
RCC_OscInitTypeDef RCC_OscInitStruct = {0};
RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};
__HAL_RCC_PWR_CLK_ENABLE();
__HAL_PWR_VOLTAGESCALING_CONFIG(PWR_REGULATOR_VOLTAGE_SCALE1);
RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSI;
RCC_OscInitStruct.HSIState = RCC_HSI_ON;
RCC_OscInitStruct.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT;
RCC_OscInitStruct.PLL.PLLState = RCC_PLL_NONE;
HAL_RCC_OscConfig(&RCC_OscInitStruct);
RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_SYSCLK|RCC_CLOCKTYPE_PCLK1|RCC_CLOCKTYPE_PCLK2;
RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_HSI;
RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;
RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV1;
RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;
HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_0);
}
static void MX_FSMC_Init(void) {
FSMC_NORSRAM_TimingTypeDef Timing = {0};
hsram1.Instance = FSMC_NORSRAM_DEVICE;
hsram1.Extended = FSMC_NORSRAM_EXTENDED_DEVICE;
hsram1.Init.NSBank = FSMC_NORSRAM_BANK3;
hsram1.Init.DataAddressMux = FSMC_DATA_ADDRESS_MUX_DISABLE;
hsram1.Init.MemoryType = FSMC_MEMORY_TYPE_SRAM;
/**
* CRITICAL CONFIGURATION: 8-BIT BUS WIDTH
* The RT-Spark schematic shows only D0-D7 connected to the LCD.
* If we select 16-bit here, the MCU will try to send data on D8-D15,
* which are not connected, resulting in corrupted colors.
*/
hsram1.Init.MemoryDataWidth = FSMC_NORSRAM_MEM_BUS_WIDTH_8;
hsram1.Init.WriteOperation = FSMC_WRITE_OPERATION_ENABLE;
hsram1.Init.ExtendedMode = FSMC_EXTENDED_MODE_DISABLE;
hsram1.Init.AsynchronousWait = FSMC_ASYNCHRONOUS_WAIT_DISABLE;
hsram1.Init.WriteBurst = FSMC_WRITE_BURST_DISABLE;
hsram1.Init.PageSize = FSMC_PAGE_SIZE_NONE;
// Timing Parameters (Tuned for ST7789)
Timing.AddressSetupTime = 15;
Timing.AddressHoldTime = 15;
Timing.DataSetupTime = 60;
Timing.BusTurnAroundDuration = 5;
Timing.CLKDivision = 16;
Timing.DataLatency = 17;
Timing.AccessMode = FSMC_ACCESS_MODE_A;
HAL_SRAM_Init(&hsram1, &Timing, NULL);
}
static void MX_GPIO_Init(void) {
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOF_CLK_ENABLE();
__HAL_RCC_GPIOE_CLK_ENABLE();
__HAL_RCC_GPIOD_CLK_ENABLE();
__HAL_RCC_GPIOG_CLK_ENABLE();
// Initial Pin States
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_9, GPIO_PIN_RESET); // Backlight Off initially
HAL_GPIO_WritePin(GPIOD, GPIO_PIN_3, GPIO_PIN_SET); // Reset High (Inactive)
// Configure LCD Pins (Backlight & Reset)
GPIO_InitStruct.Pin = GPIO_PIN_9;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOF, &GPIO_InitStruct);
GPIO_InitStruct.Pin = GPIO_PIN_3;
HAL_GPIO_Init(GPIOD, &GPIO_InitStruct);
// Configure Sensor Pins (Bit-Banging I2C)
// Must be Open-Drain (OD) to allow the line to be pulled Low by both MCU and Sensor
HAL_GPIO_WritePin(GPIOE, GPIO_PIN_0|GPIO_PIN_1, GPIO_PIN_SET);
GPIO_InitStruct.Pin = GPIO_PIN_0|GPIO_PIN_1;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD;
GPIO_InitStruct.Pull = GPIO_PULLUP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
HAL_GPIO_Init(GPIOE, &GPIO_InitStruct);
}
void Error_Handler(void) { __disable_irq(); while (1) {} }
You’ve reached the finish line! By now, your RT‑Spark STM32F407ZGT6 should be reading temperature and humidity from the AHT21 sensor and showing them clearly on the LCD. This isn’t just a demo — it’s a working foundation you can build on.
Think of this project as your starter kit for embedded development:
- You set up the hardware — board + IDE.
- You configured the IOC — FSMC and GPIO for proper communication.
- You wrote the drivers — custom LCD logic for 8‑bit color and a full I²C routine for the sensor.
- You validated the output — real‑time data displayed on screen.
With these steps complete, you now have a reusable workflow: sense → process → display. The same approach can be extended to add more sensors, log data to the cloud, or even connect to mobile apps. Treat this as your launchpad — the skills you’ve practiced here are the same ones used in real IoT and embedded products.
So, power up your board, watch the readings roll in, and start imagining what else you can connect. The next project is already waiting for you to build it.










Comments