Первый содержательный комментарий пришёл через 4 часа после публикации. И он был про то, что я написал неправильно.
Читатель @arteast заметил: перед записью каждого байта в UART надо дождаться, пока передатчик освободится, а сам UART сначала настроить. У меня в коде ничего этого не было. Байты просто клались в регистр один за другим, и всё работало.
Работало, потому что эмулятор. На настоящей плате такой код рассыпается.
Я сел проверять. Собрал два варианта, свой и правильный, прогнал на строке в 231 байт. Вывод получился побайтово одинаковый. Поставил скорость линии помедленнее, чтобы наивный код начал терять данные. Не начал. Написал программу, которая пишет байт и следующей же инструкцией смотрит, занят ли передатчик. Передатчик всегда свободен.
То есть моя ошибка внутри эмулятора не проявляется вообще. Никак. Проверить себя было нечем.
Дальше вечер ушёл на то, чтобы разобраться почему, и на то, чтобы это исправить. Не в своём коде. В эмуляторе.
Что получится в конце
Правильный вывод в UART, который не развалится на железе. Пропатченный QEMU, который умеет выдерживать настоящую скорость линии и ловить такие ошибки. И, как обещал в прошлый раз, арифметика:

Второе число там не для красоты. Выяснится, что напечатать 4 легко, а напечатать 22345 нельзя, потому что в нашем процессоре нет команды деления.
Работа над ошибками
Вот что было в первой статье:
next_char: lbu t2, 0(t1) beqz t2, done sb t2, 0(t0) /* кладём байт в UART и сразу за следующим */ addi t1, t1, 1 j next_char
Взяли байт, положили в регистр передатчика, пошли за следующим. Ни секунды на размышления.
Проблема в том, что передатчик не мгновенный. Он выдаёт байт по проводу бит за битом, и на обычной для плат скорости 115200 бод один байт уезжает 87 микросекунд. Кажется, что мало. Но процессор за это время успевает выполнить тысячи инструкций и записать сотни новых байт. Каждый следующий затирает предыдущий, и по линии уходит один символ из сотни.
У микросхемы NS16550A, которую эмулирует QEMU, для этого есть регистр состояния линии, LSR, по смещению 5 от базы. Пятый бит в нём называется THRE, Transmitter Holding Register Empty. Пока он снят, передатчик занят и писать нельзя.
Правильный цикл отличается тремя инструкциями:
wait_thre: lbu t3, UART_LSR(t0) andi t3, t3, LSR_THRE beqz t3, wait_thre
Читаем LSR, оставляем один бит, крутимся, пока он не появится. Всё.
Вот как эти три инструкции меняют дело. Сверху то, что было, снизу то, что стало:

Заодно оказалось, что линию перед работой надо настроить: задать скорость и формат кадра. В эмуляторе она работает и так, на плате нет.
li t4, LCR_DLAB /* открываем доступ к делителю скорости */ sb t4, UART_LCR(t0) li t4, DIVISOR sb t4, UART_THR(t0) li t4, 0 sb t4, UART_DLM(t0) li t4, LCR_8N1 /* закрываем и задаём 8 бит, без чётности */ sb t4, UART_LCR(t0)
Тут стоит остановиться, потому что кусок выглядит странно: делитель скорости почему-то пишется по тому же адресу, что и байты для отправки. Так и есть. У UART всего восемь адресов, а регистров больше, и один бит переключает, что именно лежит по адресам 0 и 1:

Полный исправленный вариант лежит в репозитории, в каталоге 01-hello-fixed.
Почему эмулятор меня не поймал
Тут начинается самое интересное.
Я хотел показать разницу: вот наивный код, вот правильный, смотрите, первый теряет данные. Не получилось. Оба варианта выводили всё до последнего байта.
Тогда я попробовал прижать эмулятор к стенке. Выставил делитель скорости на медленную линию, чтобы каждый байт уезжал долго. Ничего не изменилось: ни объёма вывода, ни времени работы.
Написал программу на девять строк. Она кладёт байт в передатчик и следующей же инструкцией читает LSR, печатая 1, если передатчик свободен, и 0, если занят. Десять раз подряд:
X1X1X1X1X1X1X1X1X1X1
Ни одного нуля. Передатчик свободен всегда.
Полез в исходники QEMU, в файл hw/char/serial.c. Вот ветка обработки записи в регистр передатчика:
s->lsr &= ~UART_LSR_THRE; s->lsr &= ~UART_LSR_TEMT; serial_update_irq(s); if (s->tsr_retry == 0) { serial_xmit(s); }
Бит THRE честно сбрасывается. А потом, в этом же обработчике, синхронно вызывается serial_xmit(), и внутри неё бит выставляется обратно. Между этими двумя событиями гость не успевает выполнить ни одной инструкции.
Вот вся разница на одной картинке. Сверху то, что было, снизу то, что стало после патча:

Отдельного таймера передачи в модели нет. Переменная char_transmit_time считается из делителя и формата кадра, всё как положено, но расходуется на таймаут FIFO и опрос модемного статуса. На темп отправки она не влияет никак.
Здесь я сначала сделал вывод пошире, чем следовало, и меня поправили в комментариях уже ко второй версии этого текста. Поэтому уточню сразу.
QEMU умеет снимать THRE. Просто он не решает сам, когда это делать, а перекладывает вопрос на backend, то есть на то, куда воткнут выход UART. Если байты уходят в консоль или в файл, backend глотает их мгновенно и занятым передатчик не бывает никогда. Если же backend начинает тормозить, срабатывает другая ветка: взводится tsr_retry, отправка откладывается до момента, когда получатель снова готов, и всё это время THRE снят по-настоящему.
Я это проверил, уже зная, что искать. Одна и та же программа делает 300 тысяч записей подряд и считает, сколько раз сразу после записи передатчик оказался занят:
Куда идёт вывод |
FIFO |
Сколько раз передатчик был занят |
|---|---|---|
в файл |
выключен |
ни разу |
в файл |
включён |
ни разу |
в медленный сокет |
выключен |
постоянно |
в медленный сокет |
включён |
постоянно |
Дело не в FIFO, как я сначала подумал, а в самом факте затыка на выходе.
Отсюда важное следствие. Если прокинуть UART гостя в настоящий COM-порт хоста, поведение станет честным само собой: QEMU передаст делитель скорости реальному устройству, а реальная линия своей скоростью создаст то самое обратное давление. Тогда и THRE будет сниматься так, как положено.
Моя беда была не в том, что QEMU чего-то не умеет, а в том, что я смотрел на него через backend, который физически не может затормозить. Консоль и файл не тормозят никогда.
Проблеме, как выяснилось, много лет. Есть багрепорт #1086745 с названием «serial port data THRE comes too early», где описан тот же эффект с другой стороны: гость поднимает RTS, шлёт байты, ждёт освобождения передатчика и опускает RTS слишком рано, потому что эмулятор рапортует о готовности раньше времени. Там же предлагается решение, тот самый таймер по char_transmit_time.
Статус багрепорта: Expired. Истёк в марте 2018 года без единого ответа.
Не чинят по понятной причине. Обычный вывод в консоль от этого не страдает, а страдают вещи, чувствительные ко времени. Мало кто на них натыкается.
Чиню эмулятор
Итак, честное поведение получить можно, но для этого нужен настоящий COM-порт на другом конце. У меня его нет, и у большинства читателей тоже. А проверять код хочется на том, что есть.
Значит, научим модель выдерживать темп линии самостоятельно, без физического устройства.
Идея простая. После того как байт отдан наружу, снимаем THRE и взводим таймер на время передачи одного символа. Когда таймер срабатывает, бит возвращается. А запись в занятый передатчик больше не запускает отправку: байт затирает предыдущий и пропадает, ровно как на железе.
Ключевая часть патча выглядит так:
if (serial_pacing_on(s) && !(s->fcr & UART_FCR_FE) && s->pacing_char_time) { s->lsr &= ~UART_LSR_THRE; s->lsr &= ~UART_LSR_TEMT; timer_mod(s->xmit_pacing_timer, qemu_clock_get_ns(QEMU_CLOCK_VIRTUAL) + s->pacing_char_time); }
Важное решение: поведение по умолчанию не меняется. Патч, который молча ломает чужие сценарии, никому не нужен. Всё включается свойствами устройства:
-global serial-mm.xmit-pacing=on темп по делителю, заданному самой программой -global serial-mm.xmit-pacing-baud=115200 скорость снаружи, делитель программы игнорируется
Второе оказалось нужнее первого. Программа из первой статьи линию вообще не настраивает, и задать ей скорость можно только снаружи. Кстати, пока программа молчит, модель QEMU считает линию девятитысячной: в serial_reset зашито 9600. Историческое умолчание, к современным платам отношения не имеющее.
Собирается это на QEMU 9.2.4 одной целью примерно за двенадцать минут. Патч, инструкция и тестовые программы лежат в репозитории, в каталоге qemu-uart-pacing.
Что получилось
Строка 231 байт.
Вариант программы |
Без флага |
115200 бод |
|---|---|---|
Как в первой статье, без ожидания THRE |
231 из 231 |
1 из 231 |
С ожиданием THRE |
231 из 231 |
231 из 231 |
Три прогона каждой строчки, расхождений нет. Скорость взята обычная для плат, но результат от неё не зависит: я прогнал то же самое на 300, 1200 и 9600, наивный вариант везде доносит один байт. Процессор быстрее линии на порядки при любой скорости.
А вот что стало с самой программой из первой статьи. Строка «Привет, мир!», 22 байта:
без флага: 22 байта, «Привет, мир!» 115200 бод: 1 байт, 0xD0
0xD0 это первая половина двухбайтовой буквы «П». Даже не буква. Половина буквы.
Скорость настоящая, а не «медленно против мгновенно»
Это надо было проверить отдельно, иначе грош цена такому стенду. Правильный вариант, 231 байт, замер против расчёта байт × 10 / скорость:
Скорость |
Замер |
Расчёт |
|---|---|---|
300 бод |
8082 мс |
7700 мс |
1200 бод |
2282 мс |
1925 мс |
9600 бод |
656 мс |
240 мс |
115200 бод |
412 мс |
20 мс |
Разница везде примерно одинаковая, около 390 миллисекунд. Это старт эмулятора плюс пауза, которую я добавил в программу, чтобы вывод успел дойти до диска. За вычетом этой постоянной совпадение с расчётом в пределах процентов.
В апстрим патч не отправлял. QEMU принимает вклад только письмом в рассылку, и чтобы это сделать по-человечески, надо переложить всё на master, перевести комментарии на английский и подписаться настоящим именем. Может быть, займусь. Пока просто выкладываю, пользуйтесь.
Теперь обещанное: считаем 2 + 2
После всего этого арифметика выглядит отдыхом.
li a1, 2 li a2, 2 add a0, a1, a2
Три инструкции. Положили двойку в регистр, положили вторую, сложили, результат в третьем. Никакой памяти, никаких переменных. Регистры это и есть рабочий стол процессора: 32 ячейки по 32 бита, и почти все операции происходят между ними.
Теперь напечатать результат. И вот тут первая неожиданность, хотя и небольшая.
В регистре лежит число четыре. Не символ, а число, значение. А терминалу надо отправить байт, который он поймёт как цифру. Код символа 0 равен 48, 1 это 49, и так далее. Значит:
addi a1, a1, '0' /* 4 превращается в 52, то есть в символ '4' */
Четвёрка и символ 4 это разные вещи, отличающиеся на 48. Весь путь числа от сложения до экрана выглядит так:

Пока цифра одна, всё просто.
А теперь 22345, и тут выясняется, что деления нет
С многозначным числом фокус не проходит. Чтобы напечатать 22345, надо получить цифры по одной: 5, 4, 3, 2, 2. Достаются они с конца, потому что последняя цифра это остаток от деления на десять. Дальше делим на десять и повторяем.
Пишу деление, и компилятор говорит, что такой инструкции нет.
Я сначала не поверил и полез в спецификацию. Оказалось, всё верно. В базовом наборе RV32I, который мы собираем флагом -march=rv32i, нет ни деления, ни умножения. Они живут в расширении M, а мы его не подключали.
Это не недосмотр авторов архитектуры. RISC-V собран из кубиков: базовый набор минимальный, остальное добавляется по надобности. Простому микроконтроллеру умножитель ни к чему, а места на кристалле он занимает изрядно.
Значит, делим сами:
div10: li a1, 0 /* здесь копим частное */ li t1, 10 1: blt a0, t1, 2f /* число меньше десяти: остаток готов */ sub a0, a0, t1 addi a1, a1, 1 j 1b 2: mv t2, a0 mv a0, a1 mv a1, t2 jr t3
Вычитаем десятку, пока получается, и считаем, сколько раз получилось. Сколько раз вычли, столько и частное, что осталось, то и остаток.
Вот весь путь числа 22345 до строки, шаг за шагом:

Медленно ли это? Чудовищно. Только на первое деление уйдёт 2234 оборота цикла, а на все пять 2481. Настоящая команда деления уложилась бы в сотню тактов. Но наш вариант работает, и главное, видно, что происходит.
Быстрые способы существуют, и все они начинаются с умножения на обратную величину. Умножения у нас тоже нет.
Ещё две вещи, которые появились впервые
Память, в которую можно писать. До сих пор мы только читали: строка лежала в .rodata, и трогать её не требовалось. Теперь цифры надо где-то собирать, и в линкер-скрипте появилась секция .bss на 16 байт.
Подпрограммы, но без стека. В коде четыре процедуры: putc, print_str, print_dec, div10. Обычно адрес возврата кладут в регистр ra, но у нас print_dec вызывает div10, а тот должен вернуться, не затерев адрес возврата самого print_dec. Хранить второй адрес негде: стека нет.
Поэтому каждая процедура возвращается через свой регистр:
jal t6, putc /* putc вернётся по t6 */ jal t5, print_dec /* print_dec вернётся по t5 */ jal t3, div10 /* div10 вернётся по t3 */

Это работает до тех пор, пока вложенность мелкая и все процедуры свои. Как только их станет десяток, регистры кончатся. Так что стек в следующей статье, и теперь понятно, зачем он вообще нужен.
Где я споткнулся
Главное уже рассказано: я написал в первой статье код, который на железе не работает, и не мог этого заметить, потому что стенд был неспособен показать эту ошибку. Урок скучный, но повторю его словами, которые дошли до меня только сейчас. Инструмент, который никогда не говорит «нет», не является проверкой. Он является декорацией.
Второе. Когда я собрал первую версию стенда с медленным приёмником и увидел потерю 36% данных, я обрадовался и решил, что воспроизвёл поведение железа. Не воспроизвёл. Потери были не от скорости линии, а от переполнения буфера сокета: я гнал 440 КБ, а буфер около 64 КБ. Стоило взять реалистичный объём, и потери исчезли.
Поймал это, только когда посчитал, чему соответствует моя «медленная линия» в бодах. Оказалось, 80 КБ/с, то есть в семь раз быстрее самой ходовой скорости 115200. Мой стенд был мягче железа, а я считал его строже.
Мораль: если строишь модель чего-то физического, посчитай, каким физическим величинам соответствуют твои параметры. До того, как обрадуешься результату.
Третье, мелкое, но съело час. QEMU это нативный бинарник Windows и не понимает путей вида /d/..., которые ему подсовывает MSYS. Файл вывода при этом молча не создаётся. Плюс если гасить эмулятор сразу после последней записи, backend не успевает сбросить буфер, и замер показывает ноль. Дважды я думал, что сломал патч, а сломан был замер.
Про нейросеть. Она уверенно предложила проверять результат сборки через -serial file: и не предупредила ни про одно, ни про другое. Не потому что глупая, а потому что это знание из разряда «сам напоролся». Такого в текстах, на которых её учили, просто мало.
Для тех, кто хочет разобраться сам
Ссылки из первой статьи по-прежнему в силе. Здесь только то, что понадобилось именно в этой.
Про UART и его регистры
Спецификация RISC-V, том Unprivileged: там видно, что деления в базовом наборе действительно нет, а есть отдельное расширение M.
Документация QEMU по машине virt: карта устройств и адреса.
Исходник модели UART в QEMU: около 1000 строк, читаются за вечер, и после них перестаёт быть страшно лезть в эмулятор.
Про то, как устроено деление без деления
Ассемблер RISC-V для начинающих: база по регистрам и соглашениям.
RISC-V Reference Card: две страницы таблиц, видно, какие инструкции в каком расширении.
Про то, как отправлять патчи в QEMU, если захочется довести дело до конца
Как отправить патч: только рассылка, никаких merge request, обязательная подпись.
Итог
Код из первой статьи исправлен и теперь работал бы на железе. Эмулятор научился ловить такие ошибки, и патч лежит в открытом доступе. Процессор научился складывать и печатать результат, попутно выяснив, что делить он не умеет.
Весь код в репозитории, история разбита по шагам: github.com/Pro100lamer/uart-to-lang. Тег article-02 это состояние на конец этой статьи, qemu-uart-pacing это патч.
Отдельное спасибо @arteast за комментарий. Без него я бы поехал дальше с неправильным кодом и неработающим стендом, и обнаружилось бы это через пять статей, когда переделывать пришлось бы всё.
В следующей статье займёмся стеком. Он уже понадобился здесь, и дальше без него никак: как только процедур становится больше трёх, регистры для адресов возврата кончаются.
А пока вопрос к тем, кто пишет под голое железо. Что ещё в моём коде выглядит нормально в эмуляторе, но развалится на плате? Прошлый раз это сработало лучше, чем любая моя проверка.
Void-Cowboy
кажется следующая статья будет - использование QEMU переоценено и вот мой полностью нативный эмулятор!
за статью респект, как раз именно реальная физика устройств является самой "болючей" в эмуляции так как много что на слабом железе разрабатывается с учетом этого самого железа (например нет мьютекса на опасной операции так как физически операция просто не попадает в такт с другой по таймингу)
сам по работе с некоторыми диспенсорами купюр держим на складе живое устройство просто потому что много чисто найтивных моментов
not-allowed-here
какая вкусная опечатка так и отдает кофе помешиваемым отверткой и ночными бдениями на хардовой отладке.....
unreal_undead2
Можно ещё на FPGAшке поиграться.