Встраиваемые системы и робототехника Протоколы: UART, I2C, SPI, CAN — как устройства общаются
0%

Протоколы: UART, I2C, SPI, CAN — как устройства общаются

Протоколы: UART, I2C, SPI, CAN — как устройства общаются

Стек, которого нет

Программист «больших» машин привык, что связь начинается со строчки socket(). Под ней лежат семь этажей чужой работы: железо выправляет уровни, канальный уровень режет поток на кадры и считает CRC, IP маршрутизирует, TCP нумерует байты, обнаруживает потери, повторяет и регулирует темп. Вы получаете надёжный поток и думаете о своём.

На микроконтроллере этого стека нет. Есть провод, регистр периферии и вы.

Ключевой сдвиг: во встраиваемых системах физический уровень — часть прикладного кода. Длина провода, ёмкость шины, номинал резистора и точность кварца перестают быть чужой заботой и становятся переменными вашего расчёта. Ошибка в них выглядит как «баг в парсере», хотя лежит в электрике.

Слой В серверной разработке На микроконтроллере
Уровни и фронты железо сетевой карты ваши резисторы, ваш выбор трансивера
Границы сообщения делает драйвер и TCP пишете вы: разделитель, длина, стаффинг
Целостность CRC Ethernet + контрольная сумма TCP у SPI и I2C нет вообще; у CAN есть, у UART — если добавите
Вид отказа таймаут сокета, RST молчание, мусор в буфере, зависшая шина

Отсюда рабочая методика: к любой незнакомой шине задавайте три вопросакто задаёт время, как отмечена граница сообщения, что происходит при ошибке. Ответы объясняют почти все свойства протокола, включая его типичные отказы. Дальше мы четыре раза пройдём этот цикл.

Система координат

Шины различаются по нескольким осям, и каждая ось — компромисс, оплаченный ножками, скоростью или живучестью. Синхронность: есть отдельный провод такта (SPI, I2C) или обе стороны отмеряют время сами (UART, CAN). Топология: точка-точка, общая шина с адресацией, звезда с выбором по ножке. Арбитраж: что происходит, когда заговорили двое. Целостность: есть ли CRC, подтверждение, счётчики ошибок. Физика: одиночный провод относительно земли живёт сантиметры, дифференциальная пара — сотни метров. И ножки — самый недооценённый ресурс: в корпусе LQFP48 их всего 48, и на каждую претендуют питание, кварц, отладка и светодиоды.

Топологии четырёх шин: UART точка-точка, I2C с подтяжками, SPI со звездой CS, CAN на витой паре с терминаторами

Свойство UART I2C SPI CAN
Проводов (без земли) 2 2 3 + по 1 на устройство 2
Такт нет, стороны считают сами SCL от мастера SCK от мастера нет, восстанавливается из фронтов
Топология точка-точка общая шина звезда или цепочка общая шина
Адресация нет 7 (реже 10) бит ножкой CS 11 или 29 бит, но это ID сообщения, не узла
Скорость на практике 9,6 кбит/с … 3 Мбит/с 100 кбит/с … 1 Мбит/с 1 … 60 Мбит/с 125 кбит/с … 1 Мбит/с, FD до 8
Дальность до метра TTL, сотни метров в RS-485 десятки сантиметров сантиметры 40 м на 1 Мбит/с, 1 км на 50 кбит/с
Подтверждение приёма нет ACK на каждый байт нет ACK на кадр, CRC, счётчики ошибок
Где применяют консоль, GPS, модем, лидар датчики, EEPROM, экраны АЦП, флеш, дисплеи, IMU автомобиль, привод, робот, станок

UART: два провода и вера в кварц

UART — не шина, а пара независимых однонаправленных линий. Уровень покоя высокий; передатчик роняет линию в ноль на один битовый интервал (старт-бит), выдаёт биты данных младшим вперёд и возвращает линию в единицу минимум на один интервал (стоп-бит). Ни адреса, ни длины, ни контрольной суммы, ни ответа. Тактовой линии нет: приёмник ловит спадающий фронт старта, запускает свой счётчик и дальше отмеряет время сам, обычно дискретизируя линию с шестнадцатикратной частотой и голосуя по трём отсчётам в середине бита.

Анатомия кадра: UART 8N1, кадр CAN 2.0A и побитовый арбитраж

Почему битрейт почти никогда не точный

Скорость получается делением тактовой частоты периферии на целое число — дробей регистру взять негде. Значит, реальная скорость всегда отличается от номинальной.

# Насколько реальный битрейт отличается от заказанного. O(1) на пару значений,
# но именно эта арифметика решает, заработает ли связь на морозе.
def baud_divisor(f_ck_hz: int, baud: int) -> tuple[int, float, float]:
    div = max(1, round(f_ck_hz / baud))          # регистру дробей взять негде
    actual = f_ck_hz / div
    return div, actual, (actual - baud) / baud * 100.0

for f_ck in (8_000_000, 16_000_000, 48_000_000):
    for baud in (115200, 921600):
        div, actual, err = baud_divisor(f_ck, baud)
        print(f"{f_ck/1e6:5.1f} МГц {baud:7d} бод -> /{div:<5d} факт {actual:9.1f}, ошибка {err:+6.2f}%")

