Всем привет. В прошлом году я писал про то, как я сделал компьютер на дискретных логических микросхемах. После того, как были сделаны процессор, видеокарта, интерфейсы клавиатуры и SD-карты, оставалось два классических модуля, которые есть в обычных компьютерах, но нет в моем: звук и сеть. Как вы уже поняли из названия, начать я решил с сетевой карты. Но сделать с нуля весь сетевой модуль, где будет и синхронизация, и раскодирование манчестерского кода, и фильтрация по MAC-адресу, и проверка контрольной суммы, и сохранение пакетов в память, показалось мне слишком сложным, поэтому я начал с минимума: сделал адаптер, преобразующий сигнал 10BASE-T в SPI и обратно.
Стандарт 10BASE-T
10BASE-T использует две дифференциальных пары: одну на прием, вторую на передачу. Сигнал передается манчестерским кодом с частотой 10 МГц: каждые 100 нс должен произойти переход дифференциального сигнала через 0 Вольт. Переход от отрицательной разницы напряжений к положительной означает логическую единицу. Байты передаются младшим битом вперед.
Данные передаются кадрами, которые начинаются с фиксированной последовательности октетов (байт) для синхронизации. Эта последовательность состоит из семи байт 0x55 и восьмого байта 0xD5, то есть, после чередующихся единиц и нулей идут две единицы, после которых идет уже само содержимое кадра.
Кроме того, при отсутствии данных на линии должны присутствовать с периодом в 16 мс регулярные положительные импульсы шириной 100 нс, называемые Normal Link Pulse (NLP). По этим импульсам устройство на другой стороне узнает, что с нашей стороны что-то подключено, и начинает передавать нам пакеты.
Приемник
Если посмотреть на манчестерский код, то видно, что сами данные уже присутствуют на линии в явном виде сразу после каждого перехода.
Чтобы из этого получить SPI, достаточно "всего лишь" добавить тактовый сигнал, по изменению которого будут защелкиваться данные с линии.
Генерация тактового сигнала
Когда я искал похожие проекты, я нашел этот пост. Оттуда я позаимствовал входной каскад приемника. Сначала нужно преобразовать дифференциальный сигнал в пятивольтовые логические уровни, для этого используется микросхема 75c1168. Затем выделяется сигнал, положительные всплески которого происходят при изменении логического уровня на входной линии. Это сделано при помощи исключающего ИЛИ между входной линией и ею же, пропущенной через два логических элемента для небольшой задержки.
Дальше в исходном посте происходит что-то слишком сложное, поэтому я решил действовать по-другому. Этих двух сигналов (базового и производного) почти достаточно, чтобы сделать SPI: переходы из нуля в единицу уже есть везде, где нужно защелкнуть бит. Но эти переходы встречаются не только в нужных местах, а еще иногда и посередине тактов, поэтому не все так просто.
Моя идея, как сделать настоящий тактовый сигнал, в том, чтобы производный сигнал подать на вход ждущего одновибратора, который выдаст импульс длиной 75 нс. Если в течение этого времени на вход придет еще один всплеск, он будет проигнорирован.
А вот схема одновибратора, который генерирует эти импульсы:
В спокойном состоянии на линии 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:
Поэтому в первом прототипе передатчик был очень простым:
Я надеялся, что NLP-импульсы можно будет генерировать программно, не усложняя схему. Но, видимо, мне не удалось соблюсти интервалы достаточно точно, потому что мой адаптер не определялся ни одним из сетевых устройств, которые у меня были. И мне пришлось сделать аппаратный генератор этих импульсов:
С этим генератором один наименее привередливый ноутбук начал воспринимать мой адаптер и посылать ему данные. Остальные устройства, в том числе домашний роутер, все равно не зажигали лампочку на порту. Я предположил, что проблема в том, что мой NLP неправильной формы: из-за того, что выходной усилитель всегда включен и выдает либо положительную, либо отрицательную разницу напряжений, а после него стоит трансформатор внутри разъема, вместо отдельного положительного импульса получается положительный, а затем отрицательный. Это путает детектор полярности в роутере, и он отказывается работать с этим портом.
Поэтому пришлось еще раз переделать передатчик и генерировать сигнал, включающий выходной усилитель.
Здесь сигналы driver_ena
и driver_in
идут на вход к 75c1168 (не показан на схеме). U8D делает Манчестерский код из SPI. Q3 и U2C образуют одновибратор, генерирующий сигнал, по которому драйвер активируется во время передачи данных. Этот же сигнал используется для подавления импульсов NLP во время передачи. U8A выступает в качестве буфера, который не дает ёмкости затвора Q3 вносить неоднородность во входной сигнал. Без этого SCK оказывается слегка сдвинут относительно MOSI, что ведет к мусору после XOR.
С таким передатчиком почти все компьютеры и свитчи, которые у меня есть, стали стабильно определять подключение. Только один упертый роутер до сих пор отказывается включать порт. Мне кажется, это связано с тем, что всё-таки NLP недостаточно точный.
Эволюция прототипа
Да, такая паутина проводов и односторонние самодельные платы работали на 10 МГц.
Обещанный бот на Rust
Принимать и передавать данные по SPI на частоте 10 МГц легко может STM32. У меня была отладочная плата с stm32f100, которую я и использовал с первым прототипом. Я настроил DMA для приема и передачи данных, а потом полученный кадр передавал в smoltcp. Таким образом удалось получить IP-адрес по DHCP, отвечать на пинг и даже сделать веб-сервер с одной страничкой. Но у stm32f100 очень мало памяти: всего 8 кБ. Этого едва хватило на сетевой стек. Поэтому я взял другой контроллер: stm32f401 c 96 кБ ОЗУ на плате Nucleo-64. Под нее я сделал окончательный вариант адаптера, заказав плату на заводе.
Ну и к программированию. Чтобы написать телеграм-бота, нужно:
TCP/IP стек.
TLS.
Сериализовать и десериализовать структуры, определенные в API для ботов.
Написать бизнес-логику.
Стек 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<Interface<'a, SpiDevice>>,
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<usize, TcpSocketAdapterError> {
let mut iface = self.adapter.iface.borrow_mut();
let sock = iface.get_socket::<TcpSocket>(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<usize, TcpSocketAdapterError>;
fn poll(
mut self: core::pin::Pin<&mut Self>,
ctx: &mut core::task::Context<'_>,
) -> Poll<<Self as Future>::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<Rsp, TgBotError> {
// ...
}
Внутри мы подключаемся к серверу по 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::<Rng, NoClock, 4096>(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-буфер.
Комментарии (17)
Wesha
18.12.2022 13:12+4Тепло, лампово. Чем-то напоминает как я начинал сборку своего самого-самого первого
компьютеравычислительного устройства с отдельных логических элементов...
kuza2000
18.12.2022 14:26+6Вспомнил, как я делал программную реализацию этого манчестерского протокола на ассемблере i8080 просто слушая порт КР80ВВ55. На компьютерах Радио-86рк, Вектор06ц и на "Корвете", для записи на кассетный магнитофон.
Байт синхронизации там был E6. 30 лет прошло, а все помню!))
gleb_l
18.12.2022 18:58+2..а меня, наоборот, убивало то, что в РК все программно и из последних сил - и я подпатчил Монитор, убрав EI/DI как генератор звука, поставил контроллер шины, прерываний и ВВ51 с кодером/декодером Манчестера в синхронный последовательный код как раз подобным способом - для нормального обмена с магнитофоном без срыва ПДП и всех этих воркэраундов с регенерацией. Помню, качественный магнитофон позволял 9600 бит/с читать/писать запросто )
nixtonixto
18.12.2022 17:05+7если данных приходит много, обычно кадры идут подряд с минимальным интервалом. Моя программа не успевает так быстро перенастроить DMA на новый буфер, поэтому часть кадров в таком случае теряется.
Включите кольцевой буфер — тогда ничего перенастраивать не надо. Если не упёрлись в лимит по ОЗУ.
axe_chita
19.12.2022 04:19+10Астрологи объявили неделю годных статей на Хабре? Как хорошо! А можно на месяц?
Дабл плюс!
WondeRu
19.12.2022 11:23+7– А я умею писать телеграм боты без фреймворков, на чистых http запросах.
– А я умею на транзисторах!
– ????
Респект)
maeris
19.12.2022 12:28-1Ах, какая же годнота.
По этим импульсам устройство на другой стороне узнает, что с нашей стороны что-то подключено
Я совсем не эксперт, но. По этим импульсам устройство на другой стороне синхронизирует часы через phase-locked loop. Если PLL не использовать, у вас часы не будут выравниваться на принимающей стороне, и вы прочитаете биты там, где их нет. Более быстрые сорта эзернета используют 8b/10b кодирование, чтобы отдельные битовые ошибки чинить (в том числе от несинхронизированных часов), а 10BASE-T на такой абьюз не рассчитан, и после проверки чексуммы теряется пакет целиком.
Кроме того, у вас magnetics какие-то странные, без common-mode choke.
UPD. Блин, там в оригинальной статье не только PLL есть, но ещё и параметры честно посчитаны через control theory. Это не "что-то слишком сложное", это обязательная часть!
ynoxinul Автор
19.12.2022 13:00+1Часы не синхронизируются по NLP, они синхронизируются по преамбуле.
lesha108
Я правильно понял, что у Вас свой асинхронный движок + еще RTIC? Возможно ли обойтись без RTIC?
ynoxinul Автор
Да, возможно. По сути RTIC у меня используется для удобного создания статических мутабельных переменных без unsafe.
lesha108
Спасибо, понятно. А embassy не пробовали в качестве движка и HAL?
ynoxinul Автор
Нет, не пробовал, но знаю о его существовании :) Не хотелось всё переделывать под конец.