Embedded Systems / Laboratory
LABORATORY 01

Energy Test Bench on a Raspberry Pi 5 or Pi 3

Duration: 3 hours Reference: Chapters 4, 5 and 6 Platform: Raspberry Pi 5 or Pi 3 + LCD display Book reference chapter RO versiunea română

A processor does not draw a fixed power: consumption depends on frequency, on voltage, and on how much time it spends idle. In this lab we turn a Raspberry Pi board (5 or 3) into an instrument that measures itself - it shows the real-time power consumption on an LCD and lets us compare, with real numbers, what "fast" means and what "economical" means.

1Objectives of the lab

  • Understanding the relationship P = C·V²·f and why voltage matters more than frequency
  • Reading the processor state from sysfs: current frequency, governor, thermal throttling
  • Measuring the power consumed through two independent methods: the internal power management circuit (Pi 5 only) and an external INA219 module (the only method on a Pi 3)
  • Changing the DVFS governor and observing the effect on execution time and energy
  • Building an LCD display that reports power, frequency and temperature in real time
  • Calculating energy per task and the performance-per-watt ratio - the indicators that actually matter

2About the project

We build an energy test bench: a program that runs the same compute task several times, once for each processor frequency policy, measuring each time how long it took and how much energy it consumed. The results appear on the LCD in real time and are saved to a CSV file that you can open in Excel for your report.

test task ./bench constant work processor state sysfs: frequency, governor, temperature power consumed internal PMIC · INA219 voltage × current bench.py integrates P·dt → energy (J) LCD real time CSV for the report
Fig. 1 - Structure of the test bench. The compute task is always the same; only the frequency policy changes. We measure the processor state and the power at the same time, and the program combines them into consumed energy.
Why a Raspberry Pi 5 and not a microcontroller Because the Pi 5 has exactly what we need to study consumption: a Cortex-A76 processor with several frequency steps, a governor that switches them automatically, and - unlike almost any other development board - an internal power management circuit that reports its own currents on every rail. We can start measuring before soldering a single wire.

The lab can also be done on a Raspberry Pi 3, with one important difference: the Pi 3 has no such circuit, so power can only be measured externally, with the INA219 module. The differences between the two boards are collected in the next section, and wherever the steps differ, the page says so explicitly.

3Materials needed

For both variants

  • 1 32 GB microSD card with Raspberry Pi OS (64-bit, Bookworm)
  • 1 16×2 LCD display with I²C adapter (PCF8574)
  • 8 Female-to-female jumper wires
  • 1 3.3 V ↔ 5 V I²C level shifter (recommended, for the LCD)

Raspberry Pi 5 variant

  • 1 Raspberry Pi 5 (4 or 8 GB)
  • 1 Official 27 W USB-C power supply
  • 1 Official active cooler (recommended - otherwise thermal throttling occurs)
  • 1 INA219 or INA226 module (optional, for the second part)

Raspberry Pi 3 variant

  • 1 Raspberry Pi 3 Model B or B+
  • 1 INA219 module (required - it is the only power measurement)
  • 1 Adjustable 5 V / 3 A bench power supply with current limiting
  • 2 Crocodile-clip or male-to-female wires for the supply
  • 1 Heatsink on the processor (recommended)

Pi 5 or Pi 3: what changes

First find out which board you have in front of you. The following command tells you directly:

board model
cat /proc/device-tree/model; echo

The answer looks like Raspberry Pi 5 Model B Rev 1.0 or Raspberry Pi 3 Model B Plus Rev 1.3. Write it down in your report - sections 8, 9 and 12 depend on it.

Raspberry Pi 5Raspberry Pi 3 (B / B+)
ProcessorBCM2712, 4 × Cortex-A76BCM2837, 4 × Cortex-A53
Frequency stepsseveral, roughly 1500-2400 MHzonly two: 600 and 1200 MHz (1400 MHz on the B+)
Internal power measurementyes, vcgencmd pmic_read_adcdoes not exist - the command answers Command not registered
How we measure powerPMIC, with the INA219 as a witnessINA219 only, inserted into the board's supply
Power supplyUSB-C, 5 V / 5 Amicro-USB, 5 V / 2.5 A (in this lab: a bench supply)
Sections that differ-8 (read it, do not run it), 9 (wiring required), 11 and 12
No LCD? No INA219? The lab is built in stages. On a Pi 5, sections 6-8 work on a bare board, with no wire connected at all; the LCD is added at section 10 and the INA219 at section 9, and the full experiment can be done using only the internal measurement. On a Pi 3, sections 6, 7 and 10 also work without the INA219, but without it you have no power measurement at all: the wiring in section 9 is required for the full experiment.

4Where the consumption comes from

The two components of power

The power consumed by a digital circuit has two sources, and they behave completely differently:

