Хабрахабр

Часть 2: RocketChip: подключаем оперативную память

Однако на плате есть разъём с установленным SO-DIMM DDR2 1Gb, который, очевидно, хочется использовать. В предыдущей части мы собрали микроконтроллер вообще без оперативной памяти на базе ПЛИС Altera/Intel. Под катом — тактильная отладка, брутфорс программирование и ГРАБЛИ. Для этого нам потребуется обернуть DDR2-контроллер с интерфейсом ALTMEMPHY в модуль, понятный для протокола работы с памятью TileLink, используемого повсюду в RocketChip.

На КДПВ вы видите редкий момент — две главные проблемы CS встретили друг друга и что-то замышляют. Как известно, в Computer Science есть две главные проблемы: инвалидация кешей и именование переменных.

DISCLAIMER: В дополнение к предупреждению из предыдущей статьи настоятельно рекомендую дочитать статью до конца перед повторением опытов, во избежание повреждения ПЛИС, модуля памяти или цепей питания.

Критерием успеха предлагается рассматривать возможность читать и писать через связку GDB+OpenOCD, в том числе по адресам, не выровненным на 16 байтов (ширина одного запроса в память). В этот раз хотелось если не загрузить Linux, то хотя бы подключить оперативную память, коей на моей плате аж целый гигабайт (а можно поставить до четырёх). Поддерживать-то он поддерживает, но через интерфейс MIG (ну, и, возможно, ещё какой-нибудь интерфейс от Microsemi). На первый взгляд, нужно просто чуточку поправить конфиг, не может же генератор SoC не поддерживать оперативную память из коробки. Через стандартный интерфейс AXI4 тоже поддерживает, но его, насколько я понимаю, не так то просто заполучить (по крайней мере, не осваивая Platform Designer).

Тут можно было бы подумать, что оно всё насквозь патентованое и закрытое. Лирическое отступление: Существует, насколько я понимаю, довольно популярная серия «внутричиповых» интерфейсов AXI, разработанная ARM. Я, конечно, не юрист, но похоже, что стандарт довольно таки открытый: вы либо обязаны использовать лицензированные ядра от ARM, либо вообще не претендовать на совместимость с ARM, и тогда вроде всё ОК. Но после того, как я зарегистрировался (безо всяких «университетских программ» и прочего — просто по e-mail и заполнению анкеты) и получил доступ к спецификации, меня ждало приятное удивление. Но вообще, конечно, читайте лицензию, читайте с юристами и т.д.

Задача казалась довольно простой, и я открыл описание уже имевшегося в проекте от поставщика платы модуля ddr2_64bit:

Собственность Intel и вообще

module ddr2_64bit ( local_address, local_write_req, local_read_req, local_burstbegin, local_wdata, local_be, local_size, global_reset_n, pll_ref_clk, soft_reset_n, local_ready, local_rdata, local_rdata_valid, local_refresh_ack, local_init_done, reset_phy_clk_n, mem_odt, mem_cs_n, mem_cke, mem_addr, mem_ba, mem_ras_n, mem_cas_n, mem_we_n, mem_dm, phy_clk, aux_full_rate_clk, aux_half_rate_clk, reset_request_n, mem_clk, mem_clk_n, mem_dq, mem_dqs); input [25:0] local_address; input local_write_req; input local_read_req; input local_burstbegin; input [127:0] local_wdata; input [15:0] local_be; input [2:0] local_size; input global_reset_n; input pll_ref_clk; input soft_reset_n; output local_ready; output [127:0] local_rdata; output local_rdata_valid; output local_refresh_ack; output local_init_done; output reset_phy_clk_n; output [1:0] mem_odt; output [1:0] mem_cs_n; output [1:0] mem_cke; output [13:0] mem_addr; output [1:0] mem_ba; output mem_ras_n; output mem_cas_n; output mem_we_n; output [7:0] mem_dm; output phy_clk; output aux_full_rate_clk; output aux_half_rate_clk; output reset_request_n; inout [1:0] mem_clk; inout [1:0] mem_clk_n; inout [63:0] mem_dq; inout [7:0] mem_dqs; ...

Но здесь не совсем интуитивно понятный интерфейс, поэтому всё же почитаем. Народная мудрость гласит: «Любую документацию на русском языке нужно начинать со слов: "Итак, оно не работает"». Нужно настроить PLL, провести некую калибровку, крекс-фекс-пекс, выставился сигнал local_init_done, можно работать. В описании нам тут же рассказывают, что работа с DDR2 — дело непростое. Вообще, логика именования здесь примерно следующая: имена с префиксами local_ — это «пользовательский» интерфейс, порты mem_ нужно непосредственно вывести на ножки, подключённые к модулю памяти, на pll_ref_clk нужно подать тактовый сигнал с указанной при настройке модуля частотой — из него будут получены остальные частоты, ну и всякие входы-выходы reset и выходы частот, синхронно с которыми должен работать пользовательский интерфейс.

Давайте создадим описание внешних сигналов к памяти и интерфейса модуля ddr2_64bit:

trait MemIf

trait MemIf
} class MemIfBundle extends Bundle with MemIf

class dd2_64bit

class ddr2_64bit extends BlackBox { override val io = IO(new MemIfBundle { val local_address = Input(UInt(26.W)) val local_write_req = Input(Bool()) val local_read_req = Input(Bool()) val local_burstbegin = Input(Bool()) val local_wdata = Input(UInt(128.W)) val local_be = Input(UInt(16.W)) val local_size = Input(UInt(3.W)) val local_ready = Output(Bool()) val local_rdata = Output(UInt(128.W)) val local_rdata_valid = Output(Bool()) val local_refresh_ack = Output(Bool()) })
}

Не получилось. Тут меня поджидал первый букетик граблей: во первых, насмотревшись на класс ROMGenerator, я подумал, что и контроллер памяти можно выдернуть из глубин дизайна через глобальную переменную, а Chisel как-нибудь сам провода пробросит. Почему же он не торчит из BlackBox-а, и не подключается разом? Поэтому пришлось сделать жгут проводов MemIfBundle, который протягивался по всей иерархии. }). Дело в том, что у BlackBox все внешние порты запихнуты в val io = IO(new Bundle { ... Наверное, можно сделать как-то более адекватно, но пока оставим так. Если в бандле весь MemIfBundle сделать одной переменной, то имя этой переменной будет сделано префиксом для имён всех портов, и имена банально не сойдутся с интерфейсом блока.

Далее по аналогии с другими TileLink-устройствами (преимущественно живущими в rocket-chip/src/main/scala/tilelink), и в особенности, BootROM, опишем свой интерфейс к контроллеру памяти:

class AltmemphyDDR2RAM(implicit p: Parameters) extends LazyModule { val MemoryPortParams(MasterPortParams(base, size, beatBytes, _, _, executable), 1) = p(ExtMem).get val node = TLManagerNode(Seq(TLManagerPortParameters( Seq(TLManagerParameters( address = AddressSet.misaligned(base, size), resources = new SimpleDevice("ram", Seq("sifive,altmemphy0")).reg("mem"), regionType = RegionType.UNCACHED, executable = executable, supportsGet = TransferSizes(1, 16), supportsPutFull = TransferSizes(1, 16), fifoId = Some(0) )), beatBytes = 16 ))) override lazy val module = new AltmemphyDDR2RAMImp(this)
} class AltmemphyDDR2RAMImp(_outer: AltmemphyDDR2RAM)(implicit p: Parameters) extends LazyModuleImp(_outer) { val (in, edge) = _outer.node.in(0) val ddr2 = Module(new ddr2_64bit) val mem_if = IO(new MemIfBundle) // TODO здесь дорисовать сову
} trait HasAltmemphyDDR2 { this: BaseSubsystem => val dtb: DTB val mem_ctrl = LazyModule(new AltmemphyDDR2RAM) mem_ctrl.node := mbus.toDRAMController(Some("altmemphy-ddr2"))()
} trait HasAltmemphyDDR2Imp extends LazyModuleImp { val outer: HasAltmemphyDDR2 val mem_if = IO(new MemIfBundle) mem_if <> outer.mem_ctrl.module.mem_if
}

Далее мы создаём manager port TileLink-устройства (протокол TileLink обеспечивает взаимодействие практически всего, что связано с памятью: контроллера DDR и других memory-mapped устройств, кешей процессора, возможно, ещё чего-то, у каждого устройства может быть по нескольку портов, каждое устройство может быть и manager, и client). По стандартному ключу ExtMem мы извлекаем из конфига SoC параметры внешней памяти (вот этот странный синтаксис позволяет по аналогии с паттерн-матчингом сказать «я знаю, что мне вернут экземпляр case class MemoryPortParameters (это гарантируется типом ключа на этапе компиляции Scala-кода, при условии, что в рантайме мы не упадём, вынимая содержимое из Option[MemoryPortParams], равного None, но тогда нечего было контроллер памяти создавать в System.scala...), так вот, сам case class мне не нужен, а некоторые его поля нужны»). HasAltmemphyDDR2 и HasAltmemphyDDR2Imp мы подмешаем в нужных местах в System.scala, напишем конфиг beatBytes, насколько я понимаю, задаёт размер одной транзакции, а у нас обмен с контроллером ведётся по 16 байт.

class BigZeowaaConfig extends Config ( new WithNBreakpoints(2) ++ new WithNExtTopInterrupts(0) ++ new WithExtMemSize(1l << 30) ++ new WithNMemoryChannels(1) ++ new WithCacheBlockBytes(16) ++ new WithNBigCores(1) ++ new WithJtagDTM ++ new BaseConfig
)

Тут я увидел, что такое настоящий интуитивно понятный интерфейс: это когда ты даёшь в gdb команду на запись в память, и по зависшему процессору и обожжённым чувствующим сильный нагрев пальцам понимаешь, что нужно срочно нажать на плате сброс и поправить контроллер. Сделав некий «набросок совы» в AltmemphyDDR2RAMImp, я синтезировал дизайн (что-то всего на ~30MHz, хорошо, что я тактируюсь от 25MHz) и, положив пальцы на модули памяти и микросхему ПЛИС, залил его в плату.

Так, что тут у нас?.. Видимо, пришло время почитать документацию на контроллер дальше списка портов. Упс, оказывается, входы-выходы с префиксом local_ должны выставляться синхронно не с pll_ref_clk, который 25MHz, а либо с phy_clk, выдающим половинную частоту памяти для half-rate контроллера, либо, в нашем случае, aux_half_rate_clk (может, всё-таки aux_full_rate_clk?), выдающим полную частоту памяти, а она, на минуточку, 166MHz.

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

+-+ +-+ +-+ +-+
--| |--| |--| |--| |---> +-+ +-+ +-+ +-+ | | | |
---+ | | |
inclk | | | | | |
--------+----+ |
outclk | |
------------------+
output enable

Ещё через некоторое время я понял, что и описать синхронизацию на ready-valid без общего тактового сигнала — тоже задача сродни созданию неблокирующих структур данных в том смысле, что думать и формально доказывать нужно много, ошибиться легко, заметить трудно, а главное, всё уже реализовано до нас: у Intel есть примитив dcfifo, который представляет собой очередь конфигурируемой длины и ширины, читается и пишется которая из разных частотных доменов. Но, повозившись часок, пришёл к выводу, что не осилю на «скалярных» защёлках две очереди (в высокочастотный домен и обратно), каждая из которых будет иметь противонаправленные сигналы (ready и valid), да ещё и так, чтобы быть уверенным, что какой-нибудь битик не отстанет на такт-другой по дороге. В итоге я воспользовался экспериментальной возможностью свежего Chisel, а именно, параметризованными black box-ами:

class FIFO (val width: Int, lglength: Int) extends BlackBox(Map( "intended_device_family" -> StringParam("Cyclone IV E"), "lpm_showahead" -> StringParam("OFF"), "lpm_type" -> StringParam("dcfifo"), "lpm_widthu" -> IntParam(lglength), "overflow_checking" -> StringParam("ON"), "rdsync_delaypipe" -> IntParam(5), "underflow_checking" -> StringParam("ON"), "use_eab" -> StringParam("ON"), "wrsync_delaypipe" -> IntParam(5), "lpm_width" -> IntParam(width), "lpm_numwords" -> IntParam(1 << lglength)
)) { override val io = IO(new Bundle { val data = Input(UInt(width.W)) val rdclk = Input(Clock()) val rdreq = Input(Bool()) val wrclk = Input(Clock()) val wrreq = Input(Bool()) val q = Output(UInt(width.W)) val rdempty = Output(Bool()) val wrfull = Output(Bool()) }) override def desiredName: String = "dcfifo"
}

И написал простенькую биндилку произвольных типов данных:


object FIFO { def apply[T <: Data]( lglength: Int, output: T, outclk: Clock, input: T, inclk: Clock ): FIFO = { val res = Module(new FIFO(width = output.widthOption.get, lglength = lglength)) require(input.getWidth == res.width) output := res.io.q.asTypeOf(output) res.io.rdclk := outclk res.io.data := input.asUInt() res.io.wrclk := inclk res }
}

Проблема в том, что всё всё равно дедлочилось, а иногда и изрядно грелось. После этого код превратился в перекладывание сообщений между доменами через две уже однонаправленных очереди: tl_req / ddr_req и ddr_resp / tl_resp (то, что имеет префикс tl_, тактируется вместе с TileLink, то, что ddr_ — вместе с контроллером памяти). Код при этом представлял из себя что-то вроде И если причиной перегрева оказалось одновременное выставление local_read_req и local_write_req, то с дедлоками так легко побороться не получилось.

class AltmemphyDDR2RAMImp(_outer: AltmemphyDDR2RAM)(implicit p: Parameters) extends LazyModuleImp(_outer) { val addrSize = log2Ceil(_outer.size / 16) val (in, edge) = _outer.node.in(0) val ddr2 = Module(new ddr2_64bit) require(ddr2.io.local_address.getWidth == addrSize) val tl_clock = clock val ddr_clock = ddr2.io.aux_full_rate_clk val mem_if = IO(new MemIfBundle) class DdrRequest extends Bundle { val size = UInt(in.a.bits.size.widthOption.get.W) val source = UInt(in.a.bits.source.widthOption.get.W) val address = UInt(addrSize.W) val be = UInt(16.W) val wdata = UInt(128.W) val is_reading = Bool() } val tl_req = Wire(new DdrRequest) val ddr_req = Wire(new DdrRequest) val fifo_req = FIFO(2, ddr_req, ddr_clock, tl_req, clock) class DdrResponce extends Bundle { val is_reading = Bool() val size = UInt(in.d.bits.size.widthOption.get.W) val source = UInt(in.d.bits.source.widthOption.get.W) val rdata = UInt(128.W) } val tl_resp = Wire(new DdrResponce) val ddr_resp = Wire(new DdrResponce) val fifo_resp = FIFO(2, tl_resp, clock, ddr_resp, ddr_clock) // логика общения с TileLink withClock(ddr_clock) { // логика общения с контроллером }

Чтобы локализовать проблему, решил банально закомментировать весь код внутри withClock(ddr_clock) (не правда ли, визуально похоже на создание потока) и заменить его заглушкой, которая точно работает:

withClock (ddr_clock) { ddr_resp.rdata := 0.U ddr_resp.is_reading := ddr_req.is_reading ddr_resp.size := ddr_req.size ddr_resp.source := ddr_req.source val will_read = Wire(!fifo_req.io.rdempty && !fifo_resp.io.wrfull) fifo_req.io.rdreq := will_read fifo_resp.io.wrreq := RegNext(will_read) }

Также при попытке прочитать, что же всё-таки сгенерировалось, я понял, что в режиме симуляции имеется богатый выбор assertion-ов по поводу несоблюдения протокола TileLink. Как я уже потом понял, эта заглушка тоже не работала по причине, что конструкция Wire(...), которую я добавил «для надёжности», чтобы показать, что это именно именованный провод, на самом деле использовала аргумент лишь как прототип для создания типа своего значения, но не привязывала его к выражению-аргументу. Verilator наверняка не знает про Alter-овские IP Cores, ModelSim Starter Edition скорее всего откажется симулировать такой огромный проект, но у меня он ещё и ругался на отсутствие модели контроллера для симуляции. Они мне ещё наверняка пригодятся позже, но пока обошлось без попытки запустить симуляцию — а в чём её запускать? А чтобы её сгенерировать, наверняка нужно сначала перейти на новую версию контроллера (потому что старый был настроен в древнем Quartus-е).

Но вам же лучше 😉 Кстати, постоянно пересобирать дизайн можно быстрее, если настройку WithNBigCores(1) заменить на WithNSmallCores(1) — с точки зрения базовой функциональности контроллера памяти разницы, вроде бы, нет. На самом деле, блоки кода были взяты из почти работающей версии, а не той, что активно отлаживалась за несколько часов до этого. И ещё маленькая хитрость: чтобы не вбивать в gdb каждый раз одни и те же команды (там, по крайней мере у меня, нет сохранения истории команд между сессиями), можно просто сразу в командной строке набрать что-то вроде

../../rocket-tools/bin/riscv32-unknown-elf-gdb -q -ex "target remote :3333" -ex "x/x 0x80000000"
../../rocket-tools/bin/riscv32-unknown-elf-gdb -q -ex "target remote :3333" -ex "set variable *0x80000000=0x1234"

и запускать по мере надобности штатными средствами командного интерпретатора.

В итоге был получен такой вот код работы с контроллером:

withClock(ddr_clock) { val rreq = RegInit(false.B) // запрос чтения (ещё не принят) val wreq = RegInit(false.B) // запрос записи (ещё не принят) val rreq_pending = RegInit(false.B) // запрос чтения (ждём данные) ddr2.io.local_read_req := rreq ddr2.io.local_write_req := wreq // какие-то магические константы 🙂 ddr2.io.local_size := 1.U ddr2.io.local_burstbegin := true.B // данные из запроса (надеюсь на буферизованность вывода q FIFO) ddr2.io.local_address := ddr_req.address ddr2.io.local_be := ddr_req.be ddr2.io.local_wdata := ddr_req.wdata // копируем информацию, какой запрос обслуживаем ddr_resp.is_reading := ddr_req.is_reading ddr_resp.size := ddr_req.size ddr_resp.source := ddr_req.source // читаем следующий запрос, если готово **вообщё всё** val will_read_request = !fifo_req.io.rdempty && !rreq && !wreq && !rreq_pending && ddr2.io.local_ready // отвечаем, если есть что сказать val will_respond = !fifo_resp.io.wrfull && ( (rreq_pending && ddr2.io.local_rdata_valid) || (wreq && ddr2.io.local_ready)) val request_is_read = RegNext(will_read_request) fifo_req.io.rdreq := will_read_request fifo_resp.io.wrreq := will_respond // прочитан запрос, заказанный на предыдущем такте when (request_is_read) { rreq := ddr_req.is_reading rreq_pending := ddr_req.is_reading wreq := !ddr_req.is_reading } when (will_respond) { rreq := false.B wreq := false.B ddr_resp.rdata := ddr2.io.local_rdata } // прочитанных данных ещё нет, но запрос ушёл when (rreq && ddr2.io.local_ready) { rreq := false.B } }

Поэтому скомпилируем простенький кусочек кода: Тут мы всё-таки немного поменяем критерий завершённости: я уже видел, как безо всякой работы с памятью записанные данные как будто бы читаются, потому что кеш.

#include <stdint.h> static volatile uint8_t *x = (uint8_t *)0x80000000u; void entry()
{ for (int i = 0; i < 1<<24; ++i) { x[i] = i; }
}

../../rocket-tools/bin/riscv64-unknown-elf-gcc test.c -S -O1

В итоге получим следующий фрагмент ассемблерного листинга, инициализирующий первые 16 Мб памяти:

li a5,1 slli a5,a5,31 li a3,129 slli a3,a3,24
.L2: andi a4,a5,0xff sb a4,0(a5) addi a5,a5,1 bne a5,a3,.L2

S. Его и вставим в начала bootrom/xip/leds. Осталось запустить Makefile, пересобрать проект в Quartus, залить его в плату, подключиться OpenOCD+GDB и… Предположительно, ура, победа: Теперь на одном лишь кеше вряд ли всё сможет держаться.

$ ../../rocket-tools/bin/riscv32-unknown-elf-gdb -q -ex "target remote :3333"
Remote debugging using :3333
warning: No executable has been specified and target does not support
determining executable automatically. Try using the "file" command.
0x0000000000010014 in ?? ()
(gdb) x/x 0x80000000
0x80000000: 0x03020100
(gdb) x/x 0x80000100
0x80000100: 0x03020100
(gdb) x/x 0x80000111
0x80000111: 0x14131211
(gdb) x/x 0x80010110
0x80010110: 0x13121110
(gdb) x/x 0x80010120
0x80010120: 0x23222120

Так ли это узнаем в следующей серии (я пока тоже не могу сказать про производительность, стабильность и т.д.).

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть