Ratkaistu: / tehtävää

Bittioperaatiot C-kielessä

Osaamistavoitteet: Tämän materiaalin läpikäytyäsi tiedät miksi bittioperaatiot ovat tärkeitä laiteläheisessä ohjelmoinnissa ja miten niitä käytetään C-kielessä.

Muistiinkuvattu I/O

Sulautetuissa järjestelmissä kommunikaatio oheislaitteiden kanssa voidaan hoitaa kahdella eri tavalla: Muistiinkuvatun I/O ja Port-Mapped I/O.
Port-Mapped I/O -järjestelmässä oheislaitteisiin päästään käsiksi erillisessä osoiteavaruudessa, joka on erotettu järjestelmän päämuistista. Tämä tarkoittaa, että prosessori käyttää erityisiä I/O-käskyjä (esim. IN ja OUT -käskyjä x86-arkkitehtuurissa) kommunikoidakseen laitteiden kanssa. Jokaiselle laitteelle annetaan ainutlaatuinen I/O-porttiosoite, ja prosessori lukee tai kirjoittaa näihin portteihin laitteiden kanssa toimiessaan.
Yleisin tapa kommunikoida oheislaitteiden kanssa on kuitenkin Muistiinkuvatun I/O:n avulla (engl. Memory mapped Input/Output).
Sulautetuissa järjestelmissä yleisin tapa kommunikoida oheislaitteiden kanssa on kuitenkin muistiinkuvattu I/O (engl. Memory-Mapped I/O). Muistamme aiemmasta tietokonearkkitehtuurin käsittelystä, että suoritin kommunikoi muistin ja oheislaitteiden kanssa väylien kautta. Suoritin asettaa osoitteen osoiteväylälle, siirtää dataa dataväylän kautta ja käyttää ohjaussignaaleja ilmaisemaan toimintoja, kuten lukemista ja kirjoittamista.
Muistiinkuvatussa järjestelmässä kaikki suorittimen muistiosoiteavaruuden osoitteet eivät vastaa RAM-muistia. Osa osoitealueista on sen sijaan varattu oheislaitteille. Kun suoritin lukee tällaisesta osoitteesta tai kirjoittaa siihen, osoitteenpurku- ja ohjauslogiikka ohjaa operaation järjestelmän väylien kautta vastaavalle oheislaitteelle RAM-muistin sijaan.
C-ohjelman näkökulmasta oheislaitteen käyttäminen voi tämän vuoksi näyttää hyvin samankaltaiselta kuin muistissa olevan arvon käyttäminen. Näin oheislaitteen käyttöön näkyviin asetettuja laitteistopaikkoja kutsutaan yleisesti oheislaite-rekistereiksi.
GPIO port mapping in RP2040.
RP2040 architecture overview. The processor cores access SRAM and memory-mapped peripherals through the system buses. Peripheral registers occupy defined regions of the processor address space, while GPIO signals are routed through the GPIO muxing and pad-control logic to the physical pins.
Ja nyt voimme esimerkiksi lähettää viestin oheislaitteelle kirjoittamalla tietoa tarkoitukseen varattuun muistipaikkaan ja ohjauslogiikka hoitaa viestin perille oheislaitteelle. Vastaavasti viestit oheislaitteelta tietokoneelle voidaan lukea vastaavan muistipaikan kautta, kun ohjauslogiikka on hoitanut viestit näkyville varattuun muistipaikkaan. Hyvin yleinen tapa on ohjata oheislaitteita näiden muistipaikkojen kautta, esimerkiksi asettaa LCD-näytön taustavalo päälle muuttamalla ohjausbittien arvoja tarkoitukseen varatussa muistipaikassa. Siksi bittioperaatiot.
Tällaiset muistipaikat on (sulautetussa) järjestelmässä usein vakioitu ja kehitysympäristöjen valmiit kirjastot tarjoavatkin apuja, kuten valmiiksi esiteltyjä vakioita ja muuttujia, niiden käyttöön ohjelmissa.
Esimerkki. LCD-näytön ohjaus muistiinkuvatun I/O:n avulla.
Oletetaan, että näytöllä on rekistereitä, joiden yksittäiset bitit vastaavat kuvassa esitettyjä signaaleja. Rajapinta voitaisiin esimerkiksi toteuttaa seuraavasti:
Oleellinen ajatus on, ettei ohjelma käsittele fyysisiä signaalijohtimia suoraan. Sen sijaan ohjelma lukee ja kirjoittaa rekisterien bittejä, ja laitteisto yhdistää rekisterien arvot vastaaviin signaaleihin.
Nyt vain tarvitsisi esitellä ohjelmassa joko 8-bittiset muuttujat tai 16-bittinen muuttuja, jotka osoittavat haluttuun muistipaikkaan (miten tämä tapahtuu, kerrotaan myöhemmässä luentomateriaalissa). Sitten voisimme ohjelmassa näiden muuttujien avulla ohjata näyttöä ja kirjoittaa siihen tekstiä. Mutta, palataan tähän esimerkkiin hetken päästä.
Hox! Oheislaiterekisterien arvot voivat muuttua ohjelmasta riippumatta, koska rekisterit ovat yhteydessä laitteistoon. Tämän vuoksi muistiinkuvattuja rekistereitä käsitellään C-kielessä tyypillisesti volatile-määreen avulla. Se kertoo kääntäjälle, että arvo voi muuttua odottamattomasti eikä rekisteriin kohdistuvia luku- tai kirjoitusoperaatioita saa optimoida pois tavallisen muuttujan tavoin.
Palaamme muistiinkuvattujen rekisterien tarkkaan C-kieliseen käsittelytapaan myöhemmin kurssilla.

