Programowa implementacja I2C w mikrokontrolerach AVR

Magistrala I2C jest chyba najczęściej używanym sposobem wymiany danych pomiędzy układami elektronicznymi. Możemy dzięki niej połączyć ze sobą nawet kilkadziesiąt układów, korzystając tylko z dwóch pinów mikrokontrolera. Do tego sama transmisja jest bardzo prosta w obsłudze.
Problem się jednak pojawia gdy chcemy taki rodzaj transmisji danych zaimplementować w mikrokontrolerach z serii ATtiny. Wiele z nich nie posiada sprzętowego interfejsu I2C, co jednak nie uniemożliwia nam zrealizowania takiej transmisji w wersji programowej.

Biblioteka soft I2C

Zaczniemy od utworzenia pliku nagłówkowego gdzie na samym początku zdefiniujemy podstawowe makrodefinicje.

#define SCLDDR DDRB
#define SDADDR DDRB

#define SCLPORT PORTB
#define SDAPORT PORTB

#define SCLPIN PINB
#define SDAPIN PINB

#define SCL PB0
#define SDA PB1

#define SCL_HIGH SCLPORT |= (1<<SCL)
#define SCL_LOW SCLPORT &= ~(1<<SCL)

#define SDA_HIGH SDAPORT |= (1<<SDA)
#define SDA_LOW SDAPORT &= ~(1<<SDA)

Następnym krokiem będzie utworzenie pliku .c naszej biblioteki. Pierwszą funkcją jaką napiszę będzie funkcja inicjalizacyjna.

#include "soft_i2c.h"
#include <avr/io.h>
#include <util/delay.h>

void soft_i2c_init(void)
{
	SCLDDR |= (1<<SCL);
	SDADDR |= (1<<SDA);
	SCL_HIGH;
	SDA_HIGH;
}

Funkcja po prostu ustawia piny SDA, oraz SCL na wyjścia i od razu ustawia na nich stan wysoki. Ponieważ magistrala jest „nieużywana”, właśnie gdy oba sygnały są w stanie wysokim.

Teraz przejdę do opisywania funkcji odnoszących się strikte do charakterystyki magistrali I2C. Trzeba jednak pamiętać, że I2C może pracować w różnych prędkościach, najczęściej używaną na AVR’ach jest 400 kb/s (Fast speed), a także 100 kb/s (standard speed). Co z tego wynika, transmisja musi przebiegać w ściśle określonych ramach czasowych. Niestety nie bardzo można znaleźć czegoś w rodzaju tabelki z timingami dla konkretnych prędkości. Postanowiłem więc skorzystać z analizatora stanów logicznych i podejrzeć transmisje realizowaną sprzętowo.

Transmisja I2C

Powyższa grafika przedstawia trzy typowe polecenia – start, wysłanie bajtu, oraz stop. W tym przypadku wysłałem liczbę 1, z prędkością 400 kb/s. Moim celem będzie więc odtworzenie transmisji już w sposób programowy.

I2C start

i2c start
I2C start

Na załączonej grafice możesz zauważyć jak wygląda polecenie start. Realizowane jest ono przez ustawienie stanu niskiego na linii SDA, odczekanie ok. 1.3us i ustawienie niskiego stanu na linii SCL.

Poniżej przedstawiam funkcję, która wykonuje polecenie start. Ostatnim poleceniem jest kolejne opóźnienie, spójrz na przebieg całej transmisji powyżej. Zanim wysłany zostanie pierwszy bit, między poleceniem startu występuje pewne opóźnienie.

void soft_i2c_start(void)
{
	SDA_LOW;
	_delay_us(1.3);
	SCL_LOW;
	_delay_us(0.6);
}

I2C stop

Różnicę między poleceniem start, a stop widać na powyższym przebiegu. Jedyne co musimy zrobić to zamienić kolejność zmiany stanów pinów i teraz będziemy nich nich ustawiać stan wysoki.

void soft_i2c_stop(void)
{
	SCL_HIGH;
	_delay_us(1.3);
	SDA_HIGH;
	_delay_us(1.3);
}

Ten dodatkowy delay na końcu jest umieszczony po to by nie występowały jakieś problemy w przypadku wysyłania kilku bajtów jeden-po-drugim.

I2C wysyłanie bajtu

Kolejną podstawową funkcją jest oczywiście wysłanie bajtu danych. Tutaj będzie już nieco więcej kodu, który i tak postaram się dokładnie omówić.

unsigned char soft_i2c_send(unsigned char byte)
{	
	// Sending byte "bit by bit"
	for (unsigned char mask = 128; mask != 0; mask>>=1)
	{
		if (byte & mask) SDA_HIGH;
		else SDA_LOW;
		
		_delay_us(0.4);
		SCL_HIGH;
		_delay_us(1.1);
		SCL_LOW;
	}
	_delay_us(1);
	SDA_HIGH;
	SDADDR &= ~(1<<SDA);
	SCL_HIGH;
	
	byte = SDAPIN & (1<<SDA);
	_delay_us(1.3);
	SCL_LOW;
	SDADDR |= (1<<SDA);
	
	_delay_us(1.3);
	SDA_LOW;
	return (byte == 0);
}

Utworzyłem funkcję, która przyjmuje oraz zwraca zmienną 8-bitową, przekazujemy jej bajt, który chcemy przesłać. Oczywiście, zamiast „unsigned char” można użyć typu „uint8_t” – to dokładnie to samo.
Pierwszym elementem funkcji jest pętla for, która niejako bierze nasz bajt i przesyła go bit po bicie. Utworzyłem w niej zmienną mask, która będzie maską bitową bajtu. Pętla będzie się potarzała do momentu aż mask będzie równe 0. Natomiast każdy krok pętli będzie zmienną mask przesuwał w prawo o jeden bit (znak >>= to coś takiego jak np. += tylko zamiast plusa jest przesunięcie bitowe).
Początkowo zmienna mask ma wartość 128, więc kolejno będzie to: 64, 32, 16, 8, 4, 2, 1.

Robimy to tylko po to, by wydobyć każdy bit przekazanego bajtu. Jeżeli nie wiesz czym się charakteryzuje operacja bitowa „AND”, spójrz na poniższą grafikę.

Załóżmy, że przekazujemy bajt 255. Początkowo maska ma wartość 128, a 128 w zapisie binarnym to „10000000”. Jak możesz zauważyć na grafice, operator „AND” bierze pod uwagę tylko te bity, które są symbolizowane jedynką w masce, a za resztę wstawia 0. To tak bardzo „po krótce”, w Internecie znajdziesz dużo informacji na temat operacji bitowych w języku C.

Więc za pomocą tej pętli po kolei sprawdzamy każdy bit, od najstarszego. Jeżeli ten jest równy 1, ustawiamy stan SDA jako wysoki, w przeciwnym razie jako niski. Następnie sygnałem SCL „zatrzaskujemy” bit, i tak pętla robi z każdym z 8-iu bitów.

Sprawdzałem transmisje z różnymi wartościami opóźnień i dobrałem te, które sprawiają że transmisja najbardziej przypomina tą realizowaną sprzętowo. Udało mi się uzyskać taki wynik.

i2c 400khz
Idealnie 400 KHz!

Po wyjściu z pętli for zostawiłem sygnał SDA w stanie wysokim, a pin na którym jest SDA przełączany zostaje w tryb wejścia. Teraz, zakładając że komunikujemy się z jakimś urządzeniem peryferyjnym będziemy oczekiwali na potwierdzenie transmisji ACK.

byte = SDAPIN & (1<<SDA);

Powyższe polecenie, do zmiennej byte przypisze stan pinu SDA (0/1). Ostatecznie tą zmienną funkcja będzie zwracać w następującej postaci.

return (byte == 0);

Znaczy to tyle, że funkcja zwróci 1 gdy zmienna byte będzie równa 0, lub 0 gdy będzie równa 1. Wartość 1 będzie oznaczała otrzymanie potwierdzenia.

Poniżej grafika przedstawiająca porównanie transmisji sprzętowej oraz jej programowej implementacji (start, send(1), stop).

I2C odczytywanie bajtu

unsigned char soft_i2c_read(void)
{
	unsigned char byte=0;
	unsigned char bit;
	
	SDA_HIGH;
	SDADDR &= ~(1<<SDA);
	
	for (unsigned char i=8; i>0; i--)
	{
		_delay_us(0.9);
		SCL_HIGH;
		bit = SDAPIN & (1<<SDA);
		byte <<= 1;
		if (bit) byte |= 1;
		_delay_us(0.9);
		SCL_LOW;
	}
	
	SDADDR |= (1<<SDA);
	SDA_LOW; // ACK
	
	_delay_us(0.8);
	SCL_HIGH;
	_delay_us(1.3);
	SCL_LOW;
	_delay_us(1.3);
	return byte;
}

W tym przypadku pętla for sprawdza każdy nadchodzący bit i w rezultacie otrzymujemy przesyłaną wartość w zmiennej byte. Później pin SDA jest ustawiany jako wyjście, a na nim jest wystawiany stan niski, czyli bit ACK. Kolejne polecenia to wykonanie ostatniego impulsu zegarowego zatrzaskujacego bit ACK.

Podsumowanie

Podpiąłem pod mikrokontroler testowo wyświetlacz oled – transmisja przechodzi (moduł reaguje na dane), a więc biblioteka działa poprawnie.
Wielką zaletą transmisji I2C jest to, że to my generujemy cykl zegarowy, a co za tym idzie – trudno ją popsuć. Nawet większe odchylenia od norm czasowych nie powinny spowodować jakichś nieprawidłowości w transmisji. Mogą one co najwyżej zmienić trochę ostateczną prędkość przesyłu.

Poniżej znajdziesz kod biblioteki dla prędkości 100 i 400 KHz.

https://github.com/mcinm/soft_i2c_avr

2 komentarze

  1. Dlaczego po wysłaniu bajtu danych,
    a przed sygnałem stop pojawia się jeszcze jeden impuls scl przy sda będącym w stanie wysokim?

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *