Cómo se accede al hardware

Dado el éxito que están teniendo estas entradas, voy a dar otra vuelta de tuerca al tema y voy a explicar cómo un programador puede trastear con el hardware, cosa que al final, todos los micros terminan haciendo. No voy a hablar de Windows, ni de ningún otro sistema operativo, sino que voy a intentar explicar cómo, a través del código, se accede al hardware, periféricos y demás. Esta entrada es completamente teórica y no me voy a centrar en ningún micro ni hardware en concreto, ya que no vale la pena dada la amplia variedad disponible y que, además, esto no se puede hacer desde Windows (ni desde cualquier otro PC si no arrancamos con algo como MS-DOS y a veces ni siquiera así).

Todos sabéis (o deberíais) que un microprocesador consta de varios subconjuntos lógicos: la ALU, que hace cálculos aritméticos, los registros de propósito general, que son unas celdillas de memoria interna al micro para usos varios, la unidad de decodificación y ejecución, que se encarga de interpretar la instrucción cargada, y en micros más modernos podemos encontrarnos con módulos convertidores analógico-digitales, unidades de coma flotante, unidades SIMD, etc.

La arquitectura de los microcontroladores es algo diferente a la de los microprocesadores, muchas veces sólo en apariencia, aunque en general programar un microcontrolador es más fácil porque normalmente sólo hay que ir activando bloques y funcionan sin más. Es decir, si queremos un timer, o un AD, o una UART (puerto serie, para entendernos), simplemente se activan y configuran con las instrucciones adecuadas y listo. En un microprocesador suele ser más complicado, ya que hay de por medio otros temas, como prioridades entre los dispositivos y demás.

Por otro lado, cuando en informática se habla de unos y ceros, se está haciendo una importante abstracción lógica. En general, a nivel de señales eléctricas, lo que se entiende por un uno podría ser que una pata del micro esté a 5V, pero no siempre es así, porque si es una arquitectura basada en lógica negativa, un uno será cuando dicha pata esté a 0V y un cero cuando valga 5V. Es decir, dependiendo de la lógica, un uno será una cosa u otra. De hecho, conforme vamos avanzando en velocidades, un uno ni siquiera es un nivel, sino un flanco. En otras palabras, cuando nosotros leemos una pata de un micro a 1 lo que hemos leído es el paso de la misma de 0 a n voltios, o al revés. De hecho, nunca debemos asumir que, si leemos algo y está a 1, la pata equivalente vaya a estarlo también. Dependerá de muchas cosas y siempre tendremos que morir en la documentación técnica del micro, de la placa o de quien quiera que haya hecho nuestro hardware.

Y con esto hemos dado un paso más: podemos leer el nivel (o mejor dicho, el estado) de una pata. Es decir, dentro de nuestro micro hay algo que cuando nosotros ejecutamos una instrucción en ensamblador, nos devuelve un 0 o un 1 dependiendo de lo que haya ocurrido en una de sus patas. En general, y dependiendo del tipo de micro, las patas vienen agrupadas en bloques de 8 o 16, que es lo que se conoce como un puerto, y una lectura sobre él nos devolverá un byte o un word con el equivalente lógico.

En los microcontroladores es habitual que el espacio de direcciones se encuentre separado por tipos. Tendremos un espacio de direcciones para el código, otro para la RAM (ya sea interna o externa), otro para los puertos que acabamos de ver, para EEPROM interna si tenemos, etc. O visto de otra forma: no nos vale con decir que queremos leer de la dirección de memoria 0x12345678, sino que tenemos que indicar también de qué tipo es. En general, esto se consigue con el tipo de instrucción ensamblador, que es diferente para cada uno.

No obstante, a veces los espacios están mezclados, o solo tenemos uno, o dependiendo del rango de dirección estaremos actuando sobre la RAM, la ROM o lo que sea. Todo ello depende del micro y cómo se configure, y os puedo asegurar que cada fabricante lo hace de una forma, no sólo para los suyos, sino incluso para cada familia o grupo.

