shabaz designed a Data Acquisition Board for Pi Pico . In this post, I'm simplifying the C++ class for the ADC. Focus on working with a wide set of data containers.
Goal: investigate if I can let it fill a classical C array, or STL Sequence Containers, Ranges and Views. 1 second worth of samples, in 860 samples per second mode.
image is ai generated with nightcafe
How was the data buffer handled in post 1
In the 1st post, I passed a C style array of uint to the ADS class. And the class would fill that buffer with samples.
uint16_t buf[SAMPLES];
ads.bulk_read(buf, sizeof buf);
The class then used our traditional loops to fill the data:
void ads1115::bulk_read(uint16_t* buf, size_t len) { uint8_t* begin = (uint8_t*)buf; uint8_t* end = ((uint8_t*)buf) + len; uint8_t reg = (uint8_t)reg::REG_CONVERSION; i2c_write_blocking(i2c_port, (uint8_t)address, ®, 1, false); set_data_ready(false); while (begin < end) { while(!is_data_ready()) {/* wait */ } set_data_ready(false); i2c_read_blocking(i2c_port, (uint8_t)address, begin, 2, false); begin += 2; } }
This is traditional C, where I loop from one address to the next, until I reach the end of the buffer. I tell the code what the size of the buffer is. I manually update the pointer in the loop. I take care that the loop ends at the right data element. Works well, and has proven to be effective in millions of programs.
C++ style, with Iterators or a Span
The STL can perform a number of the steps for me. It has the powers to derive the size. Deals with incrementing the iterator. And can call code on each of the buffer elements. I use all of that here.
The core part is where the samples are read from the ADS1115 IC and stored in the buffer:
std::for_each(begin, end, [this](uint16_t& u){ // capture "this", allows to call its methods while(!is_data_ready()) {/* wait */ } set_data_ready(false); // need to cast the uint16_t to a buffer of 2 uint8_t i2c_read_blocking(i2c_port, (*this)(address), reinterpret_cast<uint8_t *>(&u), 2, false); });
I used a Lambda function here. An anonymous block of code that will be executed every time. In the square brackets, I pass it the variables it needs to perform the exercise.
Because it's a Lambda, it's not part of the class, but an isolated block of code. I have to tell it the object it's working on. That's why I capture this (the object) in the [] part.
The part between the () is the data value it gets out of the container from the for_each algorithm. I passed it by reference (& u), so that the Lambda can update it inside the container.
The code between the {} is the Lambda function.
What happens in real time:
- for_each will pass the first element to the Lambda
- Lambda:
- will wait for a sample to be ready,
- get the data via i2c,
- then writes the data to the data element
- if this was the last element, for_each stops and the buffer is filled.
- else, for_each gets the next element and we go back to step 1
Option 1: pass the begin and end iterators
The first implementation takes the start and (one past) end points of the data container. All elements within that range will get a sample.
In your code:
uint16_t buf[SAMPLES];
ads.bulk_read(std::begin(buf), std::end(buf)); // option 1: with iterators
I used a traditional C array of uint_16 here. You can try this out with the STL containers.
The ads1115 class method:
// get bulk samples by passing iterators to buffer template<typename Iterator> void bulk_read(Iterator begin, Iterator end) { // defined in header: https://stackoverflow.com/questions/495021/why-can-templates-only-be-implemented-in-the-header-file uint8_t reg = (uint8_t)reg::REG_CONVERSION; i2c_write_blocking(i2c_port, (*this)(address), ®, 1, false); set_data_ready(false); std::for_each(begin, end, [this](uint16_t& u){ // capture "this", allows to call its methods while(!is_data_ready()) {/* wait */ } set_data_ready(false); // need to cast the uint16_t to a buffer of 2 uint8_t i2c_read_blocking(i2c_port, (*this)(address), reinterpret_cast<uint8_t *>(&u), 2, false); }); }
This one is the easiest if you want to fill a part of the buffer. If you use a round-robin pattern, you can use it to pass the first half of the buffer in one call, then the 2nd half in the next call, etc.... The method will nicely stay within those boundaries. This will allow you to pass the half data set to the other Pico core for processing, and fill the other half in parallel with fresh data.
Option 2: just pass the buffer (uses std::span)
This 2nd option allows you to just pass the data container. As long as it is sequentially addressable and updatable, it will work. The container can be a plain C array, a STL Vector, Array, (Forward) List, Double-ended Queue, or a Span.
Daily bit(e) of C++ | std::span by ŠIMON TÓTH: C++20 introduced std::span, a view that can wrap a contiguous memory block and provides a contiguous range interface. We can use a std::span to bridge the C and C++ code and to access the underlying byte representation. |
The method bulk_read(std::span<uint16_t> buf) will accept any of those, derive the begin and end iterator, and pass it to the option 1 function.
In real life:
uint16_t buf[SAMPLES];
ads.bulk_read(buf); // option 2: with span view
The ads1115 class method:
// get bulk samples by passing a container (can be a C array) void bulk_read(std::span<uint16_t> buf) { bulk_read(buf.begin(), buf.end()); }
This one is easier if you just want to fill the full buffer. The class will look up the start and end. Can also be used to pass a subset of the buffer (by passing it you own span view of a range of the buffer container). But in that case option 1 is usually simpler.
Enjoy!
Source and firmware: https://github.com/jancumps/adc_board_test/tree/object-oriented
I created a mock program, that tests the constructs on any platform that has a C++20 capable toolchain. Can be used to validate what containers are supported, and if the choices change size or overhead. #include <algorithm> #include <span> #include <iterator> #include <numeric> #include <stdint.h> #include <stdio.h> #define SAMPLES (10) #define BT_C_ARRAY // #define BT_STD_VECTOR // #define BT_STD_ARRAY // #define BT_STD_LIST #ifdef BT_C_ARRAY // no include uint16_t buf[SAMPLES]; #endif #ifdef BT_STD_VECTOR #include <vector> std::vector<uint16_t> buf = std::vector<uint16_t>(SAMPLES); #endif #ifdef BT_STD_ARRAY #include <array> std::array<uint16_t, SAMPLES> buf = std::array<uint16_t, SAMPLES>(); #endif #ifdef BT_STD_LIST #include <list> std::list<uint16_t> buf = std::list<uint16_t>(SAMPLES); #endif class sensor { public: // constructor sensor() : voltconversionfactor(1.1) {} // mock conversion factor // mock get bulk data by passing iterators to buffer template<typename Iterator> void fill(Iterator begin, Iterator end) { // capture "this" between the [], allows to call this object's methods // not used in this example, but used in real code to check data readiness status std::for_each(begin, end, [this](uint16_t& u){ u = 0b0000000100000000; // in reality, a sensor would get i2c/spi, ... data here // I used 256, because after byte swapping later, it 'll become 1 // obviously, just mocking sensor data here }); } // get bulk data by passing a container (can be a C array) void fill(std::span<uint16_t> buf) { fill(buf.begin(), buf.end()); } // mock convert the raw value to volts present at the ADC input // obviously, just mocking conversion here inline double to_volts(uint16_t raw) { return (double)(raw * voltconversionfactor); } private: double voltconversionfactor; // mock conversion factor }; sensor s = sensor(); int main() { setbuf(stdout, NULL); // print output while debugging // select the option to get the data: via iterators or by passing the buffer // uncomment the one you want to use, comment the other // s.fill(std::begin(buf), std::end(buf)); // option 1: with iterators s.fill(buf); // option 2: with span view // buffer bytes will now get swapped, // and that's needed for the next actions // use stl iterator and lambda function std::for_each(std::begin(buf), std::end(buf), [](uint16_t& u){ u = u >> 8 | u << 8; }); // swap bytes // statistics. Prereq: bytes are already swapped // average uint16_t average = std::accumulate(std::begin(buf), std::end(buf), 0.0) / std::size(buf); printf("AVG = %.7f V\n", s.to_volts(average)); // min and max auto minmax = std::minmax_element(std::begin(buf), std::end(buf)); printf("MIN = %.7f V\n", s.to_volts(*minmax.first)); printf("MAX = %.7f V\n", s.to_volts(*minmax.second)); // convert every value to voltage double volts[SAMPLES]; std::transform(std::begin(buf), std::end(buf), std::begin(volts), [](uint16_t u){ return s.to_volts(u); }); // print voltage std::for_each(std::begin(volts), std::end(volts), [](double& d) { printf("SMP = %.7f V\n", d); }); return 0; } https://gist.github.com/jancumps/36a1d41e2ff566f07332c7db5757a6b6 Spoiler: This fill method (option 1) supports all sequence containers: The second fill method (option 2) works with classic C array, std::vector and std::array. |
Top Comments