Crash Course STM32C0 - programowanie mikrokontrolerów ARM w rejestrach

Crash Course STM32C0 - programowanie mikrokontrolerów ARM w rejestrach

Programowanie prostszych mikrokontrolerów (np. AVR, PIC, MSP430 czy też przestarzałych już 8051 bądź HCS08) bez użycia bibliotek, tj. przy wykorzystaniu samych tylko plików nagłówkowych z definicjami rejestrów i zawartych w nich bitów, jest raczej naturalną konsekwencją nieskomplikowanej architektury tych procesorów. Bardziej rozbudowane układy – w szczególności te oparte na rdzeniach ARM – są zwykle nieporównanie trudniejsze do opanowania na niskim poziomie abstrakcji, stąd większość programistów systemów wbudowanych korzysta w swojej codziennej pracy z bibliotek. Niniejszy kurs ma na celu pokazanie innej ścieżki rozwoju i – mamy nadzieję – przekona przynajmniej część spośród naszych Czytelników do zaprzyjaźnienia się z wymagającą, ale niezwykle wartościową metodą programowania układów STM32.

Wprowadzenie

Pamiętam, jak kilkanaście lat temu pewien doświadczony elektronik-programista powiedział mi, że – cytuję – „w przypadku STM32 wystawienie jedynki na port wymaga ukończenia wyższych studiów technicznych”. Choć niewątpliwie w zdaniu tym jest pewna przesada, to jednak wbrew pozorom nie jest ono pozbawione ziarnka prawdy. Faktycznie: skonfigurowanie nawet najprostszych, podstawowych peryferiów nowoczesnego procesora ARM jest zadaniem dalece bardziej złożonym niż wykonanie tego samego zadania np. na poczciwym układzie ATtiny czy MSP430. Niesłusznie byłoby jednak zrzucać całą winę za złożoność programowania 32-bitowców na karb samego tylko rdzenia. Dużo większe znaczenie ma bowiem po prostu konstrukcja wbudowanych bloków peryferyjnych, które znajdujemy w „dużych” mikrokontrolerach – a ta zależy już tylko od decyzji poszczególnych producentów, opracowujących procesory z rdzeniami na licencji ARM.

Wśród programistów pokutuje zatem przekonanie, że programowanie 32-bitowców w rejestrach okazuje się niezwykle trudne, a sam kiedyś spotkałem się z niedowierzaniem, że w ogóle jest to możliwe. Do pisania kodu na najniższym możliwym poziomie (zaraz po asemblerze) zniechęca także powszechne stwierdzenie, że taki sposób pracy byłby zwyczajnie nieopłacalny, ponieważ kod napisany w rejestrach nieszczególnie nadaje się do przenoszenia pomiędzy różnymi rodzinami procesorów tego samego producenta. Po części istotnie tak jest – osoby znające procesory STM32 od podszewki z pewnością spotkały się już z (nierzadko zaskakującymi) różnicami pomiędzy określonymi grupami układów, np. STM32L1, STM32F4, STM32F0 czy też STM32F3. Co ciekawe, bliższe zapoznanie się z obszerną dokumentacją pokazuje dobitnie, że o ile niektóre peryferia są niemal identyczne w większości linii produktowych, o tyle dywersyfikacja nazewnictwa i funkcjonalności rejestrów w innych blokach sprzętowych potrafi przyprawić o ból głowy. Mało tego – producentowi zdarza się (choć rzecz jasna nie w złej wierze) zastawiać na programistów niskopoziomowych pewne pułapki, np. poprzez przesunięcie bitów lub nawet fragmentów pól bitowych w ramach rejestrów o tej samej nazwie i (w przybliżeniu) tożsamym przeznaczeniu.

Czy pisanie kodu w rejestrach ma zatem jakiś sens? Odpowiedź jak najbardziej brzmi TAK! W przypadku rodziny STM32C0 nie należy bowiem spodziewać się szczególnego zróżnicowania modeli układów, gdyż ich znacząca rozbudowa zanadto zbliżyłaby je do „mainstreamowych” linii produktowych, takich jak STM32F0 czy STM32L0. Jeżeli zatem ograniczymy nasz nowy sposób pracy nawet tylko do tej jednej grupy układów, to problemy z przenośnością kodu przestaną mieć znaczenie, zyskamy natomiast szereg innych benefitów. Jakich?

Oto najważniejsze zalety niskopoziomowego podejścia do programowania STM32 (i nie tylko).

  1. Wydajność obliczeniowa – kod pisany z użyciem gołych rejestrów może być wysoce zoptymalizowany pod względem szybkości wykonywania, co ma znaczenie szczególnie w przypadku procedur realizujących pewne zadania na granicy maksymalnej szybkości sprzętu. Przykładowo – jeżeli procesor musi z zawrotną prędkością obsługiwać krótkie sygnały impulsowe lub np. generować pewne sekwencje sygnałów wyjściowych, to niskopoziomowe programowanie będzie w takim przypadku optymalnym rozwiązaniem: łatwiejszym niż pisanie w asemblerze, a zarazem szybszym i bardziej przewidywalnym od tworzenia kodu np. z użyciem bibliotek HAL.
  2. Oszczędność pamięci – patrząc na współczesne portfolio procesorów ARM, można odnieść wrażenie, że ograniczenia pod względem dostępnej pamięci Flash stają się coraz mniej dotkliwe, nawet w przypadku rozbudowanych aplikacji. Nie należy jednak zapominać, że wśród mikrokontrolerów 32-bitowych zdarzają się małe, lekkie układy o pamięci na poziomie 16 kB, a nawet 8 kB (czyli zbliżonym do mniejszych przedstawicieli rodzin AVR bądź PIC). Przy tak ograniczonych zasobach sprzętowych oszczędność pamięci jest zaletą niepodważalną.
  3. Doskonała znajomość procesora – programując wyłącznie z użyciem bibliotek, można dość łatwo wpaść w pułapkę pewnych uproszczeń – biblioteki wiele zadań wykonują niejako za programistę, co sprawia, że często nie ma on potrzeby zaglądania do tego, co dzieje się pod maską wysokopoziomowych funkcji i struktur biblioteki HAL. Tymczasem pisanie kodu niskopoziomowego wymusza dokładne poznanie struktury określonych peryferiów (a także samego rdzenia) na poziomie nie tylko rejestrów, ale wręcz poszczególnych ich bitów. Programiści wyposażeni w tę cenną wiedzę potrafią znacznie lepiej radzić sobie z problemami, wynikającymi np. z braków w dokumentacji bibliotek czy też rozmaitych błędów – i to zarówno w strukturze API, jak i samego sprzętu (tzw. błędy w krzemie).
  4. Stuprocentowa przejrzystość kodu – kod napisany w rejestrach być może sam w sobie nie należy do najbardziej intuicyjnych dzieł programistycznych (chyba że zostanie odpowiednio opatrzony komentarzami, do czego gorąco zachęcamy!). Ma jednak pewną ogromną zaletę – nie ukrywa żadnych kruczków (np. potencjalnych błędów) pod kolejnymi warstwami abstrakcji i wywołaniami wielokrotnie zagnieżdżonych hierarchicznie funkcji, ułatwia zatem pełną kontrolę przepływu danych i sygnałów sterujących. Może to być istotny atut w przypadku systemów o krytycznym znaczeniu dla bezpieczeństwa, nie musimy bowiem bazować na bibliotekach, w przypadku których (z natury rzeczy) możemy nie mieć 100-procentowego przekonania co do ich stabilności oraz niezawodności.

Niniejszy kurs oparty został na procesorze z najnowszej serii STM32C0, dumnie promowanej przez firmę ST Microelectronics jako produkt, mający na celu zastąpienie (żeby nie powiedzieć – wyparcie z rynku) mikrokontrolerów 8-bitowych. I tutaj ciekawa dygresja – kilkanaście lat temu, gdy pierwsze układy STM32F1 dopiero rozkręcały się na rynku MCU, niemal równolegle z nimi marka ST promowała także własną rodzinę 8-bitowców – STM8S. Nie da się ukryć, że grupa ta nie zrobiła szczególnej furory w świecie systemów embedded, a dziś jej status handlowy to NRND (nierekomendowane do nowych projektów). W międzyczasie ST solidnie zainwestował w rozwój procesorów STM32, stając się jednym z potentatów na tym obszarze. Strategiczne posunięcie, jakim było opracowanie serii lekkich, niewielkich procesorów ARM w niewiarygodnie wręcz niskim przedziale cenowym (najtańsze układy mają kosztować przy zamówieniach hurtowych tylko 24 centy!), wydaje się więc strzałem w dziesiątkę. Firma ST Microelectronics nie kryje się zresztą za bardzo z chęcią wyparcia z rynku kultowych już mikrokontrolerów ATmega328 (stanowiących fundament ekosystemu Arduino), o czym świadczy oficjalny moduł, przeznaczony do… wpinania do podstawki DIL28 w miejsce oryginalnego procesora na Arduino Uno (fotografia 1).

Fotografia 1. Dowód na to, jak firma ST Microelectronics próbuje podbić rynek 8-bitowców za pomocą nowej rodziny STM32C0 – moduł STMC0316-DK w roli zamiennika oryginalnego procesora ATmega328 na płytce Arduino Uno (źródło: nota aplikacyjna AN5780)

Trudno w tym momencie przewidzieć, czy wspomniane posunięcie marketingowe odniesie sukces – z dużą dozą prawdopodobieństwa można jednak stwierdzić, że nie odbędzie się to w takim zakresie, jakiego prawdopodobnie oczekują menedżerowie ST (najnowsze moduły z serii Arduino – np. Arduino Uno R4 – także bazują już wszak na ARM-ach, więc trudno będzie zdominować ten segment rynku). Na pewno jednak najnowsza gałąź drzewa genealogicznego rodziny STM32 znajdzie swoje miejsce w tysiącach aplikacji komercyjnych, zarówno tych prostych i niskobudżetowych (jako główne kontrolery), jak i w rozbudowanych systemach wieloprocesorowych (jako peryferyjne układy do realizacji prostszych zadań). Tym bardziej więc warto poznać bliżej te niezwykle ciekawe procesory.

