Часть 2: RocketChip: подключаем оперативную память
В предыдущей части мы собрали микроконтроллер вообще без оперативной памяти на базе ПЛИС Altera/Intel. Однако на плате есть разъём с установленным SO-DIMM DDR2 1Gb, который, очевидно, хочется использовать. Для этого нам потребуется обернуть DDR2-контроллер с интерфейсом ALTMEMPHY
в модуль, понятный для протокола работы с памятью TileLink, используемого повсюду в RocketChip. Под катом — тактильная отладка, брутфорс программирование и ГРАБЛИ.
Как известно, в Computer Science есть две главные проблемы: инвалидация кешей и именование переменных. На КДПВ вы видите редкий момент — две главные проблемы CS встретили друг друга и что-то замышляют.
DISCLAIMER: В дополнение к предупреждению из предыдущей статьи настоятельно рекомендую дочитать статью до конца перед повторением опытов, во избежание повреждения ПЛИС, модуля памяти или цепей питания.
В этот раз хотелось если не загрузить Linux, то хотя бы подключить оперативную память, коей на моей плате аж целый гигабайт (а можно поставить до четырёх). Критерием успеха предлагается рассматривать возможность читать и писать через связку GDB+OpenOCD, в том числе по адресам, не выровненным на 16 байтов (ширина одного запроса в память). На первый взгляд, нужно просто чуточку поправить конфиг, не может же генератор SoC не поддерживать оперативную память из коробки. Поддерживать-то он поддерживает, но через интерфейс MIG (ну, и, возможно, ещё какой-нибудь интерфейс от Microsemi). Через стандартный интерфейс AXI4 тоже поддерживает, но его, насколько я понимаю, не так то просто заполучить (по крайней мере, не осваивая Platform Designer).
Лирическое отступление: Существует, насколько я понимаю, довольно популярная серия «внутричиповых» интерфейсов AXI, разработанная ARM. Тут можно было бы подумать, что оно всё насквозь патентованое и закрытое. Но после того, как я зарегистрировался (безо всяких «университетских программ» и прочего — просто по e-mail и заполнению анкеты) и получил доступ к спецификации, меня ждало приятное удивление. Я, конечно, не юрист, но похоже, что стандарт довольно таки открытый: вы либо обязаны использовать лицензированные ядра от ARM, либо вообще не претендовать на совместимость с ARM, и тогда вроде всё ОК. Но вообще, конечно, читайте лицензию, читайте с юристами и т.д.
Задача казалась довольно простой, и я открыл описание уже имевшегося в проекте от поставщика платы модуля ddr2_64bit
:
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;
...
Народная мудрость гласит: «Любую документацию на русском языке нужно начинать со слов: «Итак, оно не работает». Но здесь не совсем интуитивно понятный интерфейс, поэтому всё же почитаем. В описании нам тут же рассказывают, что работа с DDR2 — дело непростое. Нужно настроить PLL, провести некую калибровку, крекс-фекс-пекс, выставился сигнал local_init_done
, можно работать. Вообще, логика именования здесь примерно следующая: имена с префиксами local_
— это «пользовательский» интерфейс, порты mem_
нужно непосредственно вывести на ножки, подключённые к модулю памяти, на pll_ref_clk
нужно подать тактовый сигнал с указанной при настройке модуля частотой — из него будут получены остальные частоты, ну и всякие входы-выходы reset и выходы частот, синхронно с которыми должен работать пользовательский интерфейс.
Давайте создадим описание внешних сигналов к памяти и интерфейса модуля ddr2_64bit
:
trait MemIf {
val local_init_done = Output(Bool())
val global_reset_n = Input(Bool())
val pll_ref_clk = Input(Clock())
val soft_reset_n = Input(Bool())
val reset_phy_clk_n = Output(Clock())
val mem_odt = Output(UInt(2.W))
val mem_cs_n = Output(UInt(2.W))
val mem_cke = Output(UInt(2.W))
val mem_addr = Output(UInt(14.W))
val mem_ba = Output(UInt(2.W))
val mem_ras_n = Output(UInt(1.W))
val mem_cas_n = Output(UInt(1.W))
val mem_we_n = Output(UInt(1.W))
val mem_dm = Output(UInt(8.W))
val phy_clk = Output(Clock())
val aux_full_rate_clk = Output(Clock())
val aux_half_rate_clk = Output(Clock())
val reset_request_n = Output(Bool())
val mem_clk = Analog(2.W)
val mem_clk_n = Analog(2.W)
val mem_dq = Analog(64.W)
val mem_dqs = Analog(8.W)
def connectFrom(mem_if: MemIf): Unit = {
local_init_done := mem_if.local_init_done
mem_if.global_reset_n := global_reset_n
mem_if.pll_ref_clk := pll_ref_clk
mem_if.soft_reset_n := soft_reset_n
reset_phy_clk_n := mem_if.reset_phy_clk_n
mem_odt <> mem_if.mem_odt
mem_cs_n <> mem_if.mem_cs_n
mem_cke <> mem_if.mem_cke
mem_addr <> mem_if.mem_addr
mem_ba <> mem_if.mem_ba
mem_ras_n <> mem_if.mem_ras_n
mem_cas_n <> mem_if.mem_cas_n
mem_we_n <> mem_if.mem_we_n
mem_dm <> mem_if.mem_dm
mem_clk <> mem_if.mem_clk
mem_clk_n <> mem_if.mem_clk_n
mem_dq <> mem_if.mem_dq
mem_dqs <> mem_if.mem_dqs
phy_clk := mem_if.phy_clk
aux_full_rate_clk := mem_if.aux_full_rate_clk
aux_half_rate_clk := mem_if.aux_half_rate_clk
reset_request_n := mem_if.reset_request_n
}
}
class MemIfBundle extends Bundle with MemIf
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 как-нибудь сам провода пробросит. Не получилось. Поэтому пришлось сделать жгут проводов MemIfBundle
, который протягивался по всей иерархии. Почему же он не торчит из BlackBox
-а, и не подключается разом? Дело в том, что у 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
}
По стандартному ключу ExtMem
мы извлекаем из конфига SoC параметры внешней памяти (вот этот странный синтаксис позволяет по аналогии с паттерн-матчингом сказать «я знаю, что мне вернут экземпляр case class MemoryPortParameters
(это гарантируется типом ключа на этапе компиляции Scala-кода, при условии, что в рантайме мы не упадём, вынимая содержимое из Option[MemoryPortParams]
, равного None
, но тогда нечего было контроллер памяти создавать в System.scala
…), так вот, сам case class мне не нужен, а некоторые его поля нужны»). Далее мы создаём manager port TileLink-устройства (протокол TileLink обеспечивает взаимодействие практически всего, что связано с памятью: контроллера DDR и других memory-mapped устройств, кешей процессора, возможно, ещё чего-то, у каждого устройства может быть по нескольку портов, каждое устройство может быть и manager, и client). beatBytes
, насколько я понимаю, задаёт размер одной транзакции, а у нас обмен с контроллером ведётся по 16 байт. HasAltmemphyDDR2
и HasAltmemphyDDR2Imp
мы подмешаем в нужных местах в System.scala
, напишем конфиг
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
)
Сделав некий «набросок совы» в AltmemphyDDR2RAMImp
, я синтезировал дизайн (что-то всего на ~30MHz, хорошо, что я тактируюсь от 25MHz) и, положив пальцы на модули памяти и микросхему ПЛИС, залил его в плату. Тут я увидел, что такое настоящий интуитивно понятный интерфейс: это когда ты даёшь в gdb команду на запись в память, и по зависшему процессору и обожжённым чувствующим сильный нагрев пальцам понимаешь, что нужно срочно нажать на плате сброс и поправить контроллер.
Видимо, пришло время почитать документацию на контроллер дальше списка портов. Так, что тут у нас?… Упс, оказывается, входы-выходы с префиксом local_
должны выставляться синхронно не с pll_ref_clk
, который 25MHz, а либо с phy_clk
, выдающим половинную частоту памяти для half-rate контроллера, либо, в нашем случае, aux_half_rate_clk
(может, всё-таки aux_full_rate_clk
?), выдающим полную частоту памяти, а она, на минуточку, 166MHz.
Стало быть, нужно пересекать границы частотных доменов. По старой памяти решил воспользоваться защёлками, точнее цепочкой из них:
+-+ +-+ +-+ +-+
--| |--| |--| |--| |--->
+-+ +-+ +-+ +-+
| | | |
---+ | | |
inclk | | |
| | |
--------+----+ |
outclk |
|
------------------+
output enable
Но, повозившись часок, пришёл к выводу, что не осилю на «скалярных» защёлках две очереди (в высокочастотный домен и обратно), каждая из которых будет иметь противонаправленные сигналы (ready
и valid
), да ещё и так, чтобы быть уверенным, что какой-нибудь битик не отстанет на такт-другой по дороге. Ещё через некоторое время я понял, что и описать синхронизацию на ready
-valid
без общего тактового сигнала — тоже задача сродни созданию неблокирующих структур данных в том смысле, что думать и формально доказывать нужно много, ошибиться легко, заметить трудно, а главное, всё уже реализовано до нас: у Intel есть примитив dcfifo
, который представляет собой очередь конфигурируемой длины и ширины, читается и пишется которая из разных частотных доменов. В итоге я воспользовался экспериментальной возможностью свежего 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)
}
Как я уже потом понял, эта заглушка тоже не работала по причине, что конструкция Wire(...)
, которую я добавил «для надёжности», чтобы показать, что это именно именованный провод, на самом деле использовала аргумент лишь как прототип для создания типа своего значения, но не привязывала его к выражению-аргументу. Также при попытке прочитать, что же всё-таки сгенерировалось, я понял, что в режиме симуляции имеется богатый выбор assertion-ов по поводу несоблюдения протокола TileLink. Они мне ещё наверняка пригодятся позже, но пока обошлось без попытки запустить симуляцию —, а в чём её запускать? 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
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
Его и вставим в начала bootrom/xip/leds.S
. Теперь на одном лишь кеше вряд ли всё сможет держаться. Осталось запустить 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
Так ли это узнаем в следующей серии (я пока тоже не могу сказать про производительность, стабильность и т.д.).