Repaso de C: ficheros de cabecera y preprocesador

Ficheros de cabecera:

Los ficheros de cabecera o “header files” ya se han introducido en entradas anteriores, concretamente al revisar las librerías de cc65 para hacer entrada / salida, manejar un joystick, un ratón, gráficos bitmap, sprites o sonido.

Estos ficheros sirven para recoger definiciones de constantes simbólicas, prototipos de funciones, tipos de datos, macros, etc. Son útiles especialmente en el contexto de programas grandes que necesitan ser troceados en varios ficheros, o que utilizan librerías propias o ajenas.

De este modo, es posible concentrar esas definiciones comunes en uno o varios ficheros *.h, y luego referenciar esos ficheros y sus definiciones desde los ficheros *.c que las utilizan, evitando así los errores que podrían derivarse de tener que replicarlas en varios sitios.

Los ficheros de cabecera guardan mucha relación con el preprocesador de C. En primer lugar, porque muchas de las definiciones que incluyen, por ejemplo, una constante simbólica o una macro, se definen mediante directivas del preprocesador (ej. #define SPRITES_NUM 8). Y, en segundo lugar, porque para referenciar o incluir un fichero de cabecera en un fichero de implementación también se utilizan las directivas del preprocesador #include <fichero.h> o #include “fichero.h”.

La directiva #include, como su nombre indica, lo que hace es incluir las definiciones del fichero de cabecera en el fichero de implementación, justo antes de que éste se compile.

Preprocesador:

El preprocesador también lo hemos introducido en entradas anteriores. Como hemos adelantado, “es un paso previo a la compilación”.

Las dos directivas más habituales del preprocesador son #define, para definir constantes simbólicas y macros, e #include, para incluir el contenido de un fichero en otro. Ambas han sido presentadas también.

#define no sólo vale para definir constantes simbólicas, es decir, literales que se sustituyen por un valor (ej. #define SPRITES_NUM 8). También vale para definir macros.

Las macros son básicamente lo mismo que una constante, pero con algunos parámetros. Por ejemplo, si definimos #define MULTI(A, B) ((A)*(B)), cada vez que el preprocesador se encuentre MULTI(A, B) en el código fuente, lo sustituirá por ((A)*(B)), donde A y B lógicamente son parámetros. Es decir, MULTI(2, 4) se sustituiría por ((2)*(4)).

No es obligatorio abusar tanto de los paréntesis, pero es habitual hacerlo para garantizar la correcta asociación en caso de que los valores de A o B no sean simples (ej. si A = 1+1).

Una macro se parece a una función, porque tiene parámetros, pero no es lo mismo. Cada aparición de la macro se sustituye por su definición en tiempo de preprocesamiento, justo antes de compilar. Por tanto, el concepto es más similar al de una macro de un macroensamblador (defm – endm en CBM prg Studio).

Por otra lado, #include <fichero.h> sirve para buscar fichero.h en la ruta donde el compilador guarda por defecto las librerías, e incluirlo en el fichero que se está procesando. En el caso de cc65 esa ruta por defecto es cc65\include.

La variante #include “fichero.h” hace lo mismo, pero busca el fichero de cabecera primero en la ruta actual o de trabajo, es decir, donde se encuentra el fichero que se está procesando o compilando en ese momento. Si no lo encuentra ahí, busca en la ruta por defecto.

El preprocesador vale para más cosas, por ejemplo, para hacer inclusión condicional. Con una estructura como esta en un fichero de cabecera:

es posible evaluar una condición, concretamente si está definida la constante CONST, y actuar en consecuencia. En este ejemplo, si CONST no está definida se define y, además, se definen las constantes, macros, prototipos y demás construcciones que fueran de interés.

Estos controles son bastante habituales en los ficheros de cabecera. Ha de tenerse en cuenta que algunos ficheros de cabecera incluyen a otros, y estos a otros, por ejemplo, cabeceras de librerías estándar, y al final el galimatías de referencias cruzadas acaba siendo muy complejo. Por ello, para evitar que el preprocesador incluya varias veces las mismas definiciones, se verifica si una bandera (CONST) ya está definida y, caso de ya estarlo, no se hace nada; si no está definida, se define y se acompañan el resto de definiciones.

#ifdef CONST e #ifndef CONST, sin paréntesis, son versiones abreviadas de #if defined(CONST) e #if !defined(CONST) respectivamente.


Código de ejemplo: macros

Repaso de C: reglas de alcance y variables estáticas

Reglas de alcance:

Las reglas de alcance son las reglas que determinan en qué parte de un programa en C se ve o se puede usar un nombre de variable o función. Recordemos que un programa puede constar de varios ficheros fuente, no sólo uno.

Como regla general, en C no se puede referenciar nada que no haya sido declarado antes. Esto es así porque el compilador de C sólo hace una pasada. Por tanto, cuando el compilador de C está a medias de procesar un fichero fuente, sólo conoce lo declarado desde el comienzo del fichero hasta ese punto.

Si se trata de un parámetro de entrada o de una variable local declarada al comienzo de una función, el alcance es la propia función. Si se trata de una variable local declarada a mitad de la función, el alcance es desde ahí hasta el final de la función.

Si se trata de una variable externa o una función, el alcance es desde el punto en que están declaradas hasta el final del fichero en que se encuentran.

Si se quiere usar una variable externa declarada en otro fichero, entonces hay que usar extern para indicar que se trata de una variable global, y por tanto accesible, pero declarada en otro sitio.

Variables y funciones estáticas:

Las reglas de alcance anteriores se pueden modificar con la palabra reservada static, que se puede aplicar a variables y a funciones.

Cuando una variable global / externa se declara como static, entonces esa variable sólo está disponible desde las funciones del archivo en que está declarada. Es una forma de limitar el alcance de las variables globales.

Igualmente, cuando una función se declara como static, ésta sólo se puede llamar desde el fichero en que está implementada. Nuevamente, es una forma de limitar el alcance de las funciones.

Por último, cuando la palabra reservada static se aplica a una variable local a una función, lo que ocurre es que esa variable mantiene su valor entre llamadas.


Código de ejemplo: alcance

Repaso de C: funciones, variables externas y estructura de un programa

Funciones:

Una función es un conjunto de instrucciones relacionadas entre sí y que implementan una funcionalidad concreta y autocontenida.

Las funciones constan de:

  • Un prototipo, que es opcional.
  • Una definición o implementación.

El prototipo sólo es necesario si la función se utiliza, es decir, se llama, antes de ser definida. Consiste en el nombre de la función, su valor de retorno y sus parámetros. Los parámetros se suelen documentar con tipo y nombre, aunque para el prototipo es suficiente con el tipo.

Un ejemplo de prototipo de función sería así:

Y luego está la definición o implementación de la función, que es la función propiamente dicha. La definición consta de nombre, valor de retorno, parámetros (tipo y nombre), y cuerpo, que constará de un conjunto de instrucciones.

No todos estos elementos son obligatorios. En realidad, sólo el nombre lo es. El retorno, los parámetros, e incluso el cuerpo son opcionales (aunque no tiene mucho sentido una función vacía, es posible hacerla).

Un ejemplo de implementación de función sería así:

No es lo mismo el tipo void, que significa que no hay parámetros o retorno, que no poner tipo, en cuyo caso se sobreentiende que es int. No se considera buena práctica apoyarse en estos tipos implícitos.

Lógicamente, la implementación tiene que concordar con el prototipo, en el sentido de usar el mismo nombre de función y los mismos tipos de parámetros y retorno. Si no fuera así, el compilador se quejaría.

La comunicación entre la función llamante y la función llamada es mediante los parámetros y el retorno. Al llamar, los parámetros se convierten en variables locales a la función, a las que se copia el valor de los parámetros (paso por valor, con la excepción de los arrays y las cadenas que se pasan por referencia o puntero).

La comunicación en sentido contrario es mediante el retorno, que se produce cuando la función ejecuta la instrucción return. La función llamante puede usar ese retorno o prescindir de él.

Variables externas:

Las variables externas ya las hemos introducido en alguna entrada anterior. Son variables que no se definen dentro de una función y, por tanto, son globales al programa. En principio, son visibles en todas las funciones del programa, salvo que se declaren como static, en cuyo caso sólo se ven desde el fichero en que están declaradas.

Las funciones son, en cierto modo, siempre externas, ya que C no permite definir funciones dentro de otras funciones.

Otra variante del main() anterior usando una variable externa sería así:

Como se puede observar, la variable externa o global a es visible desde main().

Por tanto, otra forma de comunicación entre un programa o función llamante y una función llamada es mediante las variables externas que, al ser globales al programa, son visibles desde todas las funciones (en principio).

Estructura de un programa en C:

Las funciones son la abstracción principal de un programa en C. Permiten dividir un problema complejo en varios problemas más sencillos, reutilizar código y crear niveles de abstracción.

Por tanto, un programa en C básicamente consiste en un conjunto de variables externas y funciones que se organizan en uno o varios ficheros, que se compilan por separado.

Una de esas funciones debe ser la función main(), que es donde empieza la ejecución del programa.


Código de ejemplo: funciones

Repaso de C: control de flujo

En C las instrucciones o proposiciones se terminan con ; . Las instrucciones se pueden agrupar con { … } para dar lugar a proposiciones compuestas o bloques.

Las instrucciones de control de flujo son las que permiten tomar decisiones y controlar por dónde fluye la ejecución del programa. En el caso de C son:

  • if – else if – else.
  • switch.
  • while.
  • for.
  • do – while.
  • break y continue.
  • goto y etiquetas.

Lo más simple es ver un ejemplo de cada tipo:

if – else if – else:

La instrucción if evalúa una condición y, si se cumple, ejecuta el bloque que sigue. Este bloque puede ser simple, en cuyo caso no hacen falta llaves, o compuesto:

En caso de que no se cumpla la condición, tenemos la rama else opcional, que también puede tener un bloque simple o compuesto:

Y si se quieren evaluar varias condiciones seguidas, tenemos else if:

switch:

La instrucción switch es similar al if – else if – else, es decir, también vale para decisiones múltiples. La principal diferencia es que, en vez de evaluar una expresión arbitraria y decidir si es cierta (!= 0) o falsa (== 0), como el if, compara la expresión, normalmente una variable, con un conjunto limitado de valores constantes.

Por ejemplo, la instrucción switch:

es básicamente equivalente a if (a == 0) {…} else if (a == 1) {…} else if (a == 2) {…} else {…}.

Sí hay un matiz importante, y es que en un if – else if – else sólo se ejecuta una de las ramas. Sin embargo, en un switch se evalúan y ejecutan todas las ramas case a partir de la coincidencia, salvo que de forma explícita se decida salir del switch con un break. La instrucción break también se utiliza para salir de bucles while, for y do – while.

while:

La instrucción while sirve para hacer bucles. La condición de terminación del bucle se evalúa primero, y el bucle se ejecuta hasta que deja de cumplirse:

Cuando el cuerpo del while es una proposición simple también se pueden obviar las llaves. Esto es una norma general de todas las instrucciones de control de flujo.

for:

La instrucción for también vale para implementar bucles. Es un poco más compleja que while, porque tiene una inicialización, una condición de terminación del bucle, y una expresión que se ejecuta al final de cada iteración, y que típicamente se utiliza para incrementar o decrementar un contador.

Lo bueno del for es que todo lo que controla el bucle, la inicialización, la condición de terminación / continuación, y el cambio de una iteración a la siguiente, están claros y en el mismo sitio.

do – while:

El do – while es el tercer tipo de bucle en C. A diferencia del while, primero se ejecuta el cuerpo el mismo, y luego se comprueba la condición. Por tanto, un do – while tiene sentido cuando el bucle tiene que ejecutarse al menos una vez:

Hay bucles, sin embargo, que tienen que ejecutarse 0 o más veces, en función del valor de una variable. Para estos es mejor un while.

break y continue:

En todos los tipos de bucles cabe la opción de salir del mismo. Esto se hace con la instrucción break, que ya se presentó con el switch.

Igualmente, también cabe la opción de saltar a la siguiente iteración, lo cual se hace con la instrucción continue. Esto no tiene sentido en un switch:

goto y etiquetas:

Por último, C soporta etiquetas y goto. Las etiquetas son como las etiquetas de ensamblador: sirven para marcar posiciones en el código. Se nombran como las variables (letras, números y algunos símbolos), pero tienen que terminar con : .

Por su parte, el goto es una instrucción que hace saltar la ejecución a una etiqueta. El goto y la etiqueta tienen que estar en la misma función:

En general, el goto está desaconsejado, porque todo lo que se puede escribir con goto se puede también escribir sin él, resultando más fácil de entender y mantener.


Código de ejemplo: control_flujo

Repaso de C: operadores y expresiones

Operadores y operandos:

Como en el caso de las variables, todo programador tiene una idea intuitiva de qué es un operador: es una operación que relaciona unos operandos (variables o constantes) para devolver un valor.

En C, como en otros lenguajes, hay muchos tipos de operadores:

  • Operadores aritméticos: Sirven para realizar operaciones aritméticas. Son +, -, *, / y % (resto de la división entera). No hay exponenciación.
  • Operadores de relación: Sirven para comparar. Son >, >=, <, <=, == y !=. No se debe confundir la igualdad (==) con la asignación (=).
  • Operadores lógicos: Sirven para realizar operaciones lógicas o booleanas, es decir, && (AND), || (OR) y ! (NOT). En C no existe el tipo boolean, así que se toma 1 como true (realmente distinto de 0) y 0 como false.
  • Operadores de incremento y decremento: Son ++ y –, y sirven para incrementar o decrementar una variable. Lo curioso es que pueden usarse como prefijo o sufijo, hablándose de preincremento, postincremento, etc. En la versión prefijo primero se incrementa o decrementa y luego se devuelve el valor; en la versión sufijo primero se devuelve el valor y luego se incrementa o decrementa.
  • Operadores de manejo de bits: Son & (AND), | (OR), ^ (OR exclusivo), << (desplazamiento a la izquierda), >> (desplazamiento a la derecha) y ~ (complemento a uno o negación). Como se puede ver, C es un lenguaje de alto nivel, pero no tanto, porque se puede operar directamente con bits.
  • Operadores de asignación: El operador de asignación por excelencia es el =, que asigna un valor a una variable. Pero hay más, porque la mayoría de operadores binarios (+, -, *, /, %, …) tienen también un operador de asignación asociado (+=, -=, *=, /=, %=, …). Por ejemplo, la asignación i = i + 2 también puede expresarse como i += 2.

Hay más operadores, como los que tienen que ver con estructuras (. y ->), con arrays ([]), con direcciones y punteros (* y &), con funciones (paréntesis), con conversión de tipos o “casting”, sizeof para obtener el tamaño de una variable, etc. Todos estos operadores se verán más adelante.

Lo que sí es bastante complejo son las reglas de precedencia y asociación de los operadores de C (extracto del libro de Kernighan & Ritchie):

Expresiones:

Las expresiones son un tipo especial de instrucción que devuelven un valor. Por ejemplo, todas las operaciones vistas anteriormente (aritméticas, de relación, booleanas, de manejo de bits, etc.) conforman expresiones, porque devuelven un valor.

En particular, las asignaciones no sólo almacenan un valor en una variable; además, devuelven ese valor para hacer más cosas con él, si se quiere. Por este motivo se pueden hacer cosas como i = j = 0. Esto no sólo asigna 0 a la variable j, sino que además devuelve el valor 0, que se asigna a la variable i. Es decir, la asignación j = 0 es, además, una expresión.

Hay un tipo especial de expresión que es la expresión condicional. Ésta verifica una condición y, en función del resultado, devuelve un valor u otro. Se expresa con el operador ternario ? : . Por ejemplo, (a > b) ? a : b devuelve a si a > b y b en caso contrario.


Código de ejemplo: operadores

Repaso de C: variables, tipos, constantes y declaraciones

Variables:

Todo programador tiene una idea intuitiva de qué es una variable: un trocito de memoria que guarda un valor. Las variables tienen:

  • Una dirección de memoria.
  • Un nombre.
  • Un valor, que puede cambiar, por eso se llama variable.
  • Un tipo.

En C, los nombres de las variables tienen que empezar por una letra, y pueden contener letras, algunos símbolos y números. Por supuesto, el nombre no puede ser una palabra reservada del lenguaje. Las mayúsculas y las minúsculas se consideran diferentes. Las variables se suelen nombrar con minúsculas y las constantes simbólicas con mayúsculas.

Tipos de datos:

Las variables tienen un tipo, que define el conjunto de valores que pueden tomar. Los tipos de datos pueden ser básicos o estructurados.

Los tipos básicos son los más sencillos, los que aporta el lenguaje de forma nativa. En el caso de C son:

  • char.
  • int.
  • float (no soportado en cc65).
  • double (no soportado en cc65).

Estos tipos básicos se pueden modificar con los calificadores:

  • short y long.
  • signed y unsigned.

El primero hace referencia al tamaño en bytes, y el segundo al carácter con signo / sin signo.

Una curiosidad de C es que no fija el tamaño en bytes de sus tipos básicos o primitivos. Sólo fija que un short tiene que ser menor que un int, que tiene que ser menor que un long. Esto ha dificultado su portabilidad entre máquinas.

Constantes y constantes simbólicas:

Las constantes –o valores constantes– son valores fijos que se utilizan en inicializaciones, expresiones, llamadas a funciones, etc. Las constantes también tienen un tipo, que puede ser el natural a partir de su sintaxis o se puede forzar mediante sufijos. Lo mejor es ver ejemplos:

  • 1234 es una constante de tipo int.
  • 123456789L es una constante de tipo long.
  • 123.4 es una constante de tipo double.
  • ‘A’ es una constante de tipo char.
  • “programa” es una constante de tipo array de char (cadena).
  • Etc.

Las constantes de tipo int se pueden expresar en decimal, en octal empezando por 0, o en hexadecimal empezando por 0x.

Los caracteres especiales se pueden representar mediante secuencias de escape. Por ejemplo, la nueva línea se puede representar con \n, la barra invertida con \\, etc. También se puede usar \xHH, donde HH es el valor ASCII en hexadecimal.

Con la instrucción del preprocesador #define es posible definir constantes simbólicas, es decir, literales que equivalen a un valor fijo. Por ejemplo, #define SPRITES_NUM 8 hace que el literal SPRITES_NUM equivalga a 8.

También es posible definir enumeraciones, por ejemplo:

enum meses {ENE=1, FEB, MAR, ABR, MAY, JUN, JUL, AGO, SEP, OCT, NOV, DIC}

lo cual equivale a definir ENE = 1, FEB = 2, MAR = 3, etc.

Declaraciones:

Para poder usar una variable, antes hay que declararla. Al declararla se indica:

  • Su tipo.
  • Su nombre.
  • Y una inicialización opcional.

Por ejemplo:

  • int contador = 0;
  • char car = ‘a’;

En función de donde se declaren, las variables pueden ser locales a una función o globales al programa, que en C se llaman variables “externas”.

Las variables locales, si no se inicializan, contienen basura. Y si están inicializadas, se inicializan en cada ejecución de la función (salvo que se declaren static, como veremos más adelante).

Las variables globales se inicializan sólo una vez antes de iniciarse la ejecución del programa. Se inicializan a 0 salvo que tengan una inicialización explícita.

En una declaración se puede usar el calificador const para indicar que una variable no cambia su valor. Sería una variable constante, si vale la paradoja. También se puede usar const en los parámetros de las funciones.


Código de ejemplo: variables

Repaso rápido del lenguaje de programación C

Llegados a este punto, y de cara a desarrollar un posible proyecto en C para el C64, lo suyo sería repasar de forma breve, aunque sistemática, el lenguaje de programación C.

Y la biblia de C ya sabéis cual es, el libro escrito hace décadas por los diseñadores del lenguaje (Brian Kernighan y Dennis Ritchie). Este libro ha tenido varias ediciones según ha ido evolucionando el lenguaje y, en particular, por su proceso de estandarización en ANSI:

Siguiendo un poco la estructura de este libro, los aspectos principales a revisar serían:

  • Variables.
  • Tipos de datos.
  • Constantes.
  • Declaraciones.
  • Operadores.
  • Expresiones.
  • Control de flujo.
  • Funciones.
  • Variables externas.
  • Estructura de un programa C.
  • Reglas de alcance.
  • Variables estáticas.
  • Ficheros de cabecera.
  • Preprocesador.
  • Punteros.
  • Arrays.
  • Estructuras.
  • Uniones.
  • Typedef.
  • Librerías estándar.

Por supuesto, el lenguaje C tiene muchos más aspectos, pero lo anterior es lo fundamental. Sólo eso ya dará para varias entradas.

Un libro interesante de programación retro en C

A lo largo de este blog ya hemos mencionado, al menos en un par de ocasiones, la página web https://8bitworkshop.com/, relativa a la programación retro de diferentes máquinas de 8 bits. Pues bien, vinculados a esa página web hay varios libros que se puede ver en la dirección:

https://8bitworkshop.com/docs/books/index.html

De todos ellos, el que me parece más interesante es el titulado “Making 8-bit arcade games in C”, de Steven Hugg:

Como indica su título, está dedicado a la programación en C de juegos para máquinas de 8 bits.

Es importante destacar que el libro no trata específicamente el C64. Sí trata otras plataformas de los 80 como Midway 8080, VIC Dual, Galaxian / Scramble, Atari y Williams. Se trata de plataformas hardware específicas para juegos de tipo arcade.

De hecho, el libro toca un poco de todo:

  • Conceptos básicos de microprocesadores.
  • Conceptos y ejemplos de programación en C.
  • Plataformas de hardware como las mencionadas.
  • Y la programación de diferentes proyectos o juegos.

El libro va saltando de una temática a otra a lo largo de sus 31 capítulos y 220 páginas, pero yo diría que el hilo conductor principal son las diferentes plataformas y cómo fueron evolucionando con el paso del tiempo.

En definitiva, un libro interesante y que guarda relación con lo que venimos tratando aquí últimamente.

Desarrollo de una librería de sonido en C

Igual que hemos hecho con los sprites, se podrían desarrollar librerías en C para trabajar en modo bitmap, modo bitmap multicolor, definir y activar juegos de caracteres personalizados, hacer scroll, etc. Algunas de estas librerías ya estarían parcialmente cubiertas por otras propias de cc65, como TGI; otras no.

Sea como fuere, la otra gran librería que podemos hacer es una librería de sonido en C, y a esto es a lo que vamos a dedicar esta entrada.

Nuevamente, si nos inspiramos en la librería en ensamblador del Volumen II:

https://programacionretroc64.files.wordpress.com/2019/11/lib-v2.zip

y, más concretamente, en el fichero “LibSonido.asm”, vemos que tiene rutinas para:

  • Inicializar una imagen del SID.
  • Transferir la imagen del SID (al SID, claro).
  • Fijar el volumen.
  • Fijar la frecuencia de una voz.
  • Fijar la forma de onda de una voz.
  • Fijar el ADSR de una voz (attack – decay – sustain – release).
  • Fijar el ancho de pulso de una voz, en caso de que la forma de onda sea cuadrada.
  • Configurar un filtro sobre una voz.
  • Activar una voz.
  • Desactivar una voz.
  • Pasar de octava y nota a frecuencia.
  • Introducir un retardo.

Pues bien, todo esto perfectamente puede hacerse en C. Es más, es mucho más cómodo usarlo mediante una librería (pareja de ficheros sonido.h y sonido.c) que conociendo y manejando las estructuras de datos que cc65 define en _sid.h:

Vamos a ello:

Fichero de cabecera sonido.h:

El fichero de cabecera tiene una primera parte de definición de constantes que es así:

Es decir, aparte de controlar si el fichero ya está incluido con __SONIDO_H, define constantes para el tamaño del SID (25 registros de sólo escritura; 29 en total), para las tres voces (que ahora vamos a numerar 0, 1 y 2), para las formas de onda y para los tipos de filtro. También define el tipo byte.

A partir de ahí, incluye prototipos para las funciones de interés:

No vamos a repetir aquí las funciones, porque en el fondo son las mismas que las rutinas que ya se han enumerado para la librería en ensamblador.

Sí interesa recordar que, al ser el SID en su mayoría registros de sólo escritura, salvo los cuatro últimos ($d419 – $d41c), que son de sólo lectura, aplicaremos la técnica de trabajar sobre una imagen del SID (un array de 25 posiciones, uno por cada registro de sólo escritura) y copiar o transferir esa imagen al SID cuando se quiera configurar éste. De este modo será posible conocer y modificar el estado del SID a partir de su imagen.

Fichero de implementación sonido.c:

El fichero que implementa la librería empieza con esta apariencia:

Es decir, primero incluye el header file sonido.h (con “…” para que no se busque en cc65\include, sino en el mismo directorio que sonido.c) y algunas librerías estándar. Después recoge tres tablas de interés:

  • La tabla sonido_imagen_sid[] es la imagen del SID. Esta es la tabla que vamos a configurar con las diferentes funciones y, llegado el momento, vamos a transferir o copiar al SID.
  • La tabla sonido_offset_voces[] nos da el offset de los registros de cada voz ($00, $07 y $0e respectivamente) dentro del mapa de memoria del SID.
  • La tabla sonido_frecuencias_oct7[] nos da las frecuencias correspondientes a las 12 notas de la octava 7, y que nos permite calcular las frecuencias de esas mismas notas en otras octavas (0…6).

A partir de ahí, vienen las implementaciones de las diferentes funciones. E, igual que en el caso de la librería de sprites, no vamos a revisarlas todas, sino alguna seleccionada:

Por ejemplo, para la función sonido_fija_volumen():

  • Recibe el volumen en un byte, aunque sólo ocupa un nibble.
  • Se queda con el nibble bajo mediante un AND (volumen & 0x0F).
  • Toma la posición 0x18 = 24 de la imagen del SID, le hace un OR con el volumen (sonido_imagen_sid[0x18] | volumen), y lo vuelve a guardar en la posición 0x18.

Esto último, es decir, el tomar la posición y hacerle un OR, lo podemos hacer porque estamos trabajando con una imagen del SID. Directamente contra el SID no sería posible hacerlo, porque esos registros son de sólo escritura.

Ya sólo nos quedaría transferir la imagen al SID para que el cambio de volumen fuera efectivo.

Por otro lado, la función sonido_fija_frecuencia() hace cosas parecidas, por ejemplo un AND (frecuencia & 0x00FF) para quedarse con el byte low o menos significativo de la frecuencia, y un desplazamiento de 8 bits a la derecha (frecuencia >> 8) para quedarse con el byte high o más significativo.

Todo esto demuestra que C es un lenguaje de “alto nivel”, pero no mucho, ya que tiene operadores como &, |, >> y muchos otros que permiten operar con bits de forma similar al ensamblador.

Programa de ejemplo:

Para el programa de ejemplo nuevamente nos vamos a basar en uno ya conocido, concretamente el programa 53 del Volumen I:

Este programa, tanto la versión en ensamblador como la versión en C, tiene una tabla de cuatro entradas, siendo cada entrada:

  • La octava y la nota de la voz 0.
  • La octava y la nota de la voz 1.
  • La octava y la nota de la voz 2.
  • La duración y el volumen, que son comunes a las tres voces.

Y para que la tabla con la melodía ocupe lo menos posible, octavas y notas se codifican en el mismo byte, a razón de un nibble cada una, al igual que la duración y el volumen.

Por tanto, lo que tiene que hacer el programa es inicializar las voces con su forma de onda y ADSR y, luego, reproducir la melodía. Para esto último:

  • Lee un byte de la tabla, separa octava y nota, calcula la frecuencia, y configura la voz 0 con esa frecuencia.
  • Idem para la voz 1.
  • Idem para la voz 2.
  • Lee un byte de la tabla, separa duración y volumen, y configura el volumen para las tres voces.
  • Activa las tres voces y transfiere la imagen al SID. Hasta este punto el SID no ha cambiado.
  • Espera la duración de la nota.
  • Y repite todo lo anterior hasta que termine la melodía, lo cual se señaliza con el byte $ff, que no es un byte válido pues el C64 no soporta ni $0f = 15 octavas ni 15 notas por octava.
  • Para terminar, desactiva las tres voces y vuelve a transferir la imagen al SID. Esto último –desactivar las voces– se puede hacer en cada iteración del bucle, es decir, de una nota a la siguiente, pero si se hace sólo al final del mismo la reproducción queda como más fluida.

Y como el programa es largo, aunque esencialmente es un bucle, sólo vamos a ver en detalle algunas de sus funciones más representativas. Por ejemplo, la función configura_frecuencia() es así:

Es decir, recibe como parámetros la pareja (octava, nota) y la voz, que salen de la tabla con la melodía, y separa el nibble alto (octava) del nibble bajo (nota), imprime ambos con printf(), obtiene la frecuencia asociada con sonido_obten_frecuencia(), también la imprime, y fija esa frecuencia para la voz en cuestión.

Por su parte, configura_volumen() es parecida:

Es decir, recibe la pareja (duración, volumen), separa el nibble alto (duración) del nibble bajo (volumen), imprime ambos con printf(), y fija el volumen con sonido_fija_volumen(). Aquí no interviene la voz porque el volumen es común para todas.

Si compilamos y ejecutamos el programa el resultado es así, aparte de escuchar una bonita canción:

Los datos que se muestran en cada fila son la voz, la octava, la nota y la frecuencia. Esto para las tres voces (numeradas ahora como 0, 1 y 2), y luego la duración y el volumen.


Código de ejemplo: sonido

Desarrollo de una librería de sprites en C

En la entrada anterior hemos visto que el manejo de sprites (y del VIC en general) con cc65 es sencillo. Llega con conocer las estructuras de datos de _vic2.h. y hacer asignaciones de valores a los registros.

Ahora bien, se puede hacer todavía más sencillo. Si recordamos, cc65 aporta header files, es decir, constantes, funciones y otras cosas para el manejo de joystick, ratón, consola, disco, gráficos, etc. ¿Por qué no hacer lo mismo con los sprites? ¿Por qué limitarnos a las estructuras de datos de _vic2.h y similares?

Y esto es precisamente lo que me planteo en esta entrada. Inspirándonos en la librería en ensamblador para manejar sprites del Volumen II, hacer una librería equivalente en C. Y la implementación de esa librería se apoyaría en _vic2.h.

Vamos a ello. Partimos del fichero lib-v2.zip disponible aquí:

https://programacionretroc64.files.wordpress.com/2019/11/lib-v2.zip

El contenido de ese ZIP es así (hay muchas librerías para diferentes propósitos):

Y si analizamos el fichero “LibSprites.asm” veremos que contiene estas rutinas:

  • Una rutina para copiar los datos de un sprite de un origen a un destino.
  • Una rutina para configurar los colores multicolor.
  • Una rutina para hacer la configuración básica de un sprite, es decir, para configurar su bloque, su color y activarlo.
  • Una rutina para posicionar un sprite.
  • Una rutina para hacer la configuración avanzada de un sprite, es decir, para configurar el multicolor, la expansión horizontal y/o vertical, y la prioridad sobre el fondo.
  • Una rutina para decidir si dos sprites están o no en colisión.

Todo esto lo vamos a convertir en dos ficheros en C:

  • sprites.h, que será el header file con los prototipos, constantes y demás.
  • Y sprites.c, que será la implementación de las funciones.

Por claridad, los nombres de las funciones serán similares a los de las rutinas, y lo de los parámetros también, salvando las diferencias lógicas por las nomenclaturas de nombrado en ensamblador y en C.

Fichero de cabecera sprites.h:

El fichero sprites.h quedaría así. Tendría una primera parte con constantes:

Una primera constante (__SPRITES_H) serviría para controlar si el header file ya está incluido en un programa y, en tal caso, no volver a incluirlo. A partir de ahí, habría constantes para el tamaño de los sprites (64 bytes), el número de sprites (8), controlar el multicolor, la expansión X e Y, la prioridad del fondo y las colisiones.

También aprovechamos para definir el tipo byte, que equivale a un unsigned char de C. Este tipo es muy útil para programar el C64, ya que es un ordenador de 8 bits.

Y la segunda parte sería así:

Es decir, primero define una estructura (el tipo de dato) para tener acceso a los punteros de los sprites, y luego define SPRITES_PUNTEROS como un puntero a ese tipo de estructura y lo vincula la posición $07f8, que es donde empiezan los punteros de los sprites ($07f8 – $07ff).

Por último, define los prototipos de las funciones que permiten copiar los datos de un sprite, configurar los multicolores, hacer la configuración básica de un sprite (puntero / bloque, color y activación), posicionar un sprite, hacer la configuración avanzada (multicolor, expansión y prioridad del fondo) y, por último, la función que permite detectar si dos sprites han colisionado.

Por supuesto, se pueden definir más funciones con otros propósitos, por ejemplo, para animar sprites, pero lo anterior es lo básico.

Fichero de implementación sprites.c:

La implementación de la librería de sprites es el fichero sprites.c. Este fichero incluye el sprites.h (#include <sprites.h>) y, a partir de ahí, aporta implementaciones para sus funciones. Estas implementaciones lógicamente se apoyan en las estructuras de datos de _vic2.h.

A modo de ejemplo, la implementación de la función sprites_conf_basica() sería así:

Es decir:

  • Recibe tres parámetros, el número de sprite, el bloque donde está almacenada la definición y el color deseado.
  • Guarda el bloque recibido en el puntero del sprite.
  • Guarda el color deseado en el registro del VIC encargado del color.
  • Y, mediante un OR, es decir, mediante el operador | de C, activa el bit correspondiente al sprite en el registro encargado de habilitar los sprites.

Las otras funciones tienen implementaciones similares. Su revisión queda como ejercicio para el lector interesado.

Programa de ejemplo:

Lo último sería hacer un programa de ejemplo que, usando la nueva librería, maneje sprites. Para ello nos inspiramos en el ejemplo de la entrada:

La versión en C sería así:

Es decir:

  • Limpia la pantalla con la función clrscr() de conio.
  • Instala el driver de los joysticks.
  • Configura los sprites.
  • Y, a partir de ahí, entra en un bucle que mueve un sprite, analiza las posibles colisiones y espera un tiempo. Y lo mismo con el otro sprite.

Las funciones para configurar los sprites, moverlos y analizar las colisiones se apoyan en la nueva librería.

Para configurar los sprites, la función conf_sprites() hace así:

A saber, configura los multicolores, que son compartidos entre todos los sprites, copia la definición del sprite 0 en el bloque 0 (254), hace su configuración básica (pone el puntero apuntando al bloque 254, configura el color verde y activa el sprite), hace su configuración avanzada (activa el multicolor, desactiva las expansiones y da prioridad al fondo), y termina con su posición. Y algo análogo con el sprite 1.

Por otro lado, para mover los sprites las funciones mueve_sprite_0() y mueve_sprite_1() hacen así:

Es decir, leen el joystick 1 y 2 respectivamente, y, en función del movimiento leído incrementan o decrementan la coordenada X o la Y. Finalmente, vuelven a posicionar el sprite.

Por último, para detectar las colisiones la función analiza_colision():

Es decir, se apoya directamente en la función sprites_colision() de la nueva librería. Y, en caso de detectar la colisión, termina la ejecución con exit().

El resultado es un viejo conocido:

En conclusión, que no hay por qué limitarse al uso de las estructuras de datos de _vic2.h. Sobre éstas es posible programar librerías que simplifiquen todavía más la programación con sprites o del VIC en general.


Código de ejemplo: sprites