Si esos elementos están dentro del micro, lo tenemos fácil, ya que con muy pocas instrucciones (y a veces ninguna), éste está listo para funcionar. Hay micros que tienen RAM, EEPROM, ROM, IO, todo ello dentro de sí mismo, pero hay otros en los que algunos de estos elementos están (o pueden estar) fuera, en circuitos independientes.

Es entonces cuando entran en juego el chip select, que son unas patas extra que sirven para alertar al dispositivo adecuado. Supongamos por un momento que nuestra arquitectura tiene todos los elementos externos y que vamos a configurarlos de la siguiente manera: de la dirección de memoria 0 a la 0x7fff, está la ROM en el chip select 0. De la 0x8000 a la 0x8fff tenemos la EEPROM, y de la 0x9000 a la 0xafff la RAM. Lo primero es conectar esos rangos del bus de direcciones con cada chip equivalente. Por ejemplo, para la ROM podemos dejarnos sin conectar la pata 15 del bus de direcciones porque no va a ser usada nunca, ahorrando complejidad en las pistas.

Luego tenemos que cablear los chip select, que en nuestro caso son dos patas extra del micro. Cuando en ellas haya un 00, estaremos accediendo a la ROM, cuando haya un 01 a la RAM, y cuando haya un 10 a la EEPROM. Si nos damos cuenta nos queda la combinación 11, que podríamos asignar a periféricos o dejar sin usar. Tenemos que poner combinaciones de puertas lógicas de tal forma que cada una de esas combinaciones activen el o los chips correspondientes.

¿Cómo? Pues con lógica combinatoria, puertas AND, NAND, NOR, XNOR, etc. No hay una única solución, y cuando las placas son algo complejas se suele usar una FPGA o similar, que debe ser programada para esto y para más cosas. Recordemos que quizás un chip esté activo cuando su pata CS esté a cero, y si se tratara de los chips donde está la ROM, tendríamos que primero negar los dos CS que salen del micro y luego poner una NAND entre ellos (y da la casualidad que hay un chip con esa configuración exacta).

Ahora que ya tenemos el hardware, nos hace falta el software. Lo primero es decirle al micro qué CS se corresponde a qué área de memoria, de forma que cuando escribamos dentro de un rango, se active automáticamente la combinación de CS elegida.

No lo he dicho, pero un micro tiene un bus de direcciones y un bus de datos. En el bus de direcciones se pone la dirección a acceder, y en el de datos o bien obtenemos el dato que haya en esa dirección o bien lo ponemos nosotros para que sea leído por el elemento externo. En el caso que estamos viendo, se trata de un micro de 8 bits, que tendrá un bus de 16 bits (generalmente 16 patillas dedicadas a ello, aunque a veces no son tantas y entonces hay que hacer “trucos” que no voy a explicar aquí) y otro de 8 para los datos. Volviendo a los párrafos anteriores, tanto el bus de datos como el de direcciones está conectado a todos los chips externos que los necesiten (ya hemos visto que, por ejemplo, el bit de mayor peso del de direcciones no es necesario para la ROM y por tanto no es necesario cablearlo). Con el CS correspondiente, tan sólo se activará el chip correspondiente, que atenderá la petición.

Ahora necesitamos una pata extra, la WR, para indicar al periférico si queremos leer o escribir. Esto también lo controla el micro automáticamente, es decir, si ejecutamos una instrucción de ensamblador que es una lectura, cambiará la pata de forma adecuada y automáticamente.

Pero todavía no hemos terminado. Nos falta el reloj, ya que sin él todo lo que hemos explicado estaría muerto. El reloj no es más que un generador de onda cuadrada (más o menos, he visto relojes con el osciloscopio que me dan serias dudas de que la placa funcione) al que se conectan absolutamente todos los periféricos y el micro, y aquí sí, aquí lo válido son los flancos. A cada flanco, se produce un paso. Es lo que se llama ciclo de reloj. En algunos micros y hardware son válidos tanto los flancos de subida como los de bajada, en otros sólo los de subida o los de bajada.