ComponentFormulaWhen it occursHow to reduce it
Dynamic - transistor switchingPd = α·C·V²·f only while the circuit is workinglower V and f, or stop the clock (clock gating)
Static - leakage currentsPs ≈ V·Ileak permanently, as long as the circuit is poweredcut the power (power gating)

The key observation is the exponent: dynamic power depends on voltage squared, but only linearly on frequency. If we halve the frequency and can thereby also lower the voltage by 20%, the power drops to 0.5 · 0.8² = 32% of the initial value. That is why the two are always adjusted together - hence the name of the technique: DVFS, Dynamic Voltage and Frequency Scaling.

The trap: energy is not power

This is where most students trip up. Lower power does not automatically mean lower energy, because the task takes longer:

The fundamental relationship
E = P · t    [J = W · s]
The battery discharges in proportion to energy, not power. A processor that draws 2 W for 10 seconds consumes exactly as much as one that draws 10 W for 2 seconds.

If we halve the frequency, the task takes twice as long. The dynamic energy per task stays nearly unchanged (P falls linearly with f, t rises linearly with 1/f - they cancel out). What really changes is the static energy, which increases because the circuit stays on twice as long, and the dynamic energy through the lower voltage.

The two opposing strategies

StrategyHow it soundsWhen it wins
Race to idle
(run then sleep)
run at maximum frequency, finish as fast as possible, then enter a low-power state when idle power and leakage are large - i.e. on modern application processors
Just in time
(exactly on time)
choose the lowest frequency that still meets the deadline when idling costs almost nothing and dynamic power dominates - simple microcontrollers

There is no universal answer. Which one wins depends on the ratio between idle power and active power - and we can discover that for ourselves with the simulator below.

5DVFS explorer: where is the optimum

You have a task with a certain number of cycles and a deadline by which it must finish. Choose the frequency. The chart shows the total energy consumed by the deadline, for every possible frequency - the yellow dot is the optimum.

Frequency, voltage, energy and deadline
Experiment with the system power Leave the other parameters unchanged and move only the "system power" field - i.e. everything that stays on around the processor while it works: memory, peripherals, regulators.
System powerOptimal frequencyWhat it means
0 W≈ 800 MHzthe processor is alone in the world; only voltage matters, so work slowly
0.5 W≈ 1200 MHzbalance between the lower voltage and the longer time
3 W≈ 2200 MHzevery second is expensive - finish and put the whole system to sleep
The optimum shifts continuously, it does not jump: there is no universal "fast" or "slow" answer, but a point that depends on the ratio between what the processor consumes and what the rest of the board consumes.
This is where the whole idea of "race to idle" hides The "run then sleep" strategy only makes sense if, after the task finishes, the system can actually go into a deep sleep. If the memory stays refreshed and the peripherals stay on, finishing quickly gains you nothing - you paid for the high voltage for nothing. That is why, on an application processor, the frequency decision cannot be made separately from the decision about the platform's sleep states.
A note on the model The simulator uses a 600-2400 MHz range, typical of an application processor. The Raspberry Pi 5 has a narrower range in practice (roughly 1500-2400 MHz), and the Raspberry Pi 3 has only two steps (600 and 1200 MHz, or 1400 MHz on the B+), so the real effects you will measure will be smaller than those in the simulation. The model also assumes a linear relationship between frequency and voltage, which is an approximation. The idea, however, stays the same, and the difference between the model and the measurement is worth discussing explicitly in the report - a model that does not fit perfectly tells you something about the real system.

6Setting up the system

Go through the steps in order. Each step can be checked immediately - do not move on until you see the expected result.

  1. Update the system and install the tools

    We assume 64-bit Raspberry Pi OS (Bookworm or newer), with terminal access.

    update
    sudo apt update && sudo apt full-upgrade -y
    sudo apt install -y python3-pip python3-venv i2c-tools build-essential stress-ng
  2. Enable the I²C bus

    Through the configuration menu: Interface Options → I2C → Yes.

    configuration
    sudo raspi-config
    sudo reboot

    After rebooting, check that the bus exists:

    check
    ls -l /dev/i2c-1
    i2cdetect -y 1

    A grid of addresses should appear. Since nothing is connected yet, all the cells are --. If you get "No such file or directory", I²C was not enabled - redo the step.

  3. Create a Python virtual environment

    On Bookworm, Python packages are no longer installed directly into the system. The --system-site-packages option still lets us use libraries already installed via apt.

    working environment
    python3 -m venv --system-site-packages ~/si-lab
    source ~/si-lab/bin/activate
    pip install smbus2 RPLCD adafruit-circuitpython-ina219
    Worth rememberingEvery new terminal session you must run source ~/si-lab/bin/activate again, otherwise Python cannot find the libraries.
  4. Create the working folder
    folder
    mkdir -p ~/si-lab/lab01 && cd ~/si-lab/lab01
  5. Check which governors the processor has
    available governors
    cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors
    cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
    cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq

    You should see a list containing at least ondemand, powersave, performance and schedutil, and the current frequency expressed in kHz (for example 1500000 = 1.5 GHz).

    On a Pi 3 the current frequency can only be 600000 or 1200000 (1400000 on the B+) - the processor has only these two steps.

What /sys is It is not a folder of files on the card. It is a window into the operating system kernel's structures: when you read scaling_cur_freq, the kernel queries the clock controller on the spot and returns the answer as text. When you write to scaling_governor, the kernel actually changes the policy. It is the same idea as memory-mapped registers on microcontrollers, just moved one level up.

7Reading the processor state

The first program reads everything the system can tell us about the processor's state. It does not need any wire connected.

cpu_state.py
#!/usr/bin/env python3
"""Reads the processor state (Raspberry Pi 5 or 3) from sysfs and vcgencmd."""

import pathlib
import subprocess

NUM_CORES = 4
CPUFREQ = "/sys/devices/system/cpu/cpu{}/cpufreq/{}"


def read_value(path):
    """Reads a sysfs file; returns None if it does not exist."""
    try:
        return pathlib.Path(path).read_text().strip()
    except OSError:
        return None


def frequency(core=0):
    """Current frequency of a core, in MHz."""
    khz = read_value(CPUFREQ.format(core, "scaling_cur_freq"))
    return int(khz) / 1000 if khz else None


def governor(core=0):
    return read_value(CPUFREQ.format(core, "scaling_governor"))


def available_steps():
    """List of the frequency steps, in MHz."""
    val = read_value(CPUFREQ.format(0, "scaling_available_frequencies"))
    if not val:
        # Some cores only expose the limits, not the full list.
        low = read_value(CPUFREQ.format(0, "cpuinfo_min_freq"))
        high = read_value(CPUFREQ.format(0, "cpuinfo_max_freq"))
        return [int(low) / 1000, int(high) / 1000] if low and high else []
    return [int(v) / 1000 for v in val.split()]


def temperature():
    """Processor temperature, in degrees Celsius."""
    val = read_value("/sys/class/thermal/thermal_zone0/temp")
    return int(val) / 1000 if val else None


# Each bit of vcgencmd get_throttled flags a power or temperature issue.
# Bits 0-3 = happening now, bits 16-19 = has happened since boot.
FLAGS = {
    0: "undervoltage NOW",
    1: "frequency throttled NOW",
    2: "thermal throttling NOW",
    3: "soft temperature limit NOW",
    16: "undervoltage has occurred",
    17: "frequency throttling has occurred",
    18: "thermal throttling has occurred",
    19: "soft temperature limit has been reached",
}


def throttle_flags():
    """Returns the list of problems reported by the firmware."""
    try:
        output = subprocess.run(["vcgencmd", "get_throttled"],
                                capture_output=True, text=True, check=True).stdout
    except (OSError, subprocess.CalledProcessError):
        return ["vcgencmd unavailable"]
    value = int(output.strip().split("=")[1], 16)
    return [text for bit, text in FLAGS.items() if value & (1 << bit)]


if __name__ == "__main__":
    print("active governor  :", governor())
    print("steps (MHz)      :", available_steps())
    print("temperature      : %.1f degC" % temperature())
    print()
    for n in range(NUM_CORES):
        print("  core %d: %7.1f MHz" % (n, frequency(n)))
    print()
    issues = throttle_flags()
    print("throttling       :", ", ".join(issues) if issues else "none")

Run it twice: once on an idle system, then again while loading the processor. The difference should be visible right away.

test
python3 cpu_state.py                      # idle system

stress-ng --cpu 4 --timeout 30s &         # load all 4 cores
sleep 5 && python3 cpu_state.py           # and now?
If "undervoltage" or "thermal throttling" appears The measurement results become useless: the processor is no longer running at the frequency you requested, but at the one the power supply or the temperature allow. Use the official 27 W power supply and an active cooler. Check this before every experiment - it is the most frequent cause of results that "make no sense".
On a Raspberry Pi 3: frequency and voltage, as seen by the firmware The board's firmware reports the core frequency and the core supply voltage directly:
frequency and voltage
vcgencmd measure_clock arm
vcgencmd measure_volts core
Run the commands once idle and once under stress-ng. On some boards (especially the B+) the voltage rises together with the frequency, on others it stays almost constant. Both results are informative: without a voltage drop, DVFS gains much less than the formula in section 4 promises. Also on the Pi 3, "undervoltage" often shows up with phone chargers: the board needs 5 V / 2.5 A.

8Internal power measurement (Pi 5 only)

Using a Raspberry Pi 3? Read this section, but measure in section 9 The Pi 3 has no power circuit with measuring converters. On it, the command below answers with vc_gencmd_read_response returned -1 and error=1 error_msg="Command not registered": the firmware does not know the command. It is not an installation mistake and no update will fix it. Read the section anyway, to understand what you are missing, then continue with section 9, where the INA219 becomes the main measurement.

On a Pi 5, the same error means the firmware is too old: run sudo apt full-upgrade and sudo rpi-eeprom-update -a, then reboot the board.

The Raspberry Pi 5 has a dedicated power management circuit (PMIC) that distributes voltages to the processor, memory and peripherals. This circuit has analog-to-digital converters on every rail and can report its own currents and voltages. The command is:

raw reading
vcgencmd pmic_read_adc

The output looks like this (excerpt):

typical output
        3V7_WL_SW_A current(0)=0.00224000A
         3V3_SYS_A current(1)=0.31752000A
         1V8_SYS_A current(2)=0.29892000A
        VDD_CORE_A current(7)=2.61856000A
        ...
        3V7_WL_SW_V volt(16)=3.69946289V
         3V3_SYS_V volt(17)=3.30712891V
        VDD_CORE_V volt(23)=0.82031250V
            EXT5V_V volt(24)=5.09843750V

Every power rail appears twice: once with the _A suffix (the current) and once with _V (the voltage). The power of a rail is their product, and the total power is the sum over all rails. The following program does exactly that:

power_pmic.py
#!/usr/bin/env python3
"""Reads the power consumed directly from the Pi 5 board's power management circuit."""

import re
import subprocess

# Example line:  VDD_CORE_A current(7)=2.61856000A
LINE = re.compile(r"^\s*(?P<rail>\S+?)_(?P<kind>[AV])\s+\w+\(\d+\)=(?P<val>[0-9.]+)[AV]\s*$")


def measure():
    """Returns (total_power_W, dict with the power on each rail)."""
    try:
        output = subprocess.run(["vcgencmd", "pmic_read_adc"],
                                capture_output=True, text=True, check=True).stdout
    except (OSError, subprocess.CalledProcessError):
        return None, {}

    raw = {}
    for line in output.splitlines():
        m = LINE.match(line)
        if m:
            raw.setdefault(m.group("rail"), {})[m.group("kind")] = float(m.group("val"))

    # Only rails that report both current and voltage can yield a power.
    # EXT5V and BATT only report the voltage, so they are left out.
    detail = {name: v["A"] * v["V"] for name, v in raw.items() if "A" in v and "V" in v}
    if not detail:
        return None, {}      # the board has no measuring PMIC (e.g. a Pi 3)
    return sum(detail.values()), detail


if __name__ == "__main__":
    total, detail = measure()
    if total is None:
        raise SystemExit("PMIC unavailable - it only exists on a Raspberry Pi 5 (on a Pi 3 use power_ina219.py)")

    for name, p in sorted(detail.items(), key=lambda x: -x[1]):
        percent = 100 * p / total
        bar = "#" * int(percent / 2)
        print("%-12s %6.3f W  %5.1f%%  %s" % (name, p, percent, bar))
    print("-" * 46)
    print("%-12s %6.3f W" % ("TOTAL", total))
What you will see The VDD_CORE rail - the processor cores' supply - dominates under load and becomes almost negligible while idle. Run the program once idle and once under stress-ng and compare the breakdown. This is a measurement you cannot make on most development boards.
What this measurement does not include The reported power is what is consumed after the board's regulators. The power actually drawn from the wall is higher, by the regulator losses (typically 10-20%) and by everything powering the USB ports. That is why, in the next section, we compare it against an external measurement.

9External measurement with INA219

The INA219 module measures the voltage drop across a 0.1 Ω resistor placed in series with the load and converts it into a current. On a Pi 5 we use it as a "witness" for the internal measurement, while on a Pi 3 it is the only power measurement. It communicates over I²C, so it shares the bus with the LCD.

Raspberry Pi 5/3 40-pin header pin 1 - 3V3 pin 3 - SDA1 pin 5 - SCL1 pin 6 - GND pin 2 - 5V INA219 address 0x40 Vin+ / Vin− shunt 0.1 Ω LCD 16×2 PCF8574 address 0x27 3.3 V SDA SCL GND 5 V → LCD VCC both modules sit on the same I²C bus, at different addresses
Fig. 2 - The wiring. I²C is a two-wire bus: every module connects in parallel to the same SDA and SCL lines, and the address tells them apart. That is why the LCD and the INA219 can work at the same time on the same lines. The INA219 is powered from 3.3 V (pin 1), while the LCD is powered from 5 V (pin 2). The 40-pin header is identical on the Pi 5 and the Pi 3, so these connections are the same on both boards.
The LCD is powered from 5 V, not 3.3 V 16×2 displays with a PCF8574 adapter are designed for 5 V. Powered from 3.3 V, they usually light the backlight and answer on I²C, but the characters are not visible, however you set the contrast. Connect the LCD's VCC to pin 2 (5 V). On a Pi 3 powered through pin 4, pin 2 is the same 5 V line, so the wiring still holds.

