↓Skip to main content

Chapter 9 — AD Converter: three ways to read a voltage

Link Purpose
9. Chapter AD Converter Freenove’s official C version
Chapter 9 Serial Communication - Starter Kit for Pico Freenove’s official video description
Alire crate Alire crate containing the Ada code in this chapter
GNATdoc documentation for this chapter Automatically generated HTML documentation for the Ada code in this chapter

Hardware setup and AGND / ADC_VREF #

What I did not realise until now is that the Pico does not treat every ground and 3.3 V pin the same. Most of the header is digital: ordinary GND pins and the 3V3 output. The ADC side of the board has its own pair:

  • AGND (pin 33) — analogue ground. A separate ground plane runs under GPIO 26–29 and ends at this pin. Use it as the return path for analogue circuits.
  • ADC_VREF (pin 35) — analogue reference and ADC supply. It is not a second regulator. On the Pico it is simply 3V3 after an R–C filter (about 200 Ω into 2.2 µF). That filter knocks down some digital noise, but the ADC itself draws a little current through the resistor, so ADC_VREF sits slightly below 3V3 (on the order of tens of millivolts).

They are related to the digital rails, not isolated from them. The datasheet even says you may tie AGND to digital ground if ADC accuracy does not matter. For anything you actually want to measure, keep analogue current on AGND and treat ADC_VREF as the top of the ADC’s scale.

The RP2040 ADC has five channels. Only three of them are general-purpose inputs on the Pico header. The other two are wired on the board and are not available as analogue inputs to the end user:

Pin Name ADC Description
31 GP26_A0 0 Analogue input 0 (user)
32 GP27_A1 1 Analogue input 1 (user)
33 AGND — Analogue ground
34 GP28_A2 2 Analogue input 2 (user)
35 ADC_VREF — Filtered ADC reference / supply
— GP29_A3 3 Internal: VSYS ÷ 3. Not on the header.
— — 4 Internal temperature sensor. Not a pin.

So the kit, and any circuit you build on the breadboard, can only use GP26, GP27 and GP28.

The converter is 12-bit: 4096 steps, codes 0–4095, from AGND up to ADC_VREF. In practice there are dead zones at both ends. On my board, shorted to AGND I never saw 0 — the floor was 19 (about 15.3 mV if you scale against 3.3 V / 4096). Driven to ADC_VREF I never saw 4095 — the ceiling was 4086 (about 3.294 V on the same scale).

That leaves an awkward choice of reference for the conversion formula:

  • Scale against 3V3 and any input above the slightly lower ADC_VREF saturates at the top code.
  • Scale against the real ADC_VREF and you never quite reach full scale, because of those dead zones and the offset across the filter resistor.

Either way the ends of the range are a bit mushy. Freenove power the potentiometer from 3V3 and GND and convert as if the reference were 3.3 V. I used ADC_VREF and AGND instead. Both are valid; you are free to try both and see which matches a meter on your board.

Pico with Potentiometer
Pico with Potentiometer

Pico.Analog.Input helper package #

Pico.Analog.Input turns an RP.ADC.Analog_Value into a fixed-point Volts type. That is only convenience: a voltage reads as a voltage, without the code size and rounding behaviour of Float.

type Base_Volts is delta 0.000_1 range 0.0 .. 2.0**14 with
   Small => 2.0**(-16), Size => 32;

subtype Volts is Base_Volts range 0.0 .. 3.3;

delta is the resolution I want to talk about (0.1 mV). 'Small is finer and a power of two (\(2^{-16} \approx 15.3\,µV\)), so GNAT can implement the type as a scaled integer. The human-facing delta stays decimal; only the bound and 'Small need to be powers of two. The subtype still fits easily in 32 bits: \(3.3 / 2^{-16} \approx 216\,269\) steps.

A type that only had to store \(0 \ldots 3.3\) could be 16 bits ('Small => 2.0**(-14)). That is not enough for the conversion. I need the product

\[ 4095 \times 3.3 = 13\,513.5 \]

to exist as a fixed-point value before I divide by 4095. That is the same rule as integer scaling: multiply first, then divide. Rounded up to the next power of two, the working range is \(2^{14} = 16\,384\), which is why Base_Volts is wider than Volts. Volts is just the readable slice of that working type.

VREF      : constant Base_Volts := Volts'Last;   -- 3.3 V
Max_Count : constant Base_Volts :=
   Base_Volts (RP.ADC.Analog_Value'Last);        -- 4095.0

function To_Volts (Analog : RP.ADC.Analog_Value) return Volts is
  (if Analog >= Ceiling then
      Volts'Last
   elsif Analog <= Floor then
      Volts'First
   else
      Base_Volts (Base_Volts (Analog) * VREF) / Max_Count);

Base_Volts (Analog) is the count written as a fixed-point number (so 2 048 becomes 2048.0, not 2.048 V). Multiplying by VREF gives a value up to about 13 514, which only Base_Volts can hold. The inner Base_Volts (…) on the product is the important one: it forces that wide intermediate. Then divide by Max_Count. The result is in range for the Volts subtype and the expression function’s return type supplies the conversion.

The result is also clamped, because the RP2040 ADC never quite reaches the rails (see the dead zones in the previous section):

Floor   : constant RP.ADC.Analog_Value := 20;
         -- ≈ 16 mV at 3.3 V / 4095
Ceiling : constant RP.ADC.Analog_Value :=
   RP.ADC.Analog_Value'Last - Floor;
         -- 4075 ≈ 3.28 V

The Sketch #

The Freenove listing prints one ADC count and one voltage. I wanted all three representations on the same line: the raw 12-bit code, the HAL’s integer µV, and the fixed-point Volts from Pico.Analog.Input.

Type Package What it is
RP.ADC.Analog_Value HAL.UInt12 The converter’s code, 0 .. 4095. No unit.
RP.ADC.Microvolts Integer A new reading, scaled to µV with VREF = 3_300_000.
Pico.Analog.Input.Volts 32-bit fixed-point Another new reading, passed through To_Volts and clamped.

Read_Microvolts uses \(3.3\,\mathrm{V} / 4095\) and a Float inside rp2040_hal. To_Volts does the same ratio in fixed-point. Putting both on one line is how you see whether that matters. It will also differ in CPU cycles.

They are three conversions, not three formats of one sample. On a still pot they stay within a count or two. While the knob is moving they can drift a little further. That is expected.

Setup #

Pin_Analog_In is ADC channel 0, which is GP26 — the same pin Freenove uses.

RP.ADC.Enable;
RP.ADC.Set_Mode (RP.ADC.One_Shot);
RP.ADC.Set_Sample_Bits (12);
RP.ADC.Configure (Pin_Analog_In);
  • Enable clocks the ADC.
  • Configure puts GP26 into analogue mode (high-Z, no pulls) and selects the channel.
  • Set_Sample_Bits (12) is the native width. The other legal value is 8, which only right-shifts the FIFO by four bits.
  • One_Shot starts a fresh conversion on every Read.

I did try Free_Running, hoping it would be faster. The HAL leaves the FIFO enabled and never drains it, so after the 0.5 s delay I was popping samples from the start of that wait. The trace looked sluggish, and the three calls on one line diverged far more than a count or two. Free_Running only helps if you empty the FIFO in a tight loop so it never fills. A 2 Hz Put_Line is not that loop.

The loop #

Half a second between lines, then three independent reads:

loop
   declare
      Analog     : constant RP.ADC.Analog_Value :=
         RP.ADC.Read (Pin_Analog_In);
      Microvolts : constant RP.ADC.Microvolts :=
         RP.ADC.Read_Microvolts (Pin_Analog_In);
      Volts      : constant Pico.Analog.Input.Volts :=
         Pico.Analog.Input.Read_Volts (Pin_Analog_In);
   begin
      Pico.UART_IO.Put_Line
        ("Voltage Value: "
         & Analog'Image & " / "
         & Microvolts'Image & "µV / "
         & Volts'Image & "V");
   end;
   Next := @ + Period;
   delay until Next;
end loop;

CoolTerm then looks like this:

+ Sketch_09_1_ADC
> Initialising Main
> Starting main loop
Voltage Value:  19 /  15311µV /  0.0000V
Voltage Value:  18 /  15311µV /  0.0000V
Voltage Value:  19 /  15311µV /  0.0000V
Voltage Value:  20 /  15311µV /  0.0000V
Voltage Value:  29 /  22564µV /  0.0226V
Voltage Value:  89 /  71722µV /  0.0725V
Voltage Value:  329 /  265128µV /  0.2643V
Voltage Value:  566 /  456117µV /  0.4577V
Voltage Value:  585 /  472234µV /  0.4722V
Voltage Value:  777 /  626154µV /  0.6253V
Voltage Value:  961 /  772015µV /  0.7720V
Voltage Value:  1243 /  1000879µV /  1.0009V
Voltage Value:  1385 /  1116923µV /  1.1161V
Voltage Value:  1658 /  1334506µV /  1.3361V
Voltage Value:  1881 /  1515018µV /  1.5150V
Voltage Value:  2098 /  1690696µV /  1.6907V
Voltage Value:  2360 /  1901832µV /  1.9018V
Voltage Value:  2634 /  2121832µV /  2.1218V
Voltage Value:  2899 /  2336191µV /  2.3362V
Voltage Value:  3179 /  2561832µV /  2.5618V
Voltage Value:  3434 /  2764103µV /  2.7641V
Voltage Value:  3579 /  2884176µV /  2.8850V
Voltage Value:  3904 /  3146081µV /  3.1461V
Voltage Value:  4081 /  3290330µV /  3.3000V
Voltage Value:  4082 /  3291136µV /  3.3000V
Voltage Value:  4084 /  3289524µV /  3.3000V
Voltage Value:  4083 /  3290330µV /  3.3000V

Left to right: counts, microvolts, volts.

At the bottom the hardware floor is about 19 counts (15.3 mV as µV). To_Volts clamps that to 0.0000V, so you will not see 0 / 0µV / 0.0000V. Analog can wobble 18–20 while Read_Microvolts still happens to land on 19 and print 15311µV — that is two conversions, not a scaling bug.

At the top the hardware ceiling sits around 4081–4084, a few tens of millivolts shy of 3.3 V. The µV column tracks that; the volts column hits 3.3000V because of the software ceiling. Never 4095.

If it raises #

The handler prints the exception name and message on Ada.Text_IO and then raises again. Text_IO and UART_IO are not always the same stream on this runtime, so a crash is still visible on the debugger if the UART is wedged.

That raise is also why the procedure is marked No_Return. There is no return after the work loop, but I am cheating a little. The Jorvik profile does not allow this subprogram to come back at all, so No_Return is a promise, not decoration. The infinite loop keeps that promise on the happy path. The exception handler does not. It logs the failure and then escapes.

A SPARK proof would notice at once. After the handler the procedure has still not returned, but it has also not shown that it will run forever. The prover would insist on a second, outer loop so that a failure is logged and the sketch resumes instead of propagating:

loop
   begin
      loop
         -- the work
      end loop;
   exception
      when E : others =>
         Log (E);
         -- back into the outer loop
   end;
end loop;

What I ship here is the other choice. The embedded runtime tears down every task and halts the core. That is acceptable for a tutorial sample you will reset with the BOOTSEL button. It is not acceptable for an application that must keep running. The SPARK prover is, as always, right.