Skip to content

Executando Código no ALE

Executar um programa escrito em linguagem de montagem do RISC-V exige o uso de um hardware RISC-V ou de um simulador. Como nossos computadores não possuem processadores RISC-V, utilizaremos o simulador RISC-V ALE para executar os programas compilados para RISC-V.

O simulador RISC-V ALE será executado pelo seu navegador de Internet (p.ex, Chrome ou Firefox) . Para isso, basta acessar a página. A figura a seguir ilustra a interface inicial do simulador.

Visão Geral do Processo de Carregamento e Execução de Programas

Carregamento de Arquivos

O simulador possui um sistema de arquivos próprio que permite o armazenamento de múltiplos arquivos. Para isso, basta clicar no botão azul no canto superior direito e selecionar os arquivos do seu computador que deseja carregar no simulador.

Dica

Você pode carregar arquivos executáveis, arquivos fonte ou mesmo arquivos de dados para serem acessados pelos seus programas.

Não há estrutura de diretórios e arquivos existentes com o mesmo nome são automaticamente sobrescritos, ou seja, se você carregar um arquivo chamado prog.x mais de uma vez, apenas o último será preservado.

Run: Compilação, Montagem, Ligação e Execução de Programas

Uma vez que você carregou seus arquivos, você pode iniciar a execução do programa. Para isso, você deve clicar no botão RUN (no canto superior direito).

O simulador identificará os arquivos fonte e, caso necessário, realizará a compilação, montagem e ligação para produzir o executável final. Por fim, o simulador invocará o executável e mostrará a saída do programa (se houver) no painel que irá abrir.

Observação

O simulador interrompe a execução do programa quando: (i) o programa invoca a chamada de sistema (syscall) exit, ou (ii) quando a execução encontra instruções inválidas. Neste último caso, o simulador pode apresentar mensagens de erro como “Error: Failed stop: 64 consecutive illegal instructions: 0”. Isso é esperado em programas que não chamam a syscall exit, já que o processador não sabe onde seu programa termina e continuará executando instruções consecutivamente, até que encontre instruções inválidas.

Programas em C sem a LibC

Programas escritos em C são geralmente ligados com a biblioteca padrão C e com arquivos objeto que possuem rotinas de suporte à execução da aplicação. Estas rotinas, inicializam as estruturas de dados da biblioteca C, organizam os parâmetros para a função main (_start) e, após o retorno da função main, invocam o sistema operacional para sinalizar o término da aplicação (função exit).

Função _start e Chamada de Sistema Exit

Além de ligar (juntar) o código dos múltiplos arquivos objetos (.o), o ligador deve registrar o endereço da função de entrada do programa no cabeçalho do arquivo executável para que o loader do sistema operacional saiba por onde começar a executar o programa uma vez que ele é iniciado. Por padrão, em C e C++, o ponto de entrada do programa é definido por uma função chamada _start. Esta é uma função pequena que invoca a função main() e, depois que a main retorna, ela invoca a chamada de sistema (syscall) exit para informar o sistema operacional que o programa terminou.

Durante a geração do arquivo executável, os compiladores C e C++ ligam um arquivo objeto que contém a implementação desta função. No entanto, o compilador para RISC-V que estamos usando não realiza a ligação deste arquivo (nem da biblioteca C), dessa forma precisamos incluir uma implementação da mesma.

O código a seguir mostra uma possível implementação para a função exit e para a função _start. Neste exemplo, a função exit é composta por uma sequência de instruções em linguagem de montagem que copia o valor do parâmetro da função (code) para o registrador a0, coloca o valor 93 no registrador a7 e gera uma interrupção por software (instrução ecall). A interrupção por software redireciona o fluxo de execução para o sistema operacional que, por sua vez, utiliza os valor no registrador a7 para determinar qual é a chamada de sistema (syscall) solicitada e o valor do registrador a0 como parâmetro para esta chamada.

void exit(int code)
{
  __asm__ __volatile__(
    "mv a0, %0           # return code\n"
    "li a7, 93           # syscall exit (64) \n"
    "ecall"
    :             // Output list
    :"r"(code)    // Input list
    : "a0", "a7"
  );
}

void _start()
{
  int ret_code = main();
  exit(ret_code);
}

O código da função _start simplesmente chama a função main, que é implementada pelo usuário, e, após o retorno da main, invoca a função exit passando como parâmetro o valor de retorno da função main. Você pode copiar e colar estas duas funções em seus programas C que serão executados no simulador ALE. Alternativamente, você pode colocá-las em um arquivo chamado start.c e compilar/montar/ligar este arquivo juntamente com seu programa.

Chamadas de Sistema Read e Write

De forma geral, programas que executam em sistemas computacionais que possuem um sistema operacional não possuem acesso direto aos periféricos do sistema (p.ex., monitor, teclado, mouse, …), ou seja, não podem interagir diretamente com estes dispositivos. Neste caso, toda a interação com estes dispositivos é feita através de chamadas de sistema (syscalls).

A organização do sistema operacional Linux é fortemente baseada no conceito de arquivos. Neste contexto, cada arquivo é identificado por um caminho e um nome (p.ex., /home/mca404/prog.c). Além disso, quando um arquivo é aberto por um programa, o sistema operacional associa este arquivo a um descritor de arquivo (file descriptor) e retorna este descritor de arquivo para o programa. O descritor de arquivo é um número inteiro que deve ser fornecido pelo programa toda vez que o programa solicita que o sistema operacional realize alguma operação com o arquivo (p.ex, escrita ou leitura de dados). Em suma, para realizar a escrita (ou leitura) de um arquivo, o programa deve:

  • Invocar o sistema operacional com a syscall open para abrir o arquivo. Esta syscall abrirá o arquivo e retornará um número inteiro que corresponde ao file descriptor do arquivo aberto;
  • Invocar a chamada de sistema de escrita (write) ou leitura (read) passando como argumento o file descriptor do arquivo e um buffer para escrita ou leitura do dado; e, por fim,
  • Invocar o sistema operacional com a syscall close para fechar o arquivo.

Existem três file descriptors especiais que estão sempre disponíveis e não precisam ser abertos ou fechados: STDIN, STDOUT e STDERR. Os valores dos file descriptors STDIN, STDOUT e STDERR são 0, 1 e 2, respectivamente.

Estes file descriptors correspondem à entrada padrão, saída padrão e saída de erro do programa. Quando o programa escreve na saída padrão ou na saída de erro, o sistema operacional mostra o que foi escrito no terminal onde o programa está sendo executado. Caso o programa leia da entrada padrão, o sistema operacional (i) aguarda até que o usuário digite algo na entrada padrão e tecle ENTER, e (ii) devolve para o programa o que foi digitado no terminal.

O código a seguir mostra a implementação de uma função em C que contém código em linguagem de montagem para RISC-V para invocar a chamada de sistema (sycall read). Esta função contém um conjunto de instruções RISC-V que ajustam os parâmetros e invocam o sistema operacional para realizar a operação de leitura através da syscall read.

/* read
 * Parâmetros:
 *  __fd:  file descriptor do arquivo a ser lido.
 *  __buf: buffer para armazenar o dado lido.
 *  __n:   quantidade máxima de bytes a serem lidos.
 * Retorno:
 *  Número de bytes lidos.
 */
int read(int __fd, const void *__buf, int __n)
{
    int ret_val;
  __asm__ __volatile__(
    "mv a0, %1           # file descriptor\n"
    "mv a1, %2           # buffer \n"
    "mv a2, %3           # size \n"
    "li a7, 63           # syscall read code (63) \n"
    "ecall               # invoke syscall \n"
    "mv %0, a0           # move return value to ret_val\n"
    : "=r"(ret_val)                   // Output list
    : "r"(__fd), "r"(__buf), "r"(__n) // Input list
    : "a0", "a1", "a2", "a7"
  );
  return ret_val;
}

Como você não tem acesso à biblioteca padrão C, você pode utilizar a função acima para realizar operações de leitura da entrada padrão. Para isso, basta chamar a função read para o file descriptor de valor 0. Para utilizá-la, você deve alocar um buffer de entrada, que pode ser alocado como uma variável global, como no exemplo abaixo. Note que esta variável global (input_buffer) é um vetor com 10 caracteres, ou seja, um buffer com 10 bytes. Após realizar a leitura do dado, a função read escreve os bytes lidos no buffer fornecido e retorna a quantidade de bytes lidos. O último parâmetro da função read indica a quantidade máxima de bytes a ser lido. Caso a quantidade de bytes lidos seja maior do que esta quantidade, a função read só escreve esta quantidade (10 no exemplo abaixo) no buffer de entrada e retorna. Os outros bytes ficam armazenados em um buffer interno do sistema operacional e são retornados quando a função read for chamada novamente.

/* Buffer para leitura de dados */
char input_buffer[10];

int main()
{
  /* fd = 0 : realiza a leitura da entrada padrão (stdio) */
  int n = read(0, (void*) input_buffer, 10);
  /* … */
  return 0;
}

O código a seguir mostra uma possível implementação em C para a função write. Esta função, escrita em C, contém código em linguagem de montagem para RISC-V para invocar a chamada de sistema (sycall) write. Ela invoca o sistema operacional para escrever __n bytes do buffer __buf no arquivo (ou dispositivo) indicado pelo file descriptor indicado no parâmetro __fd. Quando __fd = 1, esta função escreve na saída padrão, ou seja, stdout.