#   8.0 МГц 115200 -> /69   факт 115942.0, ошибка +0.64%    16.0 МГц 921600 -> /17  +2.12%  на грани
#   8.0 МГц 921600 -> /9    факт 888888.9, ошибка -3.55%    48.0 МГц 115200 -> /417 -0.08%  норма

Откуда допуск. В кадре 8N1 приёмник выбирает стоп-бит через 9,5 битовых интервала после фронта старта; накопленное расхождение должно остаться меньше половины интервала — теоретически около 5 % суммарно на обе стороны. Практический бюджет жёстче: не более 2 % на каждой стороне, остальное съедают дрожание фронтов и уход частоты от температуры. Отсюда правило, экономящее дни отладки: встроенный RC-генератор (HSI у STM32, internal oscillator у AVR) уходит на проценты от температуры и питания — он годится для 9600 бод и не годится для 921600. Нужен быстрый UART — ставьте кварц.

Логика кадра одна, а физика разная, и путать их дорого. TTL/CMOS 0–3,3 В или 0–5 В — то, что выходит прямо из ножки; живёт десятки сантиметров, прямое соединение 5 В с 3,3-вольтовым входом сжигает вход. RS-232 ±5…15 В с инверсией — нужен преобразователь уровней (MAX3232), подключение ножки к COM-порту напрямую убивает ножку. RS-485 — дифференциальная пара, полудуплекс, до 32 узлов и сотен метров: та же логика UART, другая физика, терминаторы 120 Ом на концах и управление направлением по ножке DE. Классический баг здесь — отпустить DE по флагу «регистр передатчика свободен» (TXE), когда последний байт ещё физически в линии; ждать надо флага завершения передачи (TC).

Приёмник докладывает о четырёх бедах, и все четыре стоит считать в статистике прошивки: framing error (на месте стоп-бита ноль — неверный битрейт, нет общего провода), parity error, overrun (пришёл байт, а предыдущий не забрали — это про ваш код, а не про линию) и noise error (три отсчёта в середине бита разошлись — плохая электрика).

Приём без потерь

/* Приём UART по прерыванию: единственный производитель — ISR, потребитель — основной цикл. */
#define RX_CAP 256u                     /* степень двойки: маска вместо деления */
static struct {
    volatile uint8_t  buf[RX_CAP];      /* содержимое меняется вне видимости компилятора */
    volatile uint16_t head, tail;       /* head пишет только ISR, tail — только main */
} rx;
static volatile struct { uint32_t overrun, framing, dropped; } stats;

void USART2_IRQHandler(void)
{
    uint32_t isr = USART2->ISR;
    if (isr & USART_ISR_ORE) { USART2->ICR = USART_ICR_ORECF; stats.overrun++; }  /* байты потеряны */
    if (isr & USART_ISR_FE)  { USART2->ICR = USART_ICR_FECF;  stats.framing++; }
    if (isr & USART_ISR_RXNE) {
        uint8_t  b    = (uint8_t)USART2->RDR;          /* чтение RDR снимает флаг */
        uint16_t next = (uint16_t)((rx.head + 1u) & (RX_CAP - 1u));
        if (next != rx.tail) {
            rx.buf[rx.head] = b;
            __DMB();                                   /* данные видимы раньше индекса */
            rx.head = next;                            /* публикация — последним действием */
        } else {
            stats.dropped++;                           /* переполнение считаем, а не замалчиваем */
        }
    }
}

Три нюанса. Первый: volatile не про атомарность и не про порядок обращений к обычной памяти — он лишь запрещает кэшировать значение в регистре, а два volatile-доступа упорядочены только между собой. Поэтому массив тоже помечен volatile, а между записью данных и публикацией индекса стоит барьер __DMB(): на Cortex-M0 это выглядит паранойей ровно до дня переезда на M7 с кэшем. Второй: ёмкость — степень двойки, потому что % CAP внутри обработчика превращается в вызов библиотечной функции на десятки тактов. Третий: счётчик потерь в телеметрии — единственный способ узнать, что связь «работает», но теряет каждый сотый кадр. Почему обработчик обязан быть коротким — в статье про прерывания и реальное время, про volatile и модель памяти — в статье про C для встраиваемых систем.

На 921600 бод байты приходят каждые 11 мкс, и ядро занято одним лишь входом-выходом из ISR. Промышленный приём — DMA в кольцевом режиме плюс прерывание по простою линии (IDLE): контроллер сам складывает байты в память, а прерывание приходит один раз на пакет. Разбор — в статье про периферию.

Кадрирование: из потока байтов в сообщения

UART отдаёт поток без границ, и первая же потеря байта сдвигает разбор навсегда. Текстовые строки с переводом строки хороши для консоли и плохи для данных; схема «длина + CRC» компактна, но при потере байта приёмник читает мусор как длину и уходит в лес на тысячи байтов. Правильный ответ — разделитель со стаффингом: ресинхронизация гарантированно происходит на следующем разделителе, так работают HDLC, SLIP и COBS. COBS (Consistent Overhead Byte Stuffing) убирает из нагрузки все нулевые байты, после чего ноль становится однозначным разделителем кадров, а накладные расходы остаются фиксированными: один байт на каждые 254 плюс один.

буфер := [заглушка]                # место под указатель до следующего нуля
код := 1
для каждого байта b:
    если b ≠ 0: добавить b; код += 1; если код = 255: закрыть блок, начать новый
    иначе:      записать код в заглушку, начать новый блок
записать код в последнюю заглушку
def cobs_encode(data: bytes) -> bytes:
    """Убирает нули. Время O(n), память O(n), рост длины не больше n/254 + 1."""
    out = bytearray([0]); idx, code = 0, 1
    for b in data:
        if b:
            out.append(b); code += 1
            if code < 0xFF:               # блок ещё не полон — продолжаем
                continue
        out[idx] = code                   # ноль или 254 байта подряд: закрываем блок
        idx, code = len(out), 1; out.append(0)
    out[idx] = code
    return bytes(out)

def cobs_decode(enc: bytes) -> bytes:
    """Обратная операция. Время O(n), память O(n)."""
    out, i = bytearray(), 0
    while i < len(enc):
        code = enc[i]; i += 1
        if code == 0:
            raise ValueError("нулевой байт внутри кадра — это разделитель, кадр битый")
        chunk = enc[i:i + code - 1]
        if len(chunk) != code - 1:
            raise ValueError("кадр обрезан")
        out += chunk; i += code - 1
        if code != 0xFF and i < len(enc):
            out.append(0)                 # восстанавливаем вырезанный ноль
    return bytes(out)

payload = bytes([0x11, 0x22, 0x00, 0x33])
print(cobs_encode(payload).hex(" "))              # 03 11 22 02 33 — нулей внутри нет

Поверх COBS кладут CRC — например, CRC-16/CCITT, который ловит все одиночные и двойные ошибки и пачки до 16 бит. Считают его табличным методом (256 байт Flash, такт на байт) или аппаратным блоком CRC, который есть у большинства STM32. Механика кодов обнаружения ошибок подробно разобрана в статье про канальный уровень — она там ровно та же.

Когда брать UART: отладочная консоль (всегда), готовые модули с текстовым или бинарным протоколом (GPS, LTE-модем, лидар), связь двух плат до метра, а через RS-485 — до сотен метров с несколькими узлами.

I2C: два провода на весь борт

I2C экономит ножки радикально: две линии, SDA и SCL, на все устройства платы. Плата за это — выходной каскад с открытым стоком: каждый узел умеет только притягивать линию к земле, а единицу возвращает внешний подтягивающий резистор. Из этого одного факта следует почти всё: спад быстрый, подъём медленный, значит скорость ограничена ёмкостью шины; столкновение двух передатчиков не разрушительно (ноль побеждает единицу), значит арбитраж получается бесплатно; ведомый может растянуть такт (clock stretching), удерживая SCL внизу, — и мастер обязан это уметь.

Номинал подтяжки — не «обычно 4,7 кОм», а расчёт между двумя границами: снизу его ограничивает максимальный ток стока (обычно 3 мА при V_OL = 0,4 В), сверху — требование стандарта ко времени нарастания. Время нарастания приближённо равно 0,8473 × R × C: коэффициент берётся из зарядки RC между порогами 0,3 и 0,7 от питания.

K_RISE = 0.8473   # ln(0.7/0.3): пороги логического нуля и единицы

def pullup_range(vdd, c_bus_pf, t_rise_max_ns, i_ol_ma=3.0, v_ol=0.4):
    """Снизу — чтобы дотянуть линию до нуля, сверху — чтобы успеть подняться. O(1)."""
    return (vdd - v_ol) / (i_ol_ma * 1e-3), t_rise_max_ns * 1e-9 / (K_RISE * c_bus_pf * 1e-12)

# Стандарт разрешает 1000 нс нарастания на 100 кГц и 300 нс на 400 кГц.
for name, c_pf, t_ns in [("100 кГц, 100 пФ — компактная плата", 100, 1000),
                         ("400 кГц, 100 пФ — та же плата быстрее", 100, 300),
                         ("400 кГц, 400 пФ — датчик на шлейфе", 400, 300)]:
    lo, hi = pullup_range(3.3, c_pf, t_ns)
    print(f"{name:38s} R = {lo:5.0f}{hi:6.0f} Ом",
          "-> нерешаемо: буфер или меньше скорость" if hi < lo else f"-> берите {min(hi, 4700):.0f} Ом")

# 100 кГц, 100 пФ ... R = 967…11802 Ом -> берите 4700 Ом    | 400 кГц, 100 пФ ... R = 967…3541 -> 3541 Ом
# 400 кГц, 400 пФ ... R = 967…  885 Ом -> нерешаемо: шлейф даёт слишком большую ёмкость

Ёмкость оценивается просто: около 10 пФ на устройство, 0,5–1 пФ на сантиметр дорожки, 50–100 пФ на метр кабеля. Отсюда закон: I2C не выносят за пределы платы — метровый шлейф это сотня пикофарад, и 400 кГц на нём физически недостижимы.

Три детали. Повторный старт вместо стоп-старт: поставите STOP между записью адреса регистра и чтением — шина освободится, и другой мастер успеет вклиниться, сбив внутренний указатель датчика. NACK — не ошибка, а управляющий сигнал: мастер обязан ответить NACK на последний прочитанный байт, иначе ведомый продолжит выкладывать данные. NACK на адрес — самая частая ошибка новичка, означает «никто не откликнулся»; причины по частоте: адрес сдвинут на бит (в даташите он часто в 8-битной форме, а регистр ждёт 7-битный), нет питания на датчике, забыты подтяжки, перепутаны SDA и SCL.

Главная беда шины — залипание. Мастер перезагрузился (сторожевой таймер, отладчик, кнопка) ровно тогда, когда ведомый выдавал байт и держал SDA внизу. Ведомый ждёт тактов, чтобы досчитать байт, мастер видит SDA в нуле и не может сформировать START. Шина мертва до снятия питания, а в поле питание никто не снимет. Лечится тактами:

/* Восстановление залипшей шины I2C. Вызывается при старте, до инициализации периферии. */
static void i2c_bus_recover(void)
{
    gpio_config_od(SCL_PIN); gpio_config_od(SDA_PIN);   /* открытый сток, подтяжки внешние */
    gpio_set(SDA_PIN);                                  /* отпускаем SDA, дальше наблюдаем */
    for (int i = 0; i < 9 && gpio_read(SDA_PIN) == 0; i++) {
        gpio_clear(SCL_PIN); delay_us(5);               /* ~100 кГц, заведомо безопасно */
        gpio_set(SCL_PIN);   delay_us(5);               /* ведомый выдвигает очередной бит */
    }
    gpio_clear(SDA_PIN); delay_us(5);                   /* формируем STOP: SDA 0 -> 1 */
    gpio_set(SCL_PIN);   delay_us(5);                   /* при высоком SCL */
    gpio_set(SDA_PIN);   delay_us(5);
    if (gpio_read(SDA_PIN) == 0)
        sensor_power_cycle();   /* ведомый мёртв: снимаем питание ключом — потому его и ставят */
}

Второй частый отказ — конфликт адресов: два одинаковых датчика имеют один адрес. Решения по возрастанию цены: перемычка на ножке ADDR датчика, мультиплексор шины (TCA9548A), второй аппаратный контроллер I2C, программный I2C на любых ножках. Третье, о чём молчат учебники: у I2C нет контрольной суммы. Один искажённый бит в данных акселерометра — и робот получает команду на рывок. Если шина идёт хотя бы через межплатный разъём, кладите поверх свою проверку: границы значений, дублирующее чтение, счётчик отказов.

Когда брать I2C: много мелких датчиков на одной плате, ножек мало, объёмы — единицы байт за транзакцию. Не брать: межплатный кабель, потоковые данные, критичные по времени контуры управления.

SPI: скорость ценой ножек

SPI — обычный сдвиговый регистр, вывернутый наружу. Мастер выдаёт такт по SCK, на каждом такте бит уходит по MOSI и бит приходит по MISO. Дуплекс здесь не опция, а следствие конструкции: чтобы что-то прочитать, надо что-то отправить (обычно нули). Адресации нет — устройство выбирается ножкой CS, почти всегда активной низким уровнем. Отсюда главный компромисс: N устройств — N ножек.

Режим CPOL CPHA Такт в покое Данные защёлкиваются Кто так делает
0 0 0 низкий по нарастающему фронту подавляющее большинство
1 0 1 низкий по спадающему фронту редко
2 1 0 высокий по спадающему фронту редко
3 1 1 высокий по нарастающему фронту флеш-память, многие дисплеи

Ошибка в режиме даёт «читается всегда 0xFF» или «данные сдвинуты на бит». Проверять надо по временной диаграмме в даташите, а не по памяти.

Многие устройства защёлкивают команду по фронту CS, а не по последнему такту: снять CS раньше времени — отменить операцию, забыть снять — оставить шину занятой. Это ровно тот случай, когда C++ на микроконтроллере оправдан — время жизни объекта соответствует времени владения ресурсом.

// Встраиваемый C++: без исключений, без кучи, без виртуальных вызовов на горячем пути.
class SpiSelect {                                     // CS активен нулём
public:
    explicit SpiSelect(GpioPin cs) noexcept : cs_(cs) { cs_.clear(); }
    ~SpiSelect() noexcept { cs_.set(); }              // снимется при любом выходе из области
    SpiSelect(const SpiSelect&)            = delete;  // владение ресурсом не копируется
    SpiSelect& operator=(const SpiSelect&) = delete;
private:
    GpioPin cs_;
};

template <std::uint8_t Mode>
class Adxl345 {                                       // режим шины зафиксирован в типе
    static_assert(Mode == 3, "ADXL345 работает только в режиме 3");
public:
    Adxl345(Spi& bus, GpioPin cs) noexcept : bus_(bus), cs_(cs) {}

    bool probe() noexcept { return read_reg(0x00) == 0xE5; }  // DEVID: единственная проверка жизни

    std::int16_t axis_x() noexcept {
        std::uint8_t rx[2]{};
        SpiSelect guard{cs_};                         // CS опущен здесь
        bus_.transfer_byte(0x32 | 0x80 | 0x40);       // регистр + бит чтения + автоинкремент
        bus_.receive(rx, sizeof rx);
        return static_cast<std::int16_t>(rx[0] | (rx[1] << 8));
    }                                                 // CS поднят здесь, по любому пути выхода
private:
    std::uint8_t read_reg(std::uint8_t reg) noexcept {
        SpiSelect guard{cs_};
        bus_.transfer_byte(static_cast<std::uint8_t>(reg | 0x80));
        return bus_.transfer_byte(0x00);              // выдвигаем нули, чтобы вдвинуть ответ
    }
    Spi& bus_; GpioPin cs_;
};

Обратите внимание на probe(). У SPI нет ни ACK, ни CRC, ни таймаута: отсутствующее устройство неотличимо от присутствующего, а MISO без подтяжки читается как случайные 0x00 или 0xFF. Единственный способ убедиться в наличии чипа — прочитать регистр идентификации и сравнить с константой из даташита; это обязано быть частью инициализации любого SPI-драйвера.