One detail worth knowing: the adapter has pull-up resistors on SDA and SCL to its own supply, so at 5 V it pulls the Pi's lines slightly above 3.3 V. In practice it usually works, but the proper solution is an I²C level shifter between the Pi and the LCD, or removing the adapter's two pull-up resistors (the Pi has its own pull-ups to 3.3 V).
On a Pi 5: measuring the board's own consumption To measure the consumption of the whole board, the INA219 must be inserted into the +5 V wire from the power supply, before the Pi. That means cutting a USB-C cable - an operation that must be done carefully and on a sacrificial cable. The safe, recommended alternative for the lab: let the INA219 measure a separate load (for example the cooler's fan or a power LED powered from the 5 V pin) and use it to understand the principle, while reading the processor's consumption from the PMIC.

On a Raspberry Pi 3: the INA219 on the board's supply

The Pi 3 has no PMIC, so the INA219 must see all of the board's current. The simplest way, without cutting cables, is to power the board from a bench supply through the 5 V pin of the header, with the module's shunt inserted into the +5 V wire. The I²C connections (3V3, SDA, SCL, GND) remain those of Fig. 2.

bench supply 5.2 V max 2.5 A + − INA219 Vin+ → shunt → Vin− address 0x40 Raspberry Pi 3 40-pin header pin 4 - 5V (input) pin 1,3,5,6 - I²C pin 14 - GND micro-USB unplugged +5.2 V to the board I²C, as in Fig. 2 common GND
Fig. 3 - The wiring for the Pi 3. All of the board's current flows through the INA219 shunt (red), so the module measures the consumption of the whole board. The black wire closes the circuit directly, and the I²C wires are the same as in Fig. 2.
Rules for powering through the 5 V pin 1. Unplug the micro-USB cable. The board is never powered from two sources at the same time.
2. Set the supply to 5.15-5.2 V and limit the current to 2.5 A before connecting it to the board. Do not exceed 5.25 V.
3. The 5 V pin bypasses the board's protection fuse: check the polarity twice before powering up.
4. The shunt and the wires drop the voltage by about 0.1 V at 1 A, which is why the supply is set slightly above 5 V. If cpu_state.py reports "undervoltage", raise the supply voltage slightly, without exceeding the limit above.

Without a bench supply you can use a sacrificial micro-USB cable, with the red (+5 V) wire cut and routed through Vin+ → Vin−; the other wires stay uninterrupted. The bench-supply variant is, however, safer in the lab.

On either board, then check that the modules are seen on the bus:

detection
i2cdetect -y 1

40 (INA219) and 27 or 3f (LCD) should appear. If nothing appears, check the GND wire - it is the one most often forgotten.

power_ina219.py
#!/usr/bin/env python3
"""Reads voltage, current and power from an INA219 module."""

import time

import board
from adafruit_ina219 import ADCResolution, BusVoltageRange, INA219

i2c = board.I2C()             # automatically uses the I2C-1 bus
sensor = INA219(i2c)          # default address 0x40

# Configuration for quiet measurements: maximum resolution and averaging
# over 128 samples. Reduces noise, at the cost of a slower reading.
sensor.bus_adc_resolution = ADCResolution.ADCRES_12BIT_128S
sensor.shunt_adc_resolution = ADCResolution.ADCRES_12BIT_128S
sensor.bus_voltage_range = BusVoltageRange.RANGE_16V


def measure():
    """Returns (voltage_V, current_mA, power_W)."""
    # The real voltage on the load = the bus voltage + the drop across the shunt.
    voltage = sensor.bus_voltage + sensor.shunt_voltage
    current = sensor.current            # mA
    return voltage, current, voltage * current / 1000.0


if __name__ == "__main__":
    print("%8s %10s %10s" % ("V", "mA", "W"))
    try:
        while True:
            u, i, p = measure()
            print("%8.3f %10.2f %10.4f" % (u, i, p))
            time.sleep(0.5)
    except KeyboardInterrupt:
        print("\nstopped")
Why we add the shunt drop The INA219 measures the voltage at the output of the measuring resistor. The load, however, "sees" the voltage before it. At a current of 2 A on a 0.1 Ω shunt, the difference is 0.2 V - not negligible at all. It is a classic mistake in circuits found online.
On a Raspberry Pi 3: what you will see With the wiring in Fig. 3, power_ina219.py shows the power of the whole board. Run it once idle and once under stress-ng --cpu 4. As an order of magnitude, expect a few watts, a significant part of which is already consumed at idle (memory, USB, network). You do not get the per-rail breakdown of the Pi 5, but you get something the Pi 5's PMIC does not give you: the real consumption from the supply.

One measurement module for both boards

So that the following programs run unchanged on both the Pi 5 and the Pi 3, we hide the measurement source behind a small module that picks it by itself: the PMIC if it exists, the INA219 otherwise. Both variants return the same result shape, (total_power_W, detail).

power.py
#!/usr/bin/env python3
"""Automatically picks where we read the power from:
Pi 5 -> the internal PMIC (power_pmic.py),
Pi 3 -> the INA219 module on the board's supply (power_ina219.py)."""

import power_pmic

SOURCE = None


def measure():
    """No source available: returns the same format, but empty."""
    return None, {}


if power_pmic.measure()[0] is not None:
    SOURCE = "PMIC"
    measure = power_pmic.measure
else:
    try:
        import power_ina219           # initializes the sensor; fails if it is missing
        power_ina219.measure()        # a test reading
    except Exception as e:
        print("No PMIC, and the INA219 does not answer (%s)" % e)
    else:
        SOURCE = "INA219"

        def measure():
            """Same shape as power_pmic.measure(): (total_W, detail)."""
            _, _, p = power_ina219.measure()
            return p, {"BOARD_5V": p}


if __name__ == "__main__":
    total, _ = measure()
    if total is None:
        raise SystemExit("nothing to measure the power with - see sections 8 and 9")
    print("source: %s   power: %.3f W" % (SOURCE, total))
test
python3 power.py       # Pi 5: source PMIC   Pi 3: source INA219

10The real-time display

The LCD has 32 characters. We have to choose what is worth showing - a useful exercise in itself, because in embedded systems limited resources are always the rule, not the exception.

display.py
#!/usr/bin/env python3
"""16x2 LCD display for the energy test bench."""

from RPLCD.i2c import CharLCD

# If i2cdetect shows 3f instead of 27, change the address below.
ADDRESS = 0x27


class Panel:
    """Wrapper around the LCD that does not complain if the screen is missing."""

    def __init__(self, address=ADDRESS):
        self.lcd = None
        try:
            self.lcd = CharLCD("PCF8574", address, cols=16, rows=2, auto_linebreaks=False)
            self.lcd.clear()
            # Custom degree symbol, in the screen's character memory.
            self.lcd.create_char(0, [0b01100, 0b10010, 0b10010, 0b01100,
                                     0b00000, 0b00000, 0b00000, 0b00000])
        except Exception as e:
            print("LCD unavailable (%s) - continuing in the console only" % e)

    def write(self, top, bottom):
        """Writes two lines of at most 16 characters each."""
        top, bottom = top[:16].ljust(16), bottom[:16].ljust(16)
        if self.lcd is None:
            print("\r[%s|%s]" % (top, bottom), end="", flush=True)
            return
        # We write at fixed positions, not with clear(): otherwise the screen flickers.
        self.lcd.cursor_pos = (0, 0)
        self.lcd.write_string(top)
        self.lcd.cursor_pos = (1, 0)
        self.lcd.write_string(bottom)

    def close(self):
        if self.lcd is not None:
            self.lcd.clear()
            self.lcd.close(clear=True)


if __name__ == "__main__":
    import time
    from cpu_state import frequency, temperature
    from power import measure

    panel = Panel()
    try:
        while True:
            p, _ = measure()
            panel.write("%4.0fMHz  %4.1fW" % (frequency(), p or 0),
                        "temp %4.1f degC" % temperature())
            time.sleep(0.5)
    except KeyboardInterrupt:
        panel.close()
        print("\nstopped")
Why we do not use clear() every frame Fully clearing the screen takes a few milliseconds and leaves the display blank during that time, which shows up as flicker. Overwriting at fixed positions, with text padded with spaces to 16 characters, gives a stable image. The same technique is used on graphical screens too, where it is called double buffering.
If nothing appears on the LCD Go through these in order:
1. i2cdetect -y 1 must show 27 or 3f. If it shows 3f, change ADDRESS in display.py. If nothing shows up, check the SDA, SCL and GND wires.
2. If "LCD unavailable" appears in the console, the program cannot find the display and only writes the values to the terminal.
3. Backlight on, but no characters: check that the LCD's VCC is at 5 V (pin 2), then slowly turn the blue potentiometer on the back of the adapter while the program is running.
4. A row of solid blocks: the display is powered but receives no data - the problem is on I²C (address or wires).

11The complete program

Now we put everything together. First we need a compute task that does exactly the same thing every time, regardless of the processor's frequency - otherwise the comparison makes no sense.

bench.c - reference task
/* Test task: a fixed number of integer operations.
   The result is printed at the end, so the compiler cannot eliminate it. */
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

static uint64_t work(uint64_t n) {
    uint64_t x = 88172645463325252ULL;   /* xorshift64 generator */
    for (uint64_t i = 0; i < n; i++) {
        x ^= x << 13;
        x ^= x >> 7;
        x ^= x << 17;
    }
    return x;
}

int main(int argc, char **argv) {
    uint64_t n = (argc > 1) ? strtoull(argv[1], NULL, 10) : 400000000ULL;
    printf("%llu\n", (unsigned long long) work(n));
    return 0;
}
compile
gcc -O2 -o bench bench.c
time ./bench 400000000        # how long does it take? adjust the number for ~10 seconds

