18.1 Memory-mapped I/O alapelve

18.1.1 Motiváció

Egy beágyazott rendszerben a szoftver gyakran nem „számol”, hanem eszközökkel beszél: LED-et kapcsol, UART-on küld, timert indít. A Cortex-M-en ezt memory-mapped I/O (MMIO) néven tesszük: a periféria regiszterei a 32 bites címtér részei. Az a063 alfejezetben láttuk, hogy a 0x40000000-tól kezdődő Peripherals régió a perifériáké. Ha azonban ezeket a címeket ugyanúgy kezeljük, mint egy SRAM-változót, hibás kódot kapunk. Egy perifériaregiszter ugyanis nem passzív memóriacella: minden olvasása és írása mellékhatással járhat. Ez az alfejezet megmutatja, miért viselkedik másképp, és milyen programozási fegyelmet kényszerít ez ránk.

Támogatási mátrix (a fejezet és a II. záróprojekt laborjaihoz). Nem minden periféria fut minden platformon. A könyv alapútja a QEMU mps2-an385; a GPIO-laborok ezen nem futtathatók:

Periféria QEMU 8.2.2 mps2-an385 Fizikai MPS2+ kártya AN385 FPGA-képpel A könyv útja
CMSDK UART0 (0x40004000) implementálva van alapút (QEMU)
CMSDK Timer0 (0x40000000) implementálva van alapút (QEMU)
NVIC, SysTick (PPB) implementálva van alapút (QEMU)
CMSDK GPIO (0x40010000–) nincs implementálva van csak támogatott hardveren; QEMU-n SKIP

A QEMU-n nem implementált perifériánál a regiszter visszaolvasása semmit nem bizonyít: egy nem modellezett tartomány tipikusan 0-t olvas és eldobja az írást (vagy egy egyszerű RAM-modell épp a beírt értéket adja vissza), és egyik sem jelenti, hogy egy láb ténylegesen kapcsolt. A GPIO-t használó laborok (a073, a074) ezért egy LAB_HAVE_GPIO build-kapcsolóval futnak. QEMU-n ennek értéke 0, és a futó próba SKIP-et ír ki, nem feltételezett sikert. A fordítási és disassembly-ellenőrzések (címek, offsetek, generált utasítások) ettől függetlenül QEMU nélkül is elvégezhetők. A II. záróprojekt QEMU-alapútja ugyanezért UART + timer (a078).

18.1.2 Definíció: memory-mapped I/O és perifériaregiszter

Definíció (memory-mapped I/O, MMIO): olyan I/O-modell, amelyben a perifériák vezérlő-, állapot- és adatregiszterei a processzor normál adatcímtérbe vannak leképezve; a processzor ugyanazokkal a load/store utasításokkal éri el őket, mint a memóriát, de a hozzáférés hatással lehet a periféria állapotára.

Definíció (perifériaregiszter): egy címmel azonosított, tipikusan 32 bites (vagy 8/16 bites nézetű) hardveres tároló, amelynek olvasása/írása nem csak adatcserét, hanem hardveres műveletet is kiválthat.

A kulcs: a cím mint memóriacím viselkedik, de a szemantika nem az. Az a073 alfejezetben ezt konkrét GPIO-vezérlésre fordítjuk le, az a074 pedig C struktúrával absztrahálja.

18.1.3 Miért nem közönséges memória?

A processzor és a memóriarendszer optimalizál a közönséges memóriára: gyorsítótár (cache), írási puffer, soron kívüli végrehajtás, spekulatív betöltés, hozzáférés-összevonás. Ezek mind feltételezik, hogy egy cím olvasása nem változtat állapotot, és hogy két azonos olvasás ugyanazt adja, amíg senki nem ír. Egy perifériaregiszternél ez nem igaz.

Az ARMv7-M architektúra ezért a címtér régióit memóriatípusokba sorolja. A normál SRAM/Flash „Normal” memória, amely cache-elhető, újrarendezhető. A perifériaterület jellemzően „Device” vagy „Strongly-ordered” típusú. A Device memória:

A Cortex-M3 mag jellemzően nem is tartalmaz adatcache-t, de az írási puffer és a busz miatt a sorrend és a hozzáférések száma akkor is számít. Az architektúra-szintű garancia: a Device/Strongly-ordered minősítés biztosítja, hogy a hozzáférések ténylegesen megtörténjenek, és hogy a Device-hozzáférések egymáshoz képest a programsorrendben maradjanak. Ez nem jelenti, hogy egy Device-írás minden más (például Normal memóriába tett) hozzáféréshez képest is sorrendben marad; erre a gát utasítások valók (lásd lent).

Miért tilos a gyorsítótárazás? Ha a periféria állapotregiszterét cache-elnénk, a cache egy régi értéket adna vissza, miközben a hardver már változott. Ha egy írást cache-elnénk, az írás esetleg sosem jutna el a perifériáig, vagy késleltetve, más sorrendben. Ez hibás működés.

Miért tilos az átrendezés? Tegyük fel, hogy előbb beírjuk az adatregisztert, majd beállítjuk a „start” bitet. Ha a processzor a két írást felcseréli, a periféria a régi adatot küldi el. Device memóriánál az architektúra megőrzi a programozási sorrendet erre a területre.

18.1.4 A perifériaregiszter négy tipikus szerepe

A CMSDK-szerű perifériák regiszterei négy csoportba sorolhatók. A pontos nevek perifériánként változnak, de a szerepek visszatérnek.

Szerep Tipikus név Olvasás Írás Példa
Adat DATA, RX, TX bejövő adat kimenő adat UART RX/TX, GPIO adat
Engedélyezés CTRL, ENABLE engedélyezettség művelet indítása/engedélyezése timer start, megszakítás engedélyezés
Állapot STATUS, RIS aktuális állapot/flagek flag törlése (gyakran write-1-to-clear) TX FIFO tele, RX adat elérhető
Konfiguráció CFG, BAUDDIV, DIR beállított érték beállítás baud rate, GPIO irány

Ez a négyes fontos, mert a programozási minta más-más szerepnél más. Adatregiszternél az érték számít; állapotnál a flag szemantikája (olvasás töröl? írás töröl?); engedélyezésnél a bit beállítása műveletet indíthat; konfigurációnál az érték beállítása a periféria viselkedését változtatja.

18.1.5 volatile C-ben, és miért nincs ilyen probléma assemblyben

A periféria-hozzáférés helyességét három külön réteg adja. Egyikből sem következik a másik kettő, és egyik sem ad univerzális sorrendet minden memóriaelérésre.

Réteg Ki garantálja Mit garantál Mit nem
volatile (fordítói szerződés) a C fordító minden volatile hozzáférés megtörténik, nem vonható össze, nem hagyható el, és a volatile hozzáférések egymáshoz képest programsorrendben maradnak a generált kódban a nem volatile hozzáférések átrendezhetők körülötte; semmit nem mond a hardveres sorrendről, és nem ad atomitást
Device memóriaattribútum (architektúra) a mag és a buszrendszer a Device-hozzáférések nem spekulatívak, nem vonódnak össze, és egymáshoz képest sorrendben érik el a perifériát a Normal memóriához (például egy RAM-pufferhez) képesti sorrendet nem garantálja; azt sem, hogy egy írás befejeződött, mielőtt a következő utasítás fut
DMB / DSB / ISB (gát utasítások) a mag, explicit utasításra DMB: a gát előtti memóriaelérések megfigyelhetően a gát utániak előtt történnek; DSB: a következő utasítás csak a korábbi elérések befejeződése után fut; ISB: a pipeline-t üríti, a későbbi utasítások az új állapotot látják (például a rendszerregiszter-írás után) magától nem keletkezik; a fordító sem rak be ilyet a volatile miatt

A C fordító a C absztrakt gépet optimalizálja. Azt feltételezi, hogy egy változó értéke csak akkor változik, ha a program írja, és hogy az ismételt olvasás ugyanazt adja, ha közben nincs írás. Ezért egy while (*reg & MASK) ; ciklusból könnyen if (*reg & MASK) while (1) ; lesz, vagy a *reg bekerül egy regiszterbe, és a ciklus örökké a régi értéket vizsgálja. A volatile minősítő az első réteg: minden hozzáférés valódi, nem elhagyható, és nem tartható regiszterben. A volatile uint32_t * tehát nem stílus kérdése, hanem funkcionális követelmény.

Assemblyben az első réteg nem kérdés, mert az assembler nem optimalizál: amit leírtunk, az pontosan az utasítássorozat. Egy LDR r1, [r0] egy olvasás; ha a ciklusban újra leírjuk, újra megtörténik. A második és a harmadik réteg viszont assemblyben is ugyanúgy érvényes: a Device-szabályok a mag szintjén hatnak, a gátakat pedig nekünk kell kiírni, ahol kellenek. Jellemző eset: egy RAM-ban előkészített puffer után egy periféria „start” regiszterébe írunk. Ekkor a két írás közé DMB kell, mert a Normal és a Device hozzáférés közti sorrendet sem a volatile, sem a Device-attribútum nem garantálja. Egy NVIC- vagy VTOR-írás után pedig DSB + ISB biztosítja, hogy a következő utasítások már az új beállítással fussanak (NVIC-konfiguráció).

18.1.6 Kidolgozott példa: UART TX flag pollozása és GPIO írás

Munkakönyvtár: ~/arm-labor/cm/a072/ — az alfejezet minden laborfájlja ide kerül, és minden parancs innen futtatandó (a könyvtárrendet a Labor-alapkörnyezet alfejezet írja le).

mkdir -p ~/arm-labor/cm/a072
cd ~/arm-labor/cm/a072

A példa a QEMU mps2-an385 tényleges CMSDK APB UART0 perifériáját használja, nem egy elképzelt eszközt. A CMSDK-nál az állapotregiszter (STATE, +0x004) 0. bitje a TXFULL: 1 jelenti, hogy az adópuffer tele, tehát amíg 1, várunk, és 0-nál írhatunk. (Egy elképzelt „TX_READY = 1 kész” bit pont fordított polaritású lenne; a két minta keverése a klasszikus hiba, ezért itt csak a valódi bitet használjuk.) Az adó használatához a BAUDDIV és a CTRL.TXEN beállítása is kell (előfeltételek).

Assembly (Thumb-2, Armv7-M):

@ lab-a072-uart.s — Thumb-2 / Armv7-M, QEMU mps2-an385, CMSDK APB UART0
    .syntax unified
    .thumb

    .equ UART0_BASE,   0x40004000      @ MPS2-AN385 UART0
    .equ UART_DATA,    0x000           @ adatregiszter
    .equ UART_STATE,   0x004           @ bit0: TXFULL, bit1: RXFULL
    .equ UART_CTRL,    0x008           @ bit0: TXEN
    .equ UART_BAUDDIV, 0x010           @ legalább 16
    .equ UART_TXFULL,  (1 << 0)

    .text
    .thumb_func
    .global uart_init
