Rust все частіше розглядається як обіцяльна альтернатива C для підтримки та розробки ядра Linux, головним чином завдяки своїм можливостям безпеки та сучасним інструментам. Хоча C є традиційною мовою для розробки ядра, існує кілька вагомих причин, чому Rust може бути кращим вибором для певних частин ядра Linux.
Я люблю C, оскільки це "батько" всіх мов, він ефективний, дуже гнучкий, занадто гнучкий... Підтримка безпечних і надійних функцій у C вимагає великої уваги до можливих помилок.
Rust піклується про всі ці можливі помилки під час компіляції. Це дозволяє більше зосереджуватися на функціях, а не на можливих помилках.
У цій статті я розповім, чому Rust безпечніший за C для критичних додатків, таких як модулі ядра Linux.
Безпека пам'яті
Однією з найбільших переваг Rust над C є його безпека пам'яті. У C розробники несуть відповідальність за управління виділенням та звільненням пам'яті, що може призвести до таких проблем, як:
- Переповнення буфера
- Підвішені вказівники
- Помилки "використання після звільнення"
Ці помилки є поширеними джерелами уразливостей у ядрі. У Rust, з іншого боку, використовується перевірник позичок (borrow checker) для забезпечення безпеки пам'яті без необхідності в сміттєзбирачі. Це запобігає таким помилкам на етапі компіляції.
У C проста помилка, така як доступ до звільненої пам'яті, може призвести до невизначеної поведінки.
int *ptr = malloc(sizeof(int));
free(ptr);
*ptr = 5; // Доступ до звільненої пам'яті
У Rust перевірник позичок гарантує, що посилання на пам'ять будуть дійсними лише так довго, як вони потрібні.
let mut ptr = Box::new(5);
let r = &mut ptr; // Позичання ptr незмінно
drop(ptr); // ptr тут звільняється
// r не може бути використаний після звільнення ptr
Якщо ви спробуєте використовувати r після того, як ptr буде звільнений, Rust запобіжить цьому під час компіляції, забезпечуючи відсутність доступу до звільненої пам'яті.
Безпека при паралельному виконанні
Паралельне виконання — ще одна сфера, де Rust показує свої переваги. У C проблеми, такі як умови гонки (race conditions) та мертві блокування (deadlocks), є звичними при доступі кількох потоків до спільних даних. C не гарантує безпеку при паралельному виконанні, тому розробникам необхідно ретельно проектувати потокобезпечний код, що часто призводить до тонких помилок.
Система власності (ownership system) в Rust гарантує, що дані або змінюються і належать одному потоку, або є незмінними і спільно використовуються між потоками, забезпечуючи гарантії безпеки паралельного виконання без необхідності в блокуваннях у багатьох випадках. Це робить програмування з паралельним виконанням більш безпечним.
У C простий приклад умови гонки може виникнути, коли два потоки змінюють одну й ту саму змінну.
pthread_t thread1, thread2;
int counter = 0;
void* increment(void* arg) {
counter++;
return NULL;
}
У Rust компілятор гарантує, що змінні дані не будуть спільно використовуватися між потоками, якщо це не дозволено явно.
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
Типи Arc та Mutex в Rust забезпечують безпечне спільне використання даних між потоками, і компілятор гарантує, що не буде умов гонки чи порушення даних.
Жодного розіменування нульових вказівників
C дозволяє використання нульових вказівників, що може легко призвести до помилок сегментації (segmentation faults) при їх розіменуванні.
Rust усуває цей ризик, використовуючи Option типи замість сирих вказівників, що забезпечує безпечне оброблення нульових посилань.
У C розіменування нульового вказівника призводить до аварійних зупинок програми.
int* ptr = NULL;
*ptr = 5; // Розіменування нульового вказівника
У Rust тип Option гарантує, що ви явно обробляєте відсутність значення.
let x: Option = None;
if let Some(val) = x {
println!("{}", val); // Безпечно розпаковуємо значення
} else {
println!("Немає значення!");
}
Типова система Rust примушує програміста явно обробляти випадок None, запобігаючи розіменуванню нульового вказівника.
Безпечне та ефективне низькорівневе програмування
Хоча C дозволяє безпосередньо маніпулювати апаратними ресурсами, Rust пропонує схожий рівень низькорівневого контролю над системою без шкоди для безпеки. Rust надає інструменти, як-от unsafe блоки, де програміст може вручну обробляти небезпечний код, коли це необхідно, але забезпечує, щоб лише чітко визначені частини коду позначалися як небезпечні. Це дозволяє здійснювати програмування на рівні ядра з перевірками безпеки.
У C розробники можуть безпосередньо записувати в адреси пам'яті, що ставить під загрозу безпеку операцій.
int *ptr = (int*) 0x1000; // Прямий доступ до пам'яті
*ptr = 42;
У Rust прямий доступ до пам'яті можна здійснювати за допомогою ключового слова unsafe, але небезпечний код чітко позначений.
let ptr: *mut i32 = 0x1000 as *mut i32; // Небезпечний вказівник
unsafe {
*ptr = 42; // Небезпечна операція
}
Підхід Rust робить чітким, де відбувається обхід безпеки, що спрощує перегляд та аудиту коду на наявність потенційних помилок.
Покращене оброблення помилок
C покладається на коди помилок та ручні перевірки, що може призводити до неповного або помилкового оброблення помилок. Rust використовує більш надійну модель оброблення помилок через типи Result та Option, що забезпечує явне оброблення помилок.
У C обробка помилок часто здійснюється через коди повернення.
int foo() {
if (something_wrong) {
return -1; // Код помилки
}
return 0; // Успіх
}
У Rust обробка помилок здійснюється через тип Result, який примушує розробників обробляти всі можливі випадки помилок.
fn foo() -> Result {
if something_wrong {
Err("Error".to_string())
} else {
Ok(42)
}
}
Патерн матчинг в Rust гарантує, що обробка помилок є частиною потоку коду, що знижує ймовірність пропуску помилок.
Сучасні інструменти та екосистема
Rust пропонує сучасну екосистему з відмінними інструментами, яких не вистачає у C при розробці ядра.
- Cargo для управління пакетами та побудови проєктів.
- Clippy для лінтингу коду.
- Rustfmt для автоматичного форматування коду.
Ці інструменти допомагають покращити продуктивність, якість коду та його підтримуваність, що робить Rust більш зручним для розробників варіантом порівняно з C, особливо для довгострокових проєктів, як, наприклад, підтримка ядра.
Давайте розглянемо реальний приклад із кодом модуля ядра Linux
У C цей модуль приймає рядковий ввід від користувача і зберігає його у буфері фіксованого розміру.
Типовою проблемою тут є те, що неправиль перевірка меж може призвести до переповнення буфера.
#include
#include
#include
#include // для copy_from_user
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Приклад автор");
MODULE_DESCRIPTION("Простий модуль Linux, що демонструє переповнення буфера в C");
#define BUF_SIZE 16 // Фіксований розмір буфера
static char buffer[BUF_SIZE];
static ssize_t example_write(const char __user *user_buffer, size_t len)
{
// Спроба копіювати дані з користувацького простору в буфер ядра
if (len > BUF_SIZE) {
pr_err("Ризик переповнення буфера: введені дані занадто великі!\n");
return -EINVAL; // Повертаємо помилку
}
if (copy_from_user(buffer, user_buffer, len)) {
pr_err("Не вдалося скопіювати дані з користувацького простору.\n");
return -EFAULT; // Повертаємо помилку
}
pr_info("Отримано: %s\n", buffer);
return len;
}
static int __init hello_init(void)
{
pr_info("Модуль C завантажено.\n");
return 0;
}
static void __exit hello_exit(void)
{
pr_info("Модуль C вивантажено.\n");
}
module_init(hello_init);
module_exit(hello_exit);
Проблеми в коді C.
- Розробник має вручну перевіряти, чи len перевищує розмір буфера, що створює ризик пропуску.
- Якщо розробник забуде перевірити межі, може статися переповнення буфера, що призведе до непередбачуваної поведінки або вразливостей безпеки.
- Копіювання за допомогою copyfromuser безпосередньо в сирий масив робить код схильним до помилок та небезпечним.
Ось той самий модуль, реалізований у Rust, з використанням безпечного та сучасного підходу для запобігання таких помилок.
use kernel::prelude::*;
use kernel::io_buffer::IoBufferReader;
module! {
type: RustBufferModule,
name: b"rust_buffer",
author: b"Приклад автор",
description: b"Простий модуль Linux, що демонструє безпечне оброблення буфера в Rust",
license: b"GPL",
}
struct RustBufferModule;
const BUF_SIZE: usize = 16; // Фіксований розмір буфера
impl KernelModule for RustBufferModule {
fn init() -> Result {
pr_info!("Модуль Rust завантажено.\n");
Ok(RustBufferModule)
}
}
impl Drop for RustBufferModule {
fn drop(&mut self) {
pr_info!("Модуль Rust вивантажено.\n");
}
}
impl RustBufferModule {
pub fn write_input(reader: &mut IoBufferReader<'_>) -> Result<()> {
// Створюємо буфер з фіксованим розміром
let mut buffer = [0u8; BUF_SIZE];
// Безпечно зчитуємо ввід у буфер
let len = reader.read_slice(&mut buffer)?;
// Rust автоматично запобігає зчитуванню за межі буфера
pr_info!("Отримано: {:?}\n", &buffer[..len]);
Ok(())
}
}
Наш приклад з використанням модуля Rust з ввідними та вихідними межами.
Ввід в межах:
Ввід: "Hello"
Вихід: Отримано: [72, 101, 108, 108, 111] (ASCII значення "Hello").
Ввід занадто великий:
Ввід: "Цей рядок занадто довгий!"
Вихід: Помилка: Виявлено переповнення буфера (Безпечно оброблено без паніки ядра).
Чому версія Rust є безпечнішою у наших попередніх прикладах?
Автоматична перевірка меж
У C програміст має вручну перевіряти розмір вводу (len > BUF_SIZE), що є схильним до помилок.
У Rust слайс (як-от &mut buffer) гарантує, що зчитування або запис не можуть перевищити розмір буфера.
Якщо ввід користувача занадто великий, він поверне помилку безпечно, замість того щоб відбутися переповнення.
Безпека пам'яті
У C, copyfromuser записує безпосередньо в сирий буфер, і будь-яка помилка або недогляд (наприклад, не перевірений запис) може призвести до переповнення буфера.
У Rust, метод IoBufferReader::read_slice() забезпечує безпечне зчитування в буфер без перевищення його ємності.
Відсутність розіменування нульових вказівників
У C, буфер є сирим масивом, і розіменування нульового вказівника або неініціалізованої пам'яті може призвести до непередбачуваної поведінки.
У Rust, буфер завжди належним чином ініціалізований (наприклад, [0u8; BUF_SIZE]), а перевірка позичок (borrow checker) забезпечує безпечний доступ.
Обробка помилок
Тип Result у Rust змушує розробника явно обробляти помилки. Наприклад, reader.read_slice(&mut buffer)? поверне помилку, якщо операція не вдалася, забезпечуючи належну обробку.
У C, ігнорування значення, яке повертає copyfromuser, є типовою помилкою, яка призводить до непередбачуваної поведінки.
Висновок
Хоча C залишається критично важливою частиною ядра Linux, Rust має кілька значних переваг для певних частин розробки ядра.
Безпека пам'яті: Rust усуває проблеми, такі як розіменування нульових вказівників та переповнення буфера.
Безпека паралельності: Модель володіння в Rust гарантує безпеку потоків без виникнення гонок.
Абстракції Rust (модель володіння, перевірка позичок (borrow checker) та система типів) дозволяють розробникам писати безпечніший і надійніший код без шкоди для продуктивності.
Переповнення буфера неможливе, оскільки компілятор забезпечує перевірку меж.
Rust забезпечує явну обробку помилок, що робить код більш надійним та легшим для підтримки.
Ці переваги є критичними в ядрі Linux та будь-яких інших основних застосунках, які потребують надійності та безпеки. Маніпуляція низькорівневою пам'яттю та паралельність є поширеними, і помилки можуть мати серйозні наслідки.
Сучасні можливості Rust, поєднані з можливістю переходу до небезпечного коду (unsafe) за потреби, роблять його чудовим кандидатом для майбутньої розробки ядра.
Перекладено з: The End of Memory Bugs? Why Rust Is Taking Over C in the Linux Kernel