Бобер который смог: бекпорт Golang на Windows 7
- воскресенье, 23 марта 2025 г. в 00:00:08
После того как нам удалось сделать это с Node.js, занялись поиском следующей жертвы, которой после недолгих раздумий стал компилятор Go.
Полгода жестоких экспериментов и удивительный результат.
В отличие от истории с Node.js, бекпорт последней версии Golang на Windows 7 является чисто исследовательским проектом, который был реализован ради треша и угара демонстрации наших талантов.
Язык Golang слишком молодой и далек от мира Windows, несмотря на широкие возможности даже там.
Поэтому просто не успело образоваться сколь-нибудь серьезное количество проектов на Golang и под Windows, нуждающихся в подобном бекпорте.
Объем исходного кода компилятора Go не очень большой, поэтому его сборка из исходников занимает считанные минуты даже на слабой машине без особых ресурсов.
Это не .NET и не Chromium собираемые из исходников сутками, с риском спалить оборудование.
К сожалению начиная с версии 1.5, для сборки компилятора Go необходимо иметь работающий компилятор предыдущей версии:
The minimum version of Go required depends on the target version of Go:
Go <= 1.4: a C toolchain.
1.5 <= Go <= 1.19: a Go 1.4 compiler.
1.20 <= Go <= 1.21: a Go 1.17 compiler.
1.22 <= Go <= 1.23: a Go 1.20 compiler.
Going forward, Go version 1.N will require a Go 1.M compiler,
where M is N-2 rounded down to an even number.
Example: Go 1.24 and 1.25 require Go 1.22.
Так что для сборки последней на момент написания статьи версии 1.24, необходимо иметь работающий компилятор версии 1.22, для сборки которой надо иметь версию 1.20.
Поддержка Windows 7 в Golang закончилась не так уж давно — с релизом версии 1.20 в феврале 2023 года:
Go 1.20 was the last release supporting Windows 2008: https://go.dev/doc/go1.20#windows
Но даже за столь небольшой временной отрезок, разработчики успели наделать серьезных внутренних изменений (особенно в последней версии), заставивших искать концы с определенным напряжением.
Можно было начать проект с версии 1.20, бинарные сборки которой для Windows все еще доступны для скачивания, но мы решили пойти дальше и последовательно собрали всю цепочку компиляторов, начиная с версии 1.4.
Если вам это не актуально, то начальный этап можно пропустить.
Удивительно, но сборка Golang с помощью стандартного компилятора Microsoft не поддерживается и требует MinGW, причем еще и вместе с binutils.
И все это на Windows, да.
Чтобы не заморачиваться сборкой еще и такого цирка из исходников, рекомендую использовать готовый билд, например отсюда.
Папка bin
из архива должна быть в переменной PATH:
set PATH=c:\work\mingw64\bin;%PATH%
Архивы с исходным кодом Golang были взяты с официального сайта, в качестве альтернативного варианта можно использовать официальный репозиторий Google, так например выглядит релизная ветка для версии 1.22:
Во всех версиях сборка осуществляется с помощью скриптов в каталоге src
:
cd src
make
Начнем наши приключения с версии 1.4 — последней версии Golang, собираемой с помощью компилятора С.
Скачиваем архив с исходным кодом, распаковываем и запускаем сборку:
Если сборка завершится без ошибок — в каталоге bin
появится рабочий компилятор Go версии 1.4.
Следующим шагом задаем переменную окружения, указывающую на только что собранную версию 1.4, которая будет использована для сборки уже версии 1.19:
set GOROOT_BOOTSTRAP=c:\work\backport-go\1.4
Запускаем сборку аналогичным образом:
Если и эта сборка завершится успешно, в каталоге bin
появится работающий компилятор Go уже версии 1.19:
Точно таким же образом повторяем для сборки версии 1.20:
В итоге всех этих манипуляций, вы получите последнюю версию Golang, работающую в Windows 7 «из коробки» и собранную целиком из исходников.
Напоминаю, что можно было столь сильно не заморачиваться и просто скачать готовую релизную версию 1.20, которая будет использоваться для последующих сборок.
К сожалению на версии 1.20 сказка заканчивается и начинается традиционная жесть:
Да, глаза вас не подводят — ошибка действительно вроде бы в ассемблерном коде:
..
docall:
// Call stdcall function.
CALL AX
ADDQ $(const_maxArgs*8), SP
// Return result.
MOVQ 0(SP), CX
MOVQ 8(SP), SP
MOVQ AX, libcall_r1(CX)
// Floating point return values are returned in XMM0. Setting r2 to this
// value in case this call returned a floating point value. For details,
// see https://docs.microsoft.com/en-us/cpp/build/x64-calling-convention
MOVQ X0, libcall_r2(CX)
// GetLastError().
MOVQ 0x30(GS), DI
MOVL 0x68(DI), AX
MOVQ AX, libcall_err(CX)
RET
..
Код выше не сгенерирован, он создан и поддерживается полностью вручную.
Так что для работы над компилятором Golang внезапно надо знать ассемблер, причем на хорошем уровне, поскольку внутри много сложных оптимизаций.
75 строка на которую указывает трассировка (трассировка в ассемблерном коде!) это на самом деле подготовка вызова WinAPI функции:
ADDQ $(const_maxArgs*8), SP
Поэтому ошибка является наведенной.
Наведенная ошибка в коде на ассемблере, который вызывает функцию WinAPI!
А вы говорите дурка и санитары нейросети нас всех заменят, ага.
Стоило больших усилий выяснить реальную природу происходящего и найти два ключевых патча (один и два), изменения в которых необходимо откатить, чтобы Golang снова заработал в «Windows 7 and older» системах.
Прежде чем приступать к «кровавому патчингу», вам стоит знать что накатить данные патчи с помощью cherry pick невозможно, поскольку они создавались для одной конкретной версии исходников Golang, а применять их придется несколько раз и для разных версий.
Так что вся работа осуществлялась полностью вручную.
Из описания коммита, следует что авторы снова «хотели сделать как лучше»:
RtlGenRandom is a semi-undocumented API, also known as
SystemFunction036, which we use to generate random data on Windows.
It's definition, in cryptbase.dll, is an opaque wrapper for the
documented API ProcessPrng. Instead of using RtlGenRandom, switch to
using ProcessPrng, since the former is simply a wrapper for the latter,
there should be no practical change on the user side, other than a minor
change in the DLLs we load.
Все красиво, логично и замечательно, кроме того что в Windows 7 нет поддержки функции ProcessPrng.
А упомянутая SystemFunction036 внезапно есть.
Ошибка частая и страдают все, кто по какой-то причине еще использует устаревшие версии Windows.
Пропускаем две первых правки, поскольку они не содержат изменений непосредственно кода, смотрим файл rand_windows.go:
Напоминаю, поскольку мы делаем портирование назад, нужная нам логика находится слева и помечена красным, а ненужная (она же текущая) — справа.
Результат должен выглядеть так:
// Copyright 2010 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Windows cryptographically secure pseudorandom number
// generator.
package rand
import (
"internal/syscall/windows"
)
func init() { Reader = &rngReader{} }
type rngReader struct{}
func (r *rngReader) Read(b []byte) (n int, err error) {
// RtlGenRandom only returns 1<<32-1 bytes at a time. We only read at
// most 1<<31-1 bytes at a time so that this works the same on 32-bit
// and 64-bit systems.
if err := batched(windows.RtlGenRandom, 1<<31-1)(b); err != nil {
return 0, err
}
return len(b), nil
}
В следующем файле zsyscall_windows.go все несколько сложнее:
Несмотря на цветовую подсветку из всего блока нужно лишь закоментировать строчку:
modbcryptprimitives = syscall.NewLazyDLL(sysdll.Add("bcryptprimitives.dll"))
и заменить описание метода procProcessPrng, строка:
procProcessPrng = modbcryptprimitives.NewProc("ProcessPrng")
меняется на:
procSystemFunction036 = modadvapi32.NewProc("SystemFunction036")
Но это еще не конец, чуть ниже в этом файле есть еще правки:
Нужно восстановить оригинальный метод, непосредственно вызывающий syscall
:
func RtlGenRandom(buf []byte) (err error) {
var _p0 *byte
if len(buf) > 0 {
_p0 = &buf[0]
}
r1, _, e1 := syscall.Syscall(procSystemFunction036.Addr(), 2, uintptr(unsafe.Pointer(_p0)), uintptr(len(buf)), 0)
if r1 == 0 {
err = errnoErr(e1)
}
return
}
Наконец последний файл, в который необходимо внести изменения это os_windows.go. Первым делом заменяем вызов все того же ProcessPrng
:
Вместо:
_ProcessPrng stdFunction
надо вставить:
_RtlGenRandom stdFunction
Ниже в этом же файле необходимо изменить такую своеобразную ссылку на название вызываемой библиотеки:
Вместо:
bcryptprimitivesdll = [...]uint16{'b', 'c', 'r', 'y', 'p', 't', 'p', 'r', 'i', 'm', 'i', 't', 'i', 'v', 'e', 's', '.', 'd', 'l', 'l', 0}
должно получиться:
advapi32dll = [...]uint16{'a', 'd', 'v', 'a', 'p', 'i', '3', '2', '.', 'd', 'l', 'l', 0}
Еще немного ниже по коду вас ждет еще одна правка, в методе loadOptionalSyscalls
:
Тут всего лишь простая замена блока, текущую версию (справа) необходимо заменить на предыдущую версию (слева):
a32 := windowsLoadSystemLib(advapi32dll[:])
if a32 == 0 {
throw("advapi32.dll not found")
}
_RtlGenRandom = windowsFindfunc(a32, []byte("SystemFunction036\000"))
Наконец последняя правка в этом файле:
Необходимо заменить готовый вызов метода, отвечающего за генерацию шума (случайных данных) в системной функции getRandomData
.
Тут стоит предупредить, что в пределах одной мажорной версии 1.22 эта функция была переименована в
readRandom
.
Также она будет называться и в версии 1.24, описанной ниже.
Итоговый код для последней версии ветки 1.22:
//go:nosplit
func readRandom(r []byte) int {
n := 0
if stdcall2(_RtlGenRandom, uintptr(unsafe.Pointer(&r[0])), uintptr(len(r)))&0xff != 0 {
n = len(r)
}
return n
}
Если все манипуляции были выполнены правильно — сборка Golang версии 1.22 пройдет успешно:
Но правильно работать не будет — тесты запустить не получится совсем.
Тем не менее, даже этой кривой версии хватит для сборки следующей версии 1.24
Если вы не собираетесь использовать версию 1.22 для работы, а лишь в качестве промежуточного компилятора — второй патч можно пропустить.
Патч с непростой судьбой — добавленный в 2021 году, он вскоре был удален из-за полного отказа от поддержки старых версий Windows:
On Windows 7 (and below), console handles are not real kernel handles
but are rather userspace objects, with information passed via special
bits in the handle itself. That means they can't be passed in
PROC_THREAD_ATTRIBUTE_HANDLE_LIST, even though they can be inherited.
Суть его в том, что в старых версиях Windows есть определенная магия с передачей дополнительных атрибутов у «console handle», что мешает правильной работе Go.
Без патча попытка собрать Golang 1.24 немедленно порождает ошибку:
Она же появляется при попытке запуска тестов в версии 1.22:
Первый файл для правок называется exec_windows.go и правки в нем достаточно сложные:
Тут необходимо восстановить блок, отвечающий за определение версии Windows:
var maj, min, build uint32
rtlGetNtVersionNumbers(&maj, &min, &build)
isWin7 := maj < 6 || (maj == 6 && min <= 1)
// NT kernel handles are divisible by 4, with the bottom 3 bits left as
// a tag. The fully set tag correlates with the types of handles we're
// concerned about here. Except, the kernel will interpret some
// special handle values, like -1, -2, and so forth, so kernelbase.dll
// checks to see that those bottom three bits are checked, but that top
// bit is not checked.
isLegacyWin7ConsoleHandle := func(handle Handle) bool { return isWin7 && handle&0x10000003 == 3 }
и вставить логику его использования ниже, вместо:
if attr.Files[i] > 0 {
err := DuplicateHandle(p, Handle(attr.Files[i]), parentProcess, &fd[i], 0, true, DUPLICATE_SAME_ACCESS)
должен быть блок:
if attr.Files[i] > 0 {
destinationProcessHandle := parentProcess
// On Windows 7, console handles aren't real handles, and can only be duplicated
// into the current process, not a parent one, which amounts to the same thing.
if parentProcess != p && isLegacyWin7ConsoleHandle(Handle(attr.Files[i])) {
destinationProcessHandle = p
}
err := DuplicateHandle(p, Handle(attr.Files[i]), destinationProcessHandle, &fd[i], 0, true, DUPLICATE_SAME_ACCESS)
Следующая правка в этом же файле следует аналогичной логике:
Добавляется метод определения версии Windows:
// On Windows 7, console handles aren't real handles, so don't pass them
// through to PROC_THREAD_ATTRIBUTE_HANDLE_LIST.
for i := range fd {
if isLegacyWin7ConsoleHandle(fd[i]) {
fd[i] = 0
}
}
// The presence of a NULL handle in the list is enough to cause
// PROC_THREAD_ATTRIBUTE_HANDLE_LIST
// to treat the entire list as empty, so remove NULL handles.
j := 0
for i := range fd {
if fd[i] != 0 {
fd[j] = fd[i]
j++
}
}
fd = fd[:j]
который затем применяется, блок:
// Do not accidentally inherit more than these handles.
err = updateProcThreadAttribute(si.ProcThreadAttributeList, 0, _PROC_THREAD_ATTRIBUTE_HANDLE_LIST, unsafe.Pointer(&fd[0]), uintptr(len(fd))*unsafe.Sizeof(fd[0]), nil, nil)
if err != nil {
return 0, 0, err
должен быть заменен на:
// Do not accidentally inherit more than these handles.
if len(fd) > 0 {
err = updateProcThreadAttribute(si.ProcThreadAttributeList, 0, _PROC_THREAD_ATTRIBUTE_HANDLE_LIST, unsafe.Pointer(&fd[0]), uintptr(len(fd))*unsafe.Sizeof(fd[0]), nil, nil)
if err != nil {
return 0, 0, err
}
Чуть ниже в этом же файле находится следующий блок для исправления, напоминаю что интересующая нас правка в этот раз справа, а слева — текущий вариант:
Суть правки — применение фильтрации при передаче атрибутов процесса, для учета специфики старых версий Windows.
Следующая остановка — файл zsyscall_windows.go, обратите внимание что это другой файл с совпадающим названием (из первого патча), но в другом каталоге:
В этот раз необходимо добавить новую библиотеку:
modntdll = NewLazyDLL(sysdll.Add("ntdll.dll"))
и новую вызываемую функцию:
procRtlGetNtVersionNumbers = modntdll.NewProc("RtlGetNtVersionNumbers")
Чуть ниже добавляется и функция-обертка, отвечающая за вызов syscall:
После внесения этих правок, в версии 1.22 начинают распускаться цветы запускаться тесты:
Все описанное выше хоть и выглядит сложно, но вполне подпадает под формат обычной рутинной работы профессионала. Но с версии 1.24 изменилась внутренняя структура каталогов проекта и часть исходных файлов, в которых предстояло делать правки была перемещена.
А еще часть логики описанных патчей уже была внесена, но с изменениями.
Так что дальше будет то самое безудержное веселье, которое для одних является поводом к увольнению или требованиям незапланированного отпуска а для других — смыслом работы в ИТ.
Файл rand_windows.go с которого мы начинали правки, был перемещен в src/crypto/internal/sysrand/rand_windows.go а его содержимое теперь выглядит вот так:
// Copyright 2010 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package sysrand
import "internal/syscall/windows"
func read(b []byte) error {
return windows.ProcessPrng(b)
}
Что сильно отличается от оригинальной версии.
Не буду утомлять читателей детальным описанием процесса поиска решения и сразу покажу конечный результат:
// Copyright 2010 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package sysrand
import "internal/syscall/windows"
// batched returns a function that calls f to populate a []byte by chunking it
// into subslices of, at most, readMax bytes.
func batched(f func([]byte) error, readMax int) func([]byte) error {
return func(out []byte) error {
for len(out) > 0 {
read := len(out)
if read > readMax {
read = readMax
}
if err := f(out[:read]); err != nil {
return err
}
out = out[read:]
}
return nil
}
}
func read(b []byte) error {
return batched(windows.RtlGenRandom, 1<<31-1)(b)
}
Как видите прямо сюда была добавлена функция batched
, которая была удалена в версии 1.24.
Следующие правки в файле zsyscall_windows.go полностью совпадают с описанным выше для версии 1.22, так что нет смысла повторяться.
Правки в файле os_windows.go также совпадают с правками для версии 1.22, за исключением измененной функции getRandomData
, которая превратилась в readRandom
и стала возвращать int
.
Обновленный код для версии 1.22 приведен выше и работает для 1.24.
Для версии 1.24 является обязательным, поскольку без него сборка не проходит совсем. Ключевая проблема заключается в том, что логика этого патча частично уже присутствует в коде, так что придется включать голову.
Основная проблема — правки в файле exec_windows.go, первую их часть в виде блока:
var maj, min, build uint32
rtlGetNtVersionNumbers(&maj, &min, &build)
isWin7 := maj < 6 || (maj == 6 && min <= 1)
// NT kernel handles are divisible by 4, with the bottom 3 bits left as
// a tag. The fully set tag correlates with the types of handles we're
// concerned about here. Except, the kernel will interpret some
// special handle values, like -1, -2, and so forth, so kernelbase.dll
// checks to see that those bottom three bits are checked, but that top
// bit is not checked.
isLegacyWin7ConsoleHandle := func(handle Handle) bool { return isWin7 && handle&0x10000003 == 3 }
вставляем как есть, следущий блок необходимо сводить вручную, сверяя текущую логику с тем что в патче.
Готовый результат выглядит так:
fd := make([]Handle, len(attr.Files))
for i := range attr.Files {
if attr.Files[i] > 0 {
destinationProcessHandle := parentProcess
// On Windows 7, console handles aren't real handles, and can only be duplicated
// into the current process, not a parent one, which amounts to the same thing.
if parentProcess != p && isLegacyWin7ConsoleHandle(Handle(attr.Files[i])) {
destinationProcessHandle = p
}
err := DuplicateHandle(p, Handle(attr.Files[i]), destinationProcessHandle, &fd[i], 0, true, DUPLICATE_SAME_ACCESS)
if err != nil {
return 0, 0, err
}
defer DuplicateHandle(parentProcess, fd[i], 0, nil, 0, false, DUPLICATE_CLOSE_SOURCE)
}
}
Следующий блок также необходимо сводить вручную, поскольку логика частично реализована.
Готовый блок выглядит следующим образом:
// On Windows 7, console handles aren't real handles, so don't pass them
// through to PROC_THREAD_ATTRIBUTE_HANDLE_LIST.
for i := range fd {
if isLegacyWin7ConsoleHandle(fd[i]) {
fd[i] = 0
}
}
// The presence of a NULL handle in the list is enough to cause PROC_THREAD_ATTRIBUTE_HANDLE_LIST
// to treat the entire list as empty, so remove NULL handles.
j := 0
for i := range fd {
if fd[i] != 0 {
fd[j] = fd[i]
j++
}
}
fd = fd[:j]
willInheritHandles := len(fd) > 0 && !sys.NoInheritHandles
// Do not accidentally inherit more than these handles.
if willInheritHandles {
err = updateProcThreadAttribute(si.ProcThreadAttributeList, 0, _PROC_THREAD_ATTRIBUTE_HANDLE_LIST, unsafe.Pointer(&fd[0]), uintptr(len(fd))*unsafe.Sizeof(fd[0]), nil, nil)
if err != nil {
return 0, 0, err
}
}
Наконец последний блок, нуждающийся в ручном сведении из-за того что вызов метода createEnvBlock
был вынесен выше.
Результат после сведения:
pi := new(ProcessInformation)
flags := sys.CreationFlags | CREATE_UNICODE_ENVIRONMENT | _EXTENDED_STARTUPINFO_PRESENT
if sys.Token != 0 {
err = CreateProcessAsUser(sys.Token, argv0p, argvp, sys.ProcessAttributes, sys.ThreadAttributes, willInheritHandles, flags, &envBlock[0], dirp, &si.StartupInfo, pi)
} else {
err = CreateProcess(argv0p, argvp, sys.ProcessAttributes, sys.ThreadAttributes, willInheritHandles, flags, &envBlock[0], dirp, &si.StartupInfo, pi)
}
if err != nil {
return 0, 0, err
}
Правки для zsyscall_windows.go полностью совпадают с версией 1.22, которые описаны выше — просто последовательно внесите такие же изменения.
Чтобы проверить работоспособность пропатченного таким образом «бобра‑франкенштейна», был взят один из самых жирных boilerplate (шаблон проекта) для Golang, собран и запущен.
Его в работе вы можете наблюдать на заглавном скриншоте статьи.
Забираем исходники:
git clone https://github.com/codoworks/go-boilerplate.git
Запускаем сборку:
cd go-boilerplate
go get
go run . db migrate
go run . db seed
На этой стадии вылезет ошибка:
требующая сборки Golang с включенной опцией:
CGO_ENABLED=1
Устанавливаем эту переменную окружения и заново запускаем скрипт make.
Запуск приложения:
go run . start
Так выглядит запущенный сервер в работе:
Готовую сборку Golang 1.24 для Windows 7 можно забрать в нашем Телеграм канале, тут.
Описанное в статье — разумеется крутая, но исследовательская работа, о чем стоит помнить прежде чем пытаться использовать в продакшне нашу сборку.
У нас нет проекта на Golang, требующего для работы подобного бекпорта, поэтому не можем сказать ничего вразумительного о качестве созданного бекпорта и возможных проблемах эксплуатации.
Тесты падают, не все — примерно 10%, но этого достаточно чтобы оказывать существенное влияние на работоспособность сборки в боевых условиях.
Имейте это ввиду прежде чем пытаться эксплуатировать.
Более веселый оригинал статьи как обычно в нашем блоге.
Мы небольшая команда ветеранов ИТ‑индустрии, создаем и дорабатываем самое разнообразное программное обеспечение, наш софт автоматизирует бизнес‑процессы на трех континентах, в самых разных отраслях и условиях.
Оживляем давно умершее, чиним никогда не работавшее и создаем невозможное — затем рассказываем об этом в своих статьях.