Mit dem Kernel von C nach Rust – ein Projektbericht zu Effizienz und Sicherheit

Seite 2: Das sollten Interessenten beim Umstieg beachten

Inhaltsverzeichnis

Jetzt stellt sich die Frage, wie aufwendig die Portierung von C nach Rust war. Manche Teile waren zwar leicht zu portieren, einige Konzepte mussten wir jedoch überdenken. Ein Beispiel aus der Linux-Welt: Linux-Treiber bieten eine standardisierte Schnittstelle an: eine Pseudo-Datei im Verzeichnis /dev, auf die Anwendungen zugreifen, um das Gerät zu öffnen, davon zu lesen und es zu beschreiben. Im Gegenzug muss der Treiber Funktionen zur Verfügung stellen, die die entsprechenden Anfragen umsetzen. Das realisieren Treiber-Programmierer üblicherweise über eine Datenstruktur mit Funktionszeigern, die die Funktionen des Treibers beim Kernel anmelden, wie das folgende Beispiel in C verdeutlicht:

struct file_operations {
       ssize_t (*read) (struct file *, char *, size_t, loff_t *);
       ssize_t (*write) (struct file *, const char *, size_t, loff_t *);
      
       int (*open) (struct inode *, struct file *);
       int (*release) (struct inode *, struct file *);
       …
    };

Zeiger und Funktionszeiger in Rust zu verwenden, ist prinzipiell möglich, allerdings kann der Compiler die Korrektheit nicht überprüfen, sodass Entwicklerinnen und Entwickler solche Teile als unsafe deklarieren müssen. Das sollte so weit wie möglich unterbleiben. In Rust gibt es ein besseres Konzept, das der Traits. Ein Trait bündelt Funktionen und Methoden, die der jeweiligen Datenstruktur zugefügt werden. Das folgende Beispiel stellt vereinfacht Traits für einen Treiber dar.

trait Driver {
	fn open(&mut self, path: &String, flags: OpenOptions) -> Result<Box<dyn Handle>>;
}

trait Handle {
	fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
	fn write(&mut self, buf: &[u8]) -> Result<usize>;
}

Der Treiber implementiert eine Datenstruktur, die den Trait Driver umsetzt, der das Öffnen des Geräts ermöglicht und dann eine Datenstruktur, die Lese- und Schreiboperationen zurückgibt. Diese Datenstruktur wiederum müsste die Schnittstellen des Traits Handle erfüllen. Da der Compiler die genaue Umsetzung bei der Definition der Traits und auch dessen Größe nicht kennt, wird beim Rückgabewert der Funktion open das Trait Handle mit dem Präfix dyn erweitert. So weiß der Compiler, dass die Größe des Rückgabewertes erst zur Laufzeit bekannt ist. Box spezifiziert, dass der Rückgabewert auf dem Heap liegen muss.

Beim Transfer von C nach Rust stellt das keine große Hürde dar. Gegenüber der C-Schnittstelle fällt die fehlende Funktion release für das Schließen des Treibers auf. Bei Rust können Programmiererinnen und Programmierer darauf verzichten, da das Freigeben der Datenstruktur das Schließen des Treibers bedeutet. Rust bietet durch den Trait Drop die Möglichkeit eines Destruktors, der die Aufgabe von release übernimmt.

Es gibt aber auch beliebte C-Konzepte, die in Rust nicht so einfach funktionieren oder die Rust-Features benötigen, die kaum verwendet werden oder noch nicht zum stabilen Entwicklungszweig gehören. Zum Beispiel ist es in C üblich, beim Versenden von Daten einen Header anzulegen, der unter anderem die Länge der Nachricht beschreibt. Die gesamte Nachricht legen Entwicklerinnen und Entwickler im virtuellen Speicher ab, um sie effizienter zu versenden. Das folgende Beispiel zeigt ein einfaches C-Gerüst für eine solche Nachricht in variabler Länge. Es verdeutlicht auch, wo die Gefahren in C liegen: Die Funktionen produce_data und create_checksum müssen darauf vertrauen, dass die Größe des Puffers data mit der übergebenen Länge übereinstimmt. Zudem geht das Beispiel recht lax mit den Datentypen um.

struct header {
	size_t length;
	int checksum;
};

int produce_data(char* data, size_t len);
int create_checksum(char* data, len);

char* create_message(size_t len) {
	struct header* message = (struct header*)
malloc(sizeof(struct header)+len);
	message->len = len;
	produce_data((char*) (message+1), len);
	message->checksum = create_checksum((char*) (message+1),
len);
	return (char*) message;
}

Grundsätzlich ist es in Rust ebenfalls möglich, eine Nachricht mit variabler Länge zu erzeugen. Das nächste Listing erzeugt in Rust die Datenstruktur Message, die eine beliebige Größe hat, da sie einen sogenannten Slice ([u8]) enthält. Slice beschreibt eine kontinuierliche Sequenz von Daten, deren Länge zur Kompilierzeit nicht bekannt sein muss. Soll ein Programm ein solches Element erzeugen, muss es den zugehörigen Speicher explizit anfragen. Dies ist einer der wenigen Fällen, in dem die Speicherverwaltung in Rust direkt aufgerufen wird.

Hierzu erzeugen Programmiererinnen und Programmierer die Datenstruktur Layout, die die Größe und die Ausrichtung (Alignment) der Nachricht beschreibt. alloc fordert den Speicher an und wandelt anschließend in die Datenstruktur Message um. Anschließend beinhaltet die Referenz die Größe der Datenstruktur und somit implizit die Größe des Slice data.

Diese Schritte müssen Entwickler als unsafe kennzeichnen, da Zeiger zum Einsatz kommen. unsafe-Regionen sollten vermieden werden, aber hier wird einmalig eine unsafe-Datenstruktur erzeugt, die anschließend verwendet werden kann, ohne den Zugriff im Folgenden weiter mit unsafe kennzeichnen zu müssen. Funktionen wie produce_data und create_checksum können mit Referenzen arbeiten, die die Größe der referenzierten Daten erst zur Laufzeit kennen.

#[repr(C)]
pub struct Header {
	pub length: usize,
	pub checksum: u32,
} 

#[repr(C)]
pub struct Message {
	pub header: Header,
	pub data: [u8],
}

pub fn create_message(len: usize) -> Box<Message> {
	let layout = Layout::from_size_align(
size_of<Header>+len,
align_of<Header>,
			   );
	let mut message = unsafe {
		let data = alloc(layout);
		let raw = slice_from_raw_parts_mut(data, len) as *mut Message;

		Box::from(raw)
	};

	message.header.len = len;
	produce_data(&mut message.data);
	message.header.checksum = create_checksum(&message.data);

	message
}