Ante cada ciclo, se produce un evento en el sistema. Imaginemos que el micro ya sabe qué instrucción va a ejecutar. Pongamos por caso que se trate de una lectura de una dirección de RAM. Pasa un ciclo y el micro mueve las patas de los CS y WR, alertando al chip de la RAM. En el caso que nos ocupa, los CS valdrían 01, lo que activaría la entrada CS de la RAM (y sólo de la RAM). Al ciclo siguiente el micro pone en el bus de direcciones la dirección a leer. Dado el CS adecuado, sólo la RAM se pondrá a la escucha, o validará lo que quiera que venga después. En el siguiente ciclo la RAM leerá del bus de direcciones, y necesitará un determinado número de ciclos más para hacer su trabajo, al cabo de los cuales pondrá en el bus de datos el valor solicitado, y en el siguiente, el micro lo tomará. Esto es sólo un posible ejemplo de lo que ocurre. Algunos elementos necesitan un solo ciclo. Por ejemplo, el micro podría poner la dirección, los CS y el WR en un solo ciclo, y la RAM ser tan rápida que al siguiente ya tenga la solución en el bus de datos. En general, cuando un elemento complejo necesita un solo ciclo, es que dicho elemento es capaz de subdividir cada ciclo en varios más de forma interna.

Otra de las cosas que debemos decirle al micro antes de empezar es cuántos ciclos tiene que esperar a que el dato pedido esté disponible o sea grabado para cada CS o rango de direcciones. De hecho, grabar todos estos valores (y más) es lo que se conoce como startup del micro. Si os fijáis, el CS para la ROM es 00, lo que viene a decir que cuando el micro arranque empezará a leer a partir de una dirección predefinida (a veces dependiendo de cómo se encuentren las patas de arranque, por llamarlas de alguna manera). Es en esa dirección, y siguientes, en las que se encuentran las instrucciones para ir activando y configurando todos estos elementos.

De hecho, cada micro, cada familia de micros, cada fabricante, lo hace a su manera, indica unas direcciones y unas formas concretas, no sólo de cómo arranca un micro, sino también del tipo de lógica, periféricos permisibles, velocidades de reloj, retardos máximos y mínimos, y un larguísimo etcétera. Por ejemplo, los micros ARM tienen un encendido más que curioso. Necesitan de un chip que inyecte, a través de un canal serie, el código de arranque. Es decir, al encendido el micro está muerto, y es un chip externo el que, a través de una de las patas del micro, inyecte el código necesario para que éste sea capaz de arrancar. Se trata de un sistema de seguridad bastante potente, porque en ese código podría estar el algoritmo para decodificar una ROM encriptada.

***

Bueno, ya tenemos nuestro micro encendido y funcionando. ¿Qué? Ah, se me olvidaba, que todavía no sabemos cómo decirle todo lo que hemos visto. Aquí la cosa resulta algo más sencilla, ya que sólo hay unas pocas aproximaciones posibles.

En general, cada micro tiene un conjunto de registros especiales, que es donde nosotros escribiremos y leeremos la configuración. A veces el registro viene implícito en la instrucción, es decir, existe una única instrucción para modificar dicho elemento. Si tenemos un AX, BX, DX, que vienen codificados en la instrucción, también podemos tener un MBAR…

Cuando uno ejecuta una instrucción en ensamblador, por ejemplo “MOV AX,1234”, su código de operación (lo que realmente está leyendo el micro), es 0x87, por lo que, en la ROM, nos encontraríamos con los valores hexadecimales 0x87, 0x34, 0x12. Con esto no quiero decir que para un Intel, el código de operación de esa instrucción sea 0x87, es solo un ejemplo inventado.

En binario, ese código de operación es 10 000 111. Fijaros que lo he dividido en tres grupos. Lo que hace el micro, cuando está ejecutando esa instrucción, es leer los dos primeros bits, 10. Esos dos primeros bits le dicen a la unidad de decodificación que es una instrucción MOV. Podríamos tener cuatro combinaciones, 00, 01, 10, y 11. La primera podría ser para JMP, la segunda para CMP, la tercera para MOV y la cuarta para una instrucción multibyte o extendida. Es sólo un ejemplo.

