Microarquitectura: ¿cómo funciona una CPU?

Los artículos previos sobre el diseño de una CPU han sido teóricos, pero… he pensado ¿Y si creamos una CPU simple para analizarla? Así podréis ver el proceso de una forma mucho más práctica e intuitiva. Por supuesto será una CPU simple, por dos motivos evidentes. Por un lado, diseñar una compleja lleva mucho tiempo, esfuerzo y necesita de unos recurso que ahora no tengo. Por otro lado, un diseño complejo no sirve para captar la esencia y aprender de ello, porque habrá demasiada información y muchos datos que te van a distraer de lo principal.

Ya lo he comentado en otras ocasiones, pero para entender mejor la arquitectura de computadoras y cómo funciona un ordenador, es bueno que vayas enlazando estos artículos con otros, como los de programación, especialmente los primeros de ellos que comentan los aspectos más básicos sobre las instrucciones, etc., o los de RISC-V iniciales con información sobre la ISA, etc. Todo tiene conexión y teniendo una idea global entenderás perfectamente la mecánica de todo.

Recursos

Antes de nada, debes saber que existen muchos recursos que puedes usar para aprender sobre el tema, y no haré publi de mi enciclopedia, pero sí que te recomiendo que pruebes estos sitios:

  • Simulador online del 6502, ARM1 y 6800.
  • Software EDA (gEDA, KiCad,…) o simuladores como CPU Sim podrían ayudarte.
  • Juego online NandGame, que comienza desde unas puertas lógicas simples hasta hacerte construir circuitos más complejos como una CPU, muy útil para aprender, especialmente para los peques.
  • En opencores.org tienes gran cantidad de chips en lenguajes HDL para modificar, estudiar o implementar en un FPGA, etc.
  • Tienes también gran cantidad de emuladores, simuladores, kits electrónicos, entrenadores como los de mi colega Mikel, y otros proyectos interesantes. Un ejemplo es el Ibex de LowRISC, un core simple basado en RISC-V de 32-bits y de código abierto. Hasta hay proyectos disparatados de CPUs de 1-bit y 2-bit creadas en Minecraft y cosas así…

Será por recursos…

Tu primera CPU

Será una CPU muy simple, de 4-bit, y con ejecución en orden, para que se a lo más sencilla que podamos. Nos olvidaremos de implementar cache, pipeline, multithreading, predicción de saltos, renombre de registros, ejecución especulativa, etc. No pretendemos crear una CPU de alto rendimiento, sino que puedas aprender con ella. 😉

¿Qué necesito saber?

Esquema computadora simple

Como ya viste en la serie Programación, cuando escribes el código fuente de un software, el compilador lo traduce a un binario con una serie de datos e instrucciones. Para que sean prácticas, se necesita una circuitería capaz de procesarlas. Eso implica tener un esquema similar al que ves aquí. Con una entrada, una salida, una CPU que procese esas instrucciones y sepa qué debe hacer con esos datos (operandos y resultados), pero también una memoria donde se pueda almacenar el programa (esas instrucciones y datos).

Gracias a esa memoria se pueden cargar diferentes programas para que nuestro hardware haga diferentes cosas sin tener que alterarse. Una máquina cableada haría todas las operaciones para las que ha sido diseñado de una forma extremadamente eficiente, rápida, y sería más simple (robusto) y barato de fabricar. En cambio, si quisieramos hacer otra tarea diferente, deberíasmo crear un hardware totalmente diferente. Eso con los sistemas programados no es así, aunque no sean los más rápidos y eficentes, y su complejidad sea mayor, pero permiten ir variando el programa en memoria para hacer cosas diferentes manteniendo el mismo hardware.

Esto sería una descripción simple de una computadora…

Nuestra ISA

La ISA para nuestra CPU solo va a constar de 14 instrucciones sencillas. Ten en cuenta, que al ser una CPU de 4-bit podríamos tener hasta 16 posibles (de 0000 a 1111). Con esas instrucciones se debería ejecutar cualquier programa o, al menos, las aplicaciones para las que ha sido diseñada. En nuestro caso serán las siguientes:

BIN / Hex MNEMÓNICO DESCRIPCIÓN
0000 (0h) MDO Mueve desde un origen especificado a un destino. Necesita tres posiciones de memoria, una para la propia instrucción y dos para origen y destino. Ejemplo: MDO des, org
0001 (1h) MOV Mueve un valor de un registro indicado a una posición destino. Ejemplo: MOV des, #valor
0010 (2h) ADD Suma los registros P1 + P2 y coloca el resultado en el registro acumulador. Solo necesitará una posición de la memoria para la propia instrucción, ya que P1 y P2 son conocidos.
0011 (3h) SUB Igual a la anterior, pero resta.
0100 (4h) INC Incrementa el valor de P1+1 y guarda el resultado en P1 y también en el acumulador.
0101 (5h) DEC Igual al anterior, pero decrementa P1-1.
0110 (6h) AND Ejecuta P1 & P2 y guarda el resultado en el acumulador.
0111 (7h) OR Ejecuta un OR lógico, es decir, P1 || P2 y guarda el resultado en el acumulador.
1000 (8h) XOR Igual al anterior, pero XOR.
1001 (9h) DEL Borra un registro y lo deja a cero. Necesitará una dirección de memoria para la propia instrucción y otra donde se especifique el registro a borrar. Es muy útil para el reset, dejando los registros a un estado conocido.
1010 (Ah) JMP Salto de una posición de memoria (del programa). Al ser una memoria con una logitud de 4-bit pero el bus de direciones es de 8-bit, necesitará 3 posiciones de memoria. Una para la instrucción, otra para el nibble alto y otra para el nibble bajo de la dirección a la que apunta.
1011 (Bh) JIA Igual al anterior, pero es un salto condicional. Solo salta si P1>P2.
1100 (Ch) JII Igual al anterior, pero salta solo si P1=P2.
1101 (Dh) JIE Igual al anterior, pero salta solo si P1<P2.
1110 (Eh) No asignada No se está utilizando
1111 (Fh) Reservada He querido incluir esto que podéis ver en algunos manuales de CPUs, como los de Intel para aclarar que no es lo mismo que no esté asignada a que esté reservada. Normalmente hay instrucciones y registros reservados u ocultos que el diseñador de la CPU no quiere detallar para qué sirven… Algunas ni siquiera están documentadas, así que no se sabe muy bien qué hacen realmente.

Como ves, es como nuestro ensamblador particular que se podría usar para programar este sistema basado en CPU. Y en cuanto a los datos, no nos complicaremos la vida en generar varios tipos, simplificamos y asumimos que solo puede usar un tipo de dato…

Más información – Intro ISA Parte 1   /   Intro ISA  Parte 2   /  Diseño de una ISA y fundamentos

Los registros estarán también marcados con unas direcciones específicas para que la unidad de control sepa a cuál se dirige la instrucción que se está ejecutando según los valores obtenidos de la memoria:

Acumulador 0000
P1 0001
P2 0010
Salida (S) 0011
Entrada (E) 0100

Nuestra microarquitectura simple

microarquitectura simple

Un diagrama simplificado de la microarquitectura de nuestra CPU podría ser este de aquí. Como puedes ver, muy sencillo. Tan solo tenemos una interfaz con la memoria que será la unidad de control, y ella se encargará de realizar los ciclos fetch o de búsqueda, traer las instrucciones al registro, decodificar la instrucción (en nuestro caso están en binario y son simples, por lo que se podría implementar con una lógica simple para obtener las salidas de control según el código de instrucción obtenido), y tener un registro Program Counter para que vaya saltando a la siguiente dirección de memoria donde se encuentra la próxima instrucción del programa que se está ejecutando.

No he separado la memoria de datos e instrucciones en esta máquina, por tanto, sigue un diseño Von Neumann.

Por otro lado, habrá una serie de registros para los datos para el datapath, en este caso P1, P2 para los operandos, y también un acumulador para el resultado.

Ciclo fetch

En cuanto a esa misteriosa unidad de control, debe cumplir a la perfección el ciclo fetch o de búsqueda:

  1. Búsqueda de la intruscción enviando la dirección adecuada por el bus de direcciones hacia la memoria para iniciar el ciclo de lectura. En nuestro caso, al ser ROM no habrá ciclo de escritura como en una RAM. En ese caso sería necesario señales de control para determinar si se trata de una lectura o de una escritura… La información obtenida será almacenada en el registro.
  2. Luego se buscan los operandos, ya que en nuestro caso no hemos generado instrucciones complejas donde los datos van implícitos en ellas como en algunos modos de direccionamiento de ciertas CPUs modernas. En caso de que la instrucción apunte a ellos, se podrían acceder mediante un ciclo interno (si están en los registros) o externo (si está en la memoria principal). Al simplificar nuestra CPU y no incluir cache, ni demás, también nos ahorramos follones de TLB, etc.
  3. Al decodificar la instrucción (en nuestro caso no es necesario, ya que incluiríamos una circuitería que en función de la lectura de los bits de la instrucción genere una salida de control para mantar a la ALU lo que debe hacer), se puede ordenar a las unidades funcionales presentes que ejecuten una operación sobre los operandos y luego será almacenado el resultado. En nuestro datapath solo hay una unidad, la ALU, pero podría haber más, especialmente si es una CPU superescalar.

Creo que con esto quedan más o menos claros los pasos para ejecutar una instrucción, y dejando a un lado una posible pipeline, cambios de contexto, etc.

Recuerda que el objetivo de una microarquitectura es que pueda implementar una ISA o parte de ella, por tanto, la microarquitectura debe ser lo suficiente completa como para eso. En este ejemplo, como podrás comprobar más adelante, solo usamos unas cuantas instrucciones de todo el repertorio de nuestra ISA, por lo que se podría simplificar nuestra CPU para que solo ejecute las que necesitamos, pero eso limitaría la cantidad de aplicaciones.

Más información – Arquitecturas

La computadora al completo

Ahora, desarrollando un poco más el diseño de la microarquitectura tenemos el diagrama lógico de nuestra CPU, y agregando algunos complementos auxiliares tendríamos nuestra arquitectura de computadora simple completa. En él he agregado una interfaz E/S para acoplar periféricos, de lo contrario no tendría demasiada utilidad.

También es necesario incluir una circuitería adicional para alimentra nuestra CPU y dotarla de una señal de reloj. Además, es interesante incluir una señal de reset para poner la CPU en un estado conocido en un momento dado. Si se activa esta señal reset hará que todos los registros de nuestra CPU se pongan a cero (0000) y el bus de direcciones apuntará a la dirección primera de la memoria (00000000) para comenzar a ejecutar el programa desde el inicio.

Todo el conjunto se moverá a un ritmo marcado por nuestro director de orquesta, el oscilador que generará la frecuencia de reloj con la oscilación proviniente del Xtal…

Descripción de las partes

Ahora vamos a ir analizando parte a parte de nuestra CPU para que tengas una idea más clara de su función:

  • Memoria ROM: en nuestro caso es el firmware o programa que comanda nuestra CPU. Al contar con 8 líneas de direcciones, a pesar de ser de 4-bit, se pueden direccionar hasta 256 direcciones (2^8), y si cada dato ocupa 4 bits, la memoria tendría 1K (1024 bits) de memoria. Cada una de ellas contiene 4 bits de datos. No es nada raro que un microprocesador tenga un tamaño de palabra diferente al del bus de direcciones (u otros buses). Por ejemplo, el 8088 era de 16-bit, pero su bus de datos era de 8-bit y el de direcciones de 20-bit. El AMD Ahtlon es de 32-bit y tenía un bus de 40-bit para direcciones, mientras el Pentium 4 lo tenía de 36-bit.
  • Registros: hay varios en nuestro diseño, son simples circuitos de memoria compuestos de biestables. Por ejemplo, flip-flops de tipo D.
  • Contador: en nuestro diseño actúa como un registro PC (Program Counter) y puntero para señalar la siguiente instrucción a ejecutar. Comienza por la 00000000 y se irá incrementando en 1 cuando se vaya ejecutando cada instrucción para dar paso a la siguiente y recorrer todo el programa almacenado en la ROM. En caso de que haya saltos, deberá cargar el valor de la dirección adecuada. Al no tener interrupciones de ningún tipo, se simplifica mucho su implementación. En caso de ser una CPU con multihilo, superescalar, etc., sería algo más complicado.
  • Unidad de control: debe saber interpretar las instrucciones para generar salidas de control para ordenar qué debe hacer el resto de registros y unidades funcionales que componente la CPU. También debe saber gestionar los estados entregados por la ALU en el registro de estado y coordinar el contador.
  • ALU: por último, la unica unidad funcional de ejecución en nuestro diseño es una unidad aritmetico-lógica simple. Puede realizar varias operaciones según el código binario que le llega por sus entradas de control Ox (en nuestro caso 8 posibles al tener 3 líneas). La unidad de control es la que debe enviar estas señales según la instrucción que esté procesando en cada momento. Dependiendo de eso hará una operación u otra con los operandos almacenados en P1 y P2 y almacenará el resultado en el acumulador. Las operaciones que puede hacer en nuestro caso son reducidas:
Código de control Operación
000 Mueve el dato almacenado en P1 y lo carga en el acumulador.
001 Suma P1+P2.
010 Resta P1-P2.
011 Multiplica P1*P2.
100 Divide P1/P2.
101 Compara P1 y P2 y activará las líneas que van al registro de estado R0 y R1. Según el código almacenado en el registro de estado, la unidad de control sabrá si es menor, mayor o igual. Por ejemplo, 00 podría indicar que P1 = P2, 01 sería para P1 > P2, 10 podría indicar que P1 < P2, y 11 para indicar un resultado negativo en la operación de resta.
110 P1 AND P2
111 P1 OR P2

*Recuerda que algunas otras operaciones se pueden conseguir combinando éstas, como XOR, que podría usarse AND y OR para conseguirlo e invirtiendo algunos bits…

Después, sería cuestión de ir generando las tablas de verdad de cada unidad funcional o las máquinas de estados de cada uno de ellos para poder crear el circuito con puertas lógicas y biestables necesarios. Una vez obtenido ese circuito, sería cuestión de convertir cada una de esas partes a los elementos electrónicos necesarios para su implementación. Por ejemplo, convertir las puertas lógicas o celdas de memoria en transistores MOSFET (CMOS), transistores y resistencias (TTL), etc., dependiendo de la familia lógica en la que lo desees fabricar.

Un ejemplo de funcionamiento

Ahora vamos a ver un ejemplo de programa que se podría cargar en la memoria. No prestes demasiada atención a la sintaxis, se trata de un pseudocódigo para que entiendas más o menos cómo trabaja la CPU:

código ASM

Ahora intentaré explicar un poco mejor lo que ocurre en la CPU cuando se ejecuta cada línea de este código (no intentes saber el para qué se hace, simplemente quiero que entiendas lo que ocurre):

  1. Se inicia el programa almacenado en la ROM enviando por el bus de direcciones la dirección 00000000 y por el bus de datos se devolverá el dato allí almacenado, en este caso la instrucción mov. Eso producirá que se cargan los datos en el registro P1. En este caso sería un dato obtenido del registro de entrada, por ejemplo, imagina que es 0100.
  2. Ahora ocurre algo similar con P2, pero en este caso se mueve el valor 6h (0110).
  3. La instrucción generará un código para que la ALU pueda comparar P1 y P2 para saber si valen lo mismo, es decir, si son iguales. Y si lo son se produce un salto hacia la dirección de memoria donde se encuentra la instrucción almacenada en memoria correspondiente a la línea 18 de nuestro código.
  4. Se carga en el registro P2 el valor de la dirección de memoria 5h, es decir, en este código concreto habría un valor 1000 que será el nuevo estado de P2.
  5. Se vuelve a comparar y si se cumple la condición del salto, se pasa a la línea 18, o mejor dicho, a la dirección de memoria donde se almacena esa instrucción. En este caso, como se cargó 0110, el contenido de P1 también debería de ser eso para dar el salto.
  6. Turno de cargar el valor 10h (1010) en el registro P2.
  7. Nueva comparación y salto condicional. Para eso P1 (que recuerda que tiene el valor de la entrada del sistema E/S) debería ser igual a 1010.
  8. Ahora se vuelve a cargar un nuevo valor en el registro P2, en esta ocasión 1111 (15h).
  9. Volvemos a comparar el estado de P1 con P2, y ambos deben ser 1111 para que el salto condicional se de.
  10. Movemos el dato Ah a P2, en este caso 1011.
  11. Y se vuelve a evaluar si P1 y P2 son iguales y en el caso de que lo sean se salta.
  12. Nueva carga de otro dato, 4h (0100) que va a P2.
  13. Comparación P1=P2 y si se cumple se salta, pero esta vez va a la línea 16 del código, puesto que en el caso de ser 0100, el periférico de salida debería ejecutar otra acción diferente a las de la línea 18.
  14. Se ponen las salidas a cero, ya que llegados a este punto no hay más evaluaciones y ninguna de las anteriores se ha cumplido si hemos llegado a este punto de la secuencia del programa.
  15. Otro salto, pero este es incondicional. Por tanto, se salta sí o sí al inicio. Es decir, la CPU volverá a ejecutar desde la primera instrucción de la secuencia del programa para volver a evaluar los datos.
  16. Es la instrucción a la que se salta si se cumple la comparación de la línea 11. Es decir, si las entradas son 1011. En este caso, podrá el registro de salida con estado 2h (0010).
  17. Una vez se ha realizado la instrucción anterior, es decir, si se ha cumplido esa condición de salto de la línea 11, el programa vuelve a ejecutarse desde el inicio para que pueda seguir comparando los estados del registro de entrada con diferentes valores y operar en consecuencia.
  18. Esta es la otra línea a la que se salta si se dan ciertas condiciones. Se ejecuta esta instrucción que hará que se cargue un valor en el registro de salida. Esto debería indicar al periférico de salida (0011) que debe hacer alguna opción como reacción al valor de la entrada.
  19. Nuevamente, una vez ejecutada esta instrucción 18, se vuelve al inicio para que todo comience desde cero.
  20. Fin del programa. Realmente no tiene mucho sentido aquí, pero bueno…

¿Y qué hemos conseguido con ésto? Bueno, imagina que tienes un periférico conectado a la entrada que si tiene un cierto valor, la CPU debe indicar al periférico de salida que haga algo. Como ves, se ha cargado el estado del periférico de entrada en P1 y se han ido realizando una serie de comparaciones para ver si estaba en uno de esos estados. En el caso de coincidir, se saltaría a la instrucción de la línea 18 de nuestro código que es la que dará como resultado un valor para el periférico de salida.

Por ejemplo, en nuestro caso, la CPU actuaría con forme a la siguiente tabla de verdad:

tabla de verdad

Por ejemplo, imagina que cada entrada se corresponde con un botón o con un sensor. Cuando se activan conforme a la tabla de verdad, generan dos tipos de salida: 0011 y 0010. Puedes imaginar que esas líneas de salida están también conectadas a algún tipo de transductor o dispositivo como, por ejemplo, LEDs.

¿Te atreves ahora a implementarla físicamente? Se trata de ir parte a parte creando la circuitería necesaria (combinacional o secuencial) para que haga eso para lo que ha sido diseñada (no obstante, en futuros artículos me gustaría eseñarte cómo se implementan algunas partes, como una ALU, FPU, etc.)… Como ya dije en el artículo del kernel Linux, no hay nada como remangarse y ponerse manos a la obra con algo para aprender realmente. Los libros, teoría y demás material está bien, pero nada como la práctica. ¿No crees?

Isaac

Apasionado de la computación y la tecnología en general. Siempre intentando desaprender para apreHender.

Deja un comentario

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

A %d blogueros les gusta esto:

Si continuas utilizando este sitio aceptas el uso de cookies. más información

Los ajustes de cookies de esta web están configurados para "permitir cookies" y así ofrecerte la mejor experiencia de navegación posible. Si sigues utilizando esta web sin cambiar tus ajustes de cookies o haces clic en "Aceptar" estarás dando tu consentimiento a esto.

Cerrar