Nossa equipe Immunant ama Rust e está trabalhando ativamente no C2Rust, uma estrutura de migração que cuida de toda a rotina de migração para o Rust. Nós nos esforçamos para introduzir automaticamente melhorias de segurança no código Rust convertido e ajudar o programador a fazê-lo quando a estrutura falhar. No entanto, antes de tudo, precisamos criar um tradutor confiável que permita que os usuários iniciem o Rust. Os testes em pequenos programas CLI estão lentamente se tornando obsoletos, por isso decidimos transferir o Quake 3. para o Rust. Depois de alguns dias, provavelmente fomos os primeiros a jogar o Quake3 no Rust!
Preparação: Terremoto 3 fontes
Tendo estudado o código fonte do Quake 3 original e vários garfos, decidimos pelo
ioquake3 . Este é um fork do Quake 3 criado pela comunidade, que ainda é suportado e construído em plataformas modernas.
Como ponto de partida, decidimos garantir a montagem do projeto em sua forma original:
$ make release
Ao criar o ioquake3, várias bibliotecas e arquivos executáveis são criados:
$ tree --prune -I missionpack -P "*.so|*x86_64" . └── build └── debug-linux-x86_64 ├── baseq3 │ ├── cgamex86_64.so
Entre essas bibliotecas, as bibliotecas de interface do usuário, cliente e servidor podem ser compiladas como um assembly
Quake VM ou como bibliotecas compartilhadas X86 nativas. Em nosso projeto, decidimos usar versões nativas. Traduzir VMs para Rust e usar versões QVM seria muito mais simples, mas queríamos testar completamente o C2Rust.
Em nosso projeto de transferência, focamos na interface do usuário, jogo, cliente, renderizador OpenGL1 e no executável principal. Também podemos traduzir o renderizador OpenGL2, mas decidimos pular isso porque ele usa uma quantidade significativa de
.glsl
, que o sistema de compilação incorpora como literais de string no código-fonte C. Após a compilação, adicionaremos suporte para scripts de compilação para incorporação Código GLSL em seqüências de caracteres Rust, mas ainda não existe uma boa maneira automatizada de transpor esses arquivos temporários gerados automaticamente. Em vez disso, acabamos de traduzir a biblioteca do renderizador OpenGL1 e forçamos o jogo a usá-lo em vez do renderizador padrão. Além disso, decidimos pular o servidor dedicado e os arquivos de missão empacotados, porque eles não serão difíceis de transferir e não são necessários para a nossa demonstração.
Transpose Quake 3
Para preservar a estrutura de diretórios usada no Quake 3 e não alterar o código-fonte, precisamos obter exatamente os mesmos arquivos binários que no assembly nativo, ou seja, quatro bibliotecas compartilhadas e um arquivo executável.
Como o C2Rust cria os arquivos de montagem do Cargo, cada binário requer seu próprio caixote Rust com o arquivo
Cargo.toml
correspondente.
Para que o C2Rust crie uma caixa por arquivo binário de saída, ele também precisará de uma lista de arquivos binários com o objeto ou os arquivos de origem correspondentes, bem como uma chamada de vinculador usada para criar cada arquivo binário (usado para determinar outros detalhes, por exemplo, dependências da biblioteca).
No entanto, descobrimos rapidamente uma limitação causada pela maneira como o C2Rust intercepta o processo de compilação nativo: o C2Rust recebe um arquivo de
banco de dados de compilação na entrada que contém uma lista de comandos de compilação que são executados durante a compilação. No entanto, esse banco de dados contém
apenas comandos de compilação sem chamadas do vinculador. A maioria das ferramentas que criam esse banco de dados tem essa limitação intencional, por exemplo,
cmake
com
CMAKE_EXPORT_COMPILE_COMMANDS
,
bear
e
compiledb
. Em nossa experiência, a única ferramenta que inclui comandos de
build-logger
é o criador de
build-logger
criado pelo
CodeChecker
, que não usamos porque aprendemos sobre isso somente depois de escrever nossos próprios wrappers (eles são descritos abaixo). Isso significava que, para compilar um programa C com vários arquivos binários, não foi possível usar o arquivo
compile_commands.json
criado por qualquer uma das ferramentas comuns.
Portanto, escrevemos nossos próprios scripts de
compilador e wrapper
vinculador que despejam todas as chamadas para o compilador e vinculador ao banco de dados e, em seguida, o convertem no
compile_commands.json
estendido. Em vez da montagem usual, use um comando como:
$ make release
adicionamos wrappers para interceptar o assembly com:
$ make release CC=/path/to/C2Rust/scripts/cc-wrappers/cc
Os wrappers criam um diretório de vários arquivos JSON, um por chamada. O segundo
script coleta todos eles em um novo arquivo
compile_commands.json
, que contém os comandos de compilação e compilação. Em seguida, estendemos o C2Rust para que ele leia os comandos de construção do banco de dados e crie uma caixa separada para cada binário vinculado. Além disso, o C2Rust agora também lê dependências da biblioteca para cada arquivo binário e as adiciona automaticamente ao arquivo
build.rs
da caixa correspondente.
Para melhorar a conveniência, todos os binários podem ser coletados por vez, colocando-os dentro da área de
trabalho . O C2Rust cria o arquivo
Cargo.toml
trabalho de nível
Cargo.toml
, para que possamos construir o projeto com o único
cargo build
no diretório
quake3-rs
:
$ tree -L 1 . ├── Cargo.lock ├── Cargo.toml ├── cgamex86_64 ├── ioquake3 ├── qagamex86_64 ├── renderer_opengl1_x86_64 ├── rust-toolchain └── uix86_64 $ cargo build --release
Eliminar rugosidade
Quando tentamos compilar o código traduzido pela primeira vez, encontramos alguns problemas com as fontes do Quake 3: havia casos de fronteira com os quais o C2Rust não conseguia lidar (nem corretamente nem de alguma forma).
Ponteiros de matriz
Vários locais no código-fonte original contêm expressões que apontam para o próximo elemento após o último elemento da matriz. Aqui está um exemplo de código C simplificado:
int array[1024]; int *p;
O padrão C (veja, por exemplo,
C11, Seção 6.5.6 ) permite que ponteiros para um elemento ultrapassem o final de uma matriz. No entanto, Rust proíbe isso, mesmo que apenas tomemos o endereço do elemento. Encontramos exemplos desse padrão na função
AAS_TraceClientBBox
.
O compilador Rust também sinalizou um exemplo semelhante, mas realmente
G_TryPushingEntity
, em
G_TryPushingEntity
, onde a instrução condicional é da forma
>
, não
>=
. Um ponteiro que sai dos limites é desreferenciado após a construção condicional, que é um erro de segurança da memória.
Para evitar esse problema no futuro, corrigimos o transpiler C2Rust para que ele use a aritmética do ponteiro para calcular o endereço de um elemento da matriz, em vez de usar a operação de indexação da matriz. Graças a essa correção, o código que usa o padrão semelhante “endereço do elemento no final da matriz” agora está corretamente traduzido e executado sem modificações.
Elementos de matriz de comprimento variável
Lançamos o jogo para testar tudo e imediatamente entramos em pânico com Rust:
thread 'main' panicked at 'index out of bounds: the len is 4 but the index is 4', quake3-client/src/cm_polylib.rs:973:17
cm_polylib.c
, notamos que ele desreferencia o campo
p
na seguinte estrutura:
typedef struct { int numpoints; vec3_t p[4];
O campo
p
na estrutura é uma versão do membro da matriz flexível que não é suportada pelo padrão C99, mas ainda é aceito pelo
gcc
. C2Rust reconhece elementos de matrizes de comprimento variável com a sintaxe C99 (
vec3_t p[]
) e implementa uma
heurística simples para também identificar versões desse padrão até C99 (matrizes de tamanhos 0 e 1 no final das estruturas; também encontramos vários exemplos no código-fonte ioquake3).
Alterar a estrutura acima para a sintaxe C99 eliminou o pânico:
typedef struct { int numpoints; vec3_t p[];
Uma tentativa de corrigir automaticamente esse padrão no caso geral (com tamanhos de matriz diferentes de 0 e 1) será extremamente difícil, porque teremos que distinguir entre matrizes comuns e elementos de matrizes de tamanho variável de tamanhos arbitrários. Portanto, em vez disso, recomendamos que você corrija o código C original manualmente, como fizemos com ioquake3.
Operandos vinculados no código assembler embutido
Outra fonte de falha foi esse código do assembler C-assembler no cabeçalho do sistema
/usr/include/bits/select.h
:
# define __FD_ZERO(fdsp) \ do { \ int __d0, __d1; \ __asm__ __volatile__ ("cld; rep; " __FD_ZERO_STOS \ : "=c" (__d0), "=D" (__d1) \ : "a" (0), "0" (sizeof (fd_set) \ / sizeof (__fd_mask)), \ "1" (&__FDS_BITS (fdsp)[0]) \ : "memory"); \ } while (0)
definindo a versão interna da macro
__FD_ZERO
. Essa definição levanta um caso raro de fronteira de
E / S de operandos vinculados gcc
: com tamanhos diferentes. O operador de saída
"=D" (__d1)
vincula o registro
edi
à variável
__d1
como um valor de 32 bits e
"1" (&__FDS_BITS (fdsp)[0])
vincula o mesmo registro ao endereço
fdsp->fds_bits
como um ponteiro de 64 bits.
gcc
e
clang
resolvem essa incompatibilidade. usando o registro de 64 bits
rdi
e truncando seu valor antes de atribuir o valor
__d1
, e Rust usa a semântica do LLVM por padrão, no qual esse caso permanece indefinido. Nas compilações de depuração (não nas versões, que se comportaram bem), vimos que os dois operandos podem ser atribuídos ao registrador
edi
, pelo qual o ponteiro é truncado para 32 bits antes do código do assembler interno, o que causa falhas.
Como
rustc
passa o código do assembler Rust embutido para o LLVM com muito poucas alterações, decidimos corrigir esse caso específico no C2Rust. Implementamos um novo engradado
c2rust-asm-casts
que
c2rust-asm-casts
esse problema graças ao sistema do tipo Rust, usando funções de
característica e auxiliar que expandem e truncam automaticamente operandos vinculados a um tamanho interno grande o suficiente para acomodar os dois operandos. O código acima traduz corretamente para o seguinte:
let mut __d0: c_int = 0; let mut __d1: c_int = 0;
É importante notar que esse código não requer nenhum tipo de valores de entrada e saída na montagem do código do assembler; ao resolver conflitos de tipo,
fresh6
fresh8
Rust (principalmente tipos
fresh6
e
fresh8
).
Variáveis globais alinhadas
A última fonte da falha foi a seguinte variável global que armazena a constante SSE:
static unsigned char ssemask[16] __attribute__((aligned(16))) = { "\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x00\x00\x00\x00" };
Atualmente, o Rust suporta o atributo de alinhamento para tipos estruturais, mas não para variáveis globais, ou seja, elementos
static
. Nós pensamos em maneiras de resolver esse problema no caso geral, no Rust ou no C2Rust, mas por enquanto no ioquake3 decidimos corrigi-lo manualmente com um pequeno arquivo de
patch . Esse arquivo de correção substitui o equivalente ao
ssemask
do Rust
ssemask
seguinte:
#[repr(C, align(16))] struct SseMask([u8; 16]); static mut ssemask: SseMask = SseMask([ 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, ]);
Executando o quake3-rs
Quando a
cargo build --release
, os binários são criados, mas são criados sob
target/release
com uma estrutura de diretório que o binário
ioquake3
não reconhece. Escrevemos um
script que cria links simbólicos no diretório atual para recriar a estrutura de diretórios correta (incluindo links para arquivos
.pk3
contendo recursos do jogo):
$ /path/to/make_quake3_rs_links.sh /path/to/quake3-rs/target/release /path/to/paks
O caminho
/path/to/paks
deve apontar para o diretório que contém os arquivos
.pk3
.
Agora vamos rodar o jogo! Precisamos passar
+set vm_game 0
, etc., portanto, carregamos esses módulos como bibliotecas compartilhadas Rust, e não como um assembly QVM, além de
cl_renderer
para usar o renderizador OpenGL1.
$ ./ioquake3 +set sv_pure 0 +set vm_game 0 +set vm_cgame 0 +set vm_ui 0 +set cl_renderer "opengl1"
E ...
Lançamos o Quake3 no Rust!
Aqui está um vídeo de como transpomos o Quake 3, baixamos o jogo e jogamos um pouco:
Você pode estudar as
fontes transpiladas no ramo
transpiled
do nosso repositório. Também há uma ramificação
refactored
contendo as mesmas
fontes com vários
comandos de refatoração pré-aplicados.
Como transpor
Se você quiser transpor o Quake 3 e executá-lo, lembre-se de que precisará de seus próprios recursos de jogo ou recursos de demonstração do Quake 3 da Internet. Você também precisará instalar o C2Rust (no momento da redação, a versão noturna necessária é
nightly-2019-12-05
, mas recomendamos que você procure no
repositório do C2Rust ou em
crates.io para encontrar a versão mais recente):
$ cargo +nightly-2019-12-05 install c2rust
e cópias de nossos repositórios C2Rust e ioquake3:
$ git clone <a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="dcbbb5a89cbbb5a8b4a9bef2bfb3b1">[email protected]</a>:immunant/c2rust.git $ git clone <a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="dcbbb5a89cbbb5a8b4a9bef2bfb3b1">[email protected]</a>:immunant/ioq3.git
Como alternativa à instalação do
c2rust
usando o comando acima, você pode criar o C2Rust manualmente usando
cargo build --release
. De qualquer forma, o repositório C2Rust ainda será necessário, pois contém os scripts de wrapper do compilador necessários para transpor o ioquake3.
ssemask
um
script que transporta automaticamente o código C e aplica o patch
ssemask
. Para usá-lo, execute o seguinte comando no nível superior do repositório
ioq3
:
$ ./transpile.sh </path/to/C2Rust repository> </path/to/c2rust binary>
Este comando deve criar um subdiretório
quake3-rs
contendo o código Rust, para o qual você pode executar o
cargo build --release
e as etapas restantes descritas acima.