C-kielen bittioperaatiot

Bittioperaatioihin C-kielessä kuuluvat digitaalitekniikasta johdetut loogiset operaattorit AND, OR, NOT, XOR ja lisäksi bittien siirto-operaatiot (engl. shift operations).

Siirto-operaatiot

C-kielessä bittien siirto-operaattorit ovat:
Esimerkkejä.
int8_t x = 5;    // muuttuja x arvo 5 (eli 00000101)
x = x << 1;      // siirto MSB:tä kohti yhden bitin verran
                 // arvo muuttuu 00000101 -> 00001010 = 10
int8_t x = 5;         
x = x << 2;      // arvo muuttuu 00000101 -> 00010100 = 20

int8_t x = 23;   // x:n arvo 23 (00010111)
x = x >> 2;      // giljotiini pudottaa alimmat bitit pois
                 // arvo muuttuu 00010111 -> 00000101 = 5

int8_t x = 118;  // x = 01110110 = 118
x = x << 1;      // arvo muuttuu 01110110 -> 11101100
                 // 2-komplementtiluvun merkkibitti vaihtui
                 // joten x:n arvio onkin nyt -20
Hox! Arvon siirtäminen yhden bitin verran MSB:tä kohti vastaa kertomista kahdella, kunhan merkittäviä bittejä ei siirry pois luvusta. Yhden bitin siirto LSB:tä kohti vastaa kokonaislukujakoa kahdella siten, että vähiten merkitsevä bitti häviää.
Esimerkki. Nyt näytölle on varattu yksi 16-bittinen muuttuja uint16_t lcd, josta haluamme lukea näytölle syötetyn merkin arvon (D7-D0). Tällöin meidän täytyy siirtää muuttujan bittejä, niin että tiputamme ohjaussignaalit pois ja saamme luettua muuttujasta 8-bittisen data-arvon.
Tässä tarvittava bittioperaatio on lcd = lcd >> 3;, eli bittisiirto oikealle niin että bitit E, RW ja RS häviävät giljotiinissa.
Katsotaanpa miltä bittisiirto näyttää avattuna. Tässä d tarkoittaa vastaavaa databittiä (D7-D0) ja x sitä ettemme välitä kyseisen bitin arvosta. Nyt siis bitit E, RW ja RS eivät kiinnosta.
// Bittisiirto avattuna
  ddddddddxxx lcd
         >> 3 
  -----------         
  000dddddddd lcd         
Lopputuloksena meillä on jäljellä vain databitit D7-D0, joiden paikka on muuttunut niin että D0 (eli datan pieni bitti) vastaa muuttujan lcd pienintä bittiä (eli LSB:tä). Näin muuttujassa on tallella vain haluttu arvo.

Loogiset bittioperaatiot

Ohjelmoinnin Alkeet- ja digitaalitekniikan kursseilta tutuista loogisista operaatioista (AND, OR, XOR, NOT) bittioperaatiot erottaa se, että ne tehdään (binääri)luvun jokaiselle bitille erikseen, eikä esimerkiksi tarkastella koko muuttujan totuusarvoa sellaisenaan (Totuusarvoista lisää myöhemmin).
Loogisten bittioperaatioiden syntaksi C-kielessä on:
  00001111 = 15
& 10101010 = -86
  --------
  00001010 = 10   
  00001111 
| 10101010
  --------
  10101111
  00001111 
^ 10101010
  --------
  10100101
~ 00001111 
  --------
  11110000
Hox! Jos käytät etumerkillisiä lukuja, ole varovainen! Bittioperaatiot voivat muuttaa luvun merkkibittiä ja siten myös luvun etumerkkiä.
Hox! On hyvin tavallista C-koodissa sekoittaa &-operaattori (bittioperaatio AND) ja looginen &&-operaattori (looginen AND).

Bittimaskit

Okei, bittioperaatioilla pääsemme todella pitkälle sulautettujen laitteiden ohjelmoinnissa, kun otamme vielä käyttöön apuvälineen nimeltä bittimaski. Bittimaski tarkoittaa (binääri)lukua, jolla merkitsemme halutut bitit, jolloin maskin avulla kohdistamme bittioperaation vain haluttuihin bitteihin!
Noh, mikä tässä on ongelma? Miksei voida vain sijoittaa muuttujaan uusi haluttu arvo? Katsotaanpas. Ylläolevaa näyttöä ohjatessa haluaisimme esimerkiksi muuttaa vain bitin E arvoa ja jättää muut ennalleen. Voimme toki tehdä tämän niin, että ensin selvitämme muuttujasta bitin E arvon (onko se 1 vai 0), vaihdamme arvon ohjelmakoodissa (1 -> 0 tai 0 -> 1) ja kirjoitamme muokatun arvon takaisin muuttujaan. Ja tilannehan eskaloituu kunnolla, jos haluamme käsitellä yo. muuttujasta useita bittejä ja niiden arvo(-kombinaatiot) pitäisi yksitellen selvittää. Nyt bittimaskit tarjoavat nokkelan keinon merkitä halutut bitit ja muuttaa niiden arvoa suoraviivaisesti.
Esimerkki. Haluamme käsitellä 8-bittisestä bittejä 1. ja 6., jolloin maski on seuraava.
bitti 76543210
      --------
maski 00100010
Bittimaski voidaan luoda käyttämällä suoraa vakioarvoa:
uint8_t maski = 0x22;
tai bittisiirtojen avulla:
uint8_t maski = (1 << 5) | (1 << 1);
OR-operaatio voidaan avata seuraavasti:
  00100000  (1 << 5) = 0x20
| 00000010  (1 << 1) = 0x02
  --------
  00100010 = 0x22
Sama maski voidaan määritellä myös makrona:
#define MASKI 0x22
tai:
#define MASKI ((1 << 5) | (1 << 1))
Esimerkki. Palataanpa yo. näyttöön ja laaditaan sille data- ja ohjausbittejä vastaavat maskit.
D7-D0: MASK_DATA -> 11111111000 = 0x7F8
E:     MASK_E    -> 00000000100 = 0x4
RW:    MASK_RW   -> 00000000010 = 0x2
RS:    MASK_RS   -> 00000000001 = 0x1
Esimerkki. Luodaan kaikkia ohjausbittejä vastaava muuttuja ohjausmaski.
uint16_t ohjausmaski = MASK_E | MASK_RW | MASK_RS;
// ..toisin sanoen ilman vakioita
uint16_t ohjausmaski = 0x4 | 0x2 | 0x1;

// Nyt siis TAI-operaatio
  00000100 MASK_E
| 00000010 MASK_RW
| 00000001 MASK_RS
  --------
  00000111 ohjausmaski

Bittimaskin käyttö

Bittimaskilla voidaan tehdä muuttujalle/rekisterille erilaista muokkauksia yhdistämällä C-kielen bittioperaatioita yhdessä tai useammassa lauseessa.
Oletetaan nyt 8-bittinen rekisterimuuttuja lcd (pelkästään ohjaussignaalit). Seuraavissa esimerkeissä x tarkoittaa merkintää 'don't care': bitin nykyinen arvo voi olla joko 0 tai 1.Bittimaskin avulla valitaan bittipaikat, joita haluamme käsitellä. Maski itsessään ei määritä, mitä näille biteille tapahtuu. Maskin kanssa käytetty bittioperaatio määrittää, asetetaanko, nollataanko, vaihdetaanko vai irrotetaanko valitut bitit. Kahdessa ensimmäisessä alla olevassa esimerkissä operaatiot on muodostettu siten, että maskin ulkopuoliset bitit säilyvät muuttumattomina.
1. OR-operaatio asettaa maskatut bitit päälle (tässä loogiseen tilaan 1)
Esimerkki yo. näytöllä, kun asetetaan bitti E (Enable) päälle, koskematta muiden bittien tilaan.
lcd = lcd | MASK_E;  // vastaava lyhyempi muoto lcd |= MASK_E; 

// Operaatio avattuna:
  xxxxxxxx lcd (x = dont care)
| 00000100 MASK_E
  --------
  xxxxx1xx lcd