/* write
 * Parâmetros:
 *  __fd:  files descriptor para escrita dos dados.
 *  __buf: buffer com dados a serem escritos.
 *  __n:   quantidade de bytes a serem escritos.
 * Retorno:
 *  Número de bytes efetivamente escritos.
 */
void write(int __fd, const void *__buf, int __n)
{
  __asm__ __volatile__(
    "mv a0, %0           # file descriptor\n"
    "mv a1, %1           # buffer \n"
    "mv a2, %2           # size \n"
    "li a7, 64           # syscall write (64) \n"
    "ecall"
    :   // Output list
    :"r"(__fd), "r"(__buf), "r"(__n)    // Input list
    : "a0", "a1", "a2", "a7"
  );
}

Novamente, como você não tem acesso à biblioteca padrão C, você pode utilizar a função acima para escrever na saída padrão do programa, ou seja, no terminal onde o programa foi executado. Para isso, basta chamar a função write para o file descriptor de valor 1. O código a seguir mostra um exemplo onde a função write é chamada para mostrar uma string no terminal de saída.

/* Aloca uma string global com 5 bytes.
 *   OBS: o caractere de quebra de linha, \n, é codificado 
 *        com um único byte. */

char my_string[] = "1969\n";

int main()
{
  /* Imprime os 5 primeiros caracteres da string na 
   * saída padrão , ou seja, 1, 9, 6, 9 e a quebra 
   * de linha. */
  write(1, my_string, 5);

  return 0;
}

O simulador ALE espera um caractere de quebra de linha, ou seja \n, para imprimir o conteúdo escrito na saída padrão no terminal. Dessa forma, você deve adicionar uma quebra de linha no final do seu buffer ou chamar a função write novamente com uma string que contenha o caractere de quebra de linha. O exemplo acima mostra um programa que imprime uma string com 5 caracteres terminada com uma quebra de linha.

Exemplo Completo

O programa a seguir junta todas as partes acima e implementa um programa que lê uma string da entrada padrão, faz modificações pontuais na string e escreve a string modificada na saída padrão.

int read(int __fd, const void *__buf, int __n){
    int ret_val;
  __asm__ __volatile__(
    "mv a0, %1           # file descriptor\n"
    "mv a1, %2           # buffer \n"
    "mv a2, %3           # size \n"
    "li a7, 63           # syscall write code (63) \n"
    "ecall               # invoke syscall \n"
    "mv %0, a0           # move return value to ret_val\n"
    : "=r"(ret_val)  // Output list
    : "r"(__fd), "r"(__buf), "r"(__n)    // Input list
    : "a0", "a1", "a2", "a7"
  );
  return ret_val;
}

void write(int __fd, const void *__buf, int __n){
  __asm__ __volatile__(
    "mv a0, %0           # file descriptor\n"
    "mv a1, %1           # buffer \n"
    "mv a2, %2           # size \n"
    "li a7, 64           # syscall write (64) \n"
    "ecall"
    :   // Output list
    :"r"(__fd), "r"(__buf), "r"(__n)    // Input list
    : "a0", "a1", "a2", "a7"
  );
}

void exit(int code){
  __asm__ __volatile__(
    "mv a0, %0           # return code\n"
    "li a7, 93           # syscall exit (64) \n"
    "ecall"
    :   // Output list
    :"r"(code)    // Input list
    : "a0", "a7"
  );
}

void _start()
{
  int ret_code = main();
  exit(ret_code);
}

#define STDIN_FD  0
#define STDOUT_FD 1

/* Aloca um buffer com 10 bytes.*/
char buffer[10];

int main()
{
  /* Lê uma string da entrada padrão */
  int n = read(STDIN_FD, (void*) buffer, 10);

  /* Modifica a string lida */

  /* Substitui o primeiro caractere pela letra M */
  buffer[0]   = 'M';

  /* Substitui o último caractere (n-1) por uma exclamação e 
   * adiciona um caractere de quebra de linha no buffer logo 
   * após a string. 
   * OBS: no simulador ALE, se a entrada for digitada no terminal e 
   *      seguida de um enter, o último caractere será um '\n'
   */
  buffer[n-1]   = '!';
  buffer[n]     = '\n';

  /* Imprime a string lida e os dois caracteres adicionados 
   * na saída padrão. */
  write(STDOUT_FD, (void*) buffer, n+2);

  return 0;
}

Durante sua execução, o programa invoca o sistema operacional para ler uma string da entrada padrão, ou seja, do terminal exibido pelo simulador. O sistema operacional, por sua vez, aguarda até que o usuário digite algo no terminal e tecle ENTER. Em seguida, ele registra a string digitada no terminal no buffer de leitura fornecido pelo programa e retorna o número de bytes lidos. A figura a seguir mostra o terminal do simulador. Neste caso, para entrar com dados na entrada padrão, basta clicar na janela do terminal, digitar o texto e teclar ENTER.

