Implementando structs por valor no Dart FFI
Um mergulho profundo no design de API e nas convenções de chamada nativas.
Um mergulho profundo no design de API e nas convenções de chamada nativas
#No lançamento do Dart 2.12, estendemos nosso recurso de interop com C, o Dart FFI, com a capacidade de passar structs por valor. Este artigo fala sobre o que foi necessário para adicionar esse recurso ao SDK do Dart. Se você se interessa por detalhes de implementação de linguagens em baixo nível ou pelas convenções de plataforma para passar structs por valor, continue lendo.
Este artigo fala tanto sobre desenvolver a API quanto sobre descobrir a ABI (Application Binary Interface) do recurso de passar structs por valor. Durante os dois anos em que trabalhamos nesse recurso (e em outros recursos do Dart FFI), descobrimos muitas restrições que exigiram mudanças na API. A jornada pela ABI foi igualmente interessante, ilustrando que existem várias abordagens para definir os detalhes de um problema difícil.
Passagem por valor e por referência em C/C++
#Aqui vai uma rápida recapitulação, caso você não escreva código em C todos os dias. Suponha que temos a seguinte struct e funções em C:
struct Coord {
double x;
double y;
Coord* next;
};
Coord TranslateByValue(Coord coord) {
coord.x = coord.x + 10.0;
coord.y = coord.y + 10.0;
return coord;
}
void TranslateByPointer(Coord* coord) {
coord->x = coord->x + 10.0;
coord->y = coord->y + 10.0;
}
Então, podemos usar essas funções em um código C simples. Digamos que temos uma variável local c1:
Coord c1 = {10.0, 10.0, nullptr};
Se passarmos c1 para TranslateByValue, o argumento é passado por valor, o que faz com que a função trabalhe efetivamente em uma cópia da struct:
Coord c2 = TranslateByValue(c1);
Isso significa que c1 permanece inalterado.
No entanto, se passarmos c1 por referência, com um ponteiro para a memória que contém c1, então
c1 é modificado no próprio lugar:
TranslateByPointer(&c1);
c1.x agora contém 20.0.
A jornada do design da API
#O protótipo original do Dart FFI já tinha suporte a passar ponteiros para structs. No entanto, redesenhamos a API várias vezes para acomodar diferentes casos de uso e restrições.
Design inicial
#
Nosso design inicial permitia alocar structs na memória, passar esses ponteiros para o C e modificar os campos das structs. Com essa abordagem, a classe
Struct estendia a classe Pointer:
@struct
class Coordinate extends Pointer<Void> {
@Double()
double x;
@Double()
double y;
@Pointer()
Coordinate next;
/// generated by @ffi.struct annotation
external static int sizeOf();
static Coordinate allocate({int count: 1}) =>
allocate<Uint8>(count: count * sizeOf()).cast();
}
final c = Coordinate.allocate()
..x = 10.0
..y = 10.0;
Usuários do Dart FFI escreviam o trecho anterior, e os internos do Dart FFI geravam uma implementação de
sizeOf, além de getters e setters para x, y e next.
No entanto, dois anos atrás percebemos que esse design tinha um problema. Ao fazer
Coordinate estender Pointer, não conseguíamos distinguir entre Coordinate
e Coordinate*.
Distinguindo entre Coordinate e Coordinate*
#
Introduzimos o Struct no Dart FFI e fizemos as structs estenderem essa classe:
abstract class Struct<S extends NativeType> extends NativeType {
final Pointer<S> addressOf;
}
Agora, um Pointer<Coordinate> em Dart representa um Coordinate*
em C, e um Coordinate em Dart representa um Coordinate em C.
Isso significava que o campo next tinha o tipo Pointer<Coordinate>, o que tornava a anotação
@Pointer redundante. Então, nos livramos das anotações Pointer.
class Coordinate extends Struct<Coordinate> {
@Double()
double x;
@Double()
double y;
Pointer<Coordinate> next;
}
Como passamos a representar ponteiros para structs como objetos Pointer, começamos a usar a factory
allocate em Pointer:
final c = Pointer<Coordinate>.allocate();
Para acessar os campos de um Pointer<Coordinate>, precisamos de um objeto do tipo
Coordinate, porque esse objeto tem os campos x, y e next. Para isso, já tínhamos o método
load em Pointer.
c.load<Coordinate>().x = 10.0;
Claro, ter que escrever <Coordinate> ao chamar load é verboso. (Ter que escrever um argumento de tipo já era assim para carregar um
int do Dart a partir de um Pointer<Uint8>.) A razão de precisarmos desse argumento de tipo em
load é informar ao sistema de tipos do Dart o tipo de retorno desse método.
Métodos de extensão para salvar o dia
#
O Dart 2.7 introduziu os métodos de extensão. Com eles, pudemos
fazer pattern matching
no argumento de tipo T em Pointer<T>:
extension StructPointer<T extends Struct> on Pointer<T> {
external T get ref;
}
O pattern matching no argumento de tipo nos permitiu eliminar a verbosidade nos pontos de chamada:
c.ref.y = 10.0; // ref is pattern matched to be of type Coordinate.
Também pudemos usar o pattern matching em métodos de extensão para tornar redundante o argumento de tipo de
Struct<S>, mudando a definição das structs de usuário para:
class Coordinate extends Struct {
@Double()
double x;
@Double()
double y;
Pointer<Coordinate> next;
}
Antes, o argumento de tipo <S> restringia o campo addressOf do
Struct, do tipo Pointer<S>. Em vez disso, mudamos o campo para um getter de extensão:
extension StructPointer<T extends Struct> on Pointer<T> {
external Pointer<T> get addressOf;
}
Pare de vazar o armazenamento de suporte
#
Ao retornar uma struct por valor do C para o Dart, não queremos fazer malloc de memória do C para guardar a struct, porque isso seria lento
e sobrecarregaria o usuário com a tarefa de liberá-la. Então, em vez disso, a struct é copiada para um
TypedData, e o Coordinate pode ter como armazenamento de suporte tanto um Pointer
quanto um TypedData.
No entanto, addressOf, introduzido no primeiro redesenho, tinha o tipo Pointer. Esse tipo indicava que ele era sempre suportado por memória do C, mas isso não era mais verdade.
Então, descontinuamos o addressOf.
Para otimizações
#O último passo é exigir que as invocações de vários métodos do Dart FFI, incluindo os relacionados a structs, tenham argumentos de tipo constantes em tempo de compilação:
extension StructPointer<T extends Struct> on Pointer<T> {
/// Must invoke with a constant [T].
external T get ref;
}
A invocação de métodos nos permite otimizar melhor o código e fica mais alinhada com a semântica do C.
Observe que essa última mudança gera avisos de descontinuação no Dart 2.12, e a mudança passa a ser imposta no Dart 2.13.
A jornada de descoberta da ABI
#Agora que a API está pronta, a próxima pergunta é: onde o C espera que essas structs estejam quando são passadas ou retornadas por valor? Isso é conhecido como Application Binary Interface (ABI).
Documentação
#O caminho natural é procurar documentação. A ARM oferece o Procedure Call Standard for the Arm Architecture — ABI 2019Q1 e o Procedure Call Standard for the ARM 64-bit Architecture (AArch64). No entanto, a documentação oficial de x86 e x64 saiu do ar, o que levou as pessoas a procurarem essa informação em espelhos não oficiais ou recorrerem à engenharia reversa.
Uma rápida olhada na documentação mostra vários locais possíveis para passar structs por valor:
Em múltiplos registradores de CPU e FPU.
Na pilha.
Um ponteiro para uma cópia. (A cópia fica no frame de pilha de quem chama.)
Parcialmente em registradores de CPU e parcialmente na pilha.
Quando são passadas pela pilha, surgem outras perguntas sobre qual é o alinhamento exigido e se todos os registradores de CPU e FPU não usados são bloqueados ou preenchidos depois.
Ao retornar uma struct por valor, ela pode ser devolvida em dois locais:
Em múltiplos registradores de CPU e FPU.
-
Escrita em um local de memória pela função chamada, caso em que o chamador passa um ponteiro para esse local. (Essa memória reservada também fica no frame de pilha de quem chama.)
Quando um ponteiro para o local do resultado é passado, uma questão adicional é se isso conflita com um registrador de argumento de CPU comum.
Refatorando a compilação do Dart FFI
#
Essa investigação inicial foi suficiente para perceber que precisávamos reformular uma parte do pipeline de compilação do Dart FFI. Antes, reutilizávamos o tipo
Location, que tinha sido originalmente pensado para compilar código Dart para assembly.
No entanto, na ABI do Dart, nunca usamos locais de pilha não alinhados a palavras nem mais de dois registradores ao mesmo tempo. Um experimento que tentou estender o tipo
Location para suportar esses locais extras resultou em um diff enorme e complicado, porque
Location é muito usado na máquina virtual do Dart.
Então, em vez disso, substituímos o pipeline de compilação do Dart FFI.
Explorando as ABIs nativas
#Vamos explorar um pouco as ABIs.
Suponha que temos a seguinte struct e a assinatura de função em C:
struct Struct3Bytes {
uint8_t a0;
uint8_t a1;
uint8_t a2;
};
Struct3Bytes MyFunction(Struct3Bytes, Struct3Bytes, Struct3Bytes,
Struct3Bytes, Struct3Bytes, Struct3Bytes,
Struct3Bytes, Struct3Bytes);
Como as várias ABIs passam essas structs em MyFunction?
No Linux em x64, há 6 registradores de argumento de CPU. A struct é pequena o suficiente para caber em um único registrador, então os 6 primeiros argumentos vão para os 6 registradores de argumento de CPU, e os 2 últimos vão para a pilha. Os argumentos da pilha são alinhados em 8 bytes. E o valor de retorno também cabe em um registrador de CPU (exemplo maior).
rdi int64 Compound(size: 3)
rsi int64 Compound(size: 3)
rdx int64 Compound(size: 3)
rcx int64 Compound(size: 3)
r8 int64 Compound(size: 3)
r9 int64 Compound(size: 3)
S+0 Compound(size: 3)
S+8 Compound(size: 3)
=>
rax int64 Compound(size: 3)
E no Windows?
É completamente diferente. O Windows tem apenas 4 registradores de argumento. No entanto, o primeiro registrador é usado para passar o ponteiro para o local de memória onde o valor de retorno será escrito. E todos os argumentos são passados por ponteiro para uma cópia, porque o tamanho da struct é de 3 bytes, que não é potência de 2.
Locations on Windows
Pointer(rdx int64) Compound(size: 3)
Pointer(r8 int64) Compound(size: 3)
Pointer(r9 int64) Compound(size: 3)
Pointer(S+0 int64) Compound(size: 3)
Pointer(S+8 int64) Compound(size: 3)
Pointer(S+16 int64) Compound(size: 3)
Pointer(S+24 int64) Compound(size: 3)
Pointer(S+32 int64) Compound(size: 3)
=>
Pointer(rcx int64, ret:rax int64) Compound(size: 3)
Vejamos outro exemplo: ARM32 no Linux e no Android. Suponha que temos a seguinte struct e a assinatura de função em C:
struct Struct16Bytes {
float a0;
float a1;
float a2;
float a3;
};
Struct16Bytes MyFunction2(Struct16Bytes, float, Struct16Bytes);
Esses tipos específicos de structs são chamados de compostos homogêneos, porque contêm apenas elementos idênticos. E floats homogêneos com até 4 membros são tratados de forma diferente das structs comuns. Nesse caso, o Linux usa registradores de ponto flutuante para os floats individuais da struct.
Multiple(s0 float, s1 float, s2 float, s3 float) Compound(size: 16)
s4 float
Multiple(s5 float, s6 float, s7 float, s8 float) Compound(size: 16)
=>
Multiple(s0 float, s1 float, s2 float, s3 float) Compound(size: 16)
No Android, usa-se SoftFP em vez de HardFP. Isso significa que os floats são passados em registradores inteiros, e não em registradores de ponto flutuante. Além disso, estamos passando um
Pointer para o resultado. Isso resulta em uma situação curiosa
em que o primeiro argumento é passado parcialmente em registradores inteiros e parcialmente na pilha.
M(r1 int32, r2 int32, r3 int32, S+0 int32) Compound(size: 16)
S+4 float
M(S+8 int32, S+12 int32, S+16 int32, S+20 int32) Compound(size: 16)
=>
P(r0 uint32) Compound(size: 16)
Errar qualquer um desses detalhes provavelmente levará a falhas de segmentação em tempo de execução. Por isso, é fundamental acertar todos os casos extremos da ABI em cada combinação de hardware e sistema operacional.
Explorando pelo godbolt.org
#Como a documentação é muito sucinta, descobrimos vários casos extremos com o godbolt.org, um explorador de compiladores. Ele mostra código C e o assembly compilado lado a lado:
A captura de tela anterior mostra que, no Windows x86, sizeof(Struct3Bytes) é 3 bytes, porque o valor 3 é movido para o registrador de retorno
eax.
Quando alteramos a struct um pouco, podemos verificar se o tamanho continua 3:
typedef struct {
int16_t a0;
int8_t a1;
} Struct3Bytes;
O tamanho não é 3: mov eax, 4. Como o int16 precisa estar alinhado em 2 bytes, a struct também precisa estar alinhada em 2 bytes. Isso quer dizer que, ao alocar um array dessas structs, há 1 byte de padding após cada struct para garantir que a próxima esteja alinhada em 2 bytes. Por isso, essa struct tem 4 bytes na ABI nativa.
Explorando por meio de testes gerados
#Infelizmente, o compiler explorer não oferece suporte a macOS e iOS. Então, para tornar a exploração manual mais eficiente (e para ter uma suíte de testes grande e robusta para esse recurso), escrevemos um gerador de testes.
A ideia principal é gerar testes de modo que, se eles falharem com um crash, seja possível usar o GDB para ver o que está errado.
Uma forma de facilitar a identificação do problema quando ocorre uma falha de segmentação é fazer com que todos os argumentos tenham valores previsíveis e fáceis de reconhecer. Por exemplo, o teste a seguir usa inteiros consecutivos, para que esses valores possam ser facilmente identificados nos registradores e na pilha:
void testPassStruct3BytesHomogeneousUint8x10() {
final a0Pointer = calloc<Struct3BytesHomogeneousUint8>();
final Struct3BytesHomogeneousUint8 a0 = a0Pointer.ref;
final a1Pointer = calloc<Struct3BytesHomogeneousUint8>();
// ...
a0.a0 = 1;
a0.a1 = 2;
a0.a2 = 3;
a1.a0 = 4;
// ...
final result = passStruct3BytesHomogeneousUint8x10(
a0, a1, a2, a3, a4, a5, a6, a7, a8, a9);
print("result = $result");
Expect.equals(465, result);
calloc.free(a0Pointer);
calloc.free(a1Pointer);
// ...
}
Outra forma de facilitar a busca por problemas é adicionar prints em todo lugar. Por exemplo, se não tivermos uma falha de segmentação durante a transição do Dart para o C, mas conseguirmos embaralhar todos os argumentos, imprimir os argumentos ajuda:
int64_t
PassStruct3BytesHomogeneousUint8x10(Struct3BytesHomogeneousUint8 a0,
Struct3BytesHomogeneousUint8 a1,
// ...
) {
std::cout << "PassStruct3BytesHomogeneousUint8x10"
<< "((" << static_cast<int>(a0.a0) << ", "
<< static_cast<int>(a0.a1) << ", " << static_cast<int>(a0.a2)
<< "), (" << static_cast<int>(a1.a0) << ", ") << // ...
int64_t result = 0;
result += a0.a0;
result += a0.a1;
result += a0.a2;
result += a1.a0;
// ...
std::cout << "result = " << result << "\n";
return result;
}
Adicionar um teste é tão simples quanto incluir um tipo de função no arquivo de configuração. A facilidade de adicionar testes rapidamente resultou em uma suíte de testes enorme.
Não demorou para que essa suíte detectasse outro caso curioso em uma ABI nativa, desta vez no iOS-ARM64. Argumentos que não são structs, passados na pilha no iOS em ARM64, não são alinhados ao tamanho da palavra, e sim ao próprio tamanho. As structs são alinhadas ao tamanho da palavra, exceto quando a struct é homogênea e contém apenas floats: nesse caso, ela é alinhada ao tamanho do float.
Resumo
#Isso conclui nossa jornada pelo design da API e pela descoberta da ABI. Com uma boa suíte de testes e revisões de código minuciosas, integramos o suporte para passar structs por valor no Dart FFI em dezembro de 2020, na branch master, e ele está disponível no Dart 2.12! Se você se interessa por usar o Dart FFI, pode começar pela documentação de interop com C no dart.dev. Se tiver dúvidas ou comentários sobre o design da API ou a descoberta da ABI, sinta-se à vontade para deixar um comentário abaixo. Adoraríamos ouvir você!
Agradecemos à equipe da linguagem Dart e ao restante da equipe da máquina virtual Dart pelas contribuições a este recurso do Dart FFI, e a Kathy Walrath e Michael Thomsen por darem forma a este post!
Mais do Dart
Trazendo construtores primários para o Dart
Um olhar por dentro das decisões de design, dos trade-offs e do açúcar sintático por trás da adição de construtores primários no Dart 3.13.
A história da interop com JS no Dart
O suporte ao Wasm acaba de chegar ao Flutter beta atual, graças a um marco empolgante de interop com JavaScript alcançado no Dart 3.3. Para celebrar…