Chamando código Assembly legado (AVRASM2) a partir de um código novo em C (avr-gcc)

Contexto

Todos os tutoriais que encontrei na internet que falam sobre mistura de C e ASM em um mesmo projeto ensinam a fazer da mesma forma, que é usando avr-gcc. O problema comum em todos eles é que assumem que você está começando um projeto do zero. Isso significa que o código assembly deve estar na sintaxe que o avr-as (GNU Assembler) espera encontrar. Quando me refiro a “código legado” estou falando de Assembly feito no AVR Studio, usando o AVRASM2 como Assembler. A sintaxe do Assembly que o AVRASM2 espera é incompatível com a que o avr-as espera, então não podemos simplesmente pegar o código e compilar com avr-as.

Dependendo do tamanho do projeto original é inviável migrar tudo de um vez e é aí que poder mesclar C e ASM se torna muito útil, pois você pode ir escrevendo o código C ao mesmo tempo em que o sistema está evoluindo e eventualmente ganhando novas funcionalidades. O desafio desse post é conseguir juntar dois projetos que foram feitos usando ferramentas diferentes (avr-gcc e AVR Studio) e que, a princípio, são incompatíveis.

Muitos desses projetos ASM (todos?) feitos há muito tempo atrás provavelmente foram feitos com assemblers que não tinham em mente a junção com código C e portanto geram binários que não possuem suporte à link-edição e outras coisas necessárias para que possamos juntar as duas linguagens. Esse é o caso do AVR Studio (quando usando AVRASM2 como Assembler), ele gera no final do build um arquivo no formato Intel Hex, que não possui, dentre outras coisas, suporte à link-edição.

Preparação dos arquivos

Antes de podermos começar precisamos ter todos os nossos arquivos em um mesmo formato, para que possamos usar o avr-gcc para gerar nosso binário final. Isso significa que teremos que converter todos os arquivos para um formato que o avr-gcc entenda.

Como o AVRASM2 gera Intel Hex (HEX) temos que converter esse conteúdo para elf32-avr (ELF), assim poderemos juntar esse código com nosso código compilado pelo avr-gcc. Não existe uma conversão direta de HEX pra ELF, o que podemos fazer é converter de HEX para flat binary e depois para ELF. A conversão é feita com avr-objcopy.

Exemplo de código AVRASM2

Vamos pegar um pequeno exemplo de código feito com AVRASM2 para podermos fazer o processo completo.

.include "m328Pdef.inc"

.org 0x0000

_blinks:
  ldi r23, 0xa
  add r24, r23
  clr r1
  clr r25
  ret

Esse código apenas soma o valor 10 ao parametro que ele receber. A linha do .include é necessária pois é nela que existem as definiçoes de resgitradores e etc para o micro controlador que estivermos usando. Nesse caso estamos usando um ATmega328P, mas poderia ser qualquer outro AVR. Importante notar a instrução .org 0x0000, isso faz com que nosso código seja posicionado no endereço de memória 0. Precisaremos saber disso mais adiante.

O HEX gerado pelo AVRASM2 (AVRStudio 4, por exemplo) possui apenas um seção chamada .sec1, então só precisamos copiá-la pra o flat binary.


$ avr-objdump -h blinks.hex

blinks.hex:     file format ihex

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .sec1         0000000a  00000000  00000000  00000011  2**0
                        CONTENTS, ALLOC, LOAD

Copiando essa seção para o flat binary:

$ avr-objcopy -j .sec1 -I ihex -O binary blinks.hex blinks.bin

Agora precisamos converter para ELF:

$ avr-objcopy  --rename-section .data=.progmem.data,contents,alloc,load,readonly,data -I binary -O elf32-avr blinks.bin blinks.elf

Nesse momento temos um código asembly já pronto para ser link-editado com qualquer outro código gerado pelo avr-gcc. Mas ainda temos alguns problemas. Olhando o arquivo ELF de perto, vemos que o símbolo _blinks não está na tabela de símbolos e precisamos saber onde nossa rotina começa para podermos referenciá-la no código C.


  $ avr-objdump -x blinks.elf

  blinks.elf:     file format elf32-avr

  SYMBOL TABLE:
  00000000 l    d  .progmem.data	00000000 .progmem.data
  00000000 g       .progmem.data	00000000 _binary_blinks_bin_start
  0000000a g       .progmem.data	00000000 _binary_blinks_bin_end
  0000000a g       *ABS*	        00000000 _binary_blinks_bin_size

Os três símobolos _binary_* foram criados pelo avr-objcopy e marcam, respectivamente, o início, fim e tamanho total do nosso código, depois de compilado. Mesmo não tendo o símbolo _blinks podemos deduzir onde ele está. Se voltarmos no código assembly veremos que a instrução .org 0x0000 está lá e sabemos que ela força o posicionamento do ínício do nosso código no endereço 0. Então podemos usar o símbolo _binary_blinks_bin_start (que está posicionado no endereço 0) como sendo nosso ponto de entrada no código assembly.

Analisando o código em C

Para validar nossa hipótese, vamos fazer um código em C que chama essa rotina escrita em Assembly. O código é bem simples, tudo que ele faz é piscar o LED que está ligado na porta D13. Como esse código foi testando em um Arduino Nano, a porta D13 é, na verdade, o bit 5 da PORTB.

  #include <avr/io.h>
  #include <util/delay.h>

  // Arduino Pin13 is mapped to PORTB, bit 5
  // See: http://www.arduino.cc/en/Reference/PortManipulation

  extern char ASM_SYM(char n);

  int main(void){

    uint8_t total_blinks =  ASM_SYM(5);
    DDRB = DDRB | _BV(PB5); // PIN13 (internal led) as output

    PORTB = PORTB | _BV(PB5); // HIGH
    for (;;){
      uint8_t i;
      for (i = 0; i < total_blinks; i++){
        PORTB = PORTB | _BV(PB5); // HIGH
        _delay_ms(200);

        PORTB &= ~_BV(PB5); // LOW
          _delay_ms(200);
      }
      _delay_ms(1000);
    }

    return 0;
  }

Como vamos usar esse mesmo código para linkar com vários códigos ASM diferentes, deixamos o nome da função como uma constante (ASM_SYM) e vamos passar um valor dessa constante para o avr-gcc (via flag -D) no momento de compilar esse código.

Compilando tudo e juntando em um mesmo binário

A compilação do código em C é simples, nada demais em relação aqualquer outra compilação:

  $ avr-gcc -mmcu=atmega328p -Os -DF_CPU=16000000 -DASM_SYM=_binary_blinks_bin_start -o main.elf main.c blinks.elf

Perceba que aqui estamos passando o parametro -DASM_SYM=_binary_blinks_bin_start para o avr-gcc. Isso faz com que ele use esse símbolo na chamada uint8_t total_blinks = ASM_SYM(5). Isso significa que é como se o código fosse escrito assim: uint8_t total_blinks = _binary_blinks_bin_start(5);

Podemos olhar o ELF gerado para saber se o código parece correto:

  $ avr-objdump -d main.elf
  Disassembly of section .text:

  00000000 <__vectors>:
     0:	0c 94 34 00 	jmp	0x68	; 0x68 <__ctors_end>
     4:	0c 94 3e 00 	jmp	0x7c	; 0x7c <__bad_interrupt>

  00000068 <__ctors_end>:
    68:	11 24       	eor	r1, r1
    6a:	1f be       	out	0x3f, r1	; 63
    6c:	cf ef       	ldi	r28, 0xFF	; 255
    6e:	d8 e0       	ldi	r29, 0x08	; 8
    70:	de bf       	out	0x3e, r29	; 62
    72:	cd bf       	out	0x3d, r28	; 61
    74:	0e 94 45 00 	call	0x8a	; 0x8a <main>
    78:	0c 94 6d 00 	jmp	0xda	; 0xda <_exit>

  0000007c <__bad_interrupt>:
    7c:	0c 94 00 00 	jmp	0	; 0x0 <__vectors>

  00000080 <_binary_blinks_bin_start>:
    80:	7a e0       	ldi	r23, 0x0A	; 10
    82:	87 0f       	add	r24, r23
    84:	11 24       	eor	r1, r1
    86:	99 27       	eor	r25, r25
    88:	08 95       	ret

  0000008a <main>:
    8a:	80 e0       	ldi	r24, 0x00	; 0
    8c:	0e 94 40 00 	call	0x80	; 0x80 <_binary_blinks_bin_start>
    90:	25 9a       	sbi	0x04, 5	; 4
    92:	2d 9a       	sbi	0x05, 5	; 5

Algumas partes do código foram omitidas para podermos nos concentrar no que é importante. O que temos que observar aqui é onde está nosso código ASM, que nesse caso está no endereço 0x0080. Olhando o código da nossa função main vemos que a segunda instrução é o call 0x80, que é justamente a chamada à nossa rotina Assembly.

Nesse ponto, temos um ELF que precisamos converter de volta para HEX, para que possamos fazer o flash para o micro controlador.

  $ avr-objcopy -I elf32-avr -O ihex -j .text -j .data main.elf main.hex

De fato, esse é um exemplo muito simples e provavelmente não representa uma situação real em que temos um projeto Assembly legado que precisa ser migrado para C. Pensando nisso, vamos analisar exemplos mais complexos de código Assembly que fazem uso de outras instruçoes como jmp, call, rjmp.

Analisando um código que usa jmp

Agora vamos fazer o mesmo procedimento mas usando um código Assembly que faz uso da instrução jmp.

  .org 0x0000

  _blinks:
    jmp _add

  _add:
    clr r1
    clr r25
    ldi r23, 0xa
    add r24, r23
    ret

O código é basicamente o mesmo, mas forçamos um jmp apenas para ilustrar nosso problema. Depois que compilamos com o AVRASM2 e geramos o elf temos o seguinte:

  Disassembly of section .text:

  00000000 < _binary_blinks_bin_start>:
     0:	0c 94 02 00 	jmp	0x4	; 0x4 < _binary_blinks_bin_start+0x4>
     4:	11 24       	eor	r1, r1
     6:	99 27       	eor	r25, r25
     8:	7a e0       	ldi	r23, 0x0A	; 10
     a:	87 0f       	add	r24, r23
     c:	08 95       	ret

Olhando o assembly gerado, vemos que está tudo certo pois nosso código começa e 0x0000 e o jmp está indo para o endereço 0x0004, que é onde começa nossa rotina _add. Sabemos disso pois a instrução clr r1, r1 é traduzida para eor r1, r1. Agora é hora de juntar isso ao noso código C. Vejamos o Assembly final:

  Disassembly of section .text:

  00000000 <__vectors>:
     0:	0c 94 34 00 	jmp	0x68	; 0x68 <__ctors_end>
     4:	0c 94 3e 00 	jmp	0x7c	; 0x7c <__bad_interrupt>
     8:	0c 94 3e 00 	jmp	0x7c	; 0x7c <__bad_interrupt>

  00000068 <__ctors_end>:
    68:	11 24       	eor	r1, r1
    6a:	1f be       	out	0x3f, r1	; 63
    6c:	cf ef       	ldi	r28, 0xFF	; 255
    6e:	d8 e0       	ldi	r29, 0x08	; 8
    70:	de bf       	out	0x3e, r29	; 62
    72:	cd bf       	out	0x3d, r28	; 61
    74:	0e 94 47 00 	call	0x8e	; 0x8e <main>
    78:	0c 94 6f 00 	jmp	0xde	; 0xde <_exit>

  00000080 <_binary_blinks_bin_start>:
    80:	0c 94 02 00 	jmp	0x4	; 0x4 <__zero_reg__+0x3>
    84:	11 24       	eor	r1, r1
    86:	99 27       	eor	r25, r25
    88:	7a e0       	ldi	r23, 0x0A	; 10
    8a:	87 0f       	add	r24, r23
    8c:	08 95       	ret

  0000008e <main>:
    8e:	80 e0       	ldi	r24, 0x00	; 0
    90:	0e 94 40 00 	call	0x80	; 0x80 < _binary_blinks_bin_start>
    94:	25 9a       	sbi	0x04, 5	; 4

Olhando o código da nossa função main() vemos que o call é feito corretamente para o endereço 0x0080, mas quando olhamos para o código de nossa rotina Assembly, em 0x0080, vemos que o endereço para onde o jmp está indo continua sendo 0x4 e olhando esse endereço percebemos que certamente não é o endereço correto. Isso acontece pois o código Assembly foi compilado completamente separado do código C e não tem nehuma ideia de que vai, na verdade, ser inserido no meio de um outro binário e que por isso deveria ter seus endereços ajustados.

O endereço correto para onde o jmp deveria ir é 0x0084. Precisamos fazer, de alguma forma, esses endereços ficarem certos. Uma forma bem “suja” de se fazer isso é “deslocar” o código assembly em exatamente 0x0080. Afinal, sabemos que ele será posicionado no endereço 0x0080 (vimos isso no disassembly do ELF). Mudando a instrução .org 0x0000 para .org 0x0080 temos o seguinte no diassembly do ELF final.

  00000080 <_binary_blinks_bin_start>:
    80:	0c 94 82 00 	jmp	0x104	; 0x104 <_etext+0x22>
    84:	11 24       	eor	r1, r1
    86:	99 27       	eor	r25, r25
    88:	7a e0       	ldi	r23, 0x0A	; 10
    8a:	87 0f       	add	r24, r23
    8c:	08 95       	ret

Percebemos que o endereço final ainda ficou errado. Mas vamos parar um pouco e analisar como nossa instrução de jmp foi codificada. Analisando a linha isoladamente temos o segunte:

    80:	0c 94 82 00 	jmp	0x104	; 0x104 <_etext+0x22>

