receta para la mejora progresiva de un lenguaje de programación / Sudo Null IT News

A pesar de, Sonido metálico y se utiliza como herramienta para refactorización y análisis estático, tiene un serio inconveniente: el árbol de sintaxis abstracta no proporciona información sobre el origen de extensiones de macro específicas en CPP, gracias a lo cual se puede construir un nodo AST específico. Además, Clang no degrada las extensiones de macro al nivel LLVM, es decir, al código de representación intermedia (IR). Esto hace que sea extremadamente difícil diseñar esquemas de análisis estáticos que tengan en cuenta macros. Este tema se está investigando activamente actualmente. Pero las cosas están mejorando desde que se creó la herramienta el verano pasado. Macronlo que simplifica el análisis estático de este tipo.

En Macroni, los desarrolladores pueden definir la sintaxis de nuevas construcciones en lenguaje C usando macros y también proporcionar semántica para estas construcciones usando MLIR (representación intermedia multinivel). Macroni utiliza una herramienta VASTO, degradando el código C a MLIR. A su vez, la herramienta PASTA le permite averiguar de dónde provienen ciertas macros en AST y, según esta información, las macros también se pueden degradar a MLIR. Los desarrolladores pueden entonces definirconvertidores MLIR propietarios convertir la salida de Macroni en dialectos MLIR específicos de un dominio para analizar un tema de manera matizada. Este artículo utilizará varios ejemplos para mostrar cómo Macroni le permite extender C con construcciones de lenguaje más seguras y habilitar el análisis de seguridad de C.

Definiciones de tipos fuertes

Definiciones de tipos (

typedef

) en C ayudan a dar nombres más significativos a los tipos de nivel inferior. Sin embargo, los compiladores de C no tratan con estos nombres cuando verifican tipos; solo verifican los nombres reales de los tipos de bajo nivel. La manifestación más simple de este inconveniente es un error de confusión de tipos, donde los tipos semánticos representan diferentes formatos o medidas, como en el siguiente ejemplo:

typedef double fahrenheit;
typedef double celsius;
fahrenheit F;
celsius C;
F = C; // Компилятор не выдаст ни ошибки, ни предупреждения

Ejemplo 1: al verificar tipos en C, solo se tienen en cuenta las definiciones de tipos de los tipos base.

El código anterior pasa la verificación de tipos con éxito, pero entre tipos fahrenheit y celsius Hay una diferencia semántica que no se debe ignorar, ya que las temperaturas se miden de manera diferente en grados Celsius y Fahrenheit. Si trabajas solo con ordinario. typedef en C, no hay manera de imponer esta distinción únicamente mediante tipificación segura.

Pero, al trabajar con Macroni, puede utilizar macros que definirán la sintaxis de fuertes typedef-s y MLIR, implementando así una verificación de tipos especializada para ellos. El siguiente ejemplo muestra cómo puede utilizar macros para determinar fuertes typedef-s que permiten distinguir entre temperaturas en Fahrenheit y Celsius:

#define STRONG_TYPEDEF(name) name
typedef double STRONG_TYPEDEF(fahrenheit);
typedef double STRONG_TYPEDEF(celsius);

Ejemplo 2: Cómo utilizar macros para definir la sintaxis para una inferencia de tipos sólida en C.

Si envuelve el nombre de typedef en una macro STRONG_TYPEDEF()entonces Macroni podrá identificar aquellos typedefcuyos nombres se obtuvieron llamando a la extensión STRONG_TYPEDEF() y convertirlos a tipos de un dialecto MLIR especializado (p. ej. temp), como esto:

%0 = hl.var "F" : !hl.lvalue<!temp.fahrenheit>
%1 = hl.var "C" : !hl.lvalue<!temp.celsius>
%2 = hl.ref %1 : !hl.lvalue<!temp.celsius>
%3 = hl.ref %0 : !hl.lvalue<!temp.fahrenheit>
%4 = hl.implicit_cast %3 LValueToRValue : !hl.lvalue<!temp.fahrenheit> -> !temp.fahrenheit
%5 = hl.assign %4 to %2 : !temp.fahrenheit, !hl.lvalue<!temp.celsius> -> !temp.celsius

Ejemplo 3: con Macroni, puede degradar las definiciones de tipos a tipos MLIR y forzar una escritura segura.

Integrando lo siguiente en el sistema de tipos typedef-s obtenidos usando macros, ahora podemos definir nuestras propias reglas de verificación de tipos para ellas. Por ejemplo, podría especificar una verificación de tipo estricta para las operaciones realizadas con valores de temperatura. Entonces el programa anterior fallará en la verificación de tipos. También puede agregar su propia lógica de conversión de tipos para los valores de temperatura. En este caso, al convertir un valor de temperatura de una escala a otra, se insertarían implícitamente instrucciones para dicha conversión.

El objetivo de usar macros para agregar una sintaxis sólida typedef es que las macros son compatibles con versiones anteriores y portátiles. Si bien los tipos personalizados se pueden identificar usando solo Clang, anotando nuestro typedef-s usando sintaxis de atributos ÑU o Sonido metálicoes imposible garantizar que el método annotate() Estará disponible cuando trabajemos con cualquier plataforma y compilador que necesitemos. Al mismo tiempo, puede esperar con confianza que haya un preprocesador C allí.

Quizás ya estés pensando: C ya tiene su propia versión de strong typedefy esto struct. Por lo tanto, podríamos implementar una verificación de tipos más estricta convirtiendo nuestros tipos typedef en estructuras (struct) (p.ej., struct fahrenheit { double value; }). Es cierto que debido a esto, tanto la API como el tipo de ABI cambiarían, lo que arruinaría el código del cliente existente y también rompería la compatibilidad con versiones anteriores. Si nos comprometemos a transformar typedef-pecado struct-s, entonces el compilador puede producir un código ensamblador completamente diferente. Por ejemplo, considere la siguiente definición de función:

fahrenheit convert(celsius temp) { return (temp * 9.0 / 5.0) + 32.0; }

Ejemplo 4: Definición de una función que convierte Celsius a Fahrenheit.

Si definimos fuerte typedef-s con aplicación typedef-s obtenido usando macros, luego Clang producirá la siguiente representación intermedia LLVM llamar convert(25). Representación intermedia LLVM para una función convert coincide con una construcción similar de C, toma solo un tipo de argumento double y devuelve un valor como double.

tail call double @convert(double noundef 2.500000e+01)

Ejemplo 5: una representación intermedia LLVM de la función convert(25), donde se implementa una fuerte inferencia de tipos mediante macros.

Compare este código con la representación intermedia que Clang produciría si fuera fuerte. typedef-s se definieron usando estructuras. Ahora, cuando se llama, una función ya no puede tomar un argumento, sino cuatro. Primer argumento ptr indica el lugar donde convert guardará el valor de retorno. Imagínense lo que pasaría si el cliente llamara a esta nueva versión. convert de acuerdo con las convenciones de convocatoria que estaban vigentes para el original.

call void @convert(ptr nonnull sret(%struct.fahrenheit) align 8 %1,
                   i32 undef, i32 inreg 1077477376, i32 inreg 0)

Ejemplo 6: representación intermedia LLVM de convert(25), que utiliza estructuras para una fuerte inferencia de tipos.

Las debilidades son omnipresentes en las bases de código C. typedef-Eso debería ser fuerte. Esto se aplica a infraestructuras críticas como libc y núcleos de Linux. Si desea agregar una verificación de tipo estándar sólida, p. time_t, entonces es de fundamental importancia mantener la compatibilidad a nivel de API y ABI. si envolviste time_t в struct (p.ej struct strict_time_t { time_t t; }) para proporcionar una verificación de tipos sólida, no solo necesitará cambiar todas las API que acceden a los valores time_t-typed, sino también las ABI que operan en estos puntos. Aquellos clientes que ya han utilizado valores básicos. time_ttendrás que cambiar escrupulosamente el código en todos aquellos lugares donde el código utilice time_t, para que su estructura se utilice allí, activando la verificación de tipos mejorada. Por otro lado, si usaras typedef con macros para aplicar alias al original time_t (p.ej typedef time_t STRONG_TYPEDEF(time_t)), luego API y ABI time_t seguirá siendo consistente. En este caso, el código de cliente que utiliza correctamente time_tpuede permanecer sin cambios.

Mejora de Sparse desde el kernel de Linux

En 2003, Linus Torvalds desarrolló su propio preprocesador, analizador de lenguaje C y

compilador

con derecho

Escaso

. Sparse realiza una verificación de tipos que tiene en cuenta las características específicas del kernel de Linux. Sparse depende de macros para operar, en particular

__user

, que se encuentran dispersos por todo el código del kernel. En configuraciones de compilación normales no hacen nada, pero cuando se define una macro

__CHECKER__

su funcionalidad se amplía para utilizar

__attribute__((address_space(...))

).

Necesita limitar las definiciones de macros como esta usando __CHECKER__, ya que la mayoría de los compiladores no le permiten alterar una macro ni implementar una verificación de tipos especializada… al menos ese era el caso hasta hace poco. Macroni le permite manipular macros, verificar y analizar la seguridad de la misma manera que Sparse. Pero mientras Sparse está limitado a C (debido a la implementación de su propio analizador y preprocesador de C), Macroni puede trabajar con cualquier código que pueda ser analizado por Clang (por ejemplo, C, C++ y Objective C).

La primera macro Sparse a la que nos conectaremos es: __user. Actualmente el núcleo conjuntos __user para un atributo reconocido por Sparse:

# define __user     __attribute__((noderef, address_space(__user)))

Ejemplo 7: macro del kernel de Linux __user

Sparse examina este atributo para buscar macros en el espacio del usuario, como en siguiente ejemplo. noderef le dice a Sparse que no se puede desreferenciar estos punteros (p. ej. *uaddr = 1), ya que no se puede confiar en la información sobre su origen.

u32 __user *uaddr;

Ejemplo 8: utilizando la macro __user, anotamos una variable como proveniente del espacio de usuario.

Macroni puede interferir con la macro y el atributo extendido para degradar la declaración a MLIR, así:

%0 = hl.var "uaddr" : !hl.lvalue<!sparse.user<!hl.ptr<!hl.elaborated<!hl.typedef<"u32">>>>>

Ejemplo 9: código kernel después de cambiar a MLIR usando Macroni

El código degradado a MLIR incorpora la anotación en el sistema de tipos envolviendo las declaraciones provenientes del espacio del usuario en el tipo. sparse.user. Ahora podemos agregar nuestra propia lógica de verificación de tipos para variables de espacio de usuario, similar a como previamente creado fuerte typedef-s. Incluso puedes conectarte a una macro específica de Sparse __forcepara deshabilitar la verificación de tipos estricta en ocasiones. Actualmente Esto ya se hace a veces:

raw_copy_to_user(void __user *to, const void *from, unsigned long len)
{
   return __copy_user((__force void *)to, from, len);
}

Ejemplo 10: uso de la macro __force para copiar un puntero al espacio de usuario

Además, al utilizar Macroni es conveniente identificar en el kernel secciones críticas de lectura de RCU y asegúrese de que ciertas operaciones RCU (lectura-copia-actualización) ocurran solo en estas secciones. Considere, por ejemplo, próxima llamada A rcu_dereference():

rcu_read_lock();
rcu_dereference(sbi->s_group_desc)(i) = bh;
rcu_read_unlock();

Ejemplo 11: Llamar a rcu_derefernce() en el lado de lectura de la sección crítica de RCU en el kernel de Linux

El código anterior llama rcu_derefernce() en la sección crítica, es decir, en el área de código que comienza con la llamada rcu_read_lock() y termina con una llamada rcu_read_unlock(). debería ser llamado rcu_dereference() solo en secciones críticas ubicadas en el lado de lectura, pero esta restricción no se puede forzar.

Al trabajar con Macroni, puedes utilizar llamadas. rcu_read_lock() y rcu_read_unlock() para identificar secciones críticas que forman implícito áreas léxicas del código. En este caso, puede asegurarse de que las llamadas a rcu_dereference() ocurren solo en estas secciones:

kernel.rcu.critical_section {
 %1 = macroni.parameter "p" : ...
 %2 = kernel.rcu_dereference rcu_dereference(%1) : ...
}

Ejemplo 12: Resultado de degradar una sección crítica de RCU a MLIR, los tipos se omiten por brevedad

El código anterior convierte tanto las secciones críticas de RCU como las llamadas a rcu_dereference(). Por tanto, no es difícil comprobar que rcu_dereference() aparece sólo en aquellas áreas donde es necesario.

Desafortunadamente, las secciones críticas de RCU no siempre se asignan exactamente a áreas específicas del código, y rcu_dereference() Tampoco siempre se llama en tales áreas. Considere el siguiente ejemplo:

__bpf_kfunc void bpf_rcu_read_lock(void)
{
       rcu_read_lock();
}

Ejemplo 13: código centralque contiene una sección crítica de RCU no léxica

estructura estática en línea in_device *__in_dev_get_rcu(const struct net_device *dev)
{
devolver rcu_dereference(dev->ip_ptr);
}

Ejemplo 14: código centralllamando a rcu_dereference() fuera de la sección crítica de RCU

Usando la macro __force Se puede permitir que desafíos de este tipo rcu_dereference()tal como se hizo anteriormente para evitar la verificación de tipos de punteros de espacio de usuario.

Áreas inseguras parecidas al óxido

Está claro que Macroni le permite fortalecer la verificación de tipos e incluso habilitar reglas de verificación de tipos específicas de la aplicación. Pero, si marcamos tipos como fuertes, debemos cumplir con el nivel de rigor indicado al realizar la verificación. En una base de código grande, esta estrategia puede requerir un gran conjunto de cambios. Para que la adaptación a un sistema de tipos más estricto ocurra de una manera más controlada, podríamos diseñar para C algo así como el mecanismo de “inseguridad” que opera en

Rust

: En un área insegura, no se aplica una verificación de tipos estricta.

#define unsafe if (0); else


fahrenheit convert(celsius C) {
 fahrenheit F;
 unsafe {
         F = (C * 9.0 / 5.0) + 32.0;
 }
 return F;
}

Ejemplo 15: código C que muestra la sintaxis usando macros para áreas inseguras

Este fragmento muestra cuál es la sintaxis en nuestra API de seguridad: llame a la macro unsafe antes de ingresar a áreas de código potencialmente inseguras. Todos los códigos que no figuran en áreas inseguras estarán sujetos a una estricta verificación de tipo. Al mismo tiempo, la macro unsafe se puede utilizar para designaciones áreas de código de nivel relativamente bajo que deliberadamente pretendemos dejar sin cambios. ¡Esto es progreso!

Pero la macro insegura solo proporciona la sintaxis de nuestra API de seguridad, no la lógica. Para cerrar esto lleno de agujeros abstracción, necesitaremos convertir la declaración if marcada con la macro en una operación en el dialecto de seguridad que teóricamente tenemos:

...
"safety.unsafe"() ({
   ...
}) : () -> ()
...

Ejemplo 16: con Macroni, podemos degradar nuestra API de seguridad al dialecto MLIR e implementar una lógica de verificación de seguridad.

Ahora puede deshabilitar la verificación estricta de tipos para operaciones anidadas en la representación MLIR de una macro. unsafe.

Procesamiento de señales más seguro

Es posible que ya haya notado un patrón que surge al crear construcciones de lenguaje seguro: las macros se utilizan para definir la sintaxis que le permite marcar ciertos tipos, valores o áreas de código como sujetos a un determinado conjunto de invariantes. Luego se define la lógica en el nivel MLIR para garantizar que se cumplan estas invariantes.

Con Macroni puedes asegurarte de que manejadores de señales ejecute solo código que sea seguro en el nivel de señal. Considere, por ejemplo, siguiente controlador de señaldefinido en el kernel de Linux:

static void sig_handler(int signo) {
       do_detach(if_idx, if_name);
       perf_buffer__free(pb);
       exit(0);
}

Ejemplo 17: Controlador de señales definido en el kernel de Linux

sig_handler() llama directamente a otras tres funciones en su propia definición, todas las cuales deben ser seguras en el contexto del procesamiento de señales. Pero no hay ninguna verificación en el código anterior para garantizar que solo estemos llamando a funciones seguras para señales dentro de la definición. sig_handler(). Los compiladores de C simplemente no proporcionan una forma de expresar comprobaciones semánticas que se apliquen a dominios léxicos.

Con Macroni, puede agregar macros que marcan algunas funciones como manejadores de señales y otras como seguras para señales. Luego puede implementar lógica en el nivel MLIR para garantizar que los manejadores de señales solo llamen a funciones seguras para señales, como esta:

#define SIG_HANDLER(name) name
#define SIG_SAFE(name) name


int SIG_SAFE(do_detach)(int, const char*);
void SIG_SAFE(perf_buffer__free)(struct perf_buffer*);
void SIG_SAFE(exit)(int);


static void SIG_HANDLER(sig_handler)(int signo) { ... }

Ejemplo 18: Sintaxis basada en tokens para marcar manejadores de señales y funciones seguras para señales

En el código anterior la función sig_handler() está marcado como manejador de señales y las tres funciones que llama están marcadas como seguras para señales. Cada llamada de macro se distribuye en un único token, específicamente el nombre de la función que queremos etiquetar. Con este enfoque, Macroni se conecta al token extendido y determina por el nombre de la función si maneja señales o es seguro para ellas.

Un enfoque alternativo es definir estas macros para anotaciones mágicas y luego conectarse a ellas a través de Macroni:

#define SIG_HANDLER __attribute__((annotate("macroni.signal_handler")))
#define SIG_SAFE __attribute__((annotate("macroni.signal_safe")))


int SIG_SAFE do_detach(int, const char*);
void SIG_SAFE perf_buffer__free(struct perf_buffer*);
void SIG_SAFE exit(int);


static void SIG_HANDLER sig_handler(int signo) { ... }

Ejemplo 19: Sintaxis de atributo alternativa para marcar manejadores de señales y funciones seguras para señales

Con este enfoque, llamar a una macro se parece más a un especificador de tipo, y algunas personas encontrarán esta opción más agradable. La diferencia total entre la sintaxis basada en tokens y la sintaxis basada en atributos es que usar la segunda opción requiere que el compilador admita el atributo. annotate(). Si esto no es un problema o si se puede utilizar para limitar __CHECKER__-como construcciones, entonces cualquiera de las dos opciones de sintaxis funcionará bien. La lógica del servidor MLIR para comprobar la seguridad de la señal seguirá siendo la misma independientemente de la sintaxis que elijamos.

Conclusión

La herramienta Macroni degrada el código C y las macros a una representación intermedia de niveles múltiples (MLIR), por lo que puede evitar depender del insulso árbol de sintaxis abstracta de Clang para su análisis y, en su lugar, construir su análisis en torno a una representación intermedia específica del dominio. Esta vista intermedia proporciona acceso completo a los tipos, flujo de control y flujo de datos dentro del dialecto MLIR de alto nivel de VAST. Macroni degradará las macros relevantes para el dominio a MLIR e invalidará todas las demás macros por usted. Esto abre todo el poder del análisis estático que tiene en cuenta las macros. Puede configurar sus propias opciones de análisis, transformaciones y optimizaciones. Las macros se utilizan en cada etapa del análisis. Como se muestra en este artículo, incluso es posible combinar macros y MLIR, definiendo así nueva sintaxis y semántica para C. La herramienta Macroni es gratuita y se distribuye libremente, aquí está

repositorio de GitHub

.

Expresiones de gratitud

Gracias a Trail of Bits por la oportunidad de crear la herramienta Macroni. Gracias a mi gerente y mentor Peter Goodman por darme la idea de degradar las macros a MLIR y por sugerir cómo se podría utilizar Macroni. Gracias también a Lukas Korenik por revisar el código de Macroni y por sus consejos sobre cómo podría mejorarse.


Lea también:


Noticias, reseñas de productos y concursos del equipo de Timeweb.Cloud – en nuestro canal de Telegram


Publicaciones Similares

Deja una respuesta

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