Niniejszy kurs jest przeznaczony przede wszystkim dla:

  • programistów doświadczonych w pracy z STM32 z użyciem bibliotek HAL lub LL, zainteresowanych rozwojem w zakresie niskopoziomowego tworzenia kodu,
  • programistów mikrokontrolerów 8-bitowych, którzy dzięki naszym materiałom będą mogli w szybki i przystępny sposób przejść na nową, znacznie wydajniejszą platformę (nie zmieniając przy tym zanadto swoich przyzwyczajeń – tym bardziej że styl nazewnictwa funkcji i struktur, zastosowany zwłaszcza w bibliotekach HAL, może naprawdę przytłoczyć zwolenników czystego, schludnego i zwartego kodu źródłowego).

Sprzęt oraz potrzebne akcesoria

Kurs został zaplanowany z myślą o możliwie najniższych nakładach finansowych, związanych z zakupem sprzętu niezbędnego do realizacji opisanych zadań. Dlatego też wybór padł na jedną z nielicznych (ale łatwo dostępną także w Polsce) płytek ewaluacyjnych dla nowej rodziny procesorów STM32. Mowa o zestawie NUCLEO-C031C6 (fotografia 2), który – wbrew przyjętej przez ST nomenklaturze – wprawdzie bazuje na rozmiarze płytki znanym dotąd z układów w obudowach 64-pinowych, ale w istocie jest wyposażony w układ 48-nóżkowy. Niestety, wiążą się z tym pewne ograniczenia pod względem kompatybilności z niektórymi nakładkami funkcjonalnymi z serii Nucleo.

Fotografia 2. Widok płytki ewaluacyjnej NUCLEO-C031C6 (http://t.ly/QLIj7)

Drugim elementem niezbędnym do realizacji zadań kursowych będzie moduł akcelerometru MEMS, zawierający układ LIS2DW12. Programy były testowane za pomocą modułu z serii Fermion marki DFRobot (kod handlowy producenta: SEN0405 – patrz fotografia tytułowa), ale na rynku można znaleźć także kilka innych modułów o zbliżonej funkcjonalności. W razie potrzeby zaprezentowane przykłady da się także łatwo zmodyfikować pod kątem zastosowania z innymi modelami akcelerometrów (lub zupełnie innymi czujnikami) – przygotowane listingi zostały dokładnie opisane komentarzami, więc nic nie stoi na przeszkodzie, by zamiast czujnika MEMS użyć np. sensora wilgotności, temperatury bądź dowolnego innego modułu I²C/SPI o napięciu linii I/O równym 3,3 V. Oprócz płytki Nucleo oraz modułu z czujnikiem do wykonania zadań kursowych będzie rzecz jasna potrzebny kabel micro USB, kilka przewodów z żeńskimi wtykami BLS oraz komputer z systemem Windows (co nie oznacza, że użytkownicy Linuksa bądź macOS nie mogą wykonać opisanych ćwiczeń – informacje na ten temat znajdują się w dalszej części artykułu).

Instalacja oprogramowania

Krótki czas, który upłynął od rynkowej premiery STM32C0, sprawia, że nie wszystkie środowiska IDE zdołano zaadaptować do współpracy z naszym bohaterem. W chwili pisania niniejszego kursu (luty 2024 r.) najlepszym wyborem zdaje się być zainstalowanie oprogramowania Keil µVision 5 i temu właśnie zadaniu poświęcimy tę część artykułu.

Produktem potrzebnym do realizacji zadań kursowych jest środowisko IDE w wersji specjalnej, kompatybilnej jedynie z procesorami ARM Cortex-M0 oraz M0+ marki ST Microelectronics: Keil MDK for STMicroelectronics Edition. Pełna instrukcja instalacji wraz z linkami i kodem PSN, niezbędnym do uzyskania bezpłatnej licencji, znajduje się w dokumentacji dostępnej pod adresem: https://developer.arm.com/documentation/kan344/latest.

Po pobraniu pliku wykonywalnego należy go uruchomić i przeprowadzić (w sposób typowy dla środowiska Windows) proces instalacji. Gdy instalacja dobiegnie końca, trzeba włączyć program i wybrać opcję Pack Installer z jednego z górnych pasków narzędzi (rysunek 1), co spowoduje otwarcie okna z obszerną listą układów (zakładka Devices – rysunek 2).

Rysunek 1. Przycisk uruchamiający okno instalacji pakietów

Należy teraz wybrać rodzinę STM32C0, choć nic nie stoi na przeszkodzie, by przy okazji doinstalować także pakiety dla STM32F0, STM32G0 oraz STM32L0 (a nawet dla układów Bluetooth z serii BlueNRG). W prawym panelu, w zakładce Packs, należy wybrać do instalacji pakiety Keil::STM32C0xx_DFP oraz ARM::CMSIS.

Rysunek 2. Okno instalacji pakietów

Kolejnym etapem przygotowania środowiska programistycznego jest uzyskanie licencji od firmy ARM. Proces ten został dokładnie opisany w instrukcji producenta i nie będziemy go szczegółowo omawiać – dość powiedzieć, że aby otrzymać kod aktywacyjny, trzeba zastosować wspomniany powyżej kod PSN, wpisując go w oknie License Management, uruchomionym z menu File (program należy przy tym włączyć z uprawnieniami administracyjnymi). Do uzyskania licencji konieczne będzie jeszcze wypełnienie formularza online, zaś po otrzymaniu wiadomości e-mail z kodem LIC – wpisanie go w oknie License Management i zaakceptowanie.

Utworzenie i konfiguracja projektu

W tak przygotowanym środowisku klikamy pozycję New μVision Project z menu Project (rysunek 3), co spowoduje otwarcie okna widocznego na rysunku 4 i umożliwiającego wybór docelowego procesora (w tej roli STM32C031C6Tx).

Rysunek 3. Opcja utworzenia nowego projektu
Rysunek 4. Okno wyboru docelowego mikrokontrolera

W pustym IDE klikamy następnie przycisk Manage Run-Time Environment (rysunek 5), zaś w otwartym w ten sposób oknie wybieramy potrzebne pakiety: CMSIS->CORE oraz Device->Startup (rysunek 6).

Rysunek 5. Przycisk uruchamiający okno Manage Run-Time Environment
Rysunek 6. Okno Manage Run-Time Environment

Program będzie starał się przekonać nas, że konieczny jest jeszcze komponent STM32CubeMX, jednak jako rasowi niskopoziomowcy nie będziemy korzystać z graficznego generowania konfiguracji peryferiów. Zatwierdzamy wybór przyciskiem OK, a następnie klikamy prawym klawiszem myszy pozycję Source Group 1 w lewym panelu Project, co spowoduje otwarcie menu kontekstowego. Klikamy opcję Add New Item to Group (…), otwierając w ten sposób okno umożliwiające wybór rodzaju pliku. Wpisujemy nazwę main oraz wybieramy typ pliku C File (.c) (rysunek 7) i klikamy Add. Otwarty zostanie pusty plik tekstowy, w którym będziemy mogli rozpocząć pisanie naszego kodu.

Rysunek 7. Okno wyboru rodzaju i nazwy pliku dodawanego do projektu

Kolejnym etapem konfiguracji jest ustawienie programatora/debuggera – w tym celu wybieramy przycisk Options for Target (rysunek 8), a w otwartym oknie, w zakładce Debug, ustalamy rodzaj interfejsu jako ST-Link Debugger (rysunek 9) i klikamy znajdujący się obok przycisk Settings. Program zapyta nas prawdopodobnie o chęć aktualizacji firmware’u programatora – potwierdzamy wybór przyciskiem Yes, przechodząc w ten sposób do okna aktualizacji (rysunek 10). Tutaj również wyrażamy zgodę na aktualizację, uprzednio wydając programowi polecenie połączenia się ze sprzętem (Device Connect, a następnie Yes >>>>).

Rysunek 8. Przycisk otwierający okno opcji sprzętowych
Rysunek 9. Okno opcji sprzętowych z otwartym alertem dot. aktualizacji firmware’u programatora ST-Link
Rysunek 10. Okno służące do aktualizacji firmware’u programatora ST-Link

Tak przygotowane środowisko jest gotowe do rozpoczęcia pracy.

Na marginesie warto dodać, że użytkownicy środowiska STM32CubeIDE (dostępnego na systemy Windows, Linux oraz macOS) także mogą z powodzeniem wykonać ćwiczenia opisane w niniejszym kursie – w tym celu należy jednak zapoznać się z odpowiednimi instrukcjami, które można dość łatwo znaleźć w Internecie (na blogach poświęconych mikrokontrolerom oraz w tutorialach na portalu YouTube). Wszystkie pliki źródłowe udostępnione na serwerze ep.com.pl były testowane w obydwu środowiskach – zarówno Keil, jak i w najnowszej wersji STM32CubeIDE. Niestety, oficjalne IDE dla STM32 niezbyt wspiera działania programistów niskopoziomowych, dlatego trzeba skorzystać z pewnego obejścia domyślnych ustawień. Dodajmy więc tylko, że istnieją dwie drogi: można albo utworzyć normalny projekt, a następnie zignorować otwarty domyślnie graficzny generator kodu STM32CubeMX (i podmienić pliki źródłowe main.c, stm32c0xx_it.c oraz stm32c0xx_it.h na własne), albo utworzyć pusty projekt i dodać do niego odpowiednie zależności. Druga ścieżka jest znacznie lepsza, ale także bardziej pracochłonna – zainteresowanych Czytelników zachęcam do podjęcia poszukiwań odpowiednich poradników w Internecie.

Garść informacji o mikrokontrolerze STM32C031C6T6

Schemat blokowy naszego bohatera można zobaczyć na rysunku 11. Układ zawiera 32 kB pamięci Flash, 12 kB pamięci RAM i może być taktowany sygnałem o częstotliwości do 48 MHz. Zakres napięć zasilania to 2,0...3,6 V, mikrokontroler jest produkowany w obudowie LQFP48.

Rysunek 11. Schemat blokowy mikrokontrolera STM32C031C6Tx

Na pokładzie znajduje się bogaty zestaw peryferiów sprzętowych:

  • układy zarządzania zasilaniem (PWR) oraz taktowaniem i obwodami resetu (RCC),
  • obwody zabezpieczające POR, BOR,
  • wbudowane oscylatory 48 MHz oraz 32 kHz,
  • oscylatory współpracujące ze zewnętrznymi rezonatorami kwarcowymi do 48 MHz (główny) i 32 kHz (pomocniczy dla RTC),
  • 3-kanałowy kontroler DMA,
  • 12-bitowy przetwornik ADC,
  • timery/liczniki: 16-bitowy (zaawansowany) oraz cztery prostsze, także 16-bitowe,
  • timer systemowy SysTick,
  • watchdogi: okienkowy oraz niezależny,
  • zegar czasu rzeczywistego z kalendarzem,
  • interfejs I²C,
  • dwa interfejsy USART,
  • jeden interfejs SPI ze wsparciem I²S,
  • sprzętowy blok obliczeń CRC,
  • obwody wspierające generowanie sygnałów sterujących diodami nadawczymi IR (IRTIM).

Podczas realizacji zadań kursowych zdecydowanie warto mieć pod ręką dwa dokumenty: RM0490 (Reference manual. STM32C0x1 advanced Arm-based 32-bit MCUs) oraz notę katalogową naszego procesora (STM32C031x4/x6 datasheet). W pierwszym z nich znajduje się komplet informacji o budowie procesora i jego bloków peryferyjnych, sposobach konfiguracji oraz znaczeniu poszczególnych rejestrów i pól bitowych. W nocie katalogowej można znaleźć natomiast (oprócz szczegółowych parametrów elektrycznych, termicznych itp.) także dane dotyczące m.in. maksymalnych częstotliwości taktowania poszczególnych peryferiów, mapę pamięci, układ wyprowadzeń, a także tabele funkcji alternatywnych poszczególnych linii GPIO. W celu skrócenia opisu nie będziemy zatem zamieszczać w artykule map rejestrów, tabel z ustawieniami bitów, etc. – zachęcamy Czytelników do samodzielnego realizowania kursu równolegle z czytaniem oryginalnej dokumentacji.

Przykład 1. Blinky

Nasz pierwszy program – tradycyjnie w przypadku kursów programowania mikrokontrolerów – będzie miał za zadanie migać diodą, znajdującą się na płytce Nucleo. Realizację tej funkcjonalności zaczniemy od skonfigurowania rejestru RCC (Reset and Clock Control), czyli bloku peryferyjnego odpowiedzialnego za dostarczanie sygnałów taktujących i obsługę różnych źródeł resetu mikrokontrolera. Schemat bloku RCC (a dokładniej – drzewo połączeń sygnałów taktowania) można zobaczyć na rysunku 12.

Rysunek 12. Schemat blokowy RCC (drzewo sygnałów zegarowych)

Osoby zaznajomione z STM32 błyskawicznie zauważą, że w tym przypadku nie mamy (spotykanego w innych rodzinach procesorów ST) bloku syntezera częstotliwości z pętlą PLL i dzielnikami – układy STM32C0 mogą przyjąć sygnał z zewnętrznego rezonatora kwarcowego, pracującego z częstotliwością maksymalną równą 48 MHz (taki też rezonator znajduje się na płytce Nucleo).

Na początek jednak nie będziemy korzystać z zewnętrznego wzorca częstotliwości, użyjemy jedynie rejestru IOPENR, odpowiedzialnego za włączenie taktowania poszczególnych portów I/O. Do rejestru odwołamy się następująco:

RCC->IOPENR;

i przypiszemy do niego bity włączające (dla ułatwienia) od razu wszystkie porty dostępne na pokładzie naszego procesora. Według przyjętej konwencji bity te mają nazwy:

RCC_IOPENR_GPIOxEN

gdzie x to oznaczenie literowe portu A, B, C, D).

Następnie musimy skonfigurować bit 5 portu A, gdyż do niego właśnie podłączona jest dioda LED. Każda linia portu może pracować w jednej z czterech ról:

  • wejście cyfrowe [00],
  • wyjście cyfrowe [01],
  • funkcja alternatywna [10],
  • wejście funkcji analogowej [11].

Wyboru dokonujemy w 32-bitowym rejestrze GPIOx_MODER, w którym – kolejno, począwszy od najmłodszego bitu – znajdują się dwubitowe pola, pozwalające na ustawienie określonego trybu pracy. Dostępne ustawienia tychże pól podano powyżej w nawiasach kwadratowych.

W przypadku rodziny STM32C0 większość linii jest domyślnie ustawiona po resecie jako wejście funkcji analogowej, stąd aby ustawić jakikolwiek inny tryb, musimy najpierw wyzerować obydwa bity. Wykonamy to za pomocą maski, obejmującej całe pole bitowe przypisane do danego wyprowadzenia I/O:

GPIOA->MODER &= ~GPIO_MODER_MODE5;

Następnie ustawimy młodszy bit w polu odpowiadającym linii 5:

GPIOA->MODER |= GPIO_MODER_MODE5_0;

W typowej aplikacji wybralibyśmy teraz jeszcze rodzaj wyjścia (rejestr OTYPER), konfigurację wewnętrznych rezystorów podciągających (PUPDR) oraz szybkość linii GPIO (OSPEEDR) – na szczęście, domyślne ustawienia po resecie są odpowiednie w tym przypadku (wyjście typu push-pull, rezystory nieaktywne, szybkość minimalna), stąd możemy dla uproszczenia opisu pominąć na razie znaczenie tych rejestrów.

Aby zamigać diodą, w pętli głównej programu trzeba naprzemiennie włączać i wyłączać wyjście. W przypadku STM32 można to zrobić na dwa sposoby: albo z wykorzystaniem rejestru wyjściowego (ODR), poddawanego działaniu funkcji logicznej & lub | z odpowiednią maską, albo za pośrednictwem specjalnego rejestru, umożliwiającego dostęp w trybie atomowym (BSRR). Skorzystamy z drugiej, wygodniejszej opcji. Zaświecenie diody nastąpi po ustawieniu (Set) linii w stan w wysoki:

GPIOA->BSRR = GPIO_BSRR_BS5;

zaś aby ją zgasić, trzeba zmienić stan na niski (Reset):

GPIOA->BSRR = GPIO_BSRR_BR5;

Pomiędzy obydwiema tymi operacjami trzeba jeszcze wprowadzić pewne arbitralne opóźnienie – odpowiednią funkcję

void delayMilisec(uint32_t n)

przygotowano dobierając empirycznie zawartość pętli for() oraz liczbę znajdujących się w niej wywołań makra asemblera (__NOP()) w taki sposób, by w przybliżeniu uzyskać opóźnienie o zadanej za pomocą parametru n liczbie milisekund. Kompletny kod można zobaczyć na listingu 1.

/*
* Crash Course STM32C0
* Elektronika Praktyczna
*
* Przyklad 1. Dzien dobry w jezyku MCU, czyli Blinky na STM32C0
* plik main.c
*
*/

#include "stm32c031xx.h"

void delayMilisec(uint32_t n);

int main()
{

/* wlaczenie taktowania bloków GPIO */
RCC->IOPENR |= RCC_IOPENR_GPIOAEN
| RCC_IOPENR_GPIOBEN
| RCC_IOPENR_GPIOCEN
| RCC_IOPENR_GPIODEN;

/* wyzerowanie bitow konfig. linii PA5 */
GPIOA->MODER &= ~GPIO_MODER_MODE5;

/* ustawienie PA5 jako wyjscie */
GPIOA->MODER |= GPIO_MODER_MODE5_0;

while(1)
{
/* stan wysoki na PA5 */
GPIOA->BSRR = GPIO_BSRR_BS5;

delayMilisec(100);

/* stan niski na PA5 */
GPIOA->BSRR = GPIO_BSRR_BR5;

delayMilisec(100);

}
}

void delayMilisec(uint32_t n){

for(uint32_t i = 0; i < 400*n; i++)
{
__NOP();
__NOP();
__NOP();
__NOP();
__NOP();
__NOP();
__NOP();
__NOP();
__NOP();
__NOP();
}

}

Listing 1. Miganie diodą LED

Prawidłowo napisany program należy skompilować (Build lub klawisz F7), a następnie wgrać do procesora (przycisk Download lub klawisz F8).

Przykład 2. Obsługa przycisku

Tym razem dodamy do naszego kodu „turbodoładowanie” – naciskając niebieski przycisk USER, spowodujemy przyspieszenie migania diody. W tym celu musimy ustawić połączoną z przyciskiem linię portu C o numerze 13 w tryb wejścia cyfrowego:

GPIOC->MODER &= ~GPIO_MODER_MODE13;

Na płytce znajduje się dyskretny rezystor podciągający, stąd nie ma potrzeby włączania wbudowanych „podciągów” w rejestrze PUPDR.

W celu sprawdzenia, czy przycisk jest naciśnięty, odwołamy się do rejestru wejściowego, czyli IDR:

if((GPIOC->IDR & GPIO_IDR_ID13) == GPIO_IDR_ID13)

Kompletny kod ciała funkcji main() programu można zobaczyć na listingu 2.

int main()
{

uint32_t delay;

RCC->IOPENR |= RCC_IOPENR_GPIOAEN
| RCC_IOPENR_GPIOBEN
| RCC_IOPENR_GPIOCEN
| RCC_IOPENR_GPIODEN;

GPIOA->MODER &= ~GPIO_MODER_MODE5;
GPIOA->MODER |= GPIO_MODER_MODE5_0;

/* PC13 jako wejscie cyfrowe */
GPIOC->MODER &= ~GPIO_MODER_MODE13;


while(1)
{

/* sprawdzenie stanu linii wejsciowej w rejestrze GPIO_IDR*/
if((GPIOC->IDR & GPIO_IDR_ID13) == GPIO_IDR_ID13)
delay = 200;
else
delay = 50;

GPIOA->BSRR = GPIO_BSRR_BS5;

delayMilisec(delay);

GPIOA->BSRR = GPIO_BSRR_BR5;

delayMilisec(delay);

}
}

Listing 2. Funkcja main() z listingu 1 wzbogacona o obsługę przycisku zmieniającego szybkość migania diody

Przykład 3. Konfiguracja bloku RCC. Obsługa wyjścia MCO

