Explorando las superpoderes del Rust inseguro

RustRustBeginner
Practicar Ahora

This tutorial is from open-source community. Access the source code

💡 Este tutorial está traducido por IA desde la versión en inglés. Para ver la versión original, puedes hacer clic aquí

Introducción

Bienvenido a Unsafe Rust. Esta práctica es parte del Rust Book. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, exploraremos Rust inseguro, una característica que nos permite saltarnos las garantías de seguridad de memoria aplicadas en tiempo de compilación y nos otorga poderes adicionales, mientras también comprendemos los riesgos y responsabilidades implicados en su uso.

Rust inseguro

Todo el código que hemos discutido hasta ahora ha tenido las garantías de seguridad de memoria de Rust aplicadas en tiempo de compilación. Sin embargo, Rust tiene un segundo lenguaje escondido dentro de él que no aplica estas garantías de seguridad de memoria: se llama Rust inseguro y funciona exactamente como Rust regular, pero nos otorga poderes adicionales.

Rust inseguro existe porque, por naturaleza, el análisis estático es conservador. Cuando el compilador intenta determinar si el código cumple con las garantías, es mejor que rechace algunos programas válidos que que acepte algunos programas no válidos. Aunque el código podría estar bien, si el compilador de Rust no tiene suficiente información para estar seguro, rechazará el código. En estos casos, puedes usar código inseguro para decirle al compilador: "Confía en mí, sé lo que estoy haciendo". Sin embargo, ten en cuenta que usas Rust inseguro bajo tu propio riesgo: si usas código inseguro incorrectamente, pueden surgir problemas debido a la inseguridad de la memoria, como la dereferenciación de un puntero nulo.

Otra razón por la que Rust tiene una alter ego insegura es que el hardware informático subyacente es inherentemente inseguro. Si Rust no te permitiera realizar operaciones inseguras, no podrías realizar ciertas tareas. Rust necesita permitirte realizar programación de sistemas de bajo nivel, como interactuar directamente con el sistema operativo o incluso escribir tu propio sistema operativo. Trabajar con programación de sistemas de bajo nivel es uno de los objetivos del lenguaje. Vamos a explorar lo que podemos hacer con Rust inseguro y cómo hacerlo.

Poderes adicionales de Rust inseguro

Para cambiar a Rust inseguro, utiliza la palabra clave unsafe y luego comienza un nuevo bloque que contiene el código inseguro. Puedes realizar cinco acciones en Rust inseguro que no puedes realizar en Rust seguro, que llamamos poderes adicionales de Rust inseguro. Esos poderes adicionales incluyen la capacidad de:

  1. Dereferenciar un puntero crudo
  2. Llamar a una función o método inseguro
  3. Acceder o modificar una variable estática mutable
  4. Implementar un trato inseguro
  5. Acceder a los campos de las union

Es importante entender que unsafe no desactiva el verificador de préstamos ni deshabilita ninguna de las otras comprobaciones de seguridad de Rust: si utilizas una referencia en código inseguro, todavía se comprobará. La palabra clave unsafe solo te da acceso a estas cinco características que luego no se verifican por el compilador para la seguridad de la memoria. Todavía obtendrás cierto grado de seguridad dentro de un bloque inseguro.

Además, unsafe no significa que el código dentro del bloque sea necesariamente peligroso o que definitivamente tendrá problemas de seguridad de memoria: la intención es que, como programador, asegures que el código dentro de un bloque unsafe acceda a la memoria de manera válida.

Las personas son falibles y se producirán errores, pero al requerir que estas cinco operaciones inseguras estén dentro de bloques anotados con unsafe, sabrás que cualquier error relacionado con la seguridad de la memoria debe estar dentro de un bloque unsafe. Mantén los bloques unsafe pequeños; te agradecerás más tarde cuando investigues errores de memoria.

Para aislar el código inseguro lo máximo posible, es mejor encerrar ese código dentro de una abstracción segura y proporcionar una API segura, que discutiremos más adelante en el capítulo cuando examinemos funciones y métodos inseguros. Parte de la biblioteca estándar se implementa como abstracciones seguras sobre código inseguro que ha sido revisado. Envolver el código inseguro en una abstracción segura evita que el uso de unsafe se filtre a todos los lugares donde tú o tus usuarios puedan querer utilizar la funcionalidad implementada con código inseguro, porque utilizar una abstracción segura es segura.

Veamos cada uno de los cinco poderes adicionales de Rust inseguro por separado. También veremos algunas abstracciones que proporcionan una interfaz segura para el código inseguro.

Dereferenciar un puntero crudo

En "Referencias colgantes", mencionamos que el compilador asegura que las referencias siempre son válidas. Rust inseguro tiene dos nuevos tipos llamados punteros crudos que son similares a las referencias. Al igual que con las referencias, los punteros crudos pueden ser inmutables o mutables y se escriben como *const T y *mut T, respectivamente. El asterisco no es el operador de dereferencia; es parte del nombre del tipo. En el contexto de los punteros crudos, inmutable significa que el puntero no se puede asignar directamente después de ser dereferenciado.

A diferencia de las referencias y los punteros inteligentes, los punteros crudos:

  • Se permiten ignorar las reglas de préstamo al tener punteros inmutables y mutables o múltiples punteros mutables a la misma ubicación
  • No está garantizado que apunten a memoria válida
  • Se permite que sean nulos
  • No implementan ninguna limpieza automática

Al optar por no tener que Rust enforce estas garantías, puedes renunciar a la seguridad garantizada a cambio de una mayor rendimiento o la capacidad de interactuar con otro lenguaje o hardware donde las garantías de Rust no se aplican.

La Lista 19-1 muestra cómo crear un puntero crudo inmutable y un puntero crudo mutable a partir de referencias.

let mut num = 5;

let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;

Lista 19-1: Crear punteros crudos a partir de referencias

Observa que no incluimos la palabra clave unsafe en este código. Podemos crear punteros crudos en código seguro; solo no podemos dereferenciar punteros crudos fuera de un bloque unsafe, como verás enseguida.

Hemos creado punteros crudos usando as para convertir una referencia inmutable y una referencia mutable en sus tipos de puntero crudo correspondientes. Debido a que los creamos directamente a partir de referencias garantizadas como válidas, sabemos que estos punteros crudos particulares son válidos, pero no podemos hacer esa suposición sobre cualquier puntero crudo.

Para demostrar esto, a continuación crearemos un puntero crudo cuya validez no podemos estar tan seguros. La Lista 19-2 muestra cómo crear un puntero crudo a una ubicación arbitraria en la memoria. Intentar usar memoria arbitraria es indefinido: puede haber datos en esa dirección o puede que no, el compilador puede optimizar el código de modo que no haya acceso a memoria, o el programa puede terminar con un error de segmentación. Por lo general, no hay buena razón para escribir código así, pero es posible.

let address = 0x012345usize;
let r = address as *const i32;

Lista 19-2: Crear un puntero crudo a una dirección de memoria arbitraria

Recuerda que podemos crear punteros crudos en código seguro, pero no podemos dereferenciar punteros crudos y leer los datos a los que apuntan. En la Lista 19-3, usamos el operador de dereferencia * en un puntero crudo que requiere un bloque unsafe.

let mut num = 5;

let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;

unsafe {
    println!("r1 is: {}", *r1);
    println!("r2 is: {}", *r2);
}

Lista 19-3: Dereferenciar punteros crudos dentro de un bloque unsafe

Crear un puntero no hace daño; solo cuando intentamos acceder al valor al que apunta es cuando es posible que terminemos tratando con un valor no válido.

Tenga en cuenta también que en las Listas 19-1 y 19-3, creamos punteros crudos *const i32 y *mut i32 que ambos apuntan a la misma ubicación de memoria, donde se almacena num. Si en cambio intentáramos crear una referencia inmutable y una referencia mutable a num, el código no se habría compilado porque las reglas de propiedad de Rust no permiten una referencia mutable al mismo tiempo que cualquier referencia inmutable. Con punteros crudos, podemos crear un puntero mutable y un puntero inmutable a la misma ubicación y cambiar los datos a través del puntero mutable, lo que puede crear una carrera de datos. ¡Ten cuidado!

Con todos estos peligros, ¿por qué usaría punteros crudos alguna vez? Un caso de uso principal es cuando se interactúa con código C, como verás en "Llamar a una función o método inseguro". Otro caso es cuando se construyen abstracciones seguras que el verificador de préstamos no entiende. Introduciremos funciones inseguras y luego veremos un ejemplo de una abstracción segura que utiliza código inseguro.

Llamar a una función o método inseguro

El segundo tipo de operación que se puede realizar en un bloque unsafe es llamar a funciones inseguras. Las funciones y métodos inseguros se ven exactamente como las funciones y métodos regulares, pero tienen una palabra clave unsafe adicional antes del resto de la definición. La palabra clave unsafe en este contexto indica que la función tiene requisitos que debemos cumplir cuando llamamos a esta función, porque Rust no puede garantizar que hayamos cumplido estos requisitos. Al llamar a una función insegura dentro de un bloque unsafe, estamos diciendo que hemos leído la documentación de esta función y que asumimos la responsabilidad de cumplir con los contratos de la función.

Aquí hay una función insegura llamada dangerous que no hace nada en su cuerpo:

unsafe fn dangerous() {}

unsafe {
    dangerous();
}

Debemos llamar a la función dangerous dentro de un bloque unsafe separado. Si intentamos llamar a dangerous sin el bloque unsafe, obtendremos un error:

error[E0133]: call to unsafe function is unsafe and requires
unsafe function or block
 --> src/main.rs:4:5
  |
4 |     dangerous();
  |     ^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on
how to avoid undefined behavior

Con el bloque unsafe, estamos afirmando a Rust que hemos leído la documentación de la función, que entendemos cómo usarla correctamente y que hemos verificado que estamos cumpliendo con el contrato de la función.

Los cuerpos de las funciones inseguras son en realidad bloques unsafe, por lo que para realizar otras operaciones inseguras dentro de una función insegura, no es necesario agregar otro bloque unsafe.

Crear una abstracción segura sobre código inseguro

El simple hecho de que una función contenga código inseguro no significa que necesitemos marcar toda la función como insegura. De hecho, envolver código inseguro en una función segura es una abstracción común. Como ejemplo, estudiemos la función split_at_mut de la biblioteca estándar, que requiere algún código inseguro. Exploraremos cómo podríamos implementarla. Este método seguro está definido en rebanadas mutables: toma una rebanada y la divide en dos, dividiendo la rebanada en el índice dado como argumento. La Lista 19-4 muestra cómo usar split_at_mut.

let mut v = vec![1, 2, 3, 4, 5, 6];

let r = &mut v[..];

let (a, b) = r.split_at_mut(3);

assert_eq!(a, &mut [1, 2, 3]);
assert_eq!(b, &mut [4, 5, 6]);

Lista 19-4: Usar la función segura split_at_mut

No podemos implementar esta función solo con Rust seguro. Un intento podría ser similar a la Lista 19-5, que no se compilará. Para simplificar, implementaremos split_at_mut como una función en lugar de un método y solo para rebanadas de valores i32 en lugar de un tipo genérico T.

fn split_at_mut(
    values: &mut [i32],
    mid: usize,
) -> (&mut [i32], &mut [i32]) {
    let len = values.len();

    assert!(mid <= len);

    (&mut values[..mid], &mut values[mid..])
}

Lista 19-5: Un intento de implementación de split_at_mut solo con Rust seguro

Esta función primero obtiene la longitud total de la rebanada. Luego, asegura que el índice dado como parámetro está dentro de la rebanada verificando si es menor o igual a la longitud. La afirmación significa que si pasamos un índice mayor que la longitud para dividir la rebanada, la función se detendrá con un error antes de intentar usar ese índice.

Luego devolvemos dos rebanadas mutables en un par: una desde el principio de la rebanada original hasta el índice mid y otra desde mid hasta el final de la rebanada.

Cuando intentamos compilar el código de la Lista 19-5, obtendremos un error:

error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:9:31
  |
2 |     values: &mut [i32],
  |             - let's call the lifetime of this reference `'1`
...
9 |     (&mut values[..mid], &mut values[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`

El verificador de préstamos de Rust no puede entender que estamos prestando diferentes partes de la rebanada; solo sabe que estamos prestando de la misma rebanada dos veces. Prestar diferentes partes de una rebanada es fundamentalmente correcto porque las dos rebanadas no se superponen, pero Rust no es lo suficientemente inteligente para saberlo. Cuando sabemos que el código está bien, pero Rust no lo sabe, es hora de recurrir al código inseguro.

La Lista 19-6 muestra cómo usar un bloque unsafe, un puntero crudo y algunas llamadas a funciones inseguras para que la implementación de split_at_mut funcione.

use std::slice;

fn split_at_mut(
    values: &mut [i32],
    mid: usize,
) -> (&mut [i32], &mut [i32]) {
  1 let len = values.len();
  2 let ptr = values.as_mut_ptr();

  3 assert!(mid <= len);

  4 unsafe {
        (
          5 slice::from_raw_parts_mut(ptr, mid),
          6 slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

Lista 19-6: Usar código inseguro en la implementación de la función split_at_mut

Recuerde de "El tipo de rebanada" que una rebanada es un puntero a algunos datos y la longitud de la rebanada. Usamos el método len para obtener la longitud de una rebanada [1] y el método as_mut_ptr para acceder al puntero crudo de una rebanada [2]. En este caso, porque tenemos una rebanada mutable de valores i32, as_mut_ptr devuelve un puntero crudo con el tipo *mut i32, que hemos almacenado en la variable ptr.

Mantuvimos la afirmación de que el índice mid está dentro de la rebanada [3]. Luego llegamos al código inseguro [4]: la función slice::from_raw_parts_mut toma un puntero crudo y una longitud, y crea una rebanada. La usamos para crear una rebanada que comienza en ptr y tiene mid elementos de longitud [5]. Luego llamamos al método add en ptr con mid como argumento para obtener un puntero crudo que comienza en mid, y creamos una rebanada usando ese puntero y el número restante de elementos después de mid como la longitud [6].

La función slice::from_raw_parts_mut es insegura porque toma un puntero crudo y debe confiar en que este puntero es válido. El método add en punteros crudos también es inseguro porque debe confiar en que la ubicación de desplazamiento también es un puntero válido. Por lo tanto, tuvimos que poner un bloque unsafe alrededor de nuestras llamadas a slice::from_raw_parts_mut y add para poder llamarlas. Al examinar el código y agregando la afirmación de que mid debe ser menor o igual a len, podemos decir que todos los punteros crudos usados dentro del bloque unsafe serán punteros válidos a datos dentro de la rebanada. Esta es una utilización aceptable y adecuada de unsafe.

Tenga en cuenta que no necesitamos marcar la función resultante split_at_mut como unsafe, y podemos llamar a esta función desde Rust seguro. Hemos creado una abstracción segura para el código inseguro con una implementación de la función que usa código inseguro de manera segura, porque solo crea punteros válidos a partir de los datos a los que tiene acceso esta función.

En contraste, el uso de slice::from_raw_parts_mut en la Lista 19-7 probablemente se detendrá con un error cuando se use la rebanada. Este código toma una ubicación de memoria arbitraria y crea una rebanada de 10.000 elementos de longitud.

use std::slice;

let address = 0x01234usize;
let r = address as *mut i32;

let values: &[i32] = unsafe {
    slice::from_raw_parts_mut(r, 10000)
};

Lista 19-7: Crear una rebanada a partir de una ubicación de memoria arbitraria

No tenemos la propiedad de la memoria en esta ubicación arbitraria, y no hay garantía de que la rebanada que crea este código contenga valores i32 válidos. Intentar usar values como si fuera una rebanada válida da como resultado un comportamiento indefinido.

Usar funciones extern para llamar a código externo

A veces, su código de Rust puede necesitar interactuar con código escrito en otro lenguaje. Para esto, Rust tiene la palabra clave extern que facilita la creación y uso de una Interfaz de Funciones Externas (FFI), que es una forma en que un lenguaje de programación define funciones y permite que otro lenguaje de programación (exterior) llame a esas funciones.

La Lista 19-8 demuestra cómo establecer una integración con la función abs de la biblioteca estándar de C. Las funciones declaradas dentro de bloques extern siempre son inseguras para llamar desde código de Rust. La razón es que otros lenguajes no aplican las reglas y garantías de Rust, y Rust no puede comprobarlas, por lo que la responsabilidad recae en el programador para garantizar la seguridad.

Nombre del archivo: src/main.rs

extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!(
            "Valor absoluto de -3 según C: {}",
            abs(-3)
        );
    }
}

Lista 19-8: Declarar y llamar a una función extern definida en otro lenguaje

Dentro del bloque extern "C", listamos los nombres y firmas de las funciones externas de otro lenguaje que queremos llamar. La parte "C" define qué Interfaz Binaria de Aplicación (ABI) utiliza la función externa: el ABI define cómo llamar a la función a nivel de ensamblador. El ABI "C" es el más común y sigue el ABI del lenguaje de programación C.

Llamar a funciones de Rust desde otros lenguajes

También podemos usar extern para crear una interfaz que permita que otros lenguajes llamen a funciones de Rust. En lugar de crear un bloque extern completo, agregamos la palabra clave extern y especificamos el ABI a utilizar justo antes de la palabra clave fn para la función relevante. También necesitamos agregar una anotación #[no_mangle] para decirle al compilador de Rust que no desordene el nombre de esta función. Desordenar el nombre es cuando un compilador cambia el nombre que le hemos dado a una función a un nombre diferente que contiene más información para que otras partes del proceso de compilación la consuman, pero es menos legible para humanos. Cada compilador de lenguaje de programación desordena los nombres ligeramente de manera diferente, por lo que para que una función de Rust sea nombrada por otros lenguajes, debemos deshabilitar el desordenamiento de nombres del compilador de Rust.

En el siguiente ejemplo, hacemos que la función call_from_c sea accesible desde código de C, después de que se compile a una biblioteca compartida y se enlace desde C:

#[no_mangle]
pub extern "C" fn call_from_c() {
    println!("¡Acabo de llamar a una función de Rust desde C!");
}

Este uso de extern no requiere unsafe.

Acceder o modificar una variable estática mutable

En este libro, todavía no hemos hablado sobre variables globales, que Rust sí soporta pero puede ser problemático con las reglas de propiedad de Rust. Si dos hilos acceden a la misma variable global mutable, puede causar una carrera de datos.

En Rust, las variables globales se llaman variables estáticas. La Lista 19-9 muestra un ejemplo de declaración y uso de una variable estática con una rebanada de cadena como valor.

Nombre del archivo: src/main.rs

static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("value is: {HELLO_WORLD}");
}

Lista 19-9: Definir y usar una variable estática inmutable

Las variables estáticas son similares a las constantes, sobre las que hablamos en "Constantes". Las nombres de las variables estáticas por convención están en SCREAMING_SNAKE_CASE. Las variables estáticas solo pueden almacenar referencias con el período de vida 'static, lo que significa que el compilador de Rust puede determinar el período de vida y no es necesario anotarlo explícitamente. Acceder a una variable estática inmutable es seguro.

Una diferencia sutil entre las constantes y las variables estáticas inmutables es que los valores en una variable estática tienen una dirección fija en la memoria. Usar el valor siempre accederá a los mismos datos. Las constantes, por otro lado, se permiten duplicar sus datos cada vez que se usan. Otra diferencia es que las variables estáticas pueden ser mutables. Acceder y modificar variables estáticas mutables es inseguro. La Lista 19-10 muestra cómo declarar, acceder y modificar una variable estática mutable llamada COUNTER.

Nombre del archivo: src/main.rs

static mut COUNTER: u32 = 0;

fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    add_to_count(3);

    unsafe {
        println!("COUNTER: {COUNTER}");
    }
}

Lista 19-10: Leer o escribir en una variable estática mutable es inseguro.

Al igual que con las variables regulares, especificamos la mutabilidad usando la palabra clave mut. Cualquier código que lea o escriba en COUNTER debe estar dentro de un bloque unsafe. Este código se compila y muestra COUNTER: 3 como esperamos porque es de un solo hilo. Tener múltiples hilos accediendo a COUNTER probablemente resultaría en carreras de datos.

Con datos mutables que son accesibles globalmente, es difícil garantizar que no haya carreras de datos, razón por la cual Rust considera que las variables estáticas mutables son inseguras. Donde sea posible, es preferible usar las técnicas de concurrencia y los punteros inteligentes seguros para hilos que discutimos en el Capítulo 16 para que el compilador verifique que el acceso a los datos desde diferentes hilos se realice de manera segura.

Implementar un trato inseguro

Podemos usar unsafe para implementar un trato inseguro. Un trato es inseguro cuando al menos uno de sus métodos tiene alguna invariante que el compilador no puede verificar. Declaramos que un trato es inseguro agregando la palabra clave unsafe antes de trait y marcando la implementación del trato como unsafe también, como se muestra en la Lista 19-11.

unsafe trait Foo {
    // métodos van aquí
}

unsafe impl Foo for i32 {
    // implementaciones de métodos van aquí
}

Lista 19-11: Definir e implementar un trato inseguro

Al usar unsafe impl, estamos prometiendo que cumpliremos con las invariantes que el compilador no puede verificar.

Como ejemplo, recuerde los tratos marcadores Send y Sync que discutimos en "Concurrencia extensible con los tratos Send y Sync": el compilador implementa estos tratos automáticamente si nuestros tipos están compuestos enteramente de tipos Send y Sync. Si implementamos un tipo que contiene un tipo que no es Send o Sync, como punteros crudos, y queremos marcar ese tipo como Send o Sync, debemos usar unsafe. Rust no puede verificar que nuestro tipo cumpla con las garantías de que se puede enviar con seguridad entre hilos o accederse desde múltiples hilos; por lo tanto, necesitamos hacer esas verificaciones manualmente y señalarlo con unsafe.

Acceder a los campos de una unión

La última acción que solo funciona con unsafe es acceder a los campos de una unión. Una unión es similar a un struct, pero solo se utiliza un campo declarado en una instancia particular a la vez. Las uniones se utilizan principalmente para interactuar con uniones en código C. Acceder a los campos de una unión es inseguro porque Rust no puede garantizar el tipo de datos que se está almacenando actualmente en la instancia de la unión. Puede aprender más sobre uniones en la Referencia de Rust en *https://doc.rust-lang.org/reference/items/unions.html**.*

Cuando usar código inseguro

Usar unsafe para utilizar una de las cinco "superpoderes" que acabamos de discutir no está mal o ni siquiera es reprobado, pero es más difícil hacer que el código unsafe sea correcto porque el compilador no puede ayudar a mantener la seguridad de la memoria. Cuando tienes una razón para usar código unsafe, puedes hacerlo, y tener la anotación unsafe explícita hace más fácil localizar la fuente de problemas cuando ocurren.

Resumen

¡Felicitaciones! Has completado el laboratorio de Rust inseguro. Puedes practicar más laboratorios en LabEx para mejorar tus habilidades.