uart_init:
    ldr  r1, =UART0_BASE
    movs r2, #16
    str  r2, [r1, #UART_BAUDDIV]
    movs r2, #1                        @ TXEN
    str  r2, [r1, #UART_CTRL]
    bx   lr

    .thumb_func
    .global uart_putc
    @ r0 = küldendő bájt
uart_putc:
    ldr  r1, =UART0_BASE
wait_tx:
    ldr  r2, [r1, #UART_STATE]         @ állapotregiszter olvasása: minden körben valódi LDR
    tst  r2, #UART_TXFULL
    bne  wait_tx                       @ amíg TXFULL = 1, várunk
    str  r0, [r1, #UART_DATA]          @ adatregiszter írása: elindítja a küldést
    bx   lr

Itt a wait_tx ciklus minden iterációban valódi LDR-t hajt végre. A STR írás mellékhatása: a periféria megkezdi a bájt kiküldését. Ha az LDR-t kihagynánk, vagy a STR-t az állapotellenőrzés elé tennénk, hibás működés lenne. Az adatregisztert szóhosszú STR-rel írjuk; a regiszter alsó 8 bitje az adat.

C változat volatile-lal:

/* lab-a072-uart.c — ugyanaz C-ben, volatile mutatókkal */
#include <stdint.h>

#define UART0_BASE    0x40004000u
#define UART_DATA     (*(volatile uint32_t *)(UART0_BASE + 0x000u))
#define UART_STATE    (*(volatile uint32_t *)(UART0_BASE + 0x004u))
#define UART_TXFULL   (1u << 0)

void uart_putc_c(uint8_t b)
{
    while (UART_STATE & UART_TXFULL) {
        /* amíg TXFULL = 1, várunk: minden iterációban valódi olvasás */
    }
    UART_DATA = b;   /* írás: elindítja a küldést */
}

A volatile nélkül a fordító a while ciklus olvasását kiemelhetné, és a ciklus egyszer olvasná a UART_STATE-et. -O2-vel fordítva a generált kódban a cikluson belül ott a ldr r2, [r1] (a fordító a ciklust kissé át is szervezi, de minden kör olvas).

A két változatot a kanonikus startup/linker párral (a065, a066) egy rövid tesztprogramba linkelve, a helyettesítő eszközlánccal (Clang + ld.lld) és a tools/cm-run.py Cortex-M3 emulátorral futtatva mindkét szöveg megjelent a UART0 kimenetén. Az emulátor STATE regisztere mindig 0 (a puffer sosem telik meg), ezért a várakozó ágat ott nem lehetett kipróbálni.

18.1.7 Tipikus hibák és ellenpróbák

  1. Elfelejtett volatile. Tünet: a program a vártnál előbb kilép a ciklusból, vagy örökké vár. Hipotézis: a fordító kiemelte az olvasást. Adatgyűjtés: objdump -d a C objektumon, a ciklusban keresd a ldr utasítást. Ha nincs, ez a hiba. Egy változtatás: volatile hozzáadása. Ellenőrzés: a disassemblyben megjelenik a cikluson belüli betöltés.

  2. Írások összevonása. Tünet: két egymást követő írásból csak az utolsó érvényesül. Hipotézis: a periféria nem Device memóriaként van kezelve, vagy a fordító optimalizálta el az elsőt. C-ben a volatilis írások nem vonhatók össze; assemblyben a hardver Device-szabályai védenek. Ellenpróba: a két írás közé tegyünk egy olvasást, és nézzük meg, megmarad-e mindkét írás.

  3. Nem megfelelő szélesség. Egy 32 bites regiszterhez STRB-vel írni a CMSDK-nál nem garantáltan támogatott, vagy más hatása lehet. A fenti példa ezért szóhosszú STR-t használ. Ellenpróba: a CMSDK dokumentációban ellenőrizni a regiszter szélességét és a támogatott hozzáférést.

  4. Fordított polaritású állapotbit. Tünet: a küldés minden karaktert eldob, vagy örökké vár. Hipotézis: a kód „ready = 1” bitet feltételez, a CMSDK STATE.TXFULL viszont 1-nél jelzi, hogy nem lehet írni. Ellenpróba: a ciklus feltétele bne wait_tx (amíg TXFULL = 1), nem beq.

  5. Hiányzó gát Normal és Device hozzáférés között. Tünet: a periféria egy RAM-pufferből régi adatot dolgoz fel. Hipotézis: a pufferírás és a „start” regiszter írása között nincs DMB. Ellenpróba: DMB beszúrása a két írás közé; a volatile önmagában ezt nem pótolja.

18.1.8 Feladatok

1. Magyarázó feladat. Magyarázd el, miért nem cache-elhető és miért nem rendezhető át egy perifériaregiszter hozzáférése. Adj példát arra, mi történne, ha a wait_tx ciklus LDR-jét a processzor egyszer olvasná, és a ciklus a regiszterben tartaná az értéket.

2. Implementációs feladat. Írj Thumb-2 assembly rutint, amely beolvassa a GPIO0 adatregiszterét, majd írj C függvényt volatile mutatóval, amely ugyanezt teszi. A címeket a periféria-struktúra kanonikus lab-cmsdk-gpio.h térképéből vedd. A támogatási mátrix szerint a futtatás csak támogatott hardveren érdemi; QEMU-n a fordítás és a disassembly ellenőrizhető.

3. Hibakeresési feladat. Adott egy C kód, amely volatile nélkül polloz egy állapotbitet, és a program a QEMU-ban nem lép ki a ciklusból. Írd le a diagnózis lépéseit: mit néznél meg objdump -d-vel, mit várnál a volatile hozzáadása után, és hogyan igazolnád az eredményt?

18.1.9 Megoldások

1. A cache egy gyors másolat, amelyet a hardver állapotváltozásáról nem értesül. Ha a státuszregisztert cache-eljük, a program a régi értéket olvassa, és nem veszi észre, hogy a flag beállt. Az átrendezés akkor okoz gondot, ha az adatírást és a startírást felcseréli a rendszer: a periféria a régi adatot küldi. A wait_tx ciklusban, ha egyszer olvasnánk és regiszterben tartanánk, a ciklus soha nem látná meg a flag változását, mert a regiszter értéke nem frissül; ez végtelen ciklus. Ezért kell minden iterációban valódi olvasás.

2. Assembly:

    .syntax unified
    .thumb
    .equ GPIO0_BASE,  0x40010000      @ MPS2-AN385 GPIO0 (lab-cmsdk-gpio.h)
    .equ GPIO_DATA,   0x000           @ DATA: a lábak bemeneti szintje

    .text
    .global gpio_read_data
gpio_read_data:
    ldr r0, =GPIO0_BASE
    ldr r0, [r0, #GPIO_DATA]
    bx lr

C:

#include <stdint.h>
#define GPIO0_BASE 0x40010000u         /* MPS2-AN385 GPIO0 (lab-cmsdk-gpio.h) */
#define GPIO_DATA  (*(volatile uint32_t *)(GPIO0_BASE + 0x000u))

uint32_t gpio_read_data(void)
{
    return GPIO_DATA;
}

A lényeg a volatile, és hogy az assemblyben az LDR ténylegesen megtörténik.

3. Diagnózis: objdump -d a lefordított objektumon; keresd a ciklus címét és a benne lévő ldr utasítást. Ha a ciklusban nincs ldr a periféria-címre, a fordító kiemelte. Add hozzá a volatile-t, fordítsd újra, és a disassemblyben a cikluson belül megjelenik a betöltés. Ellenőrzés QEMU-ban, a valódi UART0-n: a fenti uart_putc_c kiírja a karaktert, tehát kilépett a várakozó ciklusból. Fontos: a QEMU modell időzítése eltér a fizikai hardvertől (a puffer itt ritkán vagy soha nem telik meg), ezért a várakozó ág viselkedése csak hardveren mérhető.

18.1.10 Rövid összefoglaló

A memory-mapped I/O a perifériaregisztereket a normál címtérbe képezi, de a hozzáférés mellékhatással járhat. Három réteg adja a helyességet: a volatile a fordítót köti (a volatile hozzáférések megtörténnek, sorrendben), a Device-attribútum a hardvert (a Device-hozzáférések egymáshoz képest sorrendben, spekuláció nélkül), a DMB/DSB/ISB pedig ott ad sorrendet vagy befejezést, ahol a másik kettő nem (Normal–Device határ, rendszerregiszter-írás után). Egyik sem ad univerzális sorrendet minden memóriaelérésre. A CMSDK UART STATE.TXFULL bitje 1-nél jelzi, hogy várni kell. A GPIO a QEMU-alapúton nem érhető el; ott a GPIO-próba SKIP. A regiszterek négy szerepe: adat, engedélyezés, állapot, konfiguráció.

Továbbhasznált definíciók: - memory-mapped I/O (MMIO): perifériaregiszterek a normál adatcímtérben, load/store hozzáféréssel. - perifériaregiszter: címmel azonosított hardveres tároló, amelynek olvasása/írása mellékhatással járhat. - volatile: C minősítő, amely garantálja, hogy minden hozzáférés valódi és nem cache-elhető regiszterben.

18.1.11 Források és ellenőrzési hiányok

Az alábbiak dokumentációs kiindulópontok (forrásállapot: nem megnyitott, lásd az Olvasói útmutató jelöléseit):

Ellenőrzési hiányok: - A UART-példa a CMSDK APB UART dokumentált térképét használja (DATA 0x000, STATE 0x004 – bit0 TXFULL, CTRL 0x008, BAUDDIV 0x010); a helyettesítő eszközlánccal lefordítottuk, és a tools/cm-run.py emulátorban kiírt. A támogatási mátrix GPIO-sora a lektor QEMU 8.2.2-es ellenőrzésén és a II. záróprojekt specifikációjának QEMU-korlát megjegyzésén alapul; más QEMU-verziónál info mtree-vel és ténylegesen bekötött eseményforrással kell újraellenőrizni. - Az Armv7-M Device/Strongly-ordered szabályok részletes, oldalszintű idézése ebben a folyamatban nem történt meg. - A QEMU viselkedése nem hardverhű; a mért latenciák és versenyhelyzetek eltérhetnek a fizikai Cortex-M3-tól.