Testbenchs em VHDL
Posted on sex 09 novembro 2018 in vhdl • 19 min read
Em HDLs, é muito comum escrever-se um testbench para cada módulo que for desenvolvido. Dessa forma, os módulos podem ser testados antes da integração. Também é aconselhável escrever um testbench para o arquivo toplevel, ou seja, para o arquivo de integração, para garantir que esta foi realizada corretamente.
A principal função de um testbench é testar ou validar um módulo. Nesse sentido, um testbench nada mais é que um módulo VHDL que:
- Instancia o(s) módulo(s) a serem testados;
- Injeta sinais de entrada no(s) módulo(s) em teste;
- Verifica se a saída do(s) módulo(s) são as esperadas.
Normalmente o testbench não é projetado para ser sintetizável, o que libera o projetista para utilizar primitivas funcionais não sintetizáveis e não requer uma interface (entidade vazia), pois o objetivo não é modelar um hardware. A maneira mais comum de se montar um testbench é usando o modelo DUT (do inglês Device Under Test). Este modelo pode ser visto na figura abaixo:

A instância do(s) módulos(s) a serem testados é realizada através de um comando de instância de componente (palavra reservada component), da mesma maneira como é utilizada para implementar a modularização em sistemas digitais, quando um módulo utiliza vários outros módulos menores como componentes para formar um módulo maior. Um exemplo clássico é um contador, que utiliza vários flip-flops para formar uma estrutura contadora (nesse caso há um módulo flip-flop instanciado várias vezes e organizado na forma de um contador).
Assert
Para verificar os resultados, usa-se a palavra reservada assert, que tem o seguinte formato:
1 | |
A condicao pode ser qualquer uma que retorne um valor boolean, a mensagem_string é qualquer uma do tipo string e o nivel_de_severidade é uma das opções note, warning, error ou failure. É usual que a condição seja uma comparação. As duas últimas (mensagem e nível de severidade) podem ser omitidas, caso em que uma mensagem padrão será mostrada e a gravidade será error.
A mensagem será impressa caso a condição falhe, portanto deve ser algo que tenha sentido para o projetista. É possível mostrar o valor de sinais ou variáveis usando a propriedade image do tipo de dado que se quer mostrar. Essa propriedade é definida pelo próprio tipo de dado e retorna uma string legível que representa o valor (e.g. integer'image(123) retorna a string "123").
Quanto à severidade do erro, é uma dica para o simulador sobre a ação que ele deve tomar caso a condição falhe. O nível note não faz nada e só mostra a mensagem. O warning mostra a mensagem com destaque, mas não pára a simulação, portanto deve ser utilizada para mostrar erros não críticos. O error mostra a mensagem com um destaque maior e deve ser utilizado para erros que possam ocasionar mais erros na simulação ou erros críticos recuperáveis (o circuito não se comportou como o esperado mas pode voltar a se comportar). Este nível normalmente não pára a simulação, mas dependendo da implementação do simulador pode ocasionar problemas ou até mesmo a parada da simulação. Já o nível failure sempre pára a simulação e deve ser usado para erros críticos não recuperáveis.
A origem dos dados de entrada e saída, que serão usados respectivamente para injetar os sinais de entrada do módulo e para verificar se a saída é a esperada, pode ser feita de várias formas. As mais usuais e recomendadas são:
- Geradas programaticamente no próprio testbench em VHDL;
- Através de um vetor de testes embutido no testbench;
- Geradas externamente e lidas pelo testbench em VHDL.
Cobriremos cada um destes métodos neste post.
Exemplo: escrevendo o testbench programaticamente
Considere o módulo em VHDL de um contador universal, cuja entidade tem a seguinte declaração:
1 2 3 4 5 6 7 8 | |
Este contador é genérico, cujo módulo é calculado através do parâmetro chamado modulo, na ocasião da instanciação. é sensível à borda de subida, possui clear ativo baixo assíncrono, carga paralela síncrona, determinação do sentido de contagem (up=1 contagem crescente), e um enable que desabilita a contagem.
O testbench para este módulo começa declarando-se as bibliotecas que utilizaremos e a entidade vazia:
1 2 3 4 5 | |
Após a declaração da entidade, declaramos a arquitetura normalmente, como em um módulo VHDL qualquer. A delcaração completa pode ser vista abaixo, e a dissecaremos no decorrer deste post:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 | |
No preâmbulo da arquitetura, declarou-se o componente e os sinais necessários para ligá-lo. Ainda declarou-se duas constantes, que serão usadas posteriormente.
O primeiro bloco do testbench gera o sinal de clock necessário para alimentar o contador (contadores são circuitos sequenciais) e um sinal de suporte cujo único propósito é copiar o próprio sinal de saída do contador (saida), mas convertido para inteiro (saida é um bit_vector e saidai é um integer). Este sinal de suporte facilita a montagem do testbench pois podemos usá-lo para as comparações posteriormente sem precisar chamar as funções de conversão to_integer e unsigned toda vez que formos fazer uma comparação. Para geração do clock, usou-se uma atribuição com cláusula after, ou seja, a cada periodoClock/2 o sinal será invertido, gerando um clock de periodoClock (uma constante que vale 1ns e foi declarada no preâmbulo da arquitetura) e duty-cycle de 50%. Note que este tipo de declaração (after) não é sintetizável e serve somente para fins de temporização em simulação.
1 2 | |
O segundo bloco do testbench efetivamente instancia o DUT, que nesse caso é o contador. Note que o contador está ligado aos sinais criados no preâmbulo da arquitetura, incluindo o modulo (nesse caso uma constante que vale 256). A função deste bloco é somente esta: instanciar e ligar o DUT no testbench.
1 2 3 | |
O terceiro e último bloco é o gerador de estímulos para o DUT (por isso o nome st). É composto por apenas um process que injeta e verifica os sinais de entrada e saída, respectivamente. Vamos dissecá-lo em blocos novamente.
Esta parte declara o process e imprime uma mensagem incondicionalmente (normalmente as mensagens aparecerão na tela do simulador, no terminal ou no arquivo de saída da simulação). Note que o assert está verificando um valor constante false, portanto este assert sempre irá falhar, causando a impressão da mensagem "BOT" com severidade baixa (sem parar a simulação). BOT é um acrônimo para Begin Of Test, para indicar que o teste começou.
1 2 3 4 | |
Agora sim começamos a testar o contador. Nesta parte, colocamos o clear em zero, portanto o contador deve manter as saídas zeradas independentemente das demais entradas. Este teste não é ótimo, pois seria necessário testar todas as combinações de entradas mantendo-se o clear baixo para garantir uma cobertura total. Mas, é suficiente para os propósitos que desejamos testar, que é a saída em zero mesmo com borda do clock. Os wait esperam a borda de subida (rising_edge) e de descida (falling_edge) do clock antes de fazer a verificação, portanto garantimos que o contador recebeu uma de cada uma das bordas com a as condições do teste (nesse caso as entradas clr=0, load=0, up=1 e en=1). Se a saída não for zero, o assert irá mostrar a mensagem de falha com o valor da saída, e também irá parar a simulação (severidade failure).
1 2 3 4 5 6 7 | |
O teste anterior é simples pois só testa se a saída se mantém em zero. No teste seguinte, mostrado abaixo, mudamos os valores atribuídos às entradas para configurar a contagem crescente do contador, simulando uma operação normal em contagem crescente. Para cada valor do loop, verificamos se a saída condiz com o valor esperado (note que a saída inicial é zero pois passamos no teste anterior) e aguardamos uma borda de descida do clock, garantindo que o contador avançou para o próximo valor esperado, que será verificado na próxima iteração do loop.
1 2 3 4 5 6 7 8 9 10 | |
Ao final do teste anterior, o valor da contagem é o máximo possível +1 (última iteração do teste anterior). Aproveitamos para testar o overflow, ou seja, a saída deve ser zero novamente. Também aproveitou-se para inverter o sentido de contagem e verificar o underflow, ou seja, a partir do valor máximo +1, se contarmos decrescente o valor deve ser o máximo. Os nomes overflow e underflow foram usados pelo projetista mas tem significados distintos do utilizado neste contexto (i.e. quando lidando com números inteiros ou ponto flutuante esta nomenclatura não é usada para indicar esta transição).
1 2 3 4 | |
Este próximo bloco de testes é idêntico ao bloco onde testou-se a contagem, mas dessa vez decrescente pois a última configuração das entradas (para o teste de underflow) deixou o contador configurado desta maneira. Novamente aproveita-se o último teste sabendo que o contador parte do valor máximo possível.
1 2 3 4 5 6 7 8 9 | |
Ainda falta testar a carga paralela. Neste bloco, o projetista resolveu testar a carga máxima (entrada toda em 1) e mínima (entrada toda em 0). Obviamente este teste não garante uma boa cobertura, mas é suficiente para os propósitos deste exemplo.
1 2 3 4 5 6 7 | |
Por último, testou-se a contagem por três ciclos de clock, com o enable desativado. O contador terminou o último teste com uma carga de zero, portanto este valor deve-se manter na saída durante todos os três ciclos de clock.
1 2 3 4 5 6 7 8 | |
Este último pedaço não é um teste em si mas tem duas funções. A primeira é mostrar uma mensagem de fim de teste (End Of Test) e a segunda é terminar o processo de geração e verificação de estímulos. Isso é feito através do wait incondicional, que suspende indefinidamente o process do ponto de vista do simulador, indicando que este process já realizou o trabalho que deveria.
1 2 3 4 | |
Exemplo: vetor de testes no código
Este exemplo testa um comparador de 12 bits cuja entidade é:
1 2 3 4 5 6 | |
Os procedimentos para instanciar e ligar o DUT ao testbench são os mesmos, portanto os omitiremos. A principal diferença está no processo gerador de estímulos. No exemplo anterior, os estímulos eram gerados programaticamente, um a um. Neste caso, o process apenas percorre uma estrutura contendo os vetores de teste, injetando as entradas e comparando as saídas com as do vetor. Os valores no vetor de testes devem ser previamente gerados (e.g. através de software, simulação ou manualmente).
No preâmbulo do process, declara-se um novo tipo (pattern_type) baseado no registro (record), que irá conter os valores do vetor de testes. Este registro representa um teste auto-contido, portanto deve conter as entradas e todas as saídas esperadas para estas entradas. Logo após a declaração do tipo do vetor de testes, declara-se o tipo do vetor em si (pattern_array), seguido do vetor (patterns) propriamente dito. O vetor foi declarado como contante pois ele não deve ser modificado durante os testes.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | |
Note que o vetor é composto por 7 testes distintos, cada um com duas entradas e as três saídas possíveis.
Com o vetor de testes declarado e preenchido, o teste é simples: iterar sobre o vetor injetando as entradas e verificando as saídas para cada um dos testes, até exauri-los. O restante do process que faz isso pode ser visto abaixo.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | |
Exemplo: lendo os casos de teste de um arquivo externo
Neste exemplo, vamos mostrar como ler os casos de teste de um arquivo externo. A primeira coisa a se fazer é gerar os dados de teste. A entidade que testaremos é uma ALU (Arithmetic and Logic Unit, ou ULA, Unidade Lógica e Aritmética), cuja declaração da entidade pode ser vista abaixo.
1 2 3 4 5 6 7 8 9 | |
A função realizada é definida pela entrada S, sendo 0000 AND, 0001 OR, 0010 soma A+B, 0110 subtração A-B, 0111 saída alta se A<B e baixa caso contrário, e 1100 NOR. Para gerar os casos de teste, escrevi um script em Python que gera alguns casos considerados importantes e depois 100 entradas aleatórias A e B, calculando a saída esperada para cada uma das seis operações que a ALU pode realizar. O script pode ser visto abaixo.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | |
Com este script, gerei um arquivo contendo oito valores binários de 64 bits em cada linha, sendo A, B e os seis resultados esperados, em ordem e separados por espaço. Para gerar, basta executar o script com através de um interpretador Python (testado com a versão 2.7).
1 | |
Um exemplo de uma linha deste arquivo é (note que é UMA linha):
1 | |
Agora que temos o arquivo com os vetores de teste, podemos usar o vetor dentro do testbench. Os procedimentos para instanciar a ALU como DUT são idênticos ao exemplo anterior, portanto pularei este passo.
A primeira mudança necessária é incluir a declaração de uso da biblioteca textio, que é utilizada justamente para ler arquivos. Esta declaração deve ser colocada no preâmbulo do arquivo VHDL que descreve o testbench. É importante notar que esta biblioteca não é sintetizável, portanto se o seu código usar a textio há grandes chances de ele não ser sintetizado (há uma exceção para a carga do conteúdo inicial de memórias, que explorarei em outro artigo). De modo geral, utilize esta biblioteca somente em testbench.
1 2 | |
Com a declaração de uso da biblioteca, podemos utilizar as funções de acesso a arquivos. Isso é feito no preâmbulo do processo que gera os estímulos (que nesse caso não gerará propriamente, mas sim lerá os casos de um arquivo gerado previamente).
1 2 3 4 5 | |
A declaração tb_file é a principal, que efetivamente instancia o arquivo especificado como um objeto dentro do ambiente de simulação. Neste caso, o arquivo foi aberto somente para leitura, mas é possível também escrever em um arquivo (não explorarei esta característica neste exemplo, mas ela pode ser útil para gravar os resultados em um arquivo externo). As variáveis tb_line e space são usadas para ler o arquivo linha a linha, e também para ler o caracter que separa os oito valores em uma linha (poderia ser qualquer caractere, basta que seja apenas um).
O centro de uma verificação baseada em arquivo é um laço que percorre todo o arquivo lendo-o linha por linha. A cada linha, deve-se ler os valores das entradas e injetá-las no DUT:
1 2 3 4 5 6 7 8 | |
Note que há a leitura da linha (readline), seguida pela leitura de um bit_vector de 64 bits (pois o sinal Av foi declarado como tal), e a injeção deste vetor no sinal A ligado ao DUT. Repete-se o mesmo para o sinal B, porém ao invés de lermos outra linha, lemos um caractere (o espaço), e outro vetor de 64 bits e, Bv para injetarmos em B. As leituras do read são posicionais, ou seja, ele sempre lerá a quantidade de caracteres necessária para preencher o receptor da leitura. Note que o caractere lido poderia ser qualquer coisa, o nome space é apenas um identificador. Note também que não usamos o valor lido neste sinal para nada, ele foi declarado com o único propósito de ler um caractere entre os vetores.
Com os valores das entradas injetados, devemos verificar a saída para cada operação. Sabemos que os próximos valores na linha são vetores de 64 bits correspondentes às saídas para todas as operações da ULA. A primeira operação é o AND, portanto devemos configurar a ULA para isso (S=0000) e comparar a sua saída com o valor lido do vetor de testes:
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
Em ordem, fizemos neste último teste: leitura do caractere separador dos vetores, leitura do valor esperado (armazenado em res), configuração do DUT para fazer a operação esperada (S=0000), espera para o DUT produzir a saída e finalmente a asserção de que o valor correto foi produzido. A função equalSignedBitvector retorna verdadeiro se F=res e foi usada pois F é do tipo signed e res do tipo bit_vector, uma comparação não padrão. A função pode ser vista no final desta seção.
Este padrão repete-se para todas as funções do DUT. O restante do arquivo de testes pode ser visto abaixo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | |
O laço será repetido até que o arquivo acabe, ou seja, não há mais linhas para serem lidas. Para cada linha, o DUT é testado várias vezes, para cada função.
No geral, este método é útil quando não se pode testar todas as entradas possíveis para um determinado módulo, ou quando testamos a idéia do hardware modelando-a através de uma prova de conceito em software. No primeiro caso, quando é inviável testar todas as entradas possíveis, gera-se valores aleatórios de forma a garantir uma cobertura mínima dos testes. Um exemplo comum do segundo caso, quando modela-se em software primeiro, é a criptografia. Começa-se testando a idéia matematicamente, depois faz-se uma implementação em software onde pode-se testar o desempenho e a segurança do algoritmo (e da implementação), e só depois implementa-se um hardware (e nem sempre todo o algoritmo é vantajoso em hardware). Neste caso, o motivo principal é que temos uma implementação de referência em software que confiamos estar correta (chamada de golden model ou reference model). Os valores para testar o hardware descrito podem ser facilmente retirados do software instrumentando-o de maneira que as entradas e saídas das partes desejadas (e.g. funções) sejam gravadas em um arquivo. Este arquivo pode então ser lido pelo testbench e usado como verificação.
Função de comparação usada no exemplo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Parando uma simulação baseada em eventos
Em ambos os exemplos, a parada da simulação é efetivada pelo wait incondicional no final do process, que suspende-o definitivamente dentro do escalonador de eventos do simulador. Quando todos os process estiverem suspensos indefinidamente, a simulação termina após a estabilização dos sinais combinatórios pois não há mais como nenhum sinal mudar de valor, portanto não há mais o que simular.
Contudo, há um problema: ainda estamos gerando o sinal de clock. Em quase todos os simuladores baseados em eventos, o simples fato de existir um sinal periódico sendo gerado faz com que a simulação seja executada indefinidamente. Para resolver o problema, podemos criar um sinal que habilita ou não o clock, substituindo a linha de geração por uma versão contendo um sinal controlador, como abaixo:
1 | |
O sinal simulando serve para controlar a geração do clock. Se ele for alto, o clock é gerado normalmente, caso contrário ele permanecerá baixo devido ao AND inserido. O sinal deve ser declarado no preâmbulo da arquitetura como um sinal de um bit. No começo do process (em qualquer lugar, i.e. após o begin), adicionamos a seguinte linha:
1 | |
E ao final do process (antes do wait incondicional), a seguinte:
1 | |
Isso irá parar a geração do clock, permitindo que o wait incondicional pare o simulador. Este procedimento não é necessário em todos os simuladores, mas é necessário em todos os que utilizamos nas aulas de graduação, portanto utilize-o.