Заявленные «60 МГц» упираются в физику раньше, чем в контроллер. На 30 МГц длина фронта сопоставима с десятисантиметровой дорожкой — начинаются отражения и звон, лечится резистором 22–33 Ом последовательно с SCK. Задержка ответа ведомого (сигнал дошёл, чип выставил бит, бит вернулся) ограничивает частоту независимо от качества сигнала. И третье: дисплей 320×240×16 бит — это 150 КБ на кадр, пересылать их из цикла означает съесть весь процессор, поэтому SPI на реальных скоростях всегда работает через DMA.

Когда брать SPI: нужен объём (дисплеи, SD, внешняя флеш), нужна частота опроса (IMU на 1–8 кГц), нужна предсказуемая задержка. Не брать: ножек в обрез, а датчиков много.

CAN: шина, которая знает про приоритеты

CAN придумали в Bosch в 1980-е, чтобы избавить автомобиль от километров жгутов, и требования были жёсткие: работать рядом с системой зажигания, не терять сообщения при столкновениях, гарантировать, что сигнал педали тормоза пройдёт раньше сигнала стеклоподъёмника, и продолжать работать при отказе части узлов.

Физика — дифференциальная пара с двумя терминаторами по 120 Ом. Ключевая асимметрия: логический ноль доминантный (трансивер активно разводит провода по напряжению), логическая единица рецессивная (трансивер отпускает линию, терминаторы стягивают провода). Если один узел передаёт ноль, а другой единицу — на шине будет ноль, и передававший единицу это увидит.

Из доминантного нуля вырастает самое красивое свойство CAN. Все желающие говорить начинают передачу одновременно и одновременно слушают шину; на каждом бите идентификатора узел сравнивает переданное с тем, что реально на линии, и, увидев ноль вместо своей единицы, молча уходит в приём. Итог: кадр с меньшим численным значением идентификатора выигрывает, при этом ничей кадр не разрушается и ничьи данные не теряются — в отличие от Ethernet, где коллизия уничтожает оба кадра. Разбор по битам — на нижней части схемы кадров выше.

Практический вывод: идентификатор в CAN — это приоритет сообщения, а не адрес узла. Аварийная остановка получает ID 0x001, телеметрия температуры — 0x700. Отсюда прямая аналогия с фиксированными приоритетами планировщика из статьи про RTOS: шина ведёт себя как невытесняющий планировщик, где квант — один кадр.

Стандартный кадр (CAN 2.0A) тратит 111 бит на 8 байт данных, и каждое поле работает. Бит-стаффинг: после пяти одинаковых битов вставляется шестой противоположный — это гарантирует фронты, по которым приёмники подстраивают часы, ведь тактовой линии нет; побочный эффект — длина кадра плавает, худший случай 135 бит вместо 111. CRC-15 обнаруживает пачки до 15 бит, остаточная вероятность необнаруженной ошибки — порядка 4,7 × 10⁻¹¹ на кадр. ACK-слот: передатчик отпускает линию на бит, и любой узел, принявший кадр без ошибок, придавливает её — это подтверждение приёма шиной в целом, а не адресатом. Отсюда классический симптом первого запуска: единственный включённый узел никогда не получает ACK и повторяет кадр бесконечно.

CAN — единственная из четырёх шин, где протокол сам изолирует сбойный узел. Контроллер ведёт счётчик ошибок передачи (TEC растёт на 8 за неудачу, падает на 1 за успех) и приёма (REC):

Ценность этой машины огромна: узел с отвалившимся проводом или неверной скоростью не забьёт шину бесконечными флагами ошибок — он сам себя выключит. Ваша прошивка обязана уметь читать состояние (отличная метрика здоровья жгута в телеметрии) и осознанно восстанавливаться из Bus Off — с паузой и счётчиком, чтобы не устроить шторм.

Тактовой линии нет, поэтому битовый интервал делится на кванты времени, а точка выборки ставится ближе к концу бита: сигнал должен успеть дойти до дальнего узла и вернуться, иначе арбитраж сломается. CiA рекомендует 87,5 %.

# bitrate = f_clk / (BRP * (1 + TSEG1 + TSEG2)); точка выборки = (1 + TSEG1) / квантов в бите.
def can_timing(f_clk_hz, bitrate, target_sp=0.875, tol=0.02):
    for tq in range(25, 7, -1):                       # больше квантов — точнее подстройка фазы
        brp, tseg1 = f_clk_hz / (bitrate * tq), round(target_sp * tq) - 1   # sync_seg — один квант
        tseg2 = tq - 1 - tseg1
        if brp == int(brp) and 1 <= tseg2 <= 8 and abs((1 + tseg1) / tq - target_sp) <= tol:
            return int(brp), tseg1, tseg2, (1 + tseg1) / tq, tq
    return None

print(can_timing(40_000_000, 500_000), can_timing(80_000_000, 1_000_000))  # оба (5, 13, 2, 0.875, 16)

Все узлы обязаны иметь одинаковую скорость и близкую точку выборки: расхождение на длинной шине даёт загадочные ошибки формы кадра, видные только под нагрузкой и только у дальних узлов. Зато CAN даёт то, чего не даёт ни одна другая шина в этой статье: вычислимую верхнюю границу задержки.