Execuções com apoio do assistente de correção

O simulador ALE permite a utilização de assistentes de correção que executam, automaticamente, um ou mais testes com o código carregado e produz um relatório com informações sobre o código submetido e o resultado dos testes

Visão geral do assistente de execução

O assistente é codificado na URL que aponta para o simulador. Este link, por exemplo, contém o endereço do simulador e código de um assistente que é carregado quando o simulador é aberto. Você pode clicar no link e inspecionar o menu que é aberto após clicar na seta ao lado do botão RUN. A Figura a seguir ilustra o acesso ao assistente de correção

Ao clicar no botão Assistant, o simulador abrirá uma janela com as informações sobre o assistente de correção. As informações disponibilizadas dependem do assistente em si. A Figura a seguir mostra o assistente carregado no link acima. Note que ele contém o botão RUN TESTS, que serve para iniciar os testes.

Executando um programa com o assistente de execução

Para testar seu programa com o assistente de correção, basta abrir o simulador com um link que contenha um assistente de correção (este, por exemplo), carregar seu programa no simulador e clicar no botão RUN TESTS na janela do assistente. A Figura a seguir mostra um exemplo onde o assistente de correção está executando testes com o programa carregado. Neste caso, o programa produziu os resultados corretos para os testes 1 (Addition) e 2 (Subtraction) e está sendo executado com o teste 3.

Alguns assistentes de correção disponibilizam, ao término dos testes, uma opção para que o usuário baixe um relatório com os resultados dos testes. A Figura a seguir mostra um exemplo onde, após a execução dos testes, o assistente informa a nota e disponibiliza um link para recuperação do relatório

Exercício - Calculadora de Símbolos Simples

O objetivo desta atividade é a familiarização com as ferramentas e o ambiente de trabalho da disciplina e com o assistente de correção do simulador ALE.

Você deve escrever um programa na linguagem C que implementa uma calculadora simples. Esta calculadora deve ler da entrada padrão uma string com o seguinte formato: s1 op s2, onde s1 e s2 correspondem a símbolos que possuem um valor associado a eles e op é a operação. Os símbolos a serem considerados são os caracteres 0, 1, 2, 3, 4, 5, 6, 7, 8, e 9, sendo que os valores associados a eles são os valores zero, um, dois, ..., e nove. As operações aritméticas são representadas pelos símbolos + (adição), - (subtração) e * (multiplicação). A listagem abaixo mostra exemplos de funcionamento (Note que as saídas também são símbolos):

Entrada | 3 + 5 | 5 * 1 | 7 - 7 |
--------|-------|-------|-------|
Saída   |   8   |   5   |   0   |

Para verificar a corretude do seu programa, você pode executar o simulador com assistente de correção clicando aqui. Para isso, basta carregar seu arquivo com o programa em C e executar o assistente de correção, com apersentado anteriormente.

Observações

  • Todas as entradas são compostas por strings de 6 caracteres, onde o primeiro caractere representa o primeiro símbolo, o segundo é um espaço, o terceiro corresponde à operação, o quarto é um espaço, e o quinto símbolo é outro caractere. Seguido por um \n

  • Os casos de teste têm como saída valores de 0 a 9 (apenas um dígito) seguidos de um \n, então não é necessário implementar a rotina de conversão de inteiro para string itoa

  • Seu programa deve ser autocontido e não pode usar rotinas de bibliotecas, nem mesmo da biblioteca padrão C. Revise as de Visão Geral e Execução de Programas desse Laboratório. Estas seções descrevem em mais profundidade como escrever programas em C sem a LibC e como ler/escrever da entrada/saída padrão do sistema, ou seja, do terminal.

  • Ao rodar o assistente para obter o relatório e a nota não troque de janela, minimize ou feche o navegador. É possível que ao fazê-lo o teste em execução no momento receba estado de falha.

Entrega

Utilizaremos pela primeira vez nesse laboratório o assistente do simulador RISC-V. Ele pode ser acessado clicando aqui. Ao acessar o assistente, uma nova aba do simulador será aberta com o botão Assistant, logo abaixo do botão Debug, configurado para uso

Quando estiver pronto para testar seu código, carregue o arquivo.c no simulador e clique em Run Tests após selecionar a aba do assistente. O nome de cada um dos testes será informado, e após passar o tempo de processamento o teste retornará Pass ou Fail, que podem ser clicados para informar a entrada, saída esperada e saída retornada daquele teste. Quando estiver satisfeito com a nota recebida, clique na mensagem do relatório para gerar o documento que deve ser submetido no Classroom para avaliação. Note que qualquer alteração no relatório será considerada fraude.

  • Renomeie o arquivo de relatório para lab2.report antes de submeter no Classroom
  • O arquivo do relatório deve ser submetido até às 23:59 do dia 14/03/20244.