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.