On a Pi 3, the same number of steps takes several times longer. Choose the value based on time, again for roughly 10 seconds at the maximum frequency, and put it in CYCLES in bench.py.

Why -O2 and not -O3 With aggressive optimizations, the compiler may vectorize or even eliminate the loop, and the "constant task" stops being constant. We come back to vectorization deliberately in Laboratory 5; here we need a stable reference.

And now the bench harness itself:

bench.py
#!/usr/bin/env python3
"""Energy test bench: runs the same task under every governor
and compares the time, average power and energy consumed."""

import csv
import subprocess
import sys
import time

from display import Panel
from power import SOURCE, measure
from cpu_state import frequency, governor, throttle_flags, temperature

GOVERNORS = ["powersave", "ondemand", "schedutil", "performance"]
CYCLES = 400_000_000          # adjust to get roughly 10 seconds (less on a Pi 3)
PERIOD = 0.2                  # power sampling interval, in seconds
NUM_CORES = 4


def set_governor(name):
    """Writes the same governor to every core. Requires root privileges."""
    for n in range(NUM_CORES):
        path = "/sys/devices/system/cpu/cpu%d/cpufreq/scaling_governor" % n
        subprocess.run(["sudo", "tee", path], input=name.encode(),
                       stdout=subprocess.DEVNULL, check=True)
    time.sleep(2)            # let the governor settle


def measure_idle(seconds=5):
    """Average power with the system idle - the reference for the useful energy."""
    samples = []
    end = time.time() + seconds
    while time.time() < end:
        p, _ = measure()
        if p:
            samples.append(p)
        time.sleep(PERIOD)
    return sum(samples) / len(samples) if samples else 0.0


def run_benchmark(panel, governor_name, p_idle):
    """Runs the task and samples the power while it is working."""
    set_governor(governor_name)

    process = subprocess.Popen(["./bench", str(CYCLES)], stdout=subprocess.DEVNULL)
    samples, frequencies = [], []
    t0 = time.time()

    while process.poll() is None:
        p, _ = measure()
        if p:
            samples.append(p)
        f = frequency()
        if f:
            frequencies.append(f)
        panel.write("%-11s%4.1fW" % (governor_name[:11], p or 0),
                    "%4.0fMHz  %4.1fC" % (f or 0, temperature()))
        time.sleep(PERIOD)

    duration = time.time() - t0
    p_avg = sum(samples) / len(samples) if samples else 0.0
    f_avg = sum(frequencies) / len(frequencies) if frequencies else 0.0

    return {
        "governor": governor_name,
        "duration_s": round(duration, 3),
        "avg_frequency_MHz": round(f_avg, 1),
        "avg_power_W": round(p_avg, 3),
        # Total energy consumed over the duration of the task.
        "energy_J": round(p_avg * duration, 2),
        # Energy above the idle consumption - the part actually "paid" for the computation.
        "useful_energy_J": round((p_avg - p_idle) * duration, 2),
        # How much computation we get for every joule spent.
        "Mcycles_per_J": round(CYCLES / 1e6 / max(p_avg * duration, 1e-9), 2),
        "temperature_C": round(temperature(), 1),
    }


