Наверное, мало какому айтишнику нужно объяснять, что такое Memtest86+ — пожалуй, он уже стал более-менее стандартом в тестировании оперативной памяти на ПК. Когда в одной из предыдущих частей я наткнулся на битую планку памяти, пришедшую в комплекте с платой, он (вместе с поддерживающим DDR2 нетбуком) казался очевидным решением. Другой вопрос, что там в принципе нестабильная работа системы была видна невооружённым глазом. В более хитрых случаях, слышал, что кроме банального «простукивания» ячеек памяти до бесконечности, этот инструмент использует некоторые специальные паттерны данных, на которых ошибки в работе DDR выявляются с большей вероятностью. В общем чудесная вещь, жаль, что даже в названии указано: 86 — «Только для x86-совместимых систем». Или нет? Под катом вы увидите мои попытки портировать MemTest86+ v5.1 на RISC-V и промежуточный итог. Спойлер: оно шевелится! DISCLAIMER: полученный проект минимально тестировался конкретно мной на конкретной сборке RocketChip на конкретной плате. Точность и безопасность (особенно на других системах) не гарантируется. Используйте на свой страх и риск. В частности, на данный момент никак не обрабатываются зарезервированные области памяти, если они попали в диапазон RAM. Как я уже рассказывал, не так давно я купил на AliExpress плату с Cyclone IV, но память в ней глючила. К счастью, одна из важных фич той платы заключалась в использовании обычных DDR2 SO-DIMM модулей — таких же, как в моём старом нетбуке. Тем не менее, было бы интересно получить, так сказать, self-hosted решение для тестирования модулей памяти (а фактически, ещё и контроллера). Перспектива отлаживать свои ошибки в условиях битой памяти как-то вообще не радовала. Особо не надеясь на быстрое решение и мысленно готовясь отложить полное переписывание на другом ассемблере на неопределённо долгий срок, открыл на Википедии статью про Memtest86+ и внезапно увидел «Written in: C and assembly» в карточке. Хм, то есть он, хоть и «...86», но написан не целиком на ассемблере? Это обнадёживает. Осталось лишь понять соотношение. Итак, идём на memtest.org и качаем версию 5.01 под GPL2. Для удобства разработки я перезалил её на GitHub. К счастью, прямо в архиве с исходниками нас встречает файл README.background, озаглавленный В нём довольно подробно (и даже с картинками в виде ASCII-art) объясняется высокоуровневая работа кода. В самом начале документа мы видим Binary layout, состоящий из bootsect.o, setup.o, head.o и некоего memtest_shared. Несложно видеть, что указанные три объектных файла получаются из соответствующих ассемблерных исходников. На первый взгляд, всё остальное написано на C! Неплохо, неплохо... В итоге я скопировал Makefile в Makefile.arch и начал всё переписывать, а что не переписывается, пытаться выкинуть. Первым делом мне, конечно, потребовался тулчейн для RISC-V, который, к счастью, всё ещё лежит у меня со времени предыдущих экспериментов. Сначала я думал делать порт под 32-битную архитектуру, но потом вспомнил, что в плату залит 64-битный процессор, да и тулчейн у меня с префиксом riscv64-. Лирическое отступление: разумеется, первым делом стоило изучить вопрос совместимости 32- и 64-битного кода. В итоге в спецификации на непривилегированную часть ISA (Instruction Set Architecture) нашёл в пункте 1.3 RISC-V ISA Overview утверждение: Также хочу заметить, что тулчейн с префиксом riscv64- скорее всего запросто соберёт и 32-битный код при правильном выборе целевой архитектуры — об этом позже. В процессе портирования имеет смысл держать под рукой эти документы: Для начала условимся: хочется получить порт, пригодный для дальнейшего переноса на архитектуры, отличные от x86 и RISC-V. Также предлагаю выкинуть из кросс-платформенной сборки создание загрузочных дискет и прочую специфику x86. Что мы в итоге имеем: есть три ассемблерных файла: bootsect.S, setup.S и head.S. Первые два нужны лишь при старте, а третий нужен и впоследствии при релокации в другую область памяти. Дело в том, что чтобы протестировать память «под собой», тестирующему коду нужно сначала переехать на новое место. Сишные файлы собираются в ELF, из которого потом берутся секции кода, данных и т.д. Более того, собирается оно в виде PIC (Position Independent Code) — меня поначалу даже удивило: код хотя и freestanding (то есть без ядра, libc и т.д.), но использует такие продвинутые фичи. Далее, в Makefile'е периодически попадаются параметры, задающие архитектуру: -march=i486, -m32 и подобные. Надо бы и мне что-нибудь такое написать, а то чё как лох. С указанием архитектуры у RISC-V дела обстоят примерно так: есть варианты rv32 и rv64 (вроде, есть ещё максимально урезанный embedded и припасённый на будущее rv128, но нас они не очень интересует), а название ISA образуется приписыванием к этому префиксу букв, обозначающих расширения: i — базовый целочисленный набор инструкций, m — целочисленное умножение и деление,… Конечно, хотелось бы обойтись rv64i, но вряд ли будет легко портировать Memtest86 на архитектуру без умножения. Правда, похоже компилятор просто сгенерирует вместо «проблемных» инструкций вызовы функций, но есть риск остаться с сильно пониженной производительностью (не говоря уже о том, что эти функции нужно будет написать или где-то взять). Также потребуется строчка ABI. В принципе, азы calling convention описаны уже в указанном Volume I в части «RISC-V Assembly Programmer’s Handbook», поэтому просто выполню что-то вроде И недолго думая, возьму lp64. Забегая вперёд скажу, что с этим ABI заголовочные файлы из стандартной библиотеки не заработали, поэтому взял lp64f, а ARCH «проапгрейдил» до rv64imf. Без паники, я не планирую реально использовать в своём порте floating point. Поскольку углубляться в написание кросс-платформенных линкер-скриптов как-то не хотелось — и так не сразу удалось подобрать ключи к ld, решил обойтись одним ассемблерным файлом head.S, цепляющимся перед остальными функциями с помощью memtest_shared.arch.lds. Из него я выкинул указание на формат вывода и архитектуру (всё-таки, это проще поменять из переменной в Makefile'е), а также временно закомментировал DISCARD в конце, с ходу не сумев разобраться, какие конкретно секции отладочной информации мне нужны. (Забегая вперёд: ладно отладочная информация, а вот .rela добавить пришлось) Вообще говоря, в x86-версии подчёркивалась необходимость уместиться в 64k — буду надеяться, что это просто как-то связано с особенностями реального режима и на RISC-V нас не касается. В итоге будет, как и в оригинале, собираться shared object с PIC, из него выкусываться код и данные, которые будут загружаться в память. Собираем… и компиляция валится на первом же файле reloc.c — он, по-видимому, взят из какого-нибудь ld-linux.so и отвечает за поддержку Global Offset Table и т.д. в соответствии с calling conventions для x86. Оказалось, в нём потребовалось работать напрямую с регистрами с помощью ассемблерных вставок. Но мы-то на RISC-V — он-то изначально сделан для нативной поддержки PIC, поэтому смело выкидываем reloc.c. Далее там ещё попадались вставки, местами довольно длинные. К счастью, они были или в коде тестов сразу после закомментированного кода на C, который они оптимизируют (из них я вновь сделал полноправные куски кода, переключаемые директивой препроцессора) или что-то платформенно-зависимое, без чего в крайнем случае можно (наверное) обойтись (вроде включения-выключения кэша, вычитывания CPUID и т.д.). Наконец, были отдельные вещи типа вызова rdtsc, который я без больших проблем тоже вынес в платформенно-зависимый хедер и перереализовал в соответствии с документацией на RISC-V. В итоге получился каталог arch/i386, куда переехало большое количество кода поддержки PCI, чтения информации из чипсетов, платформенно-зависимые определения memory-mapped адресов и т.д. Также туда уехало начало функции test_start, являющееся точкой входа из setup.S в код на C. Долго ли, коротко ли, но закомментировав всё что можно и перереализовав под RISC-V всё, что закомментировать нельзя (вроде setup.S и кода работы с последовательным портом в реализации SiFive), я получил каталог arch/riscv, с которым всё более-менее скомпилировалось. Тут я вынужден уточнить, что сами эксперименты частично проводились до начала написания статьи, поэтому конкретная последовательность действий может содержать некоторое количество «художественного вымысла». Впрочем, я стараюсь как минимум вести изложение таким образом, чтобы оно в любом случае представляло одну из возможных траекторий (я программист, я так помню). Поэтому давайте посмотрим, как всё это запустить. Со времён прошлых экспериментов у меня всё ещё пылится «стенд» из Raspberry Pi, соединённой проводами с отладочной платой. Провода обеспечивают UART, JTAG и адаптер с SD-карточкой. В конфигурационную память зашит некий RV64-процессор с контроллером DDR2. Как и в прошлые разы, включаю «малинку», открываю до неё две SSH-сессии, одна из которых пробрасывает 3333 порт TCP для соединения gdb с OpenOCD. В одной из сессий стартую minicom, чтобы наблюдать за UART, в другой — openocd, чтобы отлаживать с хоста по JTAG. Включаю питание платы — и побежали сообщения в консоли о том, как оно грузит данные с SD. Теперь можно выполнить команду: опции -ex командуют gdb сделать вид, что пользователь ввёл эти команды с консоли: К сожалению, не всё идёт абсолютно гладко, и хотя строчки кода в отладчике показываются корректно, но во всех глобальных переменных — нули. На самом деле, если в gdb выполнить команду вида p &global_var, мы, увы, увидим адрес в соответствии с изначальным адресом загрузки (у меня — 0x0), в не указанным с помощью add-symbol-file. Как костыльное, но очень простое решение, я просто добавлял к указанному адресу 0x80010000 вручную и смотрел содержимое памяти через x/x 0xADDR. Вообще-то, можно было бы временно указать правильный начальный адрес в линкер-скрипте, который на данный момент будет совпадать с адресом загрузки в данной тестовой конфигурации. Хорошо, как загружать код худо-бедно разобрались — запускаем. Не работает. Пошаговая отладка показывает, что мы падаем в процессе работы функции switch_to_main_stack — похоже, она всё-таки пытается использовать не релоцированное значение адреса символа, соответствующего рабочему стеку. Всё тот же первый том документации рассказывает нам о разных псевдоинструкциях и их работе с включённым и выключенным PIC:  Как видим, общий принцип в том, что адреса в памяти отсчитываются от текущей инструкции, причём первая прибавляет верхнюю часть смещения, а последующий add дошлифовывает младшие биты. Едва ли такое поможет для объявления глобальной переменной вроде А потому берём документацию RISC-V ELF psABI с описаниями видов релокаций и пишем платформенно-зависимую часть для reloc.c. Тут нужно заметить, что оригинальный файл, видимо, был взят из кросс-платформенного кода. Там даже вместо указания конкретной разрядности используются макросы вида ElfW(Addr), раскрывающиеся в Elf32_Addr или Elf64_Addr. Не везде, правда, поэтому добавим их, где они отсутствуют в общем коде (а так же в коде arch/riscv/reloc.inc.c — всё-таки для RISC-V нет особого смысла завязываться на конкретную разрядность, там где это не требуется). В итоге switch_to_main_stack начал проходить (не без платформенно-зависимой ассемблерной инструкции, конечно). Дебаггер же показывает глобальные переменные всё ещё криво. Ну и ладно :( Конечно, для тестов можно было бы вместо выкинутого кода определения оборудования использовать захардкоженные константы, но под каждую конкретную сборку процессора пересобирать memtest — это даже по меркам моей прикладной задачи чересчур костыльно. Поэтому будем поступать «как взрослые серьёзные люди». К счастью, на RISC-V (а наверное, и на большинстве современных архитектур) принято, чтобы загрузчик передавал коду указатель на Device Tree Blob, являющийся скомпилированной версией DTS-описания вроде такого: Когда-то я уже разбирал ELF-файлы, а сейчас с FDT (flat device tree) вновь убеждаюсь: эти спецификации пишут добрые заботливые люди (ещё бы, им же потом это самим парсить!) и разбор таких файлов (по крайней мере, пока не требуется обрабатывать недоверенный ввод) особых проблем не представляет. Так и здесь: в начале файла идёт нехитрая структура-заголовок, содержащая magic number 0xd00dfeed и ещё несколько полей. Нас из них интересуют смещение «плоского дерева» off_dt_struct и таблицы строк off_dt_strings. Вообще-то, нужно ещё обрабатывать off_mem_rsvmap, перечисляющие области памяти, в которые лучше не соваться. Я их пока игнорирую (на моей плате их нет), но не повторяйте такого дома. В принципе, обработка не представляет особого труда: нужно просто идти по плоскому дереву в соответствии с токенами. Ключевых токенов три: Ну, в общем, и всё: идём по этой секции, не забывая соблюдать выравнивание на 4 байта. Ах да, ложка дёгтя: числа в FDT указаны в big endian формате, поэтому делаем простенькую функцию В итоге в riscv_entry первым делом надо распарсить FDT, а часть head.S, отвечающая за передачу управления на riscv_entry, выглядит примерно так В регистре a0 нам передаётся hart id (hart — это что-то вроде аппаратного потока в терминологии RISC-V) — его я пока не использую, мне бы в однопоточном случае разобраться. В a1 загрузчик кладёт указатель на FDT. Его мы передаём функции void riscv_entry(ulong hartid, uint8_t *fdt_address). Теперь, с появлением парсилки FDT в моём коде, последовательность загрузки платы стала такой: А со стороны gdb добавляется команда Как оказалось, кроме ассемблерных вставок есть ещё и известные адреса памяти. Например SCREEN_ADR (именно так, с одной D), который указывает на область, соответствующую тому, что выводится на экран. Когда я на это наткнулся, то просто поместил широким жестом всё, что на него ссылается, под #if HAS_SCREEN, а потом долго отлаживал вслепую. Думал уже вручную раз в сколько-то времени выдавать дамп этого всего в консоль, но потом обратил внимание, что этот же код уж больно много escape-последовательностей выводит в последовательный порт. Оказалось, всё уже написано до нас, нужно только аккуратнее расставить дефайны — и вот он, знакомый интерфейс (правда, чёрно-белый) в окне minicom'а! (На данный момент HAS_SCREEN вообще не используется — я просто завёл массив dummy_con, чтобы по минимуму менять изначальный код.) Так я и отлаживал всё на реальной плате, а с некоторых пор — даже не вслепую. Но вот тормозит всё по JTAG — жуть! Что же, в итоге всё должно работать на реальном железе, но вот отладку неплохо было бы вести на QEMU. После некоторого количества экспериментов получилось что-то костыльное, но весьма похожее на работу с платой: Смотрим, какие платы готов эмулировать QEMU. Меня интересует sifive_u-совместимое железо. Теперь у нас есть «исправленный» device tree blob. Не меняя конфигурации ВМ (костыли!), запускаем: -serial stdio переадресует последовательный порт в консоль, ведь будут активно использоваться escape-последовательности. Опции -s -S поднимают gdbserver и создают VM на паузе, соответственно. Можно было бы загружать код с помощью устройства loader, но тогда придётся перезапускать QEMU каждый раз. Можно подключаться с помощью В итоге всё работает более, чем шустро! Многие, наверное, не знают, но у Memtest86+ есть режим btrace, когда он по циклу пишет сообщения о том, что он делает прямо сейчас (снимок из терминала, в который идёт вывод QEMU):  После того, как удалось заставить работать код инициализации, memtest начал показывать в консоли своё окно и падать на тестах. Иногда он показывал понятное сообщение, которое выводил мой обработчик прерываний (точнее, trap): если приглядеться, можно поверх сообщений, что у QEMU память-то битая! увидеть «заплатку» с надписью Illegal instruction и адресами, вычитанными из специальных регистров. За первую строку отвечает регистр mcause (почему?), за вторую — mepc (где?), за третью — mtval (в связи с каким адресом?), который актуален не всегда.  В простейшем случае, обработка ошибок может выглядеть примерно так: head.S: А теперь, когда аккуратно разложили аргументы в соответствии с calling convention, можно обрабатывать. Идея с массивом строк использовалась и в memtest, и в коде HiFive_U-Boot, а сами сообщения взяты из Volume II: arch.c: Обратите внимание на длиииинные строчки с пробелами — это простой способ «расчистить место» под реальные значения. В противном случае, они перезапишут «подложку» ровно по своей длине, и например, в случае адреса не очень понятно, где граница. В некоторых случаях всё может быть не так просто: обработчик ошибки отработать не может. Одна из возможных причин заключается в том, что memtest очень упорный: перефразируя изречение про фломастеры: «С одного адреса загрузки можно протестировать всю память, кроме десятка килобайтов, в которых сидишь сам. С двух адресов загрузки можно протестировать вообще всё». Работает это примерно так: функция do_test в файле main.c отсчитывает окна по 2Гб, настраивая тесты (какую область и как тестируем), при этом нулевое и первое окно — это части одной «младшей» области, в которой сидит memtest. Если ему пора сниматься с насиженных мест, он вызывает функцию run_at, которая копирует весь memtest от _start до _end на новое место (этим занимается только «загрузочное» ядро процессора), потом что-то делает с барьером и spinlock'ами для синхронизации всех ядер и с помощью goto *addr; прыгает на ассемблерную точку входа на новом месте. Кстати, обратите внимание, сделано оно не по принципу «прозрачных» для остального кода корутин, а просто через «перезаход». Тут нужно обратить внимание на то, что ассемблерный код не должен переинициализировать bss нулями при перезапуске — нужно просто вызвать _dl_start для релокации, а потом riscv_entry для запуска, предварительно настроив trap entry. Ну, и ещё одно: неплохо было бы сообщить L1I-кэшу, что код поменялся. Насколько я понял документацию, для этого используется инструкция fence.i. На самом деле, оригинальный Memtest86+ — многопроцессорный, и при релокации он использует известный адрес структуры barrier_s для синхронизации после релокации. У меня же код пока что однопоточный, поэтому структуру я банально таскаю за собой в сегменте данных. Наверное, если чуть исхитриться, можно будет вообще без глобальных адресов выкрутиться. Как я уже говорил, есть сложности очевидные: три ассемблерных файла. Есть сложности неочевидные: рассыпанные по всему коду ассемблерные вставки и обращение по известным адресам. А есть сложности внезапные: например, в какой-то момент я добился запуска первого теста (Own Address, хоть и с включённым кэшем) на реальной плате. Он отработал в релоцированном виде, потом релоцировался на старое место, отработал и там. А потом запустился следующий тест и упал. Из-за ошибки не выровненного доступа. Дело в том, что на x86 можно, например, прочитать или записать uint64_t по адресу 0x80000002 и вам за это ничего не будет. Ну, то есть как не будет: ходят слухи, что выровненные load/store на x86 якобы всегда атомарные, а вот не выровненные — нет. А ещё смутно вспоминаю, что в коде QEMU периодически всплывали ворчливые комментарии, мол «Здесь всё просто, если операция не пересекает границы страниц». Теперь, когда тесты в целом запускаются, нужно просто их починить — разобраться с unaligned access и т.д. Кстати, судя по тому, какие ошибки выдаёт реальный RocketChip, а какие — QEMU, можно сделать предположение, что то, что для RocketChip — unaligned access trap, для QEMU просто «слегка неопределённое поведение».
Поискал строчку «misaligned» в первом томе спецификации и вскоре наткнулся на абзац в списке изменений, прямо говорящий То есть, получается, раньше — выкручивайся как хочешь, но user-mode code не должно заботить, будешь ли ты эмулировать не выровненный доступ или оно отработает на аппаратном уровне. Сейчас же можно и поругаться. Неопределённого же поведения, похоже, здесь нет принципиально. В любом случае, на меня система поругаться могла всегда — я-то работаю в machine mode или как оно называется. Например, когда я попытался переписать rdtsc (x86) через rdtime (rv64), оно выдало trap, хотя это и допустимая инструкция. Просто, оказалось, на моём процессоре её для простоты аппаратной реализации должно эмулировать ядро через более точный имеющийся memory-mapped регистр. Есть ещё такая особенность: загрузить наш код могут куда угодно, а куда нужно переместиться в low_test_addr (который логично бы выставить равным младшему тестируемому адресу), мы узнаем, лишь распарсив fdt в сишном коде. На самом деле, есть подозрение, что low_test_addr может быть любым, при условии, что он попадает в первое 2Гб окно и high_test_adr вычислится в доступный адрес… Короче, переместимся в самое начало — это довольно просто: head.S будет сохранять начальный адрес загрузки в initial_load_addr, а в конце riscv_entry вызовем нехитрую функцию move_to_correct_addr: Вообще, один из подводных камней — это тот факт, что memtest уверен, что RAM отображена куда-то в младшие адреса. На RISC-V это отнюдь не обязательно, поэтому пришлось накостылить смещение в тестах на v->plim_lower. Ещё одна проблема в целом, типична для «старого» кода, который хотят сделать кросс-платформенным, а именно — файл test.c усыпан переменными типа ulong (затайпдефленный на unsigneg long), которые на 32-битном x86 обычно являются uint32_t, а вот на «гнутых 64 битах» обычно представлен uint64_t. Из этого появляются забавные ошибки вида «Ошибка!!! Good: ffffffff Real: ffffffff Bad bits: 00000000». Что за чепуха? А это просто в функцию передался параметр-паттерн -1, младшие 32 бита которого равны 1. Проблема в том, что у него есть ещё столько же старших битов, и они как бы тоже отнюдь не 0… В общем, было некоторое количество творческой работы: понять, какой ulong представляет из себя тестовые данные (uint32_t), а какой на самом деле имеет ширину машинного указателя (uintptr_t). Кстати, не выровненный доступ после этого тоже исчез. Видимо, это было обращение к uint64_t с шагом 4. RISC-V такое может игнорировать полностью, а вот с точки зрения языка C, кстати, не выровненный доступ — это очень даже UB. Значит нужно просто собрать memtest с UBSan. Между прочим, не удивлюсь, если UBSan в режиме trap-on-error сможет отлично работать вместе с отладкой по JTAG. Теперь, когда memtest более-менее выполняет тесты, пора задуматься о том, чтобы грузился он не через отладчик а традиционным загрузчиком U-Boot. Для своей платы я выработал такое решение: с помощью утилиты mkimage из состава U-Boot я упаковываю якобы ядро Linux: Теперь его можно положить на SD-карту и запустить с помощью команды (при этом нужно учитывать, что после run фактически указывается имя скрипта — на вашей плате конкретно таких может не оказаться). Тут меня поджидала ещё одна неожиданность: загрузчик передавал мне корректное значение адреса FDT: 0xbffb7c80. Ну, почти корректное: старшие биты были ffffffff, в итоге ничего не работало. Я уж приготовился к тому, что за меня уже настроили страничную организацию памяти (что мне вообще было не нужно), но ларчик просто открывался: в коде HiFive_U-Boot передача управления выполнялась как вызов функции: Проблема была в том, что объявлена она как Не уверен, что это именно ожидаемое поведение, но, по логике, больше 32 битов адреса туда в любом случае не пролезет, поэтому просто добавим в head.S строчки: Итак, оно грузится, что-то тестирует, но понятно, что это скорее начало работы, чем финал: Программист