Rovnou přiznávám, že zatím jen matně tuším kde by následující aplikace šla použít, takže klasická motivace se dnes nekoná. Nechtěl jsem v domácí karanténě úplně zakrnět a tak jsem se rozhodl, že se pokusím vymáčknout z AD převodníku na STM32F0 maximální vzorkovací rychlost. A protože vzorkovat nějaký náhodný signál by byla dosti nuda, rozhodl jsem se, že budu měřit průběh pulzu přicházejícího za trigrovacím signálem.
Teoretická maximální rychlost převodu na STM32F0 se pohybuje mezi 1 - 1.5Msps v závislosti na tom s jak moc mizerným rozlišením se smíříme. Protože netuším jestli by bylo jádro schopné takový datový tok vyhodnocovat včas, zvolil jsem raději záznam do paměti (s tím, že na pozadí nechť si s daty jádro dělá co chce). Shrňme si nejprve fakta, které dávají celou aplikaci dohromady:
O některých funkcích "slave controlleru", který umožňuje vyvolat klíčové události timeru (jako třeba spuštění, čítání atd.) jste se mohli dočíst v předchozích tutoriálech (Timer-link,PWM input). Volba padla na TIM3. Mohl jsem sáhnout i po TIM2, ale to bych si vyplýtval jediný 32bit timer na čipu (tuto demonstraci provádím na Nucleo STM32F042). Z podobného důvodu jsem nevolil ani TIM1. Ostatní timery buď neobsahují "slave controller" a nebo jejich výstup nevede do ADC. Konfiguraci timeru jsem pro názornost zakreslil do blokového schematu z datasheetu
Pojďme si tedy okomentovat konfiguraci popořadě. Spouštěcí signál (vzestupná hrana log. signálu) přivedeme na vstup TIM3_CH1. Signál vstupuje do "filter & edge detector". Zde dochází k detekci hran a digitální filtraci. V našem programu nastavíme detekci vzestupné hrany a filtr vypneme. Tím vznikne signál TI1FP1. Ten vede přímo do "slave controlleru", který nastavíme tak aby příchod signálu z TI1FP1 vyvolal událost "enable" (v knihovnách pojmenovanou Trigger). Příchodem vzestupné hrany se tedy timer spustí. Vnitřní výstup timeru (TRGO) může posílat signál od několika různých událostí. Nám se hodí do krámu událost "enable". Tedy okamžik kdy byl timer (externím signálem) spuštěn. Signál z TRGO pak použijeme v ADC ke spuštění převodu. Protože náš timer nemá nic časovat, má hrát roli pouze detektoru spouštěcího signálu, nastavíme periodu (strop timeru) na nějakou miniální hodnotu a aktivujeme One-pulse mód. Tato volba zaručí že se timer dva tiky po startu zase sám vypne a je připraven detekovat další spouštěcí signál. Abych viděl s jakým zpožděním timer reaguje, dovolil jsem si využít jeho třetí kanál ke generování pulzu (PWM) o minimální šířce (1 tik). K funkci to není nutné, takže pokud plánujete příklad někde využít, můžete příslušný kus kódu vyhodit.
Na větších MCU je vyveden i signál TIM3_ETR, se kterým je možné provádět úplně stejnou akci. Pokud bychom chtěli timer spouštět na obě hrany (vzestupnou i sestupnou), můžeme použít signál TI1F_ED, což je výstup přímo z "edge detektoru". Kdyby to bylo potřeba, můžeme použít namísto kanálu 1 i kanál 2 (a jeho signál TI2FP2).
// TIM3 snímá vzestupnou hranu na trigger signálu a spouští sérii AD převodů - funguje jako externí trigger pro ADC void init_TIM3(void){ LL_GPIO_InitTypeDef GPIO_InitStruct; LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_GPIOA); LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_GPIOB); LL_APB1_GRP1_EnableClock(LL_APB1_GRP1_PERIPH_TIM3); // PA6 (TIM3_CH1) - budoucí vstup pro trigger GPIO_InitStruct.Pin = LL_GPIO_PIN_6; GPIO_InitStruct.Mode = LL_GPIO_MODE_ALTERNATE; GPIO_InitStruct.Speed = LL_GPIO_SPEED_FREQ_LOW; GPIO_InitStruct.OutputType = LL_GPIO_OUTPUT_PUSHPULL; GPIO_InitStruct.Pull = LL_GPIO_PULL_DOWN; GPIO_InitStruct.Alternate = LL_GPIO_AF_1; LL_GPIO_Init(GPIOA, &GPIO_InitStruct); // PB0 (TIM3_CH3) - informační výstup GPIO_InitStruct.Pin = LL_GPIO_PIN_0; GPIO_InitStruct.Pull = LL_GPIO_PULL_NO; LL_GPIO_Init(GPIOB, &GPIO_InitStruct); // konfigurace Timeru LL_TIM_SetPrescaler(TIM3,0); // frekvence 48MHz LL_TIM_SetAutoReload(TIM3,2); // strop minimální (chceme aby se timer včas vypnul a byl připraven snímat další trigger) LL_TIM_SetOnePulseMode(TIM3,LL_TIM_ONEPULSEMODE_SINGLE); // one-shot, timer se po přetečení sám vypne (a bude čekat na další trigger) // konfigurace IC kanálu (vstup pro trigger timeru) LL_TIM_IC_SetPolarity(TIM3,LL_TIM_CHANNEL_CH1,LL_TIM_IC_POLARITY_RISING); // detekovat vzestupnou hranu LL_TIM_IC_SetFilter(TIM3,LL_TIM_CHANNEL_CH1,LL_TIM_IC_FILTER_FDIV1); // digitální filtr vypnutý // od teď jde na TI1FP1 pulz okamžitě po detekci vzestupné hrany na CH1 (PA6) // Informační výstup timeru generuje pulz LL_TIM_OC_SetMode(TIM3,LL_TIM_CHANNEL_CH3,LL_TIM_OCMODE_PWM2); // mód PWM (zarovnaný na "pravou stranu") LL_TIM_OC_SetCompareCH3(TIM3,1); // vzestupná hrana PWM 1 tik po startu timeru LL_TIM_CC_EnableChannel(TIM3,LL_TIM_CHANNEL_CH3); // od teď má TIM3 kontrolu nad CH3 (PB0) // nastavení master chování LL_TIM_SetTriggerOutput(TIM3,LL_TIM_TRGO_ENABLE); // vypustit TRGO signál jakmile je timer spuštěn // Nastavení Slave chování LL_TIM_SetTriggerInput(TIM3,LL_TIM_TS_TI1FP1); // Reagovat na signál TI1FP1 LL_TIM_SetSlaveMode(TIM3,LL_TIM_SLAVEMODE_TRIGGER); // událostí timer spustit }
Konfigurace AD převodníku je vcelku přímočará (a trochu zdlouhavá). Vstupní signál pustíme třeba na vstup ADC_IN0 (PA0), ale klidně můžete volit i jiný kanál. Zdroj clocku volím (asynchronních) 14MHz z interního RC oscilátoru, protože to je jeho maximální taktovací frekvence. Další možností by bylo taktovat ho 12MHz odvozenými od taktu celého MCU (48MHz), ale ztratil bych tím 15% rychlosti. Rozlišení AD převodu si můžeme volit podle potřeby a ještě se k němu budeme několikrát vracet. Jako TriggerSource volíme TRGO výstup TIM3. Aktivujeme už zmíněný kontinuální režim. DMA přenos volíme typu DMA_TRANSFER_LIMITED, což znamená že DMA po skončení přenosu zastaví AD převodník. Před spuštěním ADC ještě pro jistotu provedeme kalibraci. Nakonec provedu konfiguraci měřeného kanálu. Funkcí LL_ADC_REG_SetSequencerChannels() zvolím kanál, které chci měřit. Za běžných okolností je argumentem této funkce vícero kanálů které pak automaticky přepíná sekvencér, ale nám se tato funkce nehodí a volíme pouze jeden kanál. Funkcí LL_ADC_SetSamplingTimeCommonChannels() volíme vzorkovací čas. Pro nejrychlejší převod volím ten nejkratší - tedy 1.5 taktu ADC, což je přibližně 110ns. Tak krátký vzorkovací čas vyžaduje nízký výstupní odpor vstupního signálu (což by měl generátor s výstupní impedancí 50Ohm splňovat).
static void MX_ADC_Init(void){ LL_ADC_InitTypeDef ADC_InitStruct = {0}; LL_ADC_REG_InitTypeDef ADC_REG_InitStruct = {0}; LL_GPIO_InitTypeDef GPIO_InitStruct = {0}; LL_DMA_InitTypeDef dma; uint16_t del=10; // poslouží k realizaci tupého delay LL_APB1_GRP2_EnableClock(LL_APB1_GRP2_PERIPH_ADC1); LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_GPIOA); LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_DMA1); // PA0 je vstup pro ADC GPIO_InitStruct.Pin = LL_GPIO_PIN_0; GPIO_InitStruct.Mode = LL_GPIO_MODE_ANALOG; GPIO_InitStruct.Pull = LL_GPIO_PULL_NO; LL_GPIO_Init(GPIOA, &GPIO_InitStruct); // 1.konfigurace ADC ADC_InitStruct.Clock = LL_ADC_CLOCK_ASYNC; // taktujeme interním 14MHz oscilátorem (umožňuje nejrychlejší převod) ADC_InitStruct.Resolution = LL_ADC_RESOLUTION_12B; // rozlišení lze volit různě, menší rozlišení zkracuje dobu převodu ADC_InitStruct.DataAlignment = LL_ADC_DATA_ALIGN_RIGHT; // data zarovnaná vpravo ADC_InitStruct.LowPowerMode = LL_ADC_LP_MODE_NONE; // low power módy nás teď nezajímají LL_ADC_Init(ADC1, &ADC_InitStruct); // provede nastavení ADC // 2.konfigurace ADC ADC_REG_InitStruct.TriggerSource = LL_ADC_REG_TRIG_EXT_TIM3_TRGO; // převod startovat TRGO sginálem z TIM3 ADC_REG_InitStruct.SequencerDiscont = LL_ADC_REG_SEQ_DISCONT_DISABLE; // seqvencer nepoužíváme, Discontinuous režim nechceme ADC_REG_InitStruct.ContinuousMode = LL_ADC_REG_CONV_CONTINUOUS; // volíme kontinuální režim (!), ADC převádí od trigru, do signálu od DMA (konce přenosu) ADC_REG_InitStruct.DMATransfer = LL_ADC_REG_DMA_TRANSFER_LIMITED; // DMA má oprávnění zastavit převod, jakmile dojde ke konci přenosu (DMA řídí počet převodů) ADC_REG_InitStruct.Overrun = LL_ADC_REG_OVR_DATA_PRESERVED; // overrun neošetřujeme, takže to je jedno LL_ADC_REG_Init(ADC1, &ADC_REG_InitStruct); // provede nastavení ADC // kalibrace ADC LL_ADC_StartCalibration(ADC1); // zahájí kalibraci while(LL_ADC_IsCalibrationOnGoing(ADC1)); // počká na dokončení while(del){del--;} //počká alespoň 2us od kalibrace // spuštění DAC LL_ADC_Enable(ADC1); // spustíme ADC while(!LL_ADC_IsActiveFlag_ADRDY(ADC1)); // počkáme až se ADC rozběhne LL_ADC_ClearFlag_ADRDY(ADC1); // uklidíme po sobě vlajku // konfigurace ADC kanálu LL_ADC_REG_SetSequencerChannels(ADC1, LL_ADC_CHANNEL_0); // vstupem je ADC_IN0 (pouze) LL_ADC_SetSamplingTimeCommonChannels(ADC1, LL_ADC_SAMPLINGTIME_1CYCLE_5); // vzorkovací čas je co nejkratší // konfigurace DMA dma.Direction = LL_DMA_DIRECTION_PERIPH_TO_MEMORY; // transfer z ADC (periferie) do paměti dma.MemoryOrM2MDstAddress = (uint32_t)pulses; // nezáleží, budeme ji nastavovat znovu dma.MemoryOrM2MDstIncMode = LL_DMA_MEMORY_INCREMENT; // na straně paměti inkrementovat adresu dma.MemoryOrM2MDstDataSize = LL_DMA_MDATAALIGN_HALFWORD; // přenášíme 16bit (tady změnit pokud přenastavíme ADC na 8 nebo 6bit) dma.Mode = LL_DMA_MODE_NORMAL; // jednorázový přenos dma.NbData = SAMPLES; // počet přenášených vzorků (nezáleží, budeme jej nastavovat znovu) dma.PeriphOrM2MSrcAddress = (uint32_t)(&(ADC1->DR)); // zdrojová adresa v periferii (ADC) dma.PeriphOrM2MSrcDataSize = LL_DMA_PDATAALIGN_HALFWORD; // z periferie (ADC) přenášíme 16bit (tady změnit pokud přenastavíme ADC na 8 nebo 6bit) dma.PeriphOrM2MSrcIncMode = LL_DMA_PERIPH_NOINCREMENT; // v periferii (ADC) nechat adresu konstantní (neinkrementovat) dma.Priority = LL_DMA_PRIORITY_HIGH; // priorita DMA je celkem vysoká (nemá vliv, protože jiné DMA kanály stejně neběží) LL_DMA_Init(DMA1,LL_DMA_CHANNEL_1,&dma); // nastavit DMA (channel 1 odpovídá requestům od ADC) // nastavit přerušení od DMA __NVIC_SetPriority(DMA1_Channel1_IRQn,0); // vysoká priorita __NVIC_EnableIRQ(DMA1_Channel1_IRQn); // povolit v NVIC LL_DMA_EnableIT_TC(DMA1,LL_DMA_CHANNEL_1); // povolit přerušení od Transfer Complete našeho channel 1 }
Konfigurace DMA je jednoduchá. Nastavíme přenos z ADC do paměti, podle zvoleného rozlišení si vybereme zda přenášíme BYTE nebo HALFWORD. Adresu v paměti bude naše aplikace postupně měnit, takže při inicializaci ji není nutné znát. Stejně tak počet přenášených dat, budeme muset před každým znovu-spuštněním ADC opětovně zapsat. Voba vysoké priority zde nehraje roli, neboť poběží jen jeden DMA kanál. Smysl by dostala ve složitější aplikaci, kde by DMA obsluhovalo více přenosů. Jako poslední si od DMA povolím přerušení od "Transfer Complete". To bude okamžik kdy skončí sběr volitelného počtu vzorků (jednoduše řečeno kdy skončí jedno měření pulzu). V rutině přerušení pak provedeme znovu-připravení našeho systému k dalšímu měření. Díky tomu bude sběr vzorků z AD převodníku probíhat "na pozadí" a nebude příliš zaměstnávat jádro.
Pro záznam pulzů máme v paměti připravené dvourozměrné pole pulses[][]. Konkrétně máme vyhrazeno 16 buněk o 64 prvcích. Těch 64 prvků budeme plnit výsledky jednotlivých AD převodů a bude v nich uložen záznam jednoho pulzu. Těchto záznamů si uložíme celkem 16. Toto pole může fungovat třeba jako kruhový buffer a jádro tak může mít dost času provádět na pulzech nějakou analýzu třeba měřit amplitudu, nebo šířku a pod. Oba rozměry pole si můžete snadno změnit makry PULSE_COUNT a SAMPLES. Proměnná hotovo má pomocnou roli a slouží k identifikaci okamžiku kdy aplikace dokončí sběr všech 16ti pulzů. Proměnná pulse je počítadlo pulzů a dává nám informaci o tom do které ze 16ti buněk se zrovna provádí záznam. Proměnné cnt a uzitecna_cinnost nejsou pro práci klíčové. Abychom mohli měřit jak dlouho se aplikace připravuje na další záznam, indikujeme si tuto její činnost na výstupu PB1 (makra INFO_H a INFO_L).
(Re)start záznamu vypadá následovně. Nejprve vypneme DMA kanál abychom následně mohli zapsat počet vzorků záznamu (64) a nastavit adresu kam se mají data ukládat (ta se postupně posouvá). Pak DMA kanál zapneme, pro jistotu počkáme než se k jeho zapnutí dojde. Poté "spustíme" AD převod. Což neznamená že se rovnou zahájí první převod, ale jen to, že AD převodník začne čekat na spouštěcí signál z timeru. Toť vše. Nutné mazání vlajky a počítání změřených sad (pulse) nestojí za komentář.
// PA0 (A0) (ADC_IN0) - měřené pulzy // PA6 (A5) (TIM3_CH1) - spuštění AD převodů // PB0 (D3) (TIM3_CH3) - informační výstup (spuštění ADC) // PB1 (D6) - informační výstup (reset ADC+DMA) #include "main.h" #include "string.h" #define INFO_H LL_GPIO_SetOutputPin(GPIOB,LL_GPIO_PIN_1) #define INFO_L LL_GPIO_ResetOutputPin(GPIOB,LL_GPIO_PIN_1) void SystemClock_Config(void); static void MX_GPIO_Init(void); static void MX_ADC_Init(void); void init_TIM3(void); void get_pulses(void); #define PULSE_COUNT 16 #define SAMPLES 64 volatile uint16_t pulses[PULSE_COUNT][SAMPLES]; // tady sbíráme celkem 16 pulzů, každý po 64 vzorcích (změnit na uint16_t pokud máte převod 10 nebo 12bit) volatile uint8_t hotovo; // informuje hlavní smyčku o tom že sběr všech pulzů skončil volatile uint16_t pulse=0; // počítá pulzy v rámci jedené sady (je přístupné zbytku programu) uint32_t cnt=0; // počítá počet sad pulzů (jen tak) uint32_t uzitecna_cinnost=0; // nějaká užitečná činnost v mezi čase int main(void){ // základní inicializace LL_APB1_GRP2_EnableClock(LL_APB1_GRP2_PERIPH_SYSCFG); LL_APB1_GRP1_EnableClock(LL_APB1_GRP1_PERIPH_PWR); SystemClock_Config(); // 48MHZ (z HSI48) MX_GPIO_Init(); // konfigurace informačního výstupu (PB1) MX_ADC_Init(); // konfigurace ADC, vstupů i DMA init_TIM3(); // konfigurace TIM3 (trigrovacího systému pro ADC) a přidružených GPIO hotovo=1; // spustí první měření while (1){ if(hotovo){ // pokud jsme proveděli PULSE_COUNT měření, máme data ke zpracování INFO_H; // informujeme že probíhá "zpracování" pulse=0; // vynulujeme počítadlo pulzů // připravíme další sběr (stejně jako v DMA IRQ rutině) LL_DMA_DisableChannel(DMA1,LL_DMA_CHANNEL_1); LL_DMA_SetMemoryAddress(DMA1,LL_DMA_CHANNEL_1,(uint32_t)&pulses[pulse][0]); LL_DMA_SetDataLength(DMA1,LL_DMA_CHANNEL_1,SAMPLES); LL_DMA_EnableChannel(DMA1,LL_DMA_CHANNEL_1); while(!LL_DMA_IsEnabledChannel(DMA1,LL_DMA_CHANNEL_1)); LL_ADC_REG_StartConversion(ADC1); // od teď ADC čeká na trigger a pak zahájí sekvenci převodů hotovo=0; cnt++; // počítáme počet sad pulzů... jen tak. INFO_L; } uzitecna_cinnost++ ; // děláme něco smysluplného } } // dokončen sběr vzorků jednoho pulzu void DMA1_Channel1_IRQHandler(void){ INFO_H; // informujeme že probíhá reset DMA a ADC k dalšímu měření LL_DMA_ClearFlag_TC1(DMA1); // vyčistit vlajku abychom mohli detekovat příští událost (konec sběru) if(pulse < PULSE_COUNT){ // dokud nenasbíráme PULSE_COUNT záznamů LL_DMA_DisableChannel(DMA1,LL_DMA_CHANNEL_1); // vypneme DMA abychom ho mohli rekonfigurovat LL_DMA_SetMemoryAddress(DMA1,LL_DMA_CHANNEL_1,(uint32_t)&pulses[pulse][0]); // nastavíme novou adresu kam ukládat další pulz LL_DMA_SetDataLength(DMA1,LL_DMA_CHANNEL_1,SAMPLES); // znovunastavíme počet vzorků jednho měření LL_DMA_EnableChannel(DMA1,LL_DMA_CHANNEL_1); // povolíme DMA kanál LL_ADC_REG_StartConversion(ADC1); // povlíme ADC čekat na trigger a zahájít sběr dat pulse++; // započítáme další pulz }else{ // posbírali jsme PULSE_COUNT záznamů a máme hotovo hotovo=1; // dáme vědět hlavní smyčce že pole je plné dat } INFO_L; }
Nejprve se pojďme podívat na chování naší aplikace na oscilogramech. Na prvních z nich můžeme vidět celkovou práci programu. Přichází spouštěcí signál (červená), na to reaguje TIM3 (světle modrý pulz) a spouští kontinuální AD převod. Na žlutém kanál (měřený pulz) se objevují malé peaky značící že v těchto okamžicích probíhá vzorkování. Po skončení převodu se aplikace připravuje na další sběr a tuto činnost můžete vidět na tmavě modrém průběhu. Můžete si všimnout že doba "přípravy" k dalšímu měření je dvojí, krátká a dlouhé. Krátké doby odpovídají přípravě na další ze 16ti pulzů v rutině přerušení. Dlouhá doba přípravy pak odpovídá situaci kdy je skončen sběr všech 16ti pulzů (na tento okamžik si pak dáme do zdrojového kód breakpoint a stáhneme si surová data).
Další oscilogram ukazuje jeden z limitů naší aplikace. Reakce našeho timeru je drobně opožděná a zatížená jitterem, který vzniká synchronizací vnější asynchronní události (příchod spouštěcího pulzu) s vnitřním clockem čipu. Tento jitter je ale malý v rámci jednoho tiku 48MHz clocku, tedy cca 21ns. I tak je ale reakce timeru opožděná, neboť signál musí projít detektorem hran a digitálním filtrem. Zpoždění za time je tedy mezi 100-140ns. Reakce ADC je o poznání pomalejší (což se dá očekávat neboť je taktováno nižším kmitočtem než timer). První převod se zahájí přibližně za 1.2us a protože se TRGO signál z timeru musí synchronizovat s asynchronním clockem AD převodníku, vzniká další jitter o velikosti jednoho taktu, tedy 1/14M = ~71ns.
Na posledním oscilogramu si jen ověříme vzorkovací frekvenci 1Msps pro 12 bitový převod.
Poslední co nám zbývá je podívat se jak moc se naší aplikaci povedlo tvar pulzu zaznamenat. Musíme tedy data z aplikace nějak vyčíst. Přirozeně bychom si je mohli poslat třeba UARTem do terminálu, ale to je pro vývoj zbytečně zdlouhavé. Využijeme tedy příkaz (a teď mě opravte jestli se mýlím) pro GDB server. Příkazem print /u pulses vypíšeme obsah pole pulses v podobě dekadického čísla (to se hodí až budete vypisovat 8bit proměnné). Data z řádku můžeme pomocí copy-paste přenést do textového souboru a zpracovat dle potřeby. Já ke zpracování použil octave/matlab a skript přikládám na konci článku. GDB se snaží výpis zkracovat, takže pokud je v datech za sebou řada stejných hodnot využije formulace např.
0 <repeats 30 times>
Tuto vlastnost lze vypnout, ale bohužel jsem zapomněl jak. Pokud však data zpracováváte pomocí Octave nebo Matlab, tak vám to nemusí vadit, neboť výraz lze snadno nahradit pomocí "najdi-nahraď" na
0 *ones(2,30)
což Octave spolehlivě spolkne :)
Rekonstruované pulzy pak vypadají následovně. Při 12bit rozlišení se nám kvůli menší vzorkovací frekvenci nedaří zachytit pulz dostatkem bodů, ale o to kvalitněji vidíme pomalejší děj přicházející po pulzu. Naopak puožitím 6bit rozlišení získáme o něco detailněji průběh pulzu, ale pozvolné změny za ním už hrubě trpí kvantovací chybou.
Doufám že jste se u čtení dozvěděli něco nového a že snad někdy někdo něco z toho použije v reálné praxi :D
Home
| V1.00 6.4.2020 /
| By Michal Dudka (m.dudka@seznam.cz) /