Skip to content
Martin Mwiti
All documentation

AC Energy Meter

Hardware and firmware design of a bare-metal STM32F103 AC energy meter — a dual-ADC simultaneous sampling front-end, DMA capture, true-RMS and power maths, and a DMA UART command interface.

STM32F103CADCDMAUARTCMakeView project

Overview

The meter is a single STM32F103 running bare-metal C. It owns the whole measurement chain: drive the two ADCs that read mains voltage and load current, turn those streams into calibrated RMS and power values, accumulate energy, and serve a host controller over UART. It also switches the load through a relay.

The firmware is deliberately layered so nothing time-critical is done in software that the hardware can do on its own:

  1. Acquisition. ADC1 and ADC2 convert in dual regular simultaneous mode. A DMA channel streams the paired results into a circular buffer and interrupts on half and full transfer. The CPU never copies a sample.
  2. Measurement. Each finished half-buffer is reduced to voltage, current, active and apparent power, power factor, and energy in a fixed number of integer operations.
  3. Communication. A DMA-driven UART receives variable-length frames using idle-line detection and transmits responses without blocking.
mains ─► sensor front-end ─► ADC1 + ADC2 (simultaneous)
                                   │
                          DMA1 Ch1 (circular, 32-bit)
                                   │  half / transfer IRQ
                                   ▼
                          adc_process_samples()
                                   │
                            Measurements_t
                                   │
   ESP32 ◄── USART1 packet ◄── uart command loop

Measurement Front-End

Voltage and current are sensed with two off-the-shelf modules, each built around a small transformer and an operational amplifier. As shipped they target 5 V logic: the op-amp output is biased at half the supply (2.5 V) and the gain is set so a full-scale primary signal swings within roughly 0–5 V.

Voltage — ZMPT101C

A voltage transformer scales the mains down to a low-voltage replica. The op-amp buffers and level-shifts it so the output is a small AC signal centred on a DC bias rather than a bipolar signal the ADC cannot read.

Current — ZMCT103C

A current transformer senses the load current. A burden resistor converts the secondary current to a voltage, and an op-amp provides the same biased, AC-coupled output referenced to the mid-rail.

Adapting the modules to a 3.3 V ADC

The STM32's ADC must stay between 0 V and 3.3 V and reads around a 1.65 V mid-rail, so the 5 V modules are modified:

  • Re-bias to 1.65 V. The bias divider that sets the op-amp's DC operating point is moved to half of the ADC reference, so each channel idles at about 2047 of 4095 counts instead of sitting near mid-supply of a 5 V scale.
  • Reduce the gain. The input/feedback resistance is increased to lower the op-amp gain, so a full-scale primary signal still fits under 3.3 V with headroom instead of clipping against the ADC's top rail.

The result is a symmetric signal around the mid-rail that uses the ADC's range without hitting either limit.

Signal Chain

            ┌── ZMPT101C ──► biased AC ──► PA0  (ADC1_IN0)
230 V AC ───┤
            └── CT ──► ZMCT103C ──► burden + bias ──► PA1  (ADC2_IN1)

ADC1 and ADC2 convert their channels at the same instant, so the voltage and current words in a given buffer slot belong together. That pairing is what makes the real-power calculation valid.

Hardware

Microcontroller

ItemDetail
MCUSTM32F103C8T6 — Cortex-M3, 72 MHz, 64 KB flash, 20 KB SRAM
Also builds forSTM32F103CBT6 (128 KB flash), same STM32F103xB headers
Clock source8 MHz HSE crystal, PLL ×9
Linker scriptldscripts/stm32f103c8.ld
Startupstartup/startup_stm32f103xb.s

Pin Map

PinFunctionNotes
PA0ADC1_IN0AC voltage, ZMPT101C
PA1ADC2_IN1AC current, ZMCT103C
PA9USART1_TXAlternate function push-pull → host RX
PA10USART1_RXFloating input ← host TX
PC13GPIO outputStatus LED, driven when frames are dropped
load relayGPIO outputSwitches the live line to the load

Clocking and Timing

SystemClock_Config_72MHz() brings the part up from the external crystal:

DomainValue
HSE8 MHz
PLLHSE × 9
SYSCLK / AHB72 MHz
APB2 (PCLK2)72 MHz
APB1 (PCLK1)36 MHz
Flash latency2 wait states, prefetch on

The ADC prescaler is set to PCLK2 / 6, giving a 12 MHz ADC clock. With a sample time of 71.5 cycles and a 12.5-cycle conversion, a conversion takes 84 ADC cycles:

sample rate = 12,000,000 / 84 = 142,857 samples/second
one 50 Hz cycle = 142,857 × 0.02 = 2,857 samples  →  ADC_BUF_LEN = 2,858 (even)

The buffer holds one full mains cycle, and ADC_HALF_LEN (1,429 samples, about 10 ms) is the unit the processor works on.

Acquisition Pipeline

/* Dual regular simultaneous mode: ADC1 is master, ADC2 the slave */
ADC1->CR1 |= (0x6U << 16);
 
/* One channel each, no scan */
ADC1->SQR3 = 0;   /* ADC1_IN0 — voltage */
ADC2->SQR3 = 1;   /* ADC2_IN1 — current */
 
/* DMA: 32-bit transfers, circular, half + transfer + error interrupts */
DMA1_Channel1->CPAR  = (uint32_t)&ADC1->DR;
DMA1_Channel1->CMAR  = (uint32_t)adc_buf;
DMA1_Channel1->CNDTR = ADC_BUF_LEN;
  • Data packing. In dual mode the master's data register carries both results: bits [15:0] are the ADC1 sample (voltage) and bits [31:16] are the ADC2 sample (current). The DMA moves the whole 32-bit word, so each buffer entry is a voltage/current pair.
  • Ping-pong processing. The half-transfer interrupt processes the first half of the buffer while the DMA fills the second, and the transfer-complete interrupt does the reverse. The processor always reads a finished half while the hardware writes the other.
  • Priority. The DMA channel interrupts the CPU at priority 3 — the highest in the system — so a completed half-buffer is never delayed behind UART work.
void DMA1_Channel1_IRQHandler(void)
{
    if (DMA1->ISR & DMA_ISR_HTIF1) {
        DMA1->IFCR = DMA_IFCR_CHTIF1;
        adc_process_samples(&adc_buf[0], ADC_HALF_LEN);
    }
    if (DMA1->ISR & DMA_ISR_TCIF1) {
        DMA1->IFCR = DMA_IFCR_CTCIF1;
        adc_process_samples(&adc_buf[ADC_HALF_LEN], ADC_HALF_LEN);
    }
}

Measurement Maths

adc_process_samples() runs on each half-buffer and updates a shared Measurements_t. Every reduction runs over the same block of samples and uses 64-bit accumulators so the squares and products cannot overflow 32 bits.

typedef struct {
    double v_rms;
    double i_rms;
    double active_power;
    double apparent_power;
    double accumulated_energy;
    double power_factor;
} Measurements_t;

The three passes over the block are:

  1. Offset removal. Average the raw counts of each channel over the block. That running average is the DC bias (nominally 1.65 V) plus any slow drift, and it is subtracted from every sample before the squares are taken.
  2. Accumulate. For each sample, add v², i², and the instantaneous product v·i.
  3. Reduce.
v_rms          = sqrt(mean(v²)) · V_CAL
i_rms          = sqrt(mean(i²)) · I_CAL
active_power   = mean(v·i) · V_CAL · I_CAL
apparent_power = v_rms · i_rms
power_factor   = active_power / apparent_power      (0 when apparent is 0)
energy        += active_power · (len / 142857)      [watt-seconds]

Averaging over a whole number of half-cycles is what makes sqrt(mean(v²)) the true RMS of a sine and mean(v·i) the real power rather than a pair of unrelated averages.

V_CAL and I_CAL (200.0 and 125.0) fold together the transformer ratio, the burden resistor, and the reduced op-amp gain to map raw RMS counts to volts and amps. They are first-pass figures and are being refined against a reference meter. rms.c keeps an earlier floating-point compute_rms(buf, len, vref, offset) helper for reference; the live path is the integer reduction above.

UART Command Interface

The host link is USART1 at 115200 8N1. At 72 MHz the baud divisor is SystemCoreClock / baudrate = 625 (0x271), which is also the correct 16× oversampling encoding for the 39.0625 divider.

Transmit

uart_send_dma() moves a buffer out through DMA1 Channel 4. A tx_busy flag allows only one transfer at a time. The flag is cleared by the USART transfer-complete interrupt — after the last byte has actually left the shift register — not by the DMA's own completion, which only means the last byte was loaded.

Receive

Receive uses DMA1 Channel 5 into one of two 256-byte buffers. There is no fixed frame length: when the line goes idle the USART1 interrupt reads the number of bytes the DMA has written from CNDTR, publishes that buffer to the application, then restarts the DMA on the other buffer. If the application has not yet consumed the previous frame, the new one is discarded and counted.

typedef struct {
    const uint8_t *data;  /* valid until uart_rx_release() */
    size_t         len;
} uart_frame_t;

Error accounting

The UART layer counts overrun, framing, and noise errors per byte, plus dropped frames and DMA transfer errors, and exposes them through uart_get_stats():

typedef struct {
    uint32_t frames_dropped;
    uint32_t overrun_errors;
    uint32_t framing_errors;
    uint32_t noise_errors;
    uint32_t dma_errors;
} uart_stats_t;

main() reads these every loop and lights PC13 while any frame has been dropped, so a marginal link is visible on the board rather than silent.

Interrupt priorities

InterruptPriorityPurpose
DMA1_Channel13ADC half/transfer complete
USART15Idle line, transfer complete, errors
DMA1_Channel56RX DMA transfer error
DMA1_Channel47TX DMA transfer complete/error

Commands and packet

main() polls uart_rx_get_frame(). A single 'p' byte requests the latest reading; when the transmitter is free the meter sends a response. The response payload is built by prepare_energy_packet():

typedef struct __attribute__((__packed__)) {
    uint8_t  start_byte;   /* 0xAA */
    uint16_t voltage;      /* centivolts   */
    uint16_t current;      /* centiamps    */
    uint16_t power;        /* active, watts */
    uint32_t energy;       /* watt-seconds  */
    uint8_t  checksum;     /* byte-wise sum of the fields above */
} energy_packet_t;

The packet is 12 bytes and is little-endian on the Cortex-M. In the current build the request is answered with a placeholder string while the packet send is switched over; the packet itself and its checksum are implemented.

Relay and Load Control

A relay sits between the mains and the load. It is driven from a GPIO and controlled by the host: the ESP32 can open or close the load on command over the same UART, so the grow controller can disconnect a fixture on a threshold or schedule without a second board. The relay command shares the single-byte command channel used for readings.

Firmware Structure

FileResponsibility
src/main.cBring-up, command loop, packet assembly
src/adc_dual_dma.c/.hDual ADC + DMA setup, IRQ, sample processing, Measurements_t
src/uart_dma.c/.hDMA UART, framed receive, statistics
src/system_clock_cfg.c/.h72 MHz clock tree from HSE
src/blink.c/.hPC13 status LED
src/rms.c/.hFloating-point RMS reference helper

Build and Flash

The project uses CMake with the arm-none-eabi toolchain and CMSIS headers from STM32CubeF1 (stm32f103xb.h). Compiler flags are -mcpu=cortex-m3 -mthumb -msoft-float -O2 -ffunction-sections -fdata-sections -g -Wall, linked with --gc-sections.

rm -rf build
cmake -S . -B build
cmake --build build

Flashing over the serial bootloader and debugging over SWD:

stm32flash -w build/acMeter.hex -v -g 0x0 /dev/ttyUSB0

The VS Code cortex-debug configuration attaches OpenOCD over SWD (interface/stlink.cfg, target/stm32f1x.cfg) for step-through debugging.

Memory Footprint

SectionSizeNotes
.text2,396 BCode and constants
.data12 BInitialised data
.bss13,548 BADC buffer (2,858 × 4 B) dominates; RX double buffer (2 × 256 B)
Total~15.6 KBFits comfortably in 64 KB flash / 20 KB SRAM

Design Notes

  • Hardware does the heavy lifting. ADC conversions and both DMA streams run without CPU involvement; a half-buffer interrupt is the only thing that pulls the core away from its idle loop.
  • Bounded work per interrupt. A half-buffer is a fixed 1,429 samples, so the processing time is constant and comfortably inside the 10 ms window before the next one.
  • Idle-line framing. Variable-length commands are delimited by the UART hardware rather than a timer or a length prefix, which keeps short single-byte commands cheap.
  • Truly complete transmits. Tying tx_busy to the USART transfer-complete flag avoids the classic bug where the DMA reports done while the final byte is still shifting out.
  • Failures are visible. Every error class has a counter, and dropped frames drive an LED.

Future Improvements

  • Calibrate V_CAL / I_CAL against a reference meter and store per-unit constants in flash
  • Wire prepare_energy_packet() onto the 'p' response path in place of the placeholder
  • Extend the command set: relay control, zeroing of the energy accumulator, statistics readout
  • Add phase detection so power factor and reactive power are explicit
  • Track the actual mains frequency per cycle instead of assuming 50 Hz
  • Package readings for the grow controller's telemetry path (UART to ESP32, then MQTT)