def main():
    if SOURCE is None:
        raise SystemExit("Nothing to measure the power with - see sections 8 and 9.")
    print("Power measurement source: %s" % SOURCE)

    # Keep only the governors the kernel actually has.
    with open("/sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors") as f:
        available = f.read().split()
    to_run = [g for g in GOVERNORS if g in available]

    initial = governor()
    panel = Panel()

    issues = throttle_flags()
    if issues:
        print("WARNING - power supply or temperature:", ", ".join(issues))
        print("The results will not be comparable. Continuing anyway in 5 seconds...")
        time.sleep(5)

    print("Measuring idle consumption...")
    p_idle = measure_idle()
    print("Idle: %.3f W\n" % p_idle)

    results = []
    try:
        for g in to_run:
            print("Running with governor %s..." % g)
            r = run_benchmark(panel, g, p_idle)
            results.append(r)
            print("   %5.2f s   %5.2f W   %7.1f J   %6.0f MHz"
                  % (r["duration_s"], r["avg_power_W"], r["energy_J"],
                     r["avg_frequency_MHz"]))
    finally:
        set_governor(initial)     # leave the system as we found it
        panel.close()

    with open("results.csv", "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=list(results[0].keys()))
        writer.writeheader()
        writer.writerows(results)

    print("\n%-12s %8s %8s %9s %10s" % ("governor", "duration", "power", "energy", "Mcycles/J"))
    for r in results:
        print("%-12s %7.2fs %7.2fW %8.1fJ %9.2f"
              % (r["governor"], r["duration_s"], r["avg_power_W"],
                 r["energy_J"], r["Mcycles_per_J"]))

    fastest = min(results, key=lambda r: r["duration_s"])
    most_efficient = min(results, key=lambda r: r["energy_J"])
    print("\nfastest        : %s" % fastest["governor"])
    print("most efficient : %s" % most_efficient["governor"])
    if fastest["governor"] == most_efficient["governor"]:
        print("-> Same winner: 'race to idle' works on this platform.")
    else:
        print("-> Different winners: there is a trade-off between speed and energy.")


if __name__ == "__main__":
    sys.exit(main())
run it
cd ~/si-lab/lab01
source ~/si-lab/bin/activate
python3 bench.py

12The experiment and its interpretation

On a Raspberry Pi 5, a typical results table looks something like this (your values will differ depending on cooling, power supply and firmware version):

GovernorDurationAverage frequencyAverage powerEnergyMcycles/J
powersave16.8 s1500 MHz4.1 W68.9 J5.81
ondemand10.6 s2380 MHz6.5 W68.9 J5.81
schedutil10.5 s2390 MHz6.6 W69.3 J5.77
performance10.5 s2400 MHz6.6 W69.3 J5.77
The surprising result The energy is nearly the same everywhere, even though the power differs by 60%. That is exactly what the theory predicts: dynamic power drops linearly with frequency, but the duration grows with 1/f, and the product stays constant. What is left on the table is the static energy - and on an application processor with significant leakage, it tips the balance slightly toward "finish quickly".
What to expect on a Raspberry Pi 3 With only two frequency steps, the table splits into two groups: powersave runs at 600 MHz, while ondemand, schedutil and performance all climb to the maximum frequency and give almost identical results. The important difference from the Pi 5 is what you are measuring: the INA219 sees the whole board, including the idle consumption of the memory, the USB port and the network. In the terms of the explorer in section 5, the "system power" is high, and the theory predicts that powersave will consume more total energy, even though its power is lower. Check whether the measurement confirms the prediction, and compare the useful energy too, not just the total.

What you must compare in the report

  • Duration - how quickly the task finished
  • Total energy - what it actually cost the battery
  • Useful energy (above the idle consumption) - what was paid for the computation itself, without the surrounding infrastructure
  • Mcycles/J - the efficiency, i.e. performance per watt
  • Final temperature - to check whether thermal throttling occurred

How to check that the measurement is valid

  • Run each configuration three times; if the results differ by more than 5%, something is varying uncontrollably
  • Let the board cool down between runs (check with vcgencmd measure_temp)
  • Close the graphical interface: sudo systemctl isolate multi-user.target
  • Check vcgencmd get_throttled after every run - it must return 0x0
  • On a Pi 5: compare the power from the PMIC with the one from the INA219; the constant difference is the loss in the regulators
  • On a Pi 3: vcgencmd get_throttled must stay 0x0 with the INA219 wired in too; if undervoltage appears, the shunt and the wires drop the voltage too much (see the rules in section 9)

13Assignments

  1. Run cpu_state.py idle and under load. Note the frequencies of the four cores in both situations and explain why they are not necessarily equal.
  2. Run power_pmic.py idle and under stress-ng --cpu 4. Build a table with the power of each supply rail in both cases and calculate what percentage of the total increase comes from VDD_CORE.
    On a Pi 3: run power_ina219.py in the same two situations. You do not have the per-rail breakdown, so calculate the increase in the whole board's power and note, with vcgencmd measure_volts core, how the core voltage changes.
  3. Wire up the LCD and run display.py. Modify the program so the second line alternates every two seconds between the temperature and the energy accumulated since startup.
  4. Run the complete bench and fill in the table from the previous section with your own values.
  5. Based on the CSV file, plot two charts: energy versus governor and efficiency (Mcycles/J) versus governor. Comment on whether the two lead to the same conclusion.
  6. Manually fix the maximum frequency at 1500, 1800, 2100 and 2400 MHz and repeat the measurement:
    fixing the frequency
    echo 1800000 | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq
    Plot the energy-versus-frequency curve and compare its shape with the one from the DVFS explorer in section 5.
    On a Pi 3 there are only two steps, so the curve has two points: fix 600000 and 1200000 (1400000 on the B+) and compare the two energies. Discuss what a two-point curve can tell you and what it cannot.

14Deeper-dive challenge

Your own governor Write, in Python, a user-space governor that respects a fixed power budget, given by the user (for example 4 W). The program continuously monitors the power (from the PMIC on a Pi 5, from the INA219 on a Pi 3) and, if it exceeds the budget, lowers scaling_max_freq by one step; if it stays below the budget for more than three seconds, it raises it back by one step. Display the budget, the current power and the frequency limit in effect on the LCD.

This is, in essence, exactly what server processors do under the name RAPL power capping. The question you must answer in the report: how fast must the loop react so the system does not oscillate? Try periods of 0.1 s, 1 s and 5 s and describe the behavior in each case.

On a Pi 3, with only two steps, the loop can only switch between 600 MHz and the maximum frequency. Choose a budget between the idle power and the full-load power and compare the oscillation with that on the Pi 5.

15Self-check questions

16Resources