Ya sabe que es un MOV, y también sabe que los tres bits siguientes son el registro de destino, y los tres últimos el de origen, así que sigue decodificando (quizás en un nuevo ciclo de reloj). 000 se corresponde al registro AX, y 111 a código inmediato, es decir, el registro de origen no es un registro, sino los dos siguientes bytes dentro de la ROM, por lo que inicia la secuencia descrita arriba para obtenerlos, y una vez que lo tiene todo, termina ejecutando el tema y poniendo 0x1234 en el acumulador. Y aquí es donde entran los ciclos máquina que cada instrucción necesita, que suelen ser diferentes incluso para cada modo de una misma. Por ejemplo, MOV AX,BX quizás requiera sólo dos ciclos, decodificar y ejecutar, pero MOV AX,0x1234 requerirá 2 ciclos más 4 para obtener el primer byte inmediato más 4 para el segundo, y eso sin contar los de espera hasta que los datos han salido de la ROM.

Si os fijáis, el orden de los bytes inmediatos en el ROM está invertido. Es una de esas cosas que son porque son, sin más justificación que el fabricante así lo ha decidido. Aunque no es realmente cierto, quizás al colocarlos así ahorra en puertas lógicas en la unidad de decodificación, pero es otra cosa más a tener en cuenta, y es el orden de los bytes, Little Endian o Big Endian.

Ahora que ya sabemos cómo decodifica una instrucción un micro, quizás el código de operación 0xc5 más los bytes 0x00 0x1f sean la instrucción para decirle al micro que el MBAR está en 0x1f00. Por tanto, nuestras tres primeras instrucciones han sido ejecutadas, y le hemos dicho al micro que, a partir de ahora, los registros especiales están en 0x1f00.

Esto nos lleva a otra forma de configurarlo. Hay micros en los que los registros especiales son simples direcciones de memoria normales y corrientes pero que, en lugar de acceder a memoria normal (sea del tipo que sea), simplemente cambian la configuración interna del mismo. Quizás escribir 0x01 en la dirección 0xffff sea decirle al micro que está usando memoria externa, y que escribir 0x0000 en las direcciones 0xfffe y 0xfffd respectivamente sean decirle que el chip select 0 empieza en 0x0000. Y a su vez al escribir 0x7ffff en las 0xfffc y 0xfffb le estemos diciendo que la dirección final del CS 0 sea esa misma.

Y lo mismo con los demás CS y sus correspondientes registros mapeados en memoria. Esta es la forma más común, pero así estamos usando direcciones que no podremos utilizar en nuestros programas, por lo que algunos micros utilizan instrucciones especiales. Por ejemplo, si hacemos MOV 0xffff,0x01, estaremos escribiendo en RAM, pero si hacemos IMOV 0xffff,0x01 lo haremos en la configuración del micro.

Pero no os asustéis, en general todo esto ya viene predefinido con los ensambladores y los compiladores mediante definiciones y macros, e incluso a veces los fabricantes implementan construcciones y extensiones a C o a C++ (y a ensamblador) para lidiar con todo esto. Por ejemplo, si en un compilador de C de Imagecraft para Atmel colocamos

__flash int cero=0;

en lugar de

int cero=0;

ese entero irá en ROM en lugar de RAM. Sin embargo, la línea con la extensión, en uno de IAR para el mismo micro, nos colocará cero en la flash interna en lugar de la ROM. Y no quiero entrar en esto, en cómo cada fabricante pone las extensiones que les sale de los cojones y como les salen de los cojones, eso daría para otro post con más mala leche que este.

Volviendo al tema que nos ocupa, si ahora queremos leer el estado de bytes de un puerto, una simple instrucción en C no los soluciona:

unsigned char puerto=PINC;

El compilador hará lo que tenga que hacer para que en puerto esté la representación binaria de bits de lo que quiera que haya en dicho puerto. La macro PINC tiene su intríngulis, ya que podría ser cualquier cosa, desde una llamada a una función intrínseca al compilador hasta una simple lectura de una dirección física o una instrucción en ensamblador, quizás RMOV 0xff33,puerto.

