\vspace*{\fill}
El EDSAC (Electronic Delay Storage Automatic Calculator), de Maurice Wilkes en 1949 \cite{EDSAC}, es un ejemplo muy temprano de máquina von Neumann; para él, David Wheeler escribió Initial Orders \cite{initialorders}, un programa de carga que realizaba tres funciones:
- Manipular la cinta perforada que contiene las instrucciones.
- Traducir los mnemónicos a opcodes, así como los números en decimal a binario.
- Cargar el programa en una dirección base dada.
Initial Orders fue un proyecto que hoy podría considerarse un ensamblador, aunque en tiempo de ejecución. Poco tiempo después, Wheeler escribió un programa por el que sería ampliamente celebrado: Initial Orders 2. Permitía, por primera vez, el concepto de relocation: utilizar instrucciones con direccionamiento relativo a la base. De esta forma, era posible cambiar el punto de entrada sin ser necesario modificar manualmente todas las direcciones referenciadas.
Tras el desarrollo de la teoría computacional de Turing y von Neumann, con el ejemplo dado se puede apreciar que desde el principio de la práctica, han existido mecanismos de abstracción. Abstracción ha resultado ser la palabra que mejor define al software: todo programa se sitúa sobre un entorno que permite su ejecución.
Con las décadas se ha construido, así, un modelo de trabajo basado en capas de abstracción. Una sobre la otra, ofrecen la libertad de ignorar aspectos del hardware y mecanismos de orquestación de trabajos. Desde la década de los sesenta, la gran mayoría de estas capas se encuentra dentro del sistema operativo. Esto presenta un amplio campo de estudio de especial interés por los problemas que propone: cómo llevarlo a cabo, cómo hacerlo seguro, y cómo hacerlo rápido.
De esta forma, un sistema operativo es aquel conjunto de capas de abstracción que, por encima del hardware, permiten la ejecución de un programa, tal y como se puede apreciar en la Figura \ref{tab:whatos}. Se aporta una definición más exhaustiva en la Sección Definición.
Se propone abordar el proyecto de desarrollar un sistema operativo desde cero (salvo en lo relativo al componente que se describirá en la Sección Allocator), partiendo únicamente de un bootloader existente, y llegando a tener una serie de utilidades que ejecutar sobre una shell. Se ejecutará como un Live CD, sin posibilidad de instalarse, puesto que extender los drivers de almacenamiento para soportar discos duros es una tarea compleja que requiere tiempo, y tiene poco interés en comparación con el desafío de desarrollar el propio sistema.
Los puntos de interés son:
- Seguir un diseño basado en una arquitectura microkernel para maximizar la modularización del trabajo, así como la seguridad.
- Explorar en profundidad y resaltar la importancia de un mecanismo de comunicación entre procesos que ha pasado desapercibido en la historia de los sistemas operativos no distribuidos: las llamadas a procedimientos remotos (RPC).
- Prestar especial atención al modelo de protección, aportando mejoras sobre los existentes en otros proyectos, tanto por parte de la comunicación entre procesos como del sistema de archivos.
Mi primer programa lo escribí a los siete años. Fue un script en Batch, el lenguaje que usan los archivos con extensión .bat
en Windows (herencia de MS-DOS). Con el paso de los años, aprendí muchos otros lenguajes: Visual Basic, Java, PHP, Javascript, Python… Más adelante, con más experiencia, otros como C y C++. Debido a la vaga idea que tenía sobre el funcionamiento interno de un computador, resultaron incontables los intentos fallidos de aprender ensamblador de x86 por mi cuenta. En el primer cuatrimestre del primer curso de la carrera, allá por octubre de 2018, con la experiencia de todos estos años, conseguí al fin entender el funcionamiento básico de un x86 y, con esto, escribir un hola mundo que podía comprender.
El uso de ensamblador, siendo no más que un conjunto de macros y mnemónicos, está muy acotado en la actualidad. Se puede usar para optimizar secciones importantes de código que han de ejecutarse rápido, pero a cambio se pierde portabilidad entre arquitecturas y, dependiendo de cómo de concreta sea la operación a optimizar, también entre generaciones de procesadores. Además, solo es aplicable a lenguajes compilados, y en un mundo en el cual los programas siguen una tendencia clara de volverse independientes de la máquina (primero con Java \cite{java}, últimamente con otros como Electron \cite{electron}), son muy pocas las situaciones en las que resulta útil.
Sin embargo, existe un proyecto, necesario aún hoy en día, que únicamente puede ser escrito en ensamblador. Se trata del stage 1 de un bootloader: las primeras instrucciones controlables por software que ejecuta un procesador después de completar la inicialización más básica del hardware. Con tal de aliviar el esfuerzo necesario para escribir este lenguaje, los bootloaders intentan preparar el entorno más simple posible que permita pasar a C o C++, y de aquí nace el stage 2. Los núcleos de los sistemas operativos necesitan también secciones de ensamblador para realizar operaciones de bajo nivel, como las rutinas de interrupción o los cambios de contexto.
Todo esto se une a un interés desde pequeño de crear un sistema operativo, aún con la simplificada idea que era capaz de entender por entonces. Un sistema operativo es el mayor proyecto de software, definido por algunos como la gran frontera o el gran pináculo de la programación \cite{frontier}.
De esta manera, en el verano de 2019 me adentré en el mundo del desarrollo de sistemas operativos. Proyectos personales de tal magnitud no se comienzan de forma intencionada, sino que, con el tiempo, y cientos de horas de lectura y pruebas, uno se da cuenta de que ya dispone de todos los conocimientos necesarios para intentar aproximar el problema de forma seria y con asertividad. Un sistema operativo de prueba, y con el objetivo de aprender, comenzó dicho verano: jotadOS (posteriormente renombrado a jotaOS). Se trataba de un SO con IA-32 como target, con un kernel monolítico, escrito en C (posteriormente, C++), con su propio bootloader escrito en ensamblador (JBoot \cite{jboot}), y sin mucho razonamiento e intenciones detrás más que las de hacer cosas.
Este proyecto llegó a tener una complejidad elevada: implementaba una pila de almacenamiento y tenía una shell megalítica (dentro del kernel, para demostrar la funcionalidad). En junio de 2020, cuando mis conocimientos ya habían pasado cierto umbral, aparecieron dudas sobre las decisiones más fundamentales, y caí en la cuenta de que el proyecto no era lo que quería que fuera. Ahora que conocía los conceptos y los había puesto en práctica, sabía lo que quería:
- Un SO con arquitectura microkernel, puesto que estaba dispuesto a sacrificar velocidad por belleza.
- Cambiar el target a x86-64, puesto que aporta soluciones mucho más elegantes, modernas, y rápidas que IA-32. Además, el espacio de direccionamiento de 64 bits permitía un ASLR funcional.
- Abandonar el bootloader propio. Un bootloader es difícil de mantener, pues requiere gran parte de los drivers que hay en un SO convencional, con lo cual hay que escribir el mismo código dos veces. Además, JBoot, escrito en ensamblador, era especialmente difícil de manejar. En retrospectiva, agradezco haberlo hecho en su momento, pues ahora conozco a la perfección la secuencia de arranque de x86, pero llegó el momento de cambiar.
- Tener como punto central un modelo de protección robusto.
No se abrume con los conceptos referenciados: serán explicados más adelante. Estos cambios resultaban tan sustanciales que, en enero de 2021, consideré que valía la pena hacer borrón y cuenta nueva. Por aquel entonces, el SO era un único repositorio, con lo que, el 1 de febrero de 2021, se cambió la rama principal, y se renombró la anterior a old. Allí sigue a día de hoy \cite{old}.
Teniendo el 1 de febrero un repositorio vacío, comenzó el desarrollo. Primero, un printf
, luego una klibc simple (Kernel Standard Library, con estructuras de datos básicas), y poco a poco se fueron construyendo capas sobre capas de abstracción. Pocos meses después, en mayo de 2021, tras debatirme durante unas semanas, decidí que quería que esta idea de proyecto, aún en pañales, fuera mi trabajo de fin de grado, en lugar de dejarlo para más tarde (tesis doctoral, si la terminara haciendo), principalmente porque, aunque este trabajo tiene sus pinceladas de investigación, casa más con un proyecto de ingeniería.
En el plazo desde febrero a mayo de 2021 no me dió tiempo de hacer mucho, tenía una funcionalidad muy básica del kernel, que no llegaba ni de cerca a tener la capacidad de cargar programas. Muchas decisiones no estaban siquiera aún tomadas.
El trabajo aquí expuesto es el fruto de una vida de aprendizaje.
Con tal de evitar malentendidos y siglas imposibles de encontrar en internet, se utilizarán términos en inglés, especialmente en aquellos que tienen una abreviación asociada. La terminología se utiliza, así, en español e inglés indistintamente. En este último caso, el género de los sustantivos se eligirá de forma arbitraria, pero se mantendrá consistente durante todo el texto.
En la primera parte de este documento se han incluido un listado de términos y otro de abreviaciones, que exponen de forma breve los conceptos referenciados.
Un sistema operativo se comienza y se abandona, jamás se termina. Conforme pasan los años, los modelos de hardware y periféricos necesarios para el funciomiento más básico aumentan en cantidad y se vuelven más complejos, con lo que la tarea de desarrollar un sistema operativo desde cero se vuelve más y más compleja.
- UNIX v1, de 1970, con el bootloader, el kernel, y la shell, contaba tan solo con 4768 líneas de ensamblador \cite{v1lines}, que corresponderían a muchas menos en C.
- La primera versión pública de Linux (0.01), de 1991, estaba formada por 8413 líneas de C y 1464 de ensamblador \cite{linux001}.
Hoy en día, ambas resultan insuficientes para tener siquiera un bootloader versátil. La diferencia de tiempo también es considerable: hoy, tener un sistema operativo potente y estable podría llevar unos cinco años a un gran grupo de personas. Por todo esto, es crítico tener en cuenta desde el primer momento que no se pretende tener un producto final ni estable.
Teniendo como objetivo general de este proyecto consolidar, aprender, y poner en práctica los conocimientos más importantes relacionados con los principios y mecanismos de abstracción que proporciona un sistema software sobre el hardware de un computador, mediante la propuesta y desarrollo de sistema operativo, se plantean los siguientes objetivos primarios específicos:
- <<OP1>>Contribuir públicamente al código del bootloader usado para implementar la posibilidad del arranque de un kernel desde un CD.
- <<OP2>>Diseñar un modelo de protección sobre la comunicación entre procesos con granularidad fina.
- <<OP3>>Diseñar y desarrollar un microkernel con RPC como mecanismo de comunicación.
- <<OP4>>Crear un cargador de programas funcional fuera del kernel.
- <<OP5>>Desarrollar una biblioteca estándar del sistema para facilitar la comunicación de los procesos con el kernel, así como entre ellos, e implementaciones de diversas estructuras de datos abstractas.
- <<OP6>>Desarrollar servicios misceláneos para el funcionamiento del sistema, como el driver de terminal.
- <<OP7>>Desarrollar una pila de almacenamiento completa, incluyendo el diseño de un sistema de archivos propio.
Se referenciarán en este capítulo como OPx
, donde x
es el índice expuesto.
A partir de los objetivos primarios se puede tener el funcionamiento base del sistema operativo a desarrollar. Se consideran dos objetivos secundarios como complementarios de los anteriores:
- <<OS1>>Desarrollar un driver de teclado y una shell.
- <<OS2>>Desarrollar una serie de herramientas, usables desde la shell, para interactuar con el sistema.
- <<OS3>>Implementar un conjunto de tests unitarios.
Se referenciarán en este capítulo como OSx
.
Con la intención de detallar los aspectos de funcionalidad interactuable del sistema por un usuario final, se enumeran los siguientes requisitos funcionales:
- El proyecto debe contar con una shell para permitir la ejecución de programas.
- Son necesarias sendas utilidades para la interacción con el sistema de archivos:
- Creación de archivos y directorios.
- Lectura y escritura de archivos.
- Listado de contenidos de directorios.
- Alteración de permisos.
- Se requiere la existencia de herramientas para el manejo de usuarios:
- Creación.
- Listado.
- Modificación de los permisos de los que dispone.
- Descripción; esto es, listado de los permisos.
- Cambio de usuario.
- Es necesaria una utilidad para mostrar la memoria usada por el sistema.
- Se debe contar con un programa que efectúe tests unitarios.
Durante el proceso de realización de los objetivos con tal de alcanzar los requisitos funcionales, se deberán tener presentes en todo momento los siguientes requisitos no funcionales en forma de principios:
- El sistema debe ser seguro dentro de los ámbitos especificados más adelante en la Sección Mecanismos generales de seguridad, así como por medio de un modelo de protección que limite las acciones de las tareas, y mediante la protección de información en el sistema de archivos entre los distintos usuarios que puedan existir.
- Dado que no se pretende construir un producto final, siempre que sea posible y apropiado, el proyecto se debe centrar en dar soluciones simples a problemas complejos, y en todo momento será necesario alcanzar un equilibrio entre velocidad y dificultad conceptual.
- Debido a la magnitud del proyecto, es de máxima prioridad la mantenibilidad y reusabilidad del código, así como su legibilidad. El proceso de añadir en el futuro una nueva funcionalidad debe ser asequible y requerir pocos o ningún cambio en los demás subsistemas.
Se profundizará en los conocimientos de las siguientes asignaturas impartidas en el grado:
- Sistemas Operativos asienta las bases del área desde su perspectiva más teórica.
- Estructura de Computadores expone el funcionamiento interno de un procesador, así como el ensamblador de x86.
- Sistemas Concurrentes y Distribuidos presenta los mecanismos de sincronización entre procesos.
- Estructuras de Datos explica las implementaciones y uso de diversas estructuras de datos abstractas.
En esta sección:
- Se divide el proyecto en sus distintos subproyectos y su correspondencia a los objetivos enumerados a lo largo de la Sección Objetivos.
- Se estima el número de horas dedicadas a cada uno.
- Se muestra cómo se han distribuido dichas horas a lo largo del tiempo.
El proyecto llevado a cabo se puede dividir en ocho grandes componentes:
- Contribución al bootloader. Ajena al proyecto en sí, esto es, al código entregado, pero necesaria para la realización del mismo. Corresponde al objetivo [OP1].
- Kernel. Núcleo del sistema operativo, el mayor subproyecto, que contiene sobre un 33% de las líneas escritas (cálculo que proviene de la Sección Proyectos), y contiene la mayor parte del trabajo de diseño. Corresponde a los objetivos [OP2] y [OP3].
- Cargador de programas (loader). Ha requerido una gran cantidad de tiempo y, por ello, es pertinente separarlo del resto según los criterios de esta sección. Este componente corresponde al objetivo [OP4].
- Biblioteca estándar. Después del kernel, el siguiente mayor subproyecto, con un 31% de las líneas. Es especialmente relevante pues contiene la STL, con implementaciones de estructuras de datos abstractas que han conllevado muchas horas de depuración. Además, una parte considerable de la STL se conserva desde el SO antiguo (ver Sección Motivación e historia), con lo que es necesario tenerlo en cuenta para ilustrar la distribución a lo largo del tiempo más adelante. Corresponde al objetivo [OP5].
- Pila de almacenamiento. Esto incluye desde los drivers más fundamentales para la comunicación con interfaces de almacenamiento y lectura de sectores hasta la abstracción sobre puntos de montaje. Corresponde al objetivo [OP7].
- Interacción. Subsistemas como el driver de teclado, la shell, y todas los programas interactivos (utilidades, como
ls
) se incluyen aquí. Corresponde a los objetivos [OS1] y [OS2]. - Miscelánea. Incluye el trabajo que no tiene cabida en los otros componentes, como el driver de terminal, los servicios del entorno de ejecución (como el registro), los tests unitarios, y los metaprogramas (helper, CI/CD…). Corresponde a los objetivos [OP6] y [OS3].
- Memoria. La escritura de este mismo documento se incluye en la planificación.
Se ha realizado una estimación de las horas dedicadas a cada subproyecto, a partir de sus partes, que se encuentra en la Tabla \ref{horas}. Nótesen tres puntos:
- No se ha hecho una medición durante el desarrollo, se trata de una estimación a posteriori. Esta estimación proviene de la relectura de mensajes de una bitácora personal escrita en tiempo real durante el proyecto, que incluyen una marca de tiempo. Se han consultado los más de 6.000 mensajes escritos, y se ha hecho el esfuerzo de aproximar el tiempo dedicado.
- No se consideran las horas dedicadas al interés previo en el área; es decir, no se incluye el trabajo de aprendizaje del sistema operativo de prueba descrito en la Sección Motivación e historia, en parte por tratarse de un proyecto distinto, en parte porque sus horas son inestimables.
- Sí se anota, sin embargo, todo el trabajo de investigación, como la lectura de estándares o artículos, en las partes correspondientes al proyecto descrito en este trabajo. Esto incluye, por ejemplo, el tiempo dedicado a diseño frente a mi pizarra en blanco, o la búsqueda de referencias de la memoria en cada uno de sus capítulos.
Para finalizar la sección, se incluyen tres diagramas de Gantt que representan cómo se ha repartido el esfuerzo a lo largo de los años de trabajo: la Figura \ref{gantt2020}, para 2020, la Figura \ref{gantt2021}, para 2021, y la Figura \ref{gantt2022}, para 2022. Para poder ilustrar el diagrama en la página, la unidad mínima de trabajo representado es de medio mes. Si bien lo habitual en los diagramas de Gantt es organizarlos por fases (estudio, análisis, diseño, e implementación), aquí se encuentran organizados en base al desarrollo de cada componente, puesto que en sistemas operativos es difícil distinguir entre diseño e implementación: son fases muy cercanas y dependientes que se llevan a cabo a la vez.
Llevar a cabo el sistema operativo no tiene coste monetario. La gran mayoría de especificaciones necesarias para llevar a cabo la implementación son de libre acceso, y los artículos para comprender el estado del arte lo son también. Aquellos que no, son accesibles por las licencias de la universidad.
Los costes, sin embargo, sí aparecerían de querer aumentar la garantía del funcionamiento en distintas máquinas. Para empezar, hipervisores: herramientas que permiten instalar y ejecutar sistemas operativos como máquinas virtuales en otros sistemas operativos anfitrión. Son de especial utilidad en el proyecto puesto que aportan una forma rápida de arrancar el sistema y comprobar su funcionamiento. Se destacan tres:
- VirtualBox \cite{virtualbox}, que es gratuita, con lo que se puede ignorar en el aspecto de costes.
- Lo mismo ocurre con qemu, un emulador \cite{qemu}, y KVM, el hipervisor incluido en el kernel Linux \cite{kvm}.
- Es diferente el caso de VMWare. En el caso de su versión Workstation 16 Pro, el precio es de $199 \cite{vmware}.
Los costes se elevan considerablemente cuando se tiene en cuenta el hardware real. En este caso, para tener una garantía completa, sería necesario probar todos los procesadores x86, tanto de Intel como de AMD, desde la mínima generación soportada (Intel Core 2) hasta la actual. A su vez, esto requeriría distintas placas base para acomodar los chips.
Sin embargo, conociendo que en x86 la retrocompatibilidad tiene la máxima prioridad, sería suficiente con probar el procesador de generación mínima que soporta cada una de las funcionalidades utilizadas por el sistema operativo, centrándose solo en Intel. En la Sección Generación mínima se expone cuáles son y por qué, pero he aquí un adelanto:
- Intel Core 2, microarquitectura Core.
- Intel Core de 3ª generación, microarquitectura Ivy Bridge.
- Intel Core de 5ª generación, microarquitectura Broadwell.
Estos productos están descatalogados, con lo que se presentan dos opciones:
- Buscar usuarios en la comunidad que posean máquinas de estas generaciones y estén dispuestos a probarlas.
- Comprarlas de segunda mano. Accediendo y consultando en ebay, se pueden obtener portátiles de cada generación especificada por aproximadamente 100€ \cite{pc1} \cite{pc2} \cite{pc3}.
Se acota, de esta manera, el coste máximo a 199€ + 3
Teniendo todo esto, falta el coste del personal. Es difícil estimar el salario por hora de un trabajo de este tipo, pues se trata de un proyecto que a penas se lleva a cabo, y no existe ninguna posición de diseño o desarrollo de sistemas operativos actualmente en el mercado, tampoco acotando a desarrollo de núcleos. La mejor estimación es la siguiente: considerando tal como ha ocurrido durante el desarrollo del proyecto, la contratación de un ingeniero informático con amplia experiencia técnica pero con falta de experiencia profesional, y la dificultad de la tarea a realizar, el salario bruto podría corresponder a 35€/hora. De esta manera, si se han trabajado 1104 horas como se expone al final de la Tabla \ref{horas}, el coste salarial ascendería a 38.640€.
Sumando los costes de verificación, los costes totales aproximados en un escenario real serían de 39.039€.
Este documento se organiza en un total de seis capítulos:
- El Capítulo Introducción y Objetivos, Introducción y Objetivos, se ha enfocado en presentar el problema a resolver, detallar los objetivos a cumplir, y mostrar la planificación en bases a las fases generales y tareas necesarias, así como los costes de llevarlas a cabo.
- El Capítulo Fundamentos, Fundamentos, describe los fundamentos necesarios para comprender los diferentes aspectos relacionados con el desarrollo de sistemas operativos, proporcionando una aproximación sobre la magnitud y complejidad del problema y lógica detrás de las soluciones.
- El Capítulo Estado del Arte, Estado del Arte, analiza el estado del arte de los proyectos próximos a este, para averiguar qué soluciones se han dado a los problemas aquí descritos en el pasado.
- El Capítulo Propuesta de Sistema Operativo, Propuesta de Sistema Operativo, con el estudio e investigación llevados a cabo, describe la solución para resolver el problema a abordar, incluyendo la definición de las ideas originales.
- El Capítulo Diseño, Implementación, y Pruebas, Diseño, Implementación, y Pruebas, describe, a partir de la propuesta presentada en el capítulo anterior, los detalles relacionados con el desarrollo del sistema operativo. Contiene capturas del uso del funcionamiento y operación del proyecto.
- Para terminar, en el Capítulo Conclusiones y Trabajo Futuro, Conclusiones y Trabajo Futuro, se hace una retrospectiva del trabajo realizado, y se menciona el trabajo futuro que podría dar continuidad al realizado en este proyecto.
Este documento incluye adicionalmente dos apéndices que se describen brevemente a continuación:
- El Apéndice Margen de Mejora, Margen de Mejora, contiene una lista con aquellos detalles que han quedado en el tintero por disponer tan solo de una cantidad finita de tiempo. Sus entradas son ampliamente referenciadas a lo largo de los capítulos Propuesta de Sistema Operativo y Diseño, Implementación, y Pruebas.
- El Apéndice Cómo Compilar, Cómo Compilar, explica cómo llevar a cabo el proceso de generación de un ISO a partir del código fuente.
Alfred Aho, autor del libro más importante sobre compiladores, Compilers: Principles Techniques and Tools \cite{dragonBook}, así como un libro referente sobre algoritmos, Data Structures and Algorithms \cite{aho-alg}, comenzó una conferencia en 2015 con la siguiente afirmación:
Tal y como decía Knuth en The Art of Computer Programming, [un algoritmo] no es más que una serie finita de instrucciones que termina en un tiempo finito. […] En Columbia, usamos dos libros de texto: uno usa esta definición; el otro, afirma que un algoritmo no tiene necesariamente que parar para todas las entradas. Así, los computólogos no pueden estar de acuerdo ni en el término más fundamental del área. — Alfred Aho \cite{aho-conf}
Si algoritmo es una palabra cuya extensión es difícil de delimitar, hacerlo para sistema operativo resulta una tarea más complicada aún. Un sistema es todo aquel conjunto de bloques relacionados entre sí con el propósito de emerger un todo. La intuición es sencilla, se explica en primero de carrera en la Universidad de Granada: programa o conjunto de programas que controla la ejecución de aplicaciones y actúa como interfaz entre el usuario y el hardware.
Si bien esta definición es correcta, y muy certera en el uso del concepto de abstracción, no establece límites. Existen definiciones distintas; por ejemplo, Andrew Tanenbaum en Modern Operating Systems aporta dos que no son mutuamente excluyentes: sistema operativo como máquina extendida, en el sentido de pila de capas de abstracción, y como gestor de recursos \cite{tanen}.
Se trata de un debate abierto al cual el habla popular no ayuda: no es inusual escuchar a alguien ajeno al campo referirse a Linux como un sistema operativo, a pesar de ser un núcleo. Por otra parte, la posición de la Free Software Foundation, y especialmente la de su antiguo portavoz Richard Stallman, de ridiculizar al proyecto como una minúscula parte del sistema GNU \cite{fsf}, también resulta inadecuada, en especial sabiendo que existen motivos de conflicto de interés entre ambos proyectos.
En el mundo del hobby osdev, es decir, el de aquellos programadores que se dedican a escribir sistemas operativos como actividad recreativa, al cual yo he pertenecido durante varios años y dentro del cual he hecho grandes amigos, también existe esta disputa: es común encontrar a expertos en estos grupos que no consideran a DOS como un SO por no ofrecer un kernel con la suficiente abstracción del hardware.
Como definir el término parece ser una batalla perdida, es infructuoso dedicarse a lucharla en un trabajo de esta índole, y se tomará una postura de mente abierta, en la que se aceptarán como partes de un sistema operativo todas las capas de abstracción genéricas por debajo de una utilidad (piense en el bloc de notas), así como aquellos programas que actúen únicamente como vista para interactuar de forma directa con una de las capas, como ls
en GNU, o dir
en MS-DOS.
Al contrario de lo que puede parecer, un sistema operativo está escrito con el objetivo de soportar una arquitectura o un conjunto de ellas. De forma general, se intenta escribir el código más portable posible, pero partes críticas como ciertas rutinas del kernel, así como la totalidad del bootloader, son, por pura definición, no portables, y son necesarias versiones distintas para cada arquitectura a soportar (denominadas target architectures, o targets para abreviar). Existe el ejemplo extremo de NetBSD, cuyo objetivo es soportar la mayor cantidad de arquitecturas posibles (en el momento de redactar esto, 8 primarias y 49 secundarias \cite{netbsd}). En el otro extremo, se encuentra Windows 11, con soporte completo para únicamente x86-64 y ARM64. Los sistemas operativos hechos por un grupo reducido de personas, así como los hechos con un propósito muy concreto, suelen intentar soportar solo una. En este caso, se trata de x86-64, con lo que los fundamentos prácticos explicados en este documento se enfocarán en dicha arquitectura.
Habiendo establecido una definición, es posible distinguir cuatro partes fundamentales, que en muchas ocasiones se pueden encontrar mezcladas, y en otras extremas pueden faltar. Son: kernel, drivers, bibliotecas, y utilidades. En esta sección se hará un repaso por su significado, se darán ejemplos, y se enunciarán sus partes de haberlas.
El kernel de un sistema operativo, traducido como núcleo, es el soporte sobre el cual reposa todo el sistema. Es el primer software que se ejecuta fuera del bootloader, y se pueden destacar varios objetivos:
- Manejar los distintos recursos de bajo nivel.
- Hacer emerger el concepto de tareas.
- Interconectar tareas y drivers.
Es importante profundizar sobre cada uno de estos aspectos. Para empezar, el hardware proporciona una serie de recursos esenciales para todo programa: memoria, canales de interconexión, periféricos… De todos ellos, la memoria es el único esencial para tener un sistema (discutiblemente aburrido, pero completo). Los procedimientos de reserva y liberación de memoria son manejados por el kernel. Se profundizará en este tema en la Sección Memoria.
En todo sistema operativo moderno (especialmente aquellos que pertenecen a la familia de los multiprogramados) existe el concepto de tarea: una unidad de código y datos que se comunica con diversas partes del sistema. El kernel es el encargado de montarla en memoria, y, usualmente, intercambiarla con otras en cortos periodos de tiempo para dar la impresión de que se están ejecutando simultáneamente, cuando no necesariamente tiene que ser así. De este concepto surge la mayor parte de teoría escrita sobre sistemas operativos, y se suele considerar la parte más importante. Se profundizará mucho en este apartado durante todo el trabajo, pero en la Sección Tareas se encontrarán las primeras pinceladas.
Por último, un kernel conecta tareas entre sí y con los drivers presentes. Las tareas se comunican mediante un concepto llamado IPC (Inter-Process Communication), de las cuales existen varios tipos no necesariamente excluyentes:
- FIFOs. Son flujos de bits que funcionan como tuberías (pipe, en inglés). Usados por primera vez en UNIX, y mantenidos en sus sucesores.
- Paso de mensajes. Consiste en hacer envíos de estructuras a ciertos puntos de recepción de la tarea que actúa como servidor. Su uso para mensajes largos ha quedado en desuso, pues se conoce que la copia de grandes mensajes ralentiza mucho el sistema.
- RPC, Remote Procedure Call. En este tipo de IPC, una tarea llama a una función de otra (que puede encontrarse en un otro computador) como si se tratara de una suya propia. Es usado por el sistema operativo de este proyecto, así como partes internas de NT bajo el nombre de LPC (Local Procedure Call). A diferencia del resto, este es un procedimiento síncrono: la tarea A entra dentro de B, con lo cual la ejecución de A no continúa hasta que la rutina de B haya terminado.
- Memoria compartida. Presente en la gran mayoría de sistemas operativos modernos, el concepto de compartir memoria física es esencial para ocasiones en las que hay que transmitir una gran cantidad de datos entre tareas con mínima latencia.
Un kernel (o, al menos, parte de él) siempre se ejecuta en lo que se conoce de forma genérica como modo supervisor, siendo su contraparte el modo usuario. El supervisor tiene acceso a la totalidad de la CPU: todos los registros, todas las instrucciones, y toda la memoria. En el modo usuario, se restrigen (muchas veces granularmente) estas capacidades.
Gran parte del trabajo del kernel es recibir peticiones. Algunas de ellas son generadas por chips ajenos a la CPU, y se denominan interrupciones hardware. De estas hay dos tipos: enmascarables, relativas al hardware no esencial, y no enmascarables. Otras peticiones son causadas por el software, y generalmente se distinguen dos tipos: excepciones (la más simple: la división por cero), que ocurren cuando, tras el error, no se puede tener un estado válido del sistema, y las llamadas al sistema (en inglés, system calls, o syscalls para abreviar), que hacen peticiones explícitas al kernel, muchas de ellas relativas a la adquisición de recursos. Si bien toda excepción es una petición de tipo interrupción, una llamada al sistema puede implementarse de otra manera, con instrucciones específicas. En la Figura \ref{fig:peticiones} se encuentra una representación de estos conceptos.
Existen dos tipos de kernels fundamentales: monolíticos y microkernels.
Los monolíticos se caracterizan por tener todos los drivers dentro. Esto hace que la comunicación entre ellos sea rápida durante la ejecución, aunque, de haber un cambio en uno de ellos, será necesario enlazar de nuevo todo el kernel. Salvo finas protecciones que los kernel monolíticos suelen crear al arranque, un fallo de programación, por poco grave que sea, puede promocionar a un fallo irrecuperable del kernel (concepto conocido como kernel panic) \cite{monolitico}. Además, un driver malicioso podría tomar control del kernel y, por tanto, de todo el sistema, haciéndose a sí mismo invisible en el proceso; este tipo de malware se conoce como rootkit.
Los microkernels se caracterizan por lo opuesto: separan los drivers en tareas independientes siempre que sea posible, en espacio de usuario (userspace, en inglés). La comunicación entre ellos es más lenta, pues una petición a un driver requiere cambiar de una tarea a otra, en un proceso llamado cambio de contexto, muy costoso en recursos. A cambio, un fallo en uno de los drivers no tiene por qué resultar terminal, y la tarea correspondiente puede reiniciarse con la esperanza de que siga funcionando sin que vuelva a ocurrir ese comportamiento anómalo. Esto les aporta más robustez, así como seguridad: un driver nunca se ejecutará en modo supervisor, aunque sí puede tener acceso al hardware y causar problemas por ese camino. Los microkernels son conceptualmente más simples, pues mantienen el software separado en proyecto sencillos sin crear un delicioso plato de código spaguetti en el que un driver llama a otro localmente y sin posible detección, registro, o control de privilegios: todos están al mismo nivel. A cambio, son más complejos de escribir, pues parten de un entorno en el que no hay funcionalidad, y han montar todo un sistema en base a eso (¿Cómo cargar el programa que carga los programas?) \cite{ukernel}. Este proceso de hacer emerger un sistema de la nada se denomina bootstrapping.
Con el objetivo de hacer el plato italiano menos apetitoso y alcanzar un equilibrio entre separación de drivers y velocidad, surgen los kernels híbridos. La mayoría de kernels comerciales los utilizan, entre ellos NT (Windows), Linux, y XNU/Darwin (macOS). Los drivers separados del kernel se denominan módulos, y se cargan en tiempo de ejecución desde el sistema de archivos: o bien como una tarea como los microkernels, o bien introduciéndolos en el contexto del kernel. Por esto mismo, solo los drivers que no resultan esenciales para el funcionamiento del sistema pueden cargarse modularmente. Nótese que esta decisión se centra en aliviar el tamaño del código fuente, así como del binario final, del kernel, y ofrecer carga y descarga de módulos después del arranque, pero no está guiada por la seguridad.
En la Figura \ref{fig:tiposkernel} se encuentran tres diagramas arquitectónicos, simplificados, de cada tipo de kernel.
Un driver (en español, controlador o manejador de dispositivo) es un programa que implementa una capa de abstracción sobre un dispositivo o concepto de bajo nivel \cite{driver}. En un kernel monolítico, no es más que una colección de funciones y estructuras. En un microkernel, se ejecuta como una tarea independiente.
Existe un driver por dispositivo físico al que se quiere conectar, así como otros que agrupan otros drivers y crean abstracciones virtuales. Por ejemplo, un driver de IDE, correspondiente a los distintos dispositivos ATA conectados a la placa base (discos duros clásicos), puede ser accedido mediante otro driver que agrupe los dispositivos físicos y les dé nombres virtuales, como sda1
en el caso de Linux.
Sin drivers difícilmente puede haber un sistema operativo. Se suele considerar que el driver de vídeo, encargado de mostrar texto o imágenes por la pantalla, es esencial para un SO útil. Dependiendo del enfoque y el objetivo del proyecto, puede contar con unos y no otros. Si el SO está principalmente enfocado para servidores, puede no contar con un driver de teclado, y en su lugar tener una pila de red (network stack) amplia que permita a otros dispositivos comunicarse con el sistema. Si está enfocado a ser usado por usuarios inexpertos, un driver de vídeo que pueda mostrar gráficos es, hoy en día, imprescindible.
A la hora de escribir un driver, se recurre a la especificación del hardware. En ocasiones, esta especificación no es pública y se mantiene como secreto corporativo. En estos casos, es el fabricante el que se encarga de escribir el controlador para un sistema operativo concreto, generalmente Windows. A veces, el fabricante no publica la especificación, pero sí el código fuente del driver, y el código resulta ilegible, pues su propósito no es ser comprendido. Como gran exponente de esto último cabría destacar el archivo intel_display.c
de Linux, escrito, naturalmente, por Intel, y que implementa parte del driver en un solo archivo de más de 10,000 líneas \cite{badlinux}.
Por esto último, hay grupos de dispositivos cuyo soporte resulta inalcanzable para un desarrollador de sistemas operativos independiente sin llegar a métodos como la ingeniería inversa. Ejemplos de esto son Wifi y la aceleración gráfica en 3D.
Una biblioteca (library en inglés) es una API que proporciona una abstracción sobre un concepto; por ejemplo, permite a un programa la comunicación con otra parte del sistema de forma sencilla. Pueden estar enfocadas en envolver el funcionamiento de un driver, creando funciones que se comunican con él para hacer el proceso más transparente al programador. También pueden estar escritas con un propósito de más alto nivel, como realizar operaciones matemáticas sobre enteros de múltiple precisión.
Cuando un sistema operativo planea soportar los ejecutables producidos por un lenguaje, construye para él una biblioteca de comunicación con el kernel y el resto del sistema: se denomina la biblioteca estándar (stdlib). El ejemplo más claro es C, para el que GNU aporta la GNU libC
\cite{libc}, y Windows la API del sistema.
Las bibliotecas se juntan con los archivos objeto en el proceso de enlazado. Este proceso se puede realizar de dos maneras: estático y dinámico.
- En el estático, las bibliotecas se adjuntan en el ejecutable. Esto hace que el binario (ejecutable) resulte independiente del entorno, pues lleva con él todo lo que necesita.
- En el dinámico, las bibliotecas se referencian por su nombre y uso, y es el cargador de programas, en ejecución, quien se encarga de resolver las direcciones mediante un proceso denominado relocation. Esto reduce el tamaño del binario, y permite una actualización global de una biblioteca sin volver a enlazar todos los programas.
Una herramienta (tool) o utilidad (utility) es todo programa con una función simple que se relaciona con el kernel. Permiten una vista sobre algún aspecto del sistema, y generalmente lo hacen de forma legible para humanos (human-readable). Son programas a los que en la mayoría de ocasiones se accede mediante la shell (concha), cuyo nombre, originario de UNIX, referencia a cómo oculta en su interior una perla (el kernel). Las utilidades también se pueden combinar con otras en scripts, creando complejos procesos encadenados. UNIX inventó el concepto de pipes, mediante los cuales la salida de un programa es conectada a la entrada de otro, permitiendo así una armonía de interconexión entre utilidades \cite{unix}.
Con los años, especialmente en la comunidad Linux, este concepto ha ido en decadencia, y son pocas las utilidades que permiten este tipo de interconexión sin hacer ningún retoque.
Aquí aparece la filosofía UNIX: hacer solo una cosa, y hacerla bien, refiriéndose a que las utilidades deben mantenerse simples, y en lugar de tener una herramienta para varios propósitos, tener una herramienta para cada acción. En el entorno Linux, y especialmente en las utilidades GNU, este concepto nunca ha existido. El código fuente de ls
es un archivo de cinco mil líneas \cite{ls}.
Nótese que existen builtins (también llamadas comandos) que se comportan como utilidades a pesar de no serlo. En su lugar, son órdenes a la shell que se gestionan internamente sin pasar por ejecutar un programa. Ejemplos conocidos son cd
, echo
, o clear
.
Conociendo las partes más esenciales de un sistema operativo, existen ciertas áreas que resultan de interés teórico. Generalmente, son las relativas a las tareas, y son los conceptos que en la Universidad de Granada se impartieron en la asignatura Sistemas Operativos. En esta sección se hará un breve repaso de todas estas áreas, con tal de contextualizar el resto del trabajo: las tareas, el scheduler, y el sistema de archivos.
En la Sección Kernel se explicó superficialmente el concepto de tarea, y este capítulo trata de profundizar en él. Lo más fundamental: tarea es el nombre teórico del concepto. Se utiliza el término proceso para referirse a un binario cuando está cargado en memoria. En sistemas MT (Multi-threading), la terminología es thread (traducido como hilo o hebra), de las cuales pueden estar ejecutándose varias que comparten gran parte del contexto concurrentemente.
La forma de representación interna de una tarea en el kernel es el PCB (Process Control Block), también llamado TCB (Thread Control Block) en sistemas multithreading, una estructura que contiene todo lo necesario para su funcionamiento, incluyendo su contexto y sus regiones de memoria estáticas (cargadas del binario) o las dinámicas como la pila y el heap. Las tareas son referenciadas por su PID (Process Identifier), un entero sin signo de 16, 32, o 64 bits \cite{pcb}.
Toda tarea se crea y se ejecuta. La gran mayoría terminan, no se ejecutan indefinidamente y, en los sistemas operativos modernos, además se pausan y reanudan. El proceso de reanudar una tarea o ejecutarla por primera vez se lleva a cabo por una rutina llamada el dispatcher. Esta se encarga de realizar el cambio de contexto, es decir, recuperar el estado del procesador (registros y flags) en el que se encontraba la tarea (o el inicial de ser arrancada), así como su tabla de páginas (vista propia de la memoria). Después, realiza un cambio a modo usuario y salta al punto donde se pausó la tarea, de haber sido pausada, o el punto de entrada (entry point) de ser iniciada.
En UNIX, la primera tarea que se ejecuta es init, con PID 1 \cite{unix}. En Linux, concretamente, existen varios programas a elegir, siendo el más usado systemd \cite{systemd}, y en menor medida otros como OpenRC \cite{openrc}, runit \cite{runit}, o SysV init \cite{sysvinit}. Esta tarea inicia todas las otras, y desde entonces toda tarea tiene un padre, lo cual genera un árbol de hijos trazable. El proceso de creación de una tarea en UNIX se realiza mediante un procedimiento de fork
, por el cual la tarea hace mitosis y forma dos partes completamente independientes, seguida de exec
, por el cual sustituyen todas sus estructuras del PCB por las del binario cargado como parámetro \cite{unix}. En Windows, este procedimiento es atómico, y se realiza mediante una llamada a la API a la función CreateProcess
\cite{createprocess}.
Una tarea generalmente se encuentra en uno de tres estados: preparada para ser ejecutada, bloqueada esperando algún recurso, o ejecutándose. Cuando un programa se está ejecutando, se dice que está consumiendo tiempo de CPU. En la Figura \ref{fig:progstate} se encuentran los estados más comunes en los que se puede encontrar una tarea. Además, necesariamente todo programa en ejecución cuenta con cuatro secciones como mínimo: datos, código, pila (stack), y heap. En la Figura \ref{fig:progmem} se puede apreciar, de forma simplificada, cómo se distribuye una tarea cargada en memoria.
En un estado usual del sistema hay cientos de tareas pendientes de ejecutarse. Debe haber, así, una autoridad que decida quién se ejecuta, dónde, y durante cuánto tiempo. De esto se encarga el scheduler (traducido como planificador): es la rutina del kernel encargada de manejar las tareas en tiempo de ejecución.
En la literatura clásica se definen tres tipos \cite{schedulers}:
- Scheduler a largo plazo (long-term). Es el encargado de decidir qué procesos se admiten en memoria principal, esto es, cuándo se cargan y ejecutan por primera vez.
- Scheduler a medio plazo (medium-term). Decide cuándo los procesos entran y salen de memoria principal para situarse en memoria secundaria (disco duro).
- Scheduler a corto plazo (short-term). Decide qué tarea es la siguiente que ha de recibir tiempo de CPU, en base a ciertos criterios.
Con el tiempo, los dos primeros tipos han quedado, o bien en desuso, o bien son muy raramente utilizados. El primer tipo, en la práctica, es raramente referenciado así. Generalmente, gracias a la creación de los procesadores multinúcleo, el kernel carga una tarea de forma inmediata, aunque no necesariamente se ejecute en ese instante.
Cuando la cantidad de memoria RAM estaba en el orden de los MBs o pocos GBs, tenía sentido el scheduler a medio plazo. Existían particiones swap (de intercambio), sobre las cuales los procesos entraban y salían por no caber en memoria principal. Cualquier estudiante de ingeniería informática que haya ejecutado un algoritmo pesado y haya estado viendo a la vez la salida de htop
es consciente de que si se empieza a usar la memoria de intercambio es porque hay un memory leak en su código, y no por la pesadez del algoritmo. En otras palabras, si el proceso ha llegado a usar swap, la va a llenar pronto y el kernel lo va a terminar: ¿Para qué usar swap siquiera entonces?
En algunos casos de cómputos extremos para aplicaciones de, por ejemplo, astronomía, es posible que se llegue a usar swap, pero, por ser tan lenta, suele merecer la pena instalar más memoria principal. Los supercomputadores no son famosos por la cantidad de espacio de almacenamiento que tienen, sino por la velocidad de sus procesadores, GFLOPs, y la amplia RAM. Las particiones swap siguen existiendo, los instaladores de Linux las crean por defecto a día de hoy, pero los sitemas operativos soportan esta función muy principalmente porque ya estaba ahí, y tendría poco sentido eliminarla siendo algo que siempre va a estar inactivo, y cuyo overhead dentro del kernel es inexistente.
Por todo esto, cuando hoy en día se habla de scheduler, siempre se hace referencia a dos tipos: al scheduler a corto plazo, y a un nuevo tipo que ha surgido con la llegada de los multinúcleo, el MQMS (Multi-Queue Multiprocessor Scheduler).
El MQMS es el más amplio, y por lo tanto el que debe explicarse primero. Toda CPU moderna tiene, en mayor o menor medida, caché. La caché L1 es la que está individualizada a los núcleos. Así, tendría sentido repartir las tareas entre los cores de tal forma que se maximize el uso de caché, e idealmente quepan todos los threads que han de ejecutarse ahí, lo que conllevaría una velocidad mucho mayor en la ejecución de tareas, pues la copia de bits de RAM a caché es mucho más lenta que de caché a registros. Varios sistemas operativos, especialmente los indicados para servidores (como Linux) tienen este tipo de scheduler, pero no todos: también se puede mantener una pool global de procesos de la que cada core saca uno cuando le toque (SQMS). Implementar un MQMS es complicado, y de hacerse mal puede ser contraproducente: alcanzar un equilibrio siempre es difícil. En la Figura \ref{fig:qms} se encuentra una representación de estos conceptos.
El scheduler a corto plazo (a partir de ahora, simplemente scheduler), decide qué se ejecuta y en qué orden. Se pueden clasificar según muchos criterios:
- Con o sin reentrancia (preemption). En los schedulers reentrantes, el kernel pausa la ejecución de un proceso tras el paso de cierto tiempo, denominado quantum, normalmente en el orden de los pocos milisegundos. Esto evita tener que esperar a que la tarea termine o quede bloqueada por la espera de algún recurso (lectura del disco duro, llegada de paquetes de red…), y permite realizar el intercambio de tareas más a menudo, lo que da una sensación de concurrencia al usuario, a pesar de que exista solo un núcleo en el procesador. En estos últimos casos, si se desea tener una interfaz gráfica moderna, resulta imprescindible.
- Con soporte o no para prioridades. Tareas distintas tienen prioridad sobre otras, y esta prioridad se puede especificar numéricamente en los schedulers con soporte para prioridades. En los schedulers más simples con prioridad surge el riesgo de inanición, por el cual procesos de baja prioridad pueden potencialmente estar sin ejecutarse más tiempo del esperado: incluso infinito de haber algún problema con los más prioritarios.
- Según su nivel de tiempo real. Existen kernels muy específicos para tareas de Safety-Critical Systems, es decir, aquellos que pueden resultar responsables de pérdidas humanas, que poseen schedulers de tiempo real, en los cuales cada tarea lleva asociada una restricción de tiempo antes de la cual debe concluir. Estos sistemas operativos se clasifican como RTOS (Real-Time Operating System). De aquí se diferencian dos tipos: hard real time, en el cual es inadmisible que la tarea no concluya en el plazo dado (deadline), y soft real time, en el cual se toma una política de best-effort. Para este último caso, suelen servir sistemas operativos de propósito general: por ejemplificar, Linux y NT tienen varios schedulers, y uno de ellos es de tiempo real suave.
Se procede a hacer un muy breve repaso de los schedulers predecesores al que usará el kernel incluido en el sistema operativo de este trabajo.
En DOS (y esto incluye a MS-DOS), no existía el concepto de tareas en sí, pues solo podía haber una en ejecución en un momento dado. Cuando la tarea concluía, se volvía al prompt o se continuaba ejecutando el batch de tareas especificado en un archivo .bat
.
En las primeras versiones de Windows, anteriores a Windows 95, el scheduler no tenía reentrancia, y las tareas eran responsables de liberar la CPU cuando consideraran oportuno mediante una syscall yield.
Ligado al anterior, se trata de un algoritmo genérico que representa una cola cíclica. Corresponde a cualquier tipo de scheduler con reentrancia o yield, cuyo orden de procesamiento sea cíclico: 1, 2, 3, 1, 2, 3, 1, 2…
Extensión del anterior, pero ahora existen distintas colas para aportar soporte de prioridades. Se intenta tomar un proceso de la cola de máxima prioridad y, de no existir, se prueba la siguiente.
MultiLevel Feedback Queue, o cola multinivel con retroalimentación. Construido sobre el anterior, con la diferencia de que las prioridades de los procesos cambian dinámicamente dependiendo de si usan todo el quantum o se bloquean antes \cite{mlfq}. Surgen varios parámetros a tener en cuenta:
- ¿Cuántas veces debe agotarse el quantum para bajar su prioridad?
- ¿Es posible promocionar una tarea? Algunos schedulers MLFQ suben la tarea de cola en caso de que haya estado varios turnos sin concluir su quantum. En cuyo caso, ¿Cuántos turnos?
- ¿Se permite fijar la prioridad de una tarea?
- ¿Se usa el mismo quantum en todas las colas?
NT, de Windows, y muchos derivados de BSD, incluyendo XNU, de macOS, usan variantes de este algoritmo \cite{mlfqnt} \cite{mlfqbsd}. Por defecto, tiene el problema de que tareas de baja prioridad pueden sufrir inanición, y por esto no se suele implementar como tal.
Linux, desde su versión 2.6.23, utiliza por defecto CFS (Completely Fair Scheduler). Se trata de un Red-Black tree, una estructura de datos en forma de árbol similar a un AVL, es decir, un árbol binario de búsqueda autobalanceado. En esta estructura, las tareas pendientes se mantienen ordenadas según la cantidad de nanosegundos que se hayan ejecutado (virtual runtime). Además, el quantum es dinámico, varía según la carga del sistema \cite{cfs}. Resulta subóptimo para microkernels, pues la implementación de un árbol rojo-negro es compleja.
Un sistema de archivos es una organización de la memoria secundaria que permite asignar regiones del espacio disponible a distintos datos, creando así el concepto de archivo. Un archivo puede ser de varios tipos, los más fundamentales son los regulares, secuencias de bits de estructura interna arbitraria (como los archivos de texto o las imágenes), y los directorios, agrupaciones de referencias a otros archivos.
Todo archivo tiene una serie de metadatos: nombre, tipo, y tamaño. Dependiendo del diseño, también puede tener su fecha de creación, y distintos valores que definan los permisos de acceso y modificación según el usuario.
Todo sistema de archivos tiene al menos un directorio: la raíz (root), dentro del cual se encuentran el resto de archivos. La estructuración de los directorios puede ser de nivel restringido o jerárquico. En la primera, existe una limitación de la profundidad de anidación de directorios (uno o dos); este era el caso de algunas versiones antiguas de DOS y CP/M. La falta de agrupación de los archivos llevó rápidamente a la invención de la estructuración jerárquica, en la cual no existe un límite como tal en la profundidad de los directorios (aunque sí pueden existir otros, como la longitud de ruta). En este último caso, la jerarquía se puede expresar en forma de árbol, en el cual todo archivo se encuentra únicamente en un directorio, o en forma de grafo, que permite varias referencias a un mismo archivo, encontrándose en varias rutas simultáneamente. La mayoría de sistemas de archivos, como los derivados de UNIX, tienen un árbol de directorios de este tipo.
Existen sistemas de archivos de solo lectura, como ISO9660, el usado por los CDs y los archivos con extensión .iso
. Estos sistemas permiten una gestión óptima del espacio, puesto que la estructuración de ficheros ocurre solo una vez \cite{iso9660}. Sin embargo, la mayoría no son de este tipo, sino alterables. En estos casos, ha de tenerse especial cuidado con la organización del espacio libre para aprovecharlo lo máximo posible. Los SSAA antiguos estaban basados en la reserva de espacio secuencial, y existía el problema de la fragmentación, por el cual algunas regiones del disco quedaban inutilizables, y se requería un proceso de desfragmentación para reorganizar todo el almacenamiento y unir estas secciones inutilizables de forma que se consiguieran juntar todos los huecos secuencialmente, maximizando el espacio de almacenamiento secuencial. Otras ramas de sistemas de archivos, como la de UNIX (con s5fs) \cite{unix}, la moderna de Windows (NTFS) \cite{ntfs}, las de Linux (ext*) \cite{ext2}, y los BSDs, hacen una estructuración indexada. En el proceso de dar formato al disco por primera vez, se establece una región de memoria para almacenar bloques de datos, y estos se reservan y liberan de forma dinámica cuando es necesario crear uno nuevo. Con tal de no limitar excesivamente el tamaño de los archivos, se suele hacer una indexación multinivel.
Todo archivo tiene un descriptor asociado. En UNIX, este descriptor se denomina inodo, y contiene toda la información sobre un archivo (metadatos, tabla de índices de bloques…) salvo el nombre, que se excluye con tal de poder hacer múltiples referencias con distintos nombres al mismo archivo \cite{unix}. De igual forma que las tareas con los PIDs, en los SSAA modernos, los archivos se identifican numéricamente. En UNIX y derivados, se utiliza el número de inodo. En Windows con NTFS, existe un concepto similar, el File ID.
Con esto, todo directorio referencia a los archivos por su identificador numérico, y esta secuencia de referencias se extiende hasta la raíz. En UNIX, el descriptor a este directorio especial está situado en una estructura global al sistema de archivos, el superbloque, que en versiones modernas se encuentra replicado a lo largo del espacio para aportar tolerancia a fallos.
Por último, los SSAA modernos implementan el concepto de journaling, mediante el cual se mantiene un registro de las operaciones pendientes de escritura al disco. Esto logra hacer estas operaciones de forma semiatómica (esto es, minimizando el tiempo de escritura). Así, en caso de corte inesperado del sistema operativo (por ejemplo, fallo en la alimentación), se consigue evitar en la mayoría de los casos la corrupción de las estructuras.
Salvo ocasiones concretas, los dispositivos de almacenamiento se encuentran divididos en particiones, cada cual está formateada con un sistema de archivos. Las particiones se organizan siguiendo un esquema de particiones, de los cuales existen dos importantes: DOS (también llamado MBR) y GPT. Las limitaciones del primero (cuatro particiones salvo hacks y tamaño máximo reducido) han hecho que, con los años, GPT sea la opción más usual.
En la Sección Teoría de un sistema operativo se han visto los aspectos más teóricamente relevantes del campo. Sin embargo, a la hora de comenzar un proyecto de esta magnitud, el programador no tarda en darse cuenta de que las áreas mencionadas son una parte muy pequeña del código necesario para conseguir escribir el código del sistema operativo más básico.
Esta sección incluirá explicaciones independientes de la arquitectura sobre estos conceptos que han faltado por profundizar: la gestión de memoria, las interrupciones, y los drivers necesarios.
Tras la preparación inicial del procesador, al inicio de la ejecución del bootloader, la memoria es un espacio contiguo de palabras, se denomina memoria física, pues la dirección se emite por el bus de direcciones. Para tener un mejor manejo sobre ella, se crea el concepto de memoria virtual (también llamada lineal). Esta aparece con el concepto de paginación, un mecanismo que ofrecen la mayoría de arquitecturas: la memoria se divide en páginas, de tamaño dependiente del ISA (siempre potencia de dos bytes), y cada página virtual corresponde a una física del mismo tamaño, aunque pueden existir varias virtuales que apunten a la misma física. Este mecanismo permite al kernel crear una estructuración propia de la memoria principal y manejarla con los rangos que él considere \cite{virtualmem}. En la Figura \ref{fig:virtmem} se encuentra una representación de este concepto.
Es importante notar que una dirección física no tiene por qué corresponder a una región en RAM, sino que puede usarse para realizar MMIO (Memory Mapped Input/Output) con tal de comunicarse con otros chips conectados.
El mapping de virtual a física se realiza mediante una estructura denominada genéricamente como tabla de páginas. Toda arquitectura tiene, además, un registro protegido (acceso únicamente permitido al modo supervisor) que apunta a esta estructura. Es muy usual que, debido a la gran cantidad de memoria física disponible, esta tabla de páginas se organice de forma multinivel; este mecanismo se ejemplificará en x86 en la Sección La memoria en un x86. Cada tarea tiene una tabla de páginas propia que forma parte de su contexto \cite{virtualmem}.
Las entradas de la tabla de páginas no solo tienen la dirección física a la que apunta la virtual, sino varias flags que actúan como atributos. La mayoría de estas flags tienen propósitos de protección, y en caso de incumplirse generan una excepción. Son imprescindibles la de solo lectura y la que indica si es accesible en modo usuario. En ocasiones, también pueden aparecer flags más concretas, como puede ser la de no-ejecución o la de no-caché (que evita que se almacene en L1-L3).
En el momento en que se habilita la memoria virtual, todas las direcciones que se emitan al bus de direcciones pasan primero por un chip aparte (originalmente; hoy en día todo se implementa dentro de la CPU), la MMU (Memory Management Unit), que se encarga de hacer la traducción. La MMU tiene una caché para almacenar las traducciones más ocurrentes, se trata del TLB (Translation Lookaside Buffer). El procesador cambia la tabla de páginas en el proceso de cambio de tarea en ejecución (cambio de contexto), y es conocida como una operación costosa especialmente porque conlleva un TLB flush, es decir, el borrado de todas las traducciones cacheadas, salvo las marcadas como globales mediante una de las flags.
La mayoría de kernels modernos se cargan a sí mismos en la mitad superior de la memoria virtual, región conocida como higher half. Esto permite que las estructuras multinivel más amplias sean marcadas como globales, pues se mantienen constantes entre contextos.
La mayoría de procesadores reciben interrupciones. En la Sección Kernel se mencionó que existen dos tipos: enmascarables, y no enmascarables. El proceso de enmascarar se refiere a la capacidad de desactivar una de ellas.
Antes de nada, un breve comentario sobre la notación: las interrupciones hardware se suelen denominar por sus siglas terminadas en #
, y las excepciones por sus siglas comenzando con #
. Así, un error de protección general, que es excepción, se denomina #GP (o #GPF), mientras que la interrupción legacy A
de PCI se denomina INTA#.
Las excepciones son interrupciones no enmascarables (en otras palabras, no evitables). Son fallos captados por la CPU durante la ejecución de una instrucción. Se mencionó con anterioridad la división por cero, pero la más significativa es el fallo de página (page fault, #PF), que ocurre cuando una de las protecciones de una página virtual no se ha cumplido. Un fallo de página no es necesariamente malo, pueden usarse como herramienta para detectar situaciones en las que el kernel debe realizar una acción sobre el proceso como, por ejemplo, ampliar el tamaño de la pila reservado a la tarea, lo que permite reservar una sola página de stack al iniciar el programa, y solo si lo necesita aportarle más.
Las interrupciones enmascarables (evitables) pueden ser de dos tipos:
- Causadas por el usuario. En algunas arquitecturas donde el ISA no contiene una instrucción de llamada al sistema (como IA-32), se utilizan interrupciones en su lugar.
- Causadas por el hardware, generalmente ajeno a la CPU. Se las denomina IRQ (Interrupt Request). El ejemplo más fácil de entender es el reloj del sistema, configurado con el kernel con tal de implementar un mecanismo de reentrancia. Se configura para disparar interrupciones cada quantum, y esto hace que se vuelva al código del kernel y se retome el control.
Toda interrupción debe asociarse a una rutina de interrupción (ISR, Interrupt Service Routine), que contiene el código que se ejecuta al recibirla. Es usual que este código se ejecute en modo supervisor, y en ese caso el procesador cambia de modo si procede, carga una stack del kernel para dicha interrupción en concreto, y hace el salto.
Las arquitecturas cuentan con un controlador (controller, no confundir con driver) de interrupciones programable, y todo kernel debe contener un driver para soportarlo, para enmascarar y desenmascarar interrupciones y hacerle saber al chip cuál es la dirección del ISR y el puntero de pila a usar. Se explicará el caso concreto de x86 en la Sección Interrupciones y syscalls. De haber un problema en el ISR, o directamente no existir, las arquitecturas suelen producir una excepción concreta denominada double fault (#DF o #LOCKUP), que se espera que tenga un ISR asociado y funcional. De volver a encontrarse un fallo procesando un #DF, se produce un triple fault, que es la única interrupción de una arquitectura a la que no se puede asociar un ISR. Dependiendo del procesador, la acción a tomar es congelar la CPU (halt), o reiniciar el sistema.
Se mencionó en la Sección Memoria que algunas comunicaciones con el hardware pueden producirse por MMIO. En estos casos, el controlador correspondiente redirige las direcciones al posarse sobre el bus de direcciones de la placa base al chip que tenga asociado (hardwired). Por esto mismo, esas porciones de la memoria física están garantizadas que no corresponden a una porción de RAM usable, aunque pueden ser movidas en tiempo de ejecución mediante cambio de bancos (bank switching). Estas regiones de memoria física son desconocidas para el programador, y cambian de ordenador en ordenador, con lo cual las arquitecturas aportan un método de descubrir la distribución de las regiones mediante una estructura conocida como mapa de memoria (memory map). La parte de memoria física de la Figura \ref{fig:virtmem} asemeja uno. Este mapa de memoria suele ser leído y reestructurado por el bootloader con tal de pasarle al kernel una versión más manejera.
MMIO no es el único método de comunicación con hardware. En algunas arquitecturas, existe PIO (Port-mapped IO), una clase especial de instrucciones dadas por el ISA con este único propósito. No son necesarias más de dos, y sus mnemónicos suelen ser IN
y OUT
, aunque con distintos sufijos para indicar el tamaño de palabra a transferir, nunca más del tamaño del registro. Cada controlador de entrada/salida establece un conjunto de direcciones PIO por chip al que está conectado. Suelen ser direcciones pequeñas de 16 bytes.
Para terminar la sección, se procede a comentar qué drivers resultan imprescindibles en un sistema operativo moderno, para conseguir orientar al lector un poco en el trabajo requerido para realizar un proyecto de esta índole.
Independientemente del propósito, es necesario el driver del controlador de interrupciones, que varía según la arquitectura. Se debe configurar y desenmascarar las interrupciones nescesarias, así como proveer las direcciones de los ISRs y reservar los marcos de pila para cada núcleo.
Si se desea acceder a archivos, es necesaria una pila de almacenamiento (storage stack), es decir, los módulos que juntos ofrecen las abstracciones necesarias para ello.
- En la parte más baja de esta pila de drivers, se encuentra el driver del bus al que está conectado el dispositivo de almacenamiento a acceder. En sistemas modernos suele ser PCI Express.
- Sobre ello, el driver en sí del controlador al que está conectado el dispositivo de almacenamiento masivo. Este último puede ser un disco duro (en sus múltiples formas), un CD, un pendrive… Dependiendo del dispositivo de almacenamiento en concreto, el controlador al que está conectado es de un tipo u otro. Por ejemplificar, un disco duro SATA puede estar conectado a un controlador AHCI, un ATA a un IDE, un M.2 a un NVMe, o un pendrive a xHCI. Los controladores pueden ser o no retrocompatibles: AHCI puede emular IDE de así configurarlo en la BIOS, pero xHCI (para USB 3.x, 2.0 y 1.x), EHCI (USB 2.0), OHCI (USB 1.1) y UHCI (USB 1.x) no lo son, con lo que no es extraño que se implementen todos.
- Sobre el driver del controlador, no es inusual encontrar una abstracción que nombre los dispositivos de almacenamiento, como se mencionó en la Sección Drivers.
- Encima de esto último, puede encontrarse un programa que interprete el esquema de particiones.
- Luego, la implementación del sistema de archivos a usar.
- Y, finalmente, el VFS (Virtual File System), que abstrae todos los sistemas de archivos disponibles en el sistema para aportar una API uniforme e independiente de todo lo que está bajo la cima de la pila.
Se profundizará en la avalancha de siglas en capítulos posteriores.
En la Sección Portabilidad se comentó que x86-64 es el target del sistema operativo propuesto. Así, resulta natural ofrecer una contextualización de la arquitectura tan pronto como sea posible. Esta sección cumplirá ese objetivo. Se presentará la arquitectura, su forma de arranque y sus estructuras, como los descriptores que necesita y su forma de realizar paginación.
El mundo de la informática tuvo un punto de inflexión en 1981 con la salida del IBM Personal Computer en Estados Unidos \cite{ibmpc}. Su procesador, el Intel 8088, lanzado al mercado 5 años antes, fue la primera pieza de hardware en usar la arquitectura x86. Rápidamente empezó a ganar popularidad, y, por su alto precio, poco después de la salida al mercado, otras compañías productoras de hardware y software crearon las denominadas compatibles, computadores cuyo hardware permitía la ejecución del software diseñado para la máquina de IBM, de las cuales cabe destacar las de Compaq. La enorme presencia en mercado de las compatibles ha desencadenado en que, para finales de la década, x86 fuera la arquitectura más utilizada.
Como x86 data de tan atrás, muchas de las decisiones originales de diseño se han ido quedado obsoletas, y las subsiguientes generaciones de procesadores aportaron nuevos ISAs que han ido abandonando funcionalidades e introduciendo otras. Sin embargo, muchas de ellas, por el propio diseño de la arquitectura, siguen siendo necesarias hoy en día, y el programador del sistema debe implementarlas. Esto hace que desarrollar un kernel desde cero para x86 sea un proyecto bastante más complejo que el de una arquitectura moderna, como ARM64.
La mayoría de computadores personales de hoy día usan la arquitectura x86, sobre el ISA (Instruction Set Architecture, conjunto de instrucciones) de 64 bits llamado x86-64 (también conocido como x64 o amd64). Existe un resurgimiento de la arquitectura ARM fuera de móviles por parte de los procesadores Apple Silicon publicados desde 2020, pero a día de hoy su presencia no consigue alcanzar la de los x86.
Cuando un x86 arranca, se ejecuta un programa aportado por un chip ROM sobre la placa base. Se denomina BIOS (Basic Input Output System) en su versión original, aunque en la década pasada fue poco a poco reemplazado por UEFI (Unified Extensible Firmware Interface) hasta apoderarse del mercado. UEFI suele tener en la gran mayoría de ocasiones un modo legacy para simular ser una BIOS y así mantener la retrocompatibilidad. Esta sección se referirá solo a BIOS con tal de acotar un cierto nivel de simplicidad.
La BIOS realiza tareas de preparación del hardware, como inicializar el controlador de la memoria DRAM y puertos PCI, aunque su forma de hacerlo varía entre fabricantes y modelos. Cuando el hardware esencial ha sido inicializado, se prepara una interfaz de bajo nivel que puede usar el programador del sistema: se trata de las llamadas de interrupción BIOS, ampliamente usadas en la época de MS-DOS, cuando no existía un kernel lo suficientemente amplio como para abstraerse del hardware.
Tras montar este sistema de interrupciones, selecciona un disco de arranque, proceso que ha presenciado todo entusiasta de la informática a la hora de instalar un sistema operativo. De este disco, sea magnético, en estado sólido, unidad CD, o USB, BIOS lee el MBR (Master Boot Record), su primer sector (conjunto pequeño de bytes, usualmente 512 en discos duros y 2048 en CDs). El MBR es copiado a una región de memoria que comienza en 0x7C00
, por convenio de IBM, y BIOS hace el salto a esta dirección \cite{bios}. A partir de este punto, el programador del sistema está en control.
Cuando la BIOS salta al punto de entrada, el procesador se encuentra en un estado conocido como real mode, o modo real. Este modo es plenamente compatible con un procesador 80186 de Intel, y su ISA es x86-16; es decir, tiene un tamaño de palabra de 16 bits. Para desbloquear el verdadero potencial de la CPU, el procesador debe de cambiar al protected mode (modo protegido), capacidad que apareció por primera vez en el Intel 80386 (también llamado i386), que usa el conjunto de instrucciones IA-32, con una longitud de palabra de 32 bits. Eventualmente, también tendrá que pasar al long mode (modo largo), con el ISA x86-64, que corresponde a lo usado hoy en día \cite{modos}.
Todo este proceso de cambio de modos es realizado por una pieza de software: el bootloader, o cargador de arranque. GRUB \cite{grub} es el que posee el nombre más conocido, pero existen multitud. Por ejemplo, las versiones modernas de Windows usan BOOTMGR \cite{bootmgr}. El bootloader utiliza las interrupciones BIOS para reconocer los discos conectados y poder acceder a ellos posteriormente. Tras hacer el cambio de modos, reconoce los esquemas de particiones, así como las particiones en sí, y carga los archivos necesarios del kernel, para después darle el control, ofreciéndole en el proceso información vital para la posterior preparación del sistema (por ejemplo, el mapa de memoria mencionado en la Sección Comunicación con el hardware).
Desde su comienzo, x86 ha tenido un modo de manejo de memoria: la segmentación. Está obsoleta en x86-64 y se desaconseja su uso a los programadores de sistemas. Sin embargo, es obligatorio implementar una mínima funcionalidad por retrocompatibilidad. Segmentación divide la memoria, como indica su nombre, en segmentos. Así, un programa es una colección de unidades lógicas: código, pila, heap…
En segmentación, una dirección lógica es un par selector:desplazamiento
. Un selector es un índice de segmento con flags de protección. Los segmentos son de 64KB en modo real, y de hasta 4GB en modo protegido. Existen, además, 6 registros de segmentos, que se usan para formar direcciones cuando se quiere hacer una lectura/escritura de memoria. Son: CS
(Code Segment), DS
(Data Segment), SS
(Stack Segment), ES
(Extra Segment) y FS
y GS
, siendo los tres últimos de propósito general \cite{segmentation}.
IA-32 incluye nuevas instrucciones con microcódigo que afecta a los registros de segmentación. De ellas, far jump e IRET son las más usadas. La primera se suele utilizar en los bootloaders para realizar el cambio de segmento en CS
junto a un salto de forma atómica (hacerlo por separado dispararía una excepción), y la segunda se solía usar en los kernels para realizar cambios de contexto, pues además de cambiar CS
realiza el cambio de EFLAGS
, el registro que tiene las flags del sistema \cite{iret}.
La segmentación en x86 se define por dos estructuras: en mayor medida, la GDT (Global Descriptor Table), y, en menor, la LDT (Local Descriptor Table). Se comentará solo la GDT por ser relevante a día de hoy, pero la LDT es similar.
El registro GDTR contiene un puntero a la GDT, y se carga por medio de la instrucción LGDT. La GDT es un array de entradas, GDT entries, cuyo índice forma parte del selector del segmento. El primero siempre es nulo, y el resto contienen descriptores de segmentos, que están formados por una dirección base del segmento, su tamaño, y algunas flags. Esta estructura es un claro indicativo del paso del tiempo en x86: extensiones en IA-32 y x86-64 han dejado bits de la base y el tamaño (límite) desperdigados en los 64 bits que la componen, véase la Figura fig:gdt \cite{gdt}.
Con la GDT aparece el modo usuario en x86. El campo DPL
que se aprecia en la Figura fig:gdt es un número denominado anillo de protección (en inglés, protection ring). En x86-64 existen 4 anillos, comenzándose en el ring 0, y dando libertad al kernel de elegir si los demás están en modo supervisor, usuario, o incluso una mezcla de ambos. Generalmente, para modo usuario se usa el cuarto anillo (ring 3), y los anillos 1 y 2 no se utilizan en absoluto, pero un SO podría usarlo para drivers, a costa de complicar la portabilidad. Por ejemplo, en ARM no existen los anillos, sino como tal los modos supervisor y usuario, entre otros.
Si bien segmentación está obsoleta, un kernel de x86-64 debe aportar una GDT válida, con, como mínimo, dos entradas: código y datos del kernel. Si espera implementar tareas en modo usuario (userspace), entonces necesita otras dos: código y datos de usuario.
El modo protegido del i386 trae a x86 el ISA IA-32. Entre las nuevas funcionalidades, se encuentran otra forma de manejo de memoria: la paginación. IA-32 tiene direcciones de 32 bits, con lo que se tienen 4 GBs de memoria virtual direccionables, que se distribuyen en páginas de 4 KBs (también existen las páginas huge de 4MB). Como hacer un array en memoria de cada página virtual con su física equivalente resulta inasequible, se usa paginación multinivel. Se define una tabla de páginas como una página con un array de 1024 entradas, cada una de la cuales corresponde a una página virtual, y en cada una se encuentra su correspondiente memoria física. Sobre esto, se crea el directorio de páginas, otra página con un array de 1024 punteros (físicos) a tablas de páginas. En la Figura fig:paging2 se encuentra una representación. La dirección de esta última página se la conoce usualmente como el puntero a la tabla de páginas, aunque realmente apunte al directorio. Para utilizar estas estructuras, los procesadores x86 tienen el registro cr3
donde se sitúa el puntero.
Con el tiempo, se veía venir la obsolescencia inminente de IA-32. Para alargar su vida, apareció la tecnología PAE (Physical Address Extension), presente en todo x86 moderno de 32 bits (y todos los de 64), mediante la cual se permite un acceso a una memoria física de más de 4GB por medio de paginación a 3 niveles.
Después de PAE, con x86-64 el espacio de direccionamiento se vuelve de 64 bits, y se tiene una cantidad de direcciones virtuales cuatro mil millones de veces mayor a la de IA-32. Por esto, son necesarios más niveles. Los procesadores no soportan direcciones de 64 bits, sino de al menos 48; el resto de bits se producen por expansión de signo, y las direcciones de este tipo se denominan direcciones canónicas. Las direcciones de 48 bits se representan por paginación a 4 niveles. Ahora, una tabla de páginas tiene 512 entradas, y un directorio de páginas 512 punteros. Aparecen sobre los directorios de páginas los PDPs (Page Descriptor Pointer), y sobre estos últimos los PML4 (Page Map Level 4). En la Figura fig:paging4 se encuentra una representación. Algunos procesadores permiten paginación a 5 niveles para acceder a más memoria virtual aún (57 bits), y estos usan los PML5. Suponiendo ahora una paginación a 4 niveles, el registro cr3
contiene el puntero que apunta a la página con el PML4.
A día de hoy, la paginación a cuatro niveles de x86-64 pone a disposición del programador del sistema algunas flags. Estas son las más relevantes:
- Presente. Se alza cuando la página virtual tiene una física correspondiente.
- R/W. Se pone a uno cuando se desea permitir la escritura.
- U/S. Ondea cuando la página es accesible tanto por el kernel como en userspace.
- PCD. Está activa cuando se desea deshabilitar que la página se almacene en la caché de la CPU. Es útil para MMIO.
- G. Alzada cuando la página es global, y por tanto no debe resetearse durante un TLB flush.
Existen más, algunos de ellos enfocados a protecciones modernas de memoria, y se guardan en la PAT (Page Attribute Table), pero es mejor quedarse aquí. Cuando una de las flags no corresponde al estado del procesador durante un acceso, se dispara #PF.
Se conoce x86 como una arquitectura guiada por interrupciones, y su correcto manejo es una parte crítica del kernel.
Lo más fundamental: las interrupciones en x86 están numeradas con un identificador del 0 al 255, llamado vector de interrupción \cite{intvec}. Originalmente, llegaban a un x86 mediante pines propios en la CPU, desde un chip encargado de manejar las interrupciones. Se trata de la PIC 8259 (Programmable Interrupt Controller), usado por el IBM PC, y hoy en día, como es esperable, está dentro de la CPU. El hardware envía IRQs a la PIC. Esta hace la transformación a su identificador de x86, comprueba si está enmascarada, y, de no estarlo, la manda al procesador. En la Figura \ref{fig:pic} se encuentra este camino. En el próximo ciclo de reloj, el ciclo fetch/execute comprobará si hay interrupciones pendientes. Como la hay, guardará en la pila el estado (flags y registros fundamentales), hará el cambio de contexto, y saltará al ISR.
¿Cómo sabe la CPU dónde está el ISR? La estructura que los maneja es la IDT (Interrupt Descriptor Table), y es muy similar a la GDT. IDTR es un registro, cargado con la instrucción LIDT, que apunta a la IDT. Es un array de entradas, gate descriptors, que indican, sobre todo, la dirección del ISR y un índice de la IST (Interrupt Stack Table), otra estructura manejada por la CPU que contiene un array de pilas disponibles para la CPU en caso de interrupción \cite{idt}.
En x86-64, la IST se encuentra dentro de otra estructura de IA-32 que quedó obsoleta, la TSS (Task State Segment), cuyo objetivo original era el cambio de contexto por hardware, idea que ha quedado en desfase por implicaciones de velocidad y control del kernel. Sabiendo su contexto no resultará extraño lo siguiente: cada TSS necesita una entrada en la GDT. Tras este cambio, la TSS tiene 7 entradas de IST (del 1 al 7) para interrupciones específicas, así como otras 3 que se usan en caso de que el índice de IST sea cero \cite{tss}.
Un rango de vectores de interrupción está dedicado a las excepciones, del 0 al 30. Por ejemplo, #PF es 14, y #GP es 13 \cite{pfgp}. Las demás, las interrupciones hardware, son traducidas por la PIC desde su valor de interrupción hardware, IRQ
Junto a la PIC, apareció la PIT 8253/8254 (Programmable Interval Timer), un oscilador encargado de generar interrupciones hardware cada un cierto intervalo de tiempo programable. Asignado a IRQ0, se usaba en IA-32 para configurar la reentrancia.
Con el surgimiento de los multiprocesadores x86, la PIC se ha quedado atrás. Ha de mantenerse retrocompatible, y no se puede extender para soportar varios procesadores. En su lugar, se ve obligada a emitir la interrupción hardware a todos los cores, y los cambios de contexto innecesarios ralentizan todo el SO. Por esto, se diseñó una sucesora, la APIC (Advanced PIC).
En este nuevo sistema SMP (Symmetric Multiprocessing) que es x86, donde cada core tiene acceso a toda la memoria, el procesador es una combinación de pares <core, LAPIC>
. Esto es, todo núcleo tiene un chip asociado, una LAPIC (Local APIC), y todos los cores comparten un único IOAPIC (Input/Output APIC). Cuando una IRQ llega al procesador, la maneja la IOAPIC. Esta se comporta como la PIC, en el sentido de que la traduce a su identificador y comprueba si está enmascarada, con la gran diferencia de que, en caso de no estarlo, redirige la interrupción a una de las LAPIC. En la Figura \ref{fig:apic} se encuentra este camino. Se puede configurar de dos formas: destino fijo, en el cual se especifica uno de los cores como receptor, o por prioridad, en el que la CPU intenta averiguar qué núcleo tiene menos trabajo (por tiempo en HALT) y se la envía. Además, la APIC permite las denominadas IPIs (Inter-processor Interrupts), una forma de generar interrupciones software entre distintos cores para sincronizarlos \cite{apic}. Incluye también un reloj que emite interrupciones en un intervalo configurable, sucesor de la PIT, el LAPIC timer.
Con x86-64 aparecen dos nuevas instrucciones muy relevantes: syscall y sysret \cite{syscall}. Syscall realiza el cambio de modo, desde usuario a supervisor, y en el proceso establece RCX=RIP (se guarda el contador de programa) y R11=RFLAGS (se guardan las flags). Sysret lo deshace, con RIP=RCX y RFLAGS=R11, y vuelve a modo usuario. El kernel inicializa este comportamiento por medio de un MSR (Model Specific Register), un registro accedido con una instrucción específica (WRMSR). En concreto, el MSR relevante a syscall es el EFER (Extended Feature Enable Register), existiendo una flag para SCE (System Call Extensions). Cuando está habilitado, en otros MSRs (STAR y LSTAR) se le hace saber a la CPU qué selectores se usan para modo supervisor y usuario, así como cuál es el punto de entrada, que se podría interpretar como la dirección a un ISR genérico \cite{syscall}.
Nótese cómo estas instrucciones no escriben nada en la pila, ni siquiera cambian RSP (puntero de pila) ni la tabla de páginas. Por esto, se realiza un cambio de contexto limitado; el kernel considerará posteriormente si es oportuno cambiar estos valores. Esta nueva forma de realizar llamadas al sistema supone un incremento de velocidad con respecto al que existía anteriormente en x86, y resulta una herramienta muy útil para el programador del sistema.
En este capítulo se estudiarán y analizarán algunos sistemas operativos existentes, más antiguos o más modernos, con tal de ofrecer al lector una visión general del abanico de elecciones posibles a la hora de diseñar un sistema operativo. El capítulo se centra en los sistemas operativos que, de alguna u otra forma, sirven de referencia a la propuesta de sistema operativo y su desarrollo, que se llevará a cabo en los siguientes capítulos.
En 1925, Western Electric crea Bell Telephone Laboratories, conocido usualmente como Bell Labs \cite{belllabs}. Doce años después, se produce el primer premio Nobel del lugar por el descubrimiento de la difracción de electrones \cite{belllabs2}. Los laboratorios Bell no han sido otra cosa a lo largo de su historia que una gran fábrica de premios Nobel y grandes descubrimientos que han hecho avanzar la humanidad, como el transistor, el láser, y la célula fotovoltaica. Entre todos estos logros, existe uno cercano a este trabajo: UNIX.
Ken Thompson, conocido también por la invención de UTF-8, y Dennis Ritchie, conocido por la creación de C, comienzan en 1969 UNIX, un proyecto de sistema operativo para el PDP-11, poco después del fracaso de otro intento anterior, Multics.
El gran pilar de UNIX es la intercomunicación. Por esto, todo es un archivo: desde un disco duro en sí hasta sus particiones, y desde el chip Ethernet hasta la configuración del kernel \cite{unix}.
Existen cuatro mecanismos fundamentales de IPC en UNIX:
- Pipes, o tuberías, ya mencionados en la Sección Kernel. Los programas se comunican mediante flujos de bytes. Por un extremo, uno de los procesos escribe, y por el otro, se lee. Los bytes no tienen formato, con lo que, de ser usado el mismo pipe para varios propósitos, es necesario un mecanismo de marshalling (cambio de forma). Los pipes pueden estar asociados a un archivo (denominados named pipes), o ser independientes, asociados a un descriptor de archivo.
- Señales. Son notificaciones asíncronas de un evento que puede manejar un programa. Se podrían interpretar como vectores de interrupción de los procesos. En UNIX System V Release 4, se definen 32 de ellas \cite{sysv}.
- Memoria compartida, distribución de páginas físicas para la copia de grandes secciones de bytes de forma rápida.
- Sockets. Funcionan de forma idéntica a los sockets de red, y cuentan con las mismas operaciones sobre ellos. Se diferencian en que están asociados a un archivo, generalmente con la extensión
.socket
para diferenciarlos.
El sistema de archivos de UNIX, sin nombre, simplemente File System hasta UNIX V5 (después renombrado a s5fs), tiene especial interés para este proyecto. Se define el término de inodo, una estructura que representa a un objeto del sistema de archivos. Este objeto puede ser un archivo regular, un directorio, o un named pipe, entre otros \cite{unix}.
La memoria secundaria se divide de la forma representada en la Figura \ref{fig:s5fs}, con bloques de 512 bytes, que contienen los datos de los ficheros. Dentro del inodo, existe una representación multinivel para direccionar los inodos. Hay 8 índices de bloques, y una flag que toma valores small o large. De ser el archivo small, los bloques son directos, con lo que son direccionables
Los inodos y bloques se reservan y liberan por medio de listas enlazadas.
Existen algunos sistemas de archivos posteriores a s5fs que mejoran la idea original. De ellos cabe destacar Berkeley Fast File System (FFS), aumenta el tamaño mínimo de bloque a 4 KBs, lo que aumenta el tamaño máximo de archivo a 4GB. Con FFS también aparecen los enlaces simbólicos, el bloqueo de archivos, y las cuotas de usuario. Además, cambia la representación de listas enlazadas por bitmaps \cite{ffs}.
Muchos sistemas de archivos de hoy día son sucesores de FFS. Entre ellos, MINIX File System, ext2, zfs, btrfs…
El modelo de protección de UNIX está compuesto exclusivamente por el del sistema de archivos. Si un proceso puede acceder a la named pipe o al socket, puede comunicarse con el servicio en el otro extremo. El modelo de protección del sistema de archivos es un mecanismo de propiedad: un archivo es de un usuario (identificado por su UID, User IDentifier), y de un grupo (GID, Group IDentifier). Sobre las vistas de usuario, de grupo, y de otros, se establecen tres permisos: lectura, escritura, y ejecución.
Como ejemplo práctico de esto, en GNU/Linux, heredero de UNIX, la comunicación con el servicio de Docker se realiza por medio de un socket, /var/run/docker.sock
, y sus propietarios son el usuario docker
y el grupo homónimo. Para comunicarse con él, el usuario deberá pertenecer al grupo docker
, o ser superusuario.
UNIX tenía un kernel monolítico, pero no es el concepto de monolito que existe hoy en día. En aquella época, si bien diseñar un sistema operativo tenía una dificultad aproximable a la actual, desarrollarlo era extremadamente más simple: existían muchos menos dispositivos con los que comunicarse y los procesadores eran mucho más simples. Por todo esto, la cantidad de líneas de código que tomaban los drivers era mucho menor, y no se consideraba la idea de separarlas.
No sería así, al menos, hasta bien entrados los 70. Se suele considerar el primer microkernel el de RC 4000 Multiprogramming System, un SO para máquinas con un propósito muy específico (una planta fertilizadora) creado en 1969 \cite{rc4000}. Durante las décadas de los 70 y los 80, gran parte de la investigación de SSOO se enfocó en microkernels.
El primer producto de este estilo en triunfar económicamente fue la Commodore Amiga, en 1986, con su sistema operativo AmigaOS y su microkernel Exec. Utilizaba un IPC basado en paso de mensajes. Como no existía en aquel entonces el concepto de protección de memoria, solo existía un espacio de direccionamiento compartido por todas las tareas, así como el kernel. Por esto, el paso del mensaje del proceso A al proceso B consistía en solo transmitir el puntero a estos datos, lo que implicaba cero copias de los bytes \cite{amigaos}.
AmigaOS supo utilizar el entorno a su favor para crear lo que es a día de hoy el microkernel más rápido que ha existido. Los SSOO basados en paso de mensajes generalmente requieren copias, como mínimo una si se diseña bien, lo que supone una gran diferencia con respecto al método de Exec.
Con la salida de UNIX V7 en 1979, AT&T comenzó a usar una nueva licencia en la cual se prohibía su uso para la enseñanza. El profesor doctor Andrew Tanenbaum, de la Universidad Libre de Ámsterdam, comenzó, cinco años después, a escribir un clon para su libro de texto Operating Systems: Design and Implementation, MINIX (de mini-UNIX) \cite{minixnew}. MINIX desarrolló una nueva estructuración del sistema clásico por medio de un microkernel, y, de forma general, simplificó muchos de los diseños para hacerlos más comprensibles a la hora de explicarlos en clase.
MINIX no está centrado en la seguridad, sino en la robustez: en todo momento, el sistema debe mantenerse funcionando, lo que lo hace idóneo para sistemas embebidos. Existen mecanismos para reiniciar los servicios en caso de fallo, así como formas de actualizar los servicios en tiempo de ejecución (live update) mediante traducción de estructuras \cite{minixtrad}.
Aunque el propósito principal no es mantener un sistema seguro, existen propuestas para aplicar un modelo Bell-LaPadula \cite{belllapadula} al SO \cite{minixbell}.
MINIX sigue en desarrollo hoy en día, con un equipo de unas 50 personas trabajando en él. Además, desde 2008, en su versión 3, está presente en todos los procesadores Intel, bajo el subsistema autónomo que forma el Intel Management Engine \cite{intelme}.
Jochen Liedtke se podría considerar el tutor legal de los microkernels. Si bien no es el padre, fue uno de los grandes investigadores al respecto, y su trabajo e ideas perdurarán en todos los microkernels por venir. En 1987, tras varios años de experiencia con SSOO, comenzó el diseño de L3, con la intención de demostrar que un IPC liviano y muy enfocado en un diseño machine-specific podía realizar paso de mensajes sin overhead añadido. En L3, el diseño e implementación de las políticas de seguridad (quién puede comunicarse con quién) se delega a las tareas en sí, lo que libera mucho la carga del kernel \cite{l3}.
A principios de los 90, microkernels mal diseñados, y, sobre todo, lentos, acabaron dando mala popularidad al área. Un ejemplo es IBM Workplace OS, con su núcleo Mach, famoso por ser “de los peores microkernels escritos” \cite{mach}. Liedtke denominó a este suceso el desastre de los 100 microsegundos, por ser el tiempo mínimo necesario en Mach para realizar paso de mensajes. Por esto, decidió reimplementar L3 desde cero en ensamblador, lo que resultó en la reducción del overhead en un orden de magnitud. Este kernel se denominó L4 \cite{l4}.
Dos años después de la publicación de L4, enunció por primera vez en 1995 el concepto conocido como principio de minimalidad de los microkernels:
Un concepto es aceptado dentro del microkernel solo si moverlo fuera prevendría la implementación de la funcionalidad requerida del sistema. — Jochen Liedtke \cite{minimality}.
Uno de los puntos claves de L4 es el modelo de IPC síncrono. En él, el paso de mensajes de A a B se realiza bloqueando el proceso A con la syscall de envío. A partir de este punto, el kernel puede copiar, de forma segura, el mensaje desde el espacio de direccionamiento de A a B. Además, toma algunos registros como registros de mensaje, y su valor no se altera durante el cambio de contexto, lo que implica que parte del mensaje puede ser pasado con cero copias.
Han surgido numerosos sistemas operativos basados en microkernels derivados de este. Así, aparece la familia de microkernels L4, similar (aunque mucho más pequeña) a la de UNIX. Una representación de este árbol genealógico se puede encontrar en la Figura fig:family.
El proyecto seL4 arranca en 2007 a raíz de Gernot Heiser. Aparece, así, junto a un proyecto similar, EROS, la autodenominada tercera generación de microkernels. Está caracterizada por una API orientada a la seguridad con acceso a recursos controlados por capabilities, así como un foco en la virtualización y en enfoques modernos para el manejo de recursos del kernel.
La tercera generación de microkernels también tiene un objetivo de diseño que permita los análisis formales, es decir, una demostración matemática de que la implementación del kernel es consistente con su especificación. seL4 es de los muy pocos kernels que cuentan con una verificación formal completa en términos de seguridad, y además en términos de timeliness (temporización), haciéndolo apropiado para aplicaciones de hard real time \cite{sel4}.
El IPC de L4, aunque rápido, era complejo, y satisfacía muchos objetivos. Entre ellos, el paso de mensajes pequeños (usual), el de mensajes grandes (Long IPC), y el de sincronización. seL4 abandona Long IPC, pues con la experiencia se demostró que este mecanismo ralentizaba todo el sistema; sobre todo porque, en L4, el ISR de page faults se encontraba en userspace, lo que implicaba la necesidad de salir del kernel para resolver las copias de más de una página. En su lugar, si se desea pasar grandes mensajes, se utiliza memoria compartida \cite{heiser}.
L4 utilizaba PIDs para dirigir IPC, y con esto aparece un problema de canal encubierto. En su lugar, seL4 reemplaza este mecanismo con endpoints, parecidos a puertos, donde cada uno puede tener un número arbitrario de procesos que envían y reciben.
La primera versión de Linux se publica en 1991 por parte de Linus Torvalds \cite{linux001}, como un MINIX-like, según sus propias palabras. La gran diferencia es que se trata de un kernel monolítico en lugar de un microkernel.
En el sistema de archivos UNIX y derivados, existe una lista de usuarios y grupos a los que pueden pertenecer, definidas respectivamente en los archivos /etc/passwd
y /etc/group
\cite{unixprog}. En el inodo de un archivo se encuentran dos campos: el UID y el GID del propietario. En entornos complejos de múltiples usuarios y grupos, esto puede llegar a suponer un problema.
Considérse el siguiente escenario: un directorio notas
tiene como propietario el grupo etsiit
, del que forma parte todo usuario que está en el grupo alumnos
o en el grupo profesores
. Esta restricción de consistencia (profesores
puedan escribir sobre notas
mientras que alumnos
tan solo puedan leer. Este escenario es imposible de representar en UNIX, y habría que recurrir a complicar la jerarquía de directorios.
La solución se denomina ACL (Access Control Lists), un mecanismo de permisos en la que se desmenuzan las políticas de acceso sobre un archivo, en lugar de tener un solo propietario. De esta forma sí es representable el escenario anterior. Una posible solución: sobre notas
se especifica que todo usuario de etsiit
puede leer, y todo usuario de profesores
puede leer y escribir.
Los sistemas de archivos ext*, derivados del de UNIX, tienen una extensión, acl, que implementa este mecanismo, y está disponible en Linux \cite{ext2}. Sin embargo, utiliza un ACL por archivo. Esto implicaría que, en el escenario anterior, se debería de aplicar la política de seguridad recursivamente, lo que complica el mantenimiento. Para solucionarlo, también se permiten aplicar políticas por defecto que son heredadas por todos los archivos nuevos del directorio, pero no los existentes: tan solo aplicará la política, que está contenida en cada archivo, a los nuevos que se creen.
En Linux, por defecto, la granularidad es binaria: se permite todo (root) o no se permite nada (usuario convencional). Esto es matizable: el popular programa sudo permite a un usuario ejecutar una orden como otro, según unas políticas definidas en el archivo de configuración (/etc/sudoers
). En sudoers
, se pueden especificar reglas del estilo: el usuario jlxip puede ejecutar la orden ~/bin/programa -s *~ como root. Este uso de wildcards (*
), junto a el uso de binarios SETUID
(que funcionan de forma muy parecida), son la mayor fuente de fallos de seguridad que resultan en escalación de privilegios en servidores. Como comentario, el programa doas de OpenBSD hace algo similar, pero no soporta el uso de wildcards en los comandos permitidos, lo que, aunque minimiza la superficie de ataques, reduce la funcionalidad.
A raíz de esto nació SELinux (Security-Enhanced Linux) \cite{selinux}, un módulo de Linux de principios de los 2000 que implementa políticas de control de acceso como MAC (Mandatory Access Control): un control de acceso en el que se restringen las acciones que puede realizar un sujeto (programa o usuario). Por ejemplo, una regla MAC podría prohibir a un programa escuchar en puertos TCP o UDP. SELinux es ampliamente utilizado en servidores, y es, hasta cierto punto, customizable. En las distribuciones que lo incluyen se suelen añadir un conjunto de reglas por defecto para permitir la funcionalidad más básica del sistema. SELinux, sin embargo, no es popular en distribuciones orientadas a workstations, como puede ser Ubuntu, y su inclusión es opcional, lo que hace, en este aspecto, a Ubuntu un sistema insecure by default.
Un sistema operativo es un proyecto compuesto por una gran cantidad de subsistemas que requieren un desarrollo secuencial; así, no es posible paralelizar las tareas a realizar hasta que se alcanza un cierto nivel de madurez. Durante el estado temprano del proyecto, las capas de abstracción se apilan una sobre otra, con lo que no es posible comenzar la siguiente hasta terminar en la que se esté trabajando. Terminar la capa más alta planeada, que, en este caso, son las coreutils (Sección Programas), supone completar el proyecto, según lo enunciado en la Sección Objetivos. De esta manera, no es posible realizar sprints, correspondientes a una metodología ágil, pues todo el tiempo del que se dispone se invierte en hacer el primer prototipo; no es posible realizar metodologías iterativas, evolutivas, o incrementales como tal, pues solo se dispone de una iteración.
Sin embargo, no se pueden ignorar los problemas de las metodologías tradicionales, como puede ser la metodología en cascada. Si bien pueden funcionar en proyectos de pequeña magnitud, cuando se trata con múltiples diseños complejos, no es realista definirlos todos antes de comenzar el proyecto.
Con todo esto en mente, no resulta posible encasillar la metodología utilizada en una conocida. La más próxima es la metodología incremental, pues los subsistemas se han ido desarrollando conforme los dependientes han requerido las funcionalidades. Por ejemplo, la funcionalidad de ACLs del VFS no se ha implementado hasta comenzar las utilidades relativas a ellos: en el momento, resultaba más prioritaria la creación de archivos y el listado de directorios.
Dada la naturaleza del proyecto, es imposible realizar verificaciones informales, como podrían ser los tests unitarios, para comprobar que el funcionamiento del kernel es correcto. Sin embargo, sí es posible realizarlos en la capa de usuario. Para ello, se ha desarrollado una batería de tests unitarios, bajo el programa tests
, que se comunican con los servicios presentes en el sistema operativo y prueban sus interfaces públicas. En ocasiones, sin embargo, estos tests resultan redundantes: el solo hecho de poder ejecutarse el programa conlleva que la mayoría del sistema funciona. Se hace especial hincapié en los servicios que no forman parte de este grupo, incluyendo partes del código como pueden ser algunas estructuras de datos complejas.
La implementación del proyecto en este capítulo descrito está bajo la licencia de izquierdos de autor (copyleft) GNU General Public License 3.0 \cite{gpl3} o superior. No es Open Source, sino software libre. Atendiendo a la definción de la FSF, cumple con las cuatro libertades esenciales del software \cite{freesoftware}.
Como herramienta para gestionar el trabajo a realizar, se eligió desde un primer momento git, y su publicación está en el portal GitHub. En el caso de este proyecto, en lugar de un repositorio con directorios para cada proyecto, se ha creado una organización \cite{orga}: un usuario virtual con su propio perfil y repositorios. De esta manera, todos los proyectos que componen el sistema operativo se encuentran separados.
El proyecto se ha realizado en C++11 y ensamblador NASM, utilizando GNU Make como herramienta para orquestrar el proceso de construcción del ISO final. Existe un único Makefile que utilizan todos los subproyectos: se ha denominado helper, y cada repositorio contiene un Makefile que lo incluye y customiza por medio de variables de entorno. Durante todo el desarrollo, las pruebas se han llevado a cabo con qemu \cite{qemu}.
Una toolchain (compilador, linker, ensamblador…) de C o C++, sea la de GCC o clang (frontend de LLVM), se compila para soportar un único target. La versión de g++
incluida en las distribuciones GNU/Linux está fuertemente conectada con la GNU libstdc++ y con el entorno userspace y kernel existentes en este sistema operativo. Con tal de poder empezar a escribir uno, es necesaria una toolchain propia, y se denomina cross compiler, pues el código generado se ejecuta en otra arquitectura o entorno. Se ha utilizado, así, una versión compilada de G++ con el target amd64-elf
, genérico, que se enfoca en producir binarios en formato ELF para x86-64. El repositorio toolchain contiene scripts para compilar estas herramientas, y su uso se explica en el Apéndice Cómo Compilar.
Se ha usado CI/CD, en forma de GitHub Actions, en el repositorio principal del repositorio (explicado más adelante), así como para la toolchain. De esta forma, están públicamente disponibles archivos ISO para ser descargados.
El proyecto se denomina The Strife Project. Su nombre proviene de Empédocles, filósofo presocrático de la Grecia clásica, cuya metafísica (arjé, esencia del ser) estaba basada en elementos. De esta forma, existían dos fuerzas que formaban el universo: el amor (love), que unía los elementos, y el odio (strife), que los separaba \cite{empedocles}.
Un sistema operativo se comienza y se abandona, jamás se termina. No está dentro de mis intenciones abandonarlo en el futuro, sino seguir trabajando en él en los próximos años de ser posible. Por esto, esta memoria retrata el estado de Strife en su versión identificada con el tag TFG
.
Strife se publica como una distribución. Como se dejó ver anteriormente, la organización de GitHub contiene un repositorio por proyecto. Existe uno especial, llamado Strife, que mediante tecnología de git submodules combina todos los otros proyectos, en sus versiones concretas, utilizando un Makefile específico para ello. La versión entregada, generada por liberación continua, está disponible aquí \cite{strifetfg}, y la última, de leerse esto en el futuro, aquí \cite{strifelatest}.
La arquitectura objetivo de Strife es x86-64. Al final de la Sección Motivación e historia se explicó que comenzó con el target IA-32 y posteriormente se hizo el cambio. Por esto, en esta sección la pregunta a responder, más que por qué dicha ISA es el objetivo, por qué considerar x86. La respuesta es sencilla: es el por defecto. A fecha de hoy, y de las últimas décadas, cuando un individuo comienza su primer sistema operativo, lo hace en x86, pues le hace ilusión probarlo eventualmente en hardware real, y x86 es de lo que dispone. Este caso no es una excepción. Además, esta arquitectura tiene cierto valor histórico. De las que no están muertas (como podría ser el MOS 6502/6510), es la más antigua, y entre las líneas de los manuales de Intel y AMD, que aparecen multitud de veces en las referencias al final de la memoria, uno puede ver el pasado.
En la Sección Motivación e historia se expresó la intención de abandonar JBoot como bootloader. Su sustituto es Limine en su versión 3, un proyecto de bootloader liderado por una de las personas que conozco del mundo de hobby osdev \cite{limine}.
Limine soporta varios protocolos de arranque. A fecha de hoy, son: stivale, stivale2, Linux, y el protocolo de arranque propio de Limine. Strife está diseñado para stivale2, un protocolo muy simple que carga un ELF del kernel, y, en una sección del binario, detecta qué información desea recibir del bootloader \cite{stivale2}. Así, el kernel de Strife es compatible con todo bootloader que soporte el protocolo stivale2, no exclusivamente Limine. Entre estas opciones de recepción, Strife usa las siguientes:
- Text mode, para ejecutar el kernel en el modo gráfico de texto. En la Sección Gráficos en modo texto se encuentra su justificación.
- Memory map, para recibir de la BIOS las regiones de memoria física existentes. Posteriormente se explicará cómo se utiliza.
- Módulos, para resolver el problema del bootstrapping, explicado en la Sección Bootstrapping.
Es foco prioritario del proyecto la seguridad. Por esto, Strife toma todas sus decisiones con la seguridad en mente, en lugar de la velocidad. Estas son las medidas más generales:
- Diseño del kernel resistente a ataques Meltdown y Spectre por KPTI (Kernel page-table isolation). Esta es la primera decisión comentada en la memoria que se ve reflejada como tal en el código. En un sistema operativo usual, el kernel se encuentra en el higher half (como se dijo en la Sección Memoria), y marca sus páginas como globales. De esta forma, las páginas del modo supervisor están presentes en toda tabla de páginas, con lo que no es necesario hacer un cambio de contexto completo cuando ocurre una syscall. En 2019, se publicaron dos artículos que definían ataques sobre los procesadores por bugs en el hardware y permiten la lectura de páginas del kernel. El primero, Meltdown, por culpa de la ejecución fuera de orden que utilizan las CPUs modernas \cite{meltdown}. El segundo, Spectre, por culpa de la predicción de saltos \cite{spectre}. KPTI mitiga estos ataques haciendo que el kernel tenga su propio contexto, su propia tabla de páginas. Esto ralentiza mucho la mayoría de syscalls, a cambio de conseguir mitigar las vulnerabilidades.
- ASLR (Address Space Layout Randomization) obligatorio. Por este mecanismo, el cargador de programas monta el ejecutable y sus bibliotecas en distintas regiones de memoria generadas aleatoriamente. ASLR está habilitado por defecto en todos los sistemas operativos. Sin embargo, en Linux es posible deshabilitarlo, y en Strife es obligatorio.
- SMAP (Supervisor Memory Access Protection) y SMEP (Supervisor Memory Execute Protection). Se trata de dos mecanismos de seguridad hardware. SMAP aparece en Broadwell (quinta generación), aunque SMEP existía desde Ivy Bridge. Así, únicamente se activa de estar en una arquitectura reciente, de otro modo se ignora, no es mandatorio tener Broadwell para arrancar Strife. SMEP previene que el kernel, ejecutándose en ring 0, ejecute código que es accesible para el usuario. Esto nunca debe suceder, pues el código accesible para el usuario viene de un ejecutable arbitrario. SMAP, en cambio, previene cualquier tipo de acceso a memoria de usuario desde ring 0. En ocasiones sí es necesario realizar un acceso, y por tanto la prevención solo actúa cuando la flag AC (Alignment Check) de la CPU está a 0. Cuando el kernel desee acceder, alza la flag, lee o escribe en la memoria de usuario, para después bajarla de nuevo.
- NX stack obligatorio. Similar a ASLR, es un mecanismo dado en la mayoría de SSOO por defecto, aunque es posible desactivarlo, mientras que en Strife es mandatorio. Consiste en marcar las páginas de la pila como no ejecutables, lo que mitiga varios ataques de buffer overflow.
- Full RELRO (RELocation Read-Only) mandatorio. A la hora de resolver las referencias a funciones de bibliotecas enlazadas dinámicamente, el kernel puede optar por resolverlas todas de golpe, o conforme vaya siendo necesario (por medio de trampas page fault). Resolverlas todas de golpe aumenta el tiempo que tarda el ejecutable en cargarse, pero permite la posibilidad de aplicar el mecanismo conocido como Full RELRO. Las referencias a funciones dinámicas se encuentran en una sección propia del ejecutable, la GOT (Global Offset Table). Cuando Full RELRO está activo, se garantiza que esta sección estará en páginas independientes, con tal de poder ser marcadas como solo lectura una vez se hayan resuelto las referencias, lo que imposibilita ciertos ataques ROP (Returned Oriented Programming).
El apartado gráfico no entra dentro de los intereses del proyecto. Durante el arranque, las BIOS modernas (desde 1989) ofrece un rango de interrupciones para VBA (VESA BIOS Extensions, INT 10h), que aporta una funcionalidad simple, pero completa, para obtener una lista reducida de modos gráficos VGA y poder cambiar entre ellos \cite{vbe}. Los sistemas operativos más avanzados contienen sus propios drivers de vídeo para cada tarjeta gráfica y se comunican con ella así, lo que permite hacerlo dinámicamente y no durante el arranque, pero para los proyectos independientes es usual usar VBA, o GOP (Graphics Output Protocol) en el caso de UEFI \cite{gop}. De forma general existen dos modos gráficos:
- Modo vídeo. Es el usado por todos los sistemas operativos modernos. Se selecciona una resolución gráfica (ancho x alto) entre las soportadas, y la BIOS asigna un área de la memoria para un framebuffer de colores, según la profundidad de bits, aunque generalmente son 3 bytes por píxel.
- Modo texto. Retrocompatible con el IBM PC original, son modos gráficos en los que el framebuffer está constituido por pares
<caracter, color>
. Se selecciona por BIOS un modo según sus filas y columnas, y a partir de ahí se pueden escribir caracteres en pantalla.
En modo vídeo, es necesario rasterizar el texto si se quiere trabajar con una terminal y no un entorno gráfico completo, pues todo lo que se tienen son píxeles. Sin embargo, en modo texto, el programador del sistema tan solo selecciona la fuente y los caracteres. De no seleccionar la fuente, se tomará una por defecto, que resulta ser la famosa code page 437, apreciable en la Figura fig:437.
Una de las partes más importantes del sistema operativo es el loader, o cargador de programas (no confundir con bootloader). Su función es la de, a partir de un ejecutable, comprender las estructuras que lo forman y llegar a tener una representación final en memoria principal, el proceso. En Strife, como en varios otros SSOO microkernel, el loader se encuentra en userspace, es decir, es un proceso independiente. Esto supone un mayor reto técnico a tenerlo dentro del kernel, pues complica el problema del bootstrapping (se explicará en la Sección Bootstrapping).
Es muy positivo sacar el loader del kernel. El objetivo de un microkernel es que los fallos de un servicio no afecten a otro, y un loader es muy propenso a fallos. No solo es difícil de escribir (por relocation, sobre todo), sino que es, más que nada, muy fácil hacerlo mal: hay muchos punteros a distintas secciones del ejecutable, y cada uno de ellos debe ser comprobado para no acceder a memoria indebida. En muchas ocasiones tienen offsets, con lo cual la suma ha de ser hecha comprobando si se ha producido acarreo, y lo mismo pasa con los índices, cuyas multiplicaciones se han de computar de forma segura.
Como se dejó entrever en la Sección Herramientas utilizadas, el formato elegido para los ejecutables es ELF (Executable and Linkable Format), originario de UNIX V4, y que utilizan muchos UNIX-like, entre ellos GNU/Linux y los BSDs. ELF es un formato bien diseñado, pero sus fundamentos se dejan para el siguiente capítulo.
POSIX (Portable Operating System Interface) es un estándar del IEEE que define una interfaz y entorno de sistema operativo, así como una shell y un conjunto de utilidades, que sigue la línea de UNIX. Un sistema operativo es POSIX si cumple con el estándar, y entre ellos se podrían encontrar GNU/Linux, OpenBSD, FreeBSD, NetBSD, y macOS.
Muchos SSOO independientes se adhieren a POSIX, porque gran parte de las decisiones de diseño están ya tomadas. Strife no acepta POSIX, y rediseñan grandes partes de la API y el entorno. Toma ideas, por supuesto, como la existencia de una shell similar, la sintaxis de rutas, y la idea de puntos de montaje. También implementa algunas de las funciones del estándar de C (memcpy
, memset
…), y un printf
equivalente, por poner algunos ejemplos.
Strife idealmente debe tener dos formas de IPC: memoria compartida, y RPC. Memoria compartida es la más simple, y se explicará en esta subsección. Un proceso A desea compartir memoria con otro proceso B; para ello, el kernel mapea la región de memoria física que A quiere compartir en el espacio de direccionamiento de B.
POSIX utiliza un mecanismo de memoria compartida global. No es complejo de entender: en POSIX, el proceso de compartir memoria entre procesos se lleva a cabo por medio de claves (keys), y estas están vinculadas a un fichero del sistema de archivos (en UNIX todo es un archivo, la traducción se hace mediante la función ftok
\cite{ftok}). Así, A debe hacerle saber a B de forma indirecta que dicho archivo es el que tiene la clave vinculada a la región de memoria a compartir. Esto es un mecanismo global porque utiliza un contexto global del sistema operativo: el sistema de archivos. Teóricamente, cualquier proceso que tenga permisos de lectura sobre ese archivo, puede obtener la clave y montar la región en su espacio de direccionamiento, realizando snooping. Windows hace algo muy similar con las funciones de la API CreateFileMapping
y MapViewOfFile
\cite{createfilemapping}.
En Strife se busca un mecanismo de memoria compartida local, en el que el proceso de compartir memoria se realiza sin claves asignadas a archivos, de forma que verdaderamente solo A y B conozcan que están compartiendo memoria, y no sea posible el snooping desde fuera. Así, se define el concepto de SMID (Shared Memory Identifier), un identificador de la región compartida que es relativo a los PCBs de A y B. La compartición se hace por medio de syscalls específicas, y se explicarán en el siguiente capítulo.
La decisión más importante de Strife radica en la elección de RPC como IPC base, algo inusual en los sistemas operativos. RPC se puede entender como un caso específico de paso de mensajes síncrono, en el cual los mensajes son muy cortos, y están dirigidos a una función en concreto.
Durante la rutina de syscall que tiene el kernel, la absoluta primera cosa que se hace es comprobar si el identificador de syscall es el de RPC, en cuyo caso todo el flujo de la rutina cambia y se ejecuta otro, escrito puramente en ensamblador, y que asegura jamás pasar por el scheduler. De forma un poco abstracta y sin llegar al nivel de instrucción, será explicada en detalle en el capítulo siguiente.
Implementación original de RPC: en esta sección se define un mecanismo de RPC de diseño propio, la ejecución dual. Cuando el cliente realiza RPC, no queda bloqueado en el proceso. En su lugar, su flujo de ejecución entra dentro de la tarea remota. Esto hace que sea un mecanismo de IPC muy veloz, puesto que el salto se produce con un cambio de contexto limitado (se mantienen todos los registros del cliente, aunque se guardan para restaurarse luego, idea tomada de L4) y cero copias. Este uso de registros deja al programador un total de 4 registros para argumentos del procedimiento remoto, lo que son 32 bytes de datos. La ejecución dual hace que en todo momento un proceso se esté ejecutando, o bien como sí mismo, o bien como otro: un proceso en ejecución es un par <PID original, PID efectivo>
. Así, al contrario de lo que suele ocurrir en muchas implementaciones de paso de mensajes, se libera al servidor de la carga de abrir distintos threads para escuchar, y solo tiene que manejar las secciones críticas mediante cerrojos, semáforos, o directamente mediante el uso de algoritmos lock-free. Como es el cliente el que se está ejecutando dentro del servidor, y no el servidor en sí, el scheduler considera que es precisamente el cliente el que está en ejecución, no el servidor, y por tanto es este el que sube y baja en las colas de prioridad retroalimentadas. Esto hace que comportamientos costosos en tiempo del cliente no pasen desapercibidos y se culpe de ellos al servidor. De esta forma, los drivers esenciales del sistema operativo no necesitan tener una prioridad alta: su orden de ejecución depende únicamente del remitente.
Strife, además, tiene un mecanismo que se ha denominado PSNS (Public Service Namespace). Se trata de un servicio, el primero que se ejecuta durante el bootstrapping, que asigna nombres de hasta 8 caracteres a PIDs en ejecución. De esta forma, los servicios se publican al PSNS mediante RPC, dando un nombre para ser reconocidos por los clientes. Los clientes resuelven el nombre, obteniendo el PID en el proceso, y pueden comenzar la comunicación por medio de RPC. Para obtener el PID del PSNS existe una syscall específica, que se explica en el capítulo siguiente.
Este mecanismo evita la existencia de los archivos .pid
muy usuales en entornos GNU/Linux, y a penas complica el kernel.
De igual forma que NT y XNU utilizan schedulers derivados de MLFQ, Strife sigue por esta línea. Como se explicó en la Sección Scheduler, se trata de un scheduler con estructuras de datos extremadamente simples (lista enlazada), y esto lo hace perfecto para un microkernel. Como también se comentó, sufre de un problema de inanición a posibles tareas de alta prioridad, cuyo valor dinámico puede decrecer con el paso de las ráfagas.
Strife utiliza un scheduler autodenominado SMLFQ (Sandwich MLFQ). Se separa el scheduler en tres independientes:
- MLRR con
$n_v$ colas con prioridad - MLFQ con
$n_r$ colas con prioridad retroalimentadas (dinámicas) - MLRR con
$n_b$ colas con prioridad
El pan de arriba es un scheduler VSRT (Very Soft Real Time); esto es, un scheduler simple de máxima prioridad que, por diseño e intencionadamente, permite la inanición de procesos con menor prioridad, sin llegar a implementar deadlines. El contenido del sandwich es un MLFQ usual (regular), configurable de distintas maneras según el sistema operativo. El pan de abajo es un MLRR simple para permitir procesos en segundo plano (background), asegurando que nunca quitarán recursos si hay procesos regulares disponibles.
Concretando para Strife, se utilizan los valores
Un sistema operativo soporta uno o más sistemas de archivos. En el caso de la mayoría de SSOO, estos SSAA están fundados en grandes ideas de décadas pasadas (como el SA de UNIX), a veces alteradas con conceptos modernos de almacenamiento (familia ext*). Los SSOO en los que trabajan miles de personas de forma diaria suelen desarrollar su propio sistema de archivos para plasmar correctamente la visión abstracta que tienen de un entorno asociado a la filosofía del sistema. Curiosamente, esto también pasa en los SSOO independientes, que son proyectos personales, puesto que su objetivo primario es aprender y experimentar. Todo el rango de sistemas operativos que hay en medio, especialmente aquellos dedicados a sistemas empotrados o que tienen una finalidad específica, utilizan alguno de los ya existentes.
Strife aporta su granito de arena al mundo de los sistemas de archivos con StrifeFS (Strife File System), que toma ideas de ext2, pero también de NTFS, el de Windows. StrifeFS está diseñado para ser el sistema de archivos más simple que puede implementar los mecanismos aquí descritos, y a cambio se sacrifican características como la tolerancia a fallos de sectores. Por esto, no es un sistema de archivos que pueda competir con el resto, sino más bien una prueba de concepto. Un futuro StrifeFS2 podría tomar estas ideas y llevarlas a algo más robusto [MM3]. Por todo esto, StrifeFS no soporta journaling.
Para empezar: los conceptos base, las capas más físicas del sistema de archivos, se las debo a ext2. La indexación multinivel de los datos, así como el concepto de inodos, son ideas que han sentado precedente, posiblemente llegando a categorizarse como inmejorables. Si bien se podría criticar la dispersión de los bloques en memoria secundaria (que ocurre en el resto y se puede solucionar por desfragmentación), y requerirían que el cabezal de lectura del disco trazara trayectorias innecesariamente amplias, con la llegada, ya bien instaurada en el mercado, de los SSD, este problema desaparece. En StrifeFS, los inodos se reservan de manera FIFO usando un bitmap, de igual forma que los bloques.
El único mecanismo de protección existente en el sistema de archivos son los ACLs, teniendo cada archivo el suyo propio.
Aportación sobre ext2: ACLs jerárquicos. Por defecto, y a menos que se especifique lo contrario, todo archivo parte de un ACL vacío, que no indica otra cosa sino ningún cambio y, desde ese archivo, se recorre la jerarquía de directorios hacia arriba aplicando todos los cambios, teniendo más prioridad los más cercanos al archivo, y menos los de la raíz. Por ejemplo, privado
, dentro de notas
, puede tener un ACL que incluya una única regla: prohibida la lectura a alumnos
. Subiendo la jerarquía, la prohibición de privado
tiene más prioridad que el permiso de notas
, con lo que la combinación de todos estos ACLs, hasta la raíz, sería la lista de control de acceso efectiva del archivo. Un ejemplo de esta índole se encuentra en la Sección Puesta a prueba.
Esta solución, aunque más lenta (pese a acelerable por cachés), simplica mucho la jerarquía de directorios de todo el sistema operativo. Como nota, NTFS, el sistema de archivos de Windows, sí implementa ACLs, pero carecen de herencia. Por defecto, un archivo hereda la herencia del padre, pero no puede realizar cambios sobre él que se sumen a los de los niveles superiores \cite{ntfs}.
StrifeFS divide la memoria secundaria en cinco secciones, representadas en la Figura \ref{fig:strifefs}.
Se definen cuatro estructuras: el superbloque, el inodo, la entrada de directorio, y la entrada de ACL.
El superbloque es la estructura que describe el estado del sistema de archivos, así como sus atributos de formateo. Está compuesto por los siguientes campos:
- Firma para identificar el sistema de archivos:
StrifeFS
. - Número de inodos y bloques. Estos campos se establecen durante el proceso de formateo y se mantienen estáticos durante toda la vida del sistema de archivos.
- Número de inodos y bloques libres. Tras el formateo, comienzan con el mismo valor que los campos anteriores. Cada vez que se reserva un inodo o un bloque, se decrementa el valor.
- LBAs del bitmap de inodos y de bloques. De esta forma, se identifica dónde se sitúan en memoria secundaria para poder acceder a ellos. Son valores estáticos.
- LBAs de primer inodo y primer bloque. Similar a los campos anteriores, el sistema de archivos identifica las regiones de esta manera. También son estáticos.
Tras el superbloque se encuentran los bitmaps de inodos y bloques. Estas regiones están compuestas por sendos sectores utilizados como mapas de bits dinámicos. El primer campo del bit de inodos, que correspondería al bit más significativo del primer byte del primer sector, indica si el inodo
El inodo y el bloque con identificadores
Tras los mapas de bits, se encuentra la región (o tabla) de inodos. Un inodo es una representación abstracta de una entrada virtual del sistema de archivos, como puede ser un archivo. Se definen según la siguiente estructura:
- Tamaño del contenido en bytes.
- Timestamps del momento de la creación, última modificación, y último acceso.
- Número de enlaces. De igual forma que en el sistema de archivos de UNIX, distintos directorios pueden contener el mismo archivo. Es necesario mantener la cuenta para poder liberar los datos cuando el contador llega a cero.
- Número de bloques (particiones del contenido) que utiliza.
- Tipo. Existen 4:
- Regular; es decir, un archivo usual.
- Directorio, cuyos contenidos son entradas de directorios (definidas más adelante).
- ACL. Se utiliza un tipo de inodo específico para el ACL, lo que simplifica mucho la implementación. Sus contenidos son entradas de ACL.
- Enlace, para los enlaces simbólicos. Sus contenidos son una ruta.
- Catorce índices de bloques directos.
- Índice de bloque indirecto de nivel 1.
- Índice de bloque indirecto de nivel 2.
- Índice de bloque indirecto de nivel 3.
Tras la tabla de inodos, comienza la región de bloques. Un bloque se define como una región de 512 bytes. De esta forma, se mantiene una representación independiente del tamaño de sector del medio de almacenamiento masivo donde se sitúa el sistema de archivos. Si el tamaño de sector es mayor, se almacenan en él todos los bloques para los que haya espacio.
La entrada de directorio es simple, y se define por tres campos:
- Número de inodo.
- Tamaño del nombre.
- Nombre, de longitud variable.
Por último, la entrada de ACL define una lista de control de acceso. Un inodo de tipo ACL contiene varias, y se definen por los siguientes campos:
- Bitmap de flags, 64 bits.
allow
, si esta entrada corresponde a un permiso dado (1) o retirado (0) sobre los heredados.isUser
, si corresponde a un usuario (1) o a un grupo (0).read
, si se dan permisos de lectura sobre el archivo.write
, si se dan permisos de escritura.- El resto de bits, los 60 hasta llegar a 64, están reservados para uso futuro.
- Identificador. Dependiendo de si
isUser
está a 0 o 1 es un GID o UID respectivamente.
Nótese:
- No existe un permiso de ejecución. En Strife, como se explicará más adelante, el kernel jamás carga un ejecutable, sino que es tarea del proceso padre.
- El permiso de escritura sobre un archivo aplica también a la escritura sobre su ACL.
- StrifeFS, además, no especifica una estructura para almacenar usuarios o grupos, tan solo se referencian sus identificadores, con lo que queda a elección de la implementación.
- La raíz queda formateada con permisos de lectura y escritura para el UID 1, cuyo nombre de usuario se aconseja que sea
system
.
Habiendo resuelto el problema de la granularidad de permisos del sistema de archivos en la sección anterior, hay uno que no debe pasar desapercibido: los permisos en tiempo de ejecución.
Strife propone un nuevo sistema de MAC: el registro. Siguiendo la línea del microkernel L3 de Liedtke, gestionar dentro del kernel los permisos de IPC es subóptimo y ralentiza todo el sistema, así que, en su lugar, se delega esta responsabilidad a los servicios individuales. Strife toma esta idea y aporta el registro como un mecanismo extranuclear que pueden usar los servicios, de así desearlo, para consultar de forma uniforme todos sus permisos. El identificador de procedimiento que recibe un servicio en cada IPC sería, así, comprobado en el registro al llegar, y cacheado en el servicio para minimizar los cambios de contexto. En la Figura \ref{fig:registro} se encuentra un posible escenario de uso del registro.
El registro administra las capacidades de los usuarios, no de los programas. De esta forma, un usuario solo puede ejecutar un programa si los permisos que requiere (descubiertos en tiempo de ejecución) son un subconjunto de los que posee. Este mecanismo se denomina DAC (Discretionary Access Control), en el sentido de que los permisos del usuario pasan a los permisos del programa. Siguiendo esta línea, si el programa ejecutara otro, también se aplicaría una máscara. Así, un usuario jamás puede ejecutar un programa, directa o indirectamente, que requiera privilegios que no tiene: los privilegios solo pueden reducirse en el camino. Es diferente, sin embargo, el caso de RPC, donde se asume que el servidor implementa las protecciones apropiadas. Un usuario normal puede ejecutar un programa (tests
, por ejemplo), que se comunique con VFS
, que es ejecutado por el usuario system
.
Un punto clave es que el registro se trata de una base de datos volátil, que únicamente se encuentra en RAM. El administrador puede configurar el registro por medio de scripts que rellenan la tabula rasa con quién tiene permitido hacer qué, y no al revés: se trata de un sistema seguro por defecto, en el que, antes de la ejecución de estos scripts, nadie (excepto el superusuario y los programas que ejecuta) puede realizar ninguna acción.
Además de la gestión de permisos, el registro es responsable de mantener las políticas de escalación de privilegios. Para un usuario, contiene quién puede actuar como él (realizar el cambio de usuario), así como modificarlo. Un usuario puede aportar un permiso a otro solo si él cuenta con dicho permiso de antemano.
Para tener una vista general del sistema operativo para las siguientes secciones, los proyectos específicos que forman la distribución oficial de Strife, en la versión indicada al principio del capítulo (entrega del TFG), se encuentran mencionados en esta sección. En la Figura \ref{fig:arq} se encuentra un diagrama arquitectónico de los proyectos aquí descritos. Tras él, se mencionará el uso de cada uno.
stdlib
, la biblioteca estándar para comunicación con el kernel. Incluye una STL con estructuras de datos abstractas.
loader
, el cargador de programas en userspace.term
, primer servicio que carga el loader. Es el driver del modo texto, que controla el framebuffer que da la BIOS (pasando por el bootloader). Escribe en pantalla y maneja el cursor y el scrolling.registry
, el registro tal y como fue explicado en la Sección El registro: un entorno innovador.users
, que actúa como mediador para traducir entre UIDs y nombres de usuario.switcher
, único servicio habilitado para realizar el cambio de usuario de un proceso.keyboard
, driver que implementa la funcionalidad del teclado.
PCI
, un driver simple para la conexión con periféricos y tarjetas de expansión del conocido bus estándar.AHCI
, driver para conectar con dispositivos SATA, usado para ATAPI (CD de arranque).ramblock
, servicio que implementa un dispositivo de bloques en RAM, simula ser un disco duro.block
, servicio que abstrae los dispositivos de bloques (AHCI
yramblock
), y los nombra por UUIDs.ISO9660
, servicio que implementa el sistema de archivos ISO9660, usado universalmente por los CDs.StrifeFS
, servicio que implementa el sistema de archivos explicado en la Sección Diseño de StrifeFS.VFS
, servicio que abstrae todos los sistemas de archivos por medio de puntos de montaje sobre una misma raíz.
init
, el último programa que ejecuta el kernel para completar el arranque. Se encarga de iniciar el resto.splash
, un programa muy simple, el primero ejecutado porinit
en la distribución oficial, muestra un banner con el texto Strife.shell
, el intérprete de comandos por defecto, funciona de forma similar al de UNIX.coreutils
, una colección de programas básicos para interactuar con el sistema.tests
, batería de tests unitarios para probar servicios del sistema.
\FloatBarrier Conociendo todos los programas que forman parte de la distribución oficial, resulta de especial interés tener una forma gráfica de ver cómo los procesos que en todo momento están activos se comunican entre sí, se encuentra en la Figura \ref{fig:grafo}. Todos ellos están conectados al registro, con lo que no aparece en el grafo para tener una representación visualmente más limpia.
El bootstrapping es el proceso de emerger un entorno de la nada. Es un problema propio de los microkernels porque, en el momento del arranque, el kernel no tiene capacidad para completar el inicio por sí solo. Cuando un kernel monolítico o híbrido arranca, cuenta con una pila de almacenamiento completa que le permite comenzar a cargar programas, y el primero que suelen cargar es init
. En un microkernel, es necesario cargar los servicios que componen la pila para ello: ¿Cómo cargar el programa que permite acceder a los programas? El problema se vuelve más complejo aún cuando el loader se saca del kernel y se hace un programa independiente: ¿Cómo cargar el programa que carga los programas?
Existen varias soluciones a la primera pregunta:
- Repetir los drivers, usando solo las versiones del kernel durante el proceso de bootstrapping, y abandonándolas después. Esto es muy difícil de mantener, porque las dos versiones han de ser necesariamente distintas: el entorno del kernel es muy distinto al userspace. Se podrían abstraer las diferencias en la biblioteca estándar, pero se complicaría más de lo debido dicho proyecto.
- Propagar el problema hacia arriba. Se repiten los drivers, pero en el bootloader, donde necesariamente se encuentran ya repetidos. Muchos protocolos de arranque (entre ellos, stivale2) permiten enviar ficheros del sistema de archivos cargados durante el arranque al kernel, y, de esta forma, el kernel solo tiene que buscarlos.
El kernel de Strife utiliza la segunda opción. Para responer a la segunda pregunta (carga del loader), tan solo hay una respuesta: tener un cargador de programas simplificado dentro del kernel. Con tal de simplificar el código, se suele restringir el tipo de binario a cargar para que sea enlazado estáticamente. En el caso de ELF, esto quita mucha de la complejidad del proceso de parsing.
El proyecto Strife va un paso más allá y diseña un formato propio de ejecutable extremadamente simple para ser cargado con poco código. Se trata de SUS, y su estructura es la siguiente:
- Número mágico para ser reconocido:
7F555355
(4 bytes). - Entry point del ejecutable (8 bytes).
- Binario plano. Se trata de un formato de ejecutable sin estructura, cuyas páginas en memoria son iguales a las páginas del binario. Es así como ha de escribirse el stage 1 de un bootloader.
En el repositorio del loader se aporta un programa, elf2sus.py
, que realiza la transformación de un formato a otro. Este programa es llamado en el Makefile del subproyecto, de forma que el fichero resultante del proceso de compilación es loader.sus
.
El tipo de archivo SUS es el más simple posible que contiene un cargador de programas para ser cargado como primer paso de bootstrapping de un microkernel. Si bien se podría hacer directamente un binario plano, requeriría que el punto de entrada estuviera en 0, y cargar una tarea con dirección base 0 resulta mala idea, puesto que la derreferencia del puntero nulo es válida.
Para terminar el capítulo, en la Figura \ref{fig:boot} se encuentra el orden en el que se arrancan los distintos procesos. Su número corresponde, además, con su PID.
El kernel, siendo el proyecto más grande y complejo de todo el trabajo, toma muchas decisiones que son importantes de mencionar para entender cómo realiza las intenciones expresadas en el capítulo anterior. Se hará primero un repaso por lo más fundamental: los descriptores, tanto de segmentos como de interrupciones. Sin ellos, no existe sistema operativo. Tener una GDT válida permite la gestión de la memoria, y este será el siguiente tema a atacar. Después, comienza el userspace: syscalls, IPC, scheduler, y, para finalizar, cómo funciona el equivalente a main
en el kernel.
En un x86, en todo momento debe de haber presente una GDT válida. Al arranque, BIOS proporciona una, que varía entre fabricantes, con lo que no se puede confiar en ella; en general, hay que desconfiar de las BIOS, la gran mayoría tienen bugs en unas partes u otras. Por esto, el bootloader, en su stage 1, hace el cambio a una GDT conocida, con segmentos especificados (muy simples), y, conforme va cambiando el modo del procesador, la va alterando. Cuando se llega al kernel, este debe establecer la suya propia, para ser independiente del bootloader.
Strife inicializa la segmentación con tan solo cuatro selectores generales:
- Kernel code, para el registro CS (Code Segment). Ring 0.
- Kernel data, para el resto de registros de segmentación. Ring 0.
- User code, también para CS cuando se está en userspace. Ring 3.
- User data, análogamente. Ring 3.
Después de estos cuatro, se añade un selector por cada core para las TSS, como se explicó en la Sección Interrupciones y syscalls.
La IDT se configura para que todo vector de interrupción tenga asociado un ISR por defecto, que todo lo que hace es causar un kernel panic. Para los vectores que sí se implementan, se introducen los punteros a sus ISRs correspondientes. Todo ISR comienza con un código en ensamblador, con tal de reorganizar los registros y el marco de pila que establece el procesador para traducirlos en parámetros a la parte del ISR escrito en el lenguajde alto nivel.
La mayoría de excepciones implementadas concluyen en que debe terminarse el proceso que la ha causado, si se ha producido en ring 3, o en kernel panic, si ha sido en ring 0. Esto incluye, por ejemplificar, con #GP (error de protección general), #DE (división por cero), #DF (double fault), y #UD (opcode inexistente). La única excepción es page fault, que se utiliza para varias cosas:
- Aumentar el tamaño de la pila. Cuando la página objetivo que ha causado la excepción está una por encima de la última página de pila reservada, y no supera el límite, se procede asignando más pila al proceso [MM4].
- Si el page fault fue causado en ring 3, en la mitad inferior de la memoria, su causa es una violación de la protección de la página, y se ha producido en el primer byte de esta, entonces se trata de una petición implícita de vuelta de RPC. Este ingenioso mecanismo se explicará en la Sección RPC en detalle.
El PMM (Physical Memory Manager) de un sistema operativo es la sección del kernel que se dedica a reservar y liberar la memoria física. Existen distintas formas de implementarlo. Se suele usar un buddy allocator \cite{buddy}, pero tiene problemas de fragmentación interna, especialmente cuando las regiones de memoria libres están divididas. Por esto, la elección es bitmap: por cada región de memoria libre, dada por el bootloader según la especificación de Stivale2, se crea un mapa de bits, en el que cada entrada representa una página. Esto permite la reserva de memoria contigua en
Si la reserva devuelve ERROR, entonces no hay memoria física disponible. En este caso, si es posible, se devuelve error. De no ser posible, por necesitar memoria en el kernel, debe existir un mecanismo de liberación forzada [MM6].
Las funciones de reserva y liberación de memoria física están implementadas bajo los nombres de PhysMM::alloc
y PhysMM::free
. También existe PhysMM::calloc
, que rellena la página física con ceros antes de devolverla, para evitar posibles filtraciones de datos.
Siendo capaz el kernel de reservar memorias físicas, y después de haber habilitado la paginación, se necesita hacer lo mismo en el espacio de direccionamiento virtual. En el caso de Strife, para mitigar los ataques Meltdown y Spectre, el VMM se separa en dos distintos: uno privado, que gestiona las páginas privadas del contexto del kernel, que resulta ser el PMM, y otro público, que trabaja con páginas marcadas como globales en todo contexto.
En la tabla de páginas del kernel, la mitad inferior de la memoria está mapeada uno-a-uno; es decir, las direcciones son las mismas en memoria física y virtual. Por esto, no es necesario realizar ningún paso extra a la hora de reservar una página privada.
El público es similar. Se hace la llama a la reserva del PMM, y después se mapea la dirección física PublicMM::alloc
, PublicMM::free
, y PublicMM::calloc
.
Antes de continuar con las explicaciones sobre el kernel, ha llegado el momento de enumerar las syscalls. Existen 35 de ellas, y algunas requieren privilegios de ejecución que se comprueban en el kernel desde el registro [MM7]. En la Tabla tab:syscalls se encuentran las diferentes syscalls que existen en el microkernel de Strife con información sobre cada una.
ID | Nombre | Área | Permisos necesarios | Argumentos |
---|---|---|---|---|
0 | EXIT | General | 1 | |
1 | MORE_HEAP | General | 1 | |
2 | MMAP | General | 2 | |
3 | MUNMAP | General | 2 | |
4 | MAKE_PROCESS | Loader | Solo loader | 0 |
5 | ASLR_GET | Loader | Solo loader | 3 |
6 | MAP_IN | Loader | Solo loader | 3 |
7 | BACK_FROM_LOADER | Loader | Solo loader | 3 |
8 | FIND_PSNS | General | 0 | |
9 | HALT | RPC | 0 | |
10 | RPC | RPC | 2-6 | |
11 | ENABLE_RPC | RPC | 1 | |
12 | RPC_MORE_STACKS | RPC | Solo kernel | 1 |
13 | SM_MAKE | Memoria compartida | 1 | |
14 | SM_ALLOW | Memoria compartida | 2 | |
15 | SM_REQUEST | Memoria compartida | 2 | |
16 | SM_GETSIZE | Memoria compartida | 1 | |
17 | SM_DROP | Memoria compartida | 1 | |
18 | SM_MAP | Memoria compartida | 1 | |
19 | ALLOW_IO | Hardware | IO_ALLOWED | 0 |
20 | ALLOW_PHYS | Hardware | PHYS_ALLOWED | 0 |
21 | GET_PHYS | Hardware | PHYS_ALLOWED | 1 |
22 | MAP_PHYS | Hardware | PHYS_ALLOWED | 3 |
23 | GET_PID | Tareas | 0 | |
24 | GET_ORIG_PID | Tareas | 0 | |
25 | EXEC | Tareas | 2 | |
26 | GET_LAST_LOADER_ERROR | Tareas | 0 | |
27 | GET_KILL_REASON | Tareas | 1 | |
28 | GET_EXIT_VALUE | Tareas | 1 | |
29 | WAIT | Tareas | 1 | |
30 | INFO | Tareas | 1 | |
31 | SWITCH_USER | Tareas | SWITCH_ALLOWED | 2 |
32 | LOCK | Cerrojos | 0 | |
33 | WAKE | Cerrojos | 1 | |
34 | CSPRNG | General | 2 |
\FloatBarrier
Se procede a explicar las que están marcadas como área general, hardware, y cerrojos; el resto se explicarán en sus correspondientes secciones.
-
EXIT
es la syscall que permite que un proceso termine. Recibe un argumento, su valor de salida. Al ocurrir, se libera la memoria del proceso, y su valor de retorno pasa al proceso padre. -
MORE_HEAP
reserva más heap. Tiene un parámetro que indica el número de páginas adicionales a reservar. De ocurrir la reserva, devuelve el puntero a la primera página reservada. De fallar, devuelve nullptr (0). -
MMAP
toma 2 parámetros, y hace una reserva de$a$ páginas en una dirección arbitraria dada por ASLR, asignando en el proceso los permisos$b$ , que son un mapa de flags:-
MMAP_RW
permite la escritura sobre las páginas. -
MMAP_EXEC
permite la ejecución. MMAP_RWX = MMAP_RW | MMAP_EXEC
-
-
MUNMAP
libera una región de memoria. Toma dos parámetros: un puntero a la primera página reservada, y una cantidad de páginas a liberar. -
FIND_PSNS
devuelve el PID, conocido por el kernel, del servicio PSNS, para que los procesos puedan hacerle RPC y resolver nombres. Más sobre esto en la Sección Flujo PSNS. -
ALLOW_IO
se usa para pedirle al kernel que habilite la flagIOPL=3
en el contexto del proceso. Esta flag en realidad son dos, pues es un valor numérico de dos bits (de 0 a 3), que indica al procesador el anillo de protección máximo que es capaz de ejecutar las instruccionesin
yout
para comunicarse con el hardware mediante PIO. La usa, por ejemplo, el driver de PCI. Realizar esta syscall requiere que exista una claveIO_ALLOWED
en el registro para este programa y usuario. -
ALLOW_PHYS
se usa para pedirle al kernel que acepte las siguientes syscalls. Realizar esta requiere que exista otra clavePHYS_ALLOWED
en el registro para este programa y usuario. -
GET_PHYS
obtiene la dirección física de una página del espacio de direccionamiento virtual del proceso. La usa, por ejemplo, el driver de AHCI para comunicarse con el hardware por MMIO. Require haber llamado antes aALLOW_PHYS
. -
MAP_PHYS
también requiereALLOW_PHYS
. Mapea, no reserva, una región consecutiva de$b$ páginas físicas, comenzando en la dirección física$a$ , en la memoria virtual del proceso, con el mapa de flags$c$ :-
MAP_PHYS_RW
para permitir la escritura sobre esta página. -
MAP_PHYS_DONT_CACHE
para evitar que la página se mantenga en caché (necesario para MMIO).
-
-
LOCK
bloquea el proceso a la espera de la siguiente en la lista. -
WAKE
libera a un proceso previamente bloqueado. Este par de syscalls componen el mecanismo de sincronización para los semáforos. -
CSPRNG
utiliza el generador de números aleatorios criptográficamente seguro del kernel para generar la cantidad de bytes pedida sobre un puntero dado.
El CSPRNG (Cryptographically Secure Pseudo-Random Number Generator) del kernel se usa a la hora de generar regiones ASLR, así como para satisfacer la syscall CSPRNG
, la forma estándar de generar números aleatorios. Actualmente, no es criptográficamente seguro, sino que usa xoshiro256** junto a splitmix64 \cite{xoshiro256ss} [MM8].
El CSPRNG funciona sobre una pool de entropía de 256 bits. Durante el arranque, de estar disponible la instrucción RDRAND
, que genera números aleatorios por hardware, se usa [MM9]. De no estarlo, se rellena la pool con el número 42
, cuatro veces, una por cuadrupalabra. [MM10]
Esta es la sección más compleja del proyecto. Para empezar, las syscalls. IPC por RPC en Strife usa cuatro:
RPC
realiza una llamada a procedimiento remoto de forma síncrona. Toma 6 argumentos, aunque solo 2 de ellos son necesarios desde el punto de vista del kernel: el PID destino, y el RPID (Remote Procedure ID), es decir, un identificador, que el cliente debe conocer de antemano, que representa a la función que intenta llamar. El resto de argumentos son los parámetros que recibirá el procedimiento remoto.ENABLE_RPC
efectúa el mecanismo descrito arriba: permite que otros procesos entren dentro de él. Además, recibe un parámetro: el entry point de RPC, como si fuera un ISR. Debe estar escrito en ensamblador.HALT
se usa para parar el flujo de ejecución del programa sin terminarlo. Al ocurrir, se libera la pila y el proceso queda en estado bloqueado indefinidamente. La intención es que se use para quedar esperando a recibir un RPC. Posible causa de confusión: el proceso ya debe estar preparado para recibir RPCs (medianteENABLE_RPC
),HALT
tan solo detiene el flujo principal del programa para que no termine.RPC_MORE_STACKS
se explicará en breves instantes.
Sabiendo esto, se puede definir el flujo de comunicación del cliente y del servidor. Primero, el del cliente:
- De no conocer el PID remoto, se efectúa un RPC primero a PSNS (utilizando
FIND_PSNS
de no haber cacheado el PID aún). Podría ya conocerse para evitar públicamente este valor, por ejemplo, si el proceso con el que se quiere comunicar es hijo del cliente. - Realiza el RPC con los parámetros dados.
El del servidor es ligeramente más complejo:
- Primero, define el entry point para las llamadas. En la Sección Cabecera rpc se explicará cómo la stdlib encapsula este comportamiento.
- Después, habilita la entrada de RPC mediante la syscall
ENABLE_RPC
, pasando como argumento el puntero a la función de entrada. - De desearlo, realiza un RPC al PSNS para publicar su nombre.
- Finalmente, efectúa la syscall
HALT
para detener el flujo de ejecución del main.
Todo proceso tiene en su PCB un vector de 256 pilas para su uso en RPC. El código en ensamblador recorre esta lista buscando una pila libre y, de estar todas las presentes en uso (o directamente no haber ninguna presente), se intenta reservar una nueva. Esta reserva sí se hace desde el código en C++, pues no resultaría factible hacerlo en ensamblador. Por esto, si hacen falta más pilas, toda expectativa de hacer la ida rápido se desvanece, y el código efectúa una syscall especial, RPC_MORE_STACKS
, únicamente disponible para el kernel en esta situación tan concreta. Si durante el intento de reservar resulta que todas las 256 pilas están en uso simultáneamente, el cliente se bloquea y queda en la cola de un semáforo hasta que una de ellas se libere [MM11]. Este es el absoluto peor de los casos; en el más usual, hay una pila libre, se toma, y se marca como en uso, haciendo que durante toda la rutina no sea necesario cambiar a la tabla de páginas del kernel, lo que hace que la ida de RPC tome únicamente un cambio de contexto, aún con la protección de Meltdown presente.
Un RPC está compuesto por dos pasos: una ida y una vuelta. Un concepto fundamental en Strife es el return ticket (billete de vuelta): una estructura en la pila del servidor que guarda la información sobre cómo volver. Aquí surge un problema: ¿Cómo guardar la información necesaria para volver sin la posibilidad de que el servidor escriba sobre ella? Resultaría catastrófico: permitiría a cualquier programa saltar a cualquier dirección de cualquier proceso sin comprobación de privilegios. Para mantener la localidad solo hay una forma: hacer la página solo accesible por el kernel. De esta forma, es necesaria una página únicamente para el billete de vuelta, cuyos privilegios de acceso cambian antes de terminar la ida.
A la vuelta, es necesario comprobar que la página en la que se encuentra este billete de vuelta es, efectivamente, del kernel, para evitar falsas vueltas de RPC que nunca han tenido una ida. Esta comprobación resultaría costosa en tiempo y difícil de programar: teniendo la página virtual, habría que recorrer los cuatro niveles de paginación para comprobar que efectivamente existe y además es propiedad del kernel. Además, en ensamblador, porque todo lo relativo a RPC está en dicho lenguaje. Existe una solución mucho más elegante: causar un page fault desde el servidor que indique la vuelta. Este page fault se disparará al intentar leer bytes de esta página, pues es propiedad del kernel, y el ISR comprobará, como se presagió en la Sección IDT e ISRs, si efectivamente se cumplen las condiciones que identifican a un return ticket: ser una página en la mitad inferior de la memoria que a su vez es únicamente accesible por el kernel. De darse el caso, el kernel hará el salto a la rutina de vuelta de RPC, y se habrá evitado hacer la comprobación por software: la habrá hecho la MMU.
Las bases de RPC han quedado, con esto, explicadas. El código que las realiza es difícil de entender y lleno de trucos de ensamblador que no se consideran de relevancia para explicar aquí. Las rutinas de ida y vuelta de RPC contienen el código más complejo que he escrito en mi vida, y estoy orgulloso de ellas. Por si el lector quisiera echarles un ojo, se encuentran aquí \cite{rpcswitcher}.
El procedimiento de compartir memoria se hace parcialmente en el kernel, parcialmente fuera. El PCB tiene un campo con un puntero a una página específica para memoria compartida. En ella, se almacenan cuartetos <SMID, kptr, tptr, allowed>
; es decir, el identificador, la primera página física, la primera virtual en el espacio de direccionamiento del proceso que la pidió, y el proceso con el que quiere compartirla. Una tarea solo puede compartir 128 páginas simultáneamente (son los cuartetos que caben en una página, 4*8 bytes sobre 4096) [MM12].
Se definen seis syscalls:
SM_MAKE
crea una nueva región de memoria compartida. Recibe un argumento: la cantidad de páginas. Devuelve un SMID (Shared Memory Identifier) de 64 bits generado aleatoriamente. Se espera que la ejecute el cliente.SM_ALLOW
recibe como argumentos el SMID y un nuevo valor para el campoallowed
, con el PID del servidor.SM_REQUEST
es ejecutada por el servidor, y tiene como argumentos el PID del cliente y el el SMID. Esta syscall copia el cuarteto de memoria compartida del cliente al PCB del servidor, si así lo dictaallowed
.SM_GETSIZE
es para el servidor, y su único argumento es el SMID. Devuelve el número de páginas por el que está compuesta la región. Esto permite al servicio hacer comprobaciones sobre el espacio disponible para las estructuras requeridas.SM_MAP
, ejecutada tanto por el cliente como el servidor, toma el SMID y, finalmente, monta la región compartida al espacio de direccionamiento virtual del proceso que efectúa la syscall, dada la base por ASLR.SM_DROP
suelta el SMID del PCB de la tarea que lo ejecuta para permitir que entren otros. Nótese que no libera, simplemente elimina la estructura; la liberación se lleva a cabo porMUNMAP
, y debe realizarse primero.
El mecanismo de SM_REQUEST
está hecho para que el servidor pida expresamente que quiere ese SMID en su lista (recordemos, finita) de regiones de memoria compartida, y evitar un posible inundamiento malicioso de SMIDs. En estas syscalls, el lector se habrá dado cuenta de que en ningún momento el servidor conoce el SMID del cliente. Esta información ha de ser pasada de forma externa, y se recomienda el uso de un RPC para ello. Algunos servicios aportados en la distribución oficial contienen un procedimiento público (con su RPID asociado) denominado connect
que sirve para esto mismo. En otros, el SMID se toma como parámetro en cada llamada, y así montar una región nueva, del tamaño necesario, cada vez [MM13].
Habiendo explicado todo esto, el mecanismo para establecer una región de memoria compartida se puede encontrar en la Figura \ref{fig:sm}.
Esto rellena los campos poco a poco. Después de SM_MAKE
(que reserva una única página), uno de los índices aparece con su campo SMID distinto de cero, y kptr
(puntero del kernel, se podría entender como el físico), se reserva. Tras SM_ALLOW
, se rellena allowed
, y solo tras SM_MAP
se establece el valor de tptr
(task pointer). Unificar SM_MAKE
y SM_MAP
haría que el campo kptr
dejara de ser necesario, pues siempre habría un tptr
cuya página física es computable. Sin embargo, tendría la desventaja de que la reserva de memoria virtual solo se podría llevar a cabo antes de saber si ha habido un error en la comunicación por RPC, y los servicios perderían esta libertad.
Como se viene diciendo desde el principio de este trabajo, la estructura que representa un proceso es el Process Control Block. En Strife, el PCB forma una clase Task, y está compuesto por una serie de campos, muchos de los cuales han mencionado ya. Son los siguientes:
- Punto de entrada de RPC. Se define al principio de la estructura para tener un offset nulo, lo cual facilita su acceso desde ensamblador.
- Flags de RPC. Se encuentra en el segundo campo por la misma razón, y contiene el valor que debe tomar el registro RFLAGS a la entrada de un RPC.
- 257 punteros a pilas de RPC, todos inicializados a 1. Se dijo que eran 256 anteriormente, pero se mantiene un último cuyo valor es siempre 1 para detectar cuando no queden más pilas disponibles para asignar. Cada entrada puede tener tres valores:
- 0: esta pila en concreto existe, pero está en uso.
- 1: esta pila no existe, no está reservada.
- Cualquier otro valor: puntero a la pila, en el espacio de direccionamiento virtual del proceso.
- El puntero al PML4, administrado por una clase
Paging
que encapsula la complejidad de la paginación multinivel. - Estado salvaguardado. A la hora de interrumpirse el proceso, se guarda el estado aquí. Está formado por todos los registros de propósito general, así como RFLAGS.
- Valores de RIP (contador de programa) y RSP (puntero de pila) para ser restaurados cuando el proceso vuelva a ejecutarse.
heapBottom
ystackTop
contienen, respectivamente, cuáles son las páginas que representan el estado mayor asignado actualmente de la heap y la pila. En un punto dado de la ejecución del programa, podría tenerse una página de heap y dos de pila. En este caso,heapBottom
apuntaría a la base de la pila + tamaño de página, ystackTop
a la base del stack - 2 * tamaño de página.- PID de la tarea dentro de la cual se encuentra el flujo de ejecución (valor de
runningAs
, ver más adelante). - Un booleano sobre si la tarea ha llamado a
LOCK
y está pendiente de unWAKE
. - Número de páginas usadas.
prog
,heap
, ystack
son las bases ASLR para estas regiones de memoria.prog
es el lugar de la primera página del programa,heap
es la primera página de la heap (esté reservada o no), ystack
es la primera página después de la pila que no forma parte de ella; esto es, el valor inicial de RSP.maxHeapBottom
ymaxStackTop
precomputan cuáles son las páginas máxima y mínima de la heap y el stack respectivamente, con tal de hacer la comparación rápidamente en caso de page fault.- Una instancia de
ASLR
que contiene las regiones libres de memoria virtual consecutiva para poder hacer reservas aleatorias. - Un puntero a la página de SMIDs, inicializado a
nullptr
y asignado en caso de ser necesario. - Un booleano que indica si se permiten las syscalls de memoria física. El por defecto es false, y se establece a true si la syscall
ALLOW_PHYS
concluyó exitosamente.
De esta forma, el tamaño del PCB es grande, pero esto no es un problema, porque toda instancia se almacena en su propia página de memoria privada del kernel.
Si el lector se fija, este PCB no tiene información relativa al scheduler. Esto se debe a que existe otra estructura que engloba a Task, y se denomina SchedulerTask. Tiene los siguientes campos:
- Una copia del puntero al PML4 como primer elemento, para ser accedido desde ensamblador (rutina RPC) de forma fácil cuando no es posible derreferenciar la tarea por poder usar exclusivamente memoria pública.
- UID del usuario que ha ejecutado esta tarea.
- PID del proceso padre. Los servicios de bootstrapping tienen este valor a 0.
- El código de error que devolvió el loader la última vez que se hizo
EXEC
. Este es el valor que devuelveGET_LAST_LOADER_ERROR
. - Lista enlazada de hijos. Se trata de quintetos
<pid, kr, ret, waiting, exited>
, donde:pid
es el PID del proceso hijo.kr
, kill reason, es la razón por la que el kernel ha matado a este proceso. Los valores se encuentran en la Tabla tab:kr.ret
es el valor de retorno del proceso cuando ha terminado.waiting
indica si el padre está actualmente esperando a este proceso.exited
indica si el proceso ha terminado, por cualquier razón.
- Puntero a la página que contiene el PCB, memoria privada del kernel.
- Un booleano indicando si la última ráfaga terminó consumiendo todo el quantum (falso) o si acabó en espera de entrada/salida (verdadero).
- La prioridad actual.
- El tipo de prioridad, es decir, en cuál de los tres pisos del sandwich se sitúa.
Las syscalls GET_KILL_REASON
y GET_EXIT_VALUE
iteran la lista de hijos y devuelven respectivamente los valores kr
y ret
del PID que coincide con el argumento.
Código | Nombre | Descripción |
---|---|---|
0 | OK | El proceso terminó de manera intencionada |
1 | SEGFAULT | Acceso no permitido a memoria |
2 | BAD_STRING | El proceso envió al kernel una cadena de caracteres inválida |
3 | LOADER_SYSCALL | Intentó ejecutar una syscall del loader |
4 | UNKNOWN_SYSCALL | Efectuó una syscall inexistente |
5 | RPC_BAD_PID | Se efectuó un RPC a un PID inválido |
6 | KERNEL_SYSCALL | Intentó ejecutarse una syscall del kernel |
7 | PHYS_NOT_ALLOWED | Se intentó ejecutar una syscall de memoria física sin permiso |
\FloatBarrier
Existe un array global público de punteros a SchedulerTask, tasks
, cuyo índice es el PID. Así es como se resuelven PIDs en procesos.
Estos punteros a SchedulerTask están implementados sobre una clase ProtPtr
, en la que se usa el bit menos significativo del puntero (como está alineado con la página, ha de ser cero) como cerrojo. Así, el PCB solo es derreferenciable por exclusión mutua y no pueden existir condiciones de carrera relativas, sobre todo, a la muerte del proceso.
Los PIDs en Strife son enteros de 16 bits, así que como máximo pueden existir 65535 procesos en ejecución, dado que el PID 0 está reservado. Se asignan de manera FIFO.
Existe un mecanismo especial utilizado en RPC para obtener el puntero al PCB desde ensamblador de forma rápida, generalTask
. Al inicio del arranque, el kernel reserva una página en la memoria virtual pública del kernel que queda sin su traducción física. En la tabla de páginas de cada proceso, esta página virtual se encuentra mapeada a la página física que contiene el PCB de la tarea correspondiente. Así, en todo contexto, derreferenciar este puntero obtendrá el PCB únicamente por medio de memoria pública, sin tener que pasar a la tabla de páginas del kernel. La página virtual de generalTask
es la única que no está marcada como global en toda la región higher half.
El scheduler no tiene mucha complicación práctica. Su justificación teórica se aportó en la Sección Scheduler, y, en realidad, lo único que el lector debe conocer es que existe una función, schedule()
, que obtiene la próxima tarea que debe ejecutarse y llama al dispatcher.
Resulta de interés en esta sección discutir este último punto; en realidad, existen dos dispatchers: dispatch
y dispatchSaving
, ambos métodos de la clase Task. El primero está contenido dentro del segundo por medio de fallthrough de instrucciones. dispatch
es el intuitivo: realiza el cambio de contexto a la tarea, restaurando los registros y cambiando la tabla de páginas y selectores en el proceso. dispatchSaving
, además, primero guarda el flujo de ejecución del kernel en variables globales para ser restaurado luego. Este mecanismo se usa en el proceso de bootstrapping, cuando el microkernel tiene una serie de programas que ejecutar secuencialmente. También se usa cuando se comprueban algunos permisos en el registro, pero eso se explicará en la Sección Registro.
Para la ejecución dual, existen dos vectores en memoria que mantienen los procesos en ejecución en un instante dado: origRunning
y runningAs
, ambos de longitud igual al número de cores de la CPU. Suponga el caso en que un proceso A se encuentra, por RPC, dentro de otro proceso B. En ese instante, el core tiene su origRunning
en A, y su runningAs
en B. runningAs
es el usado en la mayoría de syscalls, pues es el proceso a quien debe modificarse. origRunning
se usa, sobre todo, para el scheduler. De esta manera, cuando un servicio hace HALT
, no vuelve a entrar en el scheduler. Todo código que se ejecute será por medio de RPCs, y lo estarán ejecutando otros procesos. Esto es una gran diferencia con respecto a paso de mensajes, donde el servidor queda a la escucha y maneja las peticiones.
Si en el escenario anterior muere B, se toma la heurística de que es imposible recuperar el funcionamiento de A, con lo cual se toma la decisión de matarlo también. Este proceso de matar al remitente se repite hasta que el valor de runningAs
sea el de origRunning
, cuando se asegurará haber llegado al fondo de la pila de llamadas. Como todas las llamadas son locales y no se almacenan en estructuras globales dentro del kernel, se recurre a los tickets de vuelta para poder recorrer la pila de llamadas [MM14].
Un microkernel tiene drivers dentro. Hay algunos que se usan tan extremadamente a menudo (en cada syscall o cada quantum) que ralentizaría excesivamente el sistema sacarlos. Hay otros, además, que son imposibles de extraer pues aportan funcionalidades básicas de un x86 moderno. Por suerte, resultan pocos todos estos casos combinados, y en Strife solo se destacan tres: PIT, ACPI y APIC.
La PIT se usa únicamente para calibrar el LAPIC timer durante el arranque, puesto que la frecuencia de oscilación de fábrica no está especificada por Intel.
APIC se explicó en la Sección Interrupciones y syscalls. Es imposible de extraer pues el kernel necesita realizar IPIs para sincronizar los cores, y, además, implementa las funcionalidades de LAPIC e IOAPIC con tal de configurar las interrupciones externas (hardware), incluyendo el LAPIC timer para configurar interrupciones periódicas para los quanta.
ACPI (Advanced Configuration Power Interface) es un estándar para descubrir y configurar componentes, así como para gestionar la energía. Es la versión moderna de APM (Advanced Power Management). Su interés reside en que la BIOS aporta ciertas tablas ACPI al bootloader, cuyos punteros después se envían al kernel.
Entre estas tablas, se recibe en el kernel el puntero a la RSDP (Root System Description Pointer), que, además de tener un campo para diferenciar entre ACPI 1.0 y 2.0 (o en adelante), contiene el puntero a RSDT (Root System Description Table, en el caso de 32 bits y ACPI 1.0) o a la XSDT (Extended System Description Table, para 64 bits y ACPI 2.0+) \cite{acpi}.
RSDT/XSDT son tablas que contienen, de nuevo, punteros a un número arbitrario de otras tablas, que se utilizan para propósitos concretos. En el caso de Strife, la única que resulta de interés para Strife es la MADT (Multiple APIC Description Pointer), que contiene información extensa sobre cada core de la CPU, y, especialmente, el APIC ID y la base MMIO de la IOAPIC, necesaria para la configuración de interrupciones hardware.
La arquitectura x86 implementa multiprocesamiento simétrico, SMP. Algunos modelos también soportan NUMA para casos concretos, pero no es relevante para el proyecto. Saber que existen varios procesadores sobre una misma memoria da lugar a que todo el kernel tenga que ser diseñado con mecanismos de cerrojos para garantizar la exclusión mutua de secciones críticas. El algoritmo de cerrojo (Spinlock) del kernel de Strife es test, test and set, y utiliza la instrucción propia de x86 pause
para evitar el consumo de energía excesivo producto de la espera ocupada \cite{pause}.
Todos los cores comienzan en modo real. Uno de ellos, elegido por la placa base, se denomina el BSP (Bootstrap Processor), también llamado CPU0, y es el único que comienza la ejecución en 0x7C00
. Después de los cambios de modo del bootloader y el arranque del kernel, leyendo la MADT y configurando APIC se puede aplicar potencia al resto de cores (denominados APs, Application Processors) mediante SIPIs (Startup IPIs), arrancándolos en modo real en una dirección arbitraria, no necesariamente la estándar de IBM. Estos cores tendrían que recorrer la misma ruta que el BSP, teniendo cuidado con la exclusión mutua, hasta llegar al kernel y, a partir de ahí, coordinarse para repartirse las tareas a realizar. Esto resulta muy tedioso para el kernel: tendría que implementar su propio bootloader.
Para evitar esta tarea, algunos protocolos de arranque, entre ellos, stivale2, promocionan este problema hacia el bootloader, y él se encarga de inicializar a todos. El bootloader pasa al kernel el puntero de una estructura, stivale2_smp_info
, con un campo goto_address
que es sondeado (polling) por los APs hasta que tiene un valor no nulo, en cuyo caso se salta a esa dirección y se completa el arranque de los cores, de manera transparente al kernel \cite{stivale2} [MM15].
Esta sección puede satisfacer la curiosidad del lector: ¿Cómo será el ~main()~ de un kernel?. En el caso de Strife, se llama kmain()
, y se encuentra en el archivo kernel.cpp
\cite{kmain}. kmain
recibe como parámetro un puntero a la estructura que le pasa Limine al kernel con tal de cumplir la especificación de stivale2. Aquí está un resumen superficial de qué pasos lleva a cabo:
- Primero, y antes de nada,
kmain
mueve el mapa de memoria que recibe del bootloader a un lugar seguro. Esto se debe a que las regiones marcadas como usables dentro del mapa pueden contener (o no, la especificación es ambigua, con lo que depende del bootloader) los propios datos pasados al kernel, con lo que es preciso moverlos a un lugar seguro antes de empezar a sobrescribir la memoria. - Seguidamente, se hace lo mismo, pero con los módulos; es decir, los ejecutables cargados por el bootloader para completar el proceso de bootstrapping.
- Se inicializan los descriptores. Primero, la GDT, y, después, la IDT.
- Se inicializa el PMM usando el mapa de memoria.
- Se inicializa la tabla de páginas del kernel, también usando el mapa de memoria.
- En este punto, se parsean las tablas ACPI.
- Se inicializan los allocators del kernel para tener una granularidad más fina de reservas de memoria dinámica.
- Se preparan las pilas que usarán el resto de cores del sistema.
- Se crean y cargan las estructuras TSS, una para cada core.
- Se inicializan la LAPIC y la IOAPIC.
- Se calibra el LAPIC timer.
- Se inicializa el scheduler.
- Se habilitan las syscalls.
- Se habilitan los mecanismos de seguridad SMEP y SMAP, de estar soportados.
- Se obtiene la entropía para inicializar el CSPRNG.
- Comienza el bootstrapping del loader, con la carga del binario SUS.
- Comienza todo el bootstrapping del userspace, empezando por
PSNS
y terminando conVFS
. - Se desbloquean el resto de cores [MM15], y todos llaman a
schedule()
.
El Public Service Namespace es un mecanismo muy simple para asignar nombres a procesos concretos, como se dijo en la Sección PSNS. Utiliza tan solo una syscall, FIND_PSNS
, que obtiene del kernel el PID del servicio para comunicarse con él por RPC y realizar las siguientes resoluciones de nombres.
Existen dos procedimientos públicamente disponibles para comunicarse con el servicio PSNS:
PUBLISH
publica el nombre, pasado por parámetro, del proceso que realiza la llamada. De existir ya el nombre, simplemente devuelve false. Sería una terrible idea sobrescribir el PID, pues esto permitiría que un proceso malicioso actuara como Man in the Middle entre los clientes y el servidor, dándole, además, la libertad de crashear el cliente, que podría ser un servicio imprescindible.RESOLVE
resuelve el nombre, también pasado por parámetro. De no existir, devuelve 0, que es un PID que se garantiza que jamás será asignado.
Se comentó en la Sección RPC que los parámetros de un RPC son a lo sumo 4 enteros de tamaño de registro (size_t
, 64 bits). Para poder enviar el nombre, se limitan los nombres públicos a 8 caracteres, y el nombre pasa por un proceso de marshalling para transformarlo en un entero. Si el nombre es de menos de 8 caracteres, los restantes se dejan a cero. Esta funcionalidad está encapsulada en la biblioteca estándar, con lo que el programador no tiene que preocuparse de ello.
El PSNS, dejando de lado su importancia en todo el entorno del sistema operativo, es un programa muy sencillo. La traducción de nombre a PID se realiza por medio de una tabla hash, cuya implementación está en la STL de la biblioteca estándar (explicado al final de este capítulo).
Todo cargador de programas sigue la misma secuencia de pasos para cargar un ejecutable en memoria:
- Parsear el archivo y copiar las regiones en memoria tal y como pide la estructura.
- Analizar las funciones exportadas, en caso de ser una biblioteca dinámica.
- Listar las bibliotecas dinámicas que el programa necesita y cargarlas en el espacio de direccionamiento.
- Relocation, resolver las referencias dinámicas con las funciones requeridas.
- Cerrar los permisos de las regiones de memoria.
Un ELF se puede dividir de dos formas: por cabeceras de programa, y por secciones.
Para empezar, las cabeceras de programa (PHDRs, Program Headers) definen las regiones de memoria virtual y cómo corresponden con los contenidos del fichero. Contienen instrucciones para su carga, y permisos. Las instrucciones pueden ser, por ejemplo, esto es memoria no inicializada, o esta es una metacabecera y debe ignorarse a la hora de cargar. Los permisos suelen definir si las regiones permiten la escritura y/o la ejecución del código. En el caso de ELF-64, una cabecera de programa está definida de la siguiente forma \cite{phdrs}:
p_type
, las instrucciones de carga, con valores comoPT_LOAD
(cárgame) oPT_NOTE
(ignorar en la carga).p_flags
, con los permisos.p_offset
, dónde en el fichero comienza esta región.p_vaddr
, dónde en memoria comienza esta región.p_paddr
, obsoleto.p_filesz
, cuánto ocupa esta región dentro del archivo.p_memsz
, cuánto ocupa esta región cuando está en memoria.p_align
, restricción de alineamiento para la región.
Conociendo los campos de un PHDR, se entiende cómo funcionaría una región de memoria no inicializada (famosamente conocida como .bss
): p_filesz
p_memsz
.
Cuando el binario está compilado con ASLR en mente, todas las direcciones virtuales son relativas a la base 0, pero el código está compilado para ser PIC (Position Independent Code), utilizando exclusivamente instrucciones con direccionamiento relativo al contador de programa.
Las secciones dividen el ejecutable de forma que se entienda qué contiene cada región, no solo cómo cargarlas. Es necesario analizar las secciones para encontrar aquellas que contienen las referencias dinámicas y los procedimientos exportados, como .dynsym
y .rela.dyn
. Con tal de identificar las secciones, cada una contiene un campo sh_name
con un offset a otras secciones específicas que contienen solo nombres, como .shstrtab
o .dynstr
.
Existe una sección, .dynamic
, que contiene la lista de bibliotecas dinámicas necesarias para el ejecutable, identificadas por nombre (por ejemplo, libstd.so
), que el sistema operativo debe cargar de forma independiente y posar sobre el espacio de direccionamiento.
Las bibliotecas no se copian, porque sería un gasto innecesario de memoria física, sino que se referencian. Esta trampa se denomina CoW (Copy on Write), donde las páginas se marcan como solo lectura, y solo se copian si ocurre un page fault de escritura sobre ellas [MM16].
Las relocations en un ELF, o sea, el enlazamiento dinámico de funciones requeridas en tiempo de ejecución, tienen varios tipos que el procesador elige dependiendo del contexto. No varían muchos entre ellos, y algunos son muy poco comunes, con lo que Strife únicamente implementa aquellos que han aparecido empíricamente. Ejemplos pueden ser R_X86_64_JUMP_SLOT
o R_X86_64_GLOB_DAT
.
Hablando estrictamente de Strife, los programas son cargados por petición del kernel al loader. En el proceso de bootstrapping, esto se realiza numerosas veces para arrancar secuencialmente los servicios imprescindibles. El mecanismo consiste en que el loader tiene una región de su espacio de direccionamiento virtual asignado para los ejecutables que entran desde el kernel, una dirección estática, que no cambia con cada carga. El kernel, al arrancar el loader y pedirle cargar la stdlib, que es su primera tarea, establece el registro RDI, correspondiente al primer argumento de una función según la ABI Sys-V, el puntero a esta región y el tamaño de la stdlib. Cuando el kernel ha completado su carga, efectúa la syscall BACK_FROM_LOADER
, que recibe como parámetros:
- El PID del proceso creado.
- Un código de error, definidos en la Tabla tab:loader.
- El punto de entrada de la tarea.
El loader está programado de tal forma que no se reserve el PID hasta que se haya comprobado que no existen errores en el binario. Por esto, de haber un código de error distinto de NONE
, el PID devuelto al loader es nulo.
Tras esto, el loader queda en estado bloqueado hasta que el kernel quiera cargar el siguiente ejecutable. En ese momento, la syscall devolverá el tamaño del nuevo ejecutable situado en la región estática.
Para efectuar la carga, el loader necesita comunicarse con el kernel, y esto lo hace mediante tres syscalls:
MAKE_PROCESS
, que crea un PCB vacío (con una tabla de páginas que solo contiene las referencias al kernel ygeneralTask
), pero con un PID reservado.ASLR_GET
, que se comunica con el objeto ASLR del PCB. Esto se hace por medio de regiones identificadas numéricamente. La syscall reibe el PID del proceso a consultar, el identificador de la región, y el número de páginas a reservar, en caso de que la región no exista anteriormente. Devuelve la dirección en el espacio virtual del nuevo proceso.MAP_IN
, que toma una página de la memoria del loader y la sitúa en un punto concreto de la memoria del nuevo proceso.
Código | Nombre | Descripción |
---|---|---|
0 | NONE | La carga se completó con éxito |
1 | NO_MEMORY | No se ha podido realizar alguna reserva de memoria |
2 | NOT_ELF | El archivo recibido no es un ELF |
3 | NOT_64 | El ELF no usa direcciones de 64 bits |
4 | NOT_LE | El ELF usa Big-Endian en lugar de Little-Endian |
5 | BAD_ARCH | La arquitectura objetivo no es x86 |
6 | INVALID_OFFSET | Uno de los offsets del ELF es inválido |
7 | NO_PHDRS | El ELF no contiene PHDRs |
8 | NO_SECTIONS | El ELF no define secciones |
9 | NO_SHSTRTAB | El ELF carece de la sección de nombres de secciones |
10 | NO_DYNSTR | El ELF no tiene la sección de nombres dinámicos |
11 | UNSUPPORTED_RELOCATION | El mecanismo de relocation no está implementado |
12 | FAILED_RELOCATION | No existe la función referenciada dinámicamente |
\FloatBarrier
Una tarea puede iniciar este mecanismo mediante la syscall EXEC
, pasándole al kernel un puntero donde está cargado el ejecutable, así como su tamaño. En la Figura \ref{fig:loader} se encuentra un diagrama que expresa el proceso descrito. No se permite la comunicación directa con el loader, pues no debe requerir permisos, y el cliente podría llegar a congelar el cargador de programas (o saturar su memoria virtual) mediante el envío de páginas vacías.
Conociendo ahora que EXEC
toma una secuencia de páginas de cualquier proceso, es como se explica que el kernel en ningún momento conozca la ruta del ejecutable cargado (podría, incluso, haberse generado en RAM). Por esto, en Strife, no tiene sentido una flag del ACL para el permiso de ejecución.
En la Sección El registro: un entorno innovador se presentó el mecanismo de registro para la configuración de servicios. Aquí se describe su implementación y comunicación. De forma intuitiva, solo existe una forma de hacer este tipo de implementación, sobre todo teniendo en cuenta la volatilidad de los datos: un árbol de tablas hash.
La representación multinivel se separa usando el caracter /
. La raíz se denomina como /
. Bajo la raíz existen dos directorios, que representan las configuraciones de distintos servicios. Estos son: u
y g
.
-
g
contiene identificadores numéricos de grupos del sistema de archivos raíz. Si existe/g/5/IO_ALLOWED
, entonces los usuarios que estén en el grupo con GID$5$ pueden ejecutar programas que realicen la syscallALLOW_IO
y utilizar los puertos de entrada/salida [MM17]. -
u
es exactamente lo mismo queg
pero para UIDs.
Todo directorio bajo /u
(como, por ejemplo, /u/2
), puede contener a su vez un directorio SUPER
. Sus hijos son UIDs (como
Los procedimientos públicos que ofrece el registro para su comunicación con PCI se encuentran en la Tabla tab:reg.
RPID | Nombre | Argumentos |
---|---|---|
0 | EXISTS | 1 |
1 | CREATE | 1 |
2 | LIST_SIZE | 1 |
3 | LIST | 1 |
\FloatBarrier
A diferencia del resto de servicios que se verán más adelante, estos procedimientos no requieren permisos, pues son gestionados internamente por el registro.
EXISTS
comprueba la existencia de una ruta (como/u/2/KEYBOARD
), devolviendotrue
ofalse
. Su argumento es un SMID que contiene dicha ruta, que se monta a la entrada de la rutina, y se desmonta a la salida. No hace ninguna comprobación de permisos, el registro es legible para todo el mundo [MM19].CREATE
funciona de forma equivalente aEXISTS
, y toma en consideraciónSUPER
[MM18]. Además, solo se puede dar un permiso en caso de tenerlo: un usuario que no poseaKEYBOARD
no puede dárselo a otro.LIST_SIZE
toma el SMID de la ruta, y devuelve el número de páginas que serían necesarias en un SMID próximo para obtener el listado de hijos.LIST
toma el SMID de la ruta, que debe tener como mínimo el tamaño devuelto porLIST_SIZE
, y escribe en él la lista de hijos de la ruta dada.
PCI (Peripheral Component Interconnect) es el driver de Strife que implementa la comunicación con el bus homónimo. PCI Express, su sucesor, es retrocompatible con PCI, con lo que el driver sirve para ambos.
No es objetivo de este trabajo entrar a explicar las profundidades del bus, pero sí hay ciertas cosas básicas que hay que conocer: la comunicación se hace por medio de PIO, donde se utilizan dos direcciones de puertos: 0xCF8
, la de configuración, y 0xCFC
, la de datos. Sobre el puerto de configuración se escriben las denominadas direcciones PCI, que son rutas de 32 bits que identifican los periféricos conectados. Esto lo hacen por su número de bus (8 bits), su número de dispositivo (5 bits), y su número de función (3 bits). Además, los 8 bits menos significativos de la dirección representan el offset dentro del descriptor del dispositivo \cite{pci}.
Al leer el offset 0 de un dispositivo, se obtiene una cabecera. Las hay de distintos tipos, pero la más importante es la cabecera 0x0
. En ella, existen campos como Vendor ID y Device ID con los cuales se puede identificar la procedencia del dispositivo. Otros campos como class code y subclass sirven para identificar de qué tipo de dispositivo se trata.
El driver de PCI de Strife tiene como principal objetivo hacer el probing de dispositivos; esto es, detectar qué hay conectado a la placa base. La forma más simple de hacerlo es por fuerza bruta. Se prueban todas las direcciones cambiando el número de bus y el número de dispositivo, dejando el número de función a cero. Si el dispositivo no existe, el controlador PCI establece el campo Vendor ID a 0xFFFF
, que está reservado para este propósito \cite{pci}. Así, hay que hacer fuerza bruta de $28+5 = 8192$ opciones, que, en realidad, toman bastante poco tiempo.
Habiendo identificado qué pares <bus, dispositivo>
son válidos, se comprueban las funciones (se podrían entender como subdispositivos). Para cada una de ellas, se obtiene la clase y subclase que identifican el tipo. El driver guarda toda esta información para otros drivers que la necesiten.
Generalmente, estos dispositivos con los que se quiere comunicar van a generar interrupciones hardware. En tiempos de la PIC, se recibían de forma simple. Con la APIC, se complica, y la forma más sencilla de recibir interrupciones PCI por APIC es usando uno de dos mecanismos muy similares: MSI (Message Signaled Interrupts), y MSI-X, sobre los cuales no se entrará a describir en detalle. Sin embargo, sepa el lector que esta es la razón por la cual el driver de acceso a disco implementado en Strife es AHCI y no IDE [MM20], puesto que IDE es antiguo (década de los 80) y no soporta MSI/MSI-X, mientras que AHCI funciona sobre PCI Express, y todo dispositivo de dicho bus está obligado por el estándar a soportar uno de los dos.
Para comunicarse con los dispositivos se utilizan las denominadas BAR (Base Address), y cada dispositivo tiene hasta seis de ellas. Pueden ser PIO o MMIO, dependiendo del dispositivo.
Los procedimientos públicos del driver se especifican en la Tabla tab:pcipub.
RPID | Nombre | Permiso | Argumentos |
---|---|---|---|
0 | GET_DEVICE | PCI_LIST | 3 |
1 | GET_BAR | PCI_FULL | 2 |
2 | DO_MSI | PCI_FULL | 2 |
3 | ENABLE_MMIO | PCI_FULL | 1 |
4 | BECOME_BUSMASTER | PCI_FULL | 1 |
\FloatBarrier
Una explicación un poco más en detalle:
-
GET_DEVICE
es un procedimiento con los parámetros: clase, subclase, índice. Devuelve la dirección PCI del dispositivo que pasa el filtro de pertener a la clase y subclase dadas. El índice se utiliza para iterar, las tareas que quieran buscar dispositivos PCI de un tipo concreto comienzan el índice en cero y continúan hasta que se devuelva un errorBAD_DEVICE
. -
DO_MSI
tiene dos parámetros: una dirección PCI y un vector de la IOAPIC. Enlaza las interrupciones del dispositivo al vector dado, usando MSI o MSI-X, según corresponda. -
GET_BAR
recibe una dirección PCI y un índice$i$ , devolviendo$\text{BAR}i$ para el dispositivo dado. -
ENABLE_MMIO
habilita la captura de direcciones en el bus de la placa base, para poder comunicarse con el driver en una región de memoria. -
BECOME_BUSMASTER
vuelve al dispositivo maestro del bus, es decir, por simplificar, selecciona el dispositivo.
AHCI (Advanced Host Controller Interface) es el driver que se comunica con este estándar. Sirve para comunicarse con discos duros (SATA, Serial ATA) y unidades de disco (ATAPI, ATA Packet Interface). La implementación actual del driver tan solo implementa ATAPI, pues no estaba dentro de los objetivos del proyecto implementar un mecanismo de instalación del sistema operativo [MM21].
Una placa base que soporta AHCI contiene un chip, el controlador de AHCI, también denominado HBA (Host Bust Adapter), conectado por PCI Express. Se identifica como un dispositivo de clase 0x01
(almacenamiento masivo) y subclase 0x06
(SATA). Mediante comunicación con el driver de PCI, este servicio enumera estos dispositivos, y para cada uno:
- Obtiene el ABAR (AHCI Base Memory Register), una dirección física con la que comunicarse con el dispositivo, reconocida como el BAR5.
- Hace maestro del bus al HBA y habilita MMIO.
- Mapea la región física (ABAR) en su memoria virtual; en concreto, son necesarias dos páginas.
- Habilita el dispositivo y enumera sus puertos.
Todo HBA tiene una serie de puertos disponibles; como máximo, 32. En concreto, como se intenta soportar únicamente ATAPI, se buscan aquellos que, en su campo de firma (signature) tengan el valor 0xeb140101
, que los identifica como tal \cite{ahci}.
Habiendo reconocido la unidad de disco, se comprueba si tiene un disco dentro; de ser el caso, recuerda a este puerto del dispositivo como una unidad ATAPI con disco, para acceder a ella posteriormente.
Como ATAPI es para CDs, y los CDs son solo lectura, el driver tan solo implementa la lectura. Existen dos formas de acceder a un dispositivo masivo de datos: PIO (Programmed Input/Output, no confundir con Port Input/Output) y DMA (Direct Memory Access).
En la primera, la CPU es responsable de hacer la copia de cada byte, o pequeño múltiplo de bytes (como mucho, el tamaño de registro, 8 bytes). Esto mantiene a la CPU ocupada, y, además, hace que el procedimiento sea mucho más lento, porque los datos tienen que pasar del disco a la CPU y de la CPU a la memoria.
En la segunda, la CPU tan solo manda las órdenes de copia, y el dispositivo toma el control del bus de datos para copiarlos a la memoria principal sin pasar por el procesador, lo que acelera mucho el proceso. Existen dos formas de DMA: DMA guiado por IRQs, y DMA polling. En el primero, el proceso queda bloqueado a la espera de una interrupción hardware de finalización, y mientras tanto puede realizar otras tareas. En la implementación de Strife actual, se realiza DMA polling: se comprueba por espera ocupada si ha finalizado la copia [MM22]. Si bien DMA polling es síncrono, es mucho más rápido que PIO, porque la ruta que toman los datos es más corta.
Estas órdenes que se envían al HBA se forman como FIS (Frame Information Structure), una estructura propia de AHCI. Estas órdenes se reparten entre otras estructuras más, como el Command Header y la Command Table, pero el objetivo de todas es uno: enviar órdenes.
En el caso concreto de ATAPI, las órdenes que se envían son comandos SCSI (Small Computer System Interface) encapsulados en órdenes ATA, haciendo una gran mezcla de estándares y chips en el proceso, sobre la cual no se va a entrar en detalle. Utiliza sectores de 2KBs.
Lo que sí es preciso comentar son los procedimientos públicos, y están en la Tabla tab:ahci.
RPID | Nombre | Permiso | Argumentos |
---|---|---|---|
0 | GET_ATAPIS | AHCI_LIST | 0 |
1 | READ_ATAPI | AHCI_ATAPI_READ | 4 |
\FloatBarrier
Sus definiciones son simples:
GET_ATAPIS
devuelve el número de discos ATAPI conectados al HBA.READ_ATAPI
realiza la lectura sobre la página de la memoria compartida. Recibe un SMID sobre el cual escribir los datos, el identificador de disco (desde 0, menor que el número devuelto porGET_ATAPIS
), un LBA de inico, y el número de sectores a leer.
ramblock
es un servicio que implementa un dispositivo de bloques en RAM de tamaño infinito. Tiene sectores del tamaño de página. Cuando se pide una que no existe, se devuelve la página llena de ceros. Cuando se escribe en ella, se guarda la página en una tabla hash, usando como clave el LBA. Es el servicio más sencillo de la pila de memoria secundaria, pues está escrito en un único archivo de 68 líneas \cite{ramblock}. Esto es gracias a todas las estructuras de datos de la STL y a las distintas abstracciones sobre el registro, IPC, PSNS, y exclusión mutua que se aportan en la stdlib.
Los procedimientos públicos se encuentran en la Tabla tab:ramblock.
RPID | Nombre | Permiso | Argumentos |
---|---|---|---|
0 | READ | RAMBLOCK_READ | 2 |
1 | WRITE | RAMBLOCK_WRITE | 2 |
\FloatBarrier
READ
y WRITE
ambos reciben un SMID y un LBA, y leen un sector o lo escriben respectivamente.
block
es la abstracción sobre dispositivos de bloques. Funciona asignando UUIDs (Universally Unique Identifier) a los dispositivos, esto es, enteros de 128 bits [MM23].
Por ser tan solo una capa de abstracción, todo lo que hace es ofrecer una interfaz homogénea para el acceso a los dispositivos, que posteriormente traduce en otros RPCs a los drivers. Los procedimientos están en la Tabla tab:block.
RPID | Nombre | Permiso | Argumentos |
---|---|---|---|
1 | LIST_DEVICES | BLOCK_LIST | 2 |
1 | SELECT | BLOCK_READ | 2 |
2 | READ | BLOCK_READ | 3 |
3 | WRITE | BLOCK_WRITE | 3 |
\FloatBarrier
LIST_DEVICES
recibe un SMID y una número de página. Escribe en la memoria compartida la lista de UUIDs presentes, de forma paginada. Cuando el cliente que está enumerando detecta el UUID00000000-0000-0000-000000000000
, la secuencia ha concluido. Además, devuelve cuántos UUIDs se han escrito en la región compartida.SELECT
realiza la selección del dispositivo en la sesión actual en base al UUID. Recibe dos parámetros: los 64 bits superior del UUID, y los 64 bits inferiores.READ
lee. Tres parámetros: SMID, dirección lineal de inicio, y número de bytes a leer. Devuelvetrue
ofalse
según la lectura haya sido correcta o no.WRITE
tiene una interfaz análoga aREAD
, solo cambia la acción que realiza y el permiso requerido.
ISO9660 es un sistema de archivos solo-lectura usado por CDs. Al ser solo lectura, está diseñado para ser muy simple, fácil de leer por reproductores de sonido y DVD. El sistema de archivos comienza en el sector 16 (0x10
). Contiene el denominado PVD (Primary Volume Descriptor), que se podría considerar el superbloque. Contiene ciertas propiedades sobre el sistema de archivos, entre ellas:
- Número de sectores escritos.
- Tamaño del LBA (generalmente 2048 bytes).
- Entrada del directorio raíz.
Una entrada de directorio define un archivo, como si fuera un inodo. Tiene varios campos, los más significativos son:
- LBA del extent, es decir, los propios contenidos del fichero (que puede ser a su vez una secuencia de directorios).
- El tamaño del extent.
- Flags. Entre ellas, la más importante es la que define si esta entrada representa a un archivo o a otro directorio.
- Fecha y hora de grabación.
- Longitud del nombre del archivo, seguido del nombre en sí.
Solo con estas dos estructuras se puede recorrer la jerarquía de directorios de todo el sistema de archivos. Como punto a destacar, los valores numéricos que aparecen (LBAs, tamaño…) están en both-endian; es decir, little-endian seguido de big-endian, para que cualquier dispositivo pueda leerlos sin realizar el cambio de endianess.
El directorio raíz tiene nombre de longitud uno, y es solo un byte nulo. El resto de directorios tienen dos entradas como mínimo: la que representa al directorio actual (equivalente a .
), y la que representa al padre (equivalente a ..
).
Existen dos extensiones a ISO9660 que se suelen usar, Rock Ridge y Joliet, que permiten cosas como nombres de archivos case-sensitive y más profundidad en los directorios. Este driver soporta Joliet, relativo al conjunto de caracteres usado en los nombres de los archivos, pero no Rock Ridge, que consiste en añadir los permisos POSIX.
Este servicio abstrae los LBAs en forma de números de inodo, que realmente son la dirección lineal (lba * 2048 + offset). Además, define una estructura abstracta de fichero, cuyos campos son:
- Número de inodo.
- Fecha de creación.
- Tamaño del archivo en bytes.
- Flags, solo se usa una: si es o no un directorio.
- Tamaño del nombre, seguido del nombre.
Esta estructura se usa a forma de marshalling para escribir sobre la región de memoria compartida. La interfaz de procedimientos públicos de este servicio se encuentra en la Tabla tab:iso9660.
RPID | Nombre | Permiso | Argumentos |
---|---|---|---|
0 | SETUP | ISO9660_SETUP | 2 |
1 | GET_ROOT | ISO9660_READ | 0 |
2 | LIST_SIZE | ISO9660_READ | 1 |
3 | LIST | ISO9660_READ | 2 |
4 | READ | ISO9660_READ | 4 |
\FloatBarrier
Funcionan de la siguiente manera:
SETUP
inicializa el servicio con un UUID, con tal de comunicarse con block. Los argumentos son los primeros 64 bits del UUID, y los últimos. Devuelvetrue
si todo ha ido bien; esto es, si no estaba inicializado ya, y si se ha podido parsear correctamente el PVD.GET_ROOT
devuelve el inodo de la raíz.LIST_SIZE
, de forma análoga a lo que hacía el registro, devuelve cuántas páginas son necesarias en una región de memoria compartida para almacenar todas las entradas abstractas de archivo. Recibe un inodo.LIST
recibe el SMID y el inodo. Se comprueba que el tamaño de la región de memoria compartida sea mayor o igual a la devuelta porLIST_SIZE
, y después se copian los contenidos.READ
recibe el SMID, el inodo, la página a leer, y la cantidad de páginas. Lee el archivo, así, de forma paginada, devolviendo en cada paso cuántos bytes se han escrito en la página compartida. Las páginas son las del sistema, de 4096 bytes. Se implementa de esta manera, en lugar de mediante direcciones lineales, para conseguir realizar una lectura desdeblock
con cero copias.
Las ideas de StrifeFS fueron explicadas en la Sección Diseño de StrifeFS. Aquí se definirá su implementación. Para empezar, si se está montando sobre un disco RAM, entonces se formatea el espacio. Como la implementación no pretende ser final, se fijan 4096 inodos y 4096 bloques [MM24].
Los procedimientos públicos implementados se encuentran en la Tabla tab:strifefs.
RPID | Nombre | Permiso | Argumentos |
---|---|---|---|
0 | SETUP | STRIFEFS_LIST | 3 |
1 | GET_INODE | STRIFEFS_READ | 2 |
2 | READ | STRIFEFS_READ | 4 |
3 | WRITE | STRIFEFS_WRITE | 4 |
4 | MAKE_DIR | STRIFEFS_WRITE | 2 |
5 | MAKE_FILE | STRIFEFS_WRITE | 2 |
6 | ADD_ACL | STRIFEFS_WRITE | 3 |
\FloatBarrier
SETUP
inicializa el sistema de archivos tomando un UUID parablock
y un booleano sobre si se debe formatear el medio.GET_INODE
recibe un SMID y escribe el inodo sobre la región.READ
recibe un SMID, un inodo, una dirección lineal de lectura, y el número de bytes a leer.WRITE
, con una interfaz igual aREAD
, realiza la escritura. Si la posición de fin es superior al tamaño del archivo, lo extiende.MAKE_FILE
recibe un SMID y un inodo padre. Crea un archivo regular con el nombre dado por la memoria compartida.MAKE_DIR
funciona de igual forma.ADD_ACL
recibe un número de inodo, un UID, y una entrada de ACL. Añade la entrada al inodo dado, de existir, para el usuario dado por parámetro.
Como los ACLs son inodos, no es necesario un tratamiento especial para leerlos. Se pueden consultar y modificar utilizando GET_INODE
para obtener el campo ACL
seguido de READ
. La escritura, sin embargo, sí está protegida para mantener la consistencia del sistema de archivos.
El driver no permite actualmente el borrado de archivos ni entradas ACL [MM25].
El VFS es la capa de abstracción sobre todos los sistemas de archivos, quedando así en la cima de la pila de almacenamiento. Homogeneiza los sistemas de archivos, dándoles todos una representación igual a la de StrifeFS.
Está basado en la idea de los puntos de montaje. Para empezar, siempre hay una raíz montada. Bajo ella, en directorios concretos pueden estar montados otros sistemas de archivos. Cuando llega una ruta al servicio, primero ocurre un proceso de simplificación, en el cual:
- Se elimina todo autolado, es decir, las partes
/./
de una ruta./cd/./boot
se simplifica a/cd/boot
. - Se elevan los directorios con referencias a
..
;/cd/boot/../libs
se simplifica a/cd/libs
. - Se eliminan los separadores duplicados.
/cd//boot
se simplifica a/cd/boot
.
Tras esto, el VFS intenta identificar a qué punto de montaje corresponde la ruta simplificada. Esto se hace por medio de un algoritmo de máxima coincidencia. Los puntos de montaje se almacenan como la ruta sobre la cual están montados. Por ejemplo, se pueden tener los siguientes puntos de montaje:
/
, StrifeFS sobreramblock
./cd/
, ISO9660 sobre AHCI ATAPI.
La ruta /cd/boot
coincide en su primer caracter con el primer punto de montaje, y en sus cuatro primeros con el segundo. El que más caracteres coincida, terminando en /
, determina cuál es el punto de montaje. A partir de ese punto, se elimina la ruta de montaje de la ruta simplificada, y resulta en la ruta relativa, que es la que se envía al servicio correspondiente.
ISO9660 se encapsula de forma que el ACL de todos los archivos esté vacío.
Los procedimientos públicos se encuentran en la Tabla tab:vfs. Nótese cómo no aparecen permisos: todo proceso puede comunicarse con el VFS, es él el que se encarga de comprobar los permisos a posteriori según las entradas ACL.
RPID | Nombre | Argumentos |
---|---|---|
0 | SELECT | 1 |
1 | LIST_SIZE | 0 |
2 | LIST | 1 |
3 | READ | 3 |
4 | WRITE | 3 |
5 | INFO | 1 |
6 | MKDIR | 1 |
7 | MKFILE | 1 |
8 | ADD_ACL | 2 |
9 | GET_ACL_SIZE | 0 |
10 | GET_ACL | 1 |
11 | GET_EACL_SIZE | 0 |
12 | GET_EACL | 1 |
\FloatBarrier
- VFS funciona por un mecanismo de estado de selección. Para simplificar la interacción, el cliente selecciona (
SELECT
) un fichero por su ruta en memoria compartida, pasando en el proceso el SMID. LIST_SIZE
funciona de la forma usual. Para el archivo seleccionado, devuelve el número de páginas necesarias para contener el listado de directorios.LIST
recibe el SMID y escribe sobre la región el listado de directorios.READ
recibe el SMID, la dirección lineal de comienzo, y la cantidad de bytes a leer.WRITE
tiene la misma interfaz.INFO
recibe un SMID y sobre él escribe una estructura en la que se encuentra un código de error, el tamaño del archivo, y si es un directorio.MKDIR
recibe un SMID con el nombre del fichero, y, de ser permitida la escritura en el sistema de archivos, crea un nuevo archivo y lo añade como hijo al directorio seleccionado. Si el archivo seleccionado resulta no ser un directorio, devuelve error.MKFILE
funciona de forma análoga.ADD_ACL
añade una entrada de ACL dada por parámetro, junto a su UID, al archivo seleccionado.GET_ACL_SIZE
funciona de forma similar aLIST_SIZE
, pero con entradas del ACL.GET_ACL
de la manera intuitiva.GET_EACL_SIZE
, exactamente igual aGET_ACL_SIZE
, pero correspondiente a las entradas efectivas de ACL (sumadas hasta la raíz).GET_EACL
, de la forma usual.
El servicio users se encarga de mantener las correspondencias entre nombres de usuario y UIDs, así como nombres de grupo y GIDs [MM26]. Consiste en dos tablas hash, una para la búsqueda en un sentido, y otra para el inverso. [MM27]
Presenta cuatro procedimientos públicos, que se encuentran en la Tabla tab:users.
RPID | Nombre | Permiso | Argumentos |
---|---|---|---|
0 | GET_UID | 1 | |
1 | GET_NAME | 1 | |
2 | GET_COUNT | 0 | |
3 | NEW_USER | USERS_NEW | 1 |
\FloatBarrier
GET_UID
recibe un SMID y hace la traducción de nombre de usuario a UID. De no encontrarse un usuario con dicho nombre, devuelve 0.GET_NAME
recibe un UID y hace la traducción a nombre de usuario, que escribe en la región compartida. De no encontrarse, la deja en blanco.GET_COUNT
devuelve el contador monotónico de asignación de UIDs. De existir los usuarios 1 y 2, devuelve 3, que sería el siguiente UID. De esta forma, se pueden enumerar haciendo sucesivas llamadas aGET_UID
sin necesidad de más procedimientos.NEW_USER
realiza la creación de un usuario. Recibe un SMID, su nombre, y se le asigna el UID dado por el contador monotónico. El nombre tiene que seguir la expresión regular[A-Za-z_][A-Za-z0-9_]*
[MM28].
El servicio switcher
se encarga de modificar el UID del proceso que realiza la llamada. Un proceso ejecutado por el usuario A puede pasar a ser ejecutado como B solo si existe la ruta /u/B/SUPER/A
en el registro (trabajando con UIDs, por supuesto). Esta restricción no se aplica si A es system
.
Tiene un único procedimiento público, SWITCH
, y recibe el UID de B. Devuelve true
si el cambio fue exitoso, false
en otro caso. El cambio se realiza por medio de la syscall SWITCH_USER
, que requiere el permiso SWITCH_ALLOWED
. Idealmente, únicamente este servicio haría uso de dicho permiso, con tal de mantener los cambios de usuario centralizados para permitir guardar logs en un futuro.
term
es el driver que implementa una terminal sobre el framebuffer de modo texto de la BIOS. Esta región de memoria está habitualmente situada en la dirección física 0xB8000
. El servicio implementa el scrolling cuando se pasa de la última línea de texto, así como el cursor, que mantiene, siempre que esté visible, en la posición siguiente al último caracter escrito.
Antes de que comience el proceso de bootstrapping, el driver no se está ejecutando, pero el kernel necesita imprimir información por pantalla, especialmente en caso de kernel panic para averiguar qué ha ido mal. Por ello, el kernel contiene un driver similar a este, salvo que es más simple. Este driver del kernel no se desecha una vez term
se está ejecutando, pues aumentaría muy considerablemente la cantidad de cambios de contexto. En su lugar, se mantienen sincronizados. Cuando el kernel arranca el servicio, le monta una página que es compartida por el kernel, donde se mantiene en todo momento la fila y columna del cursor. Así, pueden escribir sin pisarse el uno al otro.
term
es especialmente simple en cuanto a su interfaz pública. En la Tabla tab:term se enumeran los procedimientos disponibles para RPC, ninguno de ellos requiere la existencia de un permiso en el registro.
RPID | Nombre | Argumentos |
---|---|---|
0 | CONNECT | 1 |
1 | FLUSH | 1 |
2 | CLEAR | 0 |
\FloatBarrier
CONNECT
recibe un SMID y establece una sola página de memoria compartida con el proceso que desee escribir por pantalla.FLUSH
recibe un parámetro: el número de bytes escritos en la región. Realiza la escritura de ese número de bytes por pantalla.CLEAR
no recibe parámetros. Limpia la pantalla, llenándola del caracter espacio y moviendo el cursor a la esquina superior izquierda.
La comunicación con term
está abstraída dentro de la biblioteca estándar, se explicará más adelante en la Sección Cabecera cstdio.
init
es el último programa que ejecuta el kernel, tras VFS. Con él, se termina el bootstrapping, y comienza el arranque del sistema teniendo un entorno desde el cual se pueden leer archivos (y, por tanto, cargar programas). Implementado en tan solo un archivo de 80 líneas, realiza las siguientes tareas de forma ordenada y síncrona:
- Lanza el programa
splash
para mostrar el textoS T R I F E
centrado en la pantalla. No solo tiene un propósito estético: es el primer programa cargado sin ayuda del bootloader, y es lo suficientemente simple como para poder diferenciar si un fallo del arranque se debe a la pila de almacenamiento o no. - Ejecuta
users
, y espera hasta que se publique en el PSNS. - Ejecuta
registry
, y también espera hasta que aparezca allí. - Lo mismo hace con
switcher
. - Y con
keyboard
. - Finalmente, entra en un bucle infinito de ejecutar una shell del usuario
system
. De esta forma, si la shell llega a cerrarse (por ejecutarexit
), se vuelve a arrancar automáticamente.
keyboard
es el driver de teclado PS/2, comúnmente utilizado en las máquinas virtuales. Funciona mediante PIO, y se definen dos puertos:
- El puerto de estado,
0x64
, al cual escribe el teclado de producirse una pulsación. - El puerto de datos,
0x60
, sobre el cual se escribe el scancode (número de tecla y contexto) al pulsarse. Existen dos valores para cada tecla: cuando se pulsa, y cuando se suelta.
Aunque los scancodes no dependen del idioma, sí lo hacen de la distribución de las teclas, con lo cual el driver solo soporta la distribución española. Como dato curioso, la primera versión de Linux, 0.01, también sufría este problema, y solo soportaba la distribución finlandesa \cite{linux001}.
Los códigos que envía el teclado a la BIOS son completamente independientes de la letra que representen; en su lugar, se definen por su fila y por columna. Sin embargo, sabiendo la fila y la columna, tampoco es estimable qué scancode será el correspondiente, pues existen muchas excepciones y a veces los valores no son secuenciales. La única forma de crear un mapeo válido es pulsando cada una y anotando el código.
El driver hace polling al puerto de estado [MM29] hasta que se recibe un valor. Cuando ocurre, obtiene el scancode, y lo transforma, si procede, a un caracter [MM30]. Después, lo añade a una cola.
Existe un único procedimiento público que realiza la lectura del teclado: GET_CHAR
, que requiere el permiso KEYBOARD
. La rutina espera hasta que haya un nuevo caracter disponible, quedando bloqueada en el proceso. Cuando esté disponible, se saca de la cola y se devuelve.
El mecanismo de espera se hace con un semáforo no-acotado, la estructura que resuelve el conocido Problema de los Fumadores, también llamado Problema de Lectores/Escritores, por medio de una implementación estándar en la stdlib.
La shell es tan simple como puede ser. Muestra un prompt con el usuario y la ruta actual, y realiza llamadas consecutivas a GET_CHAR
del driver del teclado, mostrando cada caracter conforme llega. La shell implementa también la funcionalidad de la tecla de borrado, para desplazar el cursor hacia atrás cuando se pulse [MM31]. Cuando se recibe un salto de línea, se considera que ha terminado la línea, y se procesa.
La primera acción que se realiza sobre la línea leída es separarla por sus espacios, descartando en el proceso las partes vacías. Si no hay partes, se hace continue
para volver a mostrar el prompt. Si hay al menos una parte en la orden, la primera se considera el programa, y el resto sus argumentos.
Teniendo el programa, se comprueba si es un builtin; esto es, un comando propio de la shell. Se implementan los siguientes:
exit
, para cerrar la shell.pwd
, para mostrar la ruta actual (Print Working Directory).clear
, para limpiar la pantalla.cd
, para cambiar el directorio actual (Change Directory).lev
, Last Exit Value, muestra el valor de salida de la última orden (equivalente a$?
en una shell POSIX).
Si ha resultado ser uno de los builtins, se ejecuta su rutina, y se vuelve al prompt. Sino, en este punto, ha de tratarse de la ejecución de un programa. Si en algún punto contiene el caracter /
, entonces se trata de una ruta. [MM32]
Si no lo contiene, se asume que se trata de una ruta relativa a PATH
, la variable de entorno que contiene las rutas en las que se encuentran los binarios globales a la shell. Por defecto, PATH=/cd/bin
. El separador de PATH
es el mismo que UNIX y Windows, :
. Se itera por todas las rutas, y se comprueba si la concatenación de la ruta con el programa recibido en la línea existe. En cuyo caso, se ejecuta.
Si ninguna de las rutas de PATH
con el programa existía, se muestra un mensaje de error, y se vuelve al prompt.
coreutils
es un repositorio de la organización de GitHub que contiene un conjunto de herramientas para la interacción con el sistema. Las utilidades implementadas, sin profundizar mucho en ellas, son las siguientes:
acladd
: añade una entrada literal ACL a un fichero.- Uso:
acladd <ruta> <allow/deny> <usuario> <[rw]>
- Uso:
aclget
: muestra el ACL de un fichero. En caso de tomar la flag-e
, muestra el ACL efectivo en lugar del literal.- Uso:
aclget [-e] <ruta>
- Uso:
acltree
: muestra las entradas literales ACL desde la raíz hasta el fichero dado.- Uso:
acltree <ruta>
- Uso:
append
: añade una línea de texto a un fichero existente.- Uso:
append <ruta> [palabra 1] [palabra 2] [...]
- Uso:
cat
: lee un fichero completo.- Uso:
cat <ruta>
- Uso:
ls
: muestra el listado de archivos de un directorio. De no especificarse uno, se usaPWD
.- Uso:
ls [<ruta>]
- Uso:
mem
: muestra el uso de memoria de un PID. De no especificarse uno, se muestra el uso de todo el sistema.- Uso:
mem [<PID>]
- Uso:
mkdir
: crea un directorio.- Uso:
mkdir <ruta>
- Uso:
mkfile
: crea un archivo regular vacío.- Uso:
mkfile <ruta>
- Uso:
su
: realiza un cambio de usuario mediante un RPC aswitcher
.- Uso:
su <nombre de usuario>
- Uso:
useradd
: crea un nuevo usuario.- Uso:
useradd <nombre de usuario>
- Uso:
userinfo
: muestra información sobre un usuario. De no especificarse uno, los lista.- Uso:
userinfo [<nombre de usuario>]
- Uso:
usermod
: modifica un usuario; es decir, le añade o retira un permiso, así como modifica suSUPER
[MM33].- Uso:
usermod give <nombre de usuario> <permiso>
- Uso:
usermod take <nombre de usuario> <permiso>
- Uso:
usermod super <usuario modificable> <usuario modificador>
- Uso:
Existen varios puntos de interés a comentar sobre la biblioteca estándar: la STL, el allocator, y las abstracciones aportadas sobre el sistema y servicios. Se explicarán en esta sección.
Una muy gran parte de la biblioteca estándar es la STL (Standard Template Library), un conjunto de clases que implementan estructuras de datos abstractas para ser usadas por el resto de programas de forma transparente. Ejemplos incluyen contenedores como std::set
o std::unordered_map
, pero también std::string
. Son muy similares a las de otros sistemas operativos, pero no compatibles, pues no se adhieren necesariamente a la ABI de C++. Aquí se encuentra una lista de las estructuras abstractas de alto nivel implementadas:
bitmap
, un mapa de bits sobre una región de memoria dada. Es equivalente a un vector de booleanos, pero mucho más eficiente en memoria.vector
, contenedor de datos consecutivos en memoria, rápidamente iterables. Implementado como un array.list
, lista enlazada.dlist
, una lista doblemente enlazada.priority_queue
, un contenedor especializado en mantener rápidamente accesible el elemento mayor o menor de una secuencia. Implementado como una heap.map
, un contenedor clave-valor que mantiene las claves ordenadas, útil si quieren ser iteradas por orden. Implementado como un AVL.set
, un contenedor genérico que mantiene los elementos ordenados, para ser iterados por orden. Implementado como un AVL.unordered_map
, contenedor clave-valor desordenado. Implementado como una tabla hash Robin Hood \cite{rhht}.unordered_set
, contenedor genérico desordenado. Implementado como una tabla hash Robin Hood \cite{rhht}.pair
, un par de elmentos arbitrarios.queue
, un contenedor FIFO.stack
, un contenedor LIFO.string
, que, aunque no es una estructura abstracta, se suele incluir en esta clase de proyectos.
El allocator que usa la biblioteca estándar de C++ de Strife es liballoc
, una implementación independiente de la plataforma fácil de incluir en sistemas operativos hobby \cite{liballoc}. El programador del sistema solo tiene la responsabilida de establecer un mecanismo de cerrojo para en caso de multithreading, además de funciones para reservar memoria en la heap. Es la única parte del sistema operativo fuera del bootloader que no está escrita por mí [MM34].
La biblioteca estándar aporta algunas funciones escritas para ser especialmente rápidas en los procesadores modernos de x86. Se trata del trío memcpy
, memmove
, y memset
.
Desde la microarquitectura Ivy Bridge, los procesadores implementan una funcionalidad denominada ERMSB (Enhanced rep movsb), que hace que la copia de bytes de una parte de la memoria a otra sea, bajo ciertas circunstancias que se suelen cumplir, más rápida que cualquier otro método \cite{ermsb}.
Por esto, las tres funciones estándares mencionadas están implementadas en ensamblador utilizando estas instrucciones. Tanto memcpy
como memmove
están implementadas con rep movsb
, y memset
está implementado con rep stosb
, que también suele estar acelerado.
Se aportan abstracciones sobre RPC en la cabecera <rpc>
.
Desde el lado del cliente, hay una fina abstracción sobre la syscall de RPC en la biblioteca estándar. En lugar de tener una única función para hacerla, existen cinco, dependiendo del número de parámetros (de 0 a 4). Esto permite al compilador organizar mejor los registros para conseguir el mínimo movimiento posible.
Desde el lado del servidor, la abstracción es mayor. Se aporta un punto de entrada RPC por defecto, que se usa cuando std::enableRPC
se llama sin el puntero a la función. Esta tiene su entrada en ensamblador y organiza los RPIDs mediante un vector. Se aporta una función para modificar este array y añadir procedimientos públicos, std::exportProcedure
, que recibe un puntero a función casteado a void*
y el número de argumentos que espera recibir. Es importante comentar que el vector está implementado lock-less, sin exclusión mutua, para acelerar la entrada, con lo cual todas las llamadas a exportProcedure
deben realizarse antes de la llamada a enableRPC()
, para que no existan condiciones de carrera.
Un ejemplo, que se puede ver a lo largo de todos los servicios, sería de este estilo:
std::exportProcedure((void*)connect, 1);
std::exportProcedure((void*)flush, 1);
std::enableRPC();
std::publish("term");
std::halt();
En la cabecera, <cstdio>
, se aportan abstracciones sobre la escritura por pantalla.
El sistema operativo no fuerza al programador a escribir en la página compartida con term
cada vez que quiera escribir por pantalla. En su lugar, toda la comunicación con el servicio, incluyendo la llamada a CONNECT
para inicializar la página compartida, se encuentra encapsulada en la biblioteca estándar.
Sobre esta encapsulación existe una implementación de std::printf
funcional, que almacena todo lo que se va a imprimir en un buffer (la página compartida), y se hace la llamada a FLUSH
cuando se llena o cuando ocurre un salto de línea, de igual forma que está implementada en la libC de GNU.
Además, se aportan las funciones std::uToStr()
para convertir un entero sin signo a std::string
, y std::strToU
, para la inversa.
La cabecera <fs>
aporta funciones de muy alto nivel para la comunicación con VFS:
bool std::exists(const std::string&)
comprueba la existencia de un archivo.std::string std::simplifyPath(const std::string&)
realiza la simplificación de rutas explicada en la Sección VFS.std::VFS::Info std::getFileInfo(const std::string&)
devuelve la estructura de información sobre una ruta.bool std::isFile(const std::string&)
indica si una ruta es un archivo regular.bool std::isDir(const std::string&)
, si es un directorio.size_t std::listFiles(const std::string&, FileList&)
enumera los archivos de un directorio, escribiendo sobre una estructuraFileList
(typedef deunordered_set
), devuelve el código de error del VFS.size_t std::readFile(const std::string&, uint8_t*, size_t start, size_t sz)
realiza la lectura de un archivo sobre un buffer dado.size_t std::readWholeFile(const std::string&, Buffer&)
lee un archivo completo, modificando una referencia a un objetostd::Buffer
, basado enstd::rcptr<uint8_t>
, que es similar al estándarstd::shared_ptr
, salvo que no utiliza atomics.size_t std::writeFile(const std::string&, char*, size_t start, size_t sz)
, similar areadFile
, pero para la escritura.size_t std::mkdir(const std::string&)
crea una ruta.size_t std::mkfile(const std::string&)
crea un fichero regular.size_t std::addACL(const std::string&, size_t uid, const ACLEntry&)
añade un ACL literal a una ruta.size_t std::getACL(const std::string&, ACL&)
lee el ACL (unión de las entradas ACL) y las deposita en una referencia aACL
, definido como un typedef destd::unordered_map
.size_t std::getEACL(const std::string&, ACL&)
hace la misma operación, pero con ACLs efectivos.
Todas las funciones anteriormente descritas mantienen la ruta seleccionada en el VFS para evitar selecciones múltiples sobre el mismo archivo.
La cabecera <random>
aporta dos herramientas principales:
- La clase
std::BadRNG
, que implementa el xoshiro256** con splitmix64. Es usada entests
para generar valores con hashes dispares mientras se prueba la tabla hash Robin Hood. - La función
uint64_t std::rand64()
que genera un entero sin signo de 64 bits, mediante una llamada astd::csprng
, el wrapper de la syscallCSPRNG
.
La cabecera <registry>
implementa una abstracción a dos niveles sobre el registro.
Por un lado, las funciones:
size_t std::registry::exists(std::string&)
, que comprueba la existencia de una ruta, devolviendo un código de error destd::uregistry
(enumerado) en el proceso,std::unordered_set<std::string> std::registry::list(std::string& path)
, que devuelve la lista de hijos de una ruta en el registro. De no existir, devuelve un contenedor vacío.size_t std::registry::create(std::string&)
, que crea una ruta.
Por otro, se aporta bool std::has(std::PID pid, const std::string& perm)
, que, para ser usado en solo una línea en los procedimientos públicos, comprueba la existencia de /u/pid/perm
).
<tasks>
abstrae el funcionamiento de dos mecanismos:
- La ejecución de tareas, para la cual se implementa
std::PID std::run(const std::string&, const std::Args&, const std::Env&)
. - La recepción de argumentos y variables de entorno, para lo que se ofrecen la clase
std::Runtime
y la estructurastd::RuntimeV
. Todo programa en Strife recibe como parte de sumain
(realmente,_start
), un argumento: un objetoRuntime
. De su métodoparse()
se puede obtenerRuntimeV
, un par deArgs
yEnv
.
<users>
abstrae el servicio users
de una forma muy simple:
std::string std::uidToName(size_t)
traduce UID a nombre de usuario.size_t std::nameToUID(std::string&)
traduce nombre de usuario a UID.size_t std::howManyUsers()
enumera los usuarios.size_t std::newUser(std::string&)
crea uno nuevo.
Además, se aporta el one-liner size_t std::PIDtoUID(std::PID)
para la traducción automática de PID a UID por medio de la syscall INFO
, con tal de reducir el boilerplate de los procedimientos públicos.
<mutex>
aporta diversas semánticas de exclusión mutua. La más simple, std::Spinlock
es el mismo test, test and set
usado en el kernel.
Usando std::Spinlock
se crea std::Semaphore
. std::mutex
se define como un semáforo de capacidad 1. Además, se aporta std::Smokers
para resolver el problema de los fumadores, necesario para el driver de teclado como se explicó en la Sección keyboard, y generalizado a la stdlib por si hiciera falta en otro servicio.
Existe un directorio de cabeceras, <userspace/*.hpp>
, que contiene enumerados de cada RPID para cada servicio, con tal de poder referenciarlo sin números mágicos.
Además, <syscalls>
contiene el enumerado de todas las llamadas al sistema, pero, más importante, contiene funciones que abstraen cada una para no forzar al programador a escribir inline assembler. <kkill>
enumera las razones por las que el kernel puede matar un proceso, y <loader>
los posibles valores de error de salida del cargador de programas.
Según todo lo explicado, existen tres generaciones de procesadores que funcionan de forma distinta en Strife:
- Strife funciona en cualquier procesador desde el Core 2, o, realmente, cualquier microarquitectura x86-64 para computadores de sobremesa.
- Si la generación es Ivy Bridge o superior, entonces soporta la instrucción
RDRAND
, que se usa para inicializar la pool de entropía, y, además, se tiene SMEP, que se habilita. - Si la generación es Broadwell, soporta SMAP, que se habilita.
De esta forma, se han hecho opcionales todas las extensiones a la arquitectura original, con lo que Strife funcionará en cualquier microprocesador que soporte el ISA x86-64.
Se ha terminado con 29 repositorios en la organización, de los cuales 24 se usan para el proyecto nuevo. Todos juntos, dan la cantidad de líneas de código que aparece en la Tabla tab:loc. Desglosadas por proyecto, se obtiene la Tabla tab:loc2.
Lenguaje | Archivos | Líneas |
---|---|---|
C++ | 372 | 20238 |
Ensamblador | 16 | 905 |
Suma | 388 | 21143 |
\FloatBarrier
Proyecto | Archivos | Líneas totales |
---|---|---|
Kernel | 140 | 7028 |
stdlib | 102 | 6567 |
VFS | 28 | 1298 |
StrifeFS | 17 | 1259 |
Loader | 17 | 965 |
AHCI | 15 | 801 |
coreutils | 27 | 769 |
ISO9660 | 13 | 548 |
PCI | 6 | 469 |
tests | 12 | 441 |
block | 13 | 400 |
registry | 6 | 275 |
term | 5 | 238 |
shell | 6 | 234 |
keyboard | 5 | 152 |
users | 5 | 144 |
helper | 1 | 128 |
init | 2 | 91 |
ramblock | 2 | 79 |
PSNS | 2 | 51 |
switcher | 2 | 50 |
splash | 2 | 26 |
\FloatBarrier
El tamaño final del ISO ha resultado ser 1.33MB. Sin embargo, este formato de archivo es propenso a rellenar sectores con ceros, con lo que, de tener muchos archivos pequeños, resulta de fácil compresión. En la release de GitHub, se ofrece un binario comprimido con XZ que baja el tamaño a tan solo 249KB.
Como nota personal, estoy orgulloso de haber conseguido alcanzar un tamaño tan bajo, y, por ello, he incrustado la versión comprimida en este mismo PDF. Si su visor de PDF lo soporta[fn:soporte], puede extraerla:
[fn:soporte] Sé que el lector de PDFs de Chrome no lo soporta. Evince, sin embargo, sí. El de Firefox requiere doble click. Acrobat requiere click derecho.
Se considera de interés analizar en retrospectiva algunos de los problemas encontrados. Se mencionarán cuatro de los más importantes que ha sufrido el proyecto durante su desarrollo.
- Para empezar, no se puede descartar el borrón y cuenta nueva. A causa de no pensar cuidadosamente el diseño, en parte por la falta de conocimientos, fue necesario, como se expresó en la Sección Motivación e historia, comenzar el proyecto desde cero, lo que implica que las miles de líneas dedicadas al anterior fueron, de forma directa, a la basura, conservando únicamente la experiencia de haberlo llevado acabo. Del SO antiguo se puede extraer:
- La STL, con varias estructuras que quedaron para la nueva versión, en su repositorio propio. Entre ellas, las más importantes son los árboles balanceados.
- El bootloader. Si bien JBoot acabó siendo archivado y abandonado, no me arrepiento de haber invertido horas en él. Escribir un bootloader que sea capaz de cargar un kernel da una perspectiva minuciosa de cómo funciona x86, y escribir ensamblador en modo real, extraido directamente de la década de los 80, es una experiencia enriquecedora.
- La experiencia de desarrollo de un sistema operativo. Aunque hacerlo monolítico supone un esfuerzo menor a hacerlo microkernel, las primeras implementaciones de los drivers que he escrito se encuentran allí.
- Múltiples han sido los bugs de corrupción de memoria. En ellos, partes de la memoria se sobrescriben arbitrariamente por un bug en una parte del código que es imposible encontrar, puesto que el fallo no se manifiesta cuando la corrupción ocurre. Se pueden destacar dos grandes bugs de corrupción que ha sufrido el proyecto:
- En el SO antiguo, el bootloader era incapaz de cargar el kernel cuando este superaba un cierto tamaño. Lo recuerdo vívidamente: este fallo se manifestó cuando añadir un
if
sin utilidad a una sección del código, con un cuerpo vacío, causaba que la CPU entrara en triple fault. Añadir bloqueos (while(true);
) antes y después delif
no suponía una diferencia, y la cantidad de horas hasta caer en la cuenta de a qué se debía superó las 24. Este bug fue arreglado en el repositorio JBoot el 15 de mayo de 2020 \cite{jbootbug}. - En el SO nuevo, la rutina de
moreHeap
recibe el número de páginas a reservar. Cuando existía el bug, reservaba una única página de memoria, y mapeaba secuencialmente desde ella hasta el número dado. Esto implicaba darle al proceso memoria ya inicializada y usada. Se manifestó al ver que se sobrescribía la tabla de páginas. El bug fue arreglado en el repositorio kernel el 26 de marzo de 2022 \cite{moreheapbug}.
- En el SO antiguo, el bootloader era incapaz de cargar el kernel cuando este superaba un cierto tamaño. Lo recuerdo vívidamente: este fallo se manifestó cuando añadir un
- El PMM sufrió un gran rediseño en julio de 2022. Desde el comienzo del proyecto, estaba implementado como una free list; esto es, una lista enlazada de páginas físicas libres en memoria. Por esto, no era posible reservar páginas físicas consecutivas. Por ello, para mantener la localidad de las regiones compartidas, dichas regiones debían ser de exclusivamente una página. Esto tenía implicaciones de velocidad, pero, más desagradable para el desarrollador, implicaciones de boilerplate y complicación de la comunicación entre procesos, puesto que muchos de los procedimientos públicos debían realizarse de manera paginada. El cambio fue realizado el 9 de julio de 2022 \cite{newpmm}.
- Usualmente desarrollo en mi ordenador de sobremesa, que es x86. Durante una prueba del SO en el emulador de mi Macbook Pro, que es ARM64, el servicio
block
causaba #UD. Después de tiempo de debugging, se descubrió que era por utilizar la instrucciónRDRAND
enstd::rand64
sin comprobación previa de que estuviera disponible medianteCPUID
. Desde entonces, consideré oportuno la existencia de la Sección Generación mínima.
La demostración aquí expuesta de uso del sistema operativo está enfocada a mostrar el uso de las coreutils sobre el modelo de protección. Se tratarán dos aspectos: los permisos del registro, y los ACLs de los archivos. En la Figura fig:cap01 se encuentra Strife arrancado y esperando a la entrada de usuario.
La primera acción a realizar será ejecutar el programa mem
para ver el uso de memoria de todo el sistema, y se encuentra en la Figura fig:capmem. Allí, se puede apreciar que el uso de memoria es de 32MB [MM35].
En la Figura fig:captests se encuentra la ejecución del programa tests
, que contiene los tests unitarios implementados [MM36].
Comienza la demostración. En la Figura fig:cap02 se listan los usuarios, se añade jlxip
, y se listan de nuevo. Después, se muestran información sobre el usuario recién creado, y se trata de cambiar a él.
su
causa un error, y, como puede resultar común trabajando en el sistema operativo, ofrece una pista sobre a qué se puede deber. jlxip
no tiene acceso a /cd/bin/shell
. Esto se puede comprobar por medio de la utilidad aclget
, y se muestra en la Figura fig:cap03. También se pone a prueba el programa acltree
que realiza esta acción en un solo paso en la Figura fig:cap04. En la Figura fig:cap05, se utiliza la opción -e
de aclget
para obtener el ACL efectivo del archivo.
Para dar permisos de lectura a jlxip
sobre /cd/bin, se utiliza acladd
. Como /cd/
es un punto de montaje relativo a un sistema de archivos solo-lectura, no es posible aplicarle el ACL directamente a /cd/bin/shell
. En su lugar, se aplica a /
. En el futuro, esto se podría solucionar por medio de enlaces simbólicos. La adición de la entrada ACL y la verificación se encuentran en la Figura fig:cap06.
Teniendo jlxip
permisos de lectura sobre toda la raíz, se vuelve a ejecutar su
. Véase la Figura fig:cap07.
Se ha disparado un error MAC. La shell se ha cerrado con error puesto que no ha podido comunicarse con el servicio de teclado. Esto se puede deber a la ausencia de la ruta /u/2/KEYBOARD
en el registro, y así lo hace intuir el error. En la Figura fig:cap08 se añade el permiso, y en la Figura fig:cap09 se puede apreciar que el cambio de usuario se lleva a cabo con éxito.
Ahora, se persigue el objetivo de permitir a jlxip
crear un archivo. Si lo intenta hacer en la raíz, como aparece en la Figura fig:cap10, el VFS concluye que el permiso requerido no es un subconjunto del ACL efectivo de la ruta. Este error es captado por mkfile
, y mostrado.
Como solución, system
crea la ruta /home
, y da permisos de escritura a jlxip
. En este proceso, el ACL de /home
es permitir a ~jlxip~ la escritura. Unida a la de la raíz, permitir a ~jlxip~ la lectura, el ACL efectivo de /home
es permitir a ~jlxip~ la lectura y escritura. En la Figura fig:cap11 se puede apreciar este procedimiento.
Haciendo el cambio de usuario de vuelta a jlxip
, se puede observar cómo es posible crear el archivo bajo /home
y escribir en él para luego leerlo, habiendo completado el proceso con éxito.
Para empezar, se procede a enumerar los objetivos descritos en la Sección Objetivos y analizar su desarrollo. Se comienza con los primarios:
- La contribución al bootloader no se ha mencionado hasta ahora puesto que es un proyecto ajeno al trabajo, pese a necesario para su completación. Mis contribuciones al proyecto se pueden encontrar aquí \cite{liminecontrib}, se llevaron a cabo de febrero a abril de 2021.
- El modelo de protección para la comunicación entre procesos se ha llevado a cabo por medio del registro, cuyo diseño apareció en la Sección El registro: un entorno innovador, y cuya implementación se ha descrito en la Sección Registro.
- El microkernel en base a RPC ha sido desarrollado con éxito, presentado en la Sección RPC, y sus mecanismos de ida y vuelta explicados en la Sección RPC en detalle.
- El cargador de programas en userspace se ha llevado a cabo y descrito en la Sección Loader.
- La biblioteca estándar se ha desarrollado, y su explicación se ha dado en la Sección La biblioteca estándar.
- Los servicios misceláneos para el funcionamiento del sistema han sido todos descritos en las secciones Usuarios y switcher y De term a coreutils.
- La pila de almacenamiento ha sido desarrollada y expuesta en la Sección Pila de memoria secundaria.
Seguidamente, los secundarios:
- El driver de teclado y la shell se han llevado a cabo, y su implementación está explicada en detalle en las secciones keyboard y shell.
- Se llevó a cabo la realización de herramientas núcleo y su enumeración y breve explicación aparecen en la Sección coreutils.
- Se implementaron los tests unitarios, cuya ejecución se encuentra en la Sección Puesta a prueba.
\newpage Se resumen las tres aportaciones del proyecto Strife al estado del arte de los sistemas operativos:
- Se ha llevado a cabo el desarrollo de un microkernel con RPC local, bajo la misma máquina; un mecanismo de comunicación entre procesos que ha pasado desapercibido en el diseño de los sistemas operativos. Se ha aportado el novedoso mecanismo de ejecución dual, mediante el cual todo proceso se ejecuta en todo momento, o bien como sí mismo, o bien como otro. Esto ha conllevado la desaparición de los servicios del scheduler, y, por lo tanto, no existe disparidad de prioridades entre una tarea y el recurso al que quiere acceder. Si una tarea se encuentra en una de las colas VSRT, todas las peticiones que realice, a cualquier servicio, se efectuarán con prioridad máxima.
- Sobre las implementaciones existentes y conocidas de ACLs, se ha aportado la idea de ACLs jerárquicos. De esta manera, las entradas de control de acceso de un directorio aplican sobre las existentes en su padre, y no son independientes. Esto reduce el gasto de memoria de la implementación del sistema de archivos, aumenta la velocidad, y facilita el mantenimiento: como toda entrada es jerárquica, no hay necesidad de realizar alteraciones recursivas sobre los archivos para cambiar sus permisos, y las excepciones sobre archivos o subdirectorios concretos pueden ser llevadas a cabo sin mayor problema.
- Por último, se ha diseñado el registro como mecanismo extranuclear de gestión de MAC y DAC en los servicios del sistema, ofreciendo un modelo de protección robusto y extensible. En el registro, un usuario contiene una serie de permisos, que solo pueden ser dados por otro usuario que los posea de antemano. Los servicios, de así desearlo, consultan el registro y comprueban la existencia del permiso correspondiente del usuario cuya tarea realizó la llamada. El registro es efímero, lo que fuerza al administrador del sistema a tomar una postura secure by default en la que todo permiso debe ser concedido durante el arranque: por defecto, nadie puede hacer nada salvo
system
.
En este trabajo se han expuesto los fundamentos del diseño y desarrollo de sistemas operativos, desde su punto de vista teórico hasta su lado más práctico. Se ha hecho un análisis del estado del arte de los proyectos existentes relacionados, y se ha propuesto el proyecto Strife como un sistema operativo basado en arquitectura microkernel con un modelo de protección que es capaz de limitar las acciones de los procesos según sus permisos de control de acceso. Sobre dichas ideas, se han descrito las decisiones de diseño y sus aspectos más prácticos: desde la gestión de memoria física, que es la base más fundamental del kernel, hasta el funcionamiento de la shell, pasando por la función std::run
de la biblioteca estándar, cuyo funcionamiento abstrae más de 10.000 líneas de código de forma directa. Finalmente, se han incluido capturas de pantalla que muestran la ejecución del sistema operativo terminado sobre un caso de uso específico.
Existen ciertas mejoras y aspectos que pueden dar continuidad al proyecto de forma directa y rápida, se encuentran en el Apéndice Margen de Mejora. Por otro lado, existen otros aspectos más amplios de desarrollo que deberían de ser llevados a cabo en el futuro. El sistema operativo requiere aún muchos años de pulido. Se procede a comentar los más relevantes:
- SMP. Corto plazo. Si bien corresponde a MM15, es lo suficientemente amplio, y requeriría tanto tiempo para tener un estado funcional, que se incluye aquí. Todo el kernel y los servicios están desarrollados teniendo SMP en mente, se hace extensivo uso de mecanismos de exclusión mutua para asegurar la consistencia de las estructuras, pero, muy probablemente, existan interbloqueos que a día de hoy no son perceptibles en el sistema operativo por utilizar únicamente un núcleo. La mayoría del tiempo iría a arreglarlos, y se estima que conllevaría unas 50 horas.
- Un editor de texto. Medio plazo. Nada complejo, más bien un proyecto similar a GNU nano \cite{nano}, con atajos de teclado para moverse entre las líneas y poder redactar un documento sencillo en texto plano. Se estima que conllevaría 100 horas.
- Una pila de red. Medio plazo. Desde 2019 he estado deseando poder hacerle ping a una máquina virtual ejecutando mi sistema operativo. Esto requiere implementar una pila de servicios; para ping, se requiere un driver de Ethernet (posiblemente para el chip RTL8139), un servicio de ARP, otro de IP, una utilidad de DHCP, y un servicio de ICMP. Para llevarlo más allá, por ejemplo, para servir una página web, serían necesarios servicios de UDP y TCP (implementación posiblemente Reno), así como un servicio de DNS. Para tener un servicio ICMP funcional, llevaría unas 50 horas. Sin embargo, de querer una implementación de TCP funcional, posiblemente ello solo requerirá otras 50 horas.
- Un gestor de paquetes. Medio plazo. Teniendo una pila de red funcional, el paso inmediatamente siguiente sería construir un gestor de paquetes que sea capaz de descargar programas de un repositorio de internet e instalarlos. La mayor dificultad estaría en el diseño de las cabeceras de paquete, la estructura que respondería a preguntas como ¿Qué dependencias tiene este programa? o ¿Requiere un usuario específico, y qué permisos? Se estima que conllevaría unas 80 horas.
- Un compilador de C++. Muy largo plazo. En algún punto, y de no abandonar el proyecto antes, será necesario que el sistema operativo pueda compilarse a sí mismo. Para escribir el compilador, primero realizaría mi propia implementación de lex (tarea que llevé a cabo en la práctica 2 de la asignatura Modelos de Computación), un generador de autómatas finitos deterministas con su base en el algoritmo de Thompson. Seguidamente, mi propia implementación de yacc, mediante la generación de tablas LALR, tal y como se explicaron en la asignatura /Procesadores de Lenguajes/[fn:1]. Requeriría una cantidad inmensurable de tiempo; pero, por proponer una estimación, diría 1000 horas.
[fn:1] Este proceso lo llevé parcialmente a cabo en el pasado, en mi proyecto PL-LR \cite{pllr}.
El trabajo aquí descrito se trata del proyecto de mi vida. En lo relativo al mundo del software, existen proyectos más difíciles, como cualquier tesis doctoral, así como más grandes, como podría ser un editor de vídeo; sin embargo, el proyecto abordado ha resultado complejo porque involucra conocimiento específico y avanzado sobre una gran cantidad de partes y subsistemas. Por esto, considero que The Strife Project es mi límite, lo más alto que puedo alcanzar. Es, como todo, muy mejorable, y los diseños y, sobre todo, las implementaciones, tienen un amplio margen de mejora y optimización, pero ello no quita que haya tenido la asertividad de comenzar el proyecto y la voluntad de acabarlo.
Me alegro de haber tenido la oportunidad de presentar este trabajo como mi final de grado, y les agradezco a mis tutores, José Luis Garrido Bullejos, y Carlos Rodríguez Domínguez, haber confiado en mis capacidades para llevar a cabo una tarea de esta magnitud y complejidad.
Estoy orgulloso de haberlo conseguido. Las cientos de horas de arreglar bugs y dibujar esquemas de los diseños en la pizarra de mi habitación, durante todos estos años, han dado su fruto. En el proceso, he profundizado sobre muchas áreas de la computación y he aprendido conceptos que estarán conmigo durante el resto de mi trayectoria. Conocer el funcionamiento interno de las capas de abstracción que se usan día a día causa una gran diferencia a la hora de escribir un programa. Por ejemplo, se tiene especial cuidado en utilizar la caché de los núcleos para acelerar las operaciones.
Además, me he adentrado en el mundo del hobby osdev, dentro del cual he conocido a personas brillantes (con mentes más brillantes si cabe) que han tenido la bondad de estar ahí en momentos de confusión e incertidumbre a la hora de escribir alguno de los drivers.
En este primer apéndice se incluye una lista con aquellos detalles que han quedado en el tintero, y que deberían de considerarse a la hora de continuar el proyecto. Para cada uno, se expone un calificador de prioridad, que puede ser baja, media, o alta.
- <<MM1>>Explorar el espacio de quanta para encontrar el óptimo en un estado de carga usual del sistema operativo. Su prioridad es baja puesto que la ejecución dual reduce considerablemente el número de procesos en estado ejecutable del sistema, y la reentrancia ocurre con poca frecuencia.
- <<MM2>>Implementar un mecanismo de caché en el VFS relativo al cómputo de ACLs efectivos. Este puede resultar ser el mayor bottleneck de la lectura de archivos. De resultar ser así, la prioridad sería alta.
- <<MM3>>Considerar StrifeFS2, un sistema de archivos más robusto con las mismas ideas. El actual es usable, y no sufre problemas de velocidad. Como StrifeFS2 mejoraría solo el aspecto de tolerancia a fallos, la prioridad se fija a baja.
- <<MM4>>Extensión de la pila pendiente de implementar. No se ha encontrado un caso en el que fuera necesario, pero en algún momento deberá de hacerse. Por esto, la prioridad es baja.
- <<MM5>>Guardar el índice del primer bit libre de un bitmap es una optimización muy usual cuando se trata con bitmaps, con lo que sería una buena idea implementarla. Por conllevar poco esfuerzo y posiblemente ofrecer un speedup significativo, la prioridad es alta.
- <<MM6>>Será necesario implementar un mecanismo de liberación forzada de memoria para hacer el kernel más robusto. Por ejemplo, se podría matar al proceso que más memoria esté consumiendo en ese momento. Por tratarse de una solución que aporta robustez y no incrementa la velocidad del sistema, y puede aportar confusión mientras el proyecto está en un estado inmaduro, es mejor realizarla más adelante y su prioridad es baja.
- <<MM7>>La comprobación de syscalls relativas al hardware no está implementada. En su lugar, se comprueba si el proceso que la efectúa es
system
o no. Aún así, se referencian los permisos que se usarían para estos propósitos. La prioridad se fija a media por ser un requisito importante en el mecanismo de MAC, aunque no crítico: no es un problema de seguridad, sino más bien una restricción. - <<MM8>>xoshiro256** no es un CSPRNG. Cambiarlo en el futuro por SHA-256 debería de ser suficiente, aunque se podrían considerar hashes más rápidos como Skein o Blake. Por tener actualmente un hash funcional y solo aumentar la seguridad, se califica la prioridad de esta tarea como media.
- <<MM9>>Utilizar
RDRAND
puede levantar la ceja de más de un experto en criptografía. En principio debería ser correcto, siempre y cuando se cumpliera [MM10]. - <<MM10>>Mezclar frecuentemente entropía obtenida de la latencia de distintas operaciones de comunicación con el hardware en la pool del kernel. Sería necesario una syscall para ello. Se podría, además, inicializar con valores dados por relojes de menor frecuencia que la CPU, como la PIT. La prioridad es media puesto que no supone una mejora inmediata al funcionamiento del sistema operativo.
- <<MM11>>El procedimiento de espera cuando no hay ninguna pila disponible al hacer RPC no se realiza; en su lugar, se mata al proceso. Es complejo de programar porque el proceso está en un estado intermedio de
runningAs
, con lo que se deja para el futuro, y se establece su prioridad a baja. - <<MM12>>Es fácil evitar la restricción de cantidad máxima de SMIDs en el PCB. Se tomaría la última cuadrupalabra montando una lista enlazada en la memoria privada del kernel. Si bien las búsquedas conllevarían fallos de caché, es la solución más simple a este problema. Cualquier otra requeriría estructuras de datos más complejas que es mejor dejar fuera del microkernel. Es poco probable llegar actualmente a ese límite, con lo que sería apropiado dejarlo para cuando el problema esté cerca de manifestarse. Su prioridad es baja.
- <<MM13>>El mecanismo de creación y destrucción de regiones compartidas en cada RPC es subóptimo. Alterar la tabla de páginas cada vez que se requiera hacer un paso de información ralentiza el sistema. Sin embargo, esta ralentización se oculta con una ralentización mucho mayor: la gran secuencia de RPCs del storage stack. Aunque se trata de una cuestión de diseño y su solución es compleja, es posiblemente el mayor bottleneck de todo el sistema operativo, con lo que su prioridad no puede ser otra sino alta.
- <<MM14>>El mecanismo de cierre del stacktrace RPC no está implementado. En su lugar, actualmente se mata a B, y A se deja suelto. Es prioritario, en el futuro, escribir la rutina que realice este procedimiento, pero, como el problema es difícil que se manifieste en el estado actual del proyecto, su prioridad se deja en media.
- <<MM15>>SMP no está implementado en la versión de Strife de la entrega. Tras realizar otras tareas más prioritarias, estructurales del sistema operativo, mencionadas en este mismo apéndice, tendrá que llevarse a cabo. Su prioridad es baja.
- <<MM16>>CoW no está implementado actualmente en el loader de Strife. Aunque conllevaría un ahorro de memoria importante, no es una característica necesaria para el correcto funcionamiento del sistema, y por esto su prioridad es media.
- <<MM17>>El registro no implementa grupos, tan solo usuarios. Se deberá de hacer en el futuro, pero no resulta fundamental, así que su prioridad es baja.
- <<MM18>>La funcionalidad de
SUPER
en el registro no está implementada. De forma similar a MM7, la comprobación temporal para la modificación es si el UID es 1 (system
). Y, de la misma forma, su prioridad es media. - <<MM19>>Sería interesante considerar si la lectura global del registro compromete de alguna manera al sistema. En el primer vistazo podría parecer obvio que sí, pero conllevaría complicar todo el userspace mantener un subdirectorio de permisión de lectura. Se podría hacer por grupos, de tal forma que todos los servicios críticos estén en un grupo de lectura que se añada por defecto a todos los usuarios. Su prioridad es baja.
- <<MM20>>Implementar IDE, aunque sea un driver PIO o Ultra-DMA polling, es mandatorio y deberá hacerse en el futuro. Esto permitirá ejecutar Strife en una máquina virtual sin tener que hacer configuraciones previas, puesto que IDE es la interfaz por defecto en la mayoría de hipervisores, aunque hoy en día sea poco común en computadores modernos. Por ofrecer una mejoría claramente visible durante la puesta a prueba del sistema operativo, su prioridad es alta.
- <<MM21>>En el futuro (lejano), se implementará un subdriver de SATA para poder instalar Strife en un disco duro y arrancar de él. Su prioridad es baja puesto que, de ser necesario un servicio crítico más, todas las instalaciones quedarían obsoletas. Esto trae consigo varias dificultades. La mayor es que sería necesaria una abstracción sobre las particiones, así como un driver del sistema de archivos FAT32 para que el bootloader pueda cargar el kernel; porque, naturalmente, el bootloader no tiene driver de StrifeFS, ni merecería la pena implementarlo aunque la desarrolladora del proyecto estuviera de acuerdo.
- <<MM22>>Será importante, en el futuro, implementar DMA guiado por IRQs con tal de no bloquear la CPU durante la lectura de sectores de disco. Esto puede tratarse de un bottleneck, y, así, su prioridad es alta.
- <<MM23>>Actualmente, los UUIDs de
block
se generan de forma aleatoria. Esto debería de ser al revés: los drivers de almacenamiento deberían hacerle saber ablock
cuáles son sus UUIDs, obtenidos desde los propios dispositivos (ramfs
lo generaría aleatoriamente,AHCI
posiblemente lo llevaría a cabo leyendo valores identificadores como Vendor ID del dispositivo PCI). No tener UUIDs fijos para los dispositivos AHCI implica que, de haber varios dispositivos ATAPI conectados al HBA, no se puede identificar cuál es el de arranque. Por esto, VFS alerta y se cierra en caso de detectar más de uno. La prioridad es baja porque el caso mencionado es difícil que ocurra. - <<MM24>>Los valores de formateo del servicio de StrifeFS son completamente arbitrarios y deberían de ser estudiados más cuidadosamente. La prioridad es media puesto que se terminará alcanzando el límite de inodos en el medio plazo.
- <<MM25>>Es de alta prioridad implementar el borrado de archivos y entradas ACL. Es independiente del diseño de archivos, con lo cual es solo cuestión de añadir los procedimientos públicos y las rutinas.
- <<MM26>>La funcionalidad relativa a grupos no está soportada actualmente en el servicio
users
. Al igual que [MM17], su prioridad es baja. - <<MM27>>Cuando MM21 sea una realidad,
users
será el encargado de mantener un archivo persistente con las definiciones de usuarios y grupos, de forma similar a como lo hace UNIX con/etc/passwd
y/etc/group
. Se hereda la prioridad baja por secuencialidad. - <<MM28>>Si bien esta restricción se comprueba (nombre de usuario válido), no se hace por medio de una expresión regular puesto que no se ha implementado una máquina de Thompson en la stdlib. Realizar esto último es una tarea de baja prioridad.
- <<MM29>>Hacer polling al teclado es una solución temporal y una muy mala idea. Cuando el sistema operativo está en reposo, será por que está esperando una orden desde la shell. A causa del polling, el scheduler nunca queda vacío, con lo que el uso de CPU siempre está al 100%, lo que imposibilita cualquier tipo de medición sobre la carga del sistema: siempre es máxima. Quizá el lector pueda inducir la forma correcta de realizar esto: esperar a un IRQ. Sin embargo, como el driver está fuera del kernel, serían necesarias sendas syscalls nuevas para modificar los vectores de interrupción de la IDT. Es de alta prioridad mejorar este driver.
- <<MM30>>Transformar a un caracter es una mala idea, puesto que existen tan solo 256 de ellos. En su lugar, tendría que transformarse a una cuadrupalabra, con tal de utilizar los bits más significativos para los modificadores (shift, alt, control, super, bloqueo mayúsculas…). Requeriría, sin embargo, hacer la conversión a entero en los procesos receptores, y sería necesario extender la stdlib para aportar una interfaz estándar. Las mejoras serían visibles en la shell por la presencia de combinaciones de teclas, y por esto la prioridad es media.
- <<MM31>>Siguiendo la línea de [MM30], en la shell faltan multitud de usos del teclado. Por ejemplo, las flechas para poder mover el cursor entre el texto ya escrito. También resutlaría interesante añadir los atajos de Emacs, los mismos que soporta bash (por ejemplo, Ctrl+A para ir al inicio de la línea, Ctrl+E para ir al final, Ctrl+K para borrar desde el cursor hasta el final de la línea…). La prioridad media se hereda por secuencialidad.
- <<MM32>>En la shell, la ejecución de programas por su ruta, ya sea absoluta o relativa, no está implementada, y su prioridad es baja.
- <<MM33>>En la coreutil
usermod
, no está implementada la retirada de permisos ni la modificación deSUPER
, tan solo la adición. La prioridad es alta puesto que permitiría explotar todo el potencial del sistema de archivos. - <<MM34>>Implementar un allocator propio (en el sentido de escrito por mí, no necesariamente de mi diseño) no sería muy difícil, pero requeriría sobre una semana hacerlo funcional, con lo que nunca se ha encontrado el momento durante todo el proyecto de ponerse con ello. La prioridad es alta más por ego que otra cosa. Consideraría el allocator de Doug Lea \cite{douglea}.
- <<MM35>>Existen cuantiosos bugs de filtración de memoria en el kernel que, se estima, costarán semanas arreglar. De todas las entradas en la lista de este apéndice, este es el más crítico (y su prioridad la más alta), pues causa que el sistema se quede sin memoria tras la ejecución de varias órdenes. Aproximadamente, 128MBs se llenan tras 30 órdenes. Para que este problema no se manifieste, se recomienda asignar 1GB a la máquina virtual. El uso de memoria de 32MB al arranque se reducirá considerablemente tras arreglar estos bugs.
- <<MM36>>Los tests no suponen una cobertura del 100% de los servicios. Si bien es porque muchas pruebas serían redundantes, se podrían extender para cubrir un mayor área. La prioridad es baja.
Precaución: compilar Strife no es trivial, y posiblemente encontrará problemas en el camino y requiera varios intentos. Considere primero en su lugar utilizar la ISO que debería de haber recibido junto a este documento, o la incrustada en este mismo PDF, que se encuentra referenciada en la Sección ISO.
Si el lector quisiera compilar por sí mismo el proyecto, el primer paso es disponer de una toolchain freestanding que sea capaz de generar código independiente del sistema operativo. Puede para esto utilizar la versión previamente compilada en el repositorio /toolchain/ \cite{toolchainrelease}, que incluye binutils
2.38 y GCC
12.1.0, usando GNU/Linux amd64 como host.
Si desea compilar su propia toolchain, puede utilizar los scripts dados en el mismo repositorio. Las dependencias necesarias pueden encontrarse aquí \cite{toolchaindeps}. Tras instalarlas, considere modificar la línea 10 de cgcc.sh
para cambiar el número de threads a usar para compilar: acelerará mucho el proceso. Seguidamente, ejecute primero cbinutils.sh
y luego cgcc.sh
. Vaya a por su bebida de elección mientras espera entre 15 minutos y media hora. Cuando compile, asegúrese de añadir la ruta de los binarios a la variable de entorno $PATH
.
Teniendo la toolchain, debe instalar nasm
, el ensamblador utilizado en el proyecto, y xorriso
, necesario para crear el ISO.
Después, clone la distribución oficial en su tag TFG
, teniendo cuidado de clonar a la vez todos los submódulos. Ejecute:
git clone --recurse git@github.com:the-strife-project/Strife -b TFG
Para compilar Strife, simplemente ejecute make
. El proceso de compilación utilizará automáticamente todos los cores disponibles en su sistema, así que no pase un argumento -j
. Si tiene qemu
instalado, puede ejecutar make run
y, de esta forma, arrancar Strife de manera rápida y simple. Sino, puede utilizar cualquier otro hipervisor, como VirtualBox o VMWare, teniendo en cuenta que deberá utilizar AHCI como controlador de CD.