Por que o Windows64 usa uma convenção de chamada diferente de todos os outros sistemas operacionais em x86-64?

110

A AMD tem uma especificação ABI que descreve a convenção de chamada para usar em x86-64. Todos os sistemas operacionais o seguem, exceto o Windows, que tem sua própria convenção de chamada x86-64. Por quê?

Alguém conhece as razões técnicas, históricas ou políticas para essa diferença, ou é puramente uma questão de síndrome NIH?

Eu entendo que diferentes sistemas operacionais podem ter necessidades diferentes para coisas de nível superior, mas isso não explica por que, por exemplo, a ordem de passagem do parâmetro de registro no Windows é rcx - rdx - r8 - r9 - rest on stackenquanto todos os outros usam rdi - rsi - rdx - rcx - r8 - r9 - rest on stack.

PS Estou ciente de como essas convenções de chamada diferem em geral e sei onde encontrar detalhes se precisar. O que eu quero saber é por quê .

Editar: para saber como, consulte, por exemplo, a entrada da wikipedia e os links daí.

JanKanis
fonte
2
Bem, apenas para o primeiro registro: rcx: ecx era o parâmetro "this" para a convenção msvc __thiscall x86. Então, provavelmente apenas para facilitar a portabilidade de seu compilador para x64, eles começaram com rcx como o primeiro. Que tudo o mais também seria diferente foi apenas uma consequência dessa decisão inicial.
Chris Becke
@Chris: Eu adicionei uma referência ao documento do suplemento AMD64 ABI (e algumas explicações sobre o que ele realmente é) abaixo.
FrankH.
1
Não encontrei uma justificativa de MS, mas encontrei alguma discussão aqui
phuclv

Respostas:

81

Escolhendo quatro registradores de argumento em x64 - comum a UN * X / Win64

Uma das coisas a se ter em mente sobre o x86 é que o nome do registro para a codificação de "número de registro" não é óbvio; em termos de codificação de instrução (o byte MOD R / M , consulte http://www.c-jump.com/CIS77/CPU/x86/X77_0060_mod_reg_r_m_byte.htm ), os números de registro 0 ... 7 são - nessa ordem - ?AX, ?CX, ?DX, ?BX, ?SP, ?BP, ?SI,?DI .

Portanto, escolher A / C / D (regs 0..2) para o valor de retorno e os dois primeiros argumentos (que é a __fastcallconvenção "clássica" de 32 bits ) é uma escolha lógica. No que diz respeito a 64 bits, os regs "superiores" são solicitados e tanto a Microsoft quanto o UN * X / Linux optaram por R8/R9 como os primeiros.

Mantendo isso em mente, a escolha da Microsoft RAX(valor de retorno) e RCX, RDX, R8, R9(arg [0..3]) são uma selecção compreensível se você escolher quatro registros achados para argumentos.

Não sei por que o AMD64 UN * X ABI escolheu RDXantes RCX.

Escolhendo seis registros de argumento em x64 - específico de UN * X

UN * X, em arquiteturas RISC, tradicionalmente tem feito passagem de argumentos em registros - especificamente, para os primeiros seis argumentos (isso é assim em PPC, SPARC, MIPS pelo menos). Essa pode ser uma das principais razões pelas quais os designers da ABI AMD64 (UN * X) optaram por usar seis registradores também nessa arquitetura.

Então se você quer seis registros para passar argumentos em, e é lógico escolher RCX, RDX, R8eR9 para quatro deles, que outros dois você deve escolher?

Os regs "mais altos" requerem um byte de prefixo de instrução adicional para selecioná-los e, portanto, têm uma pegada de tamanho de instrução maior, então você não gostaria de escolher qualquer um deles se tiver opções. Dos registros clássicos, devido ao significado implícito de RBPe RSPestes não estão disponíveis, e RBXtradicionalmente tem um uso especial em UN * X (tabela de deslocamento global) com o qual aparentemente os designers do AMD64 ABI não queriam se tornar incompatíveis desnecessariamente.
Portanto, a única escolha era RSI/ RDI.

Portanto, se você tiver que tomar RSI/ RDIcomo registradores de argumento, quais argumentos eles devem ser?

Fazê-los arg[0]e arg[1]tem algumas vantagens. Veja o comentário de cHao.
?SIe ?DIsão operandos de origem / destino de instrução de string, e como cHao mencionado, seu uso como registradores de argumento significa que, com as convenções de chamada AMD64 UN * X, a strcpy()função mais simples possível , por exemplo, consiste apenas nas duas instruções de CPU repz movsb; retporque a origem / destino endereços foram colocados nos registros corretos pelo chamador. Existe, particularmente no código de "cola" gerado pelo compilador e de baixo nível (pense, por exemplo, alguns alocadores de heap C ++ preenchendo objetos em construção ou as páginas de heap de preenchimento zero do kernel emsbrk() , ou cópia -write pagefaults) uma enorme quantidade de cópia / preenchimento de bloco, portanto, será útil para o código tão freqüentemente usado para salvar as duas ou três instruções da CPU que, de outra forma, carregariam tais argumentos de endereço de origem / destino nos registros "corretos".

Então, de certa forma, UN * X e Win64 são apenas diferentes em que UN * X "prepends" dois argumentos adicionais, em propositadamente escolhidas RSI/ RDIregistadoras, para a escolha natural de quatro argumentos em RCX, RDX, R8e R9.

Além disso ...

Existem mais diferenças entre os ABIs UN * X e Windows x64 do que apenas o mapeamento de argumentos para registros específicos. Para obter uma visão geral do Win64, verifique:

http://msdn.microsoft.com/en-us/library/7kcdt6fy.aspx

Win64 e AMD64 UN * X também diferem notavelmente na forma como o stackspace é usado; no Win64, por exemplo, o chamador deve alocar o espaço de pilha para os argumentos da função, mesmo que os argumentos 0 ... 3 sejam passados ​​nos registradores. No UN * X, por outro lado, uma função folha (ou seja, uma que não chama outras funções) nem mesmo é necessária para alocar espaço de pilha se não precisar de mais de 128 bytes (sim, você possui e pode usar uma certa quantidade de pilha sem alocá-la ... bem, a menos que você seja o código do kernel, uma fonte de bugs bacanas). Todas essas são escolhas de otimização particulares, a maior parte da justificativa para elas é explicada nas referências ABI completas para as quais a referência da Wikipédia do autor original aponta.

FrankH.
fonte
1
Sobre nomes de registro: Esse byte de prefixo pode ser um fator. Mas então seria mais lógico para o MS escolher rcx - rdx - rdi - rsi como registradores de argumento. Mas o valor numérico dos oito primeiros pode guiá-lo se você estiver projetando uma ABI do zero, mas não há razão para alterá-los se uma ABI perfeitamente boa já existir, isso só leva a mais confusão.
JanKanis
2
No RSI / RDI: Essas instruções geralmente serão sequenciais; nesse caso, a convenção de chamada não importa. Caso contrário, haverá apenas uma cópia (ou talvez algumas) dessa função em todo o sistema, portanto, ela salva apenas alguns bytes no total . Não vale a pena. Sobre outras diferenças / pilha de chamadas: A utilidade de escolhas específicas é explicada nas referências ABI, mas elas não fazem uma comparação. Eles não dizem por que outras otimizações não foram escolhidas - por exemplo, por que o Windows não tem a zona vermelha de 128 bytes e por que o AMD ABI não tem os slots de pilha extras para argumentos?
JanKanis
1
@cHao: não. Mas eles mudaram mesmo assim. O Win64 ABI é diferente do Win32 (e não compatível), e também diferente do AMDs ABI.
JanKanis
7
@Somejan: Win64 e Win32 __fastcallsão 100% idênticos para o caso de não ter mais de dois argumentos maiores que 32 bits e retornar um valor não maior que 32 bits. Essa não é uma pequena classe de funções. Essa compatibilidade com versões anteriores não é possível entre os ABIs UN * X para i386 / amd64.
FrankH.
2
@szx: Acabei de encontrar o tópico da lista de discussão relevante de novembro de 2000 e postou uma resposta resumindo o raciocínio. Observe que isso memcpypode ser implementado dessa forma, não strcpy.
Peter Cordes
42

IDK por que o Windows fez o que eles fizeram. Veja o final desta resposta para um palpite. Eu estava curioso para saber como a convenção de chamadas SysV foi decidida, então pesquisei no arquivo da lista de discussão e- e encontrei algumas coisas legais.

É interessante ler alguns desses tópicos antigos na lista de discussão do AMD64, uma vez que os arquitetos da AMD estavam ativos nisso. Por exemplo, escolher os nomes dos registros foi uma das partes difíceis: a AMD considerou renomear os 8 registros originais r0-r7, ou chamar os novos registros de coisas semelhantesUAX .

Além disso, o feedback dos desenvolvedores do kernel identificou coisas que tornaram o design original syscalle swapgsinutilizável . Foi assim que a AMD atualizou a instrução para resolver isso antes de lançar qualquer chip real. Também é interessante que, no final de 2000, a suposição era que a Intel provavelmente não adotaria o AMD64.


A convenção de chamada SysV (Linux), e a decisão sobre quantos registros devem ser preservados pelo callee versus salvos pelo chamador, foi feita inicialmente em novembro de 2000, por Jan Hubicka (um desenvolvedor gcc). Ele compilou o SPEC2000 e examinou o tamanho do código e o número de instruções. Esse tópico de discussão pula em torno de algumas das mesmas idéias como respostas e comentários sobre esta pergunta SO. Em um segundo thread, ele propôs a sequência atual como ótima e, com sorte, final, gerando um código menor do que algumas alternativas .

Ele está usando o termo "global" para significar registros preservados de chamada, que devem ser push / popped se usados.

A escolha de rdi, rsi, rdxcomo os três primeiros argumentos foi motivada por:

  • economia de tamanho de código menor em funções que chamam memsetou outra função de string C em seus argumentos (onde gcc alinha uma operação de string rep?)
  • rbxé preservado por chamada porque ter dois regs preservados por chamada acessíveis sem prefixos REX (rbx e rbp) é uma vitória. Presumivelmente escolhido porque é o único outro reg que não é implicitamente usado por nenhuma instrução. (string de repetição, contagem de deslocamento e saídas / entradas mul / div afetam todo o resto).
  • Nenhum dos registros com propósitos especiais são preservados por chamada (veja o ponto anterior), então uma função que deseja usar instruções de string rep ou uma mudança de contagem de variável pode ter que mover args de função para outro lugar, mas não precisa salvar / restaurar o valor do chamador.
  • Estamos tentando evitar o RCX no início da sequência, já que ele é um registrador comumente usado para fins especiais, como o EAX, por isso tem o mesmo propósito de estar ausente na sequência. Além disso, não pode ser usado para syscalls e gostaríamos de fazer a sequência syscall para corresponder ao máximo possível à sequência de chamada de função.

    (background: syscall/ sysretinevitavelmente destrói rcx(com rip) e r11(com RFLAGS), para que o kernel não possa ver o que estava originalmente rcxquando syscallexecutado.)

A ABI de chamada de sistema do kernel foi escolhida para corresponder à ABI de chamada de função, exceto em r10vez de rcx, portanto, um wrapper libc funciona como mmap(2)pode apenas mov %rcx, %r10/ mov $0x9, %eax/ syscall.


Observe que a convenção de chamada SysV usada pelo Linux i386 é uma droga em comparação com o __vectorcall de 32 bits do Windows. Ele passa tudo na pilha e só retorna edx:eaxpara int64, não para pequenas estruturas . Não é nenhuma surpresa que pouco esforço foi feito para manter a compatibilidade com ele. Quando não há razão para não o fazer, eles fazem coisas como manterrbx chamada preservada, já que decidiram que ter outro no 8 original (que não precisava de um prefixo REX) era bom.

Tornar o ABI ideal é muito mais importante a longo prazo do que qualquer outra consideração. Eu acho que eles fizeram um bom trabalho. Não estou totalmente certo sobre como retornar structs compactados em registradores, em vez de campos diferentes em regs diferentes. Acho que o código que os passa por valor sem realmente operar nos campos vence dessa forma, mas o trabalho extra de desempacotar parece bobo. Eles poderiam ter mais registradores de retorno inteiros, mais do que apenas rdx:rax, então retornar uma estrutura com 4 membros poderia retorná-los em rdi, rsi, rdx, rax ou algo assim.

Eles consideraram a passagem de inteiros em regs vetoriais, porque SSE2 pode operar em inteiros. Felizmente eles não fizeram isso. Os inteiros são usados ​​como deslocamentos de ponteiro com muita freqüência, e uma viagem de ida e volta para a memória da pilha é muito barata . Além disso, as instruções SSE2 levam mais bytes de código do que as instruções de inteiros.


Eu suspeito que os designers de ABI do Windows podem ter buscado minimizar as diferenças entre 32 e 64 bits para o benefício de pessoas que precisam portar asm de um para o outro, ou que podem usar alguns #ifdefs em algum ASM para que a mesma fonte possa construir mais facilmente uma versão de 32 ou 64 bits de uma função.

Minimizar as mudanças no conjunto de ferramentas parece improvável. Um compilador x86-64 precisa de uma tabela separada de qual registro é usado para quê e qual é a convenção de chamada. Ter uma pequena sobreposição com 32 bits provavelmente não produzirá economias significativas no tamanho / complexidade do código do conjunto de ferramentas.

Peter Cordes
fonte
1
Acho que li em algum lugar no blog de Raymond Chen sobre a justificativa para escolher esses registros após o benchmarking do lado do MS, mas não consigo mais encontrar. No entanto, alguns motivos relacionados à zona inicial foram explicados aqui blogs.msdn.microsoft.com/oldnewthing/20160623-00/?p=93735 blogs.msdn.microsoft.com/freik/2006/03/06/…
phuclv
@phuclv: Veja também É válido escrever abaixo de ESP? . Os comentários de Raymond sobre minha resposta apontaram alguns detalhes de SEH que eu não sabia, o que explica por que o x86 32/64 Windows não tem atualmente uma zona vermelha de fato. Sua postagem no blog tem alguns casos plausíveis para a mesma possibilidade de manipulador de página de código que mencionei nessa resposta :) Então, sim, Raymond fez um trabalho melhor em explicar isso do que eu (o que não é surpresa, porque comecei sabendo muito pouco sobre o Windows), e a tabela de tamanhos da zona vermelha para não-x86 é realmente legal.
Peter Cordes
13

Lembre-se de que a Microsoft foi inicialmente "oficialmente evasiva com o esforço inicial do AMD64" (de "A History of Modern 64-bit Computing" de Matthew Kerner e Neil Padgett) porque eram fortes parceiros da Intel na arquitetura IA64. Eu acho que isso significava que mesmo se eles estivessem abertos para trabalhar com os engenheiros do GCC em uma ABI para usar no Unix e no Windows, eles não teriam feito isso, pois significaria apoiar publicamente o esforço do AMD64 quando não o fizeram ainda não oficialmente feito (e provavelmente teria chateado a Intel).

Além disso, naquela época a Microsoft não tinha absolutamente nenhuma inclinação para ser amigável com projetos de código aberto. Certamente não Linux ou GCC.

Então, por que eles teriam cooperado em uma ABI? Eu acho que as ABIs são diferentes simplesmente porque foram projetadas mais ou menos ao mesmo tempo e de forma isolada.

Outra citação de "A History of Modern 64-bit Computing":

Em paralelo com a colaboração da Microsoft, a AMD também envolveu a comunidade de código aberto para se preparar para o chip. A AMD contratou a Code Sorcery e a SuSE para o trabalho da cadeia de ferramentas (a Red Hat já estava contratada pela Intel na porta da cadeia de ferramentas IA64). Russell explicou que SuSE produziu compiladores C e FORTRAN, e Code Sorcery produziu um compilador Pascal. Weber explicou que a empresa também se envolveu com a comunidade Linux para preparar uma porta Linux. Esse esforço foi muito importante: serviu de incentivo para que a Microsoft continuasse a investir no esforço do AMD64 Windows, e também garantiu que o Linux, que estava se tornando um sistema operacional importante na época, estaria disponível assim que os chips fossem lançados.

Weber chega a dizer que o trabalho do Linux foi absolutamente crucial para o sucesso do AMD64, porque permitiu que a AMD produzisse um sistema ponta a ponta sem a ajuda de nenhuma outra empresa, se necessário. Essa possibilidade garantiu que a AMD tivesse uma estratégia de sobrevivência de pior caso, mesmo que outros parceiros desistissem, o que por sua vez manteve os outros parceiros engajados por medo de serem deixados para trás.

Isso indica que mesmo a AMD não sentiu que a cooperação era necessariamente a coisa mais importante entre MS e Unix, mas que ter suporte a Unix / Linux era muito importante. Talvez até tentar convencer um ou ambos os lados a se comprometer ou cooperar não valesse o esforço ou risco (?) De irritar qualquer um deles? Talvez a AMD tenha pensado que até mesmo sugerir uma ABI comum poderia atrasar ou inviabilizar o objetivo mais importante de simplesmente ter o suporte de software pronto quando o chip estivesse pronto.

Especulação da minha parte, mas acho que o principal motivo pelo qual as ABIs são diferentes foi a razão política de que o MS e o Unix / Linux não funcionaram juntos, e a AMD não viu isso como um problema.

Michael Burr
fonte
Boa perspectiva sobre a política. Concordo que não é culpa ou responsabilidade da AMD. Eu culpo a Microsoft por escolher uma convenção de chamada pior. Se a convenção de chamada deles tivesse sido melhor, eu teria alguma simpatia, mas eles tiveram que mudar de sua ABI inicial __vectorcallporque repassar __m128a pilha era uma droga. Ter a semântica preservada de chamada para o 128b baixo de alguns dos regs vetoriais também é estranho (em parte, culpa da Intel por não projetar um mecanismo de salvamento / restauração extensível com SSE originalmente, e ainda não com AVX.)
Peter Cordes
1
Eu realmente não têm qualquer experiência ou conhecimento de como bom os ABIs são. Ocasionalmente, preciso saber o que são, para que possa entender / depurar no nível de montagem.
Michael Burr
1
Uma boa ABI minimiza o tamanho do código e o número de instruções e mantém as cadeias de dependências com baixa latência, evitando viagens de ida e volta extras pela memória. (para args ou para locais que precisam ser derramados / recarregados). Existem compensações. A zona vermelha do SysV leva algumas instruções extras em um lugar (o despachante do manipulador de sinais do kernel), para um benefício relativamente grande para funções folha de não ter que ajustar o ponteiro da pilha para obter algum espaço temporário. Então essa é uma vitória clara com desvantagem quase zero. Ele foi adotado praticamente sem discussão depois que foi proposto para o SysV.
Peter Cordes
1
@dgnuff: Certo, essa é a resposta para Por que o código do kernel não pode usar uma Zona Vermelha . As interrupções usam a pilha do kernel, não a pilha do espaço do usuário, mesmo se chegarem quando a CPU estiver executando o código do espaço do usuário. O kernel não confia nas pilhas do espaço do usuário porque outra thread no mesmo processo do espaço do usuário poderia modificá-lo, assumindo assim o controle do kernel!
Peter Cordes
1
@ DavidA.Gray: sim, a ABI não diz que você tem que usar RBP como um ponteiro de frame, então o código otimizado geralmente não (exceto em funções que usam allocaou em alguns outros casos). Isso é normal se você está acostumado a gcc -fomit-frame-pointerser o padrão no Linux. A ABI define metadados de desenrolamento de pilha que permitem que o tratamento de exceções ainda funcione. (Suponho que funcione algo como o material CFI do GNU / Linux x86-64 System V .eh_frame). gcc -fomit-frame-pointertem sido o padrão (com otimização habilitada) desde sempre no x86-64, e outros compiladores (como o MSVC) fazem a mesma coisa.
Peter Cordes
12

O Win32 tem seus próprios usos para ESI e EDI e requer que eles não sejam modificados (ou pelo menos que sejam restaurados antes de chamar a API). Eu imagino que o código de 64 bits faça o mesmo com RSI e RDI, o que explicaria por que eles não são usados ​​para passar argumentos de função.

Eu não poderia dizer por que RCX e RDX foram trocados, no entanto.

cHao
fonte
1
Todas as convenções de chamada têm alguns registros designados como scratch e alguns como preservados, como ESI / EDI e RSI / RDI no Win64. Mas esses são registros de uso geral, a Microsoft poderia ter escolhido sem problemas usá-los de forma diferente.
JanKanis
1
@Somejan: Claro, se eles quisessem reescrever a API inteira e ter dois sistemas operacionais diferentes. Eu não chamaria isso de "sem problemas", no entanto. Por dezenas de anos, a MS fez certas promessas sobre o que fará ou não com os registros x86, e elas têm sido mais ou menos consistentes e compatíveis todo esse tempo. Eles não vão jogar tudo isso pela janela apenas por causa de algum decreto da AMD, especialmente um tão arbitrário e fora do reino de "construir um processador".
cHao
5
@Somejan: O AMD64 UN * X ABI sempre foi exatamente isso - uma peça específica do UNIX . O documento, x86-64.org/documentation/abi.pdf , é intitulado System V Application Binary Interface, AMD64 Architecture Processor Supplement por um motivo. Os (comuns) UNIX ABIs (uma coleção de vários volumes, sco.com/developers/devspecs ) deixam uma seção para o capítulo 3 específico do processador - o suplemento - que são as convenções de chamada de função e regras de layout de dados para um processador específico.
FrankH.
7
@Somejan: O Microsoft Windows nunca tentou se aproximar particularmente do UN * X e, quando se tratou de portar o Windows para x64 / AMD64, eles simplesmente optaram por estender sua própria __fastcall convenção de chamadas. Você afirma que Win32 / Win64 não são compatíveis, mas então, olhe com atenção: para uma função que usa dois args de 32 bits e retorna 32 bits, Win64 e Win32 __fastcallna verdade são 100% compatíveis (os mesmos regs para passar dois argumentos de 32 bits, mesmo valor de retorno). Mesmo algum código binário (!) Pode funcionar em ambos os modos operacionais. O lado UNIX rompeu completamente com os "métodos antigos". Por boas razões, mas uma pausa é uma pausa.
FrankH.
2
@Olof: É mais do que apenas um compilador. Tive problemas com ESI e EDI quando fiz coisas autônomas no NASM. O Windows definitivamente se preocupa com esses registros. Mas sim, você pode usá-los se salvá-los antes de fazer e restaurá-los antes que o Windows precise deles.
cHao