Embedded Systems

# Summoning Digital Entities: Advanced STM32 Techniques for IoT Communication

May 5, 2025
SecCodeSmith
15 min read
12 Comments

In the digital forge where hardware meets software, the STM32 microcontroller family stands as a powerful conduit for summoning digital entities into our physical realm. Today, we shall delve into advanced techniques for establishing robust communication between these entities through the mystical arts of embedded systems programming.

The realm of IoT demands reliable, efficient communication protocols that can withstand the chaotic nature of real-world environments. Through careful implementation and optimization of the STM32's communication peripherals, we can craft resilient connections that bridge the gap between digital and physical domains.

## The Three Elemental Protocols: I2C, SPI, and UART

Before we forge ahead into advanced territory, let us briefly recall the elemental forces at our disposal. Each protocol serves as a channel through which our digital entities communicate, each with its unique strengths and limitations:

- I2C (Inter-Integrated Circuit) - A two-wire interface requiring minimal physical connections, ideal for communication with multiple peripheral devices over short distances.
- SPI (Serial Peripheral Interface) - A faster, full-duplex protocol with dedicated select lines for each peripheral, allowing simultaneous transmission and reception of data.
- UART (Universal Asynchronous Receiver-Transmitter) - A simple two-wire asynchronous interface, perfect for direct device-to-device communication without a shared clock.

Now, let us transcend the basics and explore the deeper mysteries of these protocols as implemented on the STM32 platform.

## Optimizing I2C for Reliable Field Operations

The I2C protocol, while powerful in its simplicity, can encounter several challenges in real-world deployments. Addressing these challenges requires both hardware considerations and software techniques.

### Hardware Incantations: Pull-up Resistor Selection

The choice of pull-up resistors on the SCL and SDA lines is crucial for stable communication. Too high a resistance leads to slow signal rise times, while too low a value increases power consumption and may overload outputs.

For most applications, values between 2.2kΩ and 10kΩ work well, but the optimal value depends on the capacitance of your bus and the desired speed:

- Standard Mode (100 kHz) - 4.7kΩ to 10kΩ
- Fast Mode (400 kHz) - 2.2kΩ to 4.7kΩ
- Fast Mode Plus (1 MHz) - 1kΩ to 2.2kΩ

### Software Rituals: Handling Bus Errors

I2C buses can sometimes get locked in an error state due to noise or improper reset sequences. Implementing a recovery mechanism can prevent your system from requiring a full power cycle.

i2c_recovery.c

    /* This ritual manually toggles the clock line to free a stuck bus */
    bool I2C_ForceBusRecovery(GPIO_TypeDef* scl_port, uint16_t scl_pin,
                              GPIO_TypeDef* sda_port, uint16_t sda_pin)
    {
        // Disable the I2C peripheral
        I2C1->CR1 &= ~I2C_CR1_PE;

        // Configure SCL and SDA as open-drain outputs
        GPIO_InitTypeDef gpio;
        gpio.Mode = GPIO_MODE_OUTPUT_OD;
        gpio.Pull = GPIO_NOPULL;
        gpio.Speed = GPIO_SPEED_FREQ_HIGH;

        gpio.Pin = scl_pin;
        HAL_GPIO_Init(scl_port, &gpio);

        gpio.Pin = sda_pin;
        HAL_GPIO_Init(sda_port, &gpio);

        // Ensure SDA is high
        HAL_GPIO_WritePin(sda_port, sda_pin, GPIO_PIN_SET);

        // Toggle SCL until SDA goes high
        for(int i = 0; i < 9; i++) {
            // Clock high, delay
            HAL_GPIO_WritePin(scl_port, scl_pin, GPIO_PIN_SET);
            HAL_Delay(1);

            // Clock low, delay
            HAL_GPIO_WritePin(scl_port, scl_pin, GPIO_PIN_RESET);
            HAL_Delay(1);
        }

        // Generate a STOP condition
        HAL_GPIO_WritePin(sda_port, sda_pin, GPIO_PIN_RESET);
        HAL_Delay(1);
        HAL_GPIO_WritePin(scl_port, scl_pin, GPIO_PIN_SET);
        HAL_Delay(1);
        HAL_GPIO_WritePin(sda_port, sda_pin, GPIO_PIN_SET);
        HAL_Delay(1);

        // Reconfigure pins for I2C peripheral
        gpio.Mode = GPIO_MODE_AF_OD;
        gpio.Alternate = GPIO_AF4_I2C1;

        gpio.Pin = scl_pin;
        HAL_GPIO_Init(scl_port, &gpio);

        gpio.Pin = sda_pin;
        HAL_GPIO_Init(sda_port, &gpio);

        // Re-enable I2C peripheral
        I2C1->CR1 |= I2C_CR1_PE;

        return HAL_GPIO_ReadPin(sda_port, sda_pin) == GPIO_PIN_SET;
    }

This recovery function manually toggles the SCL line to free any stuck peripheral devices, then generates a proper STOP condition to reset the bus state.

## Harnessing the Speed of SPI with DMA

SPI offers superior speed compared to I2C, but to truly unleash its potential, we must employ Direct Memory Access (DMA) to offload the CPU during data transfers.

### Configuration for Maximum Throughput

Setting up SPI with DMA involves configuring both the SPI peripheral and associated DMA channels:

spi_dma_config.c

    /* SPI configuration with DMA for high-speed data transfer */
    void SPI_Init_DMA(void)
    {
        // Enable clocks
        __HAL_RCC_SPI1_CLK_ENABLE();
        __HAL_RCC_DMA2_CLK_ENABLE();

        // Configure SPI
        hspi1.Instance = SPI1;
        hspi1.Init.Mode = SPI_MODE_MASTER;
        hspi1.Init.Direction = SPI_DIRECTION_2LINES;
        hspi1.Init.DataSize = SPI_DATASIZE_8BIT;
        hspi1.Init.CLKPolarity = SPI_POLARITY_LOW;
        hspi1.Init.CLKPhase = SPI_PHASE_1EDGE;
        hspi1.Init.NSS = SPI_NSS_SOFT;
        hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; // Tune for your clock frequency
        hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;
        hspi1.Init.TIMode = SPI_TIMODE_DISABLE;
        hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;
        HAL_SPI_Init(&hspi1);

        // Configure DMA for TX
        hdma_spi1_tx.Instance = DMA2_Stream3;
        hdma_spi1_tx.Init.Channel = DMA_CHANNEL_3;
        hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
        hdma_spi1_tx.Init.PeriphInc = DMA_PERIPH_INC_DISABLE;
        hdma_spi1_tx.Init.MemInc = DMA_MEMORY_INC_ENABLE;
        hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
        hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
        hdma_spi1_tx.Init.Mode = DMA_NORMAL;
        hdma_spi1_tx.Init.Priority = DMA_PRIORITY_HIGH;
        hdma_spi1_tx.Init.FIFOMode = DMA_FIFOMODE_DISABLE;
        HAL_DMA_Init(&hdma_spi1_tx);

        // Link DMA to SPI
        __HAL_LINKDMA(&hspi1, hdmatx, hdma_spi1_tx);

        // Configure DMA for RX (similar to TX)
        hdma_spi1_rx.Instance = DMA2_Stream0;
        hdma_spi1_rx.Init.Channel = DMA_CHANNEL_3;
        hdma_spi1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
        // ... similar configuration as TX
        HAL_DMA_Init(&hdma_spi1_rx);

        // Link DMA to SPI
        __HAL_LINKDMA(&hspi1, hdmarx, hdma_spi1_rx);

        // Configure NVIC for DMA
        HAL_NVIC_SetPriority(DMA2_Stream3_IRQn, 1, 0);
        HAL_NVIC_EnableIRQ(DMA2_Stream3_IRQn);

        HAL_NVIC_SetPriority(DMA2_Stream0_IRQn, 1, 0);
        HAL_NVIC_EnableIRQ(DMA2_Stream0_IRQn);
    }

### Efficient Data Transfer for Sensor Arrays

When working with multiple sensor readings, we can create a transfer function that efficiently handles large batches of data:

spi_bulk_transfer.c

    /* Efficient transfer of multiple sensor readings via SPI */
    void SPI_TransferSensorData(uint8_t* tx_buffer, uint8_t* rx_buffer, uint16_t size)
    {
        // Select the peripheral
        HAL_GPIO_WritePin(SPI1_CS_GPIO_Port, SPI1_CS_Pin, GPIO_PIN_RESET);

        // Start DMA transfer (non-blocking)
        HAL_SPI_TransmitReceive_DMA(&hspi1, tx_buffer, rx_buffer, size);

        // The following code can be executed while DMA handles the transfer
        // Process other tasks, update statuses, etc.

        // Wait for completion if needed
        while(HAL_SPI_GetState(&hspi1) != HAL_SPI_STATE_READY);

        // Deselect the peripheral
        HAL_GPIO_WritePin(SPI1_CS_GPIO_Port, SPI1_CS_Pin, GPIO_PIN_SET);
    }

> When working with SPI devices at high speeds, ensure proper signal integrity through appropriate PCB layout techniques. Keep traces short, use ground planes, and consider impedance matching for long connections.

## UART and the Art of Reliable Communication

UART communication forms the backbone of many IoT systems, particularly when connecting to gateways or cellular modules. Enhancing its reliability requires careful error handling and buffer management.

### Implementing a Circular Buffer for UART Reception

A circular buffer allows for continuous reception of data without interruption, even when processing is temporarily busy:

uart_circular_buffer.c

    /* Circular buffer implementation for UART reception */
    #define UART_BUFFER_SIZE 256

    typedef struct {
        uint8_t buffer[UART_BUFFER_SIZE];
        volatile uint16_t head;
        volatile uint16_t tail;
    } CircularBuffer;

    CircularBuffer uart_rx_buffer = {0};

    // Initialize buffer
    void CircularBuffer_Init(CircularBuffer* cb) {
        cb->head = 0;
        cb->tail = 0;
    }

    // Add byte to buffer
    bool CircularBuffer_Write(CircularBuffer* cb, uint8_t data) {
        uint16_t next_head = (cb->head + 1) % UART_BUFFER_SIZE;

        if (next_head == cb->tail) {
            return false; // Buffer full
        }

        cb->buffer[cb->head] = data;
        cb->head = next_head;
        return true;
    }

    // Get byte from buffer
    bool CircularBuffer_Read(CircularBuffer* cb, uint8_t* data) {
        if (cb->head == cb->tail) {
            return false; // Buffer empty
        }

        *data = cb->buffer[cb->tail];
        cb->tail = (cb->tail + 1) % UART_BUFFER_SIZE;
        return true;
    }

    // ISR for UART reception
    void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
        static uint8_t rx_byte;

        if (huart->Instance == USART2) {
            // Add received byte to buffer
            CircularBuffer_Write(&uart_rx_buffer, rx_byte);

            // Set up for next byte reception
            HAL_UART_Receive_IT(huart, &rx_byte, 1);
        }
    }

### Implementing a Message Protocol with Error Detection

For robust UART communication, implement a simple message protocol with framing and CRC checking:

uart_message_protocol.c

    /* Message protocol implementation with CRC-16 error detection */
    #define MSG_START_DELIMITER 0x7E
    #define MSG_END_DELIMITER 0x7F
    #define MSG_ESCAPE 0x7D
    #define MSG_MAX_LENGTH 128

    typedef struct {
        uint8_t buffer[MSG_MAX_LENGTH];
        uint16_t length;
        bool in_frame;
        bool escape_next;
        uint16_t crc;
    } MessageParser;

    // CRC-16 calculation
    uint16_t CRC16_Calculate(uint8_t* data, uint16_t length) {
        uint16_t crc = 0xFFFF;

        for (uint16_t i = 0; i < length; i++) {
            crc ^= (uint16_t)data[i];

            for (uint8_t j = 0; j < 8; j++) {
                if (crc & 0x0001) {
                    crc = (crc >> 1) ^ 0xA001; // 0xA001 is the reflection of the polynomial x^16 + x^15 + x^2 + 1
                } else {
                    crc = crc >> 1;
                }
            }
        }

        return crc;
    }

    // Process incoming byte
    void MessageParser_ProcessByte(MessageParser* parser, uint8_t byte, void (*message_handler)(uint8_t*, uint16_t)) {
        if (byte == MSG_START_DELIMITER && !parser->in_frame) {
            // Start of new message
            parser->in_frame = true;
            parser->length = 0;
            parser->escape_next = false;
            return;
        }

        if (!parser->in_frame) {
            return; // Ignore bytes outside of frame
        }

        if (parser->escape_next) {
            // Previous byte was escape character
            parser->escape_next = false;
            parser->buffer[parser->length++] = byte ^ 0x20; // XOR with 0x20 to get original value
        } else if (byte == MSG_ESCAPE) {
            // Escape character
            parser->escape_next = true;
        } else if (byte == MSG_END_DELIMITER) {
            // End of message, process it
            if (parser->length >= 2) {
                // Extract CRC (last 2 bytes)
                uint16_t received_crc = (parser->buffer[parser->length - 2] << 8) | parser->buffer[parser->length - 1];
                uint16_t calculated_crc = CRC16_Calculate(parser->buffer, parser->length - 2);

                if (received_crc == calculated_crc) {
                    // Valid message, call handler
                    message_handler(parser->buffer, parser->length - 2);
                } else {
                    // CRC error, message corrupted
                    // Handle error (log, retry, etc.)
                }
            }

            // Reset for next message
            parser->in_frame = false;
        } else {
            // Regular data byte
            if (parser->length < MSG_MAX_LENGTH) {
                parser->buffer[parser->length++] = byte;
            } else {
                // Buffer overflow
                parser->in_frame = false; // Abort current frame
                // Handle error (log, reset, etc.)
            }
        }
    }

This protocol provides robust framing with start/end delimiters, byte escaping for transparent data transfer, and CRC-16 error detection to ensure data integrity.

## Power Management and Communication Optimization

For IoT devices operating on battery power, optimizing communication for power efficiency is critical. Several techniques can significantly extend operational life:

### Low-Power Communication Patterns

- Batch Transmissions - Collect and transmit data in batches rather than continuously.
- Adaptive Sampling - Adjust sampling rates based on detected activity or variance in readings.
- Sleep Modes - Put peripherals into sleep mode when not in use, waking them only when needed.

The following example demonstrates a power-optimized sensing routine using the STM32's low-power modes:

low_power_sensing.c

    /* Power-optimized sensing routine */
    void LowPowerSensingCycle(void) {
        static uint8_t buffer[SAMPLE_BUFFER_SIZE];
        static uint16_t sample_count = 0;

        // Wake up I2C peripheral
        I2C_WakeUp(&hi2c1);

        // Take sensor reading
        if (ReadSensorData(&hi2c1, buffer + sample_count) == HAL_OK) {
            sample_count += SENSOR_DATA_SIZE;

            // If buffer full or significant event detected, transmit data
            if (sample_count >= SAMPLE_BUFFER_SIZE || DetectSignificantEvent(buffer, sample_count)) {
                // Wake up communication module
                SPI_WakeUp(&hspi1);

                // Transmit data
                TransmitSensorData(&hspi1, buffer, sample_count);

                // Put communication module back to sleep
                SPI_EnterLowPower(&hspi1);

                // Reset buffer
                sample_count = 0;
            }
        }

        // Put I2C peripheral back to sleep
        I2C_EnterLowPower(&hi2c1);

        // Calculate time until next sample
        uint32_t sleep_time = CalculateNextSampleTime();

        // Enter stop mode with RTC wakeup
        EnterStopMode(sleep_time);
    }

By judiciously managing peripheral power states and transmission frequency, we can achieve a balance between responsiveness and power consumption that extends device life from days to months or even years.

## Conclusion: The Art of Digital Communication

Mastering the communication capabilities of the STM32 platform allows us to create IoT devices that reliably bridge the digital and physical realms. By understanding the strengths and limitations of each protocol, implementing robust error handling, and optimizing for power efficiency, we can forge connections that withstand the challenges of real-world deployment.

The techniques presented here form just the beginning of your journey into the arcane arts of embedded communication. As you implement these approaches in your own projects, you'll develop an intuitive understanding of when to apply each technique and how to adapt them to your specific requirements.

In future articles, we'll explore more advanced topics such as wireless communication protocols, secure boot and firmware updates, and techniques for scalable IoT device management. Until then, may your digital entities communicate without interference, and your embedded systems run true.

STM32 Embedded IoT I2C SPI UART DMA Low-Power

### Share this arcane knowledge

### SecCodeSmith

Digital blacksmith forging embedded systems and IoT solutions in the fires of innovation. Specializes in low-level firmware, communication protocols, and hardware-software integration for resource-constrained devices.

### Discussions (12)

#### Michael Chen

May 5, 2025 at 15:42

Excellent article! I've been struggling with I2C bus lockups in my weather station project. The recovery function you provided solved the issue. Have you considered adding a timeout mechanism to the SPI transfers for error detection?

Reply

#### SecCodeSmith

May 5, 2025 at 16:10

@Michael - Good point about the timeout! For SPI transfers, you can implement a timeout using either the HAL Timeout parameter or a hardware timer. For maximum reliability, I prefer using the hardware timer approach as it continues to function even if the main code gets stuck. I'll cover this in a future article on error handling.

Reply

#### Elena Petrova

May 6, 2025 at 09:23

The circular buffer implementation for UART is very useful, but I noticed it doesn't handle the case where an interrupt occurs while processing data from the buffer. Have you considered adding a critical section to prevent race conditions?

Reply

### Leave your mark