Implementación de un procesador RISC-V desde cero 
A lo largo de este post se abordará el diseño y la implementación desde cero de un procesador RISC-V básico (repertorio de instrucciones RV32I, sin extensiones), sintetizable en una FPGA de rango medio-bajo y capaz de ejecutar código generado por un compilador.

Introducción

Una arquitectura de juego de instrucciones (ISA) es una especificación de un juegos de instrucciones tanto a nivel técnico (el repertorio en sí) como a nivel funcional (que hace cada instrucción, los registros afectados, etc.). Existen varias ISAs abiertas y libres, pero pocas con un crecimiento tan grande en los últimos años como RISC-V. De hecho, ya hay fabricantes ofreciendo SoCs RISC-V de varios núcleos y con MMUs que permiten ejecutar Linux de forma segura. La ISA base de RISC-V consta de un repertorio de instrucciones muy pequeño, relativamente fácil de implementar y con muy buen soporte tanto en GCC como en LLVM. El principal atractivo de esta ISA es el hecho de que cualquiera puede fabricar un SoC, una MCU o un procesador RISC-V sin tener que pagar regalías a ninguna empresa u organización.

Objetivo

El objetivo, que se plantea como una "prueba de concepto", es la implementación de un procesador RISC-V básico desde cero, en VHDL y usando código sintetizable de tipo RTL. Una vez esté la implementación terminada, se probará la CPU con código generado por el compilador GCC.

Una CPU muy sencilla

Se plantea el repertorio de instrucciones básico (el denominado "rv32i"), sin ningún tipo de extensión. Dicho repertorio consta de 39 instrucciones "de usuario" y varias instrucciones adicionales denominadas "privilegiadas" o "protegidas", destinadas principalmente al manejo de CSRs. En la implementación realizada no se han definido CSRs, ya que se trata de una CPU sin interrupciones, sin MMU y cuyo único "periférico" será un simple pin de salida (GPIO) para encender un led.

Controlador de memoria

En el RISC-V el bus de direcciones es de 32 bits y, aunque el bus de datos es de 32 bits, se pueden realizar accesos a memoria de palabra (32 bits), media palabra (16 bits) y byte (8 bits). Se plantea la realización de un pequeño controlador de memoria que abstraiga los detalles de implementación de la RAM usada, de tal manera que cuando el procesador desee leer desde la RAM (ya sea para leer una instrucción o para leer un dato) o escribir en la RAM (escribir un dato), se lo pida al controlador de memoria y éste avise cuando la operación se haya terminado.



El procedimiento para leer un dato desde la RAM es el siguiente:

1. En la entrada AddresIn (32 bits) se pone la dirección a la que se desea acceder.

2. En la entrada DataIn (32 bits) se pone el dato en caso de que se quiera escribir. Si se quiere escribir media palabra sólo se tendrán en cuenta los 16 bits menos significativos y si se quiere escribir un byte sólo se tendrán en cuenta los 8 bits menos significativos. Esta entrada se ignora en caso de que la operación sea de lectura.

3. En la entrada WidthIn se indica la anchura de trabajo: 0 para un byte, 1 para media palabra (16 bits) y 2 o 3 para una palabra de 32 bits.

4. En la entrada ExtendSignIn se indica si, a la hora de leer datos de 16 bits (media palabra) o de 8 bits (1 byte) se debe extender el signo en los bits no leidos desde la memoria. Esta entrada se ignora en caso de que la operación sea de escritura.

5. En la entrada WEIn se indica si queremos leer (0) o escribir (1).

6. En la entrada StartIn se pone un 1 para iniciar el proceso de lectura o escritura. El circuito es síncrono, por lo que el proceso empezará en el siguiente ciclo de reloj.

Tras configurar todos los pines de entrada del controlador de memoria y poner StartIn a 1, hay que esperar a que el pin ReadyOut se ponga a 1. Cuando ReadyOut se pone a 1 significa que la operación ha terminado, esto es:

- En caso de que haya sido una operación de lectura (WEIn = 0), significará que el dato que queríamos leer estará disponible en los pines DataOut del controlador de memoria (si hemos pedido leer datos de 16 o de 8 bits en DataOut aparecerá el signo extendido en caso de que lo hayamos indicado en el pin de entrada ExtendSignIn).

- En caso de que haya sido una operación de escritura (WEIn = 1), significará que el dato que queríamos escribir (DataIn) ya está alojado en la RAM.

El controlador de memoria actúa como interfaz entre el núcleo RISC-V y cualquier memoria que queramos ponerle. En este caso se ha usado la propia memoria de la FPGA pero con esta interfaz nada impide usar una SDRAM, alguna PSRAM externa o cualquier otro tipo de memoria que queramos: sólo hay que cambiar el controlador de memoria, el núcleo de la CPU no cambia.



Se definen tres ficheros VHDL:

ROM.vhd, que alberga una imagen de una ROM simulada de 4 Kbytes mapeada en 4096 posiciones de 8 bits cada una (bus de direcciones de 12 bits y bus de datos de 8 bits). Esta ROM contiene el código a ejecutar.

RAM.vhd, que alberga una definición estándar de RAM (https://www.doulos.com/knowhow/vhdl/simple-ram-model/) de 4 Kbytes mapeada también en 4096 posiciones de 8 bits cada una (bus de direcciones de 12 bits y bus de datos de 8 bits). Mediante esta implementación, todos los entornos de desarrollo de fabricantes de FPGAs infieren que quieres usar la RAM interna de la FPGA y la habilitan para ello (no gastan unidades lógicas).

Memory.vhd, que alberga la ROM y la RAM en un único bloque de memoria de 8 Kbytes (bus de direcciones de 13 bits y bus de datos de 8 bits). Los primeros 4 Kbytes son la ROM y los siguientes 4 KBytes con la RAM. En este módulo se define también un byte de GPIO que se solapa con la primera dirección de la RAM (posición 0 de la RAM, posición 4096 de la memoria total). El bit 0 de este byte GPIO está conectado directamente a un pin de salida.

MemoryController.vhd: El controlador de memoria en sí, que alberga un componente Memory (que a su vez alberga la ROM y la RAM).

Repertorio de instrucciones RV32I

Al conjunto de instrucciones básicas que debe tener cualquier procesador RISC-V de 32 bits se le denomina repertorio "base" o repertorio "RV32I". Son 39 instrucciones altamente ortogonales, muy sencillas de implementar y que siguen el paradigma RISC: instrucciones de manipulación de datos separadas de las instrucciones de acceso a la memoria.

En el documento denominado "green card" se especifica tanto el repertorio base RV32I como las extensiones estándar (M de multiplicación, A de atómicas, C de comprimidas). Nosotros sólo implementaremos el repertorio base RV32I.



Como se puede apreciar, las instrucciones de manejo de registros incluyen campos de 5 bits ($2^5 = 32$) para indicar los registros origen y destino de cada operación.

Registros de la CPU

La CPU RISC-V consta de 32 registros de 32 bits del x0 al x31. El registro x0 no es escribible y cuando se lee siempre alberga un 0.

De cara a escribir el código VHDL que sea RTL y para evitar estar definiendo 32 registros con sus correspondientes multiplexores uno por uno, usamos tipos array y bloques de tipo "generate" en VHDL. Definimos un tipo array para los 32 registros de 32 bits y definimos dos señales RegisterD y RegisterQ de este tipo:

    ...
    type WordArray32 is array(0 to 31) of std_logic_vector(31 downto 0);
    signal RegisterD : WordArray32;
    signal RegisterQ : WordArray32;
    ...

Implementamos los biestables de forma normal:

    ...
    process (Clk)
    begin
        if ((Clk = '1') and Clk'event) then
            for i in 0 to 31 loop
                RegisterQ(i) <= RegisterD(i);
            end loop;
            IRQ <= IRD;
            PCQ <= PCD;
            FSMQ <= FSMD;
            CounterQ <= CounterD;
        end if;
    end process;
    ...

Y definimos los multiplexores y la lógica que los activa mediante bloques "generate":

    ...
    -- RegSelForALUOut: 1..31 (R1..R31)
    RegisterD(0) <= (others => '0');          -- r0 cannot be altered, always zero value  
    Gen1: for I in 1 to 31 generate
        RegisterD(I) <= ALUOut when (DecodedMuxReg(I) = '1') else
                        RegisterQ(I);
    end generate;
    Gen1X: for I in 0 to 31 generate
        DecodedMuxReg(I) <= '1' when (I = to_integer(unsigned(RegSelForALUOut))) else
                            '0';
    end generate;
    ...

Este código genera un circuito combinacional compuesto por un decodificador (5 bits de entrada y 32 bits de salida) combinado con 31 multiplexores asociados a sus registros correspondientes:



Las entradas D (RegisterD) de los biestables de los registros provienen, o bien de la salida de la ALU (ALUOut), o bien de la salida Q (RegisterQ) de los mismos biestables (para mantener el valor). Se define un bus RegSelForALUOut de 5 bits que permite definir qué registro de los 31 disponibles recibe el dato de la ALU (el resto mantienen los datos que ya albergan). Como se puede apreciar en caso de que RegSelForALUOut valga "00000" no se hace nada, esto es compatible con el comportamiento deseado, puesto que el registro x0 no es escribible y siempre alberga un 0.

Además de los 32 registros indicados, se encuentran los registros de contador de programa (PC), registro de instrucción (IR) y "Contador". Este último registro es de 5 bits y se utiliza para llevar la cuenta en las operaciones de desplazamiento de bits.

ALU

La ALU se define de forma tabular y puramente combinacional.

    ...
    ALUOut <= std_logic_vector(signed(ALUIn1) + signed(ALUIn2)) when (ALUSel = ALU_SEL_ADD) else
              std_logic_vector(signed(ALUIn1) - signed(ALUIn2)) when (ALUSel = ALU_SEL_SUB) else
              (ALUIn1 xor ALUIn2)                               when (ALUSel = ALU_SEL_XOR) else
              (ALUIn1 or ALUIn2)                                when (ALUSel = ALU_SEL_OR)  else
              (ALUIn1 and ALUIn2)                               when (ALUSel = ALU_SEL_AND) else
              (ALUIn1(30 downto 0) & '0')                       when (ALUSel = ALU_SEL_SLL) else
              ('0' & ALUIn1(31 downto 1))                       when (ALUSel = ALU_SEL_SRL) else
              (ALUIn1(31) & ALUIn1(31 downto 1))                when (ALUSel = ALU_SEL_SRA) else
              std_logic_vector(to_signed(1, 32))                when (ALUSel = ALU_SEL_LT) and (signed(ALUIn1) < signed(ALUIn2)) else
              std_logic_vector(to_signed(0, 32))                when (ALUSel = ALU_SEL_LT) and (signed(ALUIn1) >= signed(ALUIn2)) else
              std_logic_vector(to_signed(1, 32))                when (ALUSel = ALU_SEL_LTU) and (unsigned(ALUIn1) < unsigned(ALUIn2)) else
              std_logic_vector(to_signed(0, 32))                when (ALUSel = ALU_SEL_LTU) and (unsigned(ALUIn1) >= unsigned(ALUIn2)) else
              std_logic_vector(to_signed(1, 32))                when (ALUSel = ALU_SEL_EQ) and (ALUIn1 = ALUIn2) else
              std_logic_vector(to_signed(0, 32))                when (ALUSel = ALU_SEL_EQ) and (ALUIn1 /= ALUIn2) else
              std_logic_vector(to_signed(1, 32))                when (ALUSel = ALU_SEL_GE) and (signed(ALUIn1) >= signed(ALUIn2)) else
              std_logic_vector(to_signed(0, 32))                when (ALUSel = ALU_SEL_GE) and (signed(ALUIn1) < signed(ALUIn2)) else
              std_logic_vector(to_signed(1, 32))                when (ALUSel = ALU_SEL_GEU) and (unsigned(ALUIn1) >= unsigned(ALUIn2)) else
              std_logic_vector(to_signed(0, 32))                when (ALUSel = ALU_SEL_GEU) and (unsigned(ALUIn1) < unsigned(ALUIn2)) else
              std_logic_vector(to_signed(1, 32))                when (ALUSel = ALU_SEL_NE) and (ALUIn1 /= ALUIn2) else
              std_logic_vector(to_signed(0, 32))                when (ALUSel = ALU_SEL_NE) and (ALUIn1 = ALUIn2) else
              ALUIn1;   -- default operation, identity
    ...

Como se puede observar, las operaciones de desplazamiento de bits se implementan en la ALU en forma de desplazamiento de un bit. Lo que se ha hecho en esta implementación es, para evitar consumir demasiadas LUTs en la FPGA, hacer que, en las operaciones de desplazamiento de varios bits, sea la máquina de estados la que use el desplazamiento de 1 bit varias veces hasta conseguir el desplazamiento deseado: El código VHDL se vuelve algo más complejo y se usan tantos ciclos de reloj como bits a desplazar, pero se ahorran recursos LUT en la FPGA.

Multiplexores y registros especiales

Los multiplexores incluidos en esta implementación del RISC-V pueden dividirse en tres grupos:

- Los multiplexores que controlan las señales de entrada a la ALU (ALUIn1, ALUIn2 y ALUSel) y de salida a los registros (RegSelForALUOut).

- Los multiplexores que controlan las señales de entrada al controlador de memoria (MCWidthIn, MCAddressIn, MCStartIn, MCWEIn, MCExtendSignIn y MCDataIn).

- Y los multiplexores que controlan los registros especiales: PC, IR y Counter.

Los multiplexores que controlan las señales de entrada a la ALU serían los siguientes:



A continuación tenemos los multiplexores asociados a las entradas al controlador de memoria:



Y por último tenemos los tres multiplexores asociados a sus respectivos registros especiales: PC, IR y Counter (el registro usado para llevar la cuenta en las operaciones de desplazamiento de bits de más de 1 bit):



Como se verá a continuación, en el apartado "Máquina de estados", las entradas de selección de todos estos multiplexores están gobernadas por los diferentes estados de la máquina de estados del procesador.

Máquina de estados

Uno de los conceptos que siempre hay que tener presente a la hora de diseñar circuitos secuenciales es el concepto de "carga retrasada" ("delayed load") ya que, por la propia naturaleza de los circuitos secuenciales síncronos, los cambios en un estado no tienen lugar hasta el siguiente ciclo de reloj. Veámoslo con un ejemplo: consideremos que tenemos dos registros A y B, con sus respectivos multiplexores a su entrada. MUX_A permite seleccionar qué dato se carga en el registro A, mientras que MUX_B permite seleccionar qué dato se carga en el registro B. Consideremos, además, la siguiente máquina de estados:



Cuando la máquina de estados está en el estado 1, el multiplexor del registro A (MUX_A) está seleccionando la X, pero en el registro A aún no se habrá cargado dicho valor X, lo hará en el siguiente ciclo de reloj. Lo mismo ocurrirá con el registro B: si estamos en el estado 1 y se cumple la condición P=Q, en el siguiente ciclo de reloj pasaremos al estado 2, pero en este ciclo de reloj aún no se cargará la Y en B. Es, una vez en el estado 2 (donde se selecciona en el MUX_B la entrada Y) y después del siguiente ciclo de reloj (pasando del estado 2 al 3), cuando se cargue la Y en B.

Una vez refrescado el concepto de "carga retrasada" podemos ver la máquina de estados de nuestro RISC-V, que consta de 23 estados.

- Máquina de estados: Nodos iniciales: Son los nodos encargados del reset, de la búsqueda de la instrucción en la posición de memoria apuntada por el PC y de la carga de dicha instrucción en el registro IR.



En esta parte de la máquina de estados se puede ver que, al ponerse la entrada Reset en nivel alto, se pasa al estado 0 y en este estado se selecciona la entrada RESET_VECTOR del multiplexor MUX_PC. Esto hará que, en el siguiente ciclo de reloj, se cargue en el registro PC el valor RESET_VECTOR. Cuando la entrada Reset pasa a valor 0, en el siguiente ciclo de reloj, la máquina de estados pasará al estado 1 y en dicho estado se selecciona la entrada PC del multiplexor MUX_MCAddrIn, se selecciona la entrada a 2 del multiplexor MUX_MCWidthIn y se selecciona la entrada a 1 del multiplexor MUX_MCStartIn. Esto provocará que a partir del siguiente ciclo de reloj se inicie un proceso de lectura de 4 bytes de memoria en el controlador de memoria a partir de la dirección apuntada por el PC. El proceso de lectura terminará cuando MCReadyOut valga 1, y cuando esto ocurra, en el siguiente ciclo de reloj, la máquina de estados pasará al estado 2. En este estado 2 el multiplexor MUX_IR seleccionará como entrada MCDataOut (el dato recién leído), por lo que, con otro ciclo más de reloj (al pasar del estado 2 al 3), tendremos la instrucción recién leída de memoria cargada en el registro IR.

- Máquina de estados: Nodos de ejecución de instrucciones aritméticas y lógicas: Se encargan tanto de las instrucciones de un solo ciclo como de las instrucciones de N ciclos.



Aquí se puede ver como se discrimina entre operaciones de desplazamiento de bits y resto de operaciones ya que en el caso de desplazamiento de bits necesitaremos un ciclo de reloj adicional por cada bit que queramos desplazar. El resto de operaciones se ejecutan en un único ciclo de reloj.

- Máquina de estados: Nodos de ejecución de instrucciones de carga:



- Máquina de estados: Nodos de ejecución de instrucciones de almacenamiento:



- Máquina de estados: Nodos de ejecución de instrucciones de bifurcación:



- Máquina de estados: Nodos de ejecución de instrucciones de salto:



- Máquina de estados: Nodos de ejecución de instrucciones especiales:





Nótese que la instrucción "ebreak" se implementa de forma incorrecta a propósito. Es una instrucción que no es generada por el compilador (ni "ebreak" ni "ecall") y se usa para entornos con depuración y/o sistema operativo. En este caso decidí hacer que la instrucción "ebreak" provocase un paro en el procesador.

RISC-V final

Ya tenemos el procesador RISC-V operativo (sólo la implementación base RV32I, sin extensiones). Ahora falta probarlo con código real. Realizaremos el siguiente proceso de compilación en dos pasos:



Vamos a hacer un pequeño programa en C++ (un blinker), lo compilaremos con el compilador cruzado (target "riscv32-none-elf") y la salida binaria del código RISC-V del compilador la usaremos para generar el fichero ROM.vhd que servirá, a su vez, de entrada para el compilador VHDL.

Como compilador VHDL usaremos GHDL en una primera iteración, ya que nos permite hacer una simulación y generar un fichero con todas las señales que podemos visualizar mediante el software GtkWave. Una vez comprobemos que todo sale bien, en una segunda iteración, crearemos un proyecto en un entorno de programación VHDL de un fabricante de FPGAs (en nuestro caso Quartus 13, de Intel) y lo probaremos todo sobre una FPGA real (en nuestro caso una FPGA Intel Cyclone II).

Linker script personalizado

En un post anterior de este blog se describe cómo compilar e instalar el compilador GCC cruzado para arquitectura RISC-V. Partiendo de ese post, creamos un sencillo linker script que nos permitirá compilar código para nuestro nuevo procesador. Recordemos que tenemos un espacio total de 8 Kbytes (8192 bytes) repartidos en 4096 bytes de ROM más 4096 bytes de RAM. Además el bit 0 del primer byte de la RAM se comparte con un pin de salida a modo de GPIO, por lo que podemos establecer el siguiente mapa de memoria de nuestro procesador:

1. La ROM abarca 4096 bytes desde la dirección 0x0000 (la constante RESET_VECTOR definida en RISCV.vhd) hasta la dirección 0x0FFF.

2. El pin GPIO ocupa un solo byte en la dirección 0x1000 (sólo el bit 0).

3. La RAM abarca 4095 bytes desde la dirección 0x1001 hasta la dirección 0x1FFF.

Con esta premisa podemos hacer un sencillo linker script en el que, por ahora y por simplicidad, obviamos la parte de inicialización de variables globales:

/*
 * ROM  from 0x00000000 to 0x00000FFF
 * GPIO from 0x00001000 to 0x00001003
 * RAM  from 0x00001004 to 0x00001FFF
 */
SECTIONS {
    . = 0x00000000 ;
    .text : {
        startup.o (.startup)
        *(.text)
        *(.text.*)
        *(.rodata*)
    }
    . = 0x00001004 ;
    .data : {
        *(.data)
        *(.data.*)
    }
}

En este linker script obligamos a que el código de la sección ".startup" se aloje en el vector de reset (dirección 0, RESET_VECTOR al principio de la ROM), y el resto de código vaya a continuación. A continuación escribimos el fichero "startup.cc":

#include <stdint.h>

using namespace std;

extern int main();

void _startup() __attribute__((section(".startup"), naked));   // startup located at RESET_VECTOR

void _startup() {
    asm volatile (
        "la sp, 0x00001FFC"     // point SP to the end of SRAM (4 Kb ROM + 4 Kb RAM = 8 Kb total)
    );
    main();
    while (true)
        ;
}

En el que definimos una función "_startup", que alojamos en la sección ".startup" y declaramos "naked" para que el compilador no genere código preámbulo ni post-ámbulo. Dentro de esa función "_startup()" inicializamos el puntero de pila al final de la RAM e invocamos a la función "main" (declarada externa). Ahora ya podemos hacer nuestro "main.cc":

#include <stdint.h>

using namespace std;

#define  GPIO  *((uint8_t *) 0x00001000)

int main() {
    GPIO = 0;
    while (true) {
        for (int i = 0; i < 5; i++)
            ;
        GPIO = GPIO ^ 1;
    }
}

Donde definimos la función "main" y el resto del código, ahora sí, de la manera usual.

Se trata de un sencillo blinker en el que el bucle de espera es de sólo 5 iteraciones (lo hacemos así para poder depurarlo cómodamente con GHDL y GtkWave). A continuación compilamos el código con el compilador cruzado de RISC-V (hacemos "make"). Si desensamblamos el .elf generado mediante la utilidad "riscv32-none-elf-objdump" podremos ver el código ensamblador generado por el GCC:

# /opt/baremetalriscv/bin/riscv32-none-elf-objdump -M no-aliases,numeric -D main.elf

main.elf:     file format elf32-littleriscv


Disassembly of section .text:

00000000 <_Z8_startupv>:
   0:	00002137          	lui	x2,0x2
   4:	ffc10113          	addi	x2,x2,-4 # 1ffc 
   8:	008000ef          	jal	x1,10 
c: 0000006f jal x0,c <_Z8_startupv+0xc> 00000010
: 10: fe010113 addi x2,x2,-32 14: 00812e23 sw x8,28(x2) ...

Como se puede apreciar, el código empieza en la dirección 0, que es nuestro vector de reset. El Makefile también genera un fichero "main.bin" con el volcado binario del contenido que debe tener la ROM de programa de nuestro RISC-V. En el código fuente del proyecto se suministra un script de bash que permite convertir ficheros .bin a código VHDL apto para ser insertado en ROM.vhd.

Una vez generado el ROM.vhd (a partir del "main.bin" que generó, a su vez, el compilador C++), compilamos todo el VHDL con GHDL y analizamos con GtkWave la salida de señales generada (RISCV_tb.ghw).



Análisis de la ejecución

Como se puede apreciar, el compilador ha alojado la variable "i" en la dirección de memoria 4072 de la RAM (dirección absoluta 4096 + 4072 = 8168). El procesador realiza el conteo de 0 a 5 sobre esa variable, al llegar a 5, la salida GPIO cambia de valor y vuelta a empezar.

Si hacemos zoom podemos ver el proceso de ejecución instrucción a instrucción, por ejemplo, veamos las primeras 5 instrucciones del código de ejemplo:

00000000 <_Z8_startupv>:
   0:	00002137          	lui	x2,0x2
   4:	ffc10113          	addi	x2,x2,-4 # 1ffc 
   8:	008000ef          	jal	x1,10 
c: 0000006f jal x0,c <_Z8_startupv+0xc> 00000010
: 10: fe010113 addi x2,x2,-32 14: 00812e23 sw x8,28(x2) ...




Cada vez que avanza el contador de programa (señal "pcq"), se inicia un proceso de lectura en el controlador de memoria ("mcstartin") que culmina con un "mcreadyout" = 1. En ese momento se carga en el registro de instrucción ("irq") la instrucción de 32 bits que se acaba de leer. Tras este proceso de lectura de la instrucción se ejecuta la instrucción en sí:

- Dirección de memoria 0x00000000, instrucción "lui x2, 2" (irq = 00002137), carga un 2 en los 20 bits más significativos del registro x2. Avanza el PC 4 bytes.

- Dirección de memoria 0x00000004, instrucción "addi x2,x2,-4" (irq = ffc10113), resta 4 al registro x2. Avanza el PC 4 bytes.

- Dirección de memoria 0x00000008, instrucción "jal x1, 10" (irq = 008000ef), guarda en x1 la dirección de la siguiente instrucción (0x0000000C) y salta a la dirección 0x00000010. Nótese como el contador de programa pasa de 0x00000008 a 0x00000010.

- Y así con el resto de instrucciones.

Implementación en una FPGA

A continuación, viendo que en un entorno simulado (GHDL), el RISC-V funciona bien, podemos pasar a la segunda iteración, que es implementarlo todo en una FPGA real. Como nos hemos molestado en generar un código totalmente RTL (con registros biestables y lógica combinacional bien diferenciados en cada entidad VHDL) y, además, para el código RAM.vhd hemos seguido la recomendación de todos los fabricantes para el uso de bloques RAM internos de la FPGA, lo más probable es que no tengamos problemas.

En nuestro caso, llevando el código directamente al Quartus 13 para implementarlo en una FPGA Intel Cyclone II (es una FPGA antigua) todo compila sin problemas, la RAM es inferida de forma correcta (el entorno asigna bloques de RAM de la FPGA para nuestra RAM) y se usa aproximadamente un 66% de los recursos de la FPGA, lo que no está nada mal para una FPGA lanzada en 2004, hoy considerada antigua y de rango bajo.

La única modificación que se hace es en el código C++, que, en lugar de iterar hasta 5, hay que iterar hasta 250000 o valores de esa magnitud para que el parpadeo del led sea perceptible:



Dependencias

Para compilar el código VHDL sólo es necesario GHDL y para la visualización de la simulación generada por GHDL recomiendo usar GtkWave. Para compilar el código C++ sirve cualquier GCC cruzado para el target "riscv32-none-elf" (puedes compilar el tuyo propio siguiendo las indicaciones que puse hace tiempo en este mismo blog).

Para implementar el RISC-V en una FPGA se necesita el entorno de desarrollo del fabricante o alguna toolchain libre que sea compatible con tu FPGA. El código VHDL es RTL por lo que no debería haber problemas con ningún entorno.

Todo el código fuente (tanto VHDL como C++) está disponible en la sección soft.

[ añadir comentario ] ( 193 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 222 )
Receptor de radio superheterodino basado en FPGA 
Un receptor de radio superheterodino es un receptor de radio que realiza un proceso de mezcla de frecuencias para convertir la señal de la antena a una frecuencia fija fácilmente procesable por los circuitos de demodulación, siendo la gran mayoría de los receptores de radio actuales de este tipo. En este pequeño proyecto se aborda la implementación de un receptor de radio superheterodino pero implementando la mayor parte del proceso directamente en circuitos digitales dentro de una FPGA.

Heterodinización

El proceso de heterodinización consiste en trasladar la frecuencia de una emisora que queremos sintonizar a otra frecuencia que es más cómoda a nivel electrónico o de procesamiento, para demodular. Este proceso se consigue en circuitería analógica normalmente mediante lo que se denomina un mezclador (multiplicador) en combinación con un oscilador: Si multiplicamos la señal que llega de una antena por una señal sinusoidal de un oscilador local conseguimos realizar un desplazamiento de todas las frecuencias que llegan a la antena de tal manera que si tenemos una emisora en $f_1$ y nuestro oscilador local genera una señal en $f_2$, el resultado serán dos señales con las mismas características que $f_1$ pero desplazadas en frecuencia: una en $f_1 + f_2$ y otra en $f_1 - f_2$.

Si denominamos a $f_1 - f_2 = f_i$ frecuencia intermedia podemos dejar pasar sólo dicha frecuencia mediante un filtro paso-banda (con la ventaja añadida de que dicho filtro es de frecuencia fija) y realizar todo el proceso de demodulación basándonos sólo en esta frecuencia intermedia, independientemente de a qué frecuencia esté emitiendo la emisora (independientemente de $f_1$) puesto que con el mezclador y el oscilador local ya desplazamos la señal de la emisora como si emitiese en $f_i$. En los receptores superheterodinos lo que se hace normalmente es elegir una frecuencia $f_i$ relativamente cómoda (el estándar es 455 KHz para AM y 10.7 MHz para FM). De esta manera, por ejemplo, para un receptor AM comercial que deba recibir emisoras en la banda entre 530 y 1710 KHz, su oscilador local generará frecuencias en el rango de 985 a 2165 KHz; así, para recibir una emisora que emita a 576 KHz, el receptor generará una señal en su oscilador local de 1031 KHz que, al ser multiplicada por la señal de antena, proporcionará un par de frecuencias resultado de esa multiplicación estando una de dichas frecuencias en 455 KHz. Y así con cualquier emisora: basta con alterar la frecuencia del oscilador local para cambiar de emisora, el resto de la circuitería del receptor trabaja a 455 KHz.

Implementación en digital

Como objetivo inicial nos planteamos un receptor sencillo AM para la banda comercial, puesto que la demodulación en amplitud suele ser un proceso más sencillo que la demodulación en frecuencia (FM). Como se vio anteriormente el proceso de heterodinización consiste básicamente en multiplicar la señal de antena por otra señal procedente de un oscilador local. El primer escollo que nos encontramos es la lectura de la señal de la antena y su posterior conversión analógico-digital.

Amplificador analógico para la antena

El amplificador analógico de antena hace una amplificación de banda ancha (no sintonizada) pero necesaria para que el ADC pueda detectar señal. He utilizado una configuración estándar de amplificador en emisor común.



En las pruebas con el prototipo se optó por ajustar las dos resistencias de la base de forma empírica con un potenciómetro ajustable de 10 K en modo divisor de tensión hasta que la calidad fuera la mejor posible. Al usarse un transistor 2N3904 la resistencia del colector sí se calculó utilizando las curvas características:



Usamos el valor de $220 \Omega$ para $R_c$ puesto que con ese valor tenemos una recta de carga con mínima distorsión y ganancia razonable, que toca, en el eje X, al punto $V_{cc} = 3.3 V$ y, en el eje Y, al punto ${3.3 \over 220} = 0.015 A$.

ADC para la entrada de la antena

Tratamos de usar una conversión "barata" de tipo delta-sigma, de la que hablamos en una entrada anterior, usando un comparador LVDS interno de la FPGA (todas las FPGA vienen con entradas diferenciales incorporadas basadas en comparadores LVDS). Este tipo de conversión es muy eficiente, permite resolución arbitraria pero, a cambio, requiere mucho sobremuestreo (oversampling) para obtener lecturas fiables. Al tener nuestra FPGA un reloj a 50 MHz, el sobremuestreo nos puede resultar muy caro a efectos de ancho de banda: por ejemplo para obtener una resolución de 8 bits en el ADC ya no podríamos muestrear a 50 MHz, sino a ${50000000 \over {2^8}} = 195 \: KHz$ con lo cual el ancho de banda del ADC caería a los 97 KHz y ya nos iríamos fuera del rango de la banda AM que queríamos abarcar inicialmente.

¿Qué pasa si, manteniendo la frecuencia de reloj de 50 MHz, subimos la frecuencia de muestreo a costa de una pérdida de resolución en el ADC? Más aún ¿Qué pasa si nos vamos al caso extremo de poner la frecuencia de muestreo a 50 MHz y de considerar un ADC de 1 bit de resolución? Bueno, uno puede pensar, a priori que esa pérdida en los bits de resolución es inasumible, pero lo cierto es que, si el ADC es de tipo delta-sigma, aunque la resolución del ADC sea de 1 bit, la anchura de los pulsos será proporcional al nivel de la entrada y, a nivel espectral, la señal de entrada seguirá siendo fiel reflejo de lo que llega por la antena, al menos hasta cierta frecuencia. Bueno, probemos entonces con un ADC de 1 bit a ver qué tal.

Elección de la frecuencia intermedia

Como se vio al principio, en los circuitos electrónicos, lo usual es elegir frecuencias intermedias que sean cómodas de cara al cálculo de componentes, de cara a la minimización del ruido, precio, rendimiento, etc. Sin embargo si estamos realizando el mezclado (la multiplicación) de las señales y la posterior demodulación dentro de una FPGA, la elección de la frecuencia intermedia (los 455 KHz que elegimos para el receptor AM) se convierte en una elección totalmente arbitraria: podríamos elegir la frecuencia que quisiéramos. En el caso que nos ocupa, y siendo un receptor AM, nos convendrá una frecuencia intermedia que sea muy fácilmente demodulable con los recursos de los que disponemos dentro de una FPGA. Pongámonos en el lado del transmisor y analicemos cómo es una señal modulada en AM:



Cuando modulamos una señal senoidal de alta frecuencia (la frecuencia a la que emite la emisora o señal portadora) en amplitud usando una señal de baja frecuencia (música, voz, sonido, etc.), el resultado es una señal que sigue estando centrada en la portadora, pero que está acompañada de dos "lóbulos", uno hacia arriba y otro hacia abajo en el espectro: dichos lóbulos son la señal del sonido (señal moduladora) que modula a la señal portadora que se encuentra desplazada hasta esas zonas. Ambos "lóbulos" de modulación son simétricos.

Esto es, si, en el transmisor, yo emito a 576 KHz y modulo la señal en amplitud (AM) con un tono de 1 KHz estoy generando tres señales: una a 575 KHz, otra a 576 KHz (la portadora central de la banda, esta siempre estará) y otra a 577 KHz. Si al tono de 1 KHZ le añado otro tono de 2KHz se comenzarán a producir 5 señales en la antena: 574, 575, 576 (frecuencia central), 577 y 578 KHz. Como se puede apreciar el proceso de modulación AM es muy parecido al proceso de heterodinización, ya que se producen frecuencias sumas y resta (de hecho la modulación AM no deja de ser también una multiplicación de señales).

Cuando en el receptor desplazamos la señal al mezclarse (multiplicarse) con la señal del oscilador local, desplazamos todo por igual. Por ejemplo, supongamos que dentro de la FPGA queremos adoptar el mismo estándar que se utiliza en circuitería analógica y queremos desplazar hasta 455 KHz. Si queremos sintonizar una emisora que emite a 576 KHz podríamos hacer que un oscilador local (ya veremos cómo implementarlo) genere una señal a 1031 KHz, esto generará a la salida del multiplicador, dos señales, una a 455 KHz y otra a 1607 KHz (esta última habría que eliminarla mediante filtros digitales). Una vez aislada la señal de 455 KHz podremos realizar el proceso de demodulación.

Si esta emisora que emite a 576 KHz transmite en AM un tono a 1 KHz, tras ese proceso de mezcla y filtrado dentro de la FPGA tendremos dicho tono en 456 KHz, que habrá que extraerlo mediante alguna técnica DSP.

Zero-IF

¿No podríamos hacer algo para simplificar todo este proceso de mezclado a frecuencia intermedia seguido de demodulación de la frecuencia intermedia? Bueno, lo cierto es que, si estamos en AM, sí que se puede simplificar. Recordemos lo que comentamos antes de que cuando una emisora emite a 576 KHz y decide transmitir un tono a 1 KHz en AM, se radían tres señales: los 576 KHz de la frecuencia central y dos señales más y superpuestas a 575 y 577 KHz. La técnica Zero-IF (o "frecuencia intermedia cero") consiste en multiplicar la señal de la antena por una señal con EXACTAMENTE LA MISMA frecuencia que la emisora que transmite: por las propiedades de la multiplicación de las señales, si yo multiplico una señal con una frecuencia $f_1$ por otra señal con la misma frecuencia $f_1$, el resultado son dos señales: una con frecuencia $f_1 + f_1 = 2 \times f_1$ y otra con FRECUENCIA CERO ($f_1 - f_1$). Es decir que si nuestra emisora, que emite a 576 KHz, transmite un tono a 1 KHz y nosotros en el receptor multiplicamos la señal de la antena por otra señal a exactamente 576 KHz, desplazaremos al cero la frecuencia central de la señal recibida (576 KHz), por lo que el tono de 1 KHz que la emisora transmite y que, en la señal recibida en la antena, estaba en los lóbulos de 575 y 577 KHz, a la salida de nuestro multiplicador se convertirá en ¡Un tono de 1 KHz! Es decir, estaremos haciendo una demodulación de AM, sin necesidad de frecuencias intermedias (455 KHz) ni de complicados algoritmos de demodulación.

Simplificando el multiplicador

Lo habitual, y para garantizar una buena calidad de recepción, es que el oscilador local genere una onda senoidal (o lo más parecido a ésta) y, de hecho, es la implementación habitual que se realiza de osciladores locales en otros proyectos SDR basados en FPGA: un oscilador local que genera una onda senoidar de N bits de resolución que se multiplica por la señal que llega de la antena y luego se filtra y se demodula. Sin embargo incluso en sintonizadores analógicos o híbridos se utiliza a veces el concepto de "mezclador de conmutación", es decir un multiplicador que multiplica una señal por una onda cuadrada: siendo esto no más que dejar pasar tal cual o cambiada de signo la señal original al ritmo que marca la onda cuadrada (matemáticamente se traduce en que, cuando la señal del oscilador local está a nivel alto, multiplico la señal de entrada por 1 y, cuando está a nivel bajo, multiplico la señal de entrada por -1) . El uso de mezcladores de conmutación está muy extendido puesto que simplifican el diseño de los osciladores (un oscilador de onda cuadrada siempre es más barato de calibrar y de implementar en un circuito digital que un oscilador de onda senoidal) con la contrapartida de que el filtrado debe hacerse mejor (debido a las componentes de alta frecuencia que se generan por ser una señal cuadrada).

En nuestro caso he optado por simplificar el mezclador (multiplicador) hasta su mínima expresión. Como comentamos antes, la salida del ADC es una señal de 1 bit (que puede ser 0 o 1), si hacemos que la salida de nuestro oscilador local sea también de 1 bit, al usar la técnica de la mezcla mediante conmutación (0 o 1), la multiplicación de dichas señales podrá implementarse mediante un circuito combinacional simple de 2 bits de entrada y 1 bit de salida. Si consideramos que nuestras señales no tienen componente de continua podemos asumir que un valor binario de 0 se corresponde con un valor físico -1 mientras que un valor binario de 1 se corresponde con un valor físico de +1:

Entrada ADCEntrada osciladorSalida mezclador (multiplicador)
-1-1+1
-1+1-1
+1-1-1
+1+1+1

Si traducimos estos valores a binario de nuevo:

Entrada ADCEntrada osciladorSalida mezclador (multiplicador)
001
010
100
111

Lo que tenemos es que podemos modelar el mezclador mediante ¡Una simple puerta XNOR!

En la siguiente gráfica se puede ver como, incluso con una simplificación tan extrema como ésta (usando señales de 1 bit tanto para la señal delta-sigma como para la señal del oscilador local y "multiplicando" con una puerta XNOR), podemos conseguir un desplazamiento de frecuencia de la misma forma que si lo hiciésemos con un multiplicador "de verdad".



Se ha simulado, por simplicidad, que la señal de entrada (de antena) es de 2 Hz y que la señal del oscilador local es de 5 Hz. La primera columna se corresponde con el dominio del tiempo mientras que la segunda columna se corresponde con el dominio de la frecuencia:

1.- Al principio tenemos una señal senoidal normal de 2 Hz.

2.- A continuación calculamos una señal delta-sigma a partir de esa señal de entrada de 2 Hz (en la Wikipedia se explican ampliamente los principios de esta modulación pero podemos quedarnos con esta pequeña gráfica que resume en qué consiste esta modulación, que es lo que hace nuestro ADC).

3.- Por otro lado tenemos el oscilador local de onda cuadrada que, en este ejemplo, lo hemos puesto a 5 Hz.

4.- Multiplicamos ambas señales (XNOR) y el resultado, como se puede comprobar en las gráficas, es el deseado: se generan dos frecuencias, una suma (7 Hz) y otra resta (3 Hz), de las frecuencias de las dos señales de entrada (antena y oscilador local).

Implementación en la FPGA



Para el ADC delta-sigma se sigue una configuración estándar como la descrita en esta publicación anterior y se calcula el valor de la resistencia y del condensador de integración en función de la frecuencia de reloj de la FPGA (50 MHz) usando el criterio publicado por Lattice Semiconductor:

$$200 < R \times C \times f_{clk} < 1000$$

Para nuestro caso particular usamos los valores C = 10 nF y R = 1 K. Por otro lado tenemos el acumulador de fase que hace las veces de oscilador local y cuya constante de incremento se calcula a partir de la frecuencia queremos sintonizar (nótese que, como usamos la técnica Zero-IF, la frecuencia del oscilador local deberá ser exactamente la misma que la de la emisora que queremos sintonizar). Si queremos sintonizar 576 KHz (en mi caso es la frecuencia a la que emite Radio Nacional de España en Las Palmas de Gran Canaria) calcularemos la constante de incremento del acumulador de fase de la siguiente manera:

$$Inc = {576000 \over 50000000} \times 2^{64} = 212506491729134048$$

De esta manera en el bit 63 (el más significativo) del acumulador de fase tendremos una onda cuadrada con una frecuencia de 576 KHz. Como se comentó con anterioridad, la multiplicación la implementamos mediante una simple puerta XNOR entre el bit 63 del acumulador de fase (oscilador local de onda cuadrada) y el bit proveniente del ADC delta-sigma.

A la salida de la puerta XNOR (nuestro particular multiplicador) convertimos la señal de 1 bit en una señal de 11 bits apta para ser acumulada en el registro de diezmado (en algunos textos técnicos se hace referencia al "diezmado" como "decimación", a mi me gusta más el término "diezmado", ya que es la traducción más correcta del término "decimation" y creo que expresa mejor su cometido).

Lo que hace el acumulador de diezmado es ir sumando las muestras que llegan del multiplicador (la puerta XNOR) y cuando ha hecho 1024 sumas (o, lo que es lo mismo, cuando el contador de diezmado se desborda), se pasa el valor de la cuenta al latch de diezmado y se inicia el acumulador de diezmado de nuevo. ¿Cual es el resultado de esto? Lo que estamos haciendo es un "diezmado en tiempo" y convertir una señal con una frecuencia de muestreo de 50 MHz (los 0s y 1s que salen del multiplicador) en otra señal con una frecuencia de muestreo de ${50000000 \over {2^{10}}} = {50000000 \over 1024} = 48828.125 \simeq 49 \: KHz$. Con este diezmado en tiempo matamos dos pájaros de un tiro:

1.- Por un lado, hacemos un filtrado paso-bajo, ya que estamos "promediando" y generamos una muestra de salida por cada 1024 muestras de entrada.

2.- Por otro lado, ajustamos la frecuencia de muestreo de la señal a un valor aceptable para ser procesado por circuitos de audio.

La salida del latch de diezmado ya es apta para convertirla a PWM y sacarla por un altavoz.

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity CycloneIIAMReceiver is
    port (
        ClkIn         : in std_logic;   -- 50 MHz
        AntennaIn     : in std_logic;
        DeltaSigmaOut : out std_logic;
        SpeakerOut    : out std_logic
    );
end entity;

architecture A of CycloneIIAMReceiver is
    -- 1-bit ADC
    signal DeltaSigmaADCD : std_logic;
    signal DeltaSigmaADCQ : std_logic;
    -- COPE AM Las Palmas: 837 KHz
    -- (837000 / 50000000) * (2 ^ 64) = 308798495793897920 (64 bit)
    -- upper 32 bit: 71897752
    -- lower 32 bit: 2297979328
    ----constant UPPER_LOCAL_OSC_INC : integer := 71897752;
    ----constant LOWER_LOCAL_OSC_INC : integer := 2297979328;
    -- RNE AM Las Palmas: 576 KHz
    -- (576000 / 50000000) * (2 ^ 64) = 212506491729134048 (64 bit)
    -- upper 32 bit: 49478023
    -- lower 32 bit: 1073398240
    constant UPPER_LOCAL_OSC_INC : integer := 49478023;
    constant LOWER_LOCAL_OSC_INC : integer := 1073398240;
    constant LOCAL_OSC_INC : std_logic_vector(63 downto 0) := std_logic_vector(to_unsigned(UPPER_LOCAL_OSC_INC, 32)) & std_logic_vector(to_unsigned(LOWER_LOCAL_OSC_INC, 32));
    signal LocalOscAccD : std_logic_vector(63 downto 0);
    signal LocalOscAccQ : std_logic_vector(63 downto 0);
    signal LocalOscOut : std_logic;
    -- mixer
    signal MixerOut : std_logic;
    signal NumericMixerOut : std_logic_vector(10 downto 0);
    -- decimator (factor = 1024 = 2^10, so pass from 50 MHz to 48.8 KHz (50000000 / 1024)
    signal DecimatorCounterD : std_logic_vector(9 downto 0);
    signal DecimatorCounterQ : std_logic_vector(9 downto 0);
    signal DecimatorAccD : std_logic_vector(10 downto 0);
    signal DecimatorAccQ : std_logic_vector(10 downto 0);
    signal DecimatorLatchD : std_logic_vector(10 downto 0);
    signal DecimatorLatchQ : std_logic_vector(10 downto 0);
    signal DemodulatedOutput : std_logic_vector(9 downto 0);
begin
    -- delta-sigma ADC for input
    process (ClkIn)
    begin
        if (ClkIn'event and (ClkIn = '1')) then
            DeltaSigmaADCQ <= DeltaSigmaADCD;
        end if;
    end process;

    DeltaSigmaADCD <= AntennaIn;
    DeltaSigmaOut <= DeltaSigmaADCQ;

    -- local oscillator
    process (ClkIn)
    begin
        if (ClkIn'event and (ClkIn = '1')) then
            LocalOscAccQ <= LocalOscAccD;
        end if;
    end process;

    LocalOscAccD <= std_logic_vector(unsigned(LocalOscAccQ) + unsigned(LOCAL_OSC_INC));
    LocalOscOut <= LocalOscAccQ(63);

    -- mixer (multiplier)
    MixerOut <= LocalOscOut xnor DeltaSigmaADCQ;
    NumericMixerOut <= std_logic_vector(to_unsigned(1, 11)) when (MixerOut = '1') else
                       std_logic_vector(to_unsigned(0, 11));

    -- decimator
    process (ClkIn)
    begin
        if (ClkIn'event and (ClkIn = '1')) then
            DecimatorCounterQ <= DecimatorCounterD;
        end if;
    end process;

    DecimatorCounterD <= std_logic_vector(unsigned(DecimatorCounterQ) + to_unsigned(1, 10));

    process (ClkIn)
    begin
        if (ClkIn'event and (ClkIn = '1')) then
            DecimatorAccQ <= DecimatorAccD;
        end if;
    end process;

    DecimatorAccD <= NumericMixerOut when (unsigned(DecimatorCounterQ) = 0) else
                     std_logic_vector(unsigned(DecimatorAccQ) + unsigned(NumericMixerOut));

    process (ClkIn)
    begin
        if (ClkIn'event and (ClkIn = '1')) then
            DecimatorLatchQ <= DecimatorLatchD;
        end if;
    end process;

    DecimatorLatchD <= DecimatorAccQ when (unsigned(DecimatorCounterQ) = 0) else
                       DecimatorLatchQ;
    DemodulatedOutput <= DecimatorLatchQ(10 downto 1);

    -- PWM for speaker output
    SpeakerOut <= '1' when (unsigned(DecimatorCounterQ) > unsigned(DemodulatedOutput)) else
                  '0';
end architecture;


Salida PWM

La parte de la salida PWM de la FPGA lo que hace es convertir la señal del latch de diezmado en un tren de pulsos PWM que se conecta directamente a un amplificador de audio externo. El bit de salida PWM se calcula comparando el valor del latch de diezmado con el contador de diezmado, lo que provoca que la anchura de los pulsos de salida (un único bit que va al amplificador) sea proporcional al valor del latch de diezmado. Este bit (modulado en PWM) puede atacar directamente a la entrada de cualquier amplificador de audio.

Esquema eléctrico final



Resultados

Los resultados distan mucho de considerarse de calidad, el ruido en la recepción es alto (sólo a mi se me ocurre montar un circuito de radio en una protoboard...), pero "se entiende" más o menos lo que dice :-). El amplificador de antena ha sido la parte que, de lejos, más trabajo me ha dado, ya que tengo que reconocer que mi fuerte no es la electrónica analógica y menos a estas frecuencias de trabajo.



El código fuente puede descargarse de la sección soft.

[ 2 comentarios ] ( 8613 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 308 )
Estrella de Navidad con CPLD 
El proyecto navideño de este año es una sencilla estrella de navidad que puede colocarse tanto en el árbol como en el belén y que requiere de muy poca circuitería.

Idea

El objetivo era no recurrir a la típica luz parpadeante sino darle un efecto de movimiento, que parezca que la estrella se mueva por el firmamento. Para simular este efecto lo más sencillo es alinear varias luces y hacer que la luz se mueva rápidamente, con un brillo creciente y en una dirección concreta, lo que da el efecto de que la luz un efecto meteorito.

Diseño eléctrico

A nivel electrónico el concepto es muy sencillo: 8 leds de alta luminosidad protegidos con resistencias y conectados a 8 salidas de un CPLD.

Uno de los leds hace de estrella principal mientras que los 7 leds restantes servirán para hacer el efecto de la estela de la estrella principal. La secuencia de iluminación será la siguiente:

- E0 iluminado al 5%, resto apagado

- E1 iluminado al 10%, resto apagado

- E2 iluminado al 15%, resto apagado

- E3 iluminado al 20%, resto apagado

- E4 iluminado al 30%, resto apagado

- E5 iluminado al 40%, resto apagado

- E6 iluminado al 50%, resto apagado

- EP iluminado al 100% durante varias unidades de tiempo más, resto apagado

- Todo apagado durante varias unidades de tiempo

Diseño lógico y funcionamiento

A continuación una propuesta de diagrama de bloques sencillo:

Al circuito combinacional A tiene como entradas el valor del registro contador de 22 bits y el valor del registro de desplazamiento de 20 bits y como salida la entrada de selección del multiplexor del registro de desplazamiento de 20 bits:

registro contadorreg. desplaz.mux
x0valor 1
0xsalida desplazador izquierda
≠0≠0reg. desplaz. (mantener)


El registro contador se utiliza con dos propósitos:

- Como medida de unidad de tiempo: Cada vez que se desborda, se desplaza el registro de desplazamiento. A 50 MHz de frecuencia de reloj, tenemos una frecuencia de desplazamiento de ${50000000 \over {2^{22}}} = 11.92093 \: Hz$, es decir ${1 \over 11.92093} = 0.08389 \: seg$, aproximadamente una décima de segundo como unidad de tiempo.

- Como registro contador para el PWM de los leds de la estela: Como es un contador estándar de desbordamiento, se pueden usar los 10 bits menos significativos para generar una señal PWM, esto nos da una frecuencia de señal PWM de ${50000000 \over {2^{10}}} \approx 49 \: KHz$ que es una buena frecuencia para un led.

Al iniciarse el CPLD todos los bits del registro de desplazamiento estarán a 0, lo que provocará que el circuito combinacional A emita un 1 para que, en el primer ciclo de reloj, se cargue un 1 en el registro de desplazamiento. A partir de aquí el circuito combinacional A mantendrá el multiplexor en modo "copia" (manteniendo el valor del registro de desplazamiento) y sólo mandará a desplazar cuando el registro contador se desborde. El registro de desplazamiento tendrá un bit 1 moviéndose de izquierda a derecha a razón de un salto cada décima de segundo, cuando el bit llega al extremo izquierdo aparece de nuevo en el extremo derecho del registro (formalmente deberíamos llamar al registro de desplazamiento, registro de "rotación").

Los 5 bits menos significativos del registro de desplazamiento no se conectan a nada, lo que significa que durante unas 5 décimas de segundo (medio segundo aproximadamente) ninguna de las luces se enciende, cuando el 1 pasa al bit 5 del registro de desplazamiento, se pone a 1 la entrada inferior de la puerta AND que gobierna el led E0, que hace las veces de enable para la salida PWM con menor ciclo de trabajo (menor luminosidad). Cuando el 1 pasa al bit 6 del registro de desplazamiento, se pone a 1 la entrada inferior de la puerta AND que gobierna el led E1, que hace de enable para la salida PWM con ciclo de trabajo ligeramente superior (un poco más de limunosidad que el anterior) y así sucesivamente. A medida que el 1 va desplazándose a la izquierda (un salto por cada décima de segundo aproximadamente) se van iluminando los leds E0 a E6 de forma consecutiva y con ciclos de trabajo PWM crecientes (es decir, cantidad de luz creciente). Los últimos 8 bits del registro de desplazamiento (bits 19 al 12) están conectados a una puerta OR que gobierna la luz EP (estrella principal), esto hace que la estrella principal esté encendida unas 8 décimas de segundo, y luego vuelta a empezar.

Implementación

A continuación el código VHDL:
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity MaxIIBethlehemStar is
    port (
        ClkIn       : in std_logic;
        BoardLedOut : out std_logic;
        StarOut     : out std_logic;
        Wake1Out    : out std_logic;
        Wake2Out    : out std_logic;
        Wake3Out    : out std_logic;
        Wake4Out    : out std_logic;
        Wake5Out    : out std_logic;
        Wake6Out    : out std_logic;
        Wake7Out    : out std_logic
    );
end entity;

architecture A of MaxIIBethlehemStar is
    constant Intensity1PWM : integer := 100;   -- over 1024
    constant Intensity2PWM : integer := 83;
    constant Intensity3PWM : integer := 66;
    constant Intensity4PWM : integer := 50;
    constant Intensity5PWM : integer := 35;
    constant Intensity6PWM : integer := 20;
    constant Intensity7PWM : integer := 10;
    signal TimerDBus : std_logic_vector(21 downto 0);
    signal TimerQBus : std_logic_vector(21 downto 0);
    signal ShiftDBus : std_logic_vector(19 downto 0);
    signal ShiftQBus : std_logic_vector(19 downto 0);
    signal Intensity1 : std_logic;
    signal Intensity2 : std_logic;
    signal Intensity3 : std_logic;
    signal Intensity4 : std_logic;
    signal Intensity5 : std_logic;
    signal Intensity6 : std_logic;
    signal Intensity7 : std_logic;
begin
    -- intensity signals (using timer lower 10 bits as PWM counter)
    Intensity1 <= '0' when (unsigned(TimerQBus(9 downto 0)) > to_unsigned(Intensity1PWM, 10)) else
                  '1';
    Intensity2 <= '0' when (unsigned(TimerQBus(9 downto 0)) > to_unsigned(Intensity2PWM, 10)) else
                  '1';
    Intensity3 <= '0' when (unsigned(TimerQBus(9 downto 0)) > to_unsigned(Intensity3PWM, 10)) else
                  '1';
    Intensity4 <= '0' when (unsigned(TimerQBus(9 downto 0)) > to_unsigned(Intensity4PWM, 10)) else
                  '1';
    Intensity5 <= '0' when (unsigned(TimerQBus(9 downto 0)) > to_unsigned(Intensity5PWM, 10)) else
                  '1';
    Intensity6 <= '0' when (unsigned(TimerQBus(9 downto 0)) > to_unsigned(Intensity6PWM, 10)) else
                  '1';
    Intensity7 <= '0' when (unsigned(TimerQBus(9 downto 0)) > to_unsigned(Intensity7PWM, 10)) else
                  '1';

    -- timer counter
    process (ClkIn)
    begin
        if (ClkIn'event and (ClkIn = '1')) then
            TimerQBus <= TimerDBus;
        end if;
    end process;

    TimerDBus <= std_logic_vector(unsigned(TimerQBus) + to_unsigned(1, 22));

    -- shift register
    process (ClkIn)
    begin
        if (ClkIn'event and (ClkIn = '1')) then
            ShiftQBus <= ShiftDBus;
        end if;
    end process;

    ShiftDBus <= std_logic_vector(to_unsigned(1, 20)) when (unsigned(ShiftQBus) = 0) else
                 ShiftQBus(18 downto 0) & ShiftQBus(19) when (unsigned(TimerQBus) = 0) else
                     ShiftQBus;

    -- outputs
    StarOut <= ShiftQBus(19) or ShiftQBus(18) or ShiftQBus(17) or ShiftQBus(16) or ShiftQBus(15) or ShiftQBus(14) or ShiftQBus(13) or ShiftQBus(12);
    Wake1Out <= Intensity1 and ShiftQBus(11);
    Wake2Out <= Intensity2 and ShiftQBus(10);
    Wake3Out <= Intensity3 and ShiftQBus(9);
    Wake4Out <= Intensity4 and ShiftQBus(8);
    Wake5Out <= Intensity5 and ShiftQBus(7);
    Wake6Out <= Intensity6 and ShiftQBus(6);
    Wake7Out <= Intensity7 and ShiftQBus(5);

    -- debug
    BoardLedOut <= (ShiftQBus(19) or ShiftQBus(18) or ShiftQBus(17) or ShiftQBus(16) or ShiftQBus(15) or ShiftQBus(14) or ShiftQBus(13) or ShiftQBus(12)) or
                   (Intensity1 and ShiftQBus(11)) or
                   (Intensity2 and ShiftQBus(10)) or
                   (Intensity3 and ShiftQBus(9)) or
                   (Intensity4 and ShiftQBus(8)) or
                   (Intensity5 and ShiftQBus(7)) or
                   (Intensity6 and ShiftQBus(6)) or
                   (Intensity7 and ShiftQBus(5));
end architecture;

El montaje se ha implementado sobre un CPLD MAX II de Altera, usando leds blancos de alta luminosidad y montándolos luego sobre una base de cartón duro con la típica forma de estrella de navidad.





El código fuente está disponible en la sección soft. ¡Feliz Navidad y feliz 2021!

[ añadir comentario ] ( 508 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 305 )
Implementación sobre una FPGA de un transmisor de radio en la banda comercial de FM 
En anteriores entradas se realizó la implementación, tanto de un transmisor FM como de un conversor analógico digital delta-sigma, sobre FPGA y como proyectos separados. A lo largo de esta entrada se aborda el desarrollo de un transmisor FM en la banda de la radio comercial para transmitir música, combinando ambos proyectos en uno solo.

Conversión analógica digital delta-sigma

Como se puede comprobar en el post anterior relacionado, la conversión analógica digital de tipo delta-sigma tiene la ventaja de requerir muy pocos componentes externos: un comparador analógico (para lo que puede usarse un amplificador operacional normal), un condensador y una resistencia; y permite implementar un ADC de precisión arbitraria (la precisión sólo está limitada por los recursos disponibles en la FPGA).



Recordemos que la conversión analógico digital de tipo delta-sigma lo que hace es, mediante un biestable de tipo D, hacer que la salida que carga el condensador C a través de la resistencia R "siga" a la entrada analógica:


- Si la entrada + del comparador está por encima de la entrada -, el comparador emite un 1, que es cargado en el siguiente ciclo de reloj por el biestable de entrada, haciendo que la salida que va a la resistencia R tome el valor de 1 y trate de "acercar" el valor de la entrada - del comparador al valor de la entrada + del comparador.

- Si la entrada + del comparador está por debajo de la entrada -, el comparador emite un 0, que es cargado en el siguiente ciclo de reloj por el biestable de entrada, haciendo que la salida que va a la resistencia R tome el valor de 0 y trate de "acercar" (esta vez hacia "abajo") el valor de la entrada - del comparador al valor de la entrada + del comparador.


Como se puede ver, este comportamiento hace que para valores próximos a 0 voltios en la entrada + del comparador, el biestable de entrada emite muchos más 0s que 1s, mientras que para valores próximos a 3.3 voltios en la entrada + del comparador, el biestable de entrada emite muchos más 1s que 0s. También se puede ver que para valores próximos a Vcc / 2 = 1.65 voltios, el biestable emitirá una cantidad aproximadamente igual de 0s que de 1s.

Si lo que hacemos ahora es asociar al valor 0 de la salida del biestable, el valor numérico -1, y, al valor 1 de la salida del biestable, el valor numérico +1, lo que tenemos es un circuito digital que emitirá:


- Más +1 que -1 para valores próximos a 3.3 voltios.

- Más -1 que +1 para valores próximos a 0 voltios.

- Una cantidad aproximadamente igual de -1 y +1 para valores próximos a 1.65 voltios.


Si vamos acumulando estos -1 y +1 sobre un acumulador que se pone a cero cada cierto tiempo, lo que tendremos es que en dicho acumulador tendremos un valor proporcional al voltaje de entrada del ADC:


- Valores próximos a 3.3 voltios, al generar más +1 que -1, dan como resultado un valor de conversión muy alto y positivo.

- Valores próximos a 0 voltios, al generar más -1 que +1, dan como resultado un valor de conversión muy bajo y negativo.

- Valores próximos a 1.65 voltios, al generar una cantidad aproximadamente similar de -1 y de +1, dan como resultado un valor de conversión próximo a 0.


Debido a esta necesidad de contar -1s y +1s necesitamos sobremuestreo: Si queremos obtener una resolución de 16 bits, eso significa que el valor máximo de conversión tendrá que ser -32767 mientras que el valor mínimo de conversión tendrá que ser -32768. Si asumimos una escala simétrica tendremos un valor entre -32767 y +32767, eso significa que debemos hacer 32767 sumas (de -1s y +1s) antes de leer el valor de conversión. Dichas sumas las tenemos que hacer en un registro, que llamaremos "acumulador de conversión".

Para el caso de 16 bits necesitaríamos un contador de 15 bits que, en el momento de que valga 0 haga que el "acumulador de conversión" copie su valor en el registro de salida del ADC y se ponga a cero, y, durante los 32767 pulsos restantes (hasta el siguiente desbordamiento), se sumen los -1s y +1s que van entrando.

Así vemos que, por ejemplo, para 12 MHz y 16 bits de resolución, como necesitaríamos un contador de 15 bits, al final la frecuencia de muestreo podrá ser, como máximo de:

$$ {12000000 \over {2^{15}}} = 366.21 \: Hz $$

Por ejemplo, si queremos hacer una conversión en calidad CD necesitaríamos un reloj de sistema (sobremuestreo) de:

$$ {44100 \times {2^{15}}} = 1445068800 \: Hz $$

En el caso que nos ocupa se ha decidido implementar el transmisor en una FPGA MAX10 de las que viene en una placa MAX1000 de Arrow, a 12 MHz, cuyo reloj puede ser subido mediante PLLs hasta unos 400 MHz. Debido a esta limitación se ha decidido subir el reloj a 300 MHz y, para trabajar con esa frecuencia de sobremuestreo, se han tenido que bajar un poco las especificaciones del ADC, usando un contador de 13 bits y un acumulador de conversión de 14 bits. De esta forma tenemos una frecuencia de muestreo de:

$$ {300000000 \over {2^{13}}} = 36621.09375 \: Hz $$

Por lo que el ancho de banda es de unos 18 KHz (buen ancho de banda para música) y la resolución de conversión es de 14 bits (valores de conversión entre -8191 y +8191). No es calidad CD pero tampoco está mal.

Consideraciones entorno a la entrada de sonido analógica

Hay que tener en cuenta que las señales de sonido tal cual salen de un amplificador, son señales simétricas (con semiciclos positivos y negativos) mientras que nuestro ADC mide voltajes entre 0 y 3.3 voltios (no mide voltajes negativos). Es necesario, por tanto, acondicional la señal de sonido de entrada para que quede "desplazada" hacia arriba y un valor de 0 voltios de entrada se traduzca en 1.65 voltios a la entrada + del comparador. Es por esto por lo que se coloca el divisor de tensión entre 3.3 y 0 voltios a la entrada de audio, que desplaza el "0" de la señal de sonido hasta los 1.65 voltios.



El nivel de señal que entrega la salida de auriculares de un ordenador es suficiente para la entrada del ADC y no requiere amplificación adicional, al menos para esta prueba.

Generador de señal de antena

La frecuencia de transmisión elegida es 87.5 MHz (el extremo inferior de la banda de radiodifusión de sonido). La señal de antena que se quiere generar debe estar centrada, por tanto, en dicha frecuencia y dicha frecuencia deberá variarse en un rango máximo de +-75 KHz (estándar de radiodifusión). Para la generación de frecuencias arbitrarias que sean inferiores a la frecuencia de reloj de un sistema digital lo lógico es utilizar un acumulador de fase.

Un acumulador de fase no es más que un registro que se incrementa en un valor constante (no tiene por qué incrementarse de 1 en 1) con desbordamiento. Por ejemplo, si a partir de un reloj de 300 MHz queremos generar un reloj de 75 MHz lo que podemos hacer es incrementar un registro de 2 bits de 1 en 1 a 300 MHz:

...
Pulso: 00 --> 01
Pulso: 01 --> 10
Pulso: 10 --> 11
Pulso: 11 --> 00
Pulso: 00 --> 01
Pulso: 01 --> 10
Pulso: 10 --> 11
Pulso: 11 --> 00
...

En este ejemplo se puede ver que si la frecuencia de pulso es de 300 MHz, la frecuencia del bit 0 será de ${300 \over 2} = 150 \: MHz$ mientras que la frecuencia del bit 1 será de ${150 \over 2} = 75 \: MHz$. Un acumulador de fase con incrementos potencia de 2 es, formalmente, un divisor de frecuencia. Si se aplica este mismo principio para registros con mayor cantidad de bits y usando incrementos arbitrarios, conseguimos frecuencias diferentes.

Asumamos que la señal de salida de nuestro "oscilador" será siempre el bit más significativo de un registro de 16 bits y nuestro reloj va a 300 MHz. Eso significará que, incrementando el registro de 1 en 1, el bit más significativo cambiará a razón de:

$$ {300000000 \over {2^N}} = 4577 \: Hz $$

Si en lugar de incrementar el registro de 1 en 1, lo incrementamos de 2 en 2, la cantidad de pulsos que tarde en desbordarse el registro será menor, por tanto, la frecuencia del bit más significativo será mayor, es decir, la frecuencia del bit más significativo es proporcional al valor de incremento del registro. El máximo valor de incremento será $2^{N-1}$ para N bits (es decir un 1 seguido de N-1 ceros) que hará que el registro se comporte de la siguiente manera:

...
Pulso: 0000000000000000 --> 1000000000000000
Pulso: 1000000000000000 --> 0000000000000000
Pulso: 0000000000000000 --> 1000000000000000
Pulso: 1000000000000000 --> 0000000000000000
...

En este caso extremo tenemos que la frecuencia del bit más significativo es de 150 MHz (para una frecuencia de pulso de 300 MHz). De forma genérica tenemos que el valor de incremento para una frecuencia dada puede calcularse de la siguiente forma:

$$ I = {{f_{deseada}} \over 300000000} \times 2^{N} $$

En nuestro caso, si queremos emitir a 87.5 MHz hay que usar un valor de N=54 bits para que el valor de incremento no sea fraccionario:

$$ I_{central} = {87500000 \over 300000000} \times 2^{54} = 5254199565265579 $$

Ese valor es el que habría que usar como valor de incremento en cada pulso de reloj para que en el bit más significativo del registro de 54 bits (bit 53) tengamos una señal a 87.5 MHz. Nótese que para valores de incremento que no sean potencias de 2, obtendremos señales no cuadradas o de fase algo irregular (en el anterior post dedicado a la transmisión FM se profundiza en este tema), pero para el caso que nos ocupa, la "calidad" de la señal resultante no es significativa, lo importante es que la frecuencia fundamental sea la correcta. Para hacer modulación en frecuencia hemos de modificar esta frecuencia en el rango de -75 KHz y +75 KHz, lo que nos genera los siguiente incrementos mínimos y máximos:

$$ I_{min} = {(87500000 - 75000) \over 300000000} \times 2^{54} = 5249695965638208 $$

$$ I_{max} = {(87500000 + 75000) \over 300000000} \times 2^{54} = 5258703164892949 $$

Si calculamos la diferencia entre los incrementos y la dividimos entre dos nos dará el valor 4503599627370.5 que es la amplitud máxima que deberá tener la señal del ADC para que modifique el valor del incremento y que este, a su vez, genere una variación máxima de +- 75 KHz en la frecuencia portadora.

Como la salida del ADC da valores entre -8191 y +8191 a este valor hay que multiplicarle el valor ${4503599627370.5 \over 8191} = 549822930$ para que valores próximos a -8191 en la salida del ADC generen una portadora de 87.5 MHz - 75 KHz = 87.425 MHz y valores próximos a +8191 en la salida del ADC generen una portadora de 87.5 MHz + 75 KHz = 87.575 MHz. El valor del incremento del acumulador de fase que genera la señal de la antena será el siguiente:

$$ I = I_{central} + (ADC \times 549822930) = 5254199565265579 + (ADC \times 549822930) $$

A continuación puede verse el código fuente completo (cabe en un único fichero VHDL).

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;


entity MAX10FMTransmitter is
    port (
        Clk              : in std_logic;
        AnalogComparator : in std_logic;
        PWM              : out std_logic;
        Antenna          : out std_logic;
        Led              : out std_logic_vector(7 downto 0)
    );
end entity;

architecture A of MAX10FMTransmitter is
    component PLL
        port (
            inclk0 : in std_logic := '0';
            c0        : out std_logic 
        );
    end component;
    signal Clk300 : std_logic;
    signal ADCFollowerD : std_logic;
    signal ADCFollowerQ : std_logic;
    signal ADCScalerD : std_logic_vector(12 downto 0);
    signal ADCScalerQ : std_logic_vector(12 downto 0);
    signal ADCAccumulatorD : std_logic_vector(13 downto 0);
    signal ADCAccumulatorQ : std_logic_vector(13 downto 0);   -- signed value: -8191 to +8191
    signal ADCOutputD : std_logic_vector(13 downto 0);
    signal ADCOutputQ : std_logic_vector(13 downto 0);
    signal PhaseAccumulatorD : std_logic_vector(53 downto 0);
    signal PhaseAccumulatorQ : std_logic_vector(53 downto 0);
    signal PhaseAccumulatorIncD : std_logic_vector(53 downto 0);
    signal PhaseAccumulatorIncQ : std_logic_vector(53 downto 0);
    -- increment for phase accumulator to transmit at 87.5 MHz: (87500000 / 300000000) * (2^54) = 5254199565265579
    --constant TxCentralFrequencyInc : integer := 5254199565265579;   -- must fit in 54 bits
    constant TxCentralFrequencyIncL : integer := 2863311531;   -- 32 bits
    constant TxCentralFrequencyIncH : integer := 1223338;      -- 22 bits
    constant TXCentralFrequencyInc : signed(53 downto 0) := to_signed(TxCentralFrequencyIncH, 22) & to_signed(TxCentralFrequencyIncL, 32);
    -- gain for ADC output 
    constant ADCGain : integer := 549822930;   -- ADCGain needs 30 bits
begin
    -- PLL to obtain 300 MHz from external 12 MHz
    P : PLL port map (
        inclk0 => Clk,
        c0 => Clk300
    );

    -- ADC
    process (Clk300)
    begin
        if (Clk300'event and (Clk300 = '1')) then
            ADCFollowerQ <= ADCFollowerD;
        end if;
    end process;
    
    ADCFollowerD <= AnalogComparator;
    PWM <= ADCFollowerQ;
    
    process (Clk300)
    begin
        if (Clk300'event and (Clk300 = '1')) then
            ADCScalerQ <= ADCScalerD;
        end if;
    end process;
    
    ADCScalerD <= std_logic_vector(unsigned(ADCScalerQ) + 1);

    process (Clk300)
    begin
        if (Clk300'event and (Clk300 = '1')) then
            ADCAccumulatorQ <= ADCAccumulatorD;
        end if;
    end process;

    ADCAccumulatorD <= std_logic_vector(to_signed(0, 14)) when (unsigned(ADCScalerQ) = 0) else
                       std_logic_vector(signed(ADCAccumulatorQ) + to_signed(1, 14)) when (ADCFollowerQ = '1') else
                       std_logic_vector(signed(ADCAccumulatorQ) - to_signed(1, 14)) when (ADCFollowerQ = '0') else
                       ADCAccumulatorQ;
    
    process (Clk300)
    begin
        if (Clk300'event and (Clk300 = '1')) then
            ADCOutputQ <= ADCOutputD;
        end if;
    end process;
    
    ADCOutputD <= ADCAccumulatorQ when (unsigned(ADCScalerQ) = 0) else
                  ADCOutputQ;
    Led <= ADCOutputQ(13 downto 6);
    
    -- phase accumulator
    process (Clk300)
    begin
        if (Clk300'event and (Clk300 = '1')) then
            PhaseAccumulatorQ <= PhaseAccumulatorD;
        end if;
    end process;
        
    PhaseAccumulatorD <= std_logic_vector(unsigned(PhaseAccumulatorQ) + unsigned(PhaseAccumulatorIncQ));
    Antenna <= PhaseAccumulatorQ(53);
    
    -- phase accumulator increment control (output frequency control)
    process (Clk300)
    begin
        if (Clk300'event and (Clk300 = '1')) then
            PhaseAccumulatorIncQ <= PhaseAccumulatorIncD;
        end if;
    end process;
    
    -- 14 bits * 40 bits = 54 bits
    PhaseAccumulatorIncD <= std_logic_vector((signed(ADCAccumulatorQ) * to_signed(ADCGain, 40)) + TxCentralFrequencyInc) when (unsigned(ADCScalerQ) = 0) else
                            PhaseAccumulatorIncQ;
end architecture;


El bit 53 del registro acumulador de fase se saca por un pin de la FPGA y en dicho pin se puede colocar un simple trozo de cable. No es necesario hacer ningún circuito que acondicione la señal de salida.



Si a corta distancia del circuito ponemos un receptor de radio FM comercial sintonizado a 87.5 MHz podremos escuchar la señal que está leyendo el ADC de la FPGA y que está siendo transmitida en FM.



Código fuente disponible en la sección soft.

[ añadir comentario ] ( 614 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 2.9 / 413 )
Efectos de sonido con un CPLD 
¿Se pueden generar efectos de sonido rudimentarios sin un chip de sonido y utilizando muy pocas macroceldas de un CPLD o una FPGA?

Motivación

En la anterior entrada del blog se realizó un pequeño proyecto para generar dos efectos de sonido utilizando un sencillo chip PSG, el SN76489. Los sonidos generados fueron muy buenos pero a nivel hardware, como el objetivo del montaje es una futura integración en un pequeño robot con mando a distancia, me encontré con que, el hecho de tener que cablear todo un bus de 8 bits junto con los pines OE y READY, consumía muchos pines del STM32 (el robot consume ya GPIO y PWM para las ruedas, UART para la comunicación e I2C para una pequeña pantalla OLED).

Una primera aproximación válida sería un conversor serie a paralelo de tipo I2C o algo así pero instalar DOS chips sólo para generar dos ruidos rudimentarios me pareció excesivo, por lo que opté por una solución basada en un CPLD.

64 macroceldas

Ese es el reto: hacer un generador de sonido que consuma, como mucho, 64 macroceldas en un CPLD de la serie MAX3000A de Intel, en concreto el EPM3064.



64 macroceldas son 64 biestables con la lógica combinatoria asociada. No es un reto sencillo y es probable que los resultados no tengan tan buena calidad como con un PSG, pero el beneficio que se consigue en reducción de pines, miniaturización y reducción de consumo (Un CPLD consume mucho para los estándares actuales, pero el SN76489 consume más) hace que valga la pena intentarlo.

El circuito

Se plantea un circuito sencillo con dos entradas configuradas en lógica negativa y circuitería antirrebote básica y una salida con condensador de desacoplo y divisor de tensión para evitar sobretensiones en el amplificador de audio de la salida. La placa equipada con el CPLD EPM3064 incluye un oscilador a 50 MHz conectado a uno de los pines de reloj del CPLD y un led con su cátodo conectado a otro de los pines del CPLD (se enciende cuando se emite un 0 por ese pin).



Diagrama de bloques

A continuación puede verse el diagrama del bloques que se ha implementado en el CPLD.



El bloque $x^{18} + x^{11} + 1$ se corresponde con el LFSR maximal de grado 18 que permite generar ruido blanco (aproximado) en el registro de arriba.

L1 es un bloque combinacional que emite un 1 si la entrada vale 0 y la entrada sin cambiar en caso contrario.
L1
EntradaSalida
01
xx

L2 es un bloque combinacional que emite 8193 si la entrada vale 0 y la entrada sin cambiar en caso contrario.
L2
EntradaSalida
08193
xx

B es el bloque combinacional encargado de controlar los multiplexores en función de las señales de entrada y del cruce por cero del registro de 23 bits:
B
EntradasSalidas (MUX)
/Laser/NoiseNS123
11dc111
0dcdc00dc
100010
101210
(dc = don't care)

Descripción funcional

Cuando se activa la entrada /Laser (se pone a nivel bajo), se carga en el registro de 23 bits el valor "01110000000000000000000", este valor se carga para que los 4 bits más significativos tengan el valor "0111". Si nos fijamos los 4 bits más significativos del registro de 23 bits se utlizan para incrementar el valor del registro de 18 bits. Dicho registro de 18 bits actúa como acumulador de fase para una señal de onda cuadrada correspondiente al bit más significativo (bit 17) de este registro.

Si tenemos un registro de 18 bits como acumulador de fase y una frecuencia de reloj de 50 MHz (la de la placa que estamos usando) tendremos una frecuencia del bit más significativo de:

$$f_{out} = {f_{clk} \over 2^{18}}$$

De forma general, en caso de que apliquemos incrementos arbitrarios a este registro de desplazamiento, obtendremos una frecuencia en el bit más significativo de:

$$f_{out} = Inc \times {f_{clk} \over 2^{18}} = Inc \times {50000000 \over 2^{18}}$$

Para simular el sonido de un disparo láser lo que generamos es una caida rápida en frecuencia por lo que empezamos con un $Inc = 7$ cuando /Laser = 0 (de ahí los 4 bits más significativos del valor 01110000000000000000000), este valor de incremento genera una frecuencia en el bit más significativo del registro de 18 bits de:

$$Inc = 7 \Rightarrow f_{out} = 7 \times {50000000 \over 2^{18}} = 1335.1 Hz$$

Cuando /Laser vuelve al valor 1, vamos bajando el valor de Inc (los 4 bits más significativos del registro de 23 bits) hasta que vale 0:

$$Inc = 6 \Rightarrow f_{out} = 1144.4 Hz$$
$$Inc = 5 \Rightarrow f_{out} = 953.67 Hz$$
$$Inc = 4 \Rightarrow f_{out} = 762.94 Hz$$
$$Inc = 3 \Rightarrow f_{out} = 572.2 Hz$$
$$Inc = 2 \Rightarrow f_{out} = 381.47 Hz$$
$$Inc = 1 \Rightarrow f_{out} = 190.73 Hz$$
$$Inc = 0 \Rightarrow f_{out} = 0 Hz$$

Como el registro de 23 bits también actúa como un acumulador de fase (pues se decrementa en bloque, no solo los bits más significativos), la caida es lo suficientemente lenta como para ser audible (que es lo que queremos). Nótese que una vez cae a 0, el registro de 23 bits se queda ahí estancado gracias al circuito combinacional L1 que actúa como limitador, lo que, en la práctica, provoca que el registro de 18 bits "pare" de oscilar (pues $Inc = 0$ siempre). Nótese también que, aunque pare de oscilar, es posible que a la salida que va hacia el amplificador (el bit 17) se quede un "1" de forma pemanente, es por ello por lo que se hace necesario colocar siempre un condensador de desacoplo a la salida.

Si lo que se pone a 0 es la entrada /Noise y la entrada /Laser permanece a 1, lo que se hace es seleccionar como realimentación del registro de 18 bits la salida del polinomio LFSR, que provocará una secuencia de números pseudoaleatorios (ruido) en el bit 17 (salida del amplificador). La carga del valor del LFSR no se produce en cada ciclo del reloj de 50 MHz, pues provocaría ruido no audible, sino que se aprovecha el registro de desplazamiento de 23 bits y, a través del circuito combinacional L2, se hace que "desborde" en 8192, por lo que el registro de 18 bits cambiará con una frecuencia de:

$$f_{muestreo} = {50000000 \over 8192} = 6103.5 Hz$$

por lo que el ruido resultante ocupará aproximadamente hasta la banda de los 3 KHz. No es un ruido blanco pero a efectos audibles es muy parecido al ruido generado por un PSG.

Código fuente

Todo el código fuente en VHDL puede meterse dentro de una sola entidad:
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity Epm3064GunSound is
    port (
        Clk               : in std_logic;
        TrigLaserIn       : in std_logic;
        TrigNoiseIn       : in std_logic;
        SpeakerOut        : out std_logic;
        LedOut            : out std_logic
    );
end entity;

architecture Architecture1 of Epm3064GunSound is
    signal LFSRD : std_logic_vector(17 downto 0);
    signal LFSRQ : std_logic_vector(17 downto 0);
    signal LFSRRawOut : std_logic_vector(17 downto 0);
    signal LFSROut : std_logic_vector(17 downto 0);
    signal LFSRMux : std_logic_vector(1 downto 0);
    signal TimerD : std_logic_vector(22 downto 0);
    signal TimerQ : std_logic_vector(22 downto 0);
    signal TimerMux : std_logic;
    signal Limited1TimerQ : std_logic_vector(22 downto 0);
    signal Limited2TimerQ : std_logic_vector(22 downto 0);
    signal LimiterMux : std_logic;
    signal LimiterMuxOut : std_logic_vector(22 downto 0);
    signal NoiseSample : std_logic;
begin
    -- LFSR
    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            LFSRQ <= LFSRD;
        end if;
    end process;
    
    --LFSRRawOut <= (LFSRQ(0) xor LFSRQ(3)) & LFSRQ(19 downto 1);    20 bits
    LFSRRawOut <= (LFSRQ(0) xor LFSRQ(7)) & LFSRQ(17 downto 1);    -- 18 bits
    LFSROut <= LFSRRawOut when (unsigned(LFSRRawOut) /= 0) else
               std_logic_vector(to_unsigned(1, 18));
    LFSRD <= LFSROut when (LFSRMux = "10") else
             std_logic_vector(unsigned(LFSRQ) + unsigned(TimerQ(22 downto 19))) when (LFSRMux = "01") else
                LFSRQ;
    SpeakerOut <= LFSRQ(17);
    
    -- timer
    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            TimerQ <= TimerD;
        end if;
    end process;

    Limited1TimerQ <= std_logic_vector(to_unsigned(1, 23)) when (signed(TimerQ) = 0) else
                      TimerQ;
    Limited2TimerQ <= std_logic_vector(to_unsigned(8192 + 1, 23)) when (signed(TimerQ) = 0) else
                      TimerQ;
    LimiterMuxOut <= Limited1TimerQ when (LimiterMux = '1') else
                     Limited2TimerQ;
    TimerD <= std_logic_vector(signed(LimiterMuxOut) - 1) when (TimerMux = '1') else
              "01110000000000000000000";
    NoiseSample <= '1' when (signed(TimerQ) = 0) else
                   '0';
    
    -- operation logic
    LFSRMux <= "01" when (((TrigLaserIn = '1') and (TrigNoiseIn = '1')) or (TrigLaserIn = '0')) else
               "10" when ((TrigLaserIn = '1') and (TrigNoiseIn = '0') and (NoiseSample = '1')) else
                  "00";
    TimerMux <= '0' when (TrigLaserIn = '0') else
                '1';
    LimiterMux <= '1' when ((TrigLaserIn = '1') and (TrigNoiseIn = '1')) else
                  '0';
    LedOut <= TrigLaserIn and TrigNoiseIn;
end architecture;


Resultados

Con este circuito se consiguen unos resultados similares a los obtenidos utilizando el chip SN76489, con menos circuitería, ocupando menos pines y menos tiempo de procesamiento en el microcontrolador: nótense que ahora sólo necesitamos dos pines GPIO del microcontrolador (uno para /Laser y otro para /Noise).



Todo el código fuente puede descargarse de la sección soft.

[ añadir comentario ] ( 632 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 554 )

| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Siguiente> >>