Simulador de múltiples arquitecturas segmentadas de computadores
Abstract
Grado en Ingeniería Informática
Full text
Universidad de Valladolid ESCUELA DE INGENIERÍA INFORMÁTICA GRADO EN INGENIERÍA INFORMÁTICA MENCIÓN EN COMPUTACIÓN Simulador de múltiples arquitecturas segmentadas de computadores Curso académico: 2020-2021 Autor: Manuel de Castro Caballero Tutores académicos: Javier Bastida Ibáñez Yuri Torres de la Sierra
A mi tío y padrino, Juan Pablo. Puede que no nos hagamos ricos, pero estamos en esto para divertirnos. i
ii
AGRADECIMIENTOS Agradecimientos Me gustaría agradecer a mis tutores, Javier Bastida Ibáñez y Yuri Torres de la Sierra, por haberme dado la oportunidad de trabajar en este proyecto que he encontrado tan interesante. También me gustaría agradecer a todos aquellos profesores y profesoras que se ofrecieron a prestarme su ayuda en algún punto de su desarrollo: Valentín Cardeñoso Payo, Yania Crespo González Carvajal, Francisco José Andújar Muñoz y Arturo González Escribano. A mi familia, por apoyarme de manera incondicional durante toda mi trayectoria académica, y por financiármela; incluyendo aquellas asignaturas que he cursado de manera innecesaria, puramente por gusto, durante este último cuatrimestre. A mis amigos del Grupo Universitario de Informática, en especial a Pablo Martínez López, Javier Gatón Herguedas y Hugo Prieto Tárrega, por estar ahí para ayudarme con las ramas de la informática que peor se me dan, siempre que lo he necesitado. Por último pero no menos importante, a todos mis amigos que han tenido la paciencia suficiente para esperarme durante todo este tiempo que he estado trabajando demasiado. En especial, a Daniel López Martínez, Sofía Mara Rivas Cuevas y Lucía Alonso Losa. iii
AGRADECIMIENTOS iv
RESUMEN Resumen Los lenguajes ensambladores son comúnmente estudiados en asignaturas básicas sobre Arquitectura de Computadores para explicar el funcionamiento de los procesadores. Existe un conjunto significativo de lenguajes ensambladores surgidos de las distintas arquitecturas de computadores existentes. Dicho conjunto de lenguajes va en aumento conforme se desarrollan más arquitecturas hardware. Elegir qué lenguaje ensamblador estudiar y de qué modo es una decisión limitada a las tecnologías de desarrollo o simulación existentes para cada arquitectura. Este trabajo describe la implementación de un prototipo de simulador de lenguajes ensambladores con propósito docente escrito en Java. Este simulador ha sido desarrollado para soportar un conjunto extensible de lenguajes ensambladores distintos, centrándose en aquellos de arquitecturas RISC. Actualmente, está implementado el backend para ARM LEGv8, arquitectura descrita en Computer Organization and Design: ARM edition. Este backend implementa funcionalidades de segmentación de instrucciones, tales como las descritas en Computer Architecture: A Quantitative Approach, incluyendo la simulación de unidades funcionales multiciclo. También se ha implementado el backend para el subconjunto de instrucciones RV64I de RISC-V, validando la capacidad de extensión del simulador. El trabajo desarrollado en este proyecto ha dado lugar a dos publicaciones científicas que han sido aceptadas y serán presentadas en las XXXI Jornadas de Paralelismo 2020/2021 de la Sociedad de Arquitecturas de Computadores (SARTECO). Consideramos que la herramienta desarrollada puede ser de gran utilidad tanto para docentes como para estudiantes de asignaturas básicas de Arquitectura de Computadores. v
RESUMEN vi
ABSTRACT Abstract Assembly languages are commonly studied in basic Computer Architecture courses to explain the inner workings of processors. A significantly large set of assembly languages has arisen from the various different computer architectures that exist. Said set is increasing as more hardware architectures are being developed. Choosing which assembly language to study in a subject and how to do so is a decision limited by the development and simulation tools available for each architecture. This project describes the implementation of a prorotype of an education-oriented, Java-based assembly language simulator. This simulator has been developed to support an increasing set of different assembly languages, focusing on those of RISC architectures. Currently, the ARM LEGv8 backend is implemented, whose architecture is described in Computer Organization and Design: ARM edition. This backend implements instruction pipelining functionalities such as the ones described in Computer Architecture: A Quantitative Approach, including the simulation of multicycle functional units. The RV64I subset of instructions from the RISC-V architecture has also been implemented, proving the extension capabilities of the simulator. The work developed in this project has led to the writing of two scientific articles that have been accepted and will be presented in the XXXI Jornadas de Paralelismo 2020/2021, organized by the Sociedad de Arquitectura de Computadores (SARTECO). We consider that the developed tool might be really useful to both undergraduate computer science students and Computer Architecture professors. vii
ÍNDICE GENERAL xiv
ÍNDICE DE FIGURAS Índice de figuras 2.1. Diagrama de Gantt del proyecto. . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 2.2. Camino crítico del proyecto . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 3.1. Comparación de rendimiento entre CPU y GPU . . . . . . . . . . . . . . . . . . . 23 4.1. Segmentación de instrucciones en procesadores . . . . . . . . . . . . . . . . . . . . 29 4.2. PipelineclásicoRISC.................................. 31 4.3. Riesgos de datos en computadores segmentados . . . . . . . . . . . . . . . . . . . 32 4.4. Formatos de instrucciones LEGv8 ........................... 35 5.1. Diseño básico de la arquitectura del simulador . . . . . . . . . . . . . . . . . . . . 45 5.2. Diseño detallado de la arquitectura del simulador . . . . . . . . . . . . . . . . . . 47 5.3. Modelo de diseño del módulo de interfaces de arquitecturas . . . . . . . . . . . . . 49 5.4. Diseño de la pestaña de edición de la interfaz gráfica . . . . . . . . . . . . . . . . 64 5.5. Diseño del sistema de sugerencias del editor de código . . . . . . . . . . . . . . . . 65 5.6. Diseño del sistema de ejemplos del editor de código . . . . . . . . . . . . . . . . . 65 5.7. Diseño de la pestaña de ejecución de la interfaz gráfica . . . . . . . . . . . . . . . 66 5.8. Componentes de la interfaz gráfica . . . . . . . . . . . . . . . . . . . . . . . . . . 67 5.9. Diagrama de cargador de arquitecturas . . . . . . . . . . . . . . . . . . . . . . . . 68 5.10. Diagrama de clases añadidas en el módulo LEGv8 . . . . . . . . . . . . . . . . . . 70 xv
ÍNDICE DE FIGURAS 5.11. Diagrama de casos de uso del simulador. . . . . . . . . . . . . . . . . . . . . . . . 71 5.12. Diagrama de secuencia para el caso de uso Compilar. . . . . . . . . . . . . . . . . 72 5.13. Diagrama de secuencia para el caso de uso Ejecutar ciclo. . . . . . . . . . . . . . . 72 5.14. Diagrama de secuencia para el caso de uso Ejecutar programa. . . . . . . . . . . . 73 5.15. Diagrama de secuencia para el caso de uso Ciclo de pipeline. ............ 75 B.1. Pestaña de edición del simulador, con el editor de código en blanco. . . . . . . . . 139 B.2. Pestaña de edición del simulador, con un programa escrito en el editor de código. . 139 B.3. Pestaña de ejecución del simulador. . . . . . . . . . . . . . . . . . . . . . . . . . . 141 E.1. Formatos de instrucciones RISC-V . . . . . . . . . . . . . . . . . . . . . . . . . . 148 xvi
ÍNDICE DE TABLAS Índice de tablas 2.1. Riesgosdelproyecto. .................................. 13 2.2. Planesdecontingencia ................................. 14 2.3. Coste de trabajo de personal del proyecto. . . . . . . . . . . . . . . . . . . . . . . 16 2.4. Coste de uso de las máquinas en el proyecto. . . . . . . . . . . . . . . . . . . . . . 17 2.5. Costetotaldelproyecto. ................................ 17 4.1. Conjunto de instrucciones LEGv8 ........................... 38 5.1. Estabilidad de los paquetes del módulo de interfaces de las arquitecturas . . . . . 62 6.1. Llamadas al sistema implementadas en la arquitectura LEGv8 ........... 106 E.1. Conjunto de instrucciones RISC-V (RV64I) . . . . . . . . . . . . . . . . . . . . . . 149 F.1. Directivas de preprocesador soportadas . . . . . . . . . . . . . . . . . . . . . . . . 152 xvii
ÍNDICE DE TABLAS xviii
ÍNDICE DE FRAGMENTOS DE CÓDIGO Índice de fragmentos de código 4.1. Riesgos de datos en LEGv8 .............................. 32 6.1. Código de carga de arquitecturas . . . . . . . . . . . . . . . . . . . . . . . . . . . 83 6.2. Lectura y escritura de datos de memoria . . . . . . . . . . . . . . . . . . . . . . . 85 6.3. Creación de la instrucción ADD . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87 6.4. Pseudocódigo para las instrucciones ARMv8 ADD y ADDS . . . . . . . . . . . . 88 6.5. Anticipación de resultados . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92 6.6. Predicción de saltos en el pipeline ........................... 93 6.7. Gestión de las predicciones incorrectas de saltos en el pipeline LEGv8 ....... 94 6.8. Descarte de instrucciones por predicción de saltos incorrecta . . . . . . . . . . . . 95 6.9. Tokens léxicos de instrucciones LEGv8 deformatoR................ 98 6.10. Reglas gramaticales de instrucciones LEGv8 de formato R . . . . . . . . . . . . . 99 6.11. Método generador de programas LEGv8 ....................... 100 6.12. Macros de pesudo-instrucciones LEGv8 ....................... 101 6.13. Creación de macros en ANTLR 4 . . . . . . . . . . . . . . . . . . . . . . . . . . . 102 6.14.Expansióndemacros.................................. 103 6.15. Expresión regular de palabras resaltadas . . . . . . . . . . . . . . . . . . . . . . . 104 6.16. Hoja de estilo para resaltado de sintaxis . . . . . . . . . . . . . . . . . . . . . . . 105 6.17. Métodos de propiedades de la interfaz Registro . . . . . . . . . . . . . . . . . . . . 105 7.1. Implementación de DAXPY en ensamblador . . . . . . . . . . . . . . . . . . . . . 112 xix
ÍNDICE DE FRAGMENTOS DE CÓDIGO 7.2. Implementación de la sucesión de Fibonacci en ensamblador . . . . . . . . . . . . 116 xx
CAPÍTULO 1. INTRODUCCIÓN Capítulo 1 Introducción En este capítulo se presentan los siguientes aspectos: Contexto en el que se sitúa el trabajo. Motivación y objetivos del trabajo. Estructura del documento. 1.1. Contexto En esta sección se presenta el contexto en el que se desarrolla este proyecto. Se introducen los principales fundamentos de la Arquitectura de Computadores, de su estudio en ámbitos docentes, y de su importancia en el ámbito de la Computación de Alto Rendimiento. 1.1.1. Arquitectura de Computadores La Arquitectura de Computadores es una rama de la Ingeniería Informática que estudia la funcionalidad, organización e implementación de sistemas de computadoras. En el ámbito de la Arquitectura de Computadores, es de especial interés el estudio de cómo la unidad de procesamiento central (CPU) funciona de forma interna y se comunica con la memoria. Entre los campos englobados por esta rama destacan: Diseño de los conjuntos de instrucciones de las computadoras. Diseño de microarquitecturas y organización de computadoras. Diseño lógico de computadoras. Implementación hardware de computadoras. 1
1.1. CONTEXTO La Arquitectura de Computadores es una disciplina de gran relevancia en el contexto de la Ingeniería Informática y las Ciencias de la Computación. El software que se ejecuta a diario en millones de dispositivos depende del correcto funcionamiento de un hardware para funcionar. Comprender este hardware es una necesidad fundamental a la hora de desarrollar mejores programas, en cuanto a eficiencia computacional y energética. Por tanto, los avances en el desarrollo de computadores implican de forma directa mejoras en el desarrollo de software. No es sino gracias a la evolución de los computadores durante las últimas dos décadas que hoy en día podemos realizar tareas que antes tan solo teorizábamos: teléfonos inteligentes, redes neuronales y deep learning [1], eInternet of Things [2], por ejemplo. Existen múltiples ámbitos informáticos en los que es necesario tener cierto conocimiento de las arquitecturas de computadores. Entre ellos destacamos: Desarrollo de compiladores: los compiladores traducen código fuente escrito en lenguajes “de alto nivel” a código máquina ejecutable por los computadores. El uso de compiladores es imprescindible en el proceso de desarrollo de software. Para desarrollar un compilador es necesario tener conocimientos de las arquitecturas de computadores para las que se pretende generar código ejecutable. Además, cuanto mejor exploten los compiladores las características particulares de estas arquitecturas, más eficientes serán los programas ejecutables generados, tanto en velocidad como en uso de recursos. Desarrollo de software para sistemas empotrados: los sistemas empotrados o embedidos son sistemas computacionales con una cantidad especialmente limitada de recursos (memoria, espacio de disco, potencia de cómputo). Estos sistemas se utilizan para realizar pocas tareas específicas. Los desarrolladores para sistemas empotrados deben conocer con exactitud las limitaciones de estos sistemas, así como las capacidades de sus arquitecturas, para lograr aprovechar su máximo rendimiento. Hoy en día, los sistemas empotrados son utilizados en gran cantidad de situaciones y contextos. Por ejemplo, en taxímetros, sistemas de control de acceso, máquinas expendedoras, sistemas de control de impresoras y fotocopiadoras, etc. Existen diversos simuladores de arquitecturas de computadores con distintos propósitos. Algunos simuladores tienen como finalidad facilitar el desarrollo de software para ciertos microcontroladores. Estos son principalmente utilizados en los contextos de los sistemas empotrados. Otros simuladores tienen una finalidad didáctica, y son especialmente utilizados en los contextos docentes. 1.1.2. Estudio de la Arquitectura de Computadores en la universidad Para dedicarse profesionalmente al sector de la informática es necesario tener cierta formación en Arquitectura de Computadores. Esta necesidad surge por la influencia significativa de las arquitecturas de computadores en cualquier actividad informática: todo software se ejecuta en un computador. Es importante conocer las características de los computadores para comprender el proceso de ejecución de cualquier software. La formación básica en Arquitectura de Computadores es de importancia en toda clase de 2
CAPÍTULO 1. INTRODUCCIÓN profesiones: desde aquellas que consisten en desarrollar aplicaciones para ordenadores personales, hasta las que trabajen en las áreas de la ciencia de datos e inteligencia artificial. Por ello, cualquier clase de formación en ciencias o ingenierías relacionadas con la informática suele incluir en su plan de estudios asignaturas de carácter obligatorio dedicadas exclusivamente al estudio del diseño de computadores. En el caso del grado en Ingeniería Informática de la Universidad de Valladolid [3], debemos destacar la presencia de dos asignaturas obligatorias: Fundamentos de Computadoras [4], y Arquitectura y Organización de Computadoras [5]. Estas asignaturas son estudiadas en el primer y segundo curso del grado respectivamente, y cubren la formación de todos los alumnos del grado con respecto a Arquitectura de Computadores. Otra asignatura, Arquitecturas de Computación Avanzadas [6], de carácter optativo para el tercer curso de la mención de Tecnologías de la Información, complementa la formación de las dos asignaturas anteriores mediante el estudio del diseño de los computadores más actuales. Estas asignaturas presentan conceptos que sirven como base para desarrollar otras asignaturas del grado, tales como Estructura de Sistemas Operativos [7], Lenguajes de Programación [8], Computación Paralela [9], Sistemas Empotrados [10], Hardware Empotrado [11], y Rendimiento y Evaluación de Computadores [12], entre otras. En las asignaturas de Fundamentos de Computadores, Arquitectura y Organización de Computadores, y Arquitecturas de Computación Avanzadas de la Universidad de Valladolid, se utiliza MARS [13], un simulador de una arquitectura de computadores como herramienta docente de apoyo. Utilizando este simulador se desarrollan clases y actividades prácticas que sirven como complemento a las clases teóricas de las asignaturas. El uso de simuladores fomenta la adquisición de competencias básicas y avanzadas sobre Arquitectura de Computadores por parte de los alumnos, por lo que se trata de herramientas de gran utilidad. 1.1.3. Arquitecturas de Alto Rendimiento La Computación de Alto Rendimiento (High Performance Computing, HPC) se refiere al desarrollo de sistemas computacionales de gran capacidad de cálculo, y al estudio de su aplicación para la resolución de problemas complejos de ciencia, ingeniería o gestión [14]. Los campos de estudio de HPC abarcan diversas disciplinas informáticas, como las de diseño de hardware, co-diseño de hardware-software, diseño de aplicaciones, modelos de cómputo, lenguajes de programación, optimización de código, técnicas de compilación, sistemas de ejecución, etc. Todas estas disciplinas comparten un punto común, y es que afectan al rendimiento obtenido por las unidades computacionales. La Arquitectura de Computadores juega un papel fundamental en el ámbito de HPC. A lo largo de la historia ha habido numerosos intentos de aumentar la capacidad de cómputo ofrecida por las unidades computacionales. Estos intentos han dado lugar a arquitecturas de computadores específicas, como las siguientes: Arquitecturas superescalares: aquellas cuyas unidades computacionales ejecutan múltiples operaciones mediante la replicación de componentes hardware. Arquitecturas distribuidas: aquellas formadas por redes de unidades computacionales que se 3
2.1. ANÁLISIS DE TAREAS Para el desarrollo del prototipo de simulador planteado, se identifican las siguientes tareas a realizar: T1.- Contextualización: estudio de las características principales de las arquitecturas de computadores que se desean simular. T2.- Elección de la arquitectura concreta a simular. T3.- Estudio exhaustivo de la arquitectura a simular. T4.- Estudio de simuladores previos y sus subsistemas. T5.- Diseño del sistema. Elección de la aproximación concreta al problema: tipo y subsistemas del simulador. T6.- Elección y despliegue del entorno de trabajo y conjunto de herramientas de desarrollo de software a utilizar. T7.- Desarrollo de la estructura básica del simulador. T7.1.- Desarrollo de las interfaces software. T7.2.- Desarrollo de la simulación del banco de registros. T7.3.- Desarrollo de la simulación de la memoria del computador. T7.4.- Desarrollo de los elementos y estructuras básicas de un programa. T8.- Análisis y elección de las llamadas al sistema que soporte el simulador. T9.- Desarrollo de la simulación de la ejecución de instrucciones ensamblador. T10.- Desarrollo del compilador de ficheros fuente. T10.1.- Elección de la sintaxis de los programas. T10.2.- Análisis y elección de características adicionales permitidas en los programas. T10.3.- Análisis e implementación de las estructuras de compilación necesarias. T11.- Desarrollo del flujo de ejecución de programas. Análisis e implementación del pipeline segmentado. T12.- Desarrollo de la interfaz gráfica de usuario. T12.1.- Análisis y elección de las tecnologías software existentes para el desarrollo de interfaces gráficas. T12.2.- Análisis e implementación de los componentes gráficos necesarios en el simulador. T13.- Pruebas de validación del simulador. T14.- Desarrollo del manual de usuario. T15.- Pruebas de usabilidad con usuarios reales. T15.1.- Diseño de las pruebas. 10
CAPÍTULO 2. PLANIFICACIÓN Y PRESUPUESTO DEL PROYECTO Figura 2.1: Diagrama de Gantt del proyecto. T15.2.- Ejecución de las pruebas y recogida de retroalimentación. T15.3.- Modificación del software en base a la retroalimentación obtenida. El diagrama de Gantt [18] para la organización temporal tentativa del desarrollo de las tareas se muestra en la figura 2.1. 2.1.1. Camino crítico El camino crítico de un proyecto es el conjunto de tareas en las que, si alguna de ellas sufre un retraso, se retrasa el proyecto total. Partiendo del diagrama de Gantt del proyecto mostrado en la figura 2.1, el camino crítico de este proyecto se muestra en la figura 2.2. Figura 2.2: Diagrama de Gantt del proyecto, remarcando en rojo el camino crítico del mismo. Dentro del camino crítico del proyecto se incluyen las siguientes tareas: T2.- Elección de la arquitectura a simular. 11
2.2. METODOLOGÍA DE DESARROLLO T4.- Estudio de simuladores previos y sus subsistemas. T5.- Diseño del sistema. Elección de la aproximación concreta al problema: tipo y subsistemas del simulador. T7.- Desarrollo de la estructura básica del simulador. T9.- Desarrollo de la simulación de la ejecución de instrucciones ensamblador. T10.- Desarrollo del compilador de ficheros fuente. T11.- Desarrollo del flujo de ejecución de programas. Análisis e implementación del pipeline segmentado. T12.- Desarrollo de la interfaz gráfica de usuario. T13.- Pruebas de validación del simulador. T15.- Pruebas de usabilidad con usuarios reales. La tarea T2, de elección de la arquitectura a simular, forma parte del camino crítico ya que debe decidirse qué se va a simular antes de comenzar el desarrollo del simulador. La tarea T4 también debe realizarse antes del comienzo del desarrollo del simulador, pues ayuda a sentar las bases de cómo podría organizarse el sistema. Una vez conocida la estructura de otros simuladores, ha de realizarse la tarea T5 de diseño de nuestro simulador. Las tareas T7, T9, T10, T11 y T12 comprenden todo el proceso de implementación incremental del simulador, por lo que también forman parte del camino crítico. Finalmente, las tareas T13 y T14 comprenden las fases de prueba y validación del software, formando las últimas tareas a realizar en el proyecto, y formando parte del camino crítico. 2.2. Metodología de desarrollo Para el desarrollo del proyecto se sigue una metodología de tipo ágil [19]. Esta decisión se ha tomado debido a que el proyecto presenta una complejidad elevada y solo se cuenta con un desarrollador trabajando en el mismo. Concretamente, durante el desarrollo del software del proyecto se sigue una metodología principalmente de programación extrema [20], pero con ciertas características más afines a metodologías de tipo Scrum [21]. De este modo, el proyecto se desarrolla por sprints de corta duración: una o dos semanas generalmente. Para cada sprint se definen unos objetivos tentativos. Al final de cada sprint se realiza una reunión con los tutores académicos para discutir el avance del proyecto. En estas reuniones se exponen las nuevas funcionalidades añadidas, así como los problemas surgidos, y se definen los objetivos para el siguiente sprint. La utilización de metodologías ágiles permite la reacomodación de objetivos y tareas a realizar sobre la marcha. Esto resulta de gran utilidad a la hora de solventar los problemas y riesgos que surjan durante el desarrollo del proyecto. 12
CAPÍTULO 2. PLANIFICACIÓN Y PRESUPUESTO DEL PROYECTO 2.3. Riesgos y contingencias La planificación de un proyecto software es una tarea de gran relevancia y repercusión en el resto del desarrollo proyecto. Una planificación incorrecta que ignore los riesgos existentes en el proyecto puede retrasar los plazos de finalización del mismo, incrementando significativamente sus costes. Es necesario realizar un análisis de los riesgos que se pueden presentar durante el desarrollo del proyecto, junto con el desarrollo de planes de contingencia para mitigarlos y minimizar su impacto. Para este proyecto, se ha realizado un análisis de riesgos basado en las tareas y el alcance del proyecto. La tabla 2.1 presenta los riesgos detectados. Se incluye una estimación de la probabilidad de que ocurran, el impacto que tendrían en el proyecto, y el riesgo total que suponen para el mismo, denominado índice de exposición del riesgo [22]. A las probabilidades se les asigna un valor entre 0 (imposible que ocurra) y 10 (se está seguro de que va a ocurrir). Al impacto se le asigna un valor entre 0 (no repercute en el proyecto) y 10 (el proyecto no puede continuar). El riesgo total se calcula como el producto de la probabilidad y el impacto del riesgo. Los riesgos con valores totales más altos son los más importantes, por su potencial influencia en el proyecto. Descripción del riesgo Probabilidad Impacto Riesgo Desarrollo técnicamente demasiado complejo 5 4 20 Documentación del sistema a simular insuficiente 3 5 15 Estimación incorrecta del tiempo de aprendizaje de una tecnología software 6 5 30 Estimación incorrecta del tiempo de corrección de errores tras las pruebas de validación 5 4 20 Cambio en los requisitos durante el desarrollo del proyecto 3 6 18 Tabla 2.1: Riesgos del proyecto. Junto a la detección de riesgos, se han desarrollado planes de contingencia para mitigar sus efectos en caso de materializarse. La tabla 2.2 resume los planes de contingencia desarrollados para cada riesgo detectado. Como se ha mencionado en la sección 2.2, como contingencia general para los riesgos se ha adoptado una metodología de trabajo ágil. Esto permite la reacomodación de objetivos y tareas sobre la marcha, en caso de que se materializaran los riesgos o surgieran imprevistos durante el desarrollo. Uno de los riesgos más importantes detectados es que el desarrollo sea técnicamente demasiado complejo. Como plan de contingencia, si durante el desarrollo del simulador se desconoce cómo debería implementarse cierta funcionalidad concreta, se consultará la implementación de otros simuladores para esa funcionalidad y considerará la viabilidad de utilizar alguna implementación similar. La posible estimación incorrecta del tiempo de aprendizaje de una tecnología software es el 13
2.4. DESVIACIÓN DE LA PLANIFICACIÓN ORIGINAL Descripción del riesgo Plan de contingencia Desarrollo técnicamente demasiado complejo Consultar la implementación de otros simuladores como referencia Documentación del sistema a simular insuficiente Implementar el funcionamiento de las características no documentadas como decida el desarrollador, indicando y documentando esta decisión. Estimación incorrecta del tiempo de aprendizaje de una tecnología software Acotar los objetivos del proyecto, dividiendo el desarrollo del simulador en dos proyectos. Estimación incorrecta del tiempo de corrección de errores tras las pruebas de validación Limitar las características del simulador. Documentar los errores no corregidos, y considerar el sistema en estado de “beta”. Cambio en los requisitos durante el desarrollo del proyecto Aumentar las horas de trabajo semanales para alcanzar los nuevos objetivos. Tabla 2.2: Planes de contingencia concretos para los riesgos del proyecto detectados. mayor riesgo del proyecto. Como plan de contingencia, para las tareas que requieren el aprendizaje de una tecnología nueva, si se detecta que este aprendizaje puede extender considerablemente el tiempo de desarrollo del proyecto, se plantea la posibilidad de dividir el desarrollo del simulador en dos partes: la primera parte constaría del desarrollo de las funcionalidades de simulación (backend), y la segunda del desarrollo de la interfaz (frontend), contando con las pruebas de usabilidad. Esta contingencia abre la posibilidad de que otro alumno del grado en Ingeniería Informática de la Universidad de Valladolid pueda realizar su Trabajo de Fin de Grado finalizando el desarrollo del simulador. 2.4. Desviación de la planificación original En esta sección describimos la planificación ejecutada finalmente durante el desarrollo del proyecto, destacando las modificaciones realizadas sobre la planificación original. Esta sección contiene información sobre sucesos que han ocurrido cronológicamente después de la fase de planificación del proyecto. Se incluyen en este capítulo y no en ningún otro ya que guardan una relación más estrecha con los temas relativos a la planificación del proyecto que con cualquiera de los otros temas tratados en el resto del documento. A la hora de desarrollar el proyecto algunos de los riesgos previstos se materializaron (ver tablas 2.1 y 2.2). En concreto, la documentación del sistema a simular resultó ser de difícil acceso e interpretación, lo que produjo que el tiempo de dedicación estimado para el estudio de la arquitectura elegida fuese insuficiente.1. También, se subestimaron los tiempos necesarios para aprender las tecnologías utilizadas durante el desarrollo del proyecto, especialmente las requeridas para desarrollar la interfaz gráfica del simulador. 1En el apéndice C se explica de manera más detallada el problema existente con la documentación del sistema a simular. 14
CAPÍTULO 2. PLANIFICACIÓN Y PRESUPUESTO DEL PROYECTO A los riesgos descritos en el párrafo anterior se añadió la materialización de un riesgo no previsto: la pandemia de la COVID-19 y sus consecuencias a nivel de condiciones de salud y trabajo. La pandemia de la COVID-19 afectó especialmente al desarrollo del proyecto, impidiendo realizar tantas reuniones de seguimiento con los tutores académicos como se habían planificado inicialmente. También dificultó las condiciones en las que estas se desarrollarían. La disminución en la cantidad y calidad de las reuniones con los tutores académicos agravó las consecuencias de la materialización de los otros riesgos, llevando a la necesidad de poner en marcha planes de contingencia más contundentes. Como plan de acción ante los riesgos materializados descritos, se decide tomar las siguientes medidas: Desarrollar un prototipo de interfaz gráfica en vez de una completamente funcional. Solo se requeriría una interfaz lo suficientemente funcional para poder realizar una demostración de las capacidades del simulador. (Modificación de la tarea T14). Desarrollar un resumen de manual de usuario, acorde al prototipo de interfaz desarrollado. (Modificación de la tarea T14). Posponer las pruebas de usabilidad con usuarios reales. Estas podrían realizarse el primer cuatrimestre del curso académico 2021-2022, con alumnos de la asignatura Arquitectura y Organización de Computadores [5]. (Supresión de la tarea T15). Con estas medidas, el proyecto podría desarrollarse hasta un estado estable, requiriendo tan solo de ligeras modificaciones y adiciones para poder comenzar la fase final de validación. Se estima que dichas modificaciones y adiciones podrían completarse durante la primera mitad del tercer cuatrimestre de 2021. 2.5. Presupuesto del proyecto En este apartado se presenta un análisis del presupuesto estimado para el proyecto. El coste asociado al mismo se puede dividir en dos categorías principales: las horas de trabajo del desarrollador y los tutores, y la amortización de las máquinas de trabajo utilizadas para desarrollar el proyecto. No ha sido necesaria la adquisición de licencias de software para el desarrollo del proyecto. Para realizar la estimación del presupuesto del proyecto, se han estimado los siguientes costes: Sueldo del desarrollador: se estima que el sueldo de un ingeniero informático junior es de unos 20 000 ebrutos anuales. Suponiendo una jornada completa de 8 horas y diarias, y 250 días laborales al año aproximadamente, se obtiene un coste del desarrollador de 10 ela hora. Sueldo de los tutores académicos: se estima el sueldo de un profesor titular de escuela universitaria en unos 30 000 ebrutos anuales, y el sueldo de un profesor ayudante doctor en 15
2.5. PRESUPUESTO DEL PROYECTO unos 27 000 ebrutos anuales2. Suponiendo una jornada completa de 8 horas diarias, y 250 días laborales al año, se obtiene un coste de los tutores académicos de 15 ey 13.5 ela hora, respectivamente. Coste de las máquinas de desarrollo: durante el desarrollo del proyecto se han utilizado dos máquinas distintas: •El ordenador personal de sobremesa del desarrollador. Se trata de un ordenador hecho a piezas, encargado y personalizado por el desarrollador. Este ordenador ha sido utilizado por el desarrollador para trabajar en el proyecto desde casa. Fue comprado en junio de 2017 por un precio de 1144,65 ey una estimación de la vida útil de 5 años. El coste asociado a la amortización de este material de trabajo es de 0,16 ela hora. •El ordenador portátil del desarrollador. Se trata de un HP Pavilion comprado en diciembre de 2013 por un precio de 599,00 e. La estimación de su vida útil fue de 4 años. El coste asociado a la amortización de este material de trabajo es de 0,10 ela hora. Las horas de utilización de cada recurso se proyectan de la siguiente forma: Desarrollador: 450 horas; 300 asociadas a los 12 créditos de la asignatura del Trabajo de Fin de Grado, y 150 a la realización de dos publicaciones de carácter investigador relacionadas con el trabajo desarrollado en el proyecto. Tutores académicos: 60 horas cada uno, asociadas a las reuniones que se realizarán semanalmente como mínimo, durante todo el desarrollo del proyecto. La duración de estas reuniones se estima en 45 minutos. Máquinas del desarrollador: se utilizan a partes iguales durante todo el proyecto. El ordenador de sobremesa se utiliza para trabajar desde casa, y el ordenador portátil para trabajar en la facultad. Por tanto, 225 horas cada uno. Siguiendo estas condiciones, el coste del trabajo personal y de uso de las máquinas en el proyecto se describe en las tablas 2.3 y 2.4 Personal Coste/hora (e) Horas Total Alumno 10 450 4500 Tutor TEU 15 60 900 Tutor AYUDOC 13.5 60 810 Total 6210 Tabla 2.3: Coste de trabajo de personal del proyecto. El coste total del proyecto, calculado a partir del coste de las máquinas y de las horas de trabajo del personal, se indica en la tabla 2.5 2El sueldo real depende de los trienios y quinquenios de los tutores académicos. 16
CAPÍTULO 2. PLANIFICACIÓN Y PRESUPUESTO DEL PROYECTO Máquina Precio (e) Amortización (años) Coste/hora (e) Horas Total (e) Ordenador de sobremesa 1144,65 5 0,16 225 36 Ordenador portátil 599,00 5 0,10 225 22,5 Total 58,5 Tabla 2.4: Coste de uso de las máquinas en el proyecto. Actividad Coste (e) Horas de trabajo del personal 6210 Uso de las máquinas 58,5 Total 6268,5 Tabla 2.5: Coste total del proyecto. 2.6. Resumen En este capítulo se ha detallado la planificación del proyecto. En esta se incluyen las tareas a realizar, los posibles riesgos y sus planes de contingencia, y la descripción del camino crítico del proyecto. También se incluye una sección dedicada a la descripción de los riesgos que finalmente se materializaron durante el desarrollo del proyecto, y cómo afectaron a la planificación inicial. Para terminar, se realiza una estimación del presupuesto del proyecto, basado en el tiempo de desarrollo del mismo. 17
2.6. RESUMEN 18
CAPÍTULO 3. ESTADO DEL ARTE Capítulo 3 Estado del arte En este capítulo se detalla el estado del arte sobre las áreas de estudio en las que se centra el proyecto: El estado del arte de la arquitectura de computadores. La aproximación pedagógica actual al estudio de la arquitectura de computadores. Los simuladores de computadores didácticos actuales. 3.1. Arquitecturas de computadores Existen multitud de arquitecturas de computadores distintas, remontándose las primeras arquitecturas modernas a la década de 1960. A día de hoy, existen dos tipos principales de arquitecturas: Arquitecturas CISC (Complex Instruction Set Computer, arquitectura de conjunto de instrucciones complejo). Estas arquitecturas incluyen instrucciones muy complejas que pueden ejecutar múltiples operaciones de bajo nivel de forma individual. Por ejemplo, carga de memoria, operación aritmética, y almacenamiento en memoria, todo en la misma instrucción. La filosofía de los computadores CISC se centra en diseñar una gran cantidad de instrucciones que realicen operaciones muy específicas. Estas instrucciones suelen tomar más de un ciclo de reloj en ser ejecutadas. Este diseño pretende acercar las operaciones realizables por las instrucciones ensamblador de los computadores a construcciones de lenguajes de programación de alto nivel (llamadas a procedimientos, control de bucles, acceso a arrays y estructuras...). El diseño hardware de los computadores CISC es relativamente denso y complejo. Arquitecturas RISC (Reduced Instruction Set Computer, computador de conjunto de instrucciones reducido). Estas arquitecturas trabajan con conjuntos de instrucciones pequeños. Surgieron como intento por simplificar los diseños de los computadores CISC. Las instrucciones de los computadores RISC realizan operaciones más generales que las de 19
3.5. RESUMEN 26
CAPÍTULO 4. CONOCIMIENTOS PREVIOS Capítulo 4 Conocimientos previos La primera tarea del proyecto consiste en la familiarización con las características de las arquitecturas de computadores relevantes para el simulador que se desea desarrollar (tarea T1 detalladas en la sección 2.1), así como con la arquitectura específica a simular (tareas T2 y T3 detalladas en la sección 2.1). Esto implica realizar un análisis de las características de interés comunes a todas las arquitecturas consideradas para el simulador, así como un estudio exhaustivo de la arquitectura específica que se simulará, antes de comenzar el desarrollo del simulador en sí mismo. Como consecuencia de dicha tarea de análisis se ha realizado un minucioso estudio de las materias de interés para definir las características del simulador. Algunas de los temas que conforman estas materias son lo suficientemente complejos como para requerir de un capítulo específico en el que se detallen, de forma que se pueda comprender de forma correcta el desarrollo del proyecto. En este capítulo se exponen los contenidos que conforman los conocimientos previos necesarios para entender el desarrollo del proyecto: Las arquitecturas de computadores de tipo RISC. La técnica de segmentación de instrucciones en procesadores. La arquitectura ARM LEGv8. 4.1. Arquitecturas RISC RISC, del inglés Reduced Instruction Set Computer (computador con conjunto de instrucciones reducido) es un tipo de arquitectura de computadores que engloba a todas las arquitecturas que presentan un conjunto pequeño de instrucciones. Se presentan como alternativa a las arquitecturas de tipo CISC (Complex Instruction Set Computer, computador con conjunto de instrucciones complejas), que presentan una gran cantidad de instrucciones muy especializadas. El término RISC data de 1980, acuñado por David A. Patterson, profesor de la Universidad de California, Berkeley [42]. Patterson lidero el proyecto Berkeley RISC, que trataba de 27
4.1. ARQUITECTURAS RISC diseñar microprocesadores más rápidos a partir de la utilización de conjuntos de instrucciones más sencillos que los utilizados hasta entonces. Al mismo tiempo, se desarrolló el proyecto MIPS de la universidad de Stanford, con objetivos similares. Además de proponer una simplificación de los conjuntos de instrucciones y del diseño de los procesadores, a partir de ambos proyectos se determinó que para desarrollar procesadores más rápidos, estos debían contar con un mayor número de registros, cuya lectura o escritura resulta más rápida que la lectura o escritura de una memoria global. Los resultados de los proyectos mencionados inspiraron el diseño de gran cantidad de arquitecturas de computadores posteriores: las arquitecturas RISC. Todas estas arquitecturas siguen las bases comunes de los computadores RISC: Arquitecturas de carga y almacenamiento. Estas arquitecturas dividen las instrucciones en dos tipos: las que acceden a la memoria, y las que realizan operaciones aritmético-lógicas sobre registros. Esto implica que los computadores no pueden trabajar directamente sobre valores almacenados en memoria, sino que deben ser previamente cargados en un registro, y almacenados después. Presencia de registros de propósito general que pueden utilizarse tanto de fuente como de destino en cualquier operación. Esta característica ayuda a simplificar el diseño de los compiladores. •Pueden existir ficheros de registros separados dedicados a los operandos de cierto subconjunto de instrucciones concreto. Suele ser el caso de los registros de coma flotante y los registros vectoriales. Formato de instrucciones simple, de tamaño fijo y uniforme, con códigos de operación breves y generalmente de tamaño fijo también. Esta característica simplifica la lógica de búsqueda, decodificación y preparación de los computadores. Distintos formatos de instrucciones, utilizados para diferenciar las instrucciones según las tareas que realizan: operaciones aritmético-lógicas, cargas y almacenamientos de memoria, control del flujo del programa, etc... Modos de direccionamiento de memoria simples. Los direccionamientos complejos se realizan mediante secuencias de múltiples instrucciones. Soporte hardware tan solo para tipos de datos simples: bytes, medias palabras, palabras, dobles palabras... El soporte para tipos de datos más complejos, como las cadenas de caracteres, se debe gestionar a través del software. Rendimiento de los computadores cercano a 1 instrucción por ciclo de reloj (en teoría). Estas características centradas en la simplicidad de diseño, sobre todo en lo que al conjunto de instrucciones se refiere, convierten a las arquitecturas RISC en arquitecturas muy populares en los contextos docentes. Como se ha visto en el capítulo 3, en las asignaturas universitarias sobre Arquitectura de Computadores se suele estudiar el diseño de las arquitecturas RISC por su baja complejidad, y por su extenso uso en el mundo actual. 28
CAPÍTULO 4. CONOCIMIENTOS PREVIOS 4.2. Segmentación de instrucciones La segmentación de instrucciones o pipelining es una técnica utilizada en Arquitectura de Computadores que implementa paralelismo a nivel de instrucción en los procesadores. Consiste en dividir la ejecución de las instrucciones en un procesador en varias etapas secuenciales, conocidas en conjunto como pipeline. Las instrucciones avanzan de etapa en etapa para ser ejecutadas, teniendo cada etapa una función distinta. Cada ciclo de reloj del procesador se ejecuta una de las etapas de cada instrucción en el pipeline, avanzando a la siguiente. Como en cada etapa se utiliza solo una parte del hardware del procesador, pueden ejecutarse varias instrucciones a la vez, siempre y cuando estén en etapas distintas. Figura 4.1: Incremento en la productividad de los procesadores a través de la segmentación de instrucciones: ejecución en un ciclo sin pipeline (arriba) versus ejecución con pipeline (abajo). Extraído de [28]. El procesador de arriba ejecuta una instrucción por ciclo de reloj, con un periodo de reloj de 800ps. El procesador de abajo ejecuta una instrucción en 5 ciclos de reloj, con un periodo de reloj de 200ps. Pese a que la ejecución de una única instrucción tarde más en finalizar en el procesador de abajo que en el de arriba, el procesador de abajo obtiene una productividad mayor al poder ejecutar múltiples instrucciones en paralelo. Idealmente, todas las etapas del pipeline estarían ocupadas ejecutando alguna instrucción. De ser así, el procesador alcanzaría una productividad de una instrucción por ciclo. En teoría, cuantas más etapas tenga un pipeline, menor puede ser el periodo de reloj del procesador. Es decir, el procesador sería más rápido. Por tanto, la segmentación de instrucciones trata de incrementar la 29
4.2. SEGMENTACIÓN DE INSTRUCCIONES productividad de los procesadores disminuyendo el periodo de reloj de los mismos y manteniendo una tasa de ejecución de instrucciones de una instrucción por ciclo. La figura 4.1 ilustra este concepto. La segmentación de instrucciones es una técnica utilizada por la práctica totalidad de los procesadores actuales, debido a su relativa sencillez de implementación y considerable ganancia en productividad. Esta utilización masiva la convierte en una técnica fundamental en el ámbito de la Arquitectura de Computadores; tanto es así que se suele estudiar en las asignaturas que tratan los conceptos fundamentales de Arquitectura de Computadores de las universidades de todo el mundo. Cada procesador puede implementar su pipeline de forma distinta, variando él número y la función de las etapas. La conocida como “pipeline clásico RISC”, explicada en el libro de Patterson yHennessy e implementada por la arquitectura LEGv8, consta de 5 etapas: 1. Búsqueda de instrucciones (abreviada IF por Instruction Fetch): la siguiente instrucción a ejecutar se busca en la memoria de instrucciones y se lleva al hardware del procesador. 2. Decodificación de instrucciones (abreviada ID por Instruction Decode): la instrucción obtenida de la etapa anterior es decodificada. Esto activa las señales necesarias en el procesador para la ejecución de la instrucción. Además, se leen los operandos de la instrucción del banco de registros del procesador. 3. Ejecución de instrucciones (abreviada EX por EXecution): la Unidad Aritmético-Lógica (ALU a partir de ahora) realiza la operación indicada por la instrucción sobre los operandos de la misma, generando un resultado. 4. Acceso a memoria (abreviada MoMEM ): las instrucciones que lo requieran, leen o escriben datos de memoria en esta fase. 5. Escritura de resultados (abreviada WB por Write Back): el resultado producido o el dato leído de memoria se escribe en el correspondiente registro del banco de registros. La figura 4.2 ilustra la ejecución de instrucciones en esta pipeline. el pipeline clásico RISC puede parecer muy simplista, pero es suficiente para ilustrar los conceptos básicos de la segmentación de instrucciones en gran detalle. Los procesadores reales pueden implementar pipelines más o menos extensos, dependiendo del compromiso que los fabricantes estén dispuestos a adoptar en cuanto a productividad y costes de producción. 4.2.1. Riesgos en pipelines y técnicas para resolverlos La segmentación de instrucciones no está exenta de complicaciones. Existen situaciones en las que una instrucción no puede comenzar su ejecución hasta que otra anterior haya finalizado todas sus etapas. Estas situaciones son conocidas como “riesgos”. En un procesador segmentado se pueden dar tres tipos distintos de riesgos: 30
CAPÍTULO 4. CONOCIMIENTOS PREVIOS Figura 4.2: Ejecución de tres instrucciones LEGv8 en el pipeline clásico RISC. Extraído de [28]. En la figura se indica en qué etapa del pipeline se encuentra cada instrucción durante los distintos ciclos de reloj que toma su ejecución completa. Riesgos estructurales: se dan cuando no hay hardware suficiente para ejecutar las distintas etapas de dos o más instrucciones específicas en el mismo ciclo de reloj. Es decir, se producen cuando dos o más instrucciones requieren hacer uso del mismo recurso hardware en el mismo ciclo de reloj. En procesadores sencillos, estos riesgos no deberían producirse nunca. Sin embargo, la introducción de instrucciones complejas como las de multiplicación o división tienden a producir estos riesgos de manera inevitable. Riesgos de datos: estos se producen cuando una o varias instrucciones deben usar como operandos los resultados de una instrucción que todavía no ha finalizado su ejecución. Hay varios tipos de riesgos de datos. Los más comunes son los riesgos de tipo RAW (read after write): una instrucción debe leer un dato antes de que una instrucción anterior lo haya producido. Utilizando el pipeline clásico RISC como ejemplo: la ejecución de instrucciones se produce en la etapa 3 del pipeline, pero la escritura de resultados no se produce hasta la 5. Supongamos que tenemos una instrucción aritmética que hace uso del resultado producido por la instrucción inmediatamente anterior. Cuando se deba ejecutar dicha instrucción (etapa 3), la anterior todavía no habrá escrito su resultado en el banco de registros (etapa 5), ya que todavía se hallará en la etapa 4. Este clase de riesgos se ilustra en el ejemplo del código 4.1 y la figura 4.3. Riesgos de control: esta clase de riesgos se da con instrucciones que alteran el flujo de control del programa: saltos o bifurcaciones. Las bifurcaciones condicionales son aquellas en las que solo se produce la bifurcación (cambio de flujo) si se cumple una condición, generalmente basada en la comparación anterior de dos valores. Con las instrucciones de bifurcación condicional es posible que la instrucción inmediatamente después no sea la siguiente que deba ejecutarse. Además, esa información probablemente no se conozca en la primera etapa del 31
4.2. SEGMENTACIÓN DE INSTRUCCIONES SUB X2 , X1 , X3 // Registro X2 escrito por SUB. AND X12 , X2 , X5 // Primer operando (X2) depende de SUB. OR X13 , X6 , X2 // Segundo operando (X2) depende de SUB. ADD X14 , X2 , X2 // Primer y segundo operando dependen de SUB . STUR X15 , [X2 , #100] // Base de la carga de memoria (X2 ) depende de SUB . Fragmento de código 4.1: Fragmento de código ensamblador LEGv8 que ilustra los riesgos de datos en computadores segmentados. Todas la primera instrucción (SUB) genera un resultado (X2) del que dependen todas las demás instrucciones. Esta dependencia es causa de problemas en computadores segmentados, ya que algunas de las instrucciones podrían requerir leer el dato antes de que la primera instrucción escriba su valor en el registro de resultado. La figura 4.3 ilustra este problema en el pipeline clásico RISC. Figura 4.3: Ilustración de las dependencias de datos del fragmento de código LEGv8 presentado en el código 4.1 al ejecutarse en el pipeline clásico RISC de 5 etapas. Extraído de [28]. La figura muestra en orden cronológico la ejecución de las 5 instrucciones, destacando las dependencias de datos con color azul, tanto en las propias instrucciones como en el diagrama de etapas de ejecución de las instrucciones. “CC n” en la parte superior de la figura indica el n-ésimo ciclo de reloj de la ejecución. Puede observarse que el resultado de la primera instrucción (SUB) no se escribe hasta el ciclo de reloj 5, pero es requerido en los ciclos de reloj 3, 4, 5 y 6. Las dependencias en los ciclos 6 e incluso 5 no presentan ningún problema (los registros son escritos en la primera mitad de una etapa y leídos en la segunda mitad, pudiendo leerse un resultado el mismo ciclo que se ha producido). Las dependencias que van atrás en el tiempo son riesgos de datos en el pipeline. 32
CAPÍTULO 4. CONOCIMIENTOS PREVIOS pipeline. En el tiempo que se tarda en decidir si la bifurcación es tomada o no, más instrucciones podrían entrar en el pipeline, potencialmente de forma incorrecta. Además, también es posible que se desconozca la dirección de memoria de la instrucción a la que se debe bifurcar hasta después de la primera etapa del pipeline. Es el caso, por ejemplo, en instrucciones que bifurcan a la dirección apuntada por un registro (bifurcaciones indirectas). Esto causa problemas en el caso de las bifurcaciones incondicionales también: aunque se sabe cuál es la dirección que debe tomar el flujo del programa, no es posible llevar la siguiente instrucción al procesador en el siguiente ciclo de reloj. En general, los riesgos impiden continuar la ejecución de instrucciones en el procesador de forma correcta. Es necesario buscar soluciones a estos riesgos para que los procesadores segmentados puedan funcionar. La solución más simple para los riesgos consiste en detener el flujo de instrucciones del procesador, dejando de iniciar la ejecución de instrucciones hasta que todas las anteriores hayan finalizado. Este fenómeno es conocido como burbuja. La lógica y circuitería necesarias para implementar esta solución son mínimas. Sin embargo, también es la solución más indeseable de todas. Teniendo en cuenta que se desea una productividad de 1 instrucción ejecutada por ciclo, esta solución es indeseable ya que impide la ejecución de instrucciones de forma temporal. Aun así, en algunas ocasiones es la única solución factible para asegurar la correcta ejecución de un programa en el procesador. Para resolver los riesgos de datos se suele aplicar la técnica denominada anticipación. Esta técnica consiste en añadir circuitería al procesador para que los resultados producidos en cada etapa (si hubiera) estén disponibles en las etapas anteriores. De este modo, las siguientes instrucciones no tendrían que esperar a la escritura de los resultados para ser ejecutadas; solo a que se produzca el resultado. Dependiendo del pipeline del procesador y del conjunto de instrucciones utilizado por el mismo, esta técnica puede no ser aplicable a todas las secuencias de instrucciones. No obstante, es una técnica relativamente sencilla de implementar en los procesadores, e idealmente mantiene la productividad de 1 instrucción ejecutada por ciclo. Es por eso que se trata de una técnica ampliamente utilizada en los procesadores segmentados. En el caso de los riesgos de control, numerosas técnicas han sido desarrolladas a lo largo de los años para paliar sus efectos. Las relacionadas con bifurcaciones condicionales generalmente implican el uso de algún predictor de saltos. Los predictores de saltos permiten continuar la ejecución del programa por alguna de las vías alternativas, esperando que la elegida sea la correcta. Cuando se confirma la dirección de la bifurcación, en una etapa del pipeline posterior a en la que se hizo la predicción, la predicción se resuelve como correcta o incorrecta. De ser la predicción incorrecta, se deben descartar todas las instrucciones que han entrado a el pipeline desde que se hizo la predicción, obteniendo la misma productividad que se habría obtenido deteniendo el pipeline. De ser la predicción correcta, se continua la ejecución tal y como se estaba haciendo, obteniendo la máxima productividad posible en esa bifurcación. Los predictores de saltos pueden ser más o menos complejos, siendo los más sencillos los predictores estáticos: predecir siempre que la bifurcación se toma o que la bifurcación no se toma. La discusión de otros tipos de predictores queda fuera del ámbito de este documento. Otra técnica utilizada para paliar los riesgos de control se denomina “bifurcación retardada”. Consiste en continuar la ejecución de las instrucciones directamente después de la instrucción de 33
4.3. ARQUITECTURA ARM LEGV8 bifurcación hasta que se resuelva la propia bifurcación. Esta técnica confía en que el programador o el compilador reordene las instrucciones del programa de tal forma que se conserve la corrección del mismo. Las ventajas de esta técnica son que no requiere la modificación del hardware o la lógica del computador, y que la productividad obtenida es teóricamente la máxima alcanzable (al no producirse ninguna detención en ningún caso). La principal desventaja es la confianza en una entidad externa para reordenar correctamente las instrucciones del programa, tarea que en ocasiones resulta imposible. Es muy común que no se disponga de instrucciones útiles que se puedan reordenar para ser ejecutadas después de la instrucción de bifurcación, manteniendo la corrección del programa. En estos casos es necesario introducir instrucciones que no hacen nada (nops), produciendo resultados equivalentes a detener el flujo de instrucciones. Los riesgos y sus respectivas soluciones expuestos en esta sección son esenciales a la hora de trabajar con procesadores segmentados. Comprenderlos es fundamental para entender las complicaciones que acarrea la segmentación de instrucciones. Es por ello que suelen conformar parte del temario relacionado con segmentación de instrucciones en todas las asignaturas universitarias que tratan conceptos fundamentales de Arquitectura de Computadores. 4.3. Arquitectura ARM LEGv8 LEGv8, (Lessen Extrinsic Garrulity version 8, “disminuir la garrulidad extrínseca versión 8”), es una micro-arquitectura derivada de la arquitectura AArch64 [43]. AArch64 a su vez es el modo de 64 bits de la arquitectura ARMv8. (Ver el apéndice C para más información sobre las versiones de ARM). LEGv8 fue desarrollado por Hennessy yPatterson para ser utilizada con motivos pedagógicos en su libro Computer Organization and Design ARM edition: The Hardware/Software Interface. Dicho libro, en conjunto con la arquitectura LEGv8, fue publicado en 2016, y trataba de actualizar las ediciones anteriores del mismo, en las que se utilizaba la arquitectura MIPS. El enfoque que se tomó a la hora de diseñar la arquitectura fue, en esencia, elegir un subconjunto de instrucciones ensamblador de Aarch64 lo más similar posible a la arquitectura MIPS usada en las ediciones anteriores. Se tomó este enfoque para minimizar el número de cambios a realizar en el resto del libro. En definitiva, podría verse como una versión de MIPS de 64 bits, adaptada a la sintaxis e instrucciones de ARMv8. La decisión de utilizar la versión de 64 bits de ARM y no alguna de las múltiples de 32 bits que existen se basó en una encuesta realizada a los profesores y alumnos de una facultad donde se utilizaba una edición anterior del libro como material docente: el 75 % de los encuestados o bien preferían cambiar a una versión con direcciones de memoria más grandes (que 32 bits), o bien les era indiferente. Las características principales de la arquitectura LEGv8 son: Arquitectura de diseño RISC y de tipo registro-registro. 31 registros de 64 bits de propósito general, y un registro que almacena la constante cero. Además, puede contar con 32 registros opcionales para el almacenamiento de datos en 34
CAPÍTULO 4. CONOCIMIENTOS PREVIOS Figura 4.4: Codificación de los distintos formatos de instrucción presentes en la arquitectura LEGv8. Extraído de [28]. Opcode es el código de operación. Rn,Rm,Rt yRd son los registros que participan en la ejecución de la instrucción, tanto los operandos como el destino. shamt se utiliza en conjunto con el código de operación para distinguir las instrucciones de tipo R. Los distintos campos que terminan en address son las direcciones de memoria, relativas o absolutas, sobre las que trabajan las distintas instrucciones. Los campos que terminan en immediate son los valores numéricos constantes sobre los que trabajan las instrucciones de tipo I o IW. formato coma flotante de doble precisión. Espacio de direcciones de 64 bits, con palabras de 32 bits (4 bytes), permitiendo el direccionamiento de un total de 262 palabras. Instrucciones ensamblador de 32 bits, con código de operación de longitud variable. Múltiples formatos de instrucción. Los formatos de instrucción de LEGv8 son los siguientes: •Formato R: para instrucciones aritmético-lógicas que trabajan exclusivamente sobre registros. •Formato I: para instrucciones aritmético-lógicas que trabajan sobre registros y valores numéricos constantes (inmediatos). •Formato D: para instrucciones de transferencia de datos entre registros y memoria. •Formato B: para instrucciones de bifurcación (salto) incondicionales. •Formato CB: para instrucciones de bifurcación (salto) condicionales. •Formato IW o IM: para instrucciones que trabajan con valores numéricos constantes (inmediatos) de gran tamaño. La figura 4.4 muestra la codificación de los distintos formatos de instrucciones. La tabla 4.1 muestra todas las instrucciones básicas que conforman la arquitectura y su extensión aritmética. 35
5.1. ANÁLISIS DE REQUISITOS La especificación de requisitos en base a las funcionalidades observadas en otros simuladores tiene como riesgo asociado la posibilidad de definir más requisitos funcionales de los que se puedan cumplir durante el desarrollo previsto del proyecto. Como este proyecto trata de desarrollar un prototipo, es decir, realizar el desarrollo inicial de una herramienta de propósito docente, con la posibilidad de ampliarse y mejorarse según se vaya utilizando en los sucesivos cursos académicos, se plantearon una serie de requisitos tentativos deseados para el simulador, por orden de prioridad. Para los requisitos que no se pudieran llegar a cumplir, se facilitaría su futuro cumplimiento en la medida de lo posible y se dejarían como trabajo futuro. Para asegurar una calidad mínima en este proyecto, una serie de requisitos serían considerados esenciales, y no se daría por finalizado el mismo hasta que no se llegaran a cumplir. En definitiva, una versión definitiva del simulador debe cumplir todos los requisitos especificados en esta sección. El prototipo desarrollado en este proyecto tan solo debe cumplir los requisitos considerados esenciales, y facilitar el cumplimiento de todos los demás en la medida de lo posible. 5.1.1. Requisitos funcionales A continuación se detalla la lista de requisitos funcionales planteados para el simulador: RF1.- El sistema debe ser capaz de simular la ejecución de programas textuales escritos en ensamblador ARM LEGv8. RF2.- El sistema debe permitir la simulación en “modo interactivo”, proporcionando una interfaz gráfica al usuario en la que se presente toda la información de la simulación. RF3.- El sistema debe ser capaz de proporcionar información al usuario sobre el contenido de los registros del computador simulado. RF4.- El sistema debe ser capaz de proporcionar información al usuario sobre el contenido de la memoria del computador simulado. RF5.- El sistema debe ser capaz de proporcionar información al usuario sobre las etiquetas de memoria especificadas en el código ensamblador. RF6.- El sistema debe proporcionar información sobre la compilación/ensamblado de los programas ensamblador. Esta información debe incluir los fallos sintácticos hallados en el código, haciendo referencia a la línea de código fuente donde se encuentren. También debe incluir los resultados de las diferentes etapas de compilación: a) Programa preprocesado que traduce pseudo-instrucciones, constantes, etiquetas y otros símbolos por sus valores reales. b) Programa en código máquina. RF7.- El sistema debe soportar el uso de directivas de preprocesador que permitan la cómoda especificación de los datos de los programas en su código fuente. RF8.- El sistema debe ser capaz de simular pseudo-instrucciones ARM LEGv8 , tanto las indicadas en la especificación de la arquitectura en [28], como otras definibles por el usuario. 42
CAPÍTULO 5. ANÁLISIS Y DISEÑO DEL SIMULADOR RF9.- El sistema debe ser capaz de simular la funcionalidad de “segmentación de instrucciones” de manera similar a como se describe en el capítulo cuatro de Computer Organization and Design - The Hardware/Software Interface. RF10.- El sistema debe ser capaz de simular la funcionalidad de “bifurcación retardada” utilizada en múltiples computadores comerciales. RF11.- El sistema debe ser capaz de simular pipelines genéricas con unidades funcionales multiciclo, similares a las que se describen en el Computer Architecture: A Quantitative Approach [44]. RF12.- El sistema debe permitir la simulación de distintas arquitecturas de computadores, eligiendo el usuario cuál utilizar. RF13.- El sistema debe permitir la edición de código ensamblador cuando se ejecute en modo interactivo, proporcionando un editor capaz de cargar y guardar ficheros para ello. RF14.- El sistema debe permitir la simulación de la ejecución completa de un programa ensamblador. RF15.- El sistema debe permitir la simulación de la ejecución instrucción a instrucción de un programa ensamblador. RF16.- El sistema debe permitir la simulación en “modo terminal”, simulando la ejecución un programa especificado y proporcionando la información de la simulación antes de finalizar la ejecución. RF17.- El sistema debe ser capaz de proporcionar información sobre las flags de estado del computador simulado. RF18.- El sistema debe permitir la especificación de puntos de ruptura en el código que pausen la simulación de los programas. RF19.- El sistema debe ser capaz de proporcionar información al usuario sobre el pipeline en tiempo de ejecución, incluyendo sus etapas y las instrucciones ejecutándose en cada una. RF20.- El sistema debe ser capaz de simular la ejecución de programas textuales escritos en ensamblador RISC-V. RF21.- El sistema debe ser capaz de simular la ejecución de programas textuales escritos en ensamblador de MIPS. De estos requisitos se consideran requisitos esenciales RF1, RF2, RF3, RF4, RF5 y RF6. Esto implica que el software resultante de este proyecto debe cumplir por lo menos todos esos requisitos. Durante el proyecto también se trata de cumplir todos los requisitos funcionales restantes, pero estos se consideran secundarios y no estrictamente necesarios. 43
5.2. ARQUITECTURA DEL SISTEMA 5.1.2. Requisitos no funcionales A continuación se detalla la lista de requisitos no funcionales planteados para el simulador. RNF1.- El sistema debe ser multiplataforma, pudiendo ejecutarse en entornos Windows y Unix. RNF2.- El sistema debe presentar una estructura modular. RNF3.- El sistema debe ser fácilmente extensible para soportar otras arquitecturas de computadores y lenguajes ensamblador. RNF4.- El código fuente del sistema debe ser de fácil comprensión y lectura, con el objetivo de facilitar la posible modificación y expansión de sus funcionalidades. RNF5.- El código fuente del sistema debe estar escrito en inglés; incluyendo nombres de estructuras, clases, variables locales y globales, comentarios, y cualquier otro tipo de elemento al que el programador deba dar nombre. RNF6.- El modo interactivo del sistema debe permitir cambiar de arquitectura de ejecución sin la necesidad de reiniciar el sistema. RNF7.- El sistema debe permitir presentar la información numérica sobre la simulación de distintas formas cuando se ejecute en modo interactivo, incluyendo en base decimal, en base hexadecimal, y como caracteres ASCII. RNF8.- El sistema debe permitir presentar la información de memoria de la simulación en distintos tipos de endianness. De estos requisitos se consideran requisitos esenciales RNF1, RNF2, RNF3 y RNF4. 5.2. Arquitectura del sistema En esta sección se describe la arquitectura elegida para el sistema, así como las decisiones de diseño relacionadas con la misma. El requisito RNF2 especifica que el simulador debe presentar una estructura modular, aunque no detalla cuántos ni cuáles deben ser los módulos del mismo. Uno de los principales motivos por los que se impone esta estructura modular como requisito no funcional es para contribuir al cumplimiento del requisito RNF4. El requisito RNF4 especifica que el simulador debe ser extensible para soportar más arquitecturas y lenguajes ensamblador, con el objetivo de no tener que desarrollar otro sistema completo nuevo si se quiere utilizar arquitectura. Basándonos en estos dos requisitos, se ha decidido que el sistema se dividirá en varios módulos aislados, definiendo cada uno una arquitectura y lenguaje ensamblador soportada por el simulador. A cada uno de estos módulos los denominamos “arquitecturas del simulador”, o simplemente “arquitecturas”. Por ejemplo, en este proyecto se desarrolla la arquitectura LEGv8, y se plantea la posibilidad de desarrollar las arquitecturas RISC-V y MIPS (requisitos RF1, RF20 y RF21, respectivamente). 44
CAPÍTULO 5. ANÁLISIS Y DISEÑO DEL SIMULADOR Figura 5.1: Diseño general básico de la arquitectura del simulador. El simulador puede implementar un número indefinido de arquitecturas. Las arquitecturas por si solas, tal y como se han planteado, parecen ser simuladores individuales sin ninguna relación entre sí: necesitan una conexión común. Por tanto, el sistema debe presentar otro módulo adicional. Este módulo define todo el frontend del sistema, siendo las distintas arquitecturas del mismo, en su conjunto, el backend. Los propósitos del frontend serían dos: 1. Definir la interfaz gráfica del sistema, siendo el punto de conexión del usuario con el simulador. 2. Definir las interfaces que deben realizar las distintas arquitecturas para poder comunicarse correctamente con la interfaz gráfica del sistema. La figura 5.1 ilustra la arquitectura general del sistema a nivel básico, tal y como se ha descrito hasta ahora. Los módulos descritos hasta el momento son tan complejos que podrían tratarse de propios sistemas por sí mismos. Para definirlos con más detalle y facilitar su comprensión y utilización, se dividen a su vez en paquetes o submódulos. El frontend se divide en el módulo de interfaz gráfica, y el módulo de interfaces de arquitecturas. Estos dos módulos también se dividen en varios paquetes o submódulos. Las distintas arquitecturas del simulador se dividen en los submódulos análogos a los definidos en el módulo de interfaces de arquitecturas. Para realizar la interfaz gráfica del simulador, se ha decidido utilizar el patrón de modelo-vistacontrolador pasivo. De esta forma, el módulo de interfaz gráfica del frontend se dividirá en vistas 45
5.2. ARQUITECTURA DEL SISTEMA y controladores. Decidir la subdivisión de las arquitecturas y el módulo de interfaces de arquitecturas no es una tarea sencilla. Se pretende que las interfaces que se definan sirvan como abstracción de cualquier arquitectura soportada por el simulador, ya que esa debería ser su función. Esto implica que se debe diseñar un módulo que sirva como interfaz genérica para cualquier arquitectura de computadores que se desee soportar. En un principio no se pretende imponer ninguna limitación sobre qué arquitecturas soporta el sistema. Sin embargo, dada la inmensa variedad de arquitecturas de computadores diferentes que se han desarrollado a lo largo de la historia, es cuestionable que se puedan definir interfaces que sirvan de abstracción para todas ellas al mismo tiempo. Para reducir la complejidad de la tarea, se decidió que el simulador tan solo soportaría arquitecturas de computadores que pudieran ser consideradas RISC. Todas las arquitecturas RISC presentan ciertas características similares, lo que facilita la definición de interfaces comunes que las abstraigan. Tras un análisis de las arquitecturas de tipo RISC, especialmente de las arquitecturas ARMv8, LEGv8, RISC-V y MIPS, tal y como se definen en las distintas ediciones de Computer Organization and Design - The Hardware/Software Interface [28–30], se identificaron las siguientes características de interés comunes: La manipulación de registros que almacenan un valor, incluyendo la realización de operaciones aritmético-lógicas sobre dichos valores. La utilización de una memoria para almacenar y cargar valores de distintos tipos, así como la división de esta memoria en varias “secciones” o rangos de direcciones con distinto propósito. El funcionamiento a base de instrucciones asociadas a un código máquina específico. Estas instrucciones presentan un nombre o mnemónico particular, y pueden agruparse o no en distintos tipos según su codificación, tal y como se indicaba en la sección 4.1. Las instrucciones son ejecutadas por el computador, y pueden producir un resultado sobre el que operen otras instrucciones. La presencia de un pipeline o secuencia de ejecución de instrucciones más o menos compleja, cuyo periodo de ejecución fundamental es el ciclo de reloj del procesador. La existencia de ciertos parámetros de estado mutables que determinan el comportamiento de ciertos componentes del procesador, o el resultado de la ejecución de ciertas instrucciones. La identificación de estas características comunes facilitó la definición de los submódulos del módulo de interfaces. Como consecuencia directa, también facilitó la definición de submódulos de los módulos de arquitecturas del simulador. La división finalmente planteada, derivada directamente de las características comunes identificadas, es la que se indica a continuación: Un módulo para la gestión de los registros de la arquitectura. Un módulo para la gestión de la memoria de la arquitectura. 46
CAPÍTULO 5. ANÁLISIS Y DISEÑO DEL SIMULADOR Figura 5.2: Diseño general detallado de la arquitectura del simulador. Todas las arquitecturas se subdividen en los mismos módulos. Un módulo para la gestión de las instrucciones ensamblador de la arquitectura. Las instrucciones son consideradas la “entidad de simulación” fundamentales del sistema. Este módulo contiene toda la funcionalidad asociada a las entidades de simulación. Un módulo para la gestión de los programas, incluyendo la información de estado de la CPU durante su ejecución. Un módulo para la gestión del flujo de simulación Este módulo implementa las funcionalidades de pipeline y de simulación de las distintas entidades de simulación. A todos estos módulos descritos se debe añadir un módulo adicional de compilación/ensamblado de programas textuales a entidades de simulación ejecutables por el sistema. Este módulo se excluye de la lista anterior ya que no deriva de una característica intrínseca de las arquitecturas RISC, sino de una necesidad del simulador por establecer una traducción de las entradas del usuario a estructuras interpretables por el sistema. La figura 5.2 ilustra la estructura general del sistema de forma detallada, tal y como se ha descrito hasta ahora. 5.3. Diseño de los módulos del sistema Una vez detallada la estructura general del sistema, se deben detallar las entidades en las que se descompondrá el simulador, así como sus relaciones y operaciones a través de las que se comunican. Para el diseño del sistema, las entidades en las que se descompone el simulador se han detallado como clases UML [45]. 47
5.3. DISEÑO DE LOS MÓDULOS DEL SISTEMA Tal y como se planteó la estructura en la sección anterior, los submódulos del sistema no requieren agrupar demasiadas clases. Esto se traduce en una alta cohesión dentro de los submódulos, ya que denota que la funcionalidad de cada submódulo puede delegarse en unas pocas clases. Los requisitos de modularidad y extensibilidad del simulador (RNF2 y RNF3) tienen consecuencias significativas en el diseño del simulador. La más relevante es que todo el diseño del simulador gira fundamentalmente en torno al diseño de las interfaces de las arquitecturas. Desde el punto de vista del diseño, la descripción de las clases contendidas en el módulo de interfaces de las arquitecturas es equivalente a la descripción de las clases de cualquier implementación de una arquitectura. La única diferencia entre ambos tipos de clases es que las segundas son clases concretas que realizan las primeras. Así mismo. la definición de las clases contenidas en el módulo de interfaz gráfica depende de la definición de las clases del módulo de interfaces de las arquitecturas, pues la función del primer módulo es presentar al usuario información sobre las clases del segundo de forma interactiva. Se entiende por tanto que el módulo principal del simulador es el de interfaces de las arquitecturas. Su diseño es especialmente importante, pues influye en el diseño de los demás módulos. En las siguientes secciones se expone de forma detallada el diseño del módulo de interfaces de arquitecturas, el diseño de la interfaz gráfica del simulador y el diseño de la ejecución en modo terminal del simulador. También se hace un inciso en el diseño de la arquitectura LEGv8 desarrollada para el simulador. 5.3.1. Módulo de interfaces de las arquitecturas Como se ha detallado anteriormente, el módulo de interfaces de las arquitecturas juega un papel fundamental en el diseño del sistema, condicionando el diseño del resto de módulos del sistema. Esto se debe a las funciones que presenta el módulo en el sistema: Abstraer de forma genérica el concepto de “arquitectura de computadores” para permitir la extensibilidad modular del simulador (requisitos RNF2 y RNF3). Proporcionar información a la interfaz gráfica sobre la simulación de programas ensamblador. Dada la especial importancia que presenta este módulo en el sistema, su diseño se explica en gran detalle en esta sección. La figura 5.3.1 muestra el modelo de diseño del módulo de interfaces de arquitecturas del sistema, detallando las clases que lo conforman, sus operaciones y sus relaciones. El modelo de diseño se desarrolla partiendo de los requisitos del sistema y del posterior análisis del modelo de dominio del mismo. Acompañando al modelo de diseño, a continuación se explican de forma detallada todas las clases que conforman los submódulos del módulo de interfaces de las arquitecturas del sistema y las operaciones que realizan. 48
CAPÍTULO 5. ANÁLISIS Y DISEÑO DEL SIMULADOR Figura 5.3: Modelo de diseño del módulo de interfaces de arquitecturas del sistema. 49
5.3. DISEÑO DE LOS MÓDULOS DEL SISTEMA Módulo de Registros. ×Tipo de registro. Esta enumeración sirve para identificar los registros de la CPU según su propósito. Ciertas instrucciones ensamblador trabajan exclusivamente con cierto tipo de registros. Los distintos tipos de registros son: ◦GENERAL - registros de propósito general. Pueden ser leídos o escritos sin restricciones. ◦FLOATING - registros de datos de tipo coma flotante. ◦STATUS - registros de estado de la CPU. El valor almacenado en estos registros describe el estado de la CPU. Generalmente son registros de solo lectura. ◦CONSTANT - registros de almacenamiento de constantes. Estos registros son de solo lectura y su valor nunca cambia. El ejemplo habitual de registro constante es el registro cero de muchos computadores, que almacena el valor 0. ◦VECTOR - registros vectoriales. Estos almacenan conjuntos de valores en lugar de un valor único. Permiten que ciertas instrucciones ensamblador operen sobre múltiples valores a la vez. ◦SPECIAL - registros de propósito especial. Un ejemplo puede ser el contador de programa (PC), que almacena la dirección de memoria de la siguiente instrucción a ejecutar. El tratamiento de estos registros depende de la arquitectura concreta. Pueden o no ser registros de solo lectura. También pueden ser registros inaccesibles por el programador. •Registro. Esta clase representa un registro genérico de una CPU. Los registros están definidos por un número y nombre únicos en la CPU y el valor que almacenan. Las operaciones que realiza esta clase son: ◦size() : integer - devuelve el tamaño en bits del valor contenido en el registro. ◦number() : integer - devuelve el número único del registro en la CPU. Este número sirve como identificador del registro. ◦name() : String - devuelve el nombre único del registro de la CPU. Este nombre puede coincidir con el número del registro. Por lo general, el nombre del registro almacena alguna clase de información sobre su función o uso convencional. Los programadores suelen referirse a los registros por su nombre en los programas ensamblador, aunque dependiendo del compilador también pueden hacerlo por su número. ◦type() : Tipo de registro - devuelve un valor (enum) identificativo del tipo del registro. ◦getValue() : integer - devuelve el valor almacenado en el registro. ◦setValue(value : integer) : void - modifica el valor almacenado en el registro. •Banco de registros. Esta clase es una composición de todos los registros del computador. Su función es inicializar en el sistema todos los registros existentes en la arquitectura y proporcionar un punto de acceso único a ellos. La clase Banco de registros, por tanto, sigue un patrón de diseño singleton [46]. Aunque una CPU real puede tener más de un banco de registros, trabajando distinto tipo de instrucciones ensamblador sobre distintos bancos de registros, el simulador solo trabaja con una única instancia de Banco de registros en tiempo de ejecución. Esta discrepancia entre el sistema real simulado y el simulador se debe a que, conceptualmente, 50
CAPÍTULO 5. ANÁLISIS Y DISEÑO DEL SIMULADOR tener un mayor número de bancos de registros en una CPU no aporta ningún beneficio; es simplemente una limitación del hardware. Las reglas lógicas que rigen la accesibilidad a los registros para cada instrucción ensamblador concreta se pueden implementar con un único banco de registros. Las operaciones que realiza esta clase son: ◦registerCount() : integer - devuelve el número de registros totales que hay en la CPU simulada. ◦registerCount(type : Tipo de registro) : integer - devuelve el número de registros del tipo especificado por parámetro que hay en la CPU simulada. ◦getRegister(name : String) : Registro - devuelve la instancia del Registro cuyo nombre es el provisto como parámetro (name). También puede especificarse el número de registro como String, en lugar del nombre. ◦getRegisters() : Registro[*] {ordered} - devuelve una lista ordenada que contiene todos los registros de la arquitectura. El orden exacto de los registros en la lista es específico para cada arquitectura. La única restricción de orden es que la lista debería considerarse ordenada a ojos de un programador de lenguaje ensamblador de la arquitectura. ◦init() : void - inicializa el banco de registros de la CPU. Esta operación prepara el banco de registros para ejecutar un programa, almacenando en cada uno de los registros de la CPU su valor por defecto. Conceptualmente, realizaría la operación análoga a reiniciar el computador, a nivel de los registros. Módulo de Memoria •Dato. Esta clase sirve como contenedor o abstracción para el concepto de “dato almacenado en una memoria”. Los datos de memoria están definidos por la dirección de memoria en la que se encuentran almacenados, así como por el valor que almacenan. Habitualmente, las memorias de los computadores almacenan datos de un byte de tamaño (equivalente a 8 bits). Por tanto, el tamaño del valor de un dato es de un byte. Las operaciones que realiza esta clase son: ◦address() : integer - devuelve la dirección en memoria correspondiente al dato. ◦value() : byte - devuelve el valor del dato. •Sección de memoria. Esta clase proporciona una abstracción para un conjunto contiguo de datos en memoria que comparten cierta finalidad lógica. Esta finalidad común puede venir definida por la implementación hardware del computador, o simplemente tratarse de una convención aceptada por la mayoría de programadores para la arquitectura. Un ejemplo de sección de memoria habitual es la pila (stack). Una sección viene definida por un nombre que la identifica, una dirección de memoria base desde la que comienza la sección, y un tamaño que define el número de datos de memoria contenidos en la sección. Las operaciones que realiza esta clase son: ◦name() : String - devuelve el nombre identificativo de la sección. ◦baseAddress() : integer - devuelve la dirección de memoria base de la sección. ◦size() : integer - devuelve el número de datos de memoria contenidos en la sección. ◦read(address : integer) : Dato - devuelve el Dato almacenado en la dirección de memoria proporcionada como parámetro. La dirección de memoria proporcionada debe estar contenida en la sección. 51
5.3. DISEÑO DE LOS MÓDULOS DEL SISTEMA ◦init() : void - inicializa el pipeline de la CPU. Esta operación prepara el pipeline para ejecutar un programa. Módulo de Programa •Interrupción de salida. Esta clase representa una interrupción o llamada al sistema asociada a una acción de salida que puede generarse durante la ejecución de un programa. Las interrupciones de salida proveen información de forma explícita a un sistema externo; en este caso, a la interfaz gráfica del simulador. La interfaz gráfica del simulador, a su vez, presenta la información de estas interrupciones al usuario. Un ejemplo de interrupción de salida es la impresión por salida estándar de datos de distinto tipo. Esta clase realiza una única operación: ◦getOutput() : String - devuelve, en formato textual, la información de salida que se provee al sistema externo. •Interrupción de entrada. Esta clase representa una interrupción o llamada al sistema asociada a una acción de entrada que puede generarse durante la ejecución de un programa. Las interrupciones de entrada proveen información de forma explícita al computador desde un sistema externo; en este caso, desde el usuario, pasando por la interfaz gráfica. Un ejemplo de interrupción de salida es la lectura por entrada estándar de datos de distinto tipo. Las operaciones que realiza esta clase son: ◦setInput(input : String) : void - define, en formato textual, la información de entrada de la interrupción. Esta información será provista al computador. ◦getInput() : String - devuelve, en formato textual, la información de entrada que se provee al computador. Esta información debe ser previamente definida mediante la invocación de setInput(String) (ver la operación anterior). •Estado de programa. Esta clase engloba las características que gobiernan el estado de la CPU mientras ejecuta un programa. También engloba otras variables sobre la ejecución del programa de carácter estadístico y de interés informativo en las simulaciones. Las funciones de esta clase podrían combinarse con las de Programa (ver siguiente clase) en una única clase. Sin embargo, se ha preferido realizar esta división de las funcionalidades en dos clases para favorecer una mejor comprensión y organización conceptual. Las operaciones que realiza esta clase son: ◦flagsNames() : String[*] - devuelve el nombre de las banderas de estado que haya en la CPU. ◦flagsValue() : {0, 1}[*] - devuelve los valores actuales de las banderas de estado de la CPU. El orden de los valores devueltos corresponde con el orden de los nombres de las banderas tal y como se devuelven al invocar la operación flagsNames(). ◦changeFlags(values : {0, 1}[*]) : void - cambia el valor de las banderas de estado de la CPU por los provistos por parámetro. El orden de los valores provistos corresponde con el orden de los nombres de las banderas tal y como se devuelven al invocar la operación flagsNames(). ◦executedCycles() : integer - devuelve el número de ciclos de reloj que lleva ejecutándose el programa actual. ◦cyclesByInstructionType() : (String, integer)[*] - para cada tipo de instrucción distinto de la arquitectura, devuelve tanto el nombre del tipo de instrucción, como 58
CAPÍTULO 5. ANÁLISIS Y DISEÑO DEL SIMULADOR el número de ciclos de reloj de dedicados hasta el momento en ejecutar instrucciones de ese tipo. ◦breakpointTriggered() : boolean - consulta si la ejecución del programa se ha detenido por el efecto de un punto de ruptura. ◦isInterrupted() : boolean - consulta si la ejecución del programa se ha detenido por el efecto de una interrupción o llamada al sistema que debe ser resuelta. ◦init() : void - inicializa el estado del programa. Esta operación inicializa todos los valores necesarios para comenzar la ejecución del programa asociado. •Programa. Esta clase representa un programa ensamblador de la arquitectura. Se trata de una composición de las instrucciones de programa de un programa ensamblador específico, y es la unidad de ejecución más general del simulador. Las operaciones que realiza esta clase son: ◦getExecutable() : Instrucción de programa[*] - devuelve el conjunto de instrucciones de programa que componen el programa. ◦sourceAssembly() : String[*] - devuelve el código ensamblador textual que generó el programa, separado en las distintas líneas que lo componen. ◦getState() : Estado de programa - devuelve el Estado de programa asociado al programa. ◦getLabels() : Etiqueta[*] - devuelve las etiquetas de memoria (clase detallada en el punto dedicado al módulo de compilación) definidas en el programa. Cada etiqueta incluye el nombre de la etiqueta y su dirección de memoria correspondiente. ◦execute() : void - ejecuta el programa de principio a fin. Esto implica ejecutar todas las instrucciones de programa que componen el programa. ◦executeNext() : void - ejecuta el siguiente ciclo de reloj del programa. ◦setBreakpoints(lines : integer[*]) - añade puntos de ruptura al programa. Los puntos de ruptura se añaden en las instrucciones correspondientes a los números de línea provistos por parámetro. ◦recover(interrupt : Interrupción de entrada) : void - recupera el programa de una interrupción de entrada que estaba pendiente por resolver. Esta operación resuelve la interupción de entrada provista por parámetro. Módulo de compilador •Cargador de programa. Esta clase sirve como fachada para el compilador de programas ensamblador de la arquitectura. Sigue, por tanto, el patrón de diseño façade. El resto de clases con las que se comunica esta fachada dependen de la implementación concreta de las arquitecturas. El Cargador de programa compila un programa en código ensamblador a Instrucciones de programa, produciendo un Programa simulable como resultado. Una única instancia de esta clase es capaz de compilar un número indefinido de programas independientes. Por tanto, esta clase sigue el patrón de diseño singleton. Esta clase realiza una única operación: ◦loadProgram(source : String) : Program - compila un programa textual de código fuente ensamblador de la arquitectura a un programa ejecutable por el simulador. •Etiqueta. Esta clase representa una etiqueta de memoria de un programa ensamblador. Las etiquetas de memoria se utilizan en programas ensamblador para facilitar el acceso 59
5.3. DISEÑO DE LOS MÓDULOS DEL SISTEMA a una dirección de memoria, ya sea de instrucciones o de datos, al programador. Las etiquetas de memoria son ampliamente usadas a la hora de realizar cargas y almacenamientos de datos en memoria, así como a la hora de realizar bifurcaciones y saltos a otras instrucciones del programa. Las etiquetas de memoria están definidas por un nombre y una dirección de memoria. Permiten acceder a las direcciones de memoria correspondientes utilizando los nombres de las etiquetas, que generalmente son mucho más fáciles de recordar y gestionar. Las etiquetas de memoria son generadas, gestionadas y sustituidas por las respectivas direcciones de memoria en tiempo de compilación. Las operaciones que realiza esta clase son: ◦getName() : String - devuelve el nombre de la etiqueta de memoria, que sirve para identificarla. ◦getAddress() : integer - devuelve la dirección de memoria absoluta asociada a la etiqueta. Todas las apariciones de la etiqueta en el código fuente ensamblador serán sustituidas por una referencia a esta dirección de memoria, ya sea por su valor absoluto o como dirección relativa, dependiendo del contexto. ◦set(address : integer) : void - asigna una dirección de memoria a la etiqueta. Es común que en los programas ensamblador haya apariciones de etiquetas en líneas previas a la definición de las mismas (por ejemplo, en saltos hacia delante o llamadas a funciones. En estos casos, la dirección de memoria asociada a las etiquetas ha de asignarse después de la creación de la clase. •Sintaxis ensamblador. Esta clase agrupa el conjunto de reglas sintácticas, en forma de expresiones regulares, utilizadas para resaltar distintos elementos del código fuente de los programas en editores de código. Se utiliza para resaltar la sintaxis de los programas ensamblador en el editor de código del simulador. Las operaciones que realiza esta clase son: ◦getMnemonics() : Regular Expression - devuelve las reglas sintácticas para resaltar los mnemónicos de las distintas instrucciones en editores de código ensamblador de la arquitectura. ◦getRegisters() : Regular Expression - devuelve las reglas sintácticas para resaltar los registros en editores de código ensamblador de la arquitectura. ◦getLiterals() : Regular Expression - devuelve las reglas sintácticas para resaltar los literales (valores numéricos) en editores de código ensamblador de la arquitectura. ◦getComments() : Regular Expression - devuelve las reglas sintácticas para resaltar los comentarios en editores de código ensamblador de la arquitectura. ◦getLabels() : Regular Expression - devuelve las reglas sintácticas para resaltar las etiquetas de memoria en editores de código ensamblador de la arquitectura. Aparte de todas estas clases, el módulo de interfaces de las arquitecturas del sistema presenta una clase adicional que no se incluye en ninguno de los submódulos: Arquitectura. Esta clase es una fachada que permite el acceso por parte de la interfaz gráfica a las clases del sistema de las que debe consultar información o realizar operaciones. No sigue estrictamente el patrón de diseño façade, pero juega un papel especial en el sistema, comportándose de forma similar a una fachada para el módulo. Es el único punto de acceso 60
CAPÍTULO 5. ANÁLISIS Y DISEÑO DEL SIMULADOR que conocen los demás módulos para acceder a las demás clases del módulo de interfaces de las arquitecturas. •name() : String - devuelve el nombre identificativo de la arquitectura. •instructionSet() : Conjunto de instrucciones - devuelve el conjunto de instrucciones de la arquitectura. •registerFile() : Banco de registros - devuelve el banco de registros de la arquitectura. •memory() : Memoria - devuelve la memoria de la arquitectura. •programLoader() : Cargador de programa - devuelve el cargador de programa de la arquitectura. •syntax() : Sintaxis ensamblador - devuelve las reglas de resaltado de sintaxis ensamblador de la arquitectura. •flagsNames() : String[*] - devuelve los nombres de las banderas de estado de la arquitectura. El diseño detallado es suficiente para comenzar la implementación lógica del submódulo de interfaces de arquitecturas del sistema. Antes de comenzar la implementación, sin embargo, es conveniente realizar un análisis del diseño propuesto en busca de puntos problemáticos y posibles mejoras. Puesto que el sistema a desarrollar es un prototipo que pretende expandirse y refinarse de forma iterativa durante los próximos cursos académicos, ha de evaluarse la facilidad de modificación de los módulos de diseño propuestos. Estas característica se evalúan analizando las conexiones existentes entre los distintos módulos del sistema. Observando las relaciones entre las clases y módulos de diseño del sistema, se puede observar que el módulo de instrucciones es el que presenta un mayor número de relaciones con clases de otro módulo. Esto es comprensible, pues la ejecución de instrucciones consulta y modifica múltiples componentes del computador. Por otra parte, hay otros módulos que, aunque presenten pocas relaciones, son bastante estrechas. Es el caso del módulo de programa con los módulos de compilador y pipeline. Pese a esta estrecha relación, la división de las clases en estos tres módulos esta justificada, aparte de conceptualmente, por las diferencias de complejidad de implementación de cada módulo. Los módulos de pipeline y compilador requieren una implementación más cuidada y extensa que el módulo de programa, como se detalla en el capítulo 6. Esta diferencia favorece la separación del módulo de programa tal y como se ha hecho. Al no existir ninguna relación entre las clases del módulo de pipeline y las clases del módulo de compilador, está justificado también que estas se encuentren en módulos separados. La estabilidad [47] de un módulo es una medida de de la dificultad de cambio del módulo. Para evaluar el diseño del módulo de interfaces de las arquitecturas, se ha decidido medir la estabilidad de sus submódulos. La tabla 5.1 muestra los resultados obtenidos. Se puede observar que los submódulos que presentan menor estabilidad son el de Pipeline y el de Programa, teniendo ambos un valor de la métrica Ide 0.5. El principio de desarrollo software de dependencias estables [48] establece que la métrica Ide un paquete debe ser mayor que las métricas Ide los paquetes de los que depende. En nuestro sistema, el submódulo de Programa depende del submódulo de Pipeline, ambos con el mismo valor de la métrica I. Esto puede indicar que no se está cumpliendo del todo el 61
5.3. DISEÑO DE LOS MÓDULOS DEL SISTEMA Módulo Afferent Couplings Efferent Couplings I Registros 3 0 0 Memoria 3 0 0 Programa 1 1 0.5 Compilador 2 0 0 Pipeline 1 1 0.5 Instrucciones 3 2 0.4 Tabla 5.1: Estabilidad de los paquetes del módulo de interfaces de las arquitecturas. Afferent Couplings (Ca) es el número de clases fuera del paquete que dependen de clases dentro del paquete. Efferent Couplings (Ce) es el número de clases de dentro del paquete que dependen de clases de fuera del paquete. I es una medida de la estabilidad del paquete, definida como I=Ce Ca+Ce. Un valor de Ide 0 indica que la estabilidad del paquete es máxima. Un valor de Ide 1 indica que la inestabilidad del paquete es máxima. principio de dependencias estables. Sin embargo, el número de dependencias que presentan los dos módulos implicados no son demasiadas. El bajo número de dependencias de los dos módulos reduce la dificultad de cambio asociada a la relajación del cumplimiento del principio de dependencias estables entre ambos módulos. No obstante, podría tratarse de un posible punto de mejora en el diseño del sistema, y ha de tenerse en cuenta durante la implementación del mismo. 5.3.2. Interfaz gráfica Para el diseño de la interfaz gráfica, se decidió tomar como modelo la interfaz gráfica de un simulador ya existente. En concreto, si usó como modelo la interfaz gráfica del simulador MARS [13], el simulador utilizado hasta el momento en las asignaturas de Arquitectura de Computadores de la Universidad de Valladolid. La interfaz de ese simulador presenta de manera eficiente e intuitiva una gran cantidad de información de utilidad didáctica sobre la simulación de programas ensamblador. Aun así, nuestro simulador realiza algunas mejoras y modificaciones sobre la interfaz de MARS. La influencia de MARS en el desarrollo y diseño del simulador se detalla en el apéndice D. Al utilizar una interfaz gráfica ya existente como modelo para el diseño de la interfaz gráfica del simulador, la complejidad de ese proceso de diseño se reduce considerablemente. Entre otros, se reduce la necesidad de realizar prototipos iniciales de baja fidelidad [49] hasta eliminarse prácticamente. También se reduce el número de iteraciones necesarias para finalizar el diseño de la interfaz gráfica. De acuerdo con el requisito RF12 (detallado en la sección 5.1.1, el simulador debe presentar un editor de texto al usuario. De acuerdo con el requisito RF2, el simulador también debe presentar información al usuario sobre el estado de la CPU durante la simulación de los programas. La información concreta que debe presentarse se detalla en los requisitos RF3, RF4, RF5, RF6 y RF17. Entonces, distinguimos dos modos dentro de la interfaz gráfica del simulador: el modo de edición y el modo de simulación, en el que se presenta la información de la CPU. Los modos se presentarán como pestañas entre las que el usuario podrá cambiar a voluntad, siendo la pestaña por defecto la de edición de texto. 62
CAPÍTULO 5. ANÁLISIS Y DISEÑO DEL SIMULADOR En todo momento e independientemente del modo en uso, la parte superior de la ventana la interfaz gráfica presenta un conjunto de botones. Estos botones permiten: La creación de un nuevo programa en blanco. La apertura de un fichero de código fuente de programa ensamblador. El guardado del código fuente del programa actualmente en edición. El guardado del código fuente del programa actualmente en edición, en modo “guardar como” (es decir, preguntando siempre con qué nombre quiere guardarse el programa, en lugar de usando el nombre actual de manera automática). La compilación del programa actualmente en edición, para permitir su ejecución por el simulador. La ejecución completa del programa compilado (de estar el programa compilado). La ejecución del siguiente ciclo de reloj del programa compilado (de estar el programa compilado). El reinicio del programa compilado (de estar el programa compilado). Al lado de estos botones se encuentra un panel desplegable que permite cambiar la arquitectura actual del simulador por cualquier otra que se encuentre disponible. El diseño de esta barra superior puede observarse en las figuras 5.4 y 5.7. La figura 5.4 ilustra el diseño de la pestaña de edición de la interfaz gráfica. Esta pestaña contiene exclusivamente un editor de código ensamblador. El editor de código presenta características que favorecen la escritura de código ensamblador. Entre estas características destacamos la indicación de los números de línea en la parte izquierda del editor, así como el resaltado de sintaxis. El resaltado de sintaxis modifica el formato del texto escrito, incluyendo su color y tipo de letra, para destacar ciertos elementos del código: los mnemónicos de las instrucciones, los registros utilizados, las etiquetas de memoria, etc. El editor también presenta un sistema de sugerencias que ayuda al aprendizaje del conjunto de instrucciones de la arquitectura. Este sistema da sugerencias de instrucciones al usuario según el mnemónico que esté escribiendo. Estas sugerencias cuentan con una breve descripción de la instrucción explicando su funcionamiento. Del mismo modo, una vez el usuario ha escrito un mnemónico por completo, el editor presenta un ejemplo de uso de la instrucción escrita. Estos conceptos se ilustran en las figuras 5.5 y 5.6, respectivamente. Si se trata de compilar un código que presente fallos sintácticos, aparece un cuadro de texto en la parte derecha de la ventana indicando los fallos encontrados y se cancela la compilación. Los programas ensamblador se componen por sentencias generalmente cortas, por lo que el código ensamblador suele presentar una anchura muy reducida. Por otra parte, los programas ensamblador suelen ser relativamente extensos, puesto que cada línea solo puede codificar una única operación de bajo nivel. Es por estas dos características que se ha decidido que la localización del cuadro de texto de fallos de compilación aparezca a la derecha del editor de texto, en vez de debajo, como 63
5.3. DISEÑO DE LOS MÓDULOS DEL SISTEMA Figura 5.4: Prototipo de la pestaña de edición de texto de la interfaz gráfica del simulador. En esta pestaña el usuario puede editar sus programas ensamblador. Los botones de la parte superior de la ventana permiten, entre otros, abrir ficheros, guardar ficheros, y compilar el fichero en edición, para su simulación. La figura muestra las capacidades de indicación de números de línea y de resaltado de sintaxis del editor. Para la demostración se utiliza código ensamblador MIPS. podría parecer natural: la cantidad de información que queda oculta por la aparición del cuadro de texto es menor si este se sitúa verticalmente paralelo al código, en vez de horizontalmente paralelo. La figura 5.7 ilustra el diseño de la pestaña de ejecución del simulador. Esta pestaña presenta información relativa a diversos aspectos de la simulación y el programa. En concreto, se presenta la información relativa a los siguientes aspectos: El contenido de los registros en cada momento. El contenido de la memoria en cada momento. Las banderas de estado de la CPU en cada momento. Las etiquetas de memoria definidas en el programa. El preprocesado del programa y la compilación del programa a código máquina. 64
CAPÍTULO 5. ANÁLISIS Y DISEÑO DEL SIMULADOR Figura 5.5: Prototipo del sistema de sugerencias de instrucciones para el editor de código del simulador. Figura 5.6: Prototipo del sistema de ejemplos de instrucciones para el editor de código del simulador. Los mensajes de salida del programa. Como resumen de la interfaz gráfica, la figura 5.8 presenta el diagrama de componentes principales de la interfaz gráfica del sistema. Las clases derivadas de cada uno de estos componentes dependen de la tecnología concreta utilizada para implementar la interfaz gráfica. Por tanto, se considera que carecen de interés en diseño sino en implementación, por lo que no se detallan en este capítulo. 5.3.3. Diseño de la ejecución en modo terminal Según el requisito RF16, el simulador debe poder ejecutarse en modo terminal. Este modo de ejecución prescinde de la interfaz gráfica, permitiendo solamente la ejecución de programas completos. El sistema utiliza este modo de ejecución automáticamente si detecta que se le ha proporcionado algún argumento de ejecución al ser invocado a través de una terminal. El primero de estos argumentos se interpreta como la ruta al fichero ensamblador que debe ejecutarse, por lo que el sistema trata de abrir el fichero asociado, compilarlo y ejecutarlo. Para añadir cierta flexibilidad a este modo de ejecución, se permitirá la configuración de ciertos parámetros de ejecución a través de los argumentos de entrada que reciba el programa. Entre otros, se permite configurar: La arquitectura utilizada para compilar y ejecutar el programa. Esta será LEGv8 por defecto. La impresión por salida estándar del contenido de los registros al finalizar la ejecución. 65
5.3. DISEÑO DE LOS MÓDULOS DEL SISTEMA Figura 5.7: Prototipo de la pestaña de edición de texto de la interfaz gráfica del simulador. En esta pestaña el usuario puede consultar información relativa a: los registros, la memoria, las banderas de estado, las etiquetas de memoria, la compilación del programa y los mensajes de salida que genera el programa. La impresión por salida estándar del contenido de la memoria al finalizar la ejecución. La impresión por salida estándar de las etiquetas de memoria del programa al finalizar la ejecución. La impresión por salida estándar del código preprocesado del programa. La impresión por salida estándar del código máquina del programa. De estos aspectos configurables, el que puede llevar asociada una mayor complejidad es el primero: cargar una arquitectura de las disponibles en el simulador. Para realizar esta tarea, se diseña una nueva clase en el sistema: Cargador de arquitecturas: esta clase realiza la gestión de las arquitecturas disponibles en el simulador. Es una clase puramente estática; la funcionalidad que ofrece no requiere su 66
CAPÍTULO 5. ANÁLISIS Y DISEÑO DEL SIMULADOR Figura 5.8: Resumen de componentes principales de la interfaz gráfica del sistema. instanciación. Esta clase realiza dos operaciones: •loadArch(nombre : String) : Arquitectura - devuelve una instancia de la arquitectura cuyo nombre se provee por parámetro. El nombre provisto por parámetro debe corresponder al de una arquitectura disponible en el simulador. En otro caso, la invocación de la función produce un error. •getAvailableArchs() : String[*] - devuelve el nombre de todas las arquitecturas actualmente disponibles en el simulador. Los nombres devueltos son aquellos que se pueden proporcionar como parámetro al método loadArch(String) sin que produzca un fallo. El modo terminal del simulador utiliza el método loadArch(String) para obtener una instancia de la Arquitectura deseada. Con esta instancia de Arquitectura, se genera una instancia de Programa, se ejecuta, y se imprimen por salida estándar la información especificada en los argumentos de ejecución del simulador. El nombre de arquitectura provisto como parámetro de loadArch(String) se obtiene también de los argumentos de ejecución del simulador, o se utiliza “LEGv8” por defecto. El método getAvailableArchs() se define ya que resulta de utilidad a la hora de presentar información en la interfaz gráfica del sistema. Este método resulta de utilidad, concretamente en el panel desplegable de cambio de arquitectura. No es estrictamente necesario delegar la operación de obtención de nombres de arquitecturas a una clase particular, por lo que no se especificó 67
5.5. RESUMEN La descripción de los casos de uso del sistema. De este capítulo se concluye lo siguiente: El simulador se divide en dos módulos, el frontend y el backend, que a su vez se dividen en submódulos. El frontend define la interfaz gráfica del sistema y las interfaces que deben realizar las arquitecturas concretas para ser soportadas por el simulador. El backend define las arquitecturas concretas soportadas por el simulador. El diseño de la interfaz gráfica del sistema parte de la interfaz gráfica del simulador MARS, sobre la que se realizan ciertos cambios. El sistema puede ejecutarse en modo terminal, prescindiendo de interfaz gráfica. Para ello, se le debe proporcionar la ruta de un programa ensamblador a ejecutar, como argumento al invocar el programa a través de una terminal. Otros argumentos pueden utilizarse para determinar la arquitectura a utilizar o la información que imprimir por salida estándar. El diseño de los módulos de arquitecturas concretas es similar al diseño del módulo de interfaces de las arquitecturas. Estos módulos se dividen en los siguientes submódulos: •Módulo de registros. •Módulo de memoria. •Módulo de instrucciones. •Módulo de pipeline. •Módulo de programa. •Módulo de compilador. 74
CAPÍTULO 5. ANÁLISIS Y DISEÑO DEL SIMULADOR Figura 5.15: Diagrama de secuencia para el caso de uso Ciclo de pipeline. 75
5.5. RESUMEN 76
CAPÍTULO 6. IMPLEMENTACIÓN DEL SIMULADOR Capítulo 6 Implementación del simulador En este capítulo se presentan los siguientes aspectos relacionados con la implementación del simulador: La descripción de las tecnologías y herramientas utilizadas para desarrollar la implementación del simulador. La descripción de la implementación del simulador. 6.1. Descripción de tecnologías y herramientas utilizadas El requisito RNF1 (detallado en la sección 5.1.2 de este documento) especifica que el simulador debe ser multiplataforma, por lo que se decidió desde un principio que el lenguaje de programación utilizado para implementar el sistema sería Java [51]. Java es un lenguaje de programación de alto nivel, tipado y fuertemente orientado a objetos. Es uno de los lenguajes de programación más populares a día de hoy, siendo comúnmente utilizado en asignaturas de fundamentos de programación y programación orientada a objetos. Es un lenguaje con el que el programador estaba familiarizado, por lo que no fue necesario dedicar tiempo a su aprendizaje. Con respecto a la versión específica de Java que se utiliza, en un principio se trató de utilizar Java 8, ya que es la versión de Java más utilizada a fecha de 2021. Se pretendía utilizar esta versión para no crear dependencias de versiones posteriores, evitando la necesidad de actualizar Java en los sistemas que pretendiesen utilizar el software. Sin embargo, ciertos componentes del proyecto requieren el uso de Java 11 para funcionar, por lo que esa es la versión de Java finalmente utilizada. El entorno de desarrollo (IDE) utilizado para desarrollar el software del proyecto es IntelliJ IDEA Community [52]. IntelliJ IDEA es un entorno de desarrollo que presenta una gran flexibilidad. Ofrece grandes facilidades para el desarrollo de proyectos de código Java, como sistemas de autocompletitud de código, navegación de código, refactorización de código, depuración de código, 77
6.1. DESCRIPCIÓN DE TECNOLOGÍAS Y HERRAMIENTAS UTILIZADAS una terminal de comandos propia e integración con git, entre otros. Además, admite la adición de extensiones que amplían las funcionalidades que ofrece el IDE. Para gestionar las dependencias del proyecto software y automatizar las construcciones (builds) se utiliza Gradle [53]. Gradle es un sistema de automatización de construcción de código software, y se encuentra entre los sistemas de automatización de construcción de código soportados de manera nativa por IntelliJ IDEA. Para el desarrollo del compilador del módulo de la arquitectura LEGv8 se utiliza ANTLR 4 [54]. ANTLR es un generador de analizadores sintácticos (parsers). Para ello, toma una gramática libre de contexto que especifica un lenguaje a reconocer, y genera el código fuente para reconocer ese lenguaje. ANTLR es capaz de generar código para varios lenguajes de programación, siendo notablemente usado para generar parsers en Java. ANTLR es capaz de generar analizadores léxicos (lexers), analizadores sintácticos (parsers) e incluso analizadores sintáctico-léxicos (lexer-parsers), a partir de un único fichero fuente. Ofrece una notación unificada para definir los lexers yparsers, lo que facilita considerablemente el desarrollo de compiladores. Para la implementación de la interfaz gráfica del sistema se utiliza JavaFX [55]. JavaFX es una plataforma software para crear aplicaciones con entornos gráficos para gran variedad de sistemas operativos. Pretende ser una novedad sobre Java Swing [56] y AWT [57], ofreciendo un mayor número de características y una mayor facilidad y flexibilidad de desarrollo. Desde Java 11, se incluye como parte del proyecto OpenJDK de Oracle, por lo que actualmente está integrado en la mayoría de instalaciones de Java. Esta herramienta es la que obliga a que la versión de Java en la que se desarrolla el sistema sea Java 11. Para favorecer la corrección y la autodocumentación del código fuente del sistema, se decidió que se programaría utilizando Contratos para Java (cofoja) [58]. Cofoja es una infraestructura de programación mediante contratos que utiliza anotaciones Java para proporcionar comprobaciones de contratos en tiempo de ejecución. Un contrato [59] software define unas precondiciones y postcondiciones para la invocación de funciones en un sistema. Una función asegura su correcta ejecución si se cumplen las precondiciones que establece. También, la función asegura que, tras su ejecución, se cumplen las postcondiciones que establece. Una anotación Java es una forma de añadir metadatos sintácticos a código fuente Java. Las anotaciones Java pueden ser tenidas en cuenta por el compilador a la hora de generar el ejecutable del programa, alterando su funcionamiento. Cofoja ofrece una forma simple de establecer contratos en programas Java, favoreciendo la corrección del programa, y facilitando la comprensión de su código fuente. Además de Cofoja, también se utilizan las anotaciones de código provistas por el paquete org.jetbrains.annotations, que se incluye con el entorno de desarrollo de IntelliJ IDEA. En concreto, siempre que se considera apropiado se utilizan las anotaciones @Nullable y@NotNull para denotar que el valor de una variable puede o no puede ser null, respectivamente. También, para asegurar una calidad mínima del código fuente del sistema, se impuso el cumplimiento de las convenciones de código Java [60], exceptuando los casos en los que estas entrasen en conflicto con el dominio de la aplicación1. Aunque actualmente no se trate de una convención 1Por ejemplo: las convenciones de Java establecen que los nombres de variables deben comenzar por minúscula; pero los registros en una instrucción de programa LEGv8 se llaman Rd/Rt/Rn/Rm. Preferimos utilizar los nombres de variables más parecidos al concepto que se está simulando, en vez de cumplir las convenciones de Java. 78
CAPÍTULO 6. IMPLEMENTACIÓN DEL SIMULADOR de código activamente promovida, también se estableció que no debían superarse los 80 caracteres por línea en todos los ficheros del código fuente (exceptuando los generados automáticamente por herramientas como ANTLR). Por lo general, también se siguieron principios y buenas prácticas de desarrollo de software comúnmente promovidos, como los 5 principios SOLID [61]. En algunas ocasiones, estos principios se incumplieron de forma consciente para reducir la complejidad del código. Es el caso del principio de acceso uniforme, por ejemplo. No obstante, se minimizó el incumplimiento de estas prácticas y principios, permitiéndose exclusivamente en situaciones debidamente justificadas. Para promover de forma activa el cumplimiento de otras convenciones de código y buenas prácticas que aseguraran un nivel de calidad mínimo en el código fuente, se utilizó la extensión de SonarLint [62] para IntellIJ IDEA. SonarLint es una extensión para diferentes IDEs que detecta de forma automática problemas de calidad de código mientras se escribe el propio código. Categoriza los problemas según su impacto en la calidad del código y ofrece alternativas y soluciones automatizadas para ellos. Es, a su vez, una extensión adaptable, permitiendo la desactivación de las reglas de calidad específicas que no se ajusten al proyecto. 6.2. Implementación de la propuesta En esta sección se trata la implementación de los componentes principales que forman el simulador. La implementación del simulador parte del diseño detallado en la sección 5.3. Debido a la complejidad del sistema desarrollado, no se puede describir con detalle todas las funciones que son utilizadas en el sistema. En su lugar, se describe de forma general las principales estructuras y funcionalidades del simulador, desde la perspectiva del modelo de dominio, y centrándose en la arquitectura LEGv8. Para una descripción más detallada del funcionamiento y la implementación de los componentes del sistema, se recomienda consultar el código fuente del simulador y su documentación Javadoc. En esta sección se asume que el lector está familiarizado hasta cierto punto con la programación en Java, conociendo al menos los conceptos, la terminología, y las clases básicas del lenguaje. 6.2.1. Consideraciones iniciales de la implementación El requisito RNF5 especifica que el código fuente del simulador debe estar escrito en inglés. Por ello, en la implementación del simulador, los nombres de los módulos y las clases descritas en la sección 5.3 se traducen al inglés. La traducción utilizada para los nombres de los módulos y sus clases del sistema es la siguiente: Registros:registers. •Tipo de Registro: RegisterType. 79
6.2. IMPLEMENTACIÓN DE LA PROPUESTA •Registro: Register. •Banco de registros: RegisterFile. Memoria:memory. •Dato: Data. •Sección de memoria: Section. •Memoria: Memory. Instrucciones:instructions. •Resultado de instrucción: InstructionResult. •Instrucción: Instruction. •Conjunto de instrucciones: InstructionSet. •Instrucción de programa: ProgramInstruction. •Código de ejecución: ExecutionCode. Pipeline:pipeline. •Etapa del pipeline:PipelineStage. •Política de predicción de saltos: PredictionPolicy. •Unidad de salto: JumpUnit. •Unidad de operaciones: OperationUnit. •Pipeline:Pipeline. Programa. •Interrupción de salida: InterruptOut. •Interrupción de entrada: InterruptIn. •Estado de programa: ProgramState. •Programa: Program. Compilador:assembler. •Cargador de programa: ProgramLoader. •Etiqueta: Label. •Sintaxis ensamblador: AssemblerSyntax. Arquitectura: Architecture. Las clases de un paquete de arquitecturas específico que realizan las interfaces definidas en el paquete de interfaces de las arquitecturas tienen por nombre el nombre de la arquitectura seguido por el nombre de la interfaz que realizan. Por ejemplo, en el paquete de la arquitectura LEGv8, la clase que realiza la interfaz Register es LEGv8Register. Un aspecto a tener en cuenta a la hora de implementar el sistema es que, al tratar de construir un frontend genérico que funcione con cualquier tipo de arquitectura, los tipos de datos también 80
CAPÍTULO 6. IMPLEMENTACIÓN DEL SIMULADOR deben generalizarse. Es decir, no se pueden hacer suposiciones sobre el tamaño de los datos con los que trabaja una arquitectura. Por ejemplo, MIPS trabaja con registros de 32 bits, mientras que los registros de LEGv8 son de 64 bits. El simulador debe soportar ambas arquitecturas. En diseño, no se especifican los tamaños de los datos numéricos, indicándose de forma genérica como de tipo integer. Como solución, siempre que se desconozca el tamaño que va a tener un dato numérico, se utiliza como tipo del dato el tipo más grande nativo en Java, long, de 64 bits. Como consecuencia, la implementación actual del simulador no soporta arquitecturas que trabajen con tipos de datos más grandes que 64 bits. Aunque a día de hoy existen arquitecturas de este tipo (por ejemplo, la versión de 128 bits de RISC-V [27]), no son un conjunto tan significativo ni tan ampliamente utilizadas como para que su exclusión tenga un impacto relevante en el cumplimiento de los objetivos de extensibilidad del simulador. Otra consideración a tener en cuenta es que la interfaz de diseño InstructionResult se implementa como cuatro interfaces distintas: InstructionResultR, que describe las operaciones que debe realizar un resultado de instrucción que escribe registros. InstructionResultM, que describe las operaciones que debe realizar un resultado de instrucción que escribe en memoria. InstructionResultS, que describe las operaciones que debe realizar un resultado de instrucción que modifique las banderas de estado de la CPU. InstructionResultJ, que describe las operaciones que debe realizar un resultado de instrucción que puede producir una bifurcación. La división de la interfaz única en diseño en cuatro interfaces en implementación se debe a un intento de reducir la complejidad del código desarrollado: es raro que una instrucción produzca un resultado que deba realizar más de una de las cuatro interfaces a la vez. No obstante, se implementa una propuesta de clase que realiza las cuatro interfaces al mismo tiempo: InstructionResult. Esta clase puede ser, o no, utilizada por los módulos de las arquitecturas concretas. Del mismo modo, se implementa la clase DataImpl. Esta clase es una propuesta de clase concreta que realiza la interfaz Data, y puede ser, o no, utilizada por los módulos de las arquitecturas concretas. Esta clase se incluye en el módulo de interfaces de las arquitecturas debido a su considerable sencillez y a que implementa funcionalidades comunes a un gran número de arquitecturas. También se implementa la clase Label como clase concreta en vez de como interfaz. Esto se debe a que se consideró que su implementación no iba a variar de forma notable entre distintas arquitecturas. 6.2.2. Implementación de la capacidad de extensibilidad del simulador El requisito RNF3, detallado en la sección 5.1.2 de este documento, establece que el sistema debe ser fácilmente extensible para soportar otras arquitecturas de computadores y lenguajes en81
6.2. IMPLEMENTACIÓN DE LA PROPUESTA samblador. Para implementar esta funcionalidad de forma que resulte fácil de utilidad, se utiliza el sistema de carga de servicios de Java. Este sistema es accesible mediante la clase ServiceLoader [63]. La documentación de la clase explica su funcionalidad utilizando los siguientes términos: Servicio: una interfaz o clase conocida para la cual existen cero, uno, o varios proveedores existen. Proveedor de servicio oproveedor: es una clase que implementa o extiende la interfaz o clase conocida. Cargador de servicio: objeto que localiza y carga proveedores de servicio desplegados en el entorno de ejecución, a petición de la aplicación. El cargador de servicio es capaz de distinguir entre múltiples proveedores. En el prototipo desarrollado, la interfaz Architecture es un servicio. El módulo de la arquitectura LEGv8, por ejemplo, presenta un proveedor de ese servicio: LEGv8Architecture. Esto significa que, de hallarse el módulo LEGv8 compilado en una ruta localizable por el simulador, este sería capaz de cargar e instancializar una LEGv8Architecture y utilizarla como si se tratase de una instancia de Architecture. Este proceso no requiere la modificación ni la recompilación del simulador cuando se desee añadir un nuevo módulo de arquitectura; la extensión del simulador con más arquitecturas es de tipo plug-and-play. Los módulos que contienen los proveedores deben ser compilados como ficheros jar de Java. Para que el simulador pueda localizar un proveedor de Architecture, el fichero jar producto de la compilación del proveedor debe especificar que provee ese servicio. Para ello, se debe incluir en el directorio de metainformación del jar (META-INF) un directorio llamados services que incluya un fichero por cada servicio provisto. El fichero debe llamarse como la ruta Java de la interfaz o clase que define el servicio que se provee, y debe contener la ruta Java de la clase que provee el servicio. Por ejemplo, para el módulo de la arquitectura LEGv8, el directorio META-INF/services/ incluye el fichero: es.uva.infor.andromeda.cpu.interfaces.Architecture El contenido de este fichero es exclusivamente la línea: es.uva.infor.andromeda.cpu.legv8.LEGv8Architecture El fragmento de código 6.1 presenta el código fuente de los métodos de la clase ArchLoader, equivalente al Cargador de arquitecturas en diseño. Esta clase es la que utiliza el sistema de cargador de servicios de Java para proporcionar las instancias de Architecture al sistema. 6.2.3. Implementación del banco de registros LEGv8 El banco de registros de la arquitectura LEGv8 se implementa mediante las clases LEGv8RegisterFile y LEGv8Regiser. La primera, es una clase de tipo singleton que almacena todas las instancias existentes en el sistema de LEGv8Register. 82
CAPÍTULO 6. IMPLEMENTACIÓN DEL SIMULADOR public static List <String > getAvailableArchs () { List < String > archs = new ArrayList < >() ; for ( Architecture arch : ServiceLoader . load ( Architecture .class )) { archs . add ( arch . name () ); } return archs ; } public static @Nullable Architecture loadArch ( String archName ) { for ( Architecture arch : ServiceLoader . load ( Architecture .class )) { if ( arch .name () . equals ( archName )) { return arch ; } } return null; } Fragmento de código 6.1: Métodos de carga de arquitecturas de la clase archLoader. Estos métodos son capaces de decir qué arquitecturas hay disponibles en el entorno de ejecución del simulador, así como cargar una de ellas para su uso. El conjunto de registros de la arquitectura se representa en la clase LEGv8RegisterFile utilizando una estructura de tipo mapa. Concretamente, se utiliza una instancia de la clase HashMap provista por Java. Las claves del mapa son los nombres de los registros, y los valores son las instancias de LEGv8Register cuyo nombre coincide con la clave. Cualquier fragmento de código que requiera acceder a una instancia específica de LEGv8Register debe hacerlo a través de este mapa. En la inicialización de la instancia única de LEGv8RegisterFile se crean las instancias de todos los registros utilizados en la arquitectura LEGv8, añadiéndose al mapa. Los registros se añaden al mapa de forma que puedan ser accesibles a través de todos sus nombres. Por tanto, múltiples claves pueden hacer referencia a la misma instancia de LEGv8Register. La cantidad y los nombres de los registros que se crean se han tomado del libro de referencia de LEGv8 [28]. 6.2.4. Implementación de la memoria LEGv8 La memoria de la arquitectura LEGv8 se implementa mediante las clases LEGv8Memory, LEGv8Section y DataImpl. La primera es una clase de tipo singleton que almacena todas las instancias existentes en el sistema de LEGv8Section. La segunda es una clase que almacena instancias de DataImpl, simulando el almacenamiento de datos en memoria. Para la clase LEGv8Section, en un principio se planteó que las secciones de memoria podían implementarse como listas de Datos, utilizando la clase List de Java. Sin embargo, según la referencia de LEGv8, el espacio de memoria accesible en una arquitectura LEGv8 incluye todo el rango de direcciones desde 0x0 en hexadecimal (0 en decimal) hasta 0x7ffffffffc en hexadecimal (549 755 813 884 en decimal). Cualquier implementación de este espacio de memoria basada en listas requeriría el uso de un mínimo de 550 Gigabytes de memoria RAM. Este requisito es insatis83
6.2. IMPLEMENTACIÓN DE LA PROPUESTA Un mapa de tipo EnumMap que representa las instrucciones dentro del pipeline. Las claves del mapa son las distintas etapas del pipeline, y los valores son arrays que incluyen las instrucciones que las ocupan (o null, si no las ocupa ninguna instrucción). Se utiliza un array en vez de instancias directamente debido a que, en ciertas etapas, puede haber más de una instrucción en ejecución a la vez. Estos arrays, en caso de tener un tamaño mayor que uno, representarían los “subpipelines” de las etapas del pipeline. Las instancias de OperationUnit que representan las unidades funcionales que calculan, respectivamente: •las operaciones simples con números enteros, •las operaciones simples con números de coma flotante, •las multiplicaciones de cualquier tipo de número y •las divisiones de cualquier tipo de número. La instancia de JumpUnit utilizada para predecir y resolver las bifurcaciones. Atributos de tipo booleano que señalan el estado del pipeline. Entre ellos, se encuentra ended, que marca si la ejecución del programa en el pipeline ha finalizado. La clase LEGv8Pipeline realiza la interfaz Pipeline. Esta interfaz declara tres métodos: void cycle(),boolean ended() yvoid init(). En LEGv8Pipeline, el método init() simplemente inicializa el atributo ended afalse, inicializa todos los valores de los arrays del mapa de etapas a null, e introduce la primera instrucción del programa en la primera etapa del mapa. El método ended() simplemente es un getter del atributo del mismo nombre. El método cycle(), sin embargo, realiza muchas subtareas de gran complejidad. Estas son las asociadas a cada etapa. Para organizar mejor el código de la clase, cada etapa se divide en un método privado independiente. Entre las tareas que se realizan en cada una de estas funciones se encuentran: Etapa WB: •Escribir los resultados de las instrucciones en los registros. •Actualizar el atributo ended atrue si se ha ejecutado la última instrucción del programa. Etapa MEM: •Ejecutar las instrucciones de carga desde memoria. •Escribir en memoria los resultados de las instrucciones de almacenamiento en memoria. 90
CAPÍTULO 6. IMPLEMENTACIÓN DEL SIMULADOR •Actualizar el atributo ended atrue si se ha ejecutado la última instrucción del programa, y esta no utiliza la etapa WB del pipeline. •Avanzar la instrucción a la etapa WB, si la instrucción la utiliza. Etapa EX: •Se ejecutan las subetapas de ejecución asociadas a las 4 instancias de OperationUnit almacenadas como atributo. En estas subetapas se realizan las siguientes tareas: ◦Avanzar las instrucciones en el subpipeline de la etapa correspondiente, tal y como marquen la latencia y el intervalo de iniciación de la unidad de operaciones de la etapa. ◦Comprobar si alguna instrucción se encuentra en la etapa final del subpipeline, ejecutándola. ◦Avanzar las instrucciones salientes a la etapa MEM o WB, según corresponda. •Se resuelven las bifurcaciones, si así lo dice la política de saltos de la instancia de JumpUnit almacenada como atributo. Etapa ID: •Comprobar todos los posibles riesgos que se generan en el pipeline por la introducción de la instrucción que ocupa la etapa, incluyendo: ◦Riesgos de datos de tipo RAW, entre instrucciones de tipo R y cargas desde memoria. ◦Riesgos estructurales causados por la ocupación de las unidades de operaciones multiciclo. ◦Riesgos estructurales causados por la utilización de la etapa WB de dos instrucciones que utilizan diferentes unidades de operaciones. ◦Riesgos de datos de tipo RAW, entre instrucciones que utilizan distintas unidades de operaciones. •Detener el avance de las instrucciones en el pipeline durante un ciclo si se detecta alguno de los riesgos especificados en el punto anterior. •Resolver las bifurcaciones, si así lo dice la política de saltos de la instancia de JumpUnit almacenada como atributo. •Avanzar la instrucción a la subetapa EX que le corresponda, si no se ha producido una detención ese ciclo. Etapa IF: •Realizar la predicción de saltos siguiendo la política de predicción de la instancia de JumpUnit almacenada como atributo. •Resolver las bifurcaciones, si así lo dice la política de saltos de la instancia de JumpUnit almacenada como atributo. •Modifica el contador de programa, teniendo en cuenta si se ha bifurcado ese ciclo o no. •Avanzar la instrucción a la etapa ID, si no se ha producido una detención ese ciclo. •Añadir en la etapa IF la próxima instrucción a ejecutar, si no se ha producido una detención ese ciclo. 91
6.2. IMPLEMENTACIÓN DE LA PROPUESTA La lógica de detección de riesgos y de gestión de saltos, tanto predicción como resolución, presentan una gran complejidad. Hay que conocer detalladamente el dominio del sistema simulado, especialmente de los pipelines segmentados, para comprender la implementación. Hay que tener en cuenta que las etapas del pipeline se ejecutan de última (WB) a primera (IF). Esto es así ya que una etapa debe liberarse antes de que la anterior haga avanzar su instrucción; si no, se sobrescribiría la instrucción de la etapa. Esto incrementa aun más la lógica de la clase, pues hay que tener en cuenta que, dependiendo de en qué etapa se esté trabajando, algunas instrucciones ya habrán avanzado a la etapa siguiente, mientras que otras todavía no. En otras palabras, conceptualmente, todas las instrucciones de cada etapa se ejecutan al mismo tiempo; pero en la implementación no es así. Esta discrepancia dificulta el desarrollo y la comprensión del código del pipeline, haciendo que sea común confundir la etapa en la que realmente se encuentra una instrucción. Como en los pipelines segmentados reales, la clase LEGv8Pipeline simula la anticipación de resultados. Esto se hace invocando el método public long valueIn(Register) de LEGv8Pipeline cada vez que se desee obtener el valor de un registro, en vez de obteniendo el valor directamente del registro invocando su método public long getValue(). El método valueIn(Register) realiza ciertas comprobaciones para detectar si el registro que se desea leer es modificado por una instrucción que todavía no ha ejecutado su etapa WB. De ser así, se accede a la instancia de InstructionResult asociada a esa instrucción y se devuelve el valor que se va a escribir en el registro. De no ser así, se devuelve el valor ya almacenado en el registro. El fragmento de código 6.5 muestra la implementación del método valueIn(Register). Todas las clases anónimas que definen la funcionalidad de las instrucciones LEGv8 utilizan el método valueIn(Register) cuando quieren acceder a un registro, como se puede observar en el fragmento de código 6.3. public long valueIn ( Register reg ) { // Instrucciones que este ciclo empezaron en la etapa MEM : @Nullable LEGv8ProgramInstruction inWB = line . get(WB) [0]; if ( inWB != null && inWB . writesReg ( reg ) // Las instrucciones que no utilizan la ALU de enteros // no utilizan la fase MEM , asi que ya han escrito sus // resultados . && getOperationUnit ( inWB ). equals ( ALU )) { return inWB . result (). regValue ( reg ); } // Resto de instrucciones (ya han ejecutado WB): return LEGv8Architecture . registerFile . getRegister ( reg .name () ) . getValue () ; } Fragmento de código 6.5: Función de anticipación de resultados de LEGv8Pipeline. Hay que tener en cuenta que este método es invocado exclusivamente en la etapa EX del pipeline, por lo que las instrucciones que ese ciclo comenzaron en las etapas WB o MEM ya han finalizado su ejecución o avanzado a WB, respectivamente. Como se ha dicho anteriormente, la predicción de saltos se realiza en la etapa IF. El fragmento de código 6.6 muestra la lógica asociada a estas predicciones. Para resolver los saltos y corregir las predicciones incorrectas, se utiliza un método privado: private boolean processJump(PipelineStage, LEGv8ProgramInstruction). Este método pue92
CAPÍTULO 6. IMPLEMENTACIÓN DEL SIMULADOR if ( ins != null && ins . isBranch ()) { if ( jmp . resolutionStage () == IF) { ... // Logica de resolucion de saltos en la etapa IF. }else if (jmp . predictionPolicy () == STOP || ins.is("BR")) { // Las instrucciones BR no pueden predecirse sin ser ejecutadas . stopped = true; }else if ( ins. type () == LEGv8Instruction . InstructionType .B || jmp . predictionPolicy () == TAKEN ) { long branchAddress = ins . getAddress () + 4 * ins. getNumber (); PC . setValue ( branchAddress ); jumped = true; } // Las predicciones DELAYED y NOT_TAKEN no toman el salto . // La prediccion IDEAL resuelve los saltos en la etapa IF , // por lo que no necesita predecirlos . } Fragmento de código 6.6: Lógica de predicción de saltos en el método de la etapa IF de LEGv8Pipeline. La instrucción de bifurcación BR no puede predecirse, por lo que siempre detiene el flujo de instrucciones en el pipeline. La política de predicción IDEAL equivale a resolver los saltos en la etapa IF. de invocarse en las etapas EX, IF o ID. La invocación en la etapa EX no debería generar ningún problema ya que los resultados de instrucciones anteriores pueden anticiparse a esa etapa. Este no es el caso para las etapas IF o ID. Para solucionar los problemas asociados a las predicciones en esas etapas, se ha implementado el método de tal forma que la resolución de los saltos es siempre ideal, independientemente de la etapa en la que se realice tal predicción. Para realizar esta resolución ideal de saltos, todas las instrucciones del pipeline anteriores al salto se ejecutan de forma forzosa antes de realizar el salto. Esto asegura que, si el salto depende de una instrucción anterior en el pipeline, cuando se ejecute, lo hará teniendo acceso a los valores correctos de los registros y las banderas de condición. La ejecución forzosa de instrucciones para resolver correctamente saltos acarrea el evidente problema de que las instrucciones finalizan su ejecución antes de tiempo, volviendo incorrecta la simulación. Para solucionar este problema, se ha implementado un sistema para revertir los efectos de las instrucciones, de forma transparente al resto del simulador. Este sistema se basa en el guardado y la recuperación de “estados”: Las clases susceptibles a modificar su estado debido a la ejecución de una instrucción implementan los métodos public void savestate() y public void loadstate(), además de un atributo de copia de estado. Estas clases son: LEGv8RegisterFile, LEGv8Memory y LEGv8ProgramState. El método savestate() guarda el estado actual de la clase en el atributo de copia de estado. El método loadstate() recupera el estado de la clase guardado en el atributo de estado. A la hora de resolver un salto, se procede como sigue: 93
6.2. IMPLEMENTACIÓN DE LA PROPUESTA 1. Se guardan los estados de la memoria LEGv8, el banco de registros LEGv8, y el estado del programa. Para ello, se utilizan los respectivos métodos savestate(). 2. Se ejecutan todas las instrucciones del pipeline anteriores al salto. 3. Se ejecuta el salto, generando su resultado. 4. Se recupera el estado de la memoria LEGv8, el banco de registros LEGv8, y el estado del programa. Para ello, se utilizan los respectivos métodos loadstate(). 5. Si el salto es tomado, se modifica el registro LEGv8 del contador de programa como corresponda. 6. Si la predicción realizada en la etapa IF es incorrecta, se eliminan del pipeline todas las instrucciones posteriores al salto. El fragmento de código 6.7 muestra el código encargado de la gestión de las predicciones incorrectas de saltos. De esta forma, se resuelven los saltos de forma correcta sin causar efectos secundarios en el resto del sistema. if ( result . jumpTaken () ) { // Corregir las predicciones que no toman el salto : switch (jmp . predictionPolicy ()) { case NOT_TAKEN : flushPipeline (stg ); case STOP: jumped = true; result . resolveJump () ; default: break ; } }else if ( jmp . predictionPolicy () == TAKEN ) { // Corregir las predicciones que toman el salto : flushPipeline (stg ); PC . setValue ( ins . getAddress ()); // Arreglar el valor del PC. } Fragmento de código 6.7: Código de gestión de las predicciones incorrectas de saltos en la clase LEGv8Pipeline. Para eliminar las instrucciones del pipeline incorrectamente añadidas debido a la predicción incorrecta de un salto, se implementa el método privado flushPipeline(PipelineStage). Este método convierte en nulls todas las instrucciones en etapas anteriores a la provista por parámetro. Este método se muestra en el fragmento de código 6.8. Para detectar la finalización de la ejecución de un programa en el pipeline, se utiliza el atributo lastInstruction. Este atributo de tipo LEGv8ProgramInstruciton almacena la instancia de la que se cree que será la última instrucción en ejecutarse. El valor de este atributo es modificado cada vez que, en la etapa IF, se detecta que el contador de programa apunta fuera de la lista de instrucciones de programa por ejecutar. Cuando se da este caso, el valor del atributo pasa a ser el de la última instrucción no nula en el pipeline. Cuando esa instrucción de programa llega a su etapa final, ya sea MEM o WB, el valor del atributo ended pasa a ser true, ya que se ha 94
CAPÍTULO 6. IMPLEMENTACIÓN DEL SIMULADOR private void flushPipeline ( @NotNull PipelineStage stg ) { for ( PipelineStage s : line . keySet ()) { // Se itera sobre las etapas del Pipeline if (s == stg ) { return; } LEGv8ProgramInstruction [] pipeStage = line . get (s); for (int i = 0; i < pipeStage . length ; i ++) { pipeStage [i] = null; } } } Fragmento de código 6.8: Método de descarte de instrucciones incorrectamente añadidas al pipeline debido a una predicción de saltos incorrecta. Este método se incluye en la clase LEGv8Pipeline. finalizado la ejecución de la última instrucción del programa. Si antes de que esa instrucción llegue a su etapa final alguna instrucción produjese que se tomara un salto en el pipeline, saltando a una instrucción anterior, el valor del atributo lastInstruction volvería a ser null. Para finalizar, cabe destacar que el constructor de LEGv8Pipeline toma como parámetros de entrada: el programa a ejecutar, las cuatro unidades operacionales asociadas a las cuatro subetapas EX y la unidad de salto a utilizar. Esto permite la creación de distintas pipelines con unidades de operaciones y unidades de salto que presenten distintas configuraciones. En el caso de las unidades de operaciones, se puede variar su latencia y su intervalo de iniciación. El constructor generará automáticamente los subpipelines asociados a cada unidad de operaciones con el número de etapas adecuado. En el caso de la unidad de salto, se puede variar la política de predicción de saltos utilizada, además de la etapa de resolución de los saltos. Como alternativa a los constructores, la clase presenta un método estático, idealPipeline(LEGv8Program), que devuelve una instancia de LEGv8Pipeline que presenta una configuración ideal: la política de resolución de saltos es IDEAL, y todas las unidades de operaciones tienen una latencia de 0 e intervalo de iniciación de 1. 6.2.8. Implementación del compilador de ficheros ensamblador LEGv8 El compilador de LEGv8 se implementa utilizando ANTLR 4. ANTLR 4 permite la definición de analizadores léxicos y analizadores sintácticos en un mismo fichero único, lo que significa que todo un compilador puede escribirse en un único fichero ANTLR. 95
6.2. IMPLEMENTACIÓN DE LA PROPUESTA A primera vista, la especificación de un programa ensamblador no parece presentar gran dificultad. La estructura de las sentencias ensamblador es muy parecida al código de tres direcciones descrito en Compilers: Principles, Techniques and Tools [65]. El código de tres direcciones se suele utilizar dentro de los compiladores como representación intermedia de los programas de alto nivel, ya que presenta una gran sencillez estructural que facilita su traducción al objeto final de la compilación. Cada instrucción ensamblador, así como cada instrucción de código de tres direcciones, presenta: Un operador, generalmente binario. Generalmente una asignación. Como mucho tres operandos, generalmente dos sobre los que se opera y uno al que se asigna el resultado. Por ejemplo, la instrucción de código de tres direcciones x2 := x0 + x1 equivaldría a la instrucción LEGv8 ADD X2, X0, X1. Para reconocer una sentencia ensamblador, sería tan fácil como: 1. Identificar la operación (instrucción) de la sentencia mediante su mnemónico. Para ello, se lee la primera palabra del texto. En el caso del ejemplo anterior, esta sería ADD. 2. Identificar el número de operandos sobre los que trabaja la instrucción identificada. En el caso del ejemplo anterior, este sería 3. 3. Leer tantas palabras, separadas por comas, como operandos utilice la instrucción identificada (ya sean registros o constantes numéricas, según corresponda). En el caso del ejemplo anterior, se leería X2,X0 yX1. 4. Con las palabras (llamadas tokens en el contexto de los analizadores léxicos) identificadas, se construye la correspondiente LEGv8ProgramInstruction. Para generar un programa ensamblador completo (LEGv8Program) a partir de su código fuente, parecería tan sencillo como realizar el proceso anterior sobre todas las sentencias del código fuente, añadiendo las LEGv8ProgramInstruction resultantes a una lista sobre la que construir el respectivo LEGv8Program. Sin embargo, todo el planteamiento del compilador se complica en cuanto se añade un preprocesador. El requisito RF6 (detallado en la sección 5.1.1 de este documento) especifica que el simulador debe proporcionar información sobre el preprocesado del programa. Este preprocesado 96
CAPÍTULO 6. IMPLEMENTACIÓN DEL SIMULADOR debe traducir pseudo-instrucciones a instrucciones reales, constantes numéricas y etiquetas2por sus valores, y macros por su código equivalente. Implementar un preprocesador implica que la compilación de los ficheros ensamblador se debe realizar en dos pasadas por el código fuente, como mínimo. Cada una de estas pasadas realiza lo siguiente: 1. Procesar las directivas de preprocesador (macros, definición de símbolos, etc.) y procesar las etiquetas de memoria. 2. Procesar las instrucciones ensamblador, generando las instancias de LEGv8ProgramInstruction correspondientes. La cantidad final de pasadas del compilador es tres: dos para preprocesado, y una para compilación a instancias de LEGv8ProgramInstruction. El preprocesado requiere dos pasadas debido a que, en muchas ocasiones, es común utilizar en los programas ensamblador las etiquetas de memoria en instrucciones de salto que se encuentran en líneas anteriores a la definición estas etiquetas. Es decir, las instrucciones de salto que realizan un salto hacia delante en el código deben utilizar una etiqueta de memoria cuya dirección asociada todavía no se conoce. Como una pasada de un compilador procesa un fichero de arriba a abajo, sin poder dar saltos hacia atrás o hacia adelante, se deben realizar dos pasadas para poder sustituir las etiquetas de memoria y otros símbolos por sus respectivos valores. La función final de cada pasada del compilador es: 1. Procesar las directivas de preprocesador, rellenando las tablas de símbolos, etiquetas de memoria y macros. 2. Realizar la sustitución de los símbolos, etiquetas de memoria y macros definidos en sus respectivas tablas. 3. Procesar las instrucciones ensamblador, generando las instancias de LEGv8ProgramInstruction correspondientes. Las pasadas 1 y 3 se definen utilizando ANTLR 4, en dos ficheros distintos: el de preprocesador y el de compilador, respectivamente. La pasada 2 se define utilizando código Java en el fichero ANTLR 4 de preprocesado. ANTLR 4 permite la definición de funcionalidades con código Java en los ficheros de definición de gramáticas, insertando este código Java en las clases resultantes de las gramáticas. De esta forma, se definen dos ficheros de gramáticas: LEGv8Preprocessor.g4, encargado de las dos pasadas de preprocesado. LEGv8.g4, encargado de la pasada de compilación de código preprocesado. 2En ensambladores reales, la traducción de etiquetas por sus valores se realiza en compilación y no en preprocesado, simplificando la lógica del ensamblador. El simulador debe realizarlo en preprocesado, pese a que requiera una lógica más compleja, ya que es un requisito funcional. 97
6.2. IMPLEMENTACIÓN DE LA PROPUESTA El fichero LEGv8Preprocessor.g4 es el que presenta la lógica más compleja: define la lógica de dos pasadas, siendo la lógica de ambas no trivial. El fichero LEGv8.g4 define la lógica de una pasada, siendo esta lógica trivial, como se ha visto anteriormente. La mayor complejidad del fichero LEGv8.g4 reside en la gran cantidad de palabras clave que hay que definir como tokens léxicos. Los lenguajes ensamblador definen muchas más palabras clave que los lenguajes de alto nivel, ya que cada mnemónico de una instrucción y cada nombre de un registro debe ser una palabra clave. Todas estas palabras clave deben definirse como tokens léxicos de la gramática. En definitiva, la gramática definida en el fichero LEGv8.g4 no presenta gran complejidad estructural, pero sí una gran cantidad de tokens. El fragmento de código 6.9 muestra la definición de los tokens léxicos asociados a instrucciones LEGv8 de formato R. El fragmento de código 6.10 muestra las reglas gramaticales que utilizan estos tokens para generar las correspondientes instancias de LEGv8ProgramInstruction. OP3R // Instrucciones R que utilizan 3 registros . : (A D D) | (A D D S) | (A N D) | (A N D S) | (E O R) | (O R R) | (S U B) | (S U B S) ; OP2R // Instrucciones R que utilizan 2 registros . : (L S L) | (L S R) ; Fragmento de código 6.9: Definición de los tokens léxicos asociados a los mnemónicos de instrucciones LEGv8 de formato R que utilizan 3 y 2 registros. Estos tokens se utilizan en el fichero LEGv8.g4 para compilar programas ensamblador LEGv8 a objetos ejecutables por el simulador. Las reglas gramaticales que utilizan estos tokens se muestran en el fragmento de código 6.10. En el fichero LEGv8Preprocessor.g4 se definen los siguientes métodos públicos: public String preprocessedOutput(), que devuelve el código fuente del programa preprocesado. public Collection<Label> getLabels(), que devuelve la lista de etiquetas de memoria del sistema. public Collection<Data> getData(), que devuelve la lista de datos de memoria especificados mediante directivas de preprocesador. Esta lista es pasada al constructor de LEGv8Program como parámetro, encargándose esta clase de escribirlos en la memoria del sistema. 98
CAPÍTULO 6. IMPLEMENTACIÓN DEL SIMULADOR instruction : OP3R rd=REG SEP rn=REG SEP rm= REG { program .add( LEGv8ProgramInstruction . programInstructionR ( instructions . get ( $OP3R . text ), $rd .text , $rn .text , $rm .text , $OP3R . text + " " + $rd . text + ", " + $rn. text + ", " + $rm .text , $OP3R . line , programLine , getAddress () ) ); } | OP2R rd=REG SEP rn=REG SEP IMM_PREFIX number { program .add ( LEGv8ProgramInstruction . programInstructionShift ( instructions . get ( $OP2R . text ), $rd .text , $rn .text , $number .val , $OP2R . text + " " + $rd . text + ", " + $rn. text + ", #0x" + Integer . toHexString ( $number . val ), $OP2R . line , programLine , getAddress () ) ); } ... Fragmento de código 6.10: Definición de las reglas gramaticales asociadas a las instrucciones LEGv8 de formato R que utilizan 3 y 2 registros. Estas reglas generan instancias de LEGv8ProgramInstruction, que son añadidas a la lista de instrucciones de programa program. REG es el conjunto de tokens léxicos de los registros. SEP es el token de separador de registros, que equivale a una coma. En el fichero LEGv8.g4 se define el siguiente método público: public List<LEGv8ProgramInstruction> outputProgram(), que devuelve la lista de las instancias de LEGv8ProgramInstruction que conforman el programa. Estos métodos son invocados desde la clase LEGv8ProgramLoader, la clase que hace de fachada con el compilador generado por ANTLR 4. El fragmento de código 6.11 muestra el código simplificado del método de generación de programas LEGv8. Este método construye instancias de las clases generadas por las gramáticas ANTLR 4, invocando los métodos que se han definido en ellas para generar el LEGv8Program. El preprocesador implementado soporta una variante de las directivas de preprocesador de gas, el ensamblador de GNU [66]. En el apéndice F se detallan las directivas de preprocesador soportadas por el preprocesador. Entre estas directivas se encuentran las directivas de creación de macros .macro y.endm. Las macros sirven para sustituir una breve expresión en el código por un fragmento de código predefinido. El preprocesador soporta le definición de macros parametrizadas, tal y como se definen en gas. Este tipo de macros permiten la definición de pseudo-instrucciones ensamblador en el propio código de los programas. De esta forma, se está cumpliendo el requisito RF8 (detallado en la sección 5.1.1 de este documento). El fragmento de código 6.12 muestra la definición de la pseudo-instrucciones LEGv8 definidas en el libro de referencia, haciendo uso de macros. La lógica para implementar macros parametrizadas es relativamente compleja. Por eso, se delega en una clase propia: Macro. Esta clase implementa todos los métodos necesarios para 99
6.2. IMPLEMENTACIÓN DE LA PROPUESTA 2. se imprime este mensaje en el área de texto de la pestaña de ejecución de la interfaz gráfica (o por terminal si se ejecuta el simulador en modo terminal), y 3. se continúa la ejecución normal del programa. Para las interrupciones de tipo InterruptIn, 1. se activa la inserción de texto por parte del usuario en el área de texto de la pestaña de ejecución de la interfaz gráfica (si el simulador se ejecuta en modo interactivo), 2. se espera a que el usuario introduzca un dato de manera textual y pulse la tecla Enter, 3. se recupera el mensaje introducido por el usuario y se almacena en la instancia de InterruptIn capturada, 4. se “recupera” la CPU, invocando el método recover(InterruptIn) de la instancia de Program, pasando como parámetro la instancia de InterruptIn capturada, 5. la CPU recupera el mensaje de entrada, 6. la CPU hace un casting del mensaje al tipo de datos correspondiente (indicado por el código de llamada al sistema especificado), 7. la CPU almacena el resultado en el registro X0 o D0, dependiendo de si el tipo de datos es de tipo entero o coma flotante, respectivamente, y 8. se continúa la ejecución normal del programa. Como llamadas al sistema concretas de LEGv8, se implementa un subconjunto de las llamadas al sistema que ofrece el simulador de la arquitectura MIPS, MARS [13]. Las llamadas al sistema soportadas se presentan en la tabla 6.1. Servicio proporcionado Código Argumentos Resultado Imprimir un entero 1 X0 = entero Imprimir un float 2 D0 = valor en FP32 Imprimir un double 3 D0 = valor en FP64 Imprimir String 4 X0 = puntero a la String Leer un entero 5 Entero leído en X0 Leer un float 6 FP32 leído en D0 Leer un double 7 FP64 leído en D0 Leer String 8 X0 = puntero al buffer, X1 = longitud de la String Se almacena la cadena leída en memoria, a partir de la dirección apuntada por X0. Se lee el número de caracteres indicados por X1. Imprimir un carácter 11 X0 = carácter Leer carácter 12 Carácter leído en X0. Tabla 6.1: Llamadas al sistema implementadas en la arquitectura LEGv8. Las llamadas al sistema son invocadas mediante la instrucción SVC, con el código de llamada correspondiente. Como las llamadas al sistema se implementan como excepciones de Java, se deben modificar varios métodos para declarar que pueden lanzar excepciones de tipo InterruptIn e InterruptOut. Estos métodos son: 106
CAPÍTULO 6. IMPLEMENTACIÓN DEL SIMULADOR execute(ProgramInstruction) en ExecutionCode. execute(ProgramInstruction) en Instruction. cycle() en Pipeline. execute() en Program. executeNext() en Program. Las excepciones de Java, y por tanto las llamadas al sistema implementadas, se deben capturar con estructuras de código de tipo try-catch. Consideramos que estas estructuras son algo incómodas de utilizar, ya que fuerzan una organización del código específica a la que el programador se debe adaptar. Es por ello que consideramos que podría resultar interesante realizar, en el futuro, una revisión de la implementación de las llamadas al sistema y buscar otras alternativas a la implementación actual. 6.3. Resumen En este capítulo se ha detallado de forma general el proceso de implementación de las principales características del simulador. Como conclusiones del capítulo podemos destacar: El simulador se codifica principalmente en Java 11. La implementación del simulador parte del diseño propuesto en el capítulo 5, pero realizando pequeñas modificaciones que facilitan la programación. La mayoría de las principales estructuras de datos de la arquitectura LEGv8 (banco de registros, memoria, conjunto de instrucciones, etc.) se implementan utilizando una estructura de mapa. El pipeline de la arquitectura LEGv8 es segmentado. Implementa la lógica completa de detección de riesgos, anticipación de resultados, ejecución de instrucciones en distintas unidades funcionales multiciclo, y predicción y resolución de saltos en distintas etapas y siguiendo distintas políticas. El compilador LEGv8 es un compilador de tres pasadas implementado mediante ANTLR 4. Incluye soporte para casi todas las directivas de preprocesador del ensamblador de GNU, gas. La interfaz gráfica del simulador utiliza la biblioteca JavaFX. El editor de texto de la interfaz gráfica utiliza la biblioteca RichTextFX, que se basa en JavaFX. La interfaz gráfica actual es un prototipo y no la versión definitiva que utilizará el simulador. Para ampliar los conocimientos sobre la implementación del simulador, se recomienda leer su código fuente y su documentación Javadoc asociada. 107
6.3. RESUMEN 108
CAPÍTULO 7. PRUEBAS DE VALIDACIÓN Capítulo 7 Pruebas de validación En este capítulo se detallan las pruebas desarrolladas para validar las características y funcionalidades del prototipo de simulador desarrollado. Las pruebas de validación desarrolladas se dividen en tres tipos: Pruebas de extensibilidad. Pruebas de caja negra. Pruebas de caja blanca. Para finalizar, se detallan los errores detectados con las pruebas y su corrección. 7.1. Pruebas de extensibilidad Una de las principales características que ha regido el diseño e implementación del simulador es la capacidad de ser extendido a otras arquitecturas de computadores y lenguajes ensamblador. Por ello, se considera oportuno realizar pruebas para determinar la facilidad de extensión del simulador a nuevas arquitecturas. Se plantean dos pruebas que evalúan la facilidad de extensión del simulador. La facilidad de extensión del simulador se evalúa a través de dos métricas: el tiempo de desarrollo del módulo de la arquitectura a la que se añade soporte en el simulador, y la experiencia subjetiva del programador que realiza tal desarrollo. La primera prueba planteada evalúa el proceso de extensión del simulador a una arquitectura similar a LEGv8. Las arquitecturas de este tipo presentan un gran interés didáctico. Por tanto, podría surgir la necesidad real de extender el simulador para soportar un mayor número de arquitecturas con características similares a LEGv8. 109
7.1. PRUEBAS DE EXTENSIBILIDAD La primera prueba consiste en implementar la arquitectura RISC-V, especificada en el libro Computer Organization and Design RISC-V edition: The Hardware/Software Interface [30], con ciertas limitaciones. Estas limitaciones son las siguientes: Implementar exclusivamente el subconjunto de instrucciones RV64I, que incluye las instrucciones básicas de 64 y 32 bits de la arquitectura. Implementar un pipeline no segmentado, para reducir de forma considerable la complejidad de la arquitectura. En el apéndice E se tratan en más detalle las características de la arquitectura RISC-V y de su subconjunto de instrucciones RV64I. La implementación parte del módulo de la arquitectura LEGv8. Sobre ese módulo, se realizan las modificaciones de las clases pertinentes para soportar RISC-V. Las principales clases que se modifican son las siguientes: RISCVInstruction (derivada de LEGv8Instruction). RISCVProgramInstruction (derivada de LEGv8ProgramInstruction). RISCVInstructionSet (derivada de LEGv8InstructionSet). RISCVPipeline (derivada de LEGv8Pipeline). El preprocesador y el compilador ANTLR, RISCVPreprocessor.g4 y RISCV.g4 (derivados de LEGv8Preprocessor.g4 y LEGv8.g4, respectivamente), también se modifican para soportar el lenguaje ensamblador RISC-V. El preprocesador se modifica ligeramente. El compilador se modifica de forma más extensa: principalmente se cambian los mnemónicos aceptados, aunque también se realizan cambios en otras reglas sintácticas de construcción de instrucciones. La segunda prueba planteada evalúa el proceso de extensión del simulador a una arquitectura diferente a LEGv8. La prueba consiste en implementar la arquitectura PTX especificada en la documentación de NVIDIA [69], con ciertas limitaciones. Estas limitaciones son: Implementar exclusivamente un subconjunto de instrucciones reducido, similar en tamaño y capacidades al de LEGv8. Implementar un pipeline no segmentado, para reducir de forma considerable la complejidad de la arquitectura. Esta implementación se realiza sin utilizar otro módulo de arquitectura como base. A continuación se detallan las pruebas de extensibilidad planteadas y sus resultados: P-Ext1.- Extensión a la arquitectura RISC-V. 110
CAPÍTULO 7. PRUEBAS DE VALIDACIÓN Resultado: satisfactorio. Conclusiones: El tiempo final de extensión del simulador a la arquitectura fue de aproximadamente 25 horas. El programador considera que la experiencia de desarrollo resultó sencillo. Consideramos que el esfuerzo dedicado a implementar la arquitectura es reducido. Deducimos, por tanto, que el esfuerzo requerido para implementar arquitecturas similares a LEGv8 en el simulador es bajo. P-Ext2.- Extensión a la arquitectura PTX. Resultado: no realizada. Justificación: Se estima un tiempo necesario para desarrollar la prueba superior a 50 horas. Este tiempo supera el tiempo de planificación asignado a realizar pruebas de extensibilidad. Se prefiere invertir ese tiempo en realizar pruebas de otro tipo. La prueba queda planteada, pendiente de ser realizada en un futuro. 7.2. Pruebas de caja negra Las pruebas de caja negra [70] son un método de validación de software que evalúa la funcionalidad de un sistema ignorando su implementación concreta. Se centran en la comprobación de que el componente evaluado produzca los resultados esperados, al realizar casos de uso considerados comunes. Para validar el funcionamiento esperado del simulador en casos de uso comunes, se llevan a cabo pruebas de caja negra para el sistema completo. Las pruebas desarrolladas son de dos tipos: Pruebas de casos base: se valida el correcto funcionamiento del simulador ante programas correctos. Pruebas de casos con entradas erróneas: se valida el correcto funcionamiento del simulador ante programas erróneos. 7.2.1. Pruebas de casos base Las pruebas de casos base consisten en la implementación y ejecución a través del simulador de programas ensamblador LEGv8 y RISC-V. Estos programas son de dos tipos: Programas reales de distinta naturaleza. Con estos programas se busca validar casos de uso reales de forma completa. Sirven para evaluar el funcionamiento del simulador desde el punto de vista de los usuario reales. 111
7.2. PRUEBAS DE CAJA NEGRA Programas minimalistas que evalúan instrucciones individuales. Con estos programas se busca validar exhaustivamente el correcto funcionamiento de todas las instrucciones de las arquitecturas del simulador. Sirven para evaluar de forma sencilla una gran cantidad de casos de uso complejos, que se pueden interpretar como combinaciones de estos programas. Para las pruebas con programas reales, se utilizan variaciones de programas descritos en los libros de referencia. En los libros de referencia estos programas se utilizan con propósito didáctico, para explicar conceptos básicos sobre los lenguajes ensamblador. Por ello, estos programas conforman una buena base de pruebas de casos de uso reales. Un ejemplo de tales programas es el algoritmo DAXPY, ilustrado en el fragmento de código 7.1. // DAXPY loop in LEGv8 assembly . .equ n_bytes , 64 .data X: . space n_bytes Y: . space n_bytes Z: . space n_bytes .equ k, 16 .text ... // DAXPY init: MOVZ X4 , #X MOVZ X5 , #Y MOVZ X6 , #Z ADDI X3 , X6 , # n_bytes MOVZ X2 , #k // DAXPY loop: daxpy : LDUR X0 , [X4 , #0] MUL X0 , X0 , X2 LDUR X1 , [X5 , #0] ADD X1 , X1 , X0 STUR X1 , [X6 , #0] ADDI X4 , X4 , #8 ADDI X5 , X5 , #8 ADDI X6 , X6 , #8 SUBS XZR , X6 , X3 B.NE daxpy // DAXPY loop in RISC -V assembly . .equ n_bytes , 64 .data X: . space n_bytes Y: . space n_bytes Z: . space n_bytes .equ k, 16 .text ... // DAXPY init: addi s4 , s4 , X addi s5 , s5 , Y addi s6 , s6 , Z addi s3 , s6 , n_bytes addi s2 , s2 , k // DAXPY loop: daxpy : ld s0 , 0( s4) mul s0 , s0 , s2 ld s1 , 0( s5) add s1 , s1 , s0 sd s1 , 0( s6) addi s4 , s4 , 8 addi s5 , s5 , 8 addi s6 , s6 , 8 bne s6 , s3 , daxpy Fragmento de código 7.1: Programas ensamblador que implementan el algoritmo DAXPY. A la izquierda se presenta una implementación en LEGv8. A la derecha se presenta una implementación en RISC-V. También se utilizan otros programas ensamblador simples, desarrollados a partir de programas básicos que pretenden ilustrar conceptos fundamentales sobre programación. Ejemplos de estos programas son una implementación recursiva de una función que calcula la sucesión de Fibonacci, o una implementación de un algoritmo de multiplicación de matrices. El fragmento de código 7.2 muestra la implementación del programa que calcula toda la sucesión de Fibonacci hasta un 112
CAPÍTULO 7. PRUEBAS DE VALIDACIÓN número dado. Para las pruebas con programas minimalistas, se desarrollaron programas de una sola instrucción para todas las instrucciones ensamblador implementadas en las arquitecturas de LEGv8 y RISC-V. A continuación se detallan las pruebas de casos base realizadas y sus resultados: P-CB1.- DAXPY en LEGv8. Entrada: programa ensamblador LEGv8 del algortimo DAXPY. Salida esperada: ejecución correcta del programa. Vector resultante almacenado en memoria. Resultado: erróneo. Descripción de errores: ERR-1.- Error de compilación al utilizar directivas de preprocesador. Error identificado y corregido. ERR-2.- Fallo del sistema irrecuperable al tratar de ejecutar. Excepción de Java de tipo NullPointerException. Error identificado y corregido. ERR-3.- Lectura incorrecta de datos de memoria. Error identificado y corregido. P-CB2.- DAXPY en RISC-V Entrada: programa ensamblador RISC-V del algoritmo DAXPY. Salida esperada: ejecución correcta del programa. Vector resultante almacenado en memoria. Resultado: erróneo. Descripción de errores: ERR-4.- Suma de registros con inmediatos incorrecta. Error identificado y corregido. P-CB3.- Programa p86 de LEGv8 Entrada: programa LEGv8 simple descrito en la página 86 del libro de referencia de LEGv8 [28]. Salida esperada: ejecución correcta del programa. Contenidos de memoria modificados correctamente. Resultado: correcto. P-CB4.- Programa p85 de RISC-V Entrada: programa RISC-V simple descrito en la página 85 del libro de referencia de RISC-V [30]. Salida esperada: ejecución correcta del programa. Contenidos de memoria modificados correctamente. Resultado: correcto. 113
7.2. PRUEBAS DE CAJA NEGRA P-CB5.- Programa de la sucesión de Fibonacci en LEGv8 Entrada: programa LEGv8 que imprime los 11 primeros elementos de la sucesión de Fibonacci utilizando una función recursiva. Salida esperada: Ejecución correcta del programa. Impresión en el simulador de los 11 primeros números de la sucesión de Fibonacci. Resultado: erróneo. Descripción de errores: ERR-5.- Error de compilación en la definición de macros. Error identificado. ERR-6.- Error de escritura en memoria al tratar de escribir un dato en la pila. Error identificado y corregido. ERR-7.- Comportamiento incorrecto de las instrucciones de salto. Error identificado y corregido. ERR-8.- Retorno de funciones incorrecto. Error identificado y corregido. ERR-9.- Líneas de código fuente incorrectamente detectadas al utilizar macros. Error identificado y corregido. P-CB6.- Programa de la sucesión de Fibonacci en RISC-V Entrada: programa RISC-V que imprime los 11 primeros elementos de la sucesión de Fibonacci utilizando una función recursiva. Salida esperada: ejecución correcta del programa. Impresión en el simulador de los 11 primeros números de la sucesión de Fibonacci. Resultado: correcto. P-CB7.- Programa de cálculo de un número factorial en LEGv8 Entrada: programa LEGv8 para el cálculo recursivo de un número factorial descrito en la página 105 del libro de referencia de LEGv8. Valor 5 en el registro X0. Salida esperada: ejecución correcta del programa. Valor 120 en el registro X1. Resultado: correcto. P-CB8.- Programa de cálculo de un número factorial en RISC-V Entrada: programa RISC-V para el cálculo recursivo de un número factorial descrito en la página 103 del libro de referencia de RISC-V. Valor 5 en el registro x10. Salida esperada: ejecución correcta del programa. Valor 120 en el registro x10. Resultado: correcto. P-CB9.- Programa de multiplicación de matrices en LEGv8 Entrada: programa LEGv8 para el cálculo de multiplicación de matrices. Matriz identidad como primer factor, matriz arbitraria como segundo. 114
CAPÍTULO 7. PRUEBAS DE VALIDACIÓN Salida esperada: ejecución correcta del programa. Matriz arbitraria replicada en memoria. Resultado: correcto. P-CB10.- Programa de multiplicación de matrices en RISC-V Entrada: programa RISC-V, para el cálculo de multiplicación de matrices. Matriz identidad como primer factor, matriz arbitraria como segundo. Salida esperada: ejecución correcta del programa. Matriz arbitraria replicada en memoria. Resultado: correcto. P-CB11.- Verificación de instrucciones LEGv8 individuales Entrada: programas LEGv8 con una única instrucción. Un programa por instrucción. Salida esperada: ejecución correcta de todos los programas. Modificación apropiada de los componentes de la CPU simulada. Resultado: correcto. P-CB12.- Verificación de instrucciones RISC-V individuales Entrada: programas RISC-V con una única instrucción. Un programa por instrucción. Salida esperada: ejecución correcta de todos los programas. Modificación apropiada de los componentes de la CPU simulada. Resultado: correcto. 7.2.2. Pruebas de casos con entradas erróneas Para las pruebas de casos con entradas erróneas, se desarrollaron programas LEGv8 y RISCV que presentaban errores sintácticos y semánticos. Estos incluían programas completamente erróneos, como cadenas de texto aleatorias, o programas que cometían ligeros fallos en la sintaxis de las instrucciones o directivas de preprocesador. A continuación se detallan las pruebas de casos con entradas erróneas realizadas y sus resultados: P-CErr1.- Compilación de un programa erróneo Entrada: texto aleatorio como programa. Salida esperada: error de compilación. Resultado: correcto. 115
7.4. ERRORES DETECTADOS Y CORRECCIONES 7.3.2. Pruebas de casos límite para el sistema Las pruebas de casos límite para el sistema tratan de evaluar el comportamiento del simulador en casos de uso que utilizan de forma exhaustiva alguno de sus componentes. A continuación se detallan las pruebas de casos límite para el simulador realizadas y sus resultados: P-CL1.- Almacenamiento masivo en la memoria Entrada: programa que escribe en bucle infinito datos en memoria, en direcciones de memoria distintas. Salida esperada: comportamiento correcto de la memoria. No se produce ningún error de ejecución debido a un fallo la estructura de datos que modela la memoria, durante la simulación de un número razonablemente alto de ciclos de reloj. Resultado: correcto. P-CL2.- Programa con un gran número de instrucciones Entrada: programa que contiene un gran número de instrucciones y, por tanto un gran número de líneas de código. Salida esperada: compilación correcta del programa. No se produce ningún error de ejecución debido a un fallo de la estructura de datos que almacena las instrucciones de un programa. El editor de código del simulador funciona correctamente. Resultado: parcialmente correcto. Descripción de errores: ERR-15 La funcionalidad de resaltado de sintaxis del editor de código de la interfaz gráfica falla en ocasiones. P-CL3.- Programa con un gran número de llamadas recursivas Entrada: programa que implementa una función recursiva cualquiera con un gran número de llamadas recursivas. (Por ejemplo, un programa que calcule recursivamente el 100onúmero de la sucesión de Fibonacci). Salida esperada: ejecución correcta del programa. La ejecución del programa parece detenerse o entrar en bucle infinito por el gran número de llamadas recursivas, pero no se produce ningún error de ejecución. Resultado: correcto. 7.4. Errores detectados y correcciones Gracias al desarrollo de las pruebas descritas en este capítulo, se detectaron múltiples errores en el funcionamiento del simulador. La mayoría de estos errores se detectaron en las primeras 122
CAPÍTULO 7. PRUEBAS DE VALIDACIÓN pruebas de validación. Muchos de los errores detectados han sido corregidos. Otros errores no han podido ser corregidos, principalmente por limitaciones de tiempo. Estos errores sin corregir podrían corregirse en algún momento del futuro antes de comenzar las pruebas con usuarios reales. Los errores detectados con las pruebas de validación son los siguientes: ERR-1.- Este error causa que no se pudieran utilizar directivas de preprocesador en los programas ensamblador ejecutados. El error se ha identificado como un fallo en las reglas de producción de la gramática ANTLR 4 de los preprocesadores. El error se corrige modificando ciertas reglas del preprocesador y cambiando el orden de otras. ERR-2.- Este error produce una excepción Java de tipo NullPointerException al tratar de ejecutar un programa. El error se ha identificado como un problema en el orden de inicialización de los componentes del sistema: el banco de registros y la memoria se inicializan después que el conjunto de instrucciones, por lo que todas las instrucciones se ejecutan con instancias de ambas que son nulas. El error se corrige modificando el orden de inicialización de los componentes, haciendo que las instrucciones sean lo último en inicializarse. ERR-3.- Este error causa que algunos datos de memoria con tamaño superior a un byte se lean incorrectamente, cargando un valor incorrecto en los registros El error se ha identificado como un fallo al interpretar los bytes individuales que forman un dato de tamaño superior a un byte, tratándolos como bytes con signo al recomponer el valor del dato. El error se corrige haciendo una máscara and de los bytes y el valor hexadecimal 0xff al recomponer el valor de datos de tamaño superior a un byte. ERR-4.- Este error causa que las instrucciones addi yaddiw de RISC-V generaran resultados incorrectos. El error se ha identificado como un error en la lógica de ambas instrucciones, estando su lógica intercambiada. El error se corrige intercambiando la lógica de ambas instrucciones. ERR-5.- Este error causa un error de compilación al utilizar macros con parámetros en los programas. El error se ha identificado como un error en el preprocesador, que identifica la declaración de los parámetros de las macros incorrectamente en ciertas circunstancias. Este error en el preprocesador surge de un conflicto con la regla léxica que reconoce caracteres escapados (\n,\r, ...) en cadenas de texto. El error no pudo corregirse debido a que implica una modificación no trivial de una gran cantidad de reglas de la gramática del preprocesador. El tiempo estimado para realizar un cambio de tal magnitud excede el tiempo del proyecto asignado a la corrección de errores, según la planificación. Su corrección afectaría al camino crítico, 123
7.4. ERRORES DETECTADOS Y CORRECCIONES retrasando la finalización del proyecto. Como contingencia, se identificaron las circunstancias bajo las que se produce el error, pudiendo evitarlas en futuras pruebas. Se asumen las consecuencias de no corregir error. ERR-6.- Este error causa una excepción de escritura inválida en la memoria de la CPU simulada cuando se intenta almacenar un valor en la primera dirección válida de la pila. El error se ha identificado como un error aritmético al calcular la primera dirección válida de la pila. El error se corrige modificando la expresión aritmética de cálculo de la dirección correctamente, de tal forma que devuelva un valor mayor en una unidad. ERR-7.- Este error causa que las instrucciones de bifurcación condicional no bifurquen en circunstancias en las que deben hacerlo. El error se ha identificado como un error en la lógica de la ejecución forzosa de instrucciones en el pipeline para procesar correctamente las bifurcaciones. El error se corrige rediseñando y reescribiendo el método de procesamiento de bifurcaciones en el pipeline. ERR-8.- Este error causa que ciertas instrucciones de bifurcación produzcan bifurcaciones incorrectas, a zonas del programa distintas de a las que deberían bifurcar. El error se ha identificado como un error de ejecución de la instrucción LEGv8 BL, Branch and Link (bifurcación y enlace). Esta instrucción nunca llega a la etapa WB del pipeline, debido a que es eliminada en cuanto se procesa la bifurcación. Al no llegar nunca a la etapa WB, no se escribe el resultado del enlace en el registro correspondiente. Esto causa que la dirección a la que bifurcan otras instrucciones que utilizan el mismo registro sea incorrecta. El error se corrige haciendo que las instrucciones de bifurcación no se eliminen del pipeline tras procesar sus bifurcaciones asociadas. ERR-9.- Este error causa que el uso de macros en un programa ensamblador altere los números de línea que se asocian a cada instrucción del programa, haciendo que la asociación sea incorrecta. Esto se manifiesta proporcionando información errónea en la tabla de información de compilación de la interfaz gráfica del sistema. El error se ha identificado como un error en la lógica asociada a la definición de macros en el preprocesador. Esta lógica omite saltos de línea del código fuente original. El error se corrige modificando la lógica de definición de macros en el preprocesador para añadir los saltos de línea omitidos. ERR-10.- Este error causa que los mensajes de información sobre errores léxicos y sintácticos en los programas ensamblador sean poco descriptivos de cara al usuario final. El error se ha identificado como una característica de los analizadores sintácticos generados por ANTLR 4. Los mensajes de error generados por estos analizadores proporcionan información de utilidad al desarrollador del compilador, pero no al usuario que quiere compilar un programa. El error no pudo corregirse porque requiere añadir una etapa de procesado de errores del compilador antes de proporcionar la información de los errores del programa al usuario. El tiempo estimado para añadir este sistema de procesado excede el tiempo del proyecto asignado a la corrección de errores, según la planificación. Su corrección afectaría al camino crítico, retrasando la finalización del proyecto. De todas formas, el error no se considera un error crítico que requiera una corrección inmediata. 124
CAPÍTULO 7. PRUEBAS DE VALIDACIÓN ERR-11.- Este error causa que los errores léxicos y sintácticos en los programas ensamblador se detecten como múltiples errores. El error se ha identificado como una característica de los analizadores sintácticos generados por ANTLR 4. Este error está fuertemente relacionado con el error ERR-10. Como consecuencia, no pudo corregirse por los mismos motivos. ERR-12.- Este error causa un fallo irrecuperable en el sistema al tratar de compilar programas con instrucciones que utilizan registros inexistentes. El error se identificó como un error del compilador. ANTLR 4 no presenta ningún mecanismo de captura de errores de ejecución causados al procesar las reglas de una gramática. Como consecuencia, no presenta ningún mecanismo de gestión de los errores semánticos del compilador. Esto causa que el error producido en el banco de registros al tratar de acceder a un registro inexistente no se capture y sea devuelto como un error de compilación. El error no pudo corregirse porque requiere añadir una etapa de captura y gestión de errores semánticos. El tiempo estimado para añadir este sistema de tratamiento de errores semánticos excede el tiempo del proyecto asignado a la corrección de errores, según la planificación. Su corrección afectaría al camino crítico, retrasando la finalización del proyecto. Se asumen las consecuencias de no corregir el error. ERR-13.- Este error causa que la escritura y lectura de datos en la sección de memoria reservada esté permitida, cuando no debería estarlo. El error se ha identificado como un error en la simulación de la memoria. La sección de memoria reservada no presenta ningún sistema de seguridad contra lectura o escritura. El error se corrige añadiendo un sistema de seguridad a la sección de memoria reservada, de forma que se lance un error cada vez que se accede a ella. ERR-14.- Este error causa que la escritura de datos en la sección de memoria de texto (dedicada a las instrucciones del programa) esté permitida sin consecuencias. La lectura en esta sección de memoria no debería estar permitida, o debería generar la edición en tiempo de ejecución de las instrucciones del programa. Este error está fuertemente relacionado con el error ERR-13. El error se ha identificado como un error en la simulación de la memoria. La sección de memoria de texto no presenta ningún sistema de seguridad contra lectura o escritura. El error se corrigió añadiendo un sistema de seguridad a la sección de memoria de texto, de forma que se lance un error cada vez que se accede a ella. ERR-15.- Este error causa que algunas líneas de código no se resalten en el editor de código del simulador. El error no ha podido ser identificado. Probablemente se trate de un error de lógica al utilizar las bibliotecas RichTextFX y JavaFX. Se ha tratado de corregir el error reescribiendo la lógica de resaltado de texto del editor de código, pero sin resultados. Durante el proceso de identificación y corrección de los errores anteriores se identificaron más errores en el código fuente del sistema. Estos errores son los siguientes: ERR-16.- Este error causa que, para programas LEGv8, las distintas políticas de resolución de saltos 125
7.5. RESUMEN presenten comportamientos inconsistentes, dependiendo de la etapa del pipeline en que se resuelve el salto. El error se ha identificado como un error conceptual en el simulador. La resolución de saltos en las etapas IF e ID siempre se realiza de forma ideal, mientras que la resolución de saltos en la etapa EX siempre se realiza de forma realista. El error no pudo corregirse porque requiere realizar un proceso relativamente extenso de análisis, diseño y elección en el que participan múltiples soluciones posibles. El tiempo estimado para realizar este proceso excede el tiempo del proyecto asignado a la corrección de errores, según la planificación. Su corrección afectaría al camino crítico, retrasando la finalización del proyecto. De todas formas, el error no se considera un error crítico que requiera una corrección inmediata. Se asumen las consecuencias de no corregir el error. ERR-17.- Este error potencialmente puede causar que los resultados de algunas instrucciones sean incorrectos bajo determinadas circunstancias. Este error se ha identificado como un error en la simulación de la ejecución de instrucciones LEGv8 de carga desde memoria. Estas instrucciones se ejecutan en la etapa EX del pipeline, cuando en el libro de referencia se especifica que este tipo de instrucciones se ejecutan en la etapa MEM. El error se corrige añadiendo lógica a la simulación del pipeline para ejecutar las cargas desde memoria en la etapa MEM en vez de en IF. 7.5. Resumen En este capítulo se detallan las pruebas realizadas para validar las distintas funcionalidades del simulador. Se realizaron pruebas de los siguientes tipos: Pruebas de extensibilidad. Con estas pruebas se ha evaluado la capacidad del simulador de ser fácilmente extendido a otras arquitecturas. Pruebas de caja negra. Estas pruebas han sido a su vez de dos tipos: •Pruebas de casos base. Con estas pruebas se ha validado la funcionalidad del simulador ante casos de uso comunes. Han consistido en la ejecución de programas ensamblador completos especificados en el libro de referencia o derivados de programas didácticos sobre programación. También se han ejecutado programas minimalistas de una instrucción para validar de forma exhaustiva el funcionamiento de todas las instrucciones del simulador •Pruebas de casos con entradas erróneas. Con estas pruebas se ha validado el funcionamiento del simulador ante casos de uso con errores. Han consistido en la compilación y ejecución de programas ensamblador erróneos. Por ejemplo, programas con caracteres aleatorios, programas con ligeros errores léxicos y sintácticos, programas con múltiples errores léxicos y sintácticos, programas con errores semánticos, y programas que hacen un uso incorrecto de los componentes de la CPU simulados. Pruebas de caja blanca. Estas pruebas han sido a su vez de dos tipos: 126
CAPÍTULO 7. PRUEBAS DE VALIDACIÓN •Pruebas de caso límite para el sistema simulado. Con estas pruebas se ha validado el funcionamiento correcto del simulador ante casos considerados límite para el sistema simulado. Han consistido en la ejecución en el simulador de programas ensamblador que presentan comportamientos especiales en CPUs reales. Por ejemplo, programas que codifican instrucciones con constantes numéŕicas más grandes de lo permitido, o programas que escriben en registros de solo lectura. •Pruebas de caso límite para el simulador. Con estas pruebas se ha validado el funcionamiento del simulador ante casos de uso considerados límite. Han consistido en la ejecución de programas que hacen un uso intensivo de los componentes simulados. Por ejemplo, programas con un gran número de almacenamientos en memoria, programas con un gran número de instrucciones, y programas con un gran número de llamadas recursivas. Gracias a las pruebas de validación realizadas, un gran número de errores en el simulador han sido identificados. La mayoría de estos errores han sido corregidos. Algunos errores no han podido ser corregidos debido a limitaciones de tiempo. 127
7.5. RESUMEN 128
CAPÍTULO 8. CONCLUSIONES Capítulo 8 Conclusiones En este capítulo se detallan las conclusiones del proyecto. Se exponen los siguientes aspectos: Los objetivos cumplidos. Las conclusiones principales. Las líneas de trabajo futuro. La valoración personal del proyecto. 8.1. Objetivos cumplidos A lo largo de este proyecto se ha desarrollado un prototipo de simulador de arquitecturas de computadores y lenguajes ensamblador. El prototipo cuenta con características de interés de cara a la docencia, por lo que puede resultar de utilidad como herramienta de apoyo en asignaturas de Arquitectura de Computadores. A continuación se incluyen las principales características del prototipo desarrollado: Capacidad de simulación de forma interactiva, ofreciendo un editor de texto para la edición de programas ensamblador, y una pestaña de información sobre el estado de la CPU simulada en tiempo de ejecución de los programas. Software multiplataforma que puede ejecutarse tanto en entornos Linux como en entornos Windows, facilitando la portabilidad. Soporte para múltiples arquitecturas de forma extensible, sin necesidad de recompilar el software. Código fuente que cumple unos criterios mínimos de calidad, mediante el seguimiento de convenciones de código y buenas prácticas, facilitando su comprensión y expansión en futuros cursos académicos. 129
8.2. CONCLUSIONES DEL DESARROLLO Durante el proyecto se ha implementado en el simulador soporte para las arquitecturas ARM LEGv8 y RISC-V, dos de las arquitecturas RISC más utilizadas en contextos docentes a día de hoy. La arquitectura LEGv8 cuenta con un pipeline segmentado configurable y múltiples técnicas de predicción estática de saltos. Ambas características presentan un gran interés didáctico, y no suelen implementarse en los simuladores de arquitecturas de computadores. Por tanto, el prototipo desarrollado presenta funcionalidades innovadoras con respecto a los simuladores didácticos más ampliamente utilizados a día de hoy. El prototipo ha alcanzado un punto de desarrollo estable. Cumple todos los requisitos considerados “esenciales” y la mayoría de requisitos tentativos. Presenta las funcionalidades suficientes para realizar una demostración de uso y funcionamiento del sistema. Para la finalización de la primera versión del sistema, tan solo sería necesario desarrollar una versión definitiva de la interfaz gráfica y solucionar fallos menores del sistema de compilación. El simulador va a utilizarse como herramienta de apoyo en las asignaturas de Fundamentos de Computadoras y Arquitectura y Organización de Computadoras de la Universidad de Valladolid a partir del segundo cuatrimestre del curso 2021-2022. El simulador se utiliza en el grupo de Investigación Trasgo de la Universidad de Valladolid para ayudar a la optimización del código de aplicaciones de HPC para dispositivos con arquitectura ARM, como NVIDIA Jetson Nano. En concreto, se ha utilizado para optimizar una aplicación de cálculo de cómputos de tipo stencil en sistemas heterogéneos distribuidos. El trabajo desarrollado en este proyecto ha dado lugar a dos publicaciones de carácter científico que han sido aceptadas y serán presentadas en las XXXI Jornadas de Paralelismo 2020/2021 de la Sociedad de Arquitecturas y Tecnologías de Computadores (SARTECO), durante los días del 22 al 24 de septiembre: Simulador didáctico de múltiples arquitecturas segmentadas de computadores, presentado en la sección de Docencia en Arquitectura y Tecnología de Computadores. Esqueleto paralelo genérico para computaciones stencil en sistemas heterogéneos distribuidos, presentado en las secciones de Aplicaciones de la computación de altas prestaciones, y Arquitecturas heterogéneas y modelos de programación para GPU, FPGA y aceleradores de IA. Actualmente, se está desarrollando una publicación científica que continúa el trabajo presentado en esas dos publicaciones. Ese trabajo pretende ser presentado en un congreso internacional cuya clasificación GII-GRIN-SCIE-C [71] es de tipo 2 relevante (core A). 8.2. Conclusiones del desarrollo Durante el desarrollo de este proyecto se han estudiado diferentes aspectos relacionados con Arquitectura de Computadores, lenguajes ensamblador, simulación y desarrollo software. Tras 130
CAPÍTULO 8. CONCLUSIONES todo el tiempo dedicado al estudio de esos temas, y con la experiencia ganada por el desarrollo del simulador, se ha llegado a diversas conclusiones. De entre ellas destacamos las siguientes: Los simuladores son sistemas muy complejos. Su desarrollo requiere de un conocimiento exhaustivo y detallado del sistema simulado. Por ello, la documentación del sistema simulado es un material de consulta frecuente durante todo el proceso de desarrollo. Es necesario una buena documentación de un sistema para poder desarrollar un buen simulador del mismo. La arquitectura LEGv8 surge como intento de utilizar ARMv8 en contextos de docencia. No se trata de una arquitectura real, utilizada en procesadores de forma comercial. Tampoco se ha diseñado con propósito de ser simulada. Por tanto, en muchas ocasiones presenta incoherencias o falta de documentación. Estos defectos podrían tener justificación en un contexto didáctico en el que solo pretende ser una arquitectura para ilustrar ejemplos básicos, pero se agravan en contextos que pretenden darle un uso real a la arquitectura. La arquitectura RISC-V surge en una universidad, con fines didácticos y de investigación. Por tanto, está mucho mejor diseñada de cara a ser utilizada tanto en contextos de uso real como en contextos de docencia. La documentación que presenta es más extensa y accesible. La arquitectura completa de ARMv8 expande de manera considerable las funcionalidades presentes en LEGv8. La posibilidad de extender el simulador desarrollado para soportarla parece factible, pero requeriría un tiempo de desarrollo considerable. Asimismo, es posible que la arquitectura no presente un interés didáctico suficiente como para que realizar tal implementación sea una prioridad. La convención de código de no superar los 80 caracteres por línea de código fuente tenía sentido hace años, cuando los entornos de desarrollo presentaban más limitaciones (como pantallas más estrechas). Sin embargo, a día de hoy, es una práctica que puede dificultar el desarrollo y la lectura de código, sin aportar grandes beneficios de portabilidad. Estos inconvenientes son especialmente notables en código Java, donde las líneas de código pueden llegar a ser muy extensas por múltiples motivos derivados de su sintaxis. Actualmente, los monitores de los ordenadores son más anchos, y los editores de código más versátiles. Es habitual que, en proyectos software desarrollados en equipo, se permita un mayor número de caracteres por línea de código, como 90, 100 o 120. En cualquier caso, esta limitación debería ser evaluada por el equipo de desarrollo y acomodada a las necesidades del proyecto, en vez de adoptada y cumplida de forma estricta, solo por haber sido una práctica común hace años. Java es un lenguaje de programación que presenta características de gran utilidad en muchos contextos. Con cada actualización nueva añaden funcionalidades que facilitan el proceso de programación y expanden las posibilidades del lenguaje. Sin embargo, podría resultar interesante utilizar lenguajes más actuales para el desarrollo de proyectos similares. Un ejemplo ejemplo de estos lenguajes podría ser Kotlin. Kotlin es un lenguaje de programación surgido en 2016, completamente intercompatible con Java, que trata de presentar las mismas características, actualizadas al contexto de desarrollo de software actual. Podría resultar de interés continuar el desarrollo del simulador en este lenguaje, evaluando su impacto en la facilidad de comprensión, mantenimiento y extensión del código. Cofoja, la biblioteca que permite la programación por contratos en Java, no ofrece unas características lo suficientemente beneficiosas como para justificar su uso de forma continuada 131