Lopputuloksena, muuttujasta lcd vain maskin osoittama bitti pakotettiin tilaan 1 ja muut jäivät siihen tilaan x missä olivatkin. Kuten yllä esitettiin, TAI-operaatio 0:n kanssa ei muuta bitin arvoa.
2. Yhdistetyt AND- ja NOT-operaatiot asettavat maskatut bitit pois päältä (tässä loogiseen tilaan 0)
lcd = lcd & ~(MASK_E);  // vastaava lyhyempi muoto lcd &= ~(MASK_E);

// MASK_E:n negaatio
// Tässä esitetään vain tarkastelun kannalta olennaiset 8 bittiä.
~ 00000100 MASK_E
  --------
  11111011 ~MASK_E 

// JA-operaatio muuttujan kanssa
  xxxxxxxx lcd
& 11111011 ~MASK_E
  --------
  xxxxx0xx lcd
Lopputuloksena, muuttujasta lcd vain maskin osoittama bitti pakotettiin tilaan 0 ja muut jäivät siihen tilaan x missä olivatkin. Kuten yllä esitettiin, JA-operaatio 0:n kanssa muuttaa aina bitin arvon 0:ksi.
3. Bittien "irrotus" maskin avulla
Kun haluamme tarkastella yksittäisen tai useamman bitin arvoa, tarvitsemme keinoja bittien irrotukseen muuttujasta arvosta. Tämäkin onnistuu maskien avulla yhdistämällä bittioperaatioita.
// tarkastellaan bitin e arvoa
uint16_t enable = (lcd & MASK_E) >> 2;

// JA-operaatio näyttömuuttujan kanssa
  xxxxxxxx lcd
& 00000100 MASK_E
  --------
  00000e00 enable (e:n arvo joko 0 tai 1)

// siirto-operaatio
  00000e00 enable
      >> 2 
  --------
  0000000e enable
Tämän jälkeen voimme ehtolauseella tarkistaa onko enable-muuttujan arvo 0 tai 1 (joka riippuu siitä mikä on bitin e arvo, kun muut ovat nollia) arvo. Huomataan, että tässä riittäisi tarkastella pelkästään muuttujan arvoa ilman bittisiirtoa, koska jos e=1, niin muuttujan enable arvo > 0 aina.

C-kielen operaattorien suoritusjärjestys

Ylläolevissa esimerkeissä käytimme jo useita eri operaatioita samassa C-kielen lauseessa, esimerkiksi sijoitus, bittisiirto/looginen operaatio ja negaatio. No, jotta hommassa ei sotkeutuisi pahan kerran, on juuri tässä kohden hyvä esittää operaatioiden yleinen suoritusjärjestys C-kielessä.
Ja jotta ao. taulukkoa ei tarvitsisi opetella ulkoa, aina on parempi käyttää sulkeita kertomaan haluttu operaattorien suoritusjärjestys, tekemään koodista luettavaa ja pitämään hyönteiset poissa ohjelmasta.
"C-kielen operaattorit"
Edellinen taulukko on saatu tästä lähteestä (Wei-keng Liao).
Taulukossa suoritussuunta tarkoittaa sitä, että jos meillä on saman prioriteetin operaattoreita useampi samassa lauseessa, niin miten päin niiden suoritusjärjestys tulkitaan, oikealta vasemmalle vai vasemmalta oikealle?
Taulukossa esitetty assosiatiivisuus kertoo, miten saman prioriteetin operaattorit ryhmitellään lausekkeessa: joko vasemmalta oikealle tai oikealta vasemmalle.
4. Bittien tilan vaihtaminen XOR-operaatiolla
XOR-operaatiolla voidaan vaihtaa valittujen bittien tila päinvastaiseksi:
Maskin ulkopuoliset bitit säilyvät muuttumattomina, koska XOR-operaatio nollan kanssa säilyttää alkuperäisen bitin arvon.
Esimerkki: Vaihdetaan bitin E tila:
lcd ^= MASK_E;

// Operaatio avattuna:
  xxxxx1xx lcd
^ 00000100 MASK_E
  --------
  xxxxx0xx lcd
Tässä !e tarkoittaa bitin e vastakkaista arvoa.

Lopuksi

Tämä materiaali tarjosi meille tärkeitä eväitä sulautettujen ohjelmointiin, sillä bittimaskit ovat yksi yleisimmin käytetyistä C-kielen keinoista ohjata oheislaitteita.
Usein eri oheislaitteiden kirjastot piilottavat nämä bittioperaatiot nätimpien funktiokutsujen alle, mutta kirjastojen lähdekoodia tarkastellessa sieltä alta ne kyllä löytyvät.
?