Добавить в избранное

Шины I2C (TWI) и SPI на AVR: работа с датчиками и памятью на чистом C

Чтение ≈ 5 мин

Аппаратные I2C (TWI) и SPI в ATmega328P без библиотек: полный драйвер TWI-мастера с start/stop/write/read, чтение EEPROM AT24C32 и регистров датчика MPU6050, SPI-мастер с примером вывода на дисплей MAX7219 и сдвиговый регистр 74HC595, типичные ошибки на шине.

I2C и SPI: когда что

I2C (в AVR называется TWI — Two-Wire Interface) — две линии SDA/SCL с подтяжкой, адресация до 127 устройств на одной паре проводов, 100/400 кГц. Медленнее, но экономит ножки: датчики, RTC, EEPROM, OLED-дисплеи.

SPI — 4 линии (MOSI, MISO, SCK + CS на каждое устройство), полный дуплекс, мегагерцы скорости. Быстрее и проще, но на каждое устройство — свой CS: дисплеи, SD-карты, радиомодули nRF24, сдвиговые регистры.

TWI-мастер на ATmega328P: драйвер с нуля

Аппаратный TWI висит на PC4 (SDA) и PC5 (SCL). Частота задаётся TWBR и предделителем в TWSR:

// F_scl = F_CPU / (16 + 2*TWBR * prescaler), prescaler=1:
// для 100 кГц при 16 МГц: TWBR = (16000000/100000 - 16)/2 = 72
#define TWI_FREQ   100000UL
#define TWBR_VAL   ((F_CPU / TWI_FREQ - 16) / 2)

static void twi_init(void)
{
    TWSR = 0;                 // prescaler = 1
    TWBR = (uint8_t)TWBR_VAL; // 72 -> 100 кГц
}

Примитивы транзакции — start, stop, запись байта, чтение с ACK/NACK. Каждый шаг — ждём сброса флага TWINT:

#define TWI_TIMEOUT  10000

static uint8_t twi_wait(void)
{
    uint16_t t = TWI_TIMEOUT;
    while (!(TWCR & (1 << TWINT)) && --t)
        ;
    return t != 0;                       // 0 = таймаут, шина зависла
}

static void twi_start(void)
{
    TWCR = (1 << TWINT) | (1 << TWSTA) | (1 << TWEN);
    twi_wait();
}

static void twi_stop(void)
{
    TWCR = (1 << TWINT) | (1 << TWSTO) | (1 << TWEN);
}

static uint8_t twi_write(uint8_t data)
{
    TWDR = data;
    TWCR = (1 << TWINT) | (1 << TWEN);
    if (!twi_wait()) return 0;
    // TWSR & 0xF8: 0x18/0x28 = ACK, 0x20/0x30 = NACK
    return ((TWSR & 0xF8) != 0x20) && ((TWSR & 0xF8) != 0x30);
}

static uint8_t twi_read_ack(void)
{
    TWCR = (1 << TWINT) | (1 << TWEN) | (1 << TWEA);
    twi_wait();
    return TWDR;
}

static uint8_t twi_read_nack(void)
{
    TWCR = (1 << TWINT) | (1 << TWEN);   // без TWEA -> NACK -> stop
    twi_wait();
    return TWDR;
}

Последний байт при чтении всегда подтверждаем NACK — иначе слейв продолжит слать данные после нашего STOP.

Пример: EEPROM AT24C32 на шине I2C

Запись байта по адресу ячейки: START → адрес устройства+W (0xAE для AT24C32) → старший/младший байт адреса → данные → STOP. После записи чипу нужно ~5 мс на внутренний цикл — либо ждём, либо опрашиваем ACK (acknowledge polling):

#define EEPROM_W  0xAE
#define EEPROM_R  0xAF

static void ee24_write(uint16_t addr, uint8_t data)
{
    twi_start();
    twi_write(EEPROM_W);
    twi_write(addr >> 8);
    twi_write(addr & 0xFF);
    twi_write(data);
    twi_stop();

    // acknowledge polling: крутим start+W, пока не придёт ACK
    do {
        twi_start();
    } while (!twi_write(EEPROM_W));
    twi_stop();
}

static uint8_t ee24_read(uint16_t addr)
{
    // «фиктивная запись» — установить внутренний указатель
    twi_start();
    twi_write(EEPROM_W);
    twi_write(addr >> 8);
    twi_write(addr & 0xFF);
    // повторный старт и чтение
    twi_start();
    twi_write(EEPROM_R);
    uint8_t d = twi_read_nack();
    twi_stop();
    return d;
}

Пример: чтение MPU6050 (акселерометр)

Адрес MPU6050 — 0x68 (W=0xD0, R=0xD1). Аксель X лежит в регистрах 0x3B (старший) и 0x3C:

static uint8_t mpu_read_reg(uint8_t reg)
{
    twi_start();
    twi_write(0xD0);
    twi_write(reg);
    twi_start();
    twi_write(0xD1);
    uint8_t v = twi_read_nack();
    twi_stop();
    return v;
}

static int16_t mpu_accel_x(void)
{
    return (int16_t)((mpu_read_reg(0x3B) << 8) | mpu_read_reg(0x3C));
}

// инициализация: вывести из сна
static void mpu_init(void)
{
    twi_start();
    twi_write(0xD0);
    twi_write(0x6B);     // PWR_MGMT_1
    twi_write(0x00);     // clock = internal 8 MHz, sleep off
    twi_stop();
}

Паттерн «записать номер регистра → повторный start → прочитать» универсален для 99% I2C-датчиков: BMP280, DS1307, SSD1306, HMC5883L работают ровно так же.

Сканер шины I2C

Полезнейшая отладочная утилита — перебрать все адреса и посмотреть, кто отвечает ACK:

for (uint8_t a = 1; a < 127; a++) {
    twi_start();
    if (twi_write(a << 1))
        printf("device at 0x%02X\r\n", a);
    twi_stop();
}

Не отвечает ни одно устройство? В 90% случаев нет (или сгорели) подтягивающих резисторов 4,7 кОм на SDA/SCL, либо перепутаны линии.

SPI-мастер: проще и быстрее

SPI на ножках: PB3 MOSI, PB4 MISO, PB5 SCK, PB2 SS. Мастер задаёт частоту битами SPR0/1 (и SPI2X):

static void spi_init(void)
{
    DDRB |= (1 << PB3) | (1 << PB5) | (1 << PB2); // MOSI, SCK, SS — выходы
    DDRB &= ~(1 << PB4);                          // MISO — вход
    PORTB |= (1 << PB2);                          // CS неактивен (высокий)

    SPCR = (1 << SPE) | (1 << MSTR)              // включить, мастер
         | (1 << SPR0);                          // f/16 = 1 МГц при 16 МГц
}

static uint8_t spi_transfer(uint8_t data)
{
    SPDR = data;
    while (!(SPSR & (1 << SPIF)))
        ;
    return SPDR;                                  // принятый байт
}

SPI — сдвиговый регистр: каждый переданный байт одновременно принимает байт от слейва. «Прочитать» = передать 0xFF/0x00 и забрать возвращённое.

Пример: дисплей MAX7219 (8×8 LED / семисегменты)

MAX7219 принимает 16 бит: адрес регистра + данные. Линия LOAD (=SS) фиксирует передачу:

static void max7219_write(uint8_t reg, uint8_t data)
{
    PORTB &= ~(1 << PB2);            // CS low
    spi_transfer(reg);
    spi_transfer(data);
    PORTB |= (1 << PB2);             // CS high — защёлкнуть
}

static void max7219_init(void)
{
    max7219_write(0x0C, 0x01);       // shutdown off
    max7219_write(0x0F, 0x00);       // test off
    max7219_write(0x0B, 0x07);       // scan all 8 digits
    max7219_write(0x09, 0x00);       // no decode (матрица)
    max7219_write(0x0A, 0x05);       // яркость
}

// строка i (1..8) матрицы 8x8:
static void max7219_row(uint8_t row, uint8_t pixels)
{
    max7219_write(row, pixels);
}

Пример: 74HC595 — 8 выходов по трём проводам

Сдвиговый регистр превращает SPI в параллельные выходы. Данные в DS(PB3/MOSI), такты в SHCP(PB5/SCK), защёлка STCP — любая ножка, здесь PB2:

static void sr595_write(uint8_t data)
{
    PORTB &= ~(1 << PB2);            // защёлка вниз
    spi_transfer(data);
    PORTB |= (1 << PB2);             // фронт — данные на выходах Q0..Q7
}

8 байт = 64 выхода каскадом: spi_transfer в цикле от последнего регистра к первому — цепочка сдвигает данные через все 595-х.

Типовые ошибки на шинах

  • Нет pull-up на I2C — обязательны 4,7 кОм на обе линии (на платах модулей обычно уже есть — два модуля = 2,35 кОм, ещё терпимо).
  • Забыли TWEN в TWCR — мастер «нажал start», а аппаратура выключена.
  • Чтение без NACK в конце — слейв держит шину, следующая транзакция мертва.
  • SS мастера как вход — у AVR ножка SS в режиме мастера должна быть выходом или подтянута; случайный ноль на ней переключает SPI в slave — классический «SPI умер сам».
  • CS не поднят между транзакциями — многие слейвы (MAX7219, SD) требуют фронта CS.
  • Разные режимы CPOL/CPHA — у SPI 4 режима; если слейв отвечает мусором, сверьте биты CPOL/CPHA в SPCR с даташитом устройства.
  • 3,3 В слейв на 5 В шине — I2C изолирован подтяжкой к 3,3 В спасёт, а вот SPI напрямую жжёт входы: нужен level shifter или делитель.

Итог

TWI и SPI покрывают почти всю внешнюю периферию AVR: сенсоры, память, дисплеи, радио. Свой драйвер вместо Arduino Wire — это понимание каждого такта на шине и полный контроль над таймаутами и ошибками. На этом базовый набор периферии закрыт: GPIO, таймеры, прерывания, UART, АЦП, EEPROM, сон, I2C, SPI — дальше только комбинировать в свои проекты.

Поделиться
Понравился материал?

Комментарии

Свернуть форму

Необходимо Войти или Зарегистрироваться, чтобы оставлять комментарии