Pongamos por caso que hayamos conectado al bit0 del puerto C (que se corresponderá con una pata del micro), un sensor de una puerta, que pondrá dicho bit a 1 (lógico) cuando la puerta se abra. Por tanto, el código:

void main(void)
{
    inicializa_hardware();
    for(;;)
    {
        if((PINC&0x01)!=0)
        {
            suena_sirena();
        }
    }
}

Nos servirá para construir una alarma. Quizás la función suena_sirena() escriba un 1 en otro bit de otro puerto, cuya correspondiente pata del micro vaya conectada a un transistor que a su vez activará un relé que pondrá en marcha una sirena…

Dentro de inicializa_hardware() habremos puesto sentencias como:

PORTC=0xff; //Encender todos los pull-up internos del Puerto C

DDRC=0x00; //Poner el Puerto C como entradas

PORTD=0x00; //Quitar los pull-ups del Puerto D

DDRD=0x01; //Poner el Pin0 del Puerto D como salida

PIND=0x00; //Apagar la sirena.

Los comentarios son auto descriptivos, y nos enseñan cómo poner un puerto como elemento de entrada (para leer datos de él) o de salida (para escribir), si queremos tener pull-ups (unas resistencias especiales para añadir carga), etc. Luego esto será traducido a código ensamblador del micro correspondiente, y de nuevo la parte izquierda del igual son, en este caso, macros.

De todos modos la cosa no es tan sencilla. En este caso da igual que escribamos en un puerto de entrada, el micro lo ignorará, pero en otros microprocesadores no se puede hacer a riesgo de romper algo, por lo que operaciones mixtas requieren técnicas de doble buffer, como mantener un juego del estado de entradas o salidas anteriores (lo que de paso nos daría para mantener flancos lógicos, que nada tienen que ver con los físicos de la placa).

Por si no ha quedado claro, hacer algo como PINC|=0x01; significa leer el estado del puerto C y, sin importarnos cuál es el valor del bit0, ponerlo a uno (ya que una o lógica con uno de los dos bits a 1 siempre resultará en 1) sin modificar los demás y volver a escribir el resultado en el puerto. Como antes hemos leído los valores, sólo estamos cambiando el bit0. Pero ocurre que en algunos micros las salidas no se pueden leer (no es que no se puedan, sino que no leen nada coherente), por lo que tenemos que mantener variables de estado (de nuevo el doble buffer).

Esta operación de escritura en ese puerto, quizás sea compilada como una escritura en una dirección de memoria, o la ejecución de una instrucción especial de ensamblador, pero el resultado final será que la pata del microprocesador que se corresponda con ese bit pasará a estar a un uno lógico, que realmente podría ser ponerse a 5V, o a cero, o cambiar su estado, o ejecutar un flanco de subida, o de bajada, o uno de subida y luego otro de bajada, o justo al revés. Todo dependerá de qué micro y cómo esté configurado.

3 comentarios en “Cómo se accede al hardware”

  1. Buffff … que extraño proceso mental siguieron los que diseñaron todo eso? Creo que cuando encienda el PC tendré que hacer una reverencia 😉
    Cuando has explicado el funcionamiento de los microcontroladores me ha recordado un montón a los PLC’s, tanto en la forma de programarlos con en la acceder a los datos.

  2. Ah, me ha hecho recordar cuando me peleaba con el pic 16F877… y entonces entendì que odio programar en ASM. Mejor es C, aunque por ahì existen convertidores que permiten programar en Basic (con muchos GoTos) 😀 😀

    oops! Rafael, mencionaste los buses de Datos y Direcciones, pero no el de Control…
    Por favooor, sigue explicando cómo todo esto se convierte en un Windows, porque en algún punto entre el anillo 0 y el n me perdí completamente.

  3. Pues la verdad es que se me olvidó mencionar que los CS, WR y demás forman parte del bus de control.

    Y por cierto, programar hardware es MUY interesante, y casi siempre es un reto, sobre todo cuando te las tienes que ingeniar para depurar cosas que van en tiempo real…

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *