Пишем телеграм-бота на Rust, предаврительно спаяв сетевую карту

Всем привет. В прошлом году я писал про то, как я сделал компьютер на дискретных логических микросхемах. После того, как были сделаны процессор, видеокарта, интерфейсы клавиатуры и SD-карты, оставалось два классических модуля, которые есть в обычных компьютерах, но нет в моем: звук и сеть. Как вы уже поняли из названия, начать я решил с сетевой карты. Но сделать с нуля весь сетевой модуль, где будет и синхронизация, и раскодирование манчестерского кода, и фильтрация по MAC-адресу, и проверка контрольной суммы, и сохранение пакетов в память, показалось мне слишком сложным, поэтому я начал с минимума: сделал адаптер, преобразующий сигнал 10BASE-T в SPI и обратно.

Стандарт 10BASE-T

10BASE-T использует две дифференциальных пары: одну на прием, вторую на передачу. Сигнал передается манчестерским кодом с частотой 10 МГц: каждые 100 нс должен произойти переход дифференциального сигнала через 0 Вольт. Переход от отрицательной разницы напряжений к положительной означает логическую единицу. Байты передаются младшим битом вперед.

Данные передаются кадрами, которые начинаются с фиксированной последовательности октетов (байт) для синхронизации. Эта последовательность состоит из семи байт 0×55 и восьмого байта 0xE5, то есть, после чередующихся единиц и нулей идут две единицы, после которых идет уже само содержимое кадра.

Структура кадраСтруктура кадра

Кроме того, при отсутствии данных на линии должны присутствовать с периодом в 16 мс регулярные положительные импульсы шириной 100 нс, называемые Normal Link Pulse (NLP). По этим импульсам устройство на другой стороне узнает, что с нашей стороны что-то подключено, и начинает передавать нам пакеты.

NLPNLP

Приемник

Если посмотреть на манчестерский код, то видно, что сами данные уже присутствуют на линии в явном виде сразу после каждого перехода.

Манчестерский кодМанчестерский код

Чтобы из этого получить SPI, достаточно «всего лишь» добавить тактовый сигнал, по изменению которого будут защелкиваться данные с линии.

Тактовый сигналТактовый сигнал

Генерация тактового сигнала

Когда я искал похожие проекты, я нашел этот пост. Оттуда я позаимствовал входной каскад приемника. Сначала нужно преобразовать дифференциальный сигнал в пятивольтовые логические уровни, для используется микросхема 75c1168. Затем выделяется сигнал, положительные всплески которого происходят при изменении логического уровня на входной линии. Это сделано при помощи исключающего ИЛИ между входной линией и ею же, пропущенной через два логических элемента для небольшой задержки.

Позаимствованная схема входного каскадаПозаимствованная схема входного каскада

Дальше в исходном посте происходит что-то слишком сложное, поэтому я решил действовать по-другому. Этих двух сигналов (базового и производного) почти достаточно, чтобы сделать SPI: переходы из нуля в единицу уже есть везде, где нужно защелкнуть бит. Но эти переходы встречаются не только в нужных местах, а еще иногда и посередине тактов, поэтому не все так просто.

Моя идея, как сделать настоящий тактовый сигнал, в том, чтобы производный сигнал подать на вход ждущего одновибратора, который выдаст импульс длиной 75 нс. Если в течение этого времени на вход придет еще один всплеск, он будет проигнорирован.

Генерация тактового сигнала для SPIГенерация тактового сигнала для SPI

А вот схема одновибратора, который генерирует эти импульсы:

Схема одновибратораСхема одновибратора