Do tej pory nasz mikrokontroler był taktowany sygnałem z wewnętrznego oscylatora RC o częstotliwości nominalnej ok. 48 MHz (podzielonym przez 4 za pomocą preskalera HSIDIV w rejestrze RCC_CR). Docelowo jednak chcemy skorzystać z nieporównanie dokładniejszego rezonatora kwarcowego, warto więc zapoznać się ze sposobem konfiguracji bloku RCC w celu ustawienia odpowiednich częstotliwości taktowania rdzenia i magistral. Napiszemy w tym celu funkcję void RCC_configClockSource(void), której ciało zaprezentowano na listingu 3.

void RCC_configClockSource(void)
{

uint16_t i = 0;

/* wlaczenie oscylatora HSE */
RCC->CR |= RCC_CR_HSEON;

while(i < 1000)
{

i++;

/* test flagi gotowosci HSE */
if((RCC->CR & RCC_CR_HSERDY) == RCC_CR_HSERDY)
break;

}

if(i < 1000)
{

/* preskaler HCLK = 2 => HCLK = SYSCLK/2 = 24 MHz */
RCC->CFGR |= RCC_CFGR_HPRE_3;

/* preskaler PCLK = 2 => PCLK = HCLK/2 = 12 MHz */
RCC->CFGR |= RCC_CFGR_PPRE_2;

/* preskaler MCO2 = 64 => MCO2 = SYSCLK/64 = 750 kHz */
RCC->CFGR |= RCC_CFGR_MCO2PRE_2 | RCC_CFGR_MCO2PRE_1;

/* zrodlo sygnalu dla MCO2: SYSCLK */
RCC->CFGR |= RCC_CFGR_MCO2SEL_0;

/* zrodlo sygnalu dla SYSCLK: HSE */
RCC->CFGR |= RCC_CFGR_SW_0;

/* sprawdzenie czy SYSCLK przelaczyl sie na HSE */
while(i < 10)
{

/* test flagi gotowosci HSE */
if((RCC->CFGR & RCC_CFGR_SWS_0) == RCC_CFGR_SWS_0)
break;

i++;

}

/* miejsce na obsluge bledu SWS */
if(i == 10)
while(1);

} else {

/* miejsce na obsluge bledu uruchomienia HSE */
while(1);

}

}

Listing 3. Funkcja konfiguracji RCC

W rejestrze kontrolnym (CR) włączymy najpierw bit odpowiedzialny za uruchomienie oscylatora HSE współpracującego z rezonatorem kwarcowym.

RCC->CR |= RCC_CR_HSEON;

Następnie musimy odczekać do momentu prawidłowego rozruchu, wykonując przez ten czas polling bitu RCC_CR_HSERDY:

if((RCC->CR & RCC_CR_HSERDY) == RCC_CR_HSERDY)

Jeżeli oscylator działa poprawnie, należy skonfigurować preskalery poszczególnych magistral, które w przypadku STM32C0 są dwie: AHB oraz APB. Dla pierwszej z nich ustawimy częstotliwość równą połowie SYSCLK, czyli głównego sygnału zegarowego:

RCC->CFGR |= RCC_CFGR_HPRE_3;

Ta zostanie znów podzielona przez dwa, co da w efekcie taktowanie 12 MHz dla większości peryferiów mikrokontrolera (ten zabieg ma na celu jedynie zobrazowanie sposobu konfiguracji – rzecz jasna, w realnej aplikacji można uzyskać zarówno częstotliwości wyższe, jak i niższe od wybranych do tego przykładu).

RCC->CFGR |= RCC_CFGR_PPRE_2;

Ciekawą opcją jest możliwość wyprowadzenia jednego z kilku wewnętrznych sygnałów zegarowych procesora na port GPIO, a dokładniej – linię wyposażoną w funkcję alternatywną MCO1 lub MCO2. W naszym przypadku wybierzemy tę drugą, ustawiając jako źródło główny sygnał taktowania (SYSCLK):

RCC->CFGR |= RCC_CFGR_MCO2SEL_0;

oraz dzieląc ów sygnał (o docelowej częstotliwości równej 48 MHz) przez 64, co da w efekcie przebieg 750-kilohercowy:

RCC->CFGR |= RCC_CFGR_MCO2PRE_2 | RCC_CFGR_MCO2PRE_1;

Na koniec możemy nareszcie przełączyć blok RCC naszego procesora z domyślnego oscylatora HSI na generator HSE:

RCC->CFGR |= RCC_CFGR_SW_0;

W kodzie funkcji void RCC_configClockSource(void) przewidziano także miejsce na obsługę ewentualnych błędów inicjalizacji HSE lub przełączania SYSCLK na ów sygnał – dla uproszczenia przyjęliśmy, że nasz program wpadnie w którymkolwiek z wymienionych przypadków w pętlę nieskończoną. Rzecz jasna, w rzeczywistej aplikacji takie działanie byłoby niedopuszczalne i należałoby odpowiednio obsłużyć te błędy, powracając np. na HSI oraz informując użytkownika lub nadrzędne urządzenie o awarii systemu taktowania. Przedstawiona metoda może jednak bardzo ułatwić debugowanie kodu na początkowych etapach pracy.

W kodzie z przykładu 1 trzeba wykonać jeszcze jedną zmianę – tym razem potrzebujemy bowiem odpowiednio skonfigurować linię PA10, pod którą podepniemy funkcję MCO2. W tym celu ustawiamy „starszy” z dwóch rejestrów, odpowiedzialnych za ustalenie numeru funkcji alternatywnej (AF):

GPIOA->AFR[1] |= GPIO_AFRH_AFSEL10_1 | GPIO_AFRH_AFSEL10_0;

Jak widać, przyjęta przez producenta konwencja nazewnicza jest w tym przypadku nieco mniej spójna niż w innych peryferiach. Obydwa rejestry AFR występują tutaj bowiem jako… tablica o rozmiarze 2, przy czym młodszy rejestr przechowuje bity konfigurujące linie danego portu o numerach 0...7, zaś starszy obejmuje linie 8...15. W nazwach bitów ma to swoje odzwierciedlenie w postaci litery H lub L (w powyższym przykładzie korzystamy z rejestru starszego). Jak widać, wybraliśmy bity na pozycjach 0 i 1, co daje sumę równą 3 – i tego dokładnie potrzebujemy, gdyż w tabeli AF, dostępnej w nocie katalogowej mikrokontrolera, funkcja MCO2 linii PA10 figuruje właśnie pod numerem AF3.

Wyprowadzając sygnał zegarowy (nawet wstępnie podzielony w preskalerze), warto także zmienić szybkość maksymalną danej linii GPIO – w przypadku procesorów STM32 dostępne są cztery poziomy:

  • domyślny (Very Low Speed [00]),
  • powolny (Low Speed [01]),
  • szybki (High Speed [10]),
  • bardzo szybki (Very High Speed [11]).

Taka konfiguracja ma na celu umożliwienie optymalizacji poziomu zakłóceń RFI generowanych przez mikrokontroler podczas przełączania linii GPIO. Każde z ustawień pola bitowego ma swoje odzwierciedlenie w konkretnej, maksymalnej częstotliwości przełączania danego pinu – szczegóły można znaleźć w nocie katalogowej procesora. W naszym przypadku, choć sygnał jest stosunkowo wolny (tylko 750 kHz), ustawimy dla przykładu najwyższą prędkość pracy linii 10 GPIO:

GPIOA->OSPEEDR |= GPIO_OSPEEDR_OSPEED10_1 | GPIO_OSPEEDR_OSPEED10_0;

Oscylogram przebiegu zarejestrowanego na wyprowadzeniu PA10 można zobaczyć na rysunku 13.

Rysunek 13. Przebieg na wyjściu MCO2

Dla ułatwienia pracy z tym oraz kolejnymi przykładami pokażemy w tym miejscu także rozkład wyprowadzeń na listwach szpilkowych płytki Nucelo (rysunek 14) – niestety, producent nie zdecydował się na opisanie ich bezpośrednio na PCB, stąd każdorazowo przy podłączaniu pojedynczych przewodów do złączy goldpin trzeba zdać się na… ręczne liczenie pinów. Ot, drobne utrudnienie (niestety niejedyne w przypadku tej rodziny zestawów ewaluacyjnych).

Rysunek 14. Układ wyprowadzeń złączy goldpin na płytce Nucleo-C031C6

Przykład 4. Nadawanie danych przez UART

Tym razem wskoczymy od razu w bardziej zaawansowane zagadnienia – dla ułatwienia realizacji kolejnych ćwiczeń przygotujemy teraz podstawową obsługę nadawania znaków poprzez interfejs UART. Warto dodać, że do sprawdzenia działania naszego programu nie potrzebujemy żadnego zewnętrznego konwertera USB-UART – zaimplementowany na płytce Nucleo programator/debugger ST-Link w standardzie udostępnia nam bowiem wygodny w użyciu, wirtualny port szeregowy, współpracujący z interfejsem USART2 naszego procesora.

Dla ułatwienia orientacji w plikach źródłowych tym razem wydzielimy funkcje odpowiedzialne za obsługę peryferiów sprzętowych do plików hardware.c/hardware.h, zaś funkcje pomocnicze (opóźniające) – do plików utils.c/utils.h. W środowisku Keil można je dodać w sposób analogiczny do opisanego na początku, przy okazji tworzenia pliku main.c.

Linie portów USART2 (PA2 i PA3) ustawiamy w tryb funkcji AF1:

GPIOA->MODER &= ~(GPIO_MODER_MODE2 | GPIO_MODER_MODE3);
GPIOA->MODER |= GPIO_MODER_MODE2_1 | GPIO_MODER_MODE3_1;
GPIOA->AFR[0] |= GPIO_AFRL_AFSEL2_0 | GPIO_AFRL_AFSEL3_0;

W funkcji UART_config(void) konfigurujemy kolejno:

• bit zezwolenia na taktowanie USART2:

RCC->APBENR1 |= RCC_APBENR1_USART2EN;

• częstotliwość taktowania (preskaler) w celu uzyskania prędkości transmisji równej 9600 bps:

USART2->BRR = 120000/96;

Następnie musimy jeszcze włączyć nadajnik:

USART2->CR1 = USART_CR1_TE;

oraz ustawić główny bit uruchamiający interfejs USART2:

USART2->CR1 |= USART_CR1_UE;

Kod funkcji wysyłającej ciąg znaków (string) za pomocą USART2 można zobaczyć na listingu 4.

