
Мне всегда хотелось творить какую-нибудь дичь с консолями. Не знаю почему, но меня всегда привлекала идея реализовывать на устройствах неожиданные возможности. Это относится и к PlayStation 2, выпущенной Sony в 2000 году.
Sony, пожалуйста, не подавайте в суд на меня за этот пост.
Перейдём сразу к делу: я хочу научиться запускать код на консолях (подробнее о том, зачем это нужно, я расскажу в другом посте). Обычно это делается на языках низкого уровня, но сегодня мы можем проще и удобнее работать с языками наподобие Go. Поэтому я подумал: почему бы и нет?
Поискав онлайн, я не нашёл простого решения, поэтому взялся за эту задачу самостоятельно.
Имейте в виду что я пишу пост после кучи исследований и тестов. Это значит, что многие эксперименты я воспроизвожу по памяти и воссоздавая свои шаги, так что иногда могут возникать несоответствия.
Также стоит учесть, что всё это работает внутри эмулятора. У меня есть PS2, чтобы протестировать систему, но мне слишком лень её настраивать. Плюс перед этим я хочу создать полностью функциональные демо.
Последнее примечание: код я опубликую позже и соответствующим образом дополню пост.
Сложности
По умолчанию Go поддерживает только ограниченный список платформ; к сожалению, в него не входит PS2. На самом деле, Go, похоже, требует наличия операционной системы, которой на консоли не было (если не считать PS2 Linux). Однако для решения этой проблемы у нас есть TinyGo — компилятор Go для маленьких устройств наподобие микроконтроллеров и встроенных систем. По сути, он работает так — берёт код на Go, превращает в LLVM IR, а затем в двоичный код для целевой платформы.
Основной CPU консоли PS2 называется Emotion Engine, он создан на основе MIPS R5900. Он реализует команды MIPS-III и MIPS-IV, плюс некоторые специализированные возможности. Также в нём отсутствуют некоторые аспекты (подробнее об этом ниже). На самом деле, Go может собирать код для MIPS, что сэкономит мне какое-то время, но не очень много, потому что мне придётся заставить работать с ним TinyGo. Компилятор TinyGo использует LLVM 19, который поддерживает MIPS-III, но не CPU R5900.
Я перечислил технические проблемы, но есть и более серьёзная: я не знаю, как работает PS2.
SDK ps2dev и его особенности
Если поискать онлайн, как разрабатывать код для PS2, то вы, вероятно, найдёте ps2dev. Это полнофункциональный SDK, позволяющий достаточно просто генерировать двоичные файлы для консоли. Самое классное в нём то, что он изначально предоставляет различные библиотеки для графических операций, отладки, ввода-вывода и так далее — есть даже stdlib
! Поэтому я подумал: возможно, стоит выполнять компоновку с кодом этого проекта, что позволит проще и быстрее реализовывать всё для PS2 на Go. Можно воспринимать его, как API «операционной системы», который мы сможем вызывать, когда нам понадобятся операции, которые мы не хотим реализовывать самостоятельно.
Тем не менее, здесь возникает несколько проблем. Во-первых, библиотеки ps2dev компилируются в соответствии со стандартом MIPS-III N32. Это значит, что какой бы код мы не создавали, его целевая платформа должна быть той же. С теми же аппаратными float, тем же N32 ABI и так далее. Это немного неудобно, но приемлемо. Соответствие нужно, потому что мы будем компоновать с готовыми библиотеками SDK, а компоновщики любят иметь дело с разными целевыми платформами.
Уточню: MIPS-III N32 означает, что целевая платформа — это CPU MIPS, реализующий набор команд MIPS-III. Это 64-битный CPU, но из-за N32 он выполняет 32-битный код с отдельными 64-командами для обработки 64-битных integer. Поначалу непонятно, но можно почитать об этом подробнее.
Из-за этого вы увидите, что я пытаюсь использовать в дальнейшем в качестве целевой платформы mipsel с CPU mips3, хотя, строго говоря, это должен быть mips64el, так как это 64-битный CPU. N32 должен заставлять код выполняться в 32-битном режиме, даже несмотря на то, что наша целевая платформа должна поддерживать 64-битный код. Однако Clang/LLVM и TinyGo запутываются при этом, и всё становится непонятным и сложным. Кроме того, сборка для mips64el заставляет TinyGo проваливать некоторые этапы верификации при генерации кода с помощью LLVM, а clang отказывается выполнять сборку, потому что код поломан. Мне не терпелось двигаться дальше, поэтому я сдался и решил генерировать код mipsel с N32 ABI, что заставит clang внутри сменить его на MIPS64, но всё равно при этому будет генерироваться валидный код. Как я и говорил, всё очень странно.
Возможно, чтобы сделать всё правильно, придётся заново вернуться к этой теме, но пока делать этого я не буду. В будущем мы можем попробовать отказаться от ps2dev и писать всё непосредственно на Go, но при этом потребуется ассемблер.
Генерируем код при помощи TinyGo
Чтобы TinyGo знал о конкретной целевой платформе, ему нужен определяющий её файл; назовём его ps2.json
. Он определяет множество очень интересных параметров, которые нам пока не особо нужны; вот самые важные из них:
{
"llvm-target": "mipsel-unknown-unknown",
"cpu": "mips3",
"features": "-noabicalls",
"build-tags": ["ps2", "baremetal", "mipsel"],
"goos": "linux",
"goarch": "mipsle",
"linker": "ld.lld",
"rtlib": "compiler-rt",
"libc": "",
"cflags": [
],
"ldflags": [
],
"linkerscript": "",
"extra-files": [
],
"gdb": []
}
Этот файл стал итогом многодневного тестирования различных конфигураций. Он работоспособен лишь частично. Пока он не может генерировать объектные файлы (подробности ниже), поэтому я не стал заморачиваться с заполнением флагов для компиляции и компоновки кода. Однако здесь есть важные моменты, которые нужно объяснить:
Целевая платформа: mipsel-unknown-unknown. Это целевая платформа для LLVM. Выше я объяснил, почему продолжаю использовать mipsel.
У фич есть
-noabicalls
. Он необходимо, потому что в противном случае всё ломается и не работает (генерируемое LLVM IR оказывается поломанным).Я настроил файл так, чтобы не использовалась никакая
libc
. Это вызвано тем, что ps2dev и так предоставляет библиотеку, а мне не хотелось во всё это влезать. Кроме того, поскольку мы выполняем компоновку с кодом SDK, то можно использовать и его версию библиотеки.
Это базовый файл целевой платформы, чтобы TinyGo хотя бы понимал, что такое PS2. Но это ещё не всё — нам нужно определить несколько специфичных для платформы функций.
Определения baremetal
Нашей целевой платформе нужна конфигурация baremetal — baremetal_ps2.go. Обычно достаточно стандартного файла baremetal, но в данном случае я решил создать собственный, чтобы переопределить некоторые аспекты.
Комментарий из будущего: ситуацию можно улучшить, изменив файл компоновщика так, чтобы он находил правильные extern. Возможно, я так и сделаю, вернувшись к этому в дальнейшем.
//go:build ps2
package runtime
import "C"
import (
"unsafe"
)
//go:extern _heap_start
var heapStartSymbol [0]byte
//go:extern _heap_end
var heapEndSymbol [0]byte
//go:extern _fdata
var globalsStartSymbol [0]byte
//go:extern _edata
var globalsEndSymbol [0]byte
//go:extern _stack_top
var stackTopSymbol [0]byte
var (
heapStart = uintptr(unsafe.Pointer(&heapStartSymbol))
heapEnd = uintptr(unsafe.Pointer(&heapEndSymbol))
globalsStart = uintptr(unsafe.Pointer(&globalsStartSymbol))
globalsEnd = uintptr(unsafe.Pointer(&globalsEndSymbol))
stackTop = uintptr(unsafe.Pointer(&stackTopSymbol))
)
func growHeap() bool {
// В baremetal кучу никаким образом нельзя увеличивать.
return false
}
//экспортируем runtime_putchar
func runtime_putchar(c byte) {
putchar(c)
}
//go:linkname syscall_Exit syscall.Exit
func syscall_Exit(code int) {
// TODO
exit(code)
}
const baremetal = true
var timeOffset int64
//go:linkname now time.now
func now() (sec int64, nsec int32, mono int64) {
mono = nanotime()
sec = (mono + timeOffset) / (1000 * 1000 * 1000)
nsec = int32((mono + timeOffset) - sec*(1000*1000*1000))
return
}
func AdjustTimeOffset(offset int64) {
timeOffset += offset
}
var errno int32
//export __errno_location
func libc_errno_location() *int32 {
return &errno
}
Нужно ли нам полностью понимать, как всё это работает? Нет. Более того, основная часть кода просто скопирована из обычной реализации baremetal.go. Мы без проблем сможем изменить его при необходимости. Как я говорил, по большей мере это нужно для сборки, поэтому мы сможем разобраться, что пойдёт не так, и внести исправления.
Примечание: чтобы это заработало, нам всё равно придётся отключить сборку исходного baremetal.go для нашей целевой платформы, поэтому нужно изменить флаг сборки на
//go:build baremetal && !ps2
.
Среда выполнения
Нашей целевой платформе требуется файл определения среды выполнения runtime_ps2.go
. В нём определяется множество специфичных для платформы функций, в том числе способ реализации putchar
, exit
и даже main
.
Простейшая реализация будет выглядеть так:
//go:build ps2
package runtime
/*
extern void _exit(int status);
extern void* malloc(unsigned int size);
extern void free(void *ptr);
extern void scr_printf(const char *format, ...);
*/
import "C"
import "unsafe"
// timeUnit в наносекундах
type timeUnit int64
func initUART() {
// Не поддерживается.
}
func putchar(c byte) {
// Это очень грязный способ. Он предполагает, что экран отладки уже активирован и выводит
// каждый раз полную строку для отдельного char. Очень медленно, но работает. Можно усовершенствовать код позже.
x := C.CString(string(c))
C.scr_printf(x)
C.free(unsafe.Pointer(x))
}
func getchar() byte {
// TODO
return 0
}
func buffered() int {
// TODO
return 0
}
func sleepWDT(period uint8) {
// TODO
}
func exit(code int) {
// Просто делегируем выполнение функции ps2dev _exit(int).
C._exit(C.int(code))
}
func abort() {
// TODO
}
func ticksToNanoseconds(ticks timeUnit) int64 {
// TODO
return int64(ticks)
}
func nanosecondsToTicks(ns int64) timeUnit {
// TODO
return timeUnit(ns)
}
func sleepTicks(d timeUnit) {
// TODO
}
func ticks() (ticksReturn timeUnit) {
// TODO
return 0
}
Большая часть кода не реализована, и это сделано намеренно — пока я не буду всё это использовать, так что меня это не волнует. Позже мы можем реализовать эти части и добиться их правильной работы. Думаю, некоторые из функций даже можно реализовать, например, при помощи функций C ps2dev.
Прерывания
Ещё один необходимый нам базовый файл — это определения прерываний interrupt_ps2.go. Я знаю, что в ps2dev есть определения для этих вызовов, но решил пока не вызывать их. Пока мне не нужны прерывания, поэтому я реализовал функции-заглушки:
//go:build ps2
package interrupt
type State uintptr
func Disable() (state State) {
return 0
}
func Restore(state State) {}
func In() bool {
return false
}
Со всем этим у нас должно получиться собрать код на Go. Давайте попробуем.
Вызываем функции Go из C
Давайте начнём с простого примера: пусть код на C возвращает число и строку, ничего сложного. Разделим программу на две части: загрузчик (на C) и код на Go. Это будет работать так:

Вот код на Go:
//export aGoString
func aGoString() *C.char {
return C.CString("The answer for everything is")
}
//export aGoNumber
func aGoNumber() C.int {
return C.int(42)
}
А вот загрузчик, содержащий функцию main
:
// Экспортированные ранее функции go.
extern char* aGoString();
extern int aGoNumber();
int main() {
// Инициализируем экран отладки.
sceSifInitRpc(0);
init_scr();
// Выводим полученное от функций Go.
scr_printf("%s: %d\n", aGoString(), aGoNumber());
// Бесконечный цикл, чтобы программа продолжала выполняться.
while (1) {}
return 0;
}
Очень простой код, правда? Давайте соберём его.
Хотя нет, постойте, здесь есть проблема. По умолчанию TinyGo нужно, чтобы мы генерировали с его помощью финальный ELF (.elf) или объектный файл (.o). Однако ELF требует добавления linkfile и некоторых других дополнительных частей кода, а мы от этого ещё далеки. Пока мы просто хотим получить какие-то функции, чтобы можно было выполнить компоновку, а значит, и использовать объектный файл.
Однако при попытке сделать это сгенерируется некорректный файл:
$ tinygo build -target ps2 -o test.o
$ file test.o
test.o: ELF 32-bit LSB relocatable, MIPS, MIPS-III version 1 (SYSV), with debug_info, not stripped
Обратите внимание, что в строке отсутствует N32
Я подумал: так, ладно, наверно, нам просто не хватает cflags
и ldflags
? Попробуем их добавить:
{
// (...)
"cflags": [
"-mabi=n32"
],
"ldflags": [
"-mabi=n32"
],
// (...)
Возможно, это не те флаги, но согласно документации это как будто они.
$ tinygo build -target ps2 -o test.o
$ file test.o
test.o: ELF 32-bit LSB relocatable, MIPS, MIPS-III version 1 (SYSV), with debug_info, not stripped
Ой. Понятно.
Так как TinyGo здесь по каким-то причинам упорствует, я решил разбить процесс на этапы, чтобы его было проще контролировать. TinyGo будет генерировать из нашего кода на Go некое LLVM IR, а затем собирать его. Тогда давайте остановимся на уровне LLVM IR:
$ tinygo build -target ps2 -o build/go.ll
И эта команда сгенерирует валидный файл LLVM IR! Теперь мы можем просто собрать его вручную в объектный файл с нужным нам форматом:
$ clang -fno-pic -c --target=mips64el -mcpu=mips3 -fno-inline-functions -mabi=n32 -mhard-float -mxgot -mlittle-endian -o build/go.o build/go.ll
Флаги здесь важны. Наша целевая платформа — MIPS64 (ею недоволен только TinyGo), Little Endian, с набором команд MIPS-III, использующая N32 ABI. В ней используются аппаратные числа с плавающей запятой, а также -fno-pic
и -mxgot
, чтобы решить проблему ограничения размера глобальной таблицы смещений. Настроив всё это, мы получим следующее:
$ file build/go.o
build/go.o: ELF 32-bit LSB relocatable, MIPS, N32 MIPS-III version 1 (SYSV), with debug_info, not stripped
Наконец-то!
Далее мы можем выполнять компоновку с кодом на C. Для этого я решил использовать команду компоновки ps2dev (извлечённую из Makefile) с добавленным в неё кодом на Go:
mips64r5900el-ps2-elf-gcc \
-Tlinkfile \
-L/usr/local/ps2dev/ps2sdk/ee/lib \
-L/usr/local/ps2dev/ps2sdk/ports/lib \
-L/usr/local/ps2dev/gsKit/lib/ \
-Lmodules/ds34bt/ee/ \
-Lmodules/ds34usb/ee/ \
-Wl,-zmax-page-size=128 \
-lpatches \
-lfileXio \
-lpad \
-ldebug \
-lmath3d \
-ljpeg \
-lfreetype \
-lgskit_toolkit \
-lgskit \
-ldmakit \
-lpng \
-lz \
-lmc \
-laudsrv \
-lelf-loader \
-laudsrv \
-lc \
-mhard-float \
-msingle-float \
-o build/main.elf \
build/loader.o \
build/asm_mipsx.o \
build/go.o
Загрузчик — это код на C.
Примечание: asm_mipsx.o — это какой-то ассемблерный код, предоставленный TinyGo, поэтому я просто скопировал его в проект и собрал при помощи clang. Он выложен на github.
И так мы собрали наше новое приложение!
$ file build/main.elf
build/main.elf: ELF 32-bit LSB executable, MIPS, N32 MIPS-III version 1 (SYSV), statically linked, with debug_info, not stripped
Его можно успешно запустить:

Переходим на main Go
Пока вызываемая функция main
написана не на Go, а на C — это то, что я называл выше загрузчиком. Однако приложения Go могут запускаться и сами, без загрузчика на C. И было бы здорово, чтобы так умели и наши игровые приложения для PS2!
Изменения в среде выполнения
Чтобы приложения на Go могли запускаться без загрузчика, первым делом нужно раскрыть на Go функцию main
. Это можно сделать в runtime_ps2.go:
//export main
func main() {
preinit()
run()
preexit()
exit(0)
}
const (
memSize = uint(24 * 1024 * 1024)
)
var (
goMemoryAddr uintptr
)
func preinit() {
// Примечание: нет необходимости очищать .bss и другие области памяти, потому что crt0 уже делает это в __start.
// Так как мы загружаемся в существующее ядро ps2dev, безопаснее будет
// предварительно выполнить malloc. Это гарантирует, что местоположение кучи будет нашим.
// Однако в дальнейшем его нужно будет освободить.
goMemoryAddr = uintptr(unsafe.Pointer(C.malloc(C.uint(memSize))))
heapStart = goMemoryAddr
heapEnd = goMemoryAddr + uintptr(memSize)
}
func preexit() {
C.free(unsafe.Pointer(heapStart))
}
Здесь стоит отметить несколько важных моментов:
Начало и конец кучи можно определить файлом компоновщика. И это действительно происходит, однако предоставляемый ps2dev crt0 по какой-то причине сбрасывает эти переменные, из-за чего они ломаются.
Мы можем предположить, что всё выше определённого адреса памяти принадлежит нам, но-о-о... ps2dev может захотеть поиграться с дополнительной памятью, а я пока разбираться с этим не хочу.
Мы распределяем память при помощи malloc ps2dev, как это указано в коде. Так мы гарантируем, что эта область памяти принадлежит нам — если библиотекам понадобится больше, им всё равно должно остаться свободной памяти, так как у PS2 есть 32 МБ, а мы распределяем всего 24 МБ.
Строго говоря, мы можем сделать так, чтобы куча увеличивалась по необходимости, но это уже задача на будущее.
Мы намеренно освобождаем память после использования. На самом деле этого не требуется, просто на всякий случай.
Функция
run
отвечает за вызов нашей функцииmain
внутри пакетаmain
. Этим нам заниматься не требуется, это делает код TinyGo, а нам достаточно выполнить вызов.
Вкратце это работает так:

Строго говоря, это смешанный подход: это одновременно baremetal (потому что выполняется без операционной системы), и нет (потому что код распределяет память, входит и выходит из приложения).
Забавный факт: после завершения кода система переходит к экрану выбора карты памяти!

Наш код на Go
Давайте напишем что-нибудь на Go. Первым делом нам нужно что-то, что можно вызвать, поэтому создадим пакет debug
с функциями экрана отладки:
package debug
/*
extern void free(void *ptr);
extern void sceSifInitRpc(int mode);
extern void init_scr(void);
extern void scr_printf(const char *format, ...);
*/
import "C"
import (
"fmt"
"unsafe"
)
func Init() {
C.sceSifInitRpc(0)
C.init_scr()
}
func Printf(format string, args ...interface{}) {
formatted := fmt.Sprintf(format, args...)
str := C.CString(formatted)
C.scr_printf(str)
C.free(unsafe.Pointer(str))
}
Да, в коде есть
extern
для функцииfree
, который можно заменить наstdlib
. Пока я не стал этого делать, потому что для этого нужно было бы добавить флаги C для включения путей, поэтому код стал бы некрасивее. Вот, как это бы выглядело:
/*
#cgo CFLAGS: -I/Users/ricardo/dev/ps2dev/ee/mips64r5900el-ps2-elf/include -I/Users/ricardo/dev/ps2dev/ee/lib/gcc/mips64r5900el-ps2-elf/14.2.0/include/ -I/Users/ricardo/dev/ps2dev/gsKit/include -I/Users/ricardo/dev/ps2dev/ps2sdk/common/include -I/Users/ricardo/dev/ps2dev/ps2sdk/ports/include/freetype2 -I/Users/ricardo/dev/ps2dev/ps2sdk/ports/include/zlib
#include <stdlib.h>
extern void sceSifInitRpc(int mode);
extern void init_scr(void);
extern void scr_printf(const char *format, ...); /Ситуацию можно улучшить, перенеся эти флаги внешне в процесс сборки, но это уже задача на будущее.
В целом, здесь нет ничего особо сложного, обычные функции отладки, предоставленные ps2dev (объявленные и реализованные). И теперь их можно просто вызвать:
package main
import (
"ps2go/debug"
)
func main() {
debug.Init()
debug.Printf("Hello world from Go!\n")
debug.Printf(`
____ _
/ ___| ___ _ __ _ _ _ __ _ __ (_)_ __ __ _ ___ _ __
| | _ / _ \ | '__| | | | '_ \| '_ \| | '_ \ / _' | / _ \| '_ \
| |_| | (_) | | | | |_| | | | | | | | | | | | (_| | | (_) | | | |
\____|\___/ |_| \__,_|_| |_|_| |_|_|_| |_|\__, | \___/|_| |_|
____ _ ____ _ _ _ |___/ ____
| _ \| | __ _ _ _/ ___|| |_ __ _| |_(_) ___ _ __ |___ \
| |_) | |/ _' | | | \___ \| __/ _' | __| |/ _ \| '_ \ __) |
| __/| | (_| | |_| |___) | || (_| | |_| | (_) | | | | / __/
|_| |_|\__,_|\__, |____/ \__\__,_|\__|_|\___/|_| |_| |_____|
|___/
`)
for {
// Бесконечный цикл, чтобы не произошёл выход!
}
}
Давайте соберём код и посмотрим, что будет:
$ tinygo build -target ps2 -o build/go.ll
$ clang -fno-pic -c --target=mips64el -mcpu=mips3 -fno-inline-functions -mabi=n32 -mhard-float -mxgot -mlittle-endian -o build/go.o build/go.ll
$ mips64r5900el-ps2-elf-gcc \
-Tlinkfile \
-L/usr/local/ps2dev/ps2sdk/ee/lib \
-L/usr/local/ps2dev/ps2sdk/ports/lib \
-L/usr/local/ps2dev/gsKit/lib/ \
-Lmodules/ds34bt/ee/ \
-Lmodules/ds34usb/ee/ \
-Wl,-zmax-page-size=128 \
-lpatches \
-lfileXio \
-lpad \
-ldebug \
-lmath3d \
-ljpeg \
-lfreetype \
-lgskit_toolkit \
-lgskit \
-ldmakit \
-lpng \
-lz \
-lmc \
-laudsrv \
-lelf-loader \
-laudsrv \
-lc \
-mhard-float \
-msingle-float \
-o build/main.elf \
build/asm_mipsx.o \
build/go.o
Вроде всё просто.
Происходит сборка файла ELF. Давайте загрузим его в эмулятор и посмотрим!

Успех!
Проблема DDIVU
При тестировании базовой функциональности я заметил, что fmt.Sprintf
работает неправильно. Взгляните на мой очень простой код:
func main() {
debug.Init()
for i := -32; i <= 32; i++ {
debug.Printf("%02d, ", i)
}
for {
// Бесконечный цикл, чтобы не произошёл выход!
}
}

Так, это неправильно. Числа от -9 до +9 отображаются корректно, а всё остальное ошибочно. Для решения этой проблемы мне понадобилось несколько дней. В конечном итоге я сузил проблему до части реализации fmtInteger
, используемой Sprintf
в пакете fmt
:
func (f *fmt) fmtInteger(u uint64, base int, isSigned bool, verb rune, digits string) {
// (... здесь много кода ...)
switch base {
case 10:
for u >= 10 {
i--
next := u / 10
buf[i] = byte('0' + u - next*10)
u = next
}
// (... здесь много кода ...)
}
Посмотрите, как TinyGo генерирует для него код LLVM IR:
!875 = !DIFile(filename: "format.go", directory: "/usr/local/go/src/fmt")
!15696 = !DILocalVariable(name: "next", scope: !15679, file: !875, line: 243, type: !373)
; (...)
lookup.next: ; preds = %for.body
%31 = udiv i64 %27, 10, !dbg !15759
#dbg_value(i64 %31, !15696, !DIExpression(), !15757)
%.neg = mul i64 %31, 246, !dbg !15760
%32 = add i64 %27, 48, !dbg !15761
%33 = add i64 %32, %.neg, !dbg !15762
%34 = trunc i64 %33 to i8, !dbg !15763
%35 = getelementptr inbounds i8, ptr %.pn75, i32 %30, !dbg !15758
store i8 %34, ptr %35, align 1, !dbg !15758
#dbg_value(i64 %31, !15696, !DIExpression(), !15764)
#dbg_value(i64 %31, !15684, !DIExpression(), !15765)
br label %for.loop, !dbg !15700
Выглядит вполне неплохо. Изучив глубже, найдём конкретный участок: udiv i64 %27, 10
— это беззнаковое деление 64-битного integer на 10. Запомним его.
При этом генерируется следующий ассемблерный код MIPS:
.LBB139_23: # %lookup.next
# in Loop: Header=BB139_19 Depth=1
#DEBUG_VALUE: (*fmt.fmt).fmtInteger:i <- [DW_OP_plus_uconst 176] [$sp+0]
#DEBUG_VALUE: (*fmt.fmt).fmtInteger:u <- [DW_OP_plus_uconst 184] [$sp+0]
#DEBUG_VALUE: (*fmt.fmt).fmtInteger:negative <- [DW_OP_plus_uconst 332] [$sp+0]
#DEBUG_VALUE: (*fmt.fmt).fmtInteger:digits <- [DW_OP_LLVM_fragment 32 32] 17
#DEBUG_VALUE: (*fmt.fmt).fmtInteger:base <- [DW_OP_plus_uconst 316] [$sp+0]
#DEBUG_VALUE: (*fmt.fmt).fmtInteger:verb <- [DW_OP_plus_uconst 312] [$sp+0]
#DEBUG_VALUE: (*fmt.fmt).fmtInteger:digits <- [DW_OP_plus_uconst 308, DW_OP_LLVM_fragment 0 32] [$sp+0]
.loc 129 0 7 is_stmt 0 # format.go:0:7
lw $1, 176($sp) # 4-byte Folded Reload
lw $4, 272($sp) # 4-byte Folded Reload
ld $3, 184($sp) # 8-byte Folded Reload
daddiu $2, $zero, 10
.loc 129 243 14 is_stmt 1 # format.go:243:14
ddivu $zero, $3, $2
teq $2, $zero, 7
mflo $2
Давайте сосредоточимся на одной части: ddivu $zero, $3, $2
. Вроде бы выглядит правильно, так?
Ну-у-у… Давайте посмотрим, как это загружает PCSX2:

Ага. PCSX2 не видит команду DDIVU. Или, если точнее, её не видит PlayStation.
DDIVU (doubleword divide unsigned) — это команда, определённая в MIPS-III (источник) и отвечающая за деление двух беззнаковых 64-битных integer.
Однако, как мы видели выше, в PS2 она не работает. Дело в том, что команда DDIVU не определена (источник) в наборе команд PS2 MIPS, там есть только DIVU. Это приводит к серьёзной проблеме: все деления int64 (при помощи DDIV) и uint64 (при помощи DDIVU) не будут выполняться или будут выполняться некорректно, если сопоставятся с какой-то другой командой. Нам нужно избежать этого, или отделив это деление внутри компилятора Go так, чтобы не выполнялась его 64-битная версия, или так изменив LLVM, чтобы он не использовал эту команду, или даже в CPU mips3. Или же можно реализовать внутри LLVM специальный CPU — r5900, как в GCC ps2dev.
Ищем выход
Первым делом я подумал, что можно это адаптировать в LLVM. Но не буду вас обманывать, изменение этого кода — настоящий ад. Он очень сложный, требует кучи изменений и чаще всего требует полной пересборки проекта LLVM. Я слишком ленив для этого, поэтому выбрал ужасное решение: сделать всё это внутри компилятора TinyGo.
Первым делом нам нужен код 64-битного деления. Согласно моему доброму другу ChatGPT (который, разумеется, не может ошибаться), когда 64-битное деление недоступно (как в R5900), GCC использует вспомогательную функцию __udivdi3
:
uint64_t __udivdi3(uint64_t a, uint64_t b);
Я подумал, что можно просто отобразить на неё деление uint64
. Сначала нужно добавить его как что-то, доступное в нашем runtime_ps2.go
(потому что я слишком ленив, чтобы правильно реализовывать вызов):
//go:build ps2
package runtime
/*
extern long __divdi3(long a, long b);
extern unsigned long __udivdi3 (unsigned long a, unsigned long b);
extern long __moddi3(long a, long b);
extern unsigned long __umoddi3(unsigned long a, unsigned long b);
*/
import "C"
func int64div(a, b int64) int64 {
return int64(C.__divdi3(C.long(a), C.long(b)))
}
func uint64div(a, b uint64) uint64 {
return uint64(C.__udivdi3(C.ulong(a), C.ulong(b)))
}
func int64mod(a, b int64) int64 {
return int64(C.__moddi3(C.long(a), C.long(b)))
}
func uint64mod(a, b uint64) uint64 {
return uint64(C.__umoddi3(C.ulong(a), C.ulong(b)))
}
Далее нужно изменить компилятор TinyGo так, чтобы он мог это использовать. Это проще, чем кажется — весь код обработки можно найти на github.
Давайте начнём с беззнаковых операций:
if op == token.QUO {
return b.CreateUDiv(x, y, ""), nil
} else {
return b.CreateURem(x, y, ""), nil
}
которые превратятся в следующее:
if op == token.QUO {
if (x.Type().TypeKind() == llvm.IntegerTypeKind && x.Type().IntTypeWidth() == 64) ||
(y.Type().TypeKind() == llvm.IntegerTypeKind && y.Type().IntTypeWidth() == 64) {
return b.createRuntimeCall("uint64div", []llvm.Value{x, y}, ""), nil
} else {
return b.CreateUDiv(x, y, ""), nil
}
} else {
if (x.Type().TypeKind() == llvm.IntegerTypeKind && x.Type().IntTypeWidth() == 64) ||
(y.Type().TypeKind() == llvm.IntegerTypeKind && y.Type().IntTypeWidth() == 64) {
return b.createRuntimeCall("uint64mod", []llvm.Value{x, y}, ""), nil
} else {
return b.CreateURem(x, y, ""), nil
}
}
Затем мы просто пересобираем компилятор TinyGo с помощью make
и пересобираем наше приложение. Протестируем наш старый код заново:

И для наших операций int64 тоже. Следующий код:
if op == token.QUO {
return b.CreateSDiv(x, y, ""), nil
} else {
return b.CreateSRem(x, y, ""), nil
}
мы адаптируем в такой:
if op == token.QUO {
if (x.Type().TypeKind() == llvm.IntegerTypeKind && x.Type().IntTypeWidth() == 64) ||
(y.Type().TypeKind() == llvm.IntegerTypeKind && y.Type().IntTypeWidth() == 64) {
return b.createRuntimeCall("int64div", []llvm.Value{x, y}, ""), nil
} else {
return b.CreateSDiv(x, y, ""), nil
}
} else {
if (x.Type().TypeKind() == llvm.IntegerTypeKind && x.Type().IntTypeWidth() == 64) ||
(y.Type().TypeKind() == llvm.IntegerTypeKind && y.Type().IntTypeWidth() == 64) {
return b.createRuntimeCall("int64mod", []llvm.Value{x, y}, ""), nil
} else {
return b.CreateSRem(x, y, ""), nil
}
}
Теперь можно протестировать изменения:
debug.Printf("\n\n")
for i := int64(-8); i <= 8; i++ {
debug.Printf("%02d | div02 = %02d | mod04 = %02d\n", i, i/2, i%4)
}

И так мы решили проблему 64-битных integer! Ура!
Да, я знаю, что могут быть и другие нереализованные команды. Пока я точно не буду этим заниматься. И да, я не тестировал большие числа, но пока они мне и не нужны.
Спойлер: то, что мы решили проблему не на уровне LLVM, в дальнейшем нам аукнется.
Что дальше?
Ну, нам нужно продолжать двигаться вперёд! Предстоит ещё многое сделать:
Реализовать аспекты, относящиеся к целевой платформе: системные вызовы, встроенный ассемблерный код и поддержку прерываний
Числа с плавающей запятой, потому что пока они не работают
Новый CPU MIPS для LLVM — да, вероятно, он нам понадобится, плюс таким образом мы сможем избежать засовывания хаков в компилятор TinyGo
И всё остальное, что нам пригодится!
Вы можете задать вопрос: «Что с этим можно сделать сейчас?» Ну, на самом деле, что угодно. Можно вызывать библиотеки ps2dev и экспериментировать с ними, а если что-то не будет получаться, просто вызывать код на C из Go. Но ваш код в первую очередь будет выполняться на стороне Go, что, как мне кажется, здорово, хотя возможности пока довольно ограничены.
Я уже работаю над второй частью проекта, так что ждите новых постов!
