Spis treści:
W ostatnim materiale udało nam się przygotować w pełni funkcjonalną bibliotekę dedykowaną cyfrowemu czujnikowi światła VEML7700. Poza tym podzieliliśmy ją na dwa osobne pliki, wydzielając główny kod do main.c. W dzisiejszym materiale spróbujemy jeszcze bardziej ulepszyć program obsługujący sensor, dodatkowo przyjrzymy się bliżej funkcjonalności RP2040, jaką jest bezpośredni dostęp do pamięci – DMA.
Kup zestaw do nauki programowania z Raspberry Pi Pico W i skorzystaj z kursu dostępnego na Blog Botland!
W zestawie: moduł Raspberry Pi Pico W, płytka stykowa, przewody, diody LED, rezystory, przyciski, fotorezystory, cyfrowe czujniki światła, temperatury, wilgotności i ciśnienia, wyświetlacz OLED i przewód USB-microUSB.
Spis treści:
- Raspberry Pi Pico – #1 – zaczynamy
- Raspberry Pi Pico – #2 – słów kilka o programowaniu
- Raspberry Pi Pico – #3 – pierwszy program
- Raspberry Pi Pico – #4 – zaczynamy programować
- Raspberry Pi Pico – #5 – pętle, zmienne i instrukcje warunkowe
- Raspberry Pi Pico – #6 – PWM, ADC i komunikacja z komputerem
- Raspberry Pi Pico – #7 – Poprawki w kodzie i własne funkcje
- Raspberry Pi Pico – #8 – Przerwania i alarmy
- Raspberry Pi Pico – #9 – Teoria wskaźników i timery
- Raspberry Pi Pico – #10 – Tablice, struktury i maszyna stanów
- Raspberry Pi Pico – #11 – Uruchomienie cyfrowego czujnika światła, czyli I2C
- Raspberry Pi Pico – #12 – Przygotowujemy bibliotekę dla cyfrowego czujnika światła 1/2
- Raspberry Pi Pico – #13 – Biblioteka dla cyfrowego czujnika światła 2/2, DMA
Przed wyruszeniem w drogę należy zebrać drużynę
Chcąc uczyć się programowania, bazując na rzeczywistych projektach, potrzebny będzie oczywiście odpowiedni sprzęt, ale bez obaw – nie musisz teraz skakać między kolejnymi artykułami i przygotowywać listę niezbędnych elektronicznych elementów. W sklepie Botland dostępny jest gotowy zestaw, zawierający wszystkie komponenty niezbędne do wykonania projektów opisanych w serii poradników o Raspberry Pi Pico.
W gotowym zestawie elementów znajdziecie:
- Raspberry Pi Pico W,
- Przewód microUSB,
- Płytkę stykową,
- Zestaw przewodów połączeniowych w trzech rodzajach,
- Zestaw diod LED w trzech kolorach,
- Zestaw najczciej stosowanych w elektronice rezystorów,
- Przyciski Tact Switch,
- Fotorezystory,
- Cyfrowy czujnik światła,
- Cyfrowy czujnik wilgotności, temperatury i ciśnienia,
- Wyświetlacz OLED.
Czujnik jako obiekt strukturalny
W jednym z poprzednich materiałów poruszyliśmy temat struktur w języku C. Zbudowaliśmy wówczas prostą konstrukcję, składającą się z kilku zmiennych różnego typu, która opisywała przykładowego człowieka. Tak jak jednak wspominałem, prawdziwa potęga struktur ujawnia się przy okazji różnego rodzaju modułów, które możemy podłączyć do mikrokontrolera. Zamiast sterować zewnętrznym urządzeniem za pomocą dedykowanych tylko jemu poleceń, możemy stworzyć tak zwany obiekt strukturalny, opisany szeregiem zmiennych, które przekazywane będą do „uniwersalnych” funkcji sterujących. Oczywiście owa uniwersalność jest pewnym uproszczeniem, polecenia te nadal będą dedykowane konkretnej funkcjonalności, ale będą mogły sterować wieloma modułami, innymi słowy wieloma obiektami.
W taki właśnie sposób spróbujemy zmodyfikować kody obsługujące sensor światła. Stworzymy opisującą go strukturę, którą poprzez argument przekazywać będziemy do konkretnej funkcji. Pozwoli to też zbudować program, który będzie mógł działać na wielu takich samych czujnikach, różniący się miedzy sobą oczywiście tylko adresem. Powołamy do życia uniwersalną strukturę opisującą czujnik, na której podstawie utworzymy obiekt będący właśnie naszym sensorem VEML7700.
Tak więc, aby rozjaśnić ten może nieco mglisty opis przejdźmy od razu do kodów. Nie będziemy modyfikować wcześniej przygotowanego projektu, dlatego stworzymy kolejny o nazwie veml7700_struct, w jego skład wejdą podobnie jak ostatnio trzy pliki z kodem C – veml7700.h, veml7700.c oraz main.c. Dodatkowo niezbędny jest też plik CMakeFiles.txt, pamiętajcie o jego poprawnym przygotowaniu i dodaniu obu plików z rozszerzeniem .c.
veml7700.h
#include "hardware/i2c.h"
#define I2C_PORT i2c1
#define SDA_PIN 6
#define SCL_PIN 7
// VEML7700 register
#define VEML7700_ALS_CONF_0 0x00
#define VEML7700_ALS_WH 0x01
#define VEML7700_ALS_WL 0x02
#define VEML7700_POWER_SAVING 0x03
#define VEML7700_ALS 0x04
#define VEML7700_WHITE 0x05
#define VEML7700_ALS_INT 0x06
#define VEML7700_ID_REG 0x07
typedef struct {
i2c_inst_t *i2c;
uint8_t address;
} veml7700_t;
void veml7700_i2c_init();
void veml7700_init(veml7700_t *sensor, i2c_inst_t *i2c, uint8_t address);
uint16_t veml7700_read_light(veml7700_t *sensor);
uint16_t veml7700_read_white(veml7700_t *sensor);
uint16_t veml7700_read_interrupt(veml7700_t *sensor);
uint16_t veml7700_read_id(veml7700_t *sensor);
void veml7700_set_integration_time(veml7700_t *sensor, uint16_t time);
void veml7700_set_gain(veml7700_t *sensor, uint16_t gain);
void veml7700_set_persistence(veml7700_t *sensor, uint8_t persistence);
void veml7700_power_save(veml7700_t *sensor, uint16_t mode);
void veml7700_set_high_threshold(veml7700_t *sensor, uint16_t threshold);
void veml7700_set_low_threshold(veml7700_t *sensor, uint16_t threshold);
void veml7700_enable_interrupt(veml7700_t *sensor);
void veml7700_disable_interrupt(veml7700_t *sensor);
Cała magia związana z utworzeniem struktury, która reprezentować będzie sensor światła dzieje się w pliku nagłówkowym. W swojej budowie przypomina on znaną z poprzedniego artykułu wersję, różni się jednak szczegółami.
Warto zauważyć, że w tym przypadku potrzebujemy dołączenia biblioteki hardware/i2c.h, ponieważ korzystać będziemy z instancji *i2c, która ukryta jest w tej bibliotece, ale o tym za chwilę. W pierwszej części programu umieszczone zostały definicje związane z rejestrami VEML7700 oraz informacje o wybranym interfejsie i pinach SDA i SCL, ponieważ kod nadal zostawia opcję automatycznej lub manualnej inicjalizacji tego interfejsu, poprzez funkcję veml7700_i2c_init. Co warto zauważyć, zniknęła definicja określająca adres czujnika, ponieważ ten przekazywać do funkcji będziemy w nieco inny sposób.
W kolejnym kroku umieszczona została definicja struktury o nazwie veml7700_t. Składa się ona z dwóch elementów, które definiować będziemy przy okazji powoływania do życia obiektu w głównym pliku z kodem. Jest to adres czujnika w postaci uint8_t oraz nieco tajemniczo wyglądające i2c_inst_t *i2c. Rozbijając zapis ten na dwa elementy, można powiedzieć, że mamy tutaj zmienną typu i2c_inst_t wraz ze wskaźnikiem na i2c. W praktyce jednak jest to sprytne odwołanie do funkcjonującej w bibliotece hardware/i2c.h definicji. Naturalnym jest, ze w tym miejscu oczekiwać będziemy i2c0 lub i2c1, dlatego zamiast kombinować z tworzeniem dodatkowych, własnych definicji możemy odwołać się poprzez wskaźnik do już istniejącej struktury. W ten sposób tworzymy wskaźnik i2c na obiekt i2c_inst_t, innymi słowy, jest to wskaźnik wewnątrz struktury, wskazujący na inną strukturę.
Ostatni fragment kodu zawiera prototypy wszystkich obsługiwanych przez bibliotekę funkcji. Zasadniczo są to identyczne funkcje, jak te, które omawialiśmy poprzednim razem, z tą różnicą, że teraz każde polecenie oczekiwać będzie podania w argumencie wskaźnika na strukturę veml7700_t, ponieważ to właśnie tam zapisane będą dane o porcie I2C i adresie czujnika. Wyjątkiem jest tutaj veml7700_init, bo jak można zauważyć, w argumentach tej funkcji przekazujemy nie tylko strukturę, ale też wszystkie jej elementy, czyli *i2c oraz address. Jest to celowy zabieg, ponieważ w jakiś sposób będziemy musieli nakarmić strukturę odpowiednimi danymi. W głównym pliku main.c, zamiast odwoływać się do pojedynczych cementów veml7700_t, przekażemy dane poprzez argumenty funkcji, a ta zajmie się resztą i zapisze dane w strukturze.
veml7700.c
#include <stdio.h>
#include "pico/stdlib.h"
#include "hardware/i2c.h"
#include "veml7700.h"
void veml7700_i2c_init(){
i2c_init(I2C_PORT, 100 * 1000); // I2C 100kHz
gpio_set_function(SDA_PIN, GPIO_FUNC_I2C);
gpio_set_function(SCL_PIN, GPIO_FUNC_I2C);
gpio_pull_up(SDA_PIN);
gpio_pull_up(SCL_PIN);
}
void veml7700_write_register(veml7700_t *sensor, uint8_t reg, uint16_t value) {
uint8_t data[3] = {reg, value & 0xFF, value >> 8};
i2c_write_blocking(sensor->i2c, sensor->address, data, 3, false);
}
uint16_t veml7700_read_register(veml7700_t *sensor, uint8_t reg) {
uint8_t buffer[2];
i2c_write_blocking(sensor->i2c, sensor->address, ®, 1, true);
i2c_read_blocking(sensor->i2c, sensor->address, buffer, 2, false);
return (buffer[1] << 8) | buffer[0];
}
void veml7700_init(veml7700_t *sensor, i2c_inst_t *i2c, uint8_t address) {
sensor->i2c = i2c;
sensor->address = address;
veml7700_write_register(sensor, VEML7700_ALS_CONF_0, 0x0000);
}
uint16_t veml7700_read_light(veml7700_t *sensor) {
return veml7700_read_register(sensor, VEML7700_ALS);
}
uint16_t veml7700_read_white(veml7700_t *sensor) {
return veml7700_read_register(sensor, VEML7700_WHITE);
}
uint16_t veml7700_read_interrupt(veml7700_t *sensor) {
return veml7700_read_register(sensor, VEML7700_ALS_INT);
}
uint16_t veml7700_read_id(veml7700_t *sensor) {
return veml7700_read_register(sensor, VEML7700_ID_REG);
}
void veml7700_set_integration_time(veml7700_t *sensor, uint16_t time) {
uint16_t config = veml7700_read_register(sensor, VEML7700_ALS_CONF_0);
config &= ~(0x0F << 6); // Clear previous integration time settings
config |= (time << 6); // Set a new integration time
veml7700_write_register(sensor, VEML7700_ALS_CONF_0, config);
}
void veml7700_set_gain(veml7700_t *sensor, uint16_t gain) {
uint16_t config = veml7700_read_register(sensor, VEML7700_ALS_CONF_0);
config &= ~(0x03 << 4); // Clear previous gain settings
config |= (gain << 4); // Set a new gain
veml7700_write_register(sensor, VEML7700_ALS_CONF_0, config);
}
void veml7700_set_persistence(veml7700_t *sensor, uint8_t persistence) {
uint16_t config = veml7700_read_register(sensor, VEML7700_ALS_CONF_0);
config &= ~(0x03 << 4); // Clear previous ALS_PERS settings
config |= (persistence << 4); // Set new ALS_PERS bits
veml7700_write_register(sensor, VEML7700_ALS_CONF_0, config);
}
void veml7700_power_save(veml7700_t *sensor, uint16_t mode) {
veml7700_write_register(sensor, VEML7700_POWER_SAVING, mode);
}
void veml7700_set_high_threshold(veml7700_t *sensor, uint16_t threshold) {
veml7700_write_register(sensor, VEML7700_ALS_WH, threshold);
}
void veml7700_set_low_threshold(veml7700_t *sensor, uint16_t threshold) {
veml7700_write_register(sensor, VEML7700_ALS_WL, threshold);
}
void veml7700_enable_interrupt(veml7700_t *sensor) {
uint16_t config = veml7700_read_register(sensor, VEML7700_ALS_CONF_0);
config |= (1 << 1); // Setting the bit responsible for interrupts
veml7700_write_register(sensor, VEML7700_ALS_CONF_0, config);
}
void veml7700_disable_interrupt(veml7700_t *sensor) {
uint16_t config = veml7700_read_register(sensor, VEML7700_ALS_CONF_0);
config &= ~(1 << 1); // Clearing the bit responsible for interrupts
veml7700_write_register(sensor, VEML7700_ALS_CONF_0, config);
}
Wewnątrz pliku veml7700.c, tak jak ostatnim razem znalazły się rozwinięcia wszystkich prototypów funkcji umieszczonych w pliku nagłówkowym. Ich budowa nie różni się szczególnie względem poprzedniej wersji, jednak wyjaśnić musimy sobie tajemnicze opisy przypominające strzałki „->”, nierozerwalnie związane ze strukturami.
Przyjrzyjmy się pierwszej funkcji, czyli veml7700_write_register, która zapisuje dane do rejestru sensora. Ostatnim razem korzystaliśmy z przekazywanych poprzez argumenty funkcji danych, jednak tym razem ich nie ma, za to umieszczony jest tam wskaźnik na strukturę veml7700_t. Jak pamiętacie funkcja i2c_write_blocking oczekuje podania konkretnego portu I2C oraz adresu, pod który zapisane mają być dane. Informacje te znajdują się wewnątrz struktury, która w rozwinięciu funkcji określana jest jako sensor. Dlatego chcąc podać odpowiedni port, odwołujemy się do struktury sensor (niejako pod spodem jest to veml7700_t) i wybieramy z niej element o nazwie i2c poprzez zastosowanie (strzałki) „->”. Analogicznie podając adres modułu, poprzez sensor, wybieramy dane zapisane pod address.
Chwilę powinniśmy poświęcić także funkcji inicjalizującej czujnik. Tak jak wspomniałem, dane, które będziemy chcieli zapisać w strukturze, przekazywać będziemy poprzez właśnie to polecenie. W argumentach umieszczamy informację o porcie i adresie, które w kolejnych krokach poprzez poznaną już konstrukcję ze strzałką umieszczamy w strukturze. Z wnętrza struktury, które w tej funkcji przyjmuje nazwę sensor, a w rzeczywistości jest to veml7700_t wybieramy element i2c i przypisujemy do niego argument o identycznej nazwie i2c, który umieszczony był w argumencie. Podobnie w przypadku adresu, wybieramy element address i przypisujemy do niego wartość spod przekazanego w argumencie address.
Przy tej okazji zastanowić można się też, czy warto byłoby wrzucić inicjalizację interfejsu I2C do rozwinięcia właśnie tej funkcji, zamiast operować osobnym poleceniem. Mogłoby tak być, ponieważ przekazujemy tutaj już port I2C, do którego podłączony jest czujnik światła. Brakującym elementem jest tutaj tylko informacja o wyprowadzeniach, z których korzystamy. Jednak mimo to, nadal bardziej optymalną opcją wydaje mi się pozostawienie możliwości wyboru i zdecydowania, gdzie inicjalizowany będzie ten interfejs. Ktoś korzystający z tego kodu może chcieć nieco inaczej rozwiązać proces inicjalizacji, dlatego kod pozostawię w takiej formie. Jednak, jeśli chcecie, możecie spróbować umieścić ustawienie interfejsu I2C wewnątrz veml7700_init.
main.c
#include <stdio.h>
#include "pico/stdlib.h"
#include "hardware/i2c.h"
#include "veml7700.h"
int main() {
stdio_init_all();
veml7700_i2c_init();
veml7700_t sensor;
veml7700_init(&sensor, i2c1, 0x10);
veml7700_set_integration_time(&sensor, 0x0001);
veml7700_set_gain(&sensor, 0x0000);
veml7700_set_persistence(&sensor, 0x0001);
veml7700_set_high_threshold(&sensor, 0x9C40);
veml7700_set_low_threshold(&sensor, 0x0FA0);
veml7700_enable_interrupt(&sensor);
veml7700_power_save(&sensor, 0x01);
while (true) {
uint16_t device_id = veml7700_read_id(&sensor);
printf("Device ID: 0x%04X\n", device_id);
uint16_t light = veml7700_read_light(&sensor);
printf("Ambient Light: %u\n", light);
uint16_t white = veml7700_read_white(&sensor);
printf("White Light: %u\n", white);
uint16_t als_int = veml7700_read_interrupt(&sensor);
if (als_int & 0x4000) {
// Interruption of high threshold
printf("High threshold interrupt! Light: %u\n", light);
}
if (als_int & 0x8000) {
// Interruption of low threshold
printf("Low threshold interrupt! Light: %u\n", light);
}
sleep_ms(1000);
}
}
Główny program zapisany w main.c również wygląda znajomo i zasadniczo nie zmieniło się w nim wiele. Najważniejszą zmianą jest powołanie do życia struktury, a w zasadzie można nazwać to polecenie deklaracją obiektu (choć pamiętajcie, że C nie jest językiem obiektowym). Dzięki veml7700_t sensor;, tworzymy obiekt, którego nazwa to sensor i możemy traktować go jako nasz czujnik. Od teraz w kolejnych funkcjach, które jak pamiętacie wymagają w argumencie wskaźnika na odpowiednią strukturę, podawać będziemy właśnie sensor, a dokładniej &sensor, ponieważ przekazywać będziemy tylko adres a nie obiekt sam w sobie.
Zauważyć można to już w kolejnym poleceniu, które inicjalizuje czujnik. Przekazujemy wskaźnik na sensor oraz dane, które zapisane zostaną w tej strukturze, będące portem I2C oraz adresem modułu. Dzięki temu, w kolejnych poleceniach podając tylko wskaźnik, funkcja będzie mogła wyciągnąć zapisane tam dane i odpowiednio zaadresować miejsce, do którego zapisane lub z którego wyciągnięte mają być dane.
Dalsza część kodu jest już wam znana, karmimy czujnik przykładowymi danymi, a w nieskończonej pętli while odczytujemy wartości natężenia światła oraz flagi przerwań.
Pod względem działania kod ten nie różni się od poprzedniego projektu, jednak jego główną zaletą jest obiektowość. W tym przykładzie powołaliśmy do życia tylko jeden obiekt nazwany sensor, ale jeśli podłączylibyśmy do Raspberry Pi Pico więcej czujników, moglibyśmy tworzyć kolejne obiekty – sensor1, sensor2, sensor 3 itd. I z każdym komunikować się odrębnie, korzystając z tych samych funkcji, różniących się miedzy sobą tylko wskaźnikiem w argumencie. Pseudoobiektowość programów w języku C jest ich sporą zaletą, dlatego, jeśli jeszcze nie do końca to czujesz, spróbuj przeanalizować kod ponownie.
Czym jest bezpośredni dostęp do pamięci?
Przygotowując programy w języku C, naturalnym jest, że korzystamy z różnego typu zmiennych, którym przypisujemy odpowiednie wartości, mogące reprezentować dowolny typ informacji. Może to być informacja o wartości natężenia światła, temperaturze rdzenia procesora, czy też wewnętrzna wartość, z której korzystamy przy każdorazowej iteracji licznika. Jednak niezależnie od typu czy przechowywanej przez zmienną wartości, fizyczny proces przypisania wykonywany jest przez rdzeń procesora. To jego dziełem jest zapisanie konkretnego ciągu zer i jedynek w z góry określnym miejscu w pamięci. Trzeba jednak pamiętać, że tego typu proces zapisu, czy też przesyłania danych, zajmuje czas i angażuje zasoby CPU, co w niektórych przypadkach może być problematyczne.
We większości programów nie będzie miało to znaczenia, ale wyobraźcie sobie sytuację, w której musimy sterować wyświetlaczem. Każdy wyświetlacz ma własną rozdzielczość, która opisuje ilość pikseli w pionie i poziomie, dla przykładu może to być 128×64. Mnożąc obie liczby, otrzymamy wartość pikseli, z której zbudowany jest wyświetlacz, w tym przypadku będzie to 8192. Trzeba przyznać, że jest to dość sporo, a gdy dodamy do tego potrzebę jak najczęstszego odświeżania, okaże się, że procesor przynajmniej kilkanaście razy na sekundę musi generować paczki złożone z 8192 zmiennych, z której każda określa kolor pojedynczego piksela. A co z informacją o jasności?
Tym wstępem chciałbym zwrócić waszą uwagę, że w pewnych okolicznościach jednostki centralne muszą operować na naprawdę ogromnych ilościach danych. Muszą je wygenerować, a następnie przesłać, a gdy w międzyczasie chcielibyśmy zrobić też coś innego, mogłoby się okazać, że procesor sobie nie poradzi. I tu z pomocą przychodzi nam bezpośredni dostęp do pamięci, w skrócie DMA (dynamic memory acces).
Współczesne procesory, jaki i mikrokontrolery wyposażane są w moduły nazywane DMA. Dzięki nim możliwe jest przesyłanie danych bez udziału rdzenia logicznego. Innymi słowy, rdzeń niejako zleca przesłanie danych, wskazując miejsce, w którym te są umieszczone oraz to, gdzie mają trafić, a resztę zostawia modułowi DMA. W tym samym czasie realizowane mogą być inne zadania, a gdy dane zostaną przesłane, jednostka DMA zgłosi wykonanie zadania w postaci przerwania.
Raspberry Pi Pico również wspiera ten rodzaj funkcjonalności, dlatego w kolejnym przykładzie, który nazwałem dma_test, spróbujemy wykorzystać DMA i z jego pomocą przesłać dane z jednej tablicy do drugiej. Tak więc bez zbędnego przedłużania przejdźmy od razu do kodu, który omówimy fragmentowo.
#include <stdio.h>
#include "pico/stdlib.h"
#include "hardware/dma.h"
#include "hardware/irq.h"
#include "hardware/sync.h"
#define DATA_SIZE 10
// Global data buffers
uint32_t source_data[DATA_SIZE];
uint32_t destination_data[DATA_SIZE];
Zaczynamy klasycznie od dołączenia niezbędnych bibliotek. W ramach pracy z DMA skorzystać musimy z hardware/dma, hardware/irq, w której umieszczone są polecenia związane z przerwaniami oraz hardware/sync zawierającej tak zwane funkcje synchronizujące, również przydatne przy przesyłaniu danych bez użycia rdzenia procesora.
Dodatkowo tworzymy dwa bufory dla danych, których rozmiar definiuje DATA_SIZE. To właśnie w nich znajdować będą się dane, które będziemy przesyłać, w source_data umieścimy elementy do wysłania, natomiast w destination_data będzie tablicą odbiorczą.
// Interrupt handling function for DMA
void dma_complete_handler() {
dma_hw->ints0 = 1u << 0;
printf("DMA transfer complete\n");
}
// Interrupt handling function for DMA
void dma_complete_handler() {
dma_hw->ints0 = 1u << 0;
printf("DMA transfer complete\n");
}
Kolejnym elementem jest funkcja, która wywołana zostanie w momencie zgłoszenia przerwania przez DMA. Jak już wspomniałem, w taki właśnie sposób zgłaszane jest zakończenie transmisji danych i uruchamiana jest funkcja dma_complete_handler. W naszym przykładnie nie musimy specjalnie reagować na to przerwanie, wszakże chcemy tylko testowo przesłać dane z jednego miejsca w drugie. Dlatego wystarczy, że ustawimy bit w strukturze dma_hw, który pełni właśnie funkcję flagi przerwania (zgłoszone przerwanie realizowane jest zerem). Jak widać, zrealizowane jest to w dość dziwny sposób, ale taki również chciałbym wam pokazać.
Co warto podkreślić, moglibyśmy skorzystać z dma_hw->ints0 = 1; i miałoby to ten sam efekt jak widoczny wyżej zapis. Jego efektem również będzie ustawienie jedynki na ints0, jednak zrealizowane jest to w nieco inny sposób. Jeśli rozbijemy sobie widoczny kod, można dojść do wniosku, że jedynka przesuwana jest w lewo o zero kroków, czyli znajdzie się na zerowej pozycji w docelowej zmiennej. Tajemnicze „u” przy jedynce oznacza, że jest to liczba bez znaku (unsigned) i nie może w żadnym przypadku przyjąć wartości ujemnej. Stosowanie takiego zapisu może wydawać się nieco dziwne, ale jest ku temu kilka powodów. Pierwszym jest czytelność i fakt, że wiemy od razu, że jest to instrukcja działająca na bita. Teoretycznie „prostszy” zapis mógłby sugerować, że jest to operacja liczbowa. Dodatkowo, dzięki przesunięciu o zero kroków wiemy od razu, że ustawiamy bit zerowy, co mogłoby być przydatne zwłaszcza w przypadku większych zmiennych. Nie będę tutaj sugerować, który typ zapisu jest lepszy, warto jednak jest znać oba, bo z jedną, jak i drugą formą można spotkać się powszechnie.
int main() {
stdio_init_all();
// Fill the source table with data
for (uint8_t i = 0; i < DATA_SIZE; i++) {
source_data[i] = i;
}
// Initiate DMA channel
uint8_t dma_chan = dma_claim_unused_channel(true);
dma_channel_config dma_cfg = dma_channel_get_default_config(dma_chan);
// Configure the DMA channel
channel_config_set_transfer_data_size(&dma_cfg, DMA_SIZE_32); // Transmission of 32-bit data
channel_config_set_read_increment(&dma_cfg, true); // Automatic incrementation of the source address
channel_config_set_write_increment(&dma_cfg, true); // Automatic incrementation of the destination address
Po uruchomieniu głównej funkcji main pierwszym krokiem będzie uzupełnienie źródłowej tablicy przykładowymi danymi. Proces ten zrealizujemy za pomocą pętli for, która wykona się dziesięciokrotnie, ponieważ właśnie taka wartość umieszczona jest w DATA_SIZE. Poza tym przy każdym przebiegu pętli pod element tablicy zgodny z aktualną wartością „i” przypisane zostanie właśnie „i”. Dzięki temu uzyskamy paczkę danych z kolejnymi liczbami naturalnymi.
Gdy dane, które będziemy chcieli przesłać są już gotowe, możemy przejść do samej konfiguracji modułu DMA, ponieważ tak jak wspominałem, to właśnie rdzeń procesora, który wykonuje przygotowany kod, zgłasza potrzebę przesłana danych z punktu A do B. Na początek tworzymy zmienną dma_chan, do której za pomocą polecenia dma_claim_unused_channel przypisujemy numer pierwszego wolnego kanału DMA. To właśnie z jego pomocą przesyłane są dane. Parametr „true” w argumencie funkcji oznacza, że będzie ona czekać do momentu, aż pojawi się wolny kanał. Innymi słowy, gdyby w momencie jej uruchamiania DMA przesyłało już dane, funkcja wstrzymałaby program do momentu zakończenia tego procesu.
Gdy znamy już numer kanału, możemy wykorzystać go do pobrania jego domyślnej konfiguracji, którą zapiszemy w strukturze dma_channel_config. Zadanie to realizuje funkcja dma_channel_get_default_config, w której argumencie umieszczamy chwilę wcześniej pobrany numer dostępnego kanału, zapisany w dma_chan.
Kolejnym krokiem będzie ustawienie trzech szczególnych elementów. channel_config_set_transfer_data_size określa wielkość paczki, w której przesyłane będą dane. Korzystając z DMA_SIZE_32 określamy, że będą to domyślne 32 bity. Poza tym wskazać musimy również kanał DMA, którego tyczy się ten parametr, dlatego jednym z argumentów jest uzyskany dzięki „&” adres dma_cfg. W kolejnych dwóch funkcjach, dzięki parametrowi „true” uruchamiamy automatyczną inkrementację adresów w pamięci Pico, tak, aby każdy kolejny element tablicy umieszczony był w innym miejscu. Tutaj również korzystamy z operatora wyłuskania adresu, celem wybrania konkretnego kanału DMA.
dma_channel_configure(
dma_chan, // DMA channel
&dma_cfg, // Channel configuration
destination_data, // Destination address
source_data, // Source address
DATA_SIZE, // Number of elements to be sent
false // Don't run yet
);
Kolejno uzupełniamy pozostałe dane, które muszą znaleźć się w strukturze definiującej moduł DMA. Są to: wybrany kanał – dma_chan, adres do miejsca, w którym umieszczone są chwilę wcześniej ustawione parametry – dma_cfg, miejsce, z którego będziemy pobierać dane – destination_data, docelowe miejsce dla danych – source_data, liczba elementów do wysłania – DATA_SIZE oraz parametr false, definiujący, że w tym momencie DMA nie powinno być jeszcze uruchamiane.
// Register an interrupt for DMA
irq_set_exclusive_handler(DMA_IRQ_0, dma_complete_handler);
irq_set_enabled(DMA_IRQ_0, true);
// Start DMA transfer
dma_start_channel_mask(1u << dma_chan);
Następnie poprzez irq_set_exclusive_handler określamy, że w przypadku pojawienia się przerwania DMA_IRQ_0, informującego o zakończeniu transmisji danych uruchamiana powinna być funkcja dma_complete_handler, umieszczona na początku programu. Kolejna instrukcja, irq_set_enabled, dzięki parametrowi „true” uruchamia przerwania dla flagi DMA_IRQ_0.
W tym momencie DMA jest już w pełni skonfigurowane, a przerwania uruchomione, także możemy uruchomić proces, który rozpocznie przesyłane danych. Służy do tego funkcja dma_start_channel_mask, oczekująca w argumencie podania maski bitowej odpowiadającej wybranemu kanałowi DMA. Brzmi to skomplikowanie, ale w rzeczywistości oznacza utworzenie wartości, w której logiczna jedynka znajdzie się w miejscu odpowiadającym numerowi kanał. Tak więc wystarczy przesunąć liczbę jeden w lewą stronę, o wartość zapisaną w dma_chan. Dla przykładu, jeśli korzystać będziemy z kanału numer dwa, przesuniemy jedynkę o dwa miejsca, uzyskując wartość „10”, a właśnie danych w takim formacie oczekuje polecenie dma_start_channel_mask.
// Wait for the transfer to be completed
while (dma_channel_is_busy(dma_chan)) {
tight_loop_contents();
}
Następnie zważywszy, że nasz program nie będzie wykonywać żadnych innych operacji, możemy poczekać na zakończenie transmisji danych i poznać kolejną funkcję skojarzoną z DMA. dma_channel_is_busy zwraca status zajętości kanału, którego numer podaliśmy w argumencie. Gdy dane są przesyłane, będzie to jeden i funkcja while będzie cały czas zapętlona. Dopiero po zakończeniu transmisji argument pętli będzie fałszywy i RP2040 przejdzie do wykonywania kolejnych poleceń.
while(true){
// Check out the results
printf("Source Data: ");
for (int i = 0; i < DATA_SIZE; i++) {
printf("%d ", source_data[i]);
}
printf("\n");
printf("Destination Data: ");
for (int i = 0; i < DATA_SIZE; i++) {
printf("%d ", destination_data[i]);
}
printf("\n");
sleep_ms(1000);
}
}
W tym momencie wiemy już, że wszystko zostało zrobione i możemy sprawdzić efekty działania modułu DMA. W tym celu wyślemy na ekran komputera wartości spod source_data oraz destination_data, oczekując że będą one takie same. Zadanie to zrealizują dwie pętle niemal identyczne, jak ta, z której skorzystaliśmy w przypadku zapisywania danych do tablicy. Przy każdym jej przebiegu skorzystamy z printf, skojarzonego z indeksem i. Kod ten umieszczony jest wewnątrz nieskończonej pętli while i wykonywany będzie co sekundę.
Po uruchamianiu programu powinniśmy zobaczyć identyczne ciągi liczb naturalnych zarówno w tablicy źródłowej, jak i docelowej. Jeśli tak jest, oznacza to, że DMA przesłało dane poprawnie, a co najważniejsze w procesie tym nie brał udziału rdzeń procesora, a jego zadaniem było tylko odpowiednio skonfigurować ten moduł i go uruchomić. Oczywiście wykorzystywanie bezpośredniego dostępu do pamięci do tak prostego celu, jak przesłanie danych między dwoma niewielkim tablicami jest przerostem formy nad treścią, ale dzięki tak prostemu programowi mogliśmy po raz pierwszy skorzystać z DMA. Moduł ten ujawnia swoje możliwości dopiero w bardziej skomplikowanych programach takich jak sterowanie złożonymi wyświetlaczami.
Kilka słów na koniec…
W tym materiale zakończyliśmy pracę nad biblioteką dla cyfrowego czujnika światła VEML7700, czyniąc z niego element obiektowy. Dodatkowo poznaliśmy też funkcjonalność, jaką jest DMA i dzięki prostemu programowi sprawdziliśmy jej działanie. Na ten moment jest to ostatni materiał z serii poradników dla Raspberry Pi Pico, choć pozostało jeszcze sporo tematów, które moglibyśmy omówić: sterowanie wyświetlaczem OLED, komunikacja z czujnikiem warunków środowiskowych, wielordzeniowość w RP2040 i wiele innych. Jeśli chcecie, aby seria poradnikowa była kontynuowana, dajcie znać w komentarzu pod tym artykułem.
Źródła:
- https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf
- https://datasheets.raspberrypi.com/picow/pico-w-datasheet.pdf
- https://www.raspberrypi.com/products/rp2040/
- https://www.raspberrypi.com/documentation/microcontrollers/raspberry-pi-pico.html
- https://www.vishay.com/docs/84286/veml7700.pdf
Jak oceniasz ten wpis blogowy?
Kliknij gwiazdkę, aby go ocenić!
Średnia ocena: 5 / 5. Liczba głosów: 8
Jak dotąd brak głosów! Bądź pierwszą osobą, która oceni ten wpis.
2 Responses
Przydałaby się kontynuacja, niektore rzeczy mogłyby być wytłumaczone trochę prościej, ale i tak rzadko spotykane jest, aby ktoś w darmowym poradniku udostepniał tak kompleksowy kod tylko pod jeden czujnik, zazwyczaj coś takiego widziałem tylko w książkach lub płatnych kursach
Będzie kontynuacja ???