void UART_print_chars(char *str)
{

uint8_t timeout;

/* sprawdzenie gotowosci bufora do wysylki */
if ((USART2->ISR & USART_ISR_TXE_TXFNF) == USART_ISR_TXE_TXFNF){

/* wysylka znakow az do osiagniecia konca stringu */
/* warunek konca: zawartosc znaku rowna 0x00 */
while (*str){

/* zapis bajtu do rejestru nadawczego */
/* nastepnie: postinkrementacja wskaznika "w locie" */
USART2->TDR = *(str++);

/* timeout ~100 us * 12 = ~1,2 ms */
timeout = 12;

/* oczekiwanie na zwolnienie bufora TDR */
while (!(USART2->ISR & USART_ISR_TXE_TXFNF)){

if(timeout > 0){

delayMicrosec(100);
timeout--;

}else{

/* "brutalny" sposob obslugi bledu */
/* wlaczenie LED i wejscie w petle nieskonczona */
GPIOA->BSRR = GPIO_BSRR_BS5;
while(1);

}
}

}

/* kolejny timeout ~1,2 ms */
timeout = 12;

/* oczekiwanie na ustawienie flagi zakonczenia transmisji */
while (!(USART2->ISR & USART_ISR_TC)){

if(timeout > 0){

delayMicrosec(100);
timeout--;

}else{

/* "brutalny" sposob obslugi bledu */
GPIOA->BSRR = GPIO_BSRR_BS5;
while(1);
}
}
}
}
Listing 4. Ciało funkcji wysyłającej znaki przez USART2

W celu zachowania zgodności z wymogami procesora, przed nadaniem znaków musimy sprawdzić, czy bufor nadawczy jest pusty, odczytując w tym celu bit USART_ISR_TXE_TXFNF, znajdujący się w rejestrze statusu (ISR):

if ((USART2->ISR & USART_ISR_TXE_TXFNF) == USART_ISR_TXE_TXFNF) [1]

Jeżeli otrzymamy zielone światło, możemy w pętli while() nadać wszystkie znaki, aż do spełnienia warunku wyjścia z pętli (ostatni znak stringu w języku C ma wartość 0x00). Każdorazowo wysłanie bajtu odbywa się poprzez zapis znaku do rejestru nadawczego (TDR), przy czym wskaźnik jest od razu postinkrementowany:

USART2->TDR = *(str++);

Po wysłaniu odczekujemy na zwolnienie bufora, ponownie odpytując bit TXE_TXFNF rejestru ISR. Nieco inny test znajduje się natomiast na końcu opisywanej funkcji – tutaj sprawdzamy bowiem bit TC rejestru ISR, informujący nie tyle o zwolnieniu bufora, co o fizycznym zakończeniu transmisji. Warto pamiętać o tej istotnej różnicy, gdyż ma ona duże znaczenie m.in. podczas obsługi UART-a za pomocą kontrolera DMA.

Tym razem ewentualne błędy wykryte podczas działania funkcji są sygnalizowane zaświeceniem diody LED oraz (podobnie jak poprzednio) wejściem w pętlę nieskończoną. Ten rodzaj obsługi błędu, niedopuszczalny w jakimkolwiek rzeczywistym zastosowaniu, może jednak oddać nieocenione zasługi podczas początkowego debugowania kodu.

W głównej pętli while() programu wywołujemy funkcję UART_print_chars() co około 100 ms, wysyłając na UART napis „licznik=xx”, gdzie xx to kolejny numer z zakresu 0...99 (rysunek 15).

Rysunek 15. Okno terminalu portu szeregowego z widocznymi danymi, wysyłanymi przez procesor STM32C0 za pomocą interfejsu UART1

Sama obsługa wysyłki znaków jest bardzo prosta i intuicyjna, więc nie będziemy jej szerzej opisywać – oryginalne pliki źródłowe można znaleźć w materiałach dodatkowych do niniejszego kursu, znajdujących się na serwerze ep.com.pl w dziale Archiwum.

Przykład 5

Zostawmy na chwilę obsługę modułu UART i zajmijmy się zadaniem prostszym, ale o fundamentalnym znaczeniu dla aplikacji mikrokontrolerów. Tym razem skonfigurujemy timer sprzętowy TIM1 i użyjemy go do płynnego sterowania jasnością diody LED, oczywiście za pomocą sygnału PWM.

Wyjście PWM timera jest dostępne jako funkcja AF5 linii PA5. Stosowną konfigurację tego wyprowadzenia można znaleźć poniżej.

GPIOA->MODER &= ~GPIO_MODER_MODE5;
GPIOA->MODER |= GPIO_MODER_MODE5_1;
GPIOA->AFR[0] |= GPIO_AFRL_AFSEL5_2 | GPIO_AFRL_AFSEL5_0;

Ciało funkcji void PWM_config(void), odpowiedzialnej za konfigurację wyjścia kanału 1 timera TIM1, można znaleźć na listingu 5. Obsługa timera nie różni się w swoich podstawowych założeniach od tego samego zadania, zrealizowanego np. za pomocą mikrokontrolera AVR. Uwagę warto zwrócić jednak na kilka aspektów.

void PWM_config(void)
{

/* wlaczenie taktowania dla TIM1 */
RCC->APBENR2 |= RCC_APBENR2_TIM1EN;

/* preskaler = 239 */
TIM1->PSC |= 2 * PCLK_FREQ/( PWM_FREQ * PWM_RES ) – 1;

/* ustawienie wartosci automatycznego przeladowania licznika */
TIM1->ARR = PWM_RES – 1;

/* startujemy z wypelnieniem = 0 */
TIM1->CCR1 = 0;

/* PWM tryb 1 – wyjscie aktywne jezeli licznik < CCR1 */
TIM1->CCMR1 |= TIM_CCMR1_OC1M_2 | TIM_CCMR1_OC1M_1;

/* wlaczenie funkcji pre-ladowania rejestru CCR1 */
TIM1->CCMR1 |= TIM_CCMR1_OC1PE;

/* wlaczenie kanalu 1 */
TIM1->CCER |= TIM_CCER_CC1E;

/* Main Output Enable – globalne zezwolenie na generowanie sygnalow PWM */
TIM1->BDTR |= TIM_BDTR_MOE;

/* wlaczenie timera */
TIM1->CR1 |= TIM_CR1_CEN;

/* wymuszenie wstepnego przeladowania rejestrow timera */
TIM1->EGR |= TIM_EGR_UG;

}

void PWM_set_duty(uint8_t pwm)
{
/* zmiana rejestru porownywanego z wartoscia licznika */
TIM1->CCR1 = pwm;
}

Listing 5. Funkcje do obsługi timera w trybie generatora PWM

Po pierwsze, w linii konfigurującej zawartość rejestru PSC (preskaler) znalazło się mnożenie przez 2:

TIM1->PSC |= 2 * PCLK_FREQ/( PWM_FREQ * PWM_RES ) – 1;

W żadnym wypadku nie jest to błąd – w dokumentacji procesora znajdujemy bowiem informację, że sygnał zegarowy dostarczany z RCC do timerów jest 2-krotnie szybszy dla wszystkich ustawień preskalera magistrali APB > 1. W naszym przypadku preskaler APB został ustawiony na wartość 2, zatem aby uzyskać docelową częstotliwość przebiegu równą 1 kHz, należy zastosować wspomniany mnożnik. Makro PWM_FREQ to właśnie częstotliwość przebiegu (wyrażona w hercach), PCLK_FREQ to częstotliwość szyny APB (12 MHz, także zapisana w hercach), zaś PWM_RES to rozdzielczość, arbitralnie ustawiona na wartość równą 100. Taki zabieg pozwala na proste przestrajanie współczynnika wypełnienia w zakresie od 0 do 100% z podziałką co 1%, bez konieczności dokonywania dodatkowych obliczeń. Wartość PWM_RES (pomniejszona o 1) trafia zatem do rejestru automatycznego przeładowywania licznika (ARR). Linia:

TIM1->CCMR1 |= TIM_CCMR1_OC1M_2 | TIM_CCMR1_OC1M_1;

odpowiada za wybór jednego z wielu udostępnionych przez timer trybów pracy – w naszym przypadku stan aktywny na wyjściu (czyli stan wysoki) będzie trwał od momentu wyzerowania licznika (liczymy w górę) do momentu zrównania się stanu licznika z zawartością rejestru CCR1. Ten ostatni posłuży nam do przestrajania współczynnika wypełnienia przebiegu PWM i dla ułatwienia opakujemy go w funkcję void PWM_set_duty(uint8_t pwm).

W celu uruchomienia wyjścia PWM musimy jeszcze ustawić trzy różne bity, odpowiedzialne za – kolejno – włączenie kanału 1, globalne zezwolenie na generowanie sygnałów oraz włączenie timera jako takiego:

TIM1->CCER |= TIM_CCER_CC1E;
TIM1->BDTR |= TIM_BDTR_MOE;
TIM1->CR1 |= TIM_CR1_CEN;

Ostatnia linia funkcji konfiguracyjnej wymusza wstępne przeładowanie rejestrów timera, co umożliwia prawidłowy rozruch od zera:

TIM1->EGR |= TIM_EGR_UG;

Przebieg uzyskany za pomocą timera TIM1 można zobaczyć na rysunku 16. Oryginalny kod programu zmienia wypełnienie impulsu na przemian w górę (w pełnym zakresie) i w dół – efekt będzie widoczny w postaci płynnego rozjaśniania oraz przygaszania diody LED.

Rysunek 16. Prawidłowy przebieg sygnału PWM i jego statystyki (zakładka po prawej stronie ekranu oscyloskopu)

Przykład 6. Obsługa SPI full-duplex. Komunikacja z akcelerometrem

Okablowanie płytki NUCLEO w tym przykładzie:

  • LIS2DW12 (VCC) – 3V3
  • LIS2DW12 (GND) – GND
  • LIS2DW12 (SCL) – PA5
  • LIS2DW12 (SDO) –PA6
  • LIS2DW12 (SDA) – PA7
  • LIS2DW12 (CS) – PB0

W kolejnym przykładzie zajmiemy się zagadnieniem znacznie bardziej złożonym – uruchomimy interfejs SPI w trybie standardowej wymiany danych (full duplex) i skomunikujemy nasz procesor z akcelerometrem LIS2DW12, z którego będziemy pobierać wyniki pomiarów, a następnie przesyłać je w formie ramki ASCII/CSV do terminalu portu szeregowego.

Maksymalna częstotliwość sygnału SCLK akcelerometru wynosi 10 MHz. W naszych zastosowaniach nie potrzebujemy zbyt szybkiego taktowania, dlatego możemy bez problemu obniżyć częstotliwość np. do 1,5 MHz – w tym celu 12-megahercowy zegar PCLK podzielimy przez 8. Za takie ustawienie preskalera SPI odpowiada linia:

SPI1->CR1 = SPI_CR1_BR_1;

Zgodnie z dokumentacją czujnika trzeba ustawić tryb SPI na wartość 3 (CPOL=1, CPHA=1), przy czym dane mają być przesyłane, począwszy od najstarszego bitu:

SPI1->CR1 |= SPI_CR1_CPHA | SPI_CR1_CPOL;
SPI1->CR1 &= ~SPI_CR1_LSBFIRST;

W kolejnych liniach wybieramy tryb obsługi linii zezwolenia (CE/NSS) – chcemy programowo sterować tym wyprowadzeniem poprzez normalny port wyjściowy GPIO, dlatego wyłączamy automatyczne przełączanie stanu NSS:

SPI1->CR1 |= SPI_CR1_SSM | SPI_CR1_SSI;

Następnie konfigurujemy tryb pracy interfejsu (master), rozmiar danych (8 bitów) oraz moment wyzwalania sygnału RXNE (powinien być dobrany odpowiednio do długości słowa danych, którą w mikrokontrolerach STM32 można regulować w szerokim zakresie):

SPI1->CR1 |= SPI_CR1_MSTR;
SPI1->CR2 |= SPI_CR2_DS_2 | SPI_CR2_DS_1 | SPI_CR2_DS_0;
SPI1->CR2 |= SPI_CR2_FRXTH;

Na koniec włączamy interfejs SPI:

SPI1->CR1 |= SPI_CR1_SPE;

Funkcja odpowiedzialna za dwukierunkowy przesył danych z wykorzystaniem 4-liniowego interfejsu SPI (CE, SCLK, MOSI, MISO) rozpoczyna się oczywiście od ustawienia wspomnianego wyjścia obsługującego linię CE czujnika:

GPIOB->BSRR = GPIO_BSRR_BR0;

Następnie w pętli for() dokonujemy wysyłki kolejnych bajtów danych, oczekując za każdym razem na zwolnienie bufora nadawczego:

while (!(SPI1->SR & SPI_SR_TXE)){};

Rzecz jasna, tutaj także powinniśmy zastosować odpowiednią obsługę błędu i timeout programowy, zaś sposób reakcji na zajętość bufora podczas próby nadania znaków należałoby oczywiście dobrać odpowiednio do potrzeb aplikacji (metod może być wiele, np. odczekanie i ponowienie próby, zresetowanie czujnika, sygnał alarmowy do układu nadrzędnego czy nawet awaryjne zatrzymanie urządzenia – zależnie od znaczenia takiego błędu dla funkcjonalności oraz bezpieczeństwa całego systemu).

void SPI_config()
{

/* wlaczenie taktowania SPI1 */
RCC->APBENR2 |= RCC_APBENR2_SPI1EN;

/* baudrate: ~1,5 MHz */
SPI1->CR1 = SPI_CR1_BR_1;

/* Tryb SPI: 3 (1,1) */
SPI1->CR1 |= SPI_CR1_CPHA | SPI_CR1_CPOL;

/* upewniamy sie, ze dane beda wysylane od MSB do LSB */
SPI1->CR1 &= ~SPI_CR1_LSBFIRST;

/* programowa obsluga linii CE (NSS) */
SPI1->CR1 |= SPI_CR1_SSM | SPI_CR1_SSI;

/* SPI w trybie master */
SPI1->CR1 |= SPI_CR1_MSTR;

/* transmisja slow 8-bitowych */
SPI1->CR2 |= SPI_CR2_DS_2 | SPI_CR2_DS_1 | SPI_CR2_DS_0;

/* ustawienie momentu wyzwalania zdarzenia RXNE (8 bitow)*/
SPI1->CR2 |= SPI_CR2_FRXTH;

/* wlaczenie bloku SPI */
SPI1->CR1 |= SPI_CR1_SPE;
}

void SPI_duplex_transaction(uint8_t *out, uint8_t *in, uint8_t len)
{

/* CS = 0 */
GPIOB->BSRR = GPIO_BSRR_BR0;

uint16_t tmt = 500;

for(uint8_t i = 0; i < len; i++)
{

/* oczekiwanie na pusty bufor nadawczy */
while (!(SPI1->SR & SPI_SR_TXE))
{};

/* zapis bajtu do wysylki */
*(uint8_t *)&(SPI1->DR) = out[i];

/* oczekiwanie na zakonczenie odbioru */
while(((SPI1->SR & SPI_SR_RXNE) != SPI_SR_RXNE) && (tmt > 0)){
tmt--;
}

/* odczyt odebranego bajtu z bufora */
in[i] = (uint8_t)(SPI1->DR);

}

/* CS = 1 */
GPIOB->BSRR = GPIO_BSRR_BS0;

}

Listing 6. Funkcje przeznaczone do konfiguracji i wysyłania danych przez SPI

Należy zwrócić uwagę na sposób uzyskania dostępu do rejestru danych:

*(uint8_t *)&(SPI1->DR) = out[i];

Zastosowanie jawnego rzutowania i odwołania do rejestru DR przez wskaźnik jest tutaj nieprzypadkowe – niestety (o czym dokumentacja wspomina jedynie dość lakonicznie) zwykłe odwołanie w postaci:

SPI1->DR = out[i];

byłoby przez kompilator potraktowane jako dostęp 32-bitowy, co doprowadziłoby do nieprawidłowego działania interfejsu.

Po wysłaniu każdego bajtu należy odczekać na zakończenie odbioru danych z linii MISO – w tym celu sprawdzamy stan bitu RXNE, znajdującego się w rejestrze SR. Tym razem zastosujemy ograniczenie liczby wykonań pętli:

while(((SPI1->SR & SPI_SR_RXNE) != SPI_SR_RXNE) && (tmt > 0))

Na koniec odczytujemy odebrany bajt z tego samego rejestru danych (DR) i zapisujemy go do bufora odbiorczego:

in[i] = (uint8_t)(SPI1->DR);

Do obsługi modułu akcelerometru służy sterownik, którego kod umieszczono w plikach lis2dw12_driver.c/lis2dw12_driver.h. Zawiera on zaledwie trzy proste funkcje:

void LIS2DW12_startup(void)

Funkcja wykonuje podstawową konfigurację akcelerometru – na początek odczytywany jest rejestr WHO_AM_I, co pozwala (przy użyciu analizatora stanów logicznych lub debuggera) sprawdzić poprawność komunikacji z procesorem. Po otrzymaniu komendy sensor powinien zwrócić wartość 0x44.

void LIS2DW12_read_XYZ(int16_t *x, int16_t *y, int16_t *z){

Funkcja odczytuje zawartość rejestrów wyjściowych 3-osiowej struktury MEMS. Wartości są przesyłane w formacie uzupełnienia do 2, stąd po odebraniu sześciu bajtów należy z nich poskładać 16-bitowe wartości surowych odczytów, które umieszczone zostaną w zmiennych x, y, z za pomocą wskaźników, podanych jako parametry funkcji.

uint8_t LIS2DW12_check_DRDY(void){

Funkcja sprawdza stan bitu DRDY w rejestrze STATUS akcelerometru – w kodzie funkcji main() wykorzystujemy ją do pollingu w oczekiwaniu na nowe wyniki pomiaru przyspieszenia.

W ramach artykułu nie będziemy szczegółowo omawiać wszystkich operacji wykonywanych przez poszczególne funkcje sterownika akcelerometru – całość jest szczegółowo opisana za pomocą komentarzy, zatem ponownie zachęcamy naszych Czytelników, by zajrzeli do materiałów dodatkowych powiązanych z kursem. Dla lepszego zobrazowania przebiegu komunikacji zamieszczamy natomiast rysunek 17, prezentujący stosowny zrzut ekranu z analizatora stanów logicznych.

Rysunek 17. Przebiegi na liniach interfejsu SPI w trybie odczytu danych z akcelerometru (przykład 6)

Przykład 7. Obsługa przerwań. Bloki EXTI i NVIC

Okablowanie płytki NUCLEO w tym przykładzie:

  • LIS2DW12 (VCC) – 3V3
  • LIS2DW12 (GND) – GND
  • LIS2DW12 (SCL) – PA5
  • LIS2DW12 (SDO) –PA6
  • LIS2DW12 (SDA) – PA7
  • LIS2DW12 (CS) – PB0
  • LIS2DW12 (INT1) – PA10

Tym razem wzbogacimy nasz przykład o zastosowanie przerwań od linii GPIO, unikając tym samym konieczności ciągłego odpytywania akcelerometru pod kątem dostępności nowych danych pomiarowych. Skorzystamy z jednej z dwóch linii przerwań układu LIS2DW12 (INT1), konfigurując ją uprzednio w taki sposób, by po zakończeniu każdego kolejnego pomiaru na INT1 pojawiał się impuls dodatni (L→H→L), informujący nasz procesor o konieczności dokonania pełnego odczytu danych X, Y, Z.

Konfiguracja linii GPIO do współpracy z blokiem przerwań nie wymaga specjalnych zabiegów – wyprowadzenie PA10 będzie po prostu zwykłym wejściem cyfrowym (bez funkcji alternatywnej). W przypadku inicjalizacji bloku NVIC (kontrolera przerwań) użyjemy wyjątkowo nie definicji rejestrów, ale gotowych, prostych funkcji należących do biblioteki CMSIS. Ciała obydwu funkcji konfigurujących NVIC oraz EXTI zaprezentowano na listingu 7.

void NVIC_Config(void){

/* wywolanie funkcji CMSIS – globalne zezwolenie przerwan */
__enable_irq();

/* ustawienie priorytetu przerwania EXTI4...15 */
NVIC_SetPriority(EXTI4_15_IRQn, 1);

/* "odmaskowanie" przerwania EXTI4...15 */
NVIC_EnableIRQ(EXTI4_15_IRQn);

}

void EXTI_config(void){

/* "odmaskowanie" przerwania od EXTI */
EXTI->IMR1 |= EXTI_IMR1_IM10;

/* przewanie generowane na zboczu narastajacym */
EXTI->RTSR1 = EXTI_RTSR1_RT10;

/* wyzerowanie bitow EXTICR => podpiecie linii PA10 */
EXTI->EXTICR[2] &= ~EXTI_EXTICR3_EXTI10;

}

Listing 7. Konfiguracja kontrolera przerwań NVIC i bloku EXTI

Najpierw ustawimy globalne zezwolenie obsługi przerwań:

__enable_irq();

Następnie ustawimy priorytet przerwania od linii bloku EXTI – czyli kontrolera przerwań i zdarzeń, obsługującego w naszym procesorze zarówno przerwania od linii GPIO, jak i szereg sygnałów wewnętrznych z różnych peryferiów. Ponieważ korzystamy z linii PA10, do jej obsługi musimy zastosować przerwanie wspólne dla wyprowadzeń o numerach od 4 do 15:

NVIC_SetPriority(EXTI4_15_IRQn, 1);

Na koniec „odmaskowujemy” przerwanie, umożliwiając jego wywołanie po wykryciu aktywnego zbocza na linii PA10:

NVIC_EnableIRQ(EXTI4_15_IRQn);

Funkcja konfiguracji samego bloku EXTI także nie jest zbyt skomplikowana i składa się zaledwie z trzech linii kodu. W pierwszej z nich zezwalamy na aktywację zdarzenia od linii 10 portu (na razie jeszcze bez wskazania, którego portu dotyczy):

EXTI->IMR1 |= EXTI_IMR1_IM10;

Następnie wybieramy rodzaj zbocza aktywnego (rosnące), ustawiając bit w rejestrze RTSR1 (gdyby konieczne było ustawienie aktywnego zbocza niskiego, wykonalibyśmy analogiczną operację na siostrzanym rejestrze FTSR1):

EXTI->RTSR1 = EXTI_RTSR1_RT10;

Na koniec musimy jeszcze wskazać kontrolerowi EXTI, którego portu ma dotyczyć obsługa przerwania. Do tego celu służą obszerne pola bitowe (o długości aż 8 bitów), umieszczone w czterech rejestrach EXTICR[0]...EXTICR[3]. Konwencja nazewnicza oraz sposób odwołania do rejestru jako komórki tablicy jest tutaj analogiczny do tego, z którym mieliśmy już do czynienia przy okazji omawiania rejestru AFR bloku GPIO. Linie portów o numerach 0…3 są obsługiwane przez EXTICR[0], linie 4...7 – EXTICR[1], 8...11 – przez EXTICR[2], zaś 12...15 – przez EXTICR[3]. Linia 10 „wypada” więc w zakresie EXTICR[2], a wybór portu A wymaga po prostu wyzerowania wszystkich ośmiu bitów w tej części rejestru – i tak też czynimy w poniższej linii kodu:

EXTI->EXTICR[2] &= ~EXTI_EXTICR3_EXTI10;

W celu obsługi przerwania skorzystamy z handlera o ściśle określonej nazwie, zdefiniowanej w pliku startup_stm32c031xx.s (patrz listing 8).

__Vectors DCD __initial_sp ; Top of Stack
DCD Reset_Handler ; Reset Handler
DCD NMI_Handler ; NMI Handler
DCD HardFault_Handler ; Hard Fault Handler
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD SVC_Handler ; SVCall Handler
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD PendSV_Handler ; PendSV Handler
DCD SysTick_Handler ; SysTick Handler

; External Interrupts
DCD WWDG_IRQHandler ; Window Watchdog
DCD 0 ; Reserved
DCD RTC_IRQHandler ; RTC through EXTI Line
DCD FLASH_IRQHandler ; FLASH
DCD RCC_IRQHandler ; RCC
DCD EXTI0_1_IRQHandler ; EXTI Line 0 and 1
DCD EXTI2_3_IRQHandler ; EXTI Line 2 and 3
DCD EXTI4_15_IRQHandler ; EXTI Line 4 to 15
DCD 0 ; Reserved
DCD DMA1_Channel1_IRQHandler ; DMA1 Channel 1
DCD DMA1_Channel2_3_IRQHandler ; DMA1 Channel 2 and Channel 3
DCD DMAMUX1_IRQHandler ; DMAMUX
DCD ADC1_IRQHandler ; ADC1
DCD TIM1_BRK_UP_TRG_COM_IRQHandler ; TIM1 Break, Update, Trigger and Commutation
DCD TIM1_CC_IRQHandler ; TIM1 Capture Compare
DCD 0 ; Reserved
DCD TIM3_IRQHandler ; TIM3
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD TIM14_IRQHandler ; TIM14
DCD 0 ; Reserved
DCD TIM16_IRQHandler ; TIM16
DCD TIM17_IRQHandler ; TIM17
DCD I2C1_IRQHandler ; I2C1
DCD 0 ; Reserved
DCD SPI1_IRQHandler ; SPI1
DCD 0 ; Reserved
DCD USART1_IRQHandler ; USART1
DCD USART2_IRQHandler ; USART2
DCD 0 ; Reserved
DCD 0 ; Reserved
DCD 0 ; Reserved

__Vectors_End

Listing 8. Fragment pliku startup_stm32c031xx.s, prezentujący tablicę wektorów przerwań z domyślnymi nazwami poszczególnych procedur obsługi

Na listingu 9 można zobaczyć ciało funkcji obsługującej przerwanie EXTI4_15IRQn – jak widać, jest ona bardzo prosta.

void EXTI4_15_IRQHandler(void)
{

extern volatile uint8_t drdy_flag;

/* sprawdzenie czy zrodlem przerwania byla linia PA10 */
if(EXTI->RPR1 & EXTI_RPR1_RPIF10){

/* resetowanie flagi przerwania */
EXTI->RPR1 |= EXTI_RPR1_RPIF10;

/* ustawienie flagi programowej, ktora zostanie odczytana w main() */
drdy_flag = 1;

}
}

Listing 9. Ciało procedury obsługi przerwania od EXTI4...15

Całość rozpoczyna się sprawdzeniem, czy obsługiwane przerwanie faktycznie pochodzi od linii o interesującym nas numerze:

if(EXTI->RPR1 & EXTI_RPR1_RPIF10)

Jeżeli tak, musimy od razu zresetować flagę przerwania poprzez... ustawienie tego samego bitu:

EXTI->RPR1 |= EXTI_RPR1_RPIF10;

Na koniec wpisujemy nową wartość (1) do zmiennej globalnej drdy_flag, zadeklarowanej w pliku main.c. Teraz wystarczy już tylko proste sprawdzenie zawartości tej zmiennej w pętli głównej programu – jeżeli będzie ona ustawiona, program przeprowadzi odczyt danych z akcelerometru, resetując uprzednio flagę drdy_flag do wartości spoczynkowej równej 0. Na rysunku 18 można zobaczyć przebiegi, zarejestrowane za pomocą analizatora stanów logicznych – tym razem z dodatkowym, piątym kanałem, podłączonym do linii PA10.

Rysunek 18. Przebiegi na liniach interfejsu SPI w trybie odczytu danych z akcelerometru, tym razem z obsługą przerwania od linii INT1 czujnika (przykład 7)

Przykład 8. Obsługa I²C

Okablowanie płytki NUCLEO w tym przykładzie:

  • LIS2DW12 (VCC) – 3V3
  • LIS2DW12 (GND) – GND
  • LIS2DW12 (SCL) – PB8
  • LIS2DW12 (SDA) – PB9
  • LIS2DW12 (INT1) – PA10
  • LIS2DW12 (SDO) – GND (ustawienie adresu I²C)
  • LIS2DW12 (CS) – 3V3 (wyłączenie SPI, zezwolenie na tryb I²C)

W ostatnim ćwiczeniu niniejszego kursu zaprezentujemy program o działaniu analogicznym do zaprezentowanego w poprzednim przykładzie, tym razem jednak skorzystamy z interfejsu I²C. W opisie pominiemy już konfigurację linii interfejsu SCL/SDA, przejdziemy bowiem od razu do właściwej inicjalizacji bloku I²C. Teraz, oprócz włączenia taktowania w rejestrze APBENR1, musimy dodatkowo wybrać źródło głównego sygnału zegarowego dla I²C – dokonujemy tego, ustawiając bit I2C1SEL_0 w rejestrze CCIPR bloku RCC:

RCC->CCIPR |= RCC_CCIPR_I2C1SEL_0;

Nieprzypadkowo korzystamy właśnie z tego źródła – mikrokontrolery STM32 mają dość specyficzny, silnie rozbudowany zestaw opcji służących do konfiguracji najdrobniejszych nawet aspektów timingu sygnałów na liniach interfejsu I²C. Ręczne ustawienie tych parametrów może nastręczać pewnych trudności, dlatego producent opracował trzy metody uproszczenia pracy programistów:

W dokumencie Reference Manual dostępne są tabele, prezentujące przykładowe ustawienia poszczególnych pól bitowych rejestru TIMINGR przy różnych prędkościach transmisji i różnych częstotliwościach taktowania.

Dla mikrokontrolerów STM32F dostępny jest arkusz kalkulacyjny, umożliwiający zautomatyzowane wyliczenie potrzebnych nastaw po podaniu szeregu parametrów wejściowych.

Narzędzie do graficznej konfiguracji peryferiów STM32 (STM32CubeMX) zawiera użyteczny kalkulator, pozwalający na wyklikanie odpowiednich wartości za pomocą intuicyjnego interfejsu GUI.

My wybierzemy opcję pierwszą – we wspomnianym dokumencie są dostępne dane dla taktowania 48 MHz i docelowej częstotliwości SCL równej 100 kHz – takie też wartości wpisujemy do naszego rejestru:

I2C1->TIMINGR = (uint32_t)(0x0B << I2C_TIMINGR_PRESC_Pos) |
(uint32_t)(0x13 << I2C_TIMINGR_SCLL_Pos) |
(uint32_t)(0x0F << I2C_TIMINGR_SCLH_Pos) |
(uint32_t)(0x02 << I2C_TIMINGR_SDADEL_Pos) |
(uint32_t)(0x04 << I2C_TIMINGR_SCLDEL_Pos);

Następnie włączamy blok I²C, zezwalamy na obsługę przerwań odbiorczych oraz uruchamiamy funkcję automatycznego zakończenia transmisji. W tym miejscu wpiszemy także adres slave (na szynie I²C znajduje się tylko jeden czujnik, więc jego adres możemy przypisać raz, w czasie inicjalizacji).

I2C1->CR1 = I2C_CR1_PE | I2C_CR1_RXIE;
I2C1->CR2 = I2C_CR2_AUTOEND | (addr << I2C_CR2_SADD_Pos);

Funkcje przeznaczone do zapisu i odczytu danych poprzez I²C są dość obszerne, choć w istocie stosunkowo łatwe do przeanalizowania. Tym razem analizę poszczególnych zapisów pozostawiamy więc naszym Czytelnikom jako swego rodzaju zadanie domowe – pomocą będą komentarze, opisujące szczegółowo działanie programu. Ciała funkcji inicjalizacji oraz zapisu i odczytu bajtów można zobaczyć na listingach 10...12.

void I2C_config(uint8_t addr){

/* wybieramy SYSCLK = 48 MHz jako zrodlo sygnalu taktowania I2C1 */
RCC->CCIPR |= RCC_CCIPR_I2C1SEL_0;

/* wlaczenie taktowania interfejsu od strony szyny systemowej */
RCC->APBENR1 |= RCC_APBENR1_I2C1EN;

/* konfiguracja timingu dla f_SCLK = 100 kHz @ I2CCLK = 48 MHz */
I2C1->TIMINGR = (uint32_t)(0x0B << I2C_TIMINGR_PRESC_Pos) |
(uint32_t)(0x13 << I2C_TIMINGR_SCLL_Pos) |
(uint32_t)(0x0F << I2C_TIMINGR_SCLH_Pos) |
(uint32_t)(0x02 << I2C_TIMINGR_SDADEL_Pos) |
(uint32_t)(0x04 << I2C_TIMINGR_SCLDEL_Pos);

/* wlaczenie peryferium i zezwolenie na przerwanie od RX */
I2C1->CR1 = I2C_CR1_PE | I2C_CR1_RXIE;

/* wlaczenie opcji autmatycznego konczenia transmisji */
/* ustawienie adresu slave’a */
I2C1->CR2 = I2C_CR2_AUTOEND | (addr << I2C_CR2_SADD_Pos);

}

Listing 10. Funkcja inicjalizująca interfejs I2C
void I2C_write_bytes(uint8_t *ptr, uint8_t n){

uint16_t tout = 0;

/* sprawdzenie warunku rozpoczecia kolejnej transmisji */
while((I2C1->CR2 & I2C_CR2_START) == I2C_CR2_START){

if(tout < 1000){
delayMicrosec(1);
}else{
break;
}
tout++;

}

/* ponowne sprawdzenie bitu START */
if((I2C1->CR2 & I2C_CR2_START) != I2C_CR2_START){

/* zerowanie bitu R/W (bedziemy nadawac dane) */
I2C1->CR2 &= ~I2C_CR2_RD_WRN;

/* ustawienie automatycznego zakonczenia transmisji */
I2C1->CR2 |= I2C_CR2_AUTOEND;

/* ustawienie dlugosci transmisji */
I2C1->CR2 &= ~I2C_CR2_NBYTES;
I2C1->CR2 |= (n << 16);

/* sprawdzenie czy bufor nadawczy jest pusty */
if((I2C1->ISR & I2C_ISR_TXE) == I2C_ISR_TXE){

/* zapis pierwszego bajtu do rejestru wyjsciowego */
I2C1->TXDR = ptr[0];

/* nadanie warunku startu */
I2C1->CR2 |= I2C_CR2_START;

}

/* wysylka pozostalych bajtow */
for(uint8_t i = 1; i < n; i++){

tout = 0;

/* oczekiwanie na pusty bufor nadawczy */
while((I2C1->ISR & I2C_ISR_TXE) != I2C_ISR_TXE){

if(tout < 1000){

delayMicrosec(1);

}else{

break;
}

tout++;

}

/* jezeli bufor pusty */
if((I2C1->ISR & I2C_ISR_TXE) == I2C_ISR_TXE){

/* nadawanie kolejnych bajtow */
I2C1->TXDR = ptr[i];

tout = 0;

/* oczekiwanie na pusty bufor nadawczy */
while(!(I2C1->ISR & I2C_ISR_TXE)){

if(tout < 1000){

delayMicrosec(1);

}else{

break;
}

tout++;
}

}else{
/* miejsce na obsluge bledu zajetosci bufora */
}
}
}else{
/* miejsce na obsluge bledu bitu START */
}
}

Listing 11. Funkcja zapisu danych przez I2C
void I2C_read_bytes(uint8_t reg, uint8_t *ptr, uint8_t n){

uint16_t tout = 0;

/* sprawdzenie warunku rozpoczecia kolejnej transmisji */
while((I2C1->CR2 & I2C_CR2_START) == I2C_CR2_START){

if(tout < 1000){

delayMicrosec(1);

}else{

break;

}
tout++;

}

/* ponowne sprawdzenie bitu START */
if((I2C1->CR2 & I2C_CR2_START) != I2C_CR2_START){

I2C1->CR2 &= ~I2C_CR2_RD_WRN;

I2C1->CR2 &= ~I2C_CR2_AUTOEND;
I2C1->CR2 &= ~I2C_CR2_NBYTES;
I2C1->CR2 |= (1 << 16);

if((I2C1->ISR & I2C_ISR_TXE) == I2C_ISR_TXE){

I2C1->TXDR = reg;
I2C1->CR2 |= I2C_CR2_START;
}

tout = 0;

while((I2C1->CR2 & I2C_CR2_START) == I2C_CR2_START){

if(tout < 1000){

delayMicrosec(1);

}else{

break;

}

tout++;

}

if((I2C1->CR2 & I2C_CR2_START) != I2C_CR2_START){

I2C1->CR2 |= I2C_CR2_RD_WRN | I2C_CR2_AUTOEND;
I2C1->CR2 &= ~I2C_CR2_NBYTES;
I2C1->CR2 |= (n << 16);

/* powtorzony start */
I2C1->CR2 |= I2C_CR2_START;

for(uint8_t i = 0; i < n; i++){

tout = 0;

/* oczekiwanie na dane w buforze odbiorczym */
while((I2C1->ISR & I2C_ISR_RXNE) != I2C_ISR_RXNE){

if(tout < 1000){
delayMicrosec(1);
}else{
break;
}

tout++;

}

/* ponowne sprawdzenie bufora odbiorczego */
if((I2C1->ISR & I2C_ISR_RXNE) == I2C_ISR_RXNE){

/* odczyt bajtu z bufora */
ptr[i] = I2C1->RXDR;

}else{
/* przykladowa obsluga przypadku braku */
/* danych w buforze */
ptr[i] = 0;

}
}
}else{
/* miejsce na obsluge bledu zajetosci bufora */
}
}else{
/* miejsce na obsluge bledu zajetosci bufora */
}
}

Listing 12. Funkcja odczytu danych przez I2C
Rysunek 19. Przebiegi na liniach interfejsu I²C w trybie odczytu danych z akcelerometru z obsługą przerwania od linii INT1 czujnika (przykład 8)

Rysunek 19 pokazuje zrzut ekranu z analizatora stanów logicznych, zaś rysunek 20 – przykładowe odczyty z akcelerometru, zobrazowane za pomocą narzędzia Serial Plotter, dostępnego w środowisku Arduino IDE.

Rysunek 20. Przykładowy zapis danych z akcelerometru w formie graficznej

Podsumowanie

W niniejszym artykule przekazaliśmy naszym Czytelnikom solidne podstawy programowania mikrokontrolerów STM32C0 za pomocą rejestrów i zaledwie trzech funkcji biblioteki CMSIS. Mamy nadzieję, że zaprezentowany materiał nie tylko zachęci do samodzielnego eksplorowania dokumentacji procesorów STM32, ale także zmotywuje do głębszego poznania tych interesujących procesorów. W pojedynczym artykule nie sposób rzecz jasna opisać wszystkich bloków peryferyjnych – na pokładzie najnowszych układów znajdują się wszak peryferia takie, jak kontroler DMA, przetwornik ADC, układy watchdogów, timer systemowy, zegar RTC, akcelerator obliczeń CRC i kilka innych. Mało tego – każdy z nich, podobnie jak opisane w kursie bloki GPIO, RCC, UART, SPI, I²C oraz timery – ma jeszcze szereg funkcji, o których nie zdołaliśmy nawet wspomnieć na łamach naszego opracowania. Zachęcamy zatem do samodzielnego kontynuowania przygody z niskopoziomowym programowaniem układów ARM.

Mamy także nadzieję, że formuła kursu ekspresowego (crash course) przypadnie naszym Czytelnikom do gustu. A może nasi Czytelnicy sami zaproponują tematy kolejnych „szkoleń w pigułce”? Jeśli tak, serdecznie zapraszamy do kontaktu z Redakcją poprzez e-mail bądź chat na facebookowym profilu „Elektroniki Praktycznej”.

inż. Przemysław Musz, EP

[1] Zapis ten można także skrócić do postaci:

if (USART2->ISR & USART_ISR_TXE_TXFNF)
Artykuł ukazał się w
Elektronika Praktyczna
marzec 2024
DO POBRANIA
Materiały dodatkowe

Elektronika Praktyczna Plus lipiec - grudzień 2012

Elektronika Praktyczna Plus

Monograficzne wydania specjalne

Elektronik czerwiec 2024

Elektronik

Magazyn elektroniki profesjonalnej

Raspberry Pi 2015

Raspberry Pi

Wykorzystaj wszystkie możliwości wyjątkowego minikomputera

Świat Radio lipiec - sierpień 2024

Świat Radio

Magazyn krótkofalowców i amatorów CB

Automatyka, Podzespoły, Aplikacje maj 2024

Automatyka, Podzespoły, Aplikacje

Technika i rynek systemów automatyki

Elektronika Praktyczna czerwiec 2024

Elektronika Praktyczna

Międzynarodowy magazyn elektroników konstruktorów

Elektronika dla Wszystkich lipiec 2024

Elektronika dla Wszystkich

Interesująca elektronika dla pasjonatów