def frame_bits(n_data: int, extended: bool = False, worst_case: bool = True) -> int:
    """Длина кадра в битах с межкадровой паузой; worst_case — максимум вставок бит-стаффинга."""
    base = (67 if extended else 47) + 8 * n_data       # включая 3 бита IFS
    stuffable = (54 if extended else 34) + 8 * n_data  # поля, подлежащие стаффингу
    return base + (stuffable - 1) // 4 if worst_case else base

BITRATE = 500_000
print(frame_bits(8, worst_case=False), frame_bits(8))      # 111 и 135 бит -> 270 мкс на кадр
load = 10 * frame_bits(8) / 0.010 / BITRATE                # 10 сообщений по 8 байт раз в 10 мс
print(f"загрузка шины {load * 100:.0f}%")                  # 27%

Правило проектирования: держите загрузку ниже 30–40 % для систем управления; выше растёт задержка низкоприоритетных сообщений, а на 80 % они могут не пройти вовсе. Самый приоритетный кадр ждёт максимум длительность одного уже начавшегося чужого кадра — это блокировка, прямой аналог инверсии приоритетов из мира RTOS. Полный аппарат анализа времени отклика для CAN построил Кен Тинделл в 1990-е, и в автомобильной отрасли им пользуются до сих пор.

CAN FD снимает два ограничения классики: до 64 байт в кадре и повышенная скорость (5–8 Мбит/с) на время передачи данных, пока арбитраж остаётся медленным. Нужна поддержка и контроллера (FDCAN в STM32 G4/H7), и трансивера; классический контроллер увидит FD-кадр как ошибку. Сам CAN определяет только транспорт — смысл битов задают прикладные протоколы: CANopen (CiA 301, промышленная автоматика), J1939 (грузовики и спецтехника), UDS/ISO-TP (диагностика, сегментация длинных сообщений поверх 8-байтных кадров).

Когда брать CAN: несколько узлов, электрически шумная среда, требования к задержке и приоритетам, необходимость переживать частичные отказы. Не брать: два чипа на одной плате, потоковые данные вроде видео.

Три шины, которые встречаются реже

  • 1-Wire. Один провод плюс земля, паразитное питание от линии данных, уникальный 64-битный идентификатор у каждого устройства, поиск по двоичному дереву. Классика — термометр DS18B20. Протокол построен на длительностях импульсов с микросекундной точностью, поэтому программная реализация требует запрета прерываний, что плохо сочетается с реальным временем. Элегантный обход: генерировать тайм-слоты через UART на 115200 бод, где байт равен одному биту 1-Wire.
  • RS-485 + Modbus RTU. Промышленный рабочий конь: дифференциальная физика плюс простой протокол «запрос-ответ» с адресами и CRC-16. Границы кадра задаёт пауза в 3,5 символа — на быстрых скоростях это микросекунды, поэтому кадрирование делают по таймеру, а не по read(). Мастер один, коллизий нет по построению.
  • USB и Ethernet появляются, когда устройство говорит с компьютером или сетью: оба заметно сложнее (дескрипторы и перечисление у USB, весь IP-стек у Ethernet) и оба требуют памяти и такта. Про них — в статье про IoT-связь.

Как выбрать шину

Короткая версия того же: внутри платы выбор идёт между скоростью (SPI) и ножками (I2C); за пределами платы — между простотой (UART, RS-485) и живучестью с приоритетами (CAN). Всё, что дальше и быстрее одновременно, стоит дороже: это уже Ethernet со своим стеком.

Железо: что даёт каждая плата

Выбор шины и выбор платы связаны жёстче, чем кажется: нужный протокол может просто отсутствовать в кристалле.

Плата UART I2C SPI CAN Что важно знать
STM32 F1/F4/G4/H7 3–8, все с DMA 2–4 3–6, до 50 МГц bxCAN или FDCAN Эталон по периферии, аппаратный CRC на борту, трансивер CAN внешний
ESP32 / S3 3 2 2 свободных TWAI, совместим с CAN 2.0 Wi-Fi и BLE в комплекте, но радиостек занимает ядро и портит детерминизм
Arduino Uno (AVR) 1 1 (Wire) 1 нет Буфер Wire — 32 байта, длинные транзакции молча обрезаются
RP2040 (Pico) 2 2 2 нет аппаратного PIO синтезирует почти любой протокол параллельно ядру — уникально в классе
Raspberry Pi (Linux) 1 полноценный 1–2 (/dev/i2c-*) 2 (spidev) нет, ставят MCP2515 по SPI SocketCAN и полноценный Linux, но джиттер планировщика — миллисекунды

Практические выводы: нужен CAN и детерминизм — STM32 с FDCAN плюс трансивер (SN65HVD230, TJA1050), это стандарт де-факто для нижнего уровня робота; нужен CAN на Raspberry Pi — плата на MCP2515 или USB-адаптер, дальше ip link set can0 type can bitrate 500000 и обычный сетевой интерфейс; нужен нестандартный протокол — RP2040 с PIO; нужны радио и шина вместе — ESP32, помня о плавающей латентности. Подробнее об архитектурных различиях плат — в статье про железо для программиста.

Отладка без принтов

