2 puntos por GN⁺ 2024-03-27 | 1 comentarios | Compartir por WhatsApp
  • El firmware del switch de red de Oxide no encendía después de probar cambios en la secuenciación de energía, y la causa fue un bug donde la verificación de préstamo de memoria por IPC del kernel Hubris entraba en conflicto con el nuevo esquema de disposición de memoria
  • Hubris es un sistema operativo embebido que aísla tareas con un MPU, y cuando una tarea presta memoria a otra mediante IPC, el kernel verifica si esa memoria está realmente dentro de una región accesible
  • El empaquetado de tareas introducido recientemente recuperó hasta 30% de RAM en algunas imágenes de firmware, pero la verificación anterior fallaba porque asumía que la memoria prestada estaba contenida dentro de una sola región del MPU
  • La tarea sequencer murió con un synthetic memory fault al intentar prestar al driver I2C una memoria que incluía la dirección 0x801bffd, y humility tasks dejó como rastro 115 reinicios y el estado mem fault... in syscall
  • La corrección consistió en cambiar el algoritmo de verificación para permitir préstamos que crucen varias regiones contiguas del MPU, y desde detectar la falla hasta corregir el bug del kernel pasaron unas 3 horas

Un switch de red que no enciende

  • Arjen Roodselaar, de Oxide, estaba probando cambios en la secuenciación de energía y en la configuración de relojes del firmware de un switch de red cuando, tras un cambio que parecía menor, se encontró con que el switch ya no encendía
  • Algunas partes del firmware respondían a consultas, pero la parte crítica encargada del secuenciador de energía parecía haberse detenido
  • Un error en la secuenciación de energía puede dañar realmente el hardware, así que primero había que confirmar si el switch estaba muerto o simplemente no respondía

Hubris y la memoria limitada

  • Hubris es un sistema operativo para sistemas profundamente embebidos, como el controlador interno de un teclado, y fue creado para manejar el trabajo necesario para arrancar los procesadores grandes del Oxide Rack
  • El firmware basado en Hubris está compuesto por varias tareas (tasks), que son programas compilados por separado
    • Cada tarea incluye su propio código necesario, como el de la biblioteca estándar
    • Las tareas están aisladas entre sí por el MPU de hardware para evitar que una haga fallar a otra o corrompa memoria
  • En la familia ARM Cortex-M que más usan, ARMv7-M, las regiones de memoria protegida deben tener un tamaño de potencia de dos y estar alineadas a ese tamaño
    • Por ejemplo, si una región de 1024 bytes necesita un byte más, no pasa a 1025 bytes sino a una región de 2048 bytes

Los nuevos límites creados por el empaquetado de tareas

  • Las primeras versiones de Hubris usaban un método simple con una región para la RAM de la tarea y una región para su flash, pero eso dejaba huecos inutilizables entre tareas y desperdiciaba memoria
  • Matt Keeter mejoró el sistema de build para que, cuando fuera posible, colocara tareas combinando varias regiones de potencia de dos
    • El hardware permite como máximo 8 regiones por tarea
    • En algunas imágenes de firmware se recuperó hasta 30% de la RAM
    • Los dispositivos más pequeños, que antes estaban tan ajustados que requerían optimización constante, pasaron a tener algo de margen
  • Con este cambio, podían aparecer límites de regiones MPU difíciles de predecir en medio del flash y la RAM de una tarea

La pista que dejó humility tasks

  • Arjen investigó el switch fallido con Humility, el depurador de Hubris, y vio que el procesador de servicio encargado de la secuenciación de energía seguía vivo y ejecutándose, por lo que un problema de hardware parecía poco probable
  • En la salida de humility tasks, la tarea sequencer mostraba este estado:
mem fault (precise: 0x801bffd) in syscall (was: wait: reply from i2c_driver/gen0)
  • Esa misma tarea se había reiniciado 115 veces, y en Hubris reiniciar una tarea es casi siempre la reacción ante un crash
  • La cadena de estado significaba lo siguiente
    • mem fault: violación de las reglas de manejo de memoria
    • precise: 0x801bffd: se conoce la dirección exacta que causó el problema
    • in syscall: la tarea no estaba ejecutando código normal sino una llamada al sistema
    • was: wait: reply from i2c_driver/gen0: estaba esperando la respuesta a un mensaje enviado al driver I2C
  • gen0 significa que i2c_driver aún no se había caído nunca, mientras que sequencer iba por la generación 115

IPC de Hubris y préstamo de memoria

  • Las tareas de Hubris se comunican mediante mensajes IPC, que funcionan como llamadas a funciones
    • La tarea que envía el mensaje se detiene
    • La tarea receptora toma el control de la CPU
    • Cuando vuelve el resultado, la tarea emisora despierta otra vez
  • El IPC está diseñado para encajar bien con el modelo de ownership de Rust, permitiendo que una tarea preste parte de su memoria a otra junto con el mensaje IPC
  • Las tareas que interactúan con dispositivos I2C prestan una región de su memoria al driver del bus I2C, y el driver lee o escribe directamente sobre esa región
    • Se reduce la necesidad de que el driver del bus tenga su propio pool de buffers
    • Se reduce la cantidad de copias de datos
  • Si esto se implementa mal, puede abrir un agujero de seguridad, así que el kernel de Hubris prohíbe que una tarea preste memoria que en realidad no posee o a la que no puede acceder
    • El servidor recibe un código de error
    • El cliente recibe un fault y siempre termina
    • Eso se trata como una violación de acceso que indica bug, corrupción o posibilidad de exploit

Synthetic fault y la causa real

  • Hubris distingue entre real fault y synthetic fault
    • Un real fault es una violación de reglas de hardware, como dereferenciar un null pointer o escribir en una región de código
    • Un synthetic fault es una violación de reglas de software agregadas por Hubris, como las de IPC o préstamo de memoria
  • El fault de sequencer era un synthetic fault producido durante el préstamo de memoria al driver I2C mediante IPC
  • La dirección problemática, 0x801bffd, era una dirección flash válida, pero se veía extraña porque estaba 3 bytes por debajo de un límite de potencia de dos
  • La salida de humility mem mostraba que dos regiones flash pertenecientes a la tarea sequencer se tocaban en 0x801c000
LOW         HIGH           SIZE ATTR   ID TASK
0x08018000 - 0x0801bfff   16kiB r-x--- 17 sequencer
0x0801c000 - 0x0801dfff    8kiB r-x--- 17 sequencer
  • Como ambas regiones pertenecían a la misma tarea, durante la ejecución normal el MPU de hardware podía permitir el acceso sin problemas, pero la verificación del kernel para el préstamo de memoria por IPC partía de otra suposición

El punto donde una simplificación antigua se volvió bug

  • La verificación anterior del kernel solo comprobaba si todo el slice de memoria a prestar cabía completamente dentro de una única región de la tarea
self.region_table().iter().any(|region| {
    region.covers(slice)
        && region.attributes.contains(desired)
        && !region.attributes.intersects(forbidden)
})
  • Ese código coincidía con el diseño original de la época, donde cada tarea tenía una sola región de RAM y una sola región de flash
  • Con la introducción del empaquetado de tareas, la memoria de una misma tarea podía quedar dividida en varias regiones MPU contiguas, así que esa suposición dejó de ser válida
  • El acceso normal a memoria no se veía afectado porque lo verificaba directamente el MPU de hardware; el problema solo aparecía al intentar prestar esa memoria por IPC

Una falla creada por dos funciones juntas

  • El empaquetado de tareas funciona de forma oportunista
    • Existe un límite máximo de 8 regiones por tarea
    • Las tareas de drivers de hardware ya usan algunas regiones por registros mapeados en memoria
    • El sistema solo intenta una colocación más inteligente cuando quedan slots de región disponibles
  • Como resultado, los límites entre regiones podían aparecer en lugares difíciles de prever para quien escribía la tarea
  • Un pequeño cambio de tamaño en la tarea A podía mover la posición del límite de región MPU de una tarea B no relacionada
  • Bastaba con agregar código de depuración para que cambiaran las decisiones de colocación y los límites de región, haciendo desaparecer el crash
  • Matt desactivó de inmediato el empaquetado de tareas en el sistema de build para que Arjen pudiera generar una imagen de firmware funcional, mientras en paralelo avanzaban el análisis y la corrección del bug del kernel

Cómo se corrigió el kernel

  • La clave de la corrección fue cambiar el algoritmo de verificación de acceso a memoria para permitir que la memoria prestada cruce varias regiones MPU exactamente adyacentes
  • El nuevo algoritmo se diseñó para recorrer la tabla de regiones una sola vez
    • Hubris evita exponer operaciones cuya complejidad temporal pueda ser controlada por una tarea
    • El rendimiento debía depender solo de la tabla de regiones, de tamaño fijo, y no del tamaño de la memoria prestada
    • El tamaño de la tabla de regiones está fijo en 8 entradas
  • Para eso, el sistema de build se modificó para ordenar las regiones de cada tarea en orden ascendente por dirección
regions.sort_by_key(|i| region_table.get_index(*i).unwrap().1.base);
  • El commit de la corrección hace que el kernel aproveche esa propiedad de orden para realizar una verificación de acceso más barata
  • El código que se volvió más complejo se separó del núcleo del kernel Hubris y se movió a un crate más portable, además de agregarse pruebas unitarias para corner cases importantes
  • Con el nuevo código se pudo volver a activar el empaquetado de tareas sin dejar crashes impredecibles para quienes desarrollan tareas

Por qué la falla no se propagó más

  • Todo el proceso empezó con un switch de red que no encendía y terminó con la corrección del bug del kernel en unas 3 horas
  • Gracias al fault isolation, de las 23 tareas aisladas que componían el firmware del switch solo sequencer se caía repetidamente, mientras que muchas otras partes seguían funcionando
    • El sistema de actualización de firmware
    • El stack de red IP para interfaces de administración y control
    • Varios servicios de red, desde la implementación del protocolo echo hasta la interfaz del plano de control del rack
    • I2C, SMBus y PMBus para monitoreo de sensores, ventiladores y otros estados del sistema
    • Los drivers de los 32 transceptores frontales QSFP 100G
  • El IPC de Hubris está diseñado asumiendo que otras tareas pueden fallar, por lo que las operaciones marcadas como idempotentes pueden reintentarse de forma transparente
  • El bug existente en la verificación de acceso a memoria bloqueaba accesos de programas correctos, no permitía accesos inválidos o maliciosos, así que no tuvo impacto de seguridad
  • En el momento en que sequencer y el driver I2C en la práctica iban a compartir memoria, sequencer moría, pero el driver I2C seguía funcionando sin riesgo de corrupción

Infraestructura de depuración y trabajo en equipo

  • Humility es un depurador que evolucionó junto con el kernel Hubris, y Arjen pudo identificar en pocos minutos la ubicación del código que crasheó hasta nivel de número de línea, además de compartir un snapshot independiente del procesador de servicio
  • Hubris escribe un coredump comprimido de la tarea caída en RAM y puede recuperarlo a través de la red
    • Se pueden obtener crash dumps incluso sin almacenamiento persistente escribible
    • La función de crashdump no está en el kernel sino en una tarea separada
  • Esos procesadores no manejan datos de carga de trabajo del cliente, solo tráfico de administración del sistema, y los reportes de crash no se suben automáticamente
  • La parte independiente de la arquitectura del kernel Hubris tiene 1,789 líneas de código y 1,192 líneas de comentarios, y el soporte para ARMv6-M, ARMv7-M y ARMv8-M agrega 1,075 líneas de código y 534 líneas de comentarios
  • Como los conceptos del kernel y el IPC de Hubris son simples, cuando un fault apuntaba al IPC no había tantos lugares distintos que revisar

1 comentarios

 
GN⁺ 2024-03-27
Opiniones en Hacker News
  • Hubris está realmente muy bueno. Leí el código del kernel unos 30 minutos y, lejos de ese código en C que había visto antes lleno de macros ifdef, con preferencia por nombres de variables de dos letras y pocos comentarios, está escrito con mucha claridad
    También es buena lectura antes de dormir; recomiendo darle una mirada: https://github.com/oxidecomputer/hubris/blob/b44e677fb39cde8...

    • Me molesta bastante que una buena parte de la cultura de C parezca resumirse en “me da flojera aprender a escribir a una velocidad decente”
      El espacio en disco del código fuente no ha sido un gran problema desde hace 40 años, pero todavía se escatima en los nombres de variables
    • La IA podría acabar con esa costumbre. Si metes código C viejo y áspero en una IA, de pronto todas las variables pueden quedar ordenadas y nombradas de la forma que le gusta al usuario
      Eso es porque la IA aprendió con precisión las preferencias y manías de cierto coder
  • Es un buen artículo, pero me parece desafortunada la ubicación del comentario de abajo
    El comentario encima de regions.sort_by_key(|i| region_table.get_index(*i).unwrap().1.base);, que dice “hay que ordenar por dirección ascendente, y el kernel usa esta propiedad para hacer baratas las comprobaciones de acceso”, es más una invariante de campo que todos los autores deben respetar y que todos los lectores pueden aprovechar, no tanto un detalle de esta función
    Por eso parece más correcto ponerlo en la cadena de documentación de TaskDesc::regions: https://github.com/oxidecomputer/hubris/commit/b44e677fb39cd...

    • Aun así, está bien que el comentario esté junto al código de ordenamiento. Si no, ese ordenamiento en sí podría verse bastante inesperado
      Probablemente la mejor forma sería crear un método constructor en TaskDesc que ordene las regiones y fuerce la invariante. Como se ve que el código se va volviendo más complejo con el tiempo, ahora parece valer la pena dedicar algo de tiempo a encapsular la complejidad dentro de métodos
  • Es una de las mejores ofertas de empleo que he visto hasta ahora. Me gusta cómo pasa naturalmente a hablar de cultura y al final agrega un “por cierto, estamos contratando”
    Es un análisis post mortem realmente excelente, y hasta yo, que soy desarrollador de capa de aplicación, pude seguirlo. Justo estoy leyendo Rust in Action, así que también estaba más preparado para este tipo de contenido
    Siempre es un gusto ver a alguien que comenta mucho el código. La programación literaria funciona

    • Lamentablemente, eso solo aplica en Estados Unidos
  • Parece que la entrega anterior se puede ver aquí

    1. https://hachyderm.io/@mjk/112157472314396711
    2. https://www.mattkeeter.com/blog/2024-03-25-packing/
  • Me llamó la atención la parte de la “integración estrecha y no jerárquica del equipo”. No es una función de Hubris en sí, pero es impresionante la explicación de que es difícil separar Hubris del equipo que lo creó, y de que en el equipo de ingeniería de Oxide prácticamente no hay silos internos
    Me gustaría escuchar más sobre por qué crearon una cultura que fomenta la apertura, la curiosidad y la comunicación, y desalienta la actitud defensiva, la construcción de imperios y el gatekeeping, y cómo la implementaron en concreto. También me da curiosidad si no hay desventajas al cultivar este tipo de cultura dentro de una organización
    Hay lugares que eligen sistemas jerárquicos más estrictos, y el organigrama puede tener que decidirse estratégicamente, así que no termino de captar bien los trade-offs

    • Los valores declarados en sí son difíciles de evaluar, pero en general la desventaja de las organizaciones sin una estructura fuertemente definida es que de todos modos surge alguna forma de estructura de poder
      Si esa estructura no es explícita, es menos pública, no fue elegida de manera deliberada y es más difícil de entender, especialmente para quienes no son muy hábiles en las interacciones sociales. Por eso, debido a su carácter de sombra, puede permitir conductas más patológicas y, aun si no llega a ponerse muy mal, puede hacer que la coordinación sea mucho más difícil
      Viví esto en varias empresas. Una gran consultora sí tenía una estructura formal de poder, pero en la práctica no se respetaba mucho, y la forma de entrar a proyectos se parecía más a hacerse amigo de las personas de ventas y gestión que a usar canales formales. Si podías construir bien la red social necesaria, estaba bien; si no, no funcionaba bien
      Un ejemplo parecido es “The Tyranny of Structurelessness”. Es una charla de una feminista que vio que lo mismo ocurría dentro de organizaciones que rechazaban las jerarquías por considerarlas patriarcales, y también hay discusiones similares sobre Valve, donde la estructura interna no es clara. Los proyectos open source pueden sufrir el mismo problema, y creo que algunos conflictos en Rust también surgieron de problemas similares
      Dicho eso, una estructura de poder explícita no tiene por qué ser necesariamente jerárquica. Las organizaciones empresariales tradicionales son jerárquicas, pero la estructura de Oxide podría ser explícita y aun así no jerárquica. Este enfoque suele funcionar mejor cuanto menor es la escala, y la consultora que mencioné era el caso más grande que conozco de una empresa operada de una forma cercana al estilo libre, aunque aun así tenía cierto andamiaje de apoyo
      Esto no es una dicotomía, sino un espectro. Incluso la estructura de poder más rígida sobre el papel tiene debajo una estructura implícita más compleja, y esa es la naturaleza de los grupos humanos
      No creo que una estructura explícita sea siempre mejor que una implícita. Solo estoy señalando las desventajas que se observan en organizaciones menos explícitas, y las estructuras de poder más explícitas también tienen sus propios problemas. En relación con eso, también están “seeing like a state” y los problemas de legibilidad
  • Es un excelente artículo que muestra en profundidad el proceso de depurar un problema complejo. El hecho de que el resto del sistema se haya mantenido estable demuestra bien la calidad de ingeniería del equipo de Oxide
    A nivel personal también me resultó bastante inspirador, y pienso aplicar técnicas similares en mi trabajo diario

  • Si tratas ese hardware como una TLB rellenada por software, también podrías admitir más de 8 regiones

    • Probablemente ellos querían (a) rendimiento soft real-time y (b) no introducir un componente central que pudiera obstaculizar la depurabilidad o la confiabilidad
      Yo no lo haría salvo que fuera la última opción. La paginación virtual es sucia, y no quieres dejar dudas
    • Sé que TLB significa translation lookaside buffer, pero me da curiosidad qué quiere decir “soft fill” aquí
  • Lo que hace Oxide es realmente sorprendente

    • Después de Tailscale, ahora Oxide se convirtió en una de esas cosas queridas para proyectos que el 99% de la gente no necesita
  • Me gusta todo lo que hace la gente de Oxide, y esto es una de esas cosas

  • ¿Le pusieron Hubris al sistema operativo? Ah, eso es… no sé ni qué decir

    • Te alegrará saber que el depurador se llama “humility”: https://github.com/oxidecomputer/humility
    • Para ser precisos, fue Brian Cantrill quien llamó hubris al sistema operativo
      ¿Qué persona en su sano juicio usaría un sistema operativo nuevo hoy en día? La respuesta es: alguien que intenta resolver el problema que todos los sistemas operativos ignoran, es decir, el de los controladores de la placa base y de las tarjetas de expansión que el sistema operativo no controla ni puede controlar
    • Parece encajar bastante bien con la marca