O que temos aqui é o código da instrução oc 94 e o endereço para onde o jmp deve ir, nesse caso 82 00. Quando compilamos nosso código com o avrasm2 podemos gerar um arquivo adicional que contem todos os labels originais do assembly (opção -m) e seus endereços finais. Olhando esse arquivo temos o seguinte:

  CSEG _blinks      00000080
  CSEG _add         00000082

isso nos diz que nossa rotina _add está exatamente no endereço 0082 que é o mesmo endereço que vemos na codificação da nossa instrução (0c 94 82 00) do ELF, eles estão apenas representados de forma diferente.

Nossa rotina que estava originalmente no endereço 0082 está com o jmp para 0x104. Mas 0x104 é exatamente o dobro de 0x0082 então vamos trocar o nosso .org 0x0080 para .org 0x0040 e ver o que acontece.

  00000080 <_binary_blinks_bin_start>:
    80:	0c 94 42 00 	jmp	0x84	; 0x84 <_binary_blinks_bin_start+0x4>
    84:	11 24       	eor	r1, r1
    86:	99 27       	eor	r25, r25
    88:	7a e0       	ldi	r23, 0x0A	; 10
    8a:	87 0f       	add	r24, r23
    8c:	08 95       	ret

Agora sim temos o jmp para o endereço correto! Não sei ao certo porque isso funciona mas parece dar certo. Funciona inclusive pra um código assembly em que fazemos uso de várias instruçoes de desvio ao mesmo tempo (jmp, rjmp, call):

  _blinks:
    rjmp _add
  _ret:
    ret

  _add:
    call _ldi
  _add1:
    add r24, r23
    call _clear
    rjmp _ret

  _clear:
    clr r1
    clr r25
    ret

  _ldi:
    ldi r23, 0x5
    jmp _add1

Diassembly do ELF final:

  00000080 <_binary_blinks_bin_start>:
    80:	01 c0       	rjmp	.+2      	; 0x84 <_binary_blinks_bin_start+0x4>
    82:	08 95       	ret
    84:	0e 94 4b 00 	call	0x96	; 0x96 <__binary_blinks_bin_start+0x16>
    88:	87 0f       	add	r24, r23
    8a:	0e 94 48 00 	call	0x90	; 0x90 <__binary_blinks_bin_start+0x10>
    8e:	f9 cf       	rjmp	.-14     	; 0x82 <__binary_blinks_bin_start+0x2>
    90:	11 24       	eor	r1, r1
    92:	99 27       	eor	r25, r25
    94:	08 95       	ret
    96:	75 e0       	ldi	r23, 0x05	; 5
    98:	0c 94 44 00 	jmp	0x88	; 0x88 <__binary_blinks_bin_start+0x8>

Conclusoes

Vimos que é possível gerar um HEX, converter pra ELF e chamar uma rotina Assembly que está dentro desse binário. Mas isso é só o início, ainda temos um longo caminho pela frente até podermos pegar um projeto Assembly realmente grande (10K+ LOC) e mesclar com C.

Quando misturamos C e Assembly existem regras que devemos obedecer no momento de usar os registradores. Essas regras estão descritas nesse documento da Atmel. Antes de tentar reproduzir o que fizemos aqui em um projeto Assembly maior e com funcionalidades reais certifique-se de que o uso dos registradores está em conformidade com essas regras ou as chamadas ao código assembly podem simplesmente não funcionar.

Trabalhos futuros

Ainda tenho muita pesquisa para fazer e algumas hipóteses para confirmar, mas isso é assunto para alguns próxmos posts. Isso inclui:

  • Como inserir simbolos na tabela de simbolos dos ELFs gerados. Isso nos daria a possibilidade de chamar rotinas que estão “no meio” do código Assembly;
  • Como trabalhar com relocação de simbolos. Quando vemos o disassembly de um ELF gerado em um projeto C+Assembly feito com avr-gcc vemos que os simbolos do código assembly são adicionados em uma seção especial do ELF chamada Relocation table. Sabendo manipular esse tabela pode ser que se torne bem mais fácil o uso de código assembly, sem precisar por exemplo desse hack da instrução .org que precisamos fazer;
  • Descobrir como fazer a chamada no sentido contrário, ou seja, código assembly legado chamando código novo C. O que fizemos aqui foi apenas código C chamando código Assembly.

Obrigado pela leitura e fique ligado em posts futuros sobre esse assunto. Ainda tenho muita pesquisa para fazer sobre isso.

Próximo post: Convertendo Intel HEX para ELF32-avr criando tabela de símbolos e tabela de realocação