Это самая важная часть статьи. Отлаживать протоколы печатью нельзя по фундаментальной причине: printf внутри обмена меняет тайминги обмена. Вывод строки на 115200 бод занимает около миллисекунды — за это время I2C проведёт десяток транзакций, а CAN передаст четыре кадра; явление, которое вы ловите, сместится или исчезнет. Классическое «с логами работает, без логов падает» — это про гонки, и лечится оно приборами, а не увеличением количества логов.

  • Логический анализатор (от 10 USD за клон Saleae, лучше DSLogic) — главный прибор. Видит 8–16 цифровых каналов и, что важнее, декодирует протоколы: показывает не «фронты», а «START, адрес 0x76, W, ACK, 0xF7, NACK». Свободный sigrok/PulseView содержит больше сотни декодеров. При любой проблеме с шиной — цепляйте его первым.
  • Осциллограф нужен там, где цифра врёт: анализатор говорит «единица», а на деле 2,1 В при пороге 2,0 В. Смотрят форму фронтов (медленный подъём I2C — слабая подтяжка или большая ёмкость), звон на SPI, просадки питания в момент передачи, дифференциальный сигнал CAN.
  • JTAG/SWD-отладчик показывает состояние микроконтроллера, а не шины: регистры периферии, содержимое буферов, точку останова в обработчике. Незаменим для вопроса «почему прошивка не забрала байт», бесполезен для «что реально было на проводе». Отдельная техника — SEGGER RTT: отладочный вывод через буфер в ОЗУ, который отладчик читает без остановки ядра, — на три порядка дешевле printf по UART и почти не искажает тайминги.
  • Анализаторы протокола: для CAN это candump, cangen и cansniffer из can-utils поверх SocketCAN. Смысл — уметь сгенерировать эталонный обмен и сравнить со своим.
Симптом Чем смотреть Наиболее вероятная причина
UART: регулярный мусор анализатор, измерить длительность бита неверный битрейт; измеренная скорость подскажет верную
I2C: NACK на адрес анализатор с декодером I2C адрес сдвинут на бит, нет питания, перепутаны SDA/SCL
I2C: 100 кГц работает, 400 нет осциллограф, время нарастания слишком большая подтяжка или ёмкость шины
I2C: SDA постоянно в нуле хватит вольтметра залипание после сброса мастера — нужна процедура восстановления
SPI: всегда 0x00 или 0xFF анализатор на четырёх каналах не тот CPOL/CPHA, не тот CS, MISO не подключён
CAN: бесконечный повтор кадра анализатор или второй узел нет ACK: один узел на шине, нет терминаторов, разная скорость
CAN: узел ушёл в Bus Off чтение регистров по SWD обрыв, замыкание пары, чужая скорость на шине

Отдельный совет: землю щупа подключайте рядом с точкой измерения, а не к другому концу платы. Двадцатисантиметровый провод земли добавляет к картинке звон, которого в схеме нет, и вы будете чинить несуществующую проблему. Подробнее — в статье про отладку и производство.

Свой протокол поверх чужой шины

Рано или поздно вы пишете обмен между своими устройствами. Дисциплина та же, что в распределённых системах, только бюджет — сотни байт ОЗУ. Разделяйте слои: драйвер шины (байты) → фреймер (кадры, стаффинг, CRC) → сессия (номера, таймауты, повторы) → прикладная логика. Смешивать их — гарантия того, что смена UART на CAN потребует переписать всё.

  1. Заголовок фиксированной длины и версия протокола в нём. Однажды вы обновите прошивку только у половины устройств — версия спасёт от молчаливой порчи данных.
  2. CRC обязателен даже там, где шина «надёжная». SPI и I2C не проверяют ничего, а одиночные искажения в шумной среде случаются регулярно.
  3. Порядковый номер и идемпотентность. Команда «повернуть на 10 градусов» при повторе даст 20; команда «встать в позицию 30 градусов» безопасна. Та же логика — в статье про идемпотентность и доставку.
  4. Таймаут на каждый запрос, явное состояние «связь потеряна» и фиксированные размеры полей. Молчание — самый частый вид отказа, обрабатывать его надо как штатное событие. И никаких struct напрямую в провод без упаковки: на другом конце может быть процессор с другими правилами выравнивания — см. статью про двоичное представление.
  5. Сторожевой таймер на приёмнике команд движения. Перестали приходить команды — двигатели останавливаются сами. Это не опция, а требование безопасности.

Как это собирается в роботе

Типичный мобильный робот использует все четыре шины одновременно, и каждая на своём месте: SPI — инерциальный модуль с опросом 1–8 кГц (нужен объём и низкая задержка, устройство одно, на той же плате); I2C — медленная периферия вроде датчика температуры, монитора заряда и экранчика; CAN — приводы колёс и манипулятора (несколько узлов, помехи от токов в десятки ампер, приоритеты: аварийный стоп важнее телеметрии); UART — лидар, GNSS-приёмник и отладочная консоль; Ethernet или Wi-Fi — связь нижнего уровня с бортовым компьютером, где живут зрение и планирование.

Верхний уровень чаще всего собирают на ROS 2, а на микроконтроллер спускают micro-ROS, делая контроллер полноценным участником той же сети. Прототипы шины удобно писать на Python: python-can работает поверх SocketCAN и даёт один интерфейс для реального адаптера, виртуального vcan0 и записанного лога.

# Узел на Raspberry Pi: читаем телеметрию привода и шлём уставку скорости.
# Перед запуском: sudo ip link set can0 type can bitrate 500000 && sudo ip link set can0 up
import struct
import can

