Chapter 9 — AD Converter: three ways to read a voltage
Table of Contents
| 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.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
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);
Enableclocks the ADC.Configureputs 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_Shotstarts a fresh conversion on everyRead.
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.