В спокойном состоянии на линии edge ноль, поэтому U4C (логическое И) тоже выдает ноль. Транзистор закрыт, а резистор R5 подтягивает входную ножку U2E к 5 Вольтам. Эта логическая единица проходит через оба инвертора и возвращается на второй вход U4C. Когда edge становится единицей, на выходе этого элемента тоже возникает высокий уровень, который открывает транзистор. Конденсатор быстро разряжается, и логический уровень на входе и на выходе цепочки инверторов меняется на ноль. Этот ноль вынуждает U4C выдать ноль на выходе, что снова закрывает транзистор. Конденсатор начинает медленно заряжаться, и, пока напряжение на нем не достигнет порогового значения, логический ноль остается на втором входе U4C. Если в это время придут новые импульсы на вход, они не будут пропущены через U4C.

Определение начала и конца кадра

Чтобы отделить один кадр от другого, нужен еще один сигнал, который будет изменять свое состояние на время приема кадра. Этот сигнал я тоже сделал на одновибраторе. Разница в том, что тут каждый новый поступивший на вход импульс не игнорируется, а продлевает выходной импульс.

Входной каскад и детектор кадраВходной каскад и детектор кадра

Здесь одновибратор проще: он образован транзистром Q1 и инвертором U2F. Еще на этой схеме виден переключатель SW1, который позволяет инвертировать входящий сигнал. Это сделано для того, чтобы мой приемник работал с теми устройствами, в которых перепутана полярность выходного сигнала. Да, такие существуют, но друг с другом они работают без проблем благодаря автоматическому определению полярности в коммерческих приемниках.

Фильтрация лишнего фронта в начале

Согласно документации микросхемы 75c1168, если на дифференциальном входе компаратора близкие значения (то есть, до передачи кадра, когда на линии тихо), то значение на его выходе не определено. И если это неопределенное значение оказывается единицей (как было в моем случае), то в начале кадра возникает лишний фронт сигнала, так как любой кадр начинается с бита 1 (переход из низкого уровня в высокий):

Лишний фронт в началеЛишний фронт в начале

Этот лишний фронт нужно проигнирировать, иначе тактовый сигнал будет сдвинут и данные будут получены неправильно. Я это делаю при помощи элементов U5D и U4D и RC-цепочки R6-C10 (см. предыдущую схему). U4D пропускает сигнал только в том случае, если значение на линии только что было нулем или (U5D) если уже был фронт. В моем случае «дефолтное» значение на выходе 75c1168 было всегда единицей, поэтому проверить оба случая не получилось.

Несколько фоток с осциллографа

Исходный сигнал (голубой) и производный (желтый)Исходный сигнал (голубой) и производный (желтый)Удаление лишнего первого фронтаУдаление лишнего первого фронтаСгенерированный тактовый сигнал (голубой) и исходный (желтый)Сгенерированный тактовый сигнал (голубой) и исходный (желтый)Сгенерированный тактовый сигнал (голубой) и исходный (желтый)Сгенерированный тактовый сигнал (голубой) и исходный (желтый)

Так выглядел первый прототип приемникаТак выглядел первый прототип приемника

Синхронизация

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

Схема, осуществляющая синхронизациюСхема, осуществляющая синхронизацию

Здесь триггеры U6A, U6B и U7A образуют сдвиговый регистр, в который последовательно вдвигаются принятые биты. А в третий триггер защелкивается единица, как только было вдвинуто две единицы подряд. U4B фильтрует тактовый сигнал, чтобы он начинался не раньше фактического начала данных.

Передатчик

Манчестерский сигнал легко получить из SPI, просто пропустив обе линии через XOR:

49f4b9c99e85b29885126e4a13d41de9.png

Поэтому в первом прототипе передатчик был очень простым:

7d3e59532f56d300bbaf89533936e3e7.png

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

Генератор NLPГенератор NLP

С этим генератором один наименее привередливый ноутбук начал воспринимать мой адаптер и посылать ему данные. Остальные устройства, в том числе домашний роутер, все равно не зажигали лампочку на порту. Я предположил, что проблема в том, что мой NLP неправильной формы: из-за того, что выходной усилитель всегда включен и выдает либо положительную, либо отрицательную разницу напряжений, а после него стоит трансформатор внутри разъема, вместо отдельного положительного импульса получается положительный, а затем отрицательный. Это путает детектор полярности в роутере, и он отказывается работать с этим портом.

683a1850433034e01ecf749f9d9a0937.png

Поэтому пришлось еще раз переделать передатчик и генерировать сигнал, включающий выходной усилитель.

Схема передатчикаСхема передатчика

Здесь сигналы driver_ena и driver_in идут на вход к 75c1168 (не показан на схеме). U8D делает Манчестерский код из SPI. Q3 и U2C образуют одновибратор, генерирующий сигнал, по которому драйвер активируется во время передачи данных. Этот же сигнал используется для подавления импульсов NLP во время передачи. U8A выступает в качестве буфера, который не дает ёмкости затвора Q3 вносить неоднородность во входной сигнал. Без этого SCK оказывается слегка сдвинут относительно MOSI, что ведет к мусору после XOR.

С таким передатчиком почти все компьютеры и свитчи, которые у меня есть, стали стабильно определять подключение. Только один упертый роутер до сих пор отказывается включать порт. Мне кажется, это связано с тем, что всё-таки NLP недостаточно точный.

Эволюция прототипа

Приемник и передатчик с stm32vldiscovery (stm32f100)Приемник и передатчик с stm32vldiscovery (stm32f100)Приемник, передатчик и плата синхронизации с Nucleo-64 (stm32f401)Приемник, передатчик и плата синхронизации с Nucleo-64 (stm32f401)

Да, такая паутина проводов и односторонние самодельные платы работали на 10 МГц.

Обещанный бот на Rust

Принимать и передавать данные по SPI на частоте 10 МГц легко может STM32. У меня была отладочная плата с stm32f100, которую я и использовал с первым прототипом. Я настроил DMA для приема и передачи данных, а потом полученный кадр передавал в smoltcp. Таким образом удалось получить IP-адрес по DHCP, отвечать на пинг и даже сделать веб-сервер с одной страничкой. Но у stm32f100 очень мало памяти: всего 8 кБ. Этого едва хватило на сетевой стек. Поэтому я взял другой контроллер: stm32f401 c 96 кБ ОЗУ на плате Nucleo-64. Под нее я сделал окончательный вариант адаптера, заказав плату на заводе.

Готовый адаптер, установленный на Nucleo-64Готовый адаптер, установленный на Nucleo-64

Ну и к программированию. Чтобы написать телеграм-бота, нужно:

  1. TCP/IP стек.

  2. TLS.

  3. Сериализовать и десериализовать структуры, определенные в API для ботов.

  4. Написать бизнес-логику.

Стек TCP/IP

Как я уже упоминал, я не пишу свой TCP/IP стек, а использую smoltcp. На вход этой библиотеке нужно передать объект, реализующий трейт Device. В моем случае получилась довольно простая обертка над приемником и передатчиком, в которых уже спрятана вся логика работы с SPI и DMA. Приемник пришлось завернуть в специальный мьютекс, основанный на критических секциях, потому что он используется не только изнутри smoltcp, а еще и из обработчика прерывания: прерывание происходит в конце кадра, и нужно сообщить приемнику, чтобы он остановил прием данных и подключил к DMA новый буфер, готовый для приема следующего кадра.

TLS

TLS нужно, поскольку общение с сервером Telegram происходит по HTTPS. Я взял библиотеку embedded-tls. Принцип работы у нее простой: ты ей свой сокет, она тебе свой, зашифрованный. Но обычную блокирующую версию embedded-tls использовать нельзя, так как мы не можем просто ждать, пока в сокете появятся данные, а должны постоянно поллить интерфейс (так работает smoltcp). К счастью, у embedded-tls есть асинхронная версия, которая решает эту проблему. К несчастью, пришлось написать асинхронную обертку для синхронных сокетов smoltcp. Обертка содержит в себе сетевой интерфейс, хэндл сокета и указатель на функцию получения текущего времени:

type GetTicks = fn() -> i64;

pub struct TcpSocketAdapter<'a> {
    iface: &'a RefCell>,
    handle: SocketHandle,
    pub get_ticks: GetTicks,
}

Этот адаптер должен реализовать необходимые для embdedded-tls асинхронные трейты. На примере Read:

pub struct ReadFuture<'socket, 'adapter, 'buf>
where
    'socket: 'adapter,
    'socket: 'buf,
{
    adapter: &'adapter mut TcpSocketAdapter<'socket>,
    buf: &'buf mut [u8],
}

impl<'socket> Read for TcpSocketAdapter<'socket> {
    fn read<'adapter, 'buf>(
        &'adapter mut self,
        buf: &'buf mut [u8],
    ) -> ReadFuture<'socket, 'adapter, 'buf> {
        ReadFuture { adapter: self, buf }
    }
}

impl<'socket, 'adapter, 'buf> ReadFuture<'socket, 'adapter, 'buf> {
    fn recv(&mut self) -> Result {
        let mut iface = self.adapter.iface.borrow_mut();
        let sock = iface.get_socket::(self.adapter.handle);
        sock.recv_slice(self.buf).map_err(Into::into)
    }
}

impl<'socket, 'adapter, 'buf> Future for ReadFuture<'socket, 'adapter, 'buf> {
    type Output = Result;
    fn poll(
        mut self: core::pin::Pin<&mut Self>,
        ctx: &mut core::task::Context<'_>,
    ) -> Poll<::Output> {
        if let Err(e) = self.adapter.poll() {
            return Poll::Ready(Err(e));
        }
        if !self.adapter.is_active() {
            return Poll::Ready(Err(TcpSocketAdapterError::Smoltcp(smoltcp::Error::Dropped)));
        }
        if self.adapter.can_recv() {
            Poll::Ready((*self).recv())
        } else {
            ctx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

Здесь read — асинхронный метод, возвращающий ReadFuture. ReadFuture реализует трейт Future, имеющий единственный метод poll. В этом методе мы вызываем poll уже у интерфейса и проверяем, не появились ли в сокете данные.

Теперь можно писать асинхронную функцию, содержащую бизнес-логику и использующую embedded-tls.

pub async fn bot_task(
    seed: u64,
    adapter1: TcpSocketAdapter<'_>,
    mut adapter2: TcpSocketAdapter<'_>,
    bt_press_consumer: &mut crate::event::BtnPressConsumer,
) -> ! {
    // ...
}

Осталась одна небольшая проблема: у нас нет асинхронного рантайма вроде tokio. Но это не беда. Сделать свой асинхронный рантайм очень просто, особенно если не надо думать об экономии процессорного времени. Просто будем поллить Future в цикле:

let iface_cell = RefCell::new(iface);
let adapter1 = TcpSocketAdapter::new(&iface_cell, tcp_handle_1, || {
    monotonics::now().ticks() as i64
});
let adapter2 = TcpSocketAdapter::new(&iface_cell, tcp_handle_2, || {
    monotonics::now().ticks() as i64
});
{
    let mut task = bot_task::bot_task(
        seed,
        adapter1,
        adapter2,
        ctx.local.bt_press_consumer,
    );
    let mut task_pin = unsafe { core::pin::Pin::new_unchecked(&mut task) };
    let mut ctx = Context::from_waker(noop_waker_ref());
    let mut result = Poll::Pending;
    while result.is_pending() {
        result = task_pin.as_mut().poll(&mut ctx);
    }
};

Общение с сервером телеграма

Любое обращение к API состоит из отправки HTTP-запроса. Я использую POST-запросы и отправляю параметры, сериализованные в JSON. Сериализую данные я с помощью serde.

Для отправки запроса у меня есть следующая асинхронная функция, отправляющая и возвращающая любые сериализуемые объекты:

async fn api_post<
    'a,
    Rng: CryptoRng + RngCore,
    Req: serde::Serialize,
    Rsp: serde::Deserialize<'a>,
    const PARAMS_LEN: usize,
>(
    method: &str,
    req: Req,
    rx_buf: &'a mut [u8],
    adapter: &mut TcpSocketAdapter<'_>,
    rng: &mut Rng,
) -> Result {
    // ...
}

Внутри мы подключаемся к серверу по TCP, выбирая случайным образом эфемерный порт:

let server_ip = IpAddress::from_str("149.154.167.220").unwrap();
let local_port: u16 = 50000 + (rng.next_u32() % 15535) as u16;
adapter.connect((server_ip, 443), local_port).await?;

Далее открываем TLS-соединение, используя буфер на стеке:

let mut record_buffer = [0 as u8; 16384];
let config = TlsConfig::new()
    .with_server_name("api.telegram.org")
    .verify_cert(false);
let mut conn: TlsConnection<_, Aes128GcmSha256> =
    TlsConnection::new(adapter, &mut record_buffer);
conn.open::(TlsContext::new(&config, rng))
    .await?;

Сериализуем параметры и вручную создаем заголовок HTTP-запроса:

let params_json: Vec<_, PARAMS_LEN> = serde_json_core::to_vec(&req).unwrap();
let mut request_str: String<256> = String::new();
write!(&mut request_str, "POST /bot{}/{} HTTP/1.1\r\nHost: api.telegram.org\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n",
    bot_token::BOT_TOKEN, method, params_json.len()).ok();

Теперь можно передать запрос, получить ответ и распарсить его с помощью httparse:

conn.write(request_str.as_bytes()).await?;
conn.write(¶ms_json).await?;
let rsp_len = conn.read(rx_buf).await?;
let mut headers = [httparse::EMPTY_HEADER; 24];
let mut rsp = httparse::Response::new(&mut headers);
let data_offset = match rsp.parse(&rx_buf[..rsp_len]) {
    Ok(httparse::Status::Complete(len)) => len,
    Ok(httparse::Status::Partial) => return Err(TgBotError::ResponseOverflow),
    Err(e) => return Err(TgBotError::HttpParseError)
};
if rsp.code != Some(200) {
    return Err(TgBotError::HttpCodeNotOk);
}

Осталось десериализовать данные и закрыть сокет.

Бизнес-логика бота

Мой бот обладает минимальной функциональностью: он умеет отправлять обратно принятое сообщение и сообщать, если нажата кнопка на плате. Для этого и нужно два сокета в функции bot_task: один ждет новых сообщений, второй независимо отправляет их.

В асинхронной bot_task нужно ждать одновременно двух событий: нажатия кнопки и получения сообщения от сервера. Для этого я использую select. Пришлось побороться с компилятором, чтобы убедить его следующей итерации цикла использовать старое Future, если оно еще не выполнилось. Кода слишком много, чтобы приводить его тут, поэтому интересующихся приглашаю в репозиторий.

Результат и дальнейшие планы

Бот работает стабильно, но отвечает на сообщение довольно долго, в течение 3–4 секунд. Это происходит из-за большого количества потерянных пакетов: если данных приходит много, обычно кадры идут подряд с минимальным интервалом. Моя программа не успевает так быстро перенастроить DMA на новый буфер, поэтому часть кадров в таком случае теряется. Но TCP справляется с этим и стабильно принимает все данные.

Если TCP-трафика нет, то пинг проходит за 1,6 мс практически без потерь.

Дальше я планирую делать вторую часть сетевого адаптера, которая уже будет подключаться к дискретному компьютеру. Она должна будет фильтровать пакеты по MAC-адресу и контрольной сумме, а также сохранять их в FIFO-буфер.

© Habrahabr.ru