CMD_SPEED, TELEMETRY = 0x201, 0x701     # низкий ID = высокий приоритет: команда важнее телеметрии

with can.Bus(channel="can0", interface="socketcan") as bus:
    # Фильтр живёт в ядре: ненужные кадры не доедут до Python и не съедят такты
    bus.set_filters([{"can_id": TELEMETRY, "can_mask": 0x7FF, "extended": False}])
    bus.send(can.Message(arbitration_id=CMD_SPEED, is_extended_id=False,
                         data=struct.pack("<hh", 1200, -1200)))   # об/мин на два колеса
    msg = bus.recv(timeout=0.1)         # таймаут обязателен: молчание — штатный отказ
    if msg is None:
        raise TimeoutError("привод не отвечает — останавливаем движение")
    rpm_l, rpm_r, temp_c = struct.unpack("<hhb", msg.data[:5])
    print(f"факт: {rpm_l} / {rpm_r} об/мин, температура драйвера {temp_c} °C")

Обратите внимание на фильтр: он ставится в ядре, до попадания кадра в пространство пользователя. На микроконтроллере ту же роль играют аппаратные фильтры приёмных ящиков контроллера CAN — узел, слушающий все кадры шины на 500 кбит/с, тратит на бесполезные прерывания заметную долю процессора.

Дальше эта картина разворачивается в статьях про датчики и приводы и робототехнику. На портале тема продолжена в разделе НИР, где роботизированные системы разобраны уровнем выше — как архитектура из сервисов, а не как отдельный контроллер: определение и классификация роботизированных систем, теоретическое обоснование микросервисной архитектуры и программная реализация сервисов. Полезно читать сразу после этой статьи: видно, во что превращается «кадр на шине», когда над ним вырастает распределённая система.

Типичные ошибки

  1. Забыть общий провод между платами. Уровни отсчитываются от земли; без общей земли «ноль» у двух устройств отличается на непредсказуемую величину. Симптом — редкие необъяснимые искажения.
  2. Тянуть I2C кабелем. Метр шлейфа — ёмкость, при которой даже 100 кГц работают на грани. Между платами идут UART, RS-485 или CAN.
  3. Ставить подтяжки I2C на каждой плате и терминаторы CAN на каждом узле. Три подтяжки по 4,7 кОм дают параллельно 1,6 кОм, и слабые ведомые перестают дотягивать линию до нуля; пять терминаторов по 120 Ом дают 24 Ом, и доминантный уровень создать невозможно. Подтяжка на шине одна, терминатора ровно два — на физических концах.
  4. Проверять первый запуск CAN одним узлом. Без второго узла нет ACK, и вы будете искать ошибку в коде. Минимальный стенд — два узла или USB-адаптер.
  5. Верить, что SPI прочитал данные. Отключённый чип возвращает 0xFF или 0x00 — валидные байты. Проверка идентификатора при инициализации обязательна.
  6. Держать CS опущенным между транзакциями. Многие устройства завершают команду по фронту CS, и «оптимизация» ломает протокол непредсказуемо.
  7. Делать длинный обработчик прерывания приёма. Разбор пакета и CRC внутри ISR дают overrun на следующем байте: ISR кладёт байт в буфер и выходит.
  8. Считать проценты ошибки битрейта необязательными. 2,1 % расхождения на внутреннем RC-генераторе — это связь, которая работает на столе и отваливается на морозе.
  9. Отлаживать протокол принтами. Тайминги уедут, явление исчезнет — логический анализатор стоит дешевле одного вечера отладки.

Мини-итог

Четыре шины — четыре ответа на одни и те же три вопроса.

  • UART: время каждый считает сам, границы сообщения не размечены вовсе, при ошибке приёмник поднимает флаг и живёт дальше. Отсюда простота, требование к точности генератора и необходимость своего кадрирования (COBS + CRC).
  • I2C: время задаёт мастер, границы задают START и STOP, при ошибке — NACK. Отсюда экономия ножек, потолок скорости из-за подтяжек, ограничение по расстоянию и риск залипания, от которого нужна процедура восстановления.
  • SPI: время задаёт мастер, границы задаёт ножка CS, а на ошибку у протокола ответа нет вообще. Отсюда максимальная скорость, расход ножек и обязательная собственная проверка живости устройства.
  • CAN: время восстанавливается из фронтов, границы задаёт кадр со стаффингом и CRC, на ошибку отвечает целая машина изоляции сбойных узлов. Отсюда живучесть, приоритеты, вычислимая верхняя граница задержки — и цена в виде трансиверов и терминаторов.

Выбор между ними — это выбор между ножками, скоростью, расстоянием и живучестью, и считать надо до разводки платы: перепаять шину дороже, чем переписать код. Главное правило работы: при любой проблеме со связью сначала прибор, потом гипотеза. Один взгляд на декодированную транзакцию в PulseView заменяет три часа чтения кода.

Источники

Что дальше

RTOS: задачи, планировщик, синхронизация, FreeRTOS на практике — разберём, что происходит, когда обменов становится несколько и каждый требует своего темпа: как задачи делят процессор, почему приоритеты планировщика устроены так же, как приоритеты идентификаторов в CAN, чем очередь отличается от кольцевого буфера, откуда берётся инверсия приоритетов и как драйвер шины из этой статьи превращается в задачу с очередью запросов.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов