37. Системное программирование

Содержание: Работа с памятью в no_std (аллокаторы, raw pointers) Системные вызовы (nix, libc) Пример для встраиваемых систем (минимальный драйвер) Rust: embedded-hal vs кастомный доступ к железу Написание ядра ОС Упражнение: Написание утилиты для чтения системных данных Заключение

Добро пожаловать в главу 37 нашего курса по Rust, посвященную системному программированию. Здесь мы углубимся в низкоуровневые аспекты разработки на Rust, исследуя такие темы, как работа с памятью, системные вызовы, встраиваемые системы и даже создание ядра операционной системы. Эта глава предназначена для тех, кто хочет выйти за рамки высокоуровневого программирования и освоить тонкости управления ресурсами на уровне системы. Мы начнем с первого раздела, посвященного работе с памятью в среде no_std, и постепенно перейдем к более сложным темам.


Раздел 1: Работа с памятью в no_std (аллокаторы, raw pointers)

Системное программирование часто требует полного контроля над ресурсами, включая память. В Rust это особенно актуально в средах, где стандартная библиотека std недоступна — например, при разработке операционных систем, встраиваемых устройств или других низкоуровневых приложений. В таких случаях используется режим no_std, который исключает зависимость от std и предоставляет разработчику возможность управлять памятью вручную. В этом разделе мы подробно разберем, что такое no_std, как работают аллокаторы и сырые указатели, а также рассмотрим практические примеры, советы и потенциальные ловушки.

Что такое no_std?

По умолчанию Rust предоставляет стандартную библиотеку std, которая включает удобные инструменты: коллекции (например, Vec или HashMap), ввод-вывод, потоки и многое другое. Однако std опирается на функции операционной системы, такие как выделение памяти через системный аллокатор или взаимодействие с файловой системой. В средах, где операционной системы нет (например, на микроконтроллерах) или где требуется минимальная зависимость от внешних компонентов, std становится неприменимой.

Режим no_std отключает библиотеку std, оставляя доступ только к библиотеке core, которая содержит базовые возможности языка: примитивные типы, итераторы, трейты и макросы. Если вам нужно динамическое выделение памяти, можно подключить библиотеку alloc, но она требует, чтобы вы сами реализовали механизм выделения памяти — глобальный аллокатор. Давайте разберем это шаг за шагом.

Чтобы включить no_std в вашем проекте, добавьте следующую строку в начало файла:

#![no_std]

Обратите внимание на #![...] с восклицательным знаком — это внутренняя аннотация, которая применяется к содержащему элементу (в данном случае, всему файлу). Если вы хотите использовать alloc, добавьте:

extern crate alloc;

Теперь вы в полной мере отвечаете за управление памятью. Это открывает двери к гибкости, но требует глубокого понимания.

Аллокаторы в no_std

В std память выделяется автоматически через системный аллокатор (например, malloc на POSIX-системах). В no_std такого удобства нет — вам нужно либо использовать готовую библиотеку (например, buddy-alloc или linked-list-allocator), либо написать собственный аллокатор. Аллокатор в Rust — это тип, реализующий трейт GlobalAlloc из модуля core::alloc. Этот трейт определяет два основных метода:

Необязательные методы, такие как alloc_zeroed или realloc, позволяют оптимизировать выделение памяти с обнулением или изменение размера, но для начала достаточно базовых.

Пример 1: Простой линейный аллокатор

Рассмотрим простейший аллокатор, который выделяет память последовательно из фиксированного участка (heap). Это неэффективно для реальных систем, но идеально для обучения.

#![no_std]

use core::alloc::{GlobalAlloc, Layout};
use core::ptr;

struct SimpleAllocator {
    heap_start: usize,  // Начало кучи
    heap_end: usize,    // Конец кучи
    next: usize,        // Следующая свободная позиция
    allocations: usize, // Счетчик выделений
}

impl SimpleAllocator {
    const fn new(heap_start: usize, heap_size: usize) -> Self {
        SimpleAllocator {
            heap_start,
            heap_end: heap_start + heap_size,
            next: heap_start,
            allocations: 0,
        }
    }
}

unsafe impl GlobalAlloc for SimpleAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        // Выравниваем адрес начала выделения
        let alloc_start = align_up(self.next, layout.align());
        let alloc_end = alloc_start + layout.size();

        if alloc_end > self.heap_end {
            ptr::null_mut() // Нет места
        } else {
            self.next = alloc_end;
            self.allocations += 1;
            alloc_start as *mut u8
        }
    }

    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
        // Этот аллокатор не освобождает память
    }
}

// Функция выравнивания адреса
fn align_up(addr: usize, align: usize) -> usize {
    (addr + align - 1) & !(align - 1)
}

// Глобальный аллокатор
#[global_allocator]
static ALLOCATOR: SimpleAllocator = SimpleAllocator::new(0x100000, 0x100000);

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

Этот код:

  1. Определяет структуру SimpleAllocator с полями для отслеживания кучи.
  2. Реализует alloc, выравнивая адрес и проверяя, хватает ли места.
  3. Игнорирует dealloc (в реальных системах это недопустимо).
  4. Использует атрибут #[global_allocator] для регистрации аллокатора.
  5. Добавляет panic_handler, обязательный в no_std.

Такой аллокатор подойдет для простейших встраиваемых систем, где память выделяется один раз и не освобождается.

Пример 2: Аллокатор с освобождением

Теперь добавим базовую поддержку освобождения памяти с использованием связного списка свободных блоков.

#![no_std]

use core::alloc::{GlobalAlloc, Layout};
use core::ptr;

struct Block {
    size: usize,
    next: Option<&'static mut Block>,
}

struct FreeListAllocator {
    heap_start: usize,
    heap_end: usize,
    free_list: Option<&'static mut Block>,
}

impl FreeListAllocator {
    const fn new(heap_start: usize, heap_size: usize) -> Self {
        FreeListAllocator {
            heap_start,
            heap_end: heap_start + heap_size,
            free_list: None,
        }
    }

    fn init(&mut self) {
        let block = unsafe { &mut *(self.heap_start as *mut Block) };
        block.size = self.heap_end - self.heap_start;
        block.next = None;
        self.free_list = Some(block);
    }
}

unsafe impl GlobalAlloc for FreeListAllocator {
    unsafe fn alloc(&mut self, layout: Layout) -> *mut u8 {
        let size = layout.size();
        let align = layout.align();

        let mut current = self.free_list;
        let mut prev = None;

        while let Some(ref mut block) = current {
            let start = align_up(block as *mut _ as usize, align);
            let total_size = size + (start - block as *mut _ as usize);

            if block.size >= total_size {
                if block.size > total_size + core::mem::size_of::() {
                    // Разделяем блок
                    let next_block = (start + size) as *mut Block;
                    next_block.write(Block {
                        size: block.size - total_size,
                        next: block.next.take(),
                    });
                    block.size = total_size;
                    block.next = Some(&mut *next_block);
                }
                if let Some(ref mut p) = prev {
                    p.next = block.next.take();
                } else {
                    self.free_list = block.next.take();
                }
                return start as *mut u8;
            }
            prev = current;
            current = block.next;
        }
        ptr::null_mut()
    }

    unsafe fn dealloc(&mut self, ptr: *mut u8, layout: Layout) {
        let block_ptr = ptr as *mut Block;
        block_ptr.write(Block {
            size: layout.size(),
            next: self.free_list.take(),
        });
        self.free_list = Some(&mut *block_ptr);
    }
}

#[global_allocator]
static mut ALLOCATOR: FreeListAllocator = FreeListAllocator::new(0x100000, 0x100000);

#[no_mangle]
pub extern "C" fn main() {
    unsafe {
        ALLOCATOR.init();
    }
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

Этот аллокатор поддерживает освобождение памяти, но он все еще прост: нет защиты от фрагментации или конкуренции в многопоточной среде.

Сырые указатели (raw pointers)

Сырые указатели (*const T и *mut T) — это инструмент для работы с памятью напрямую, без гарантий безопасности, которые дают ссылки (&T, &mut T). Они необходимы в системном программировании для задач вроде написания аллокаторов, взаимодействия с оборудованием или работы с внешними библиотеками.

Отличия от ссылок

Пример 1: Копирование данных

#![no_std]

unsafe fn copy_data(src: *const u8, dst: *mut u8, len: usize) {
    for i in 0..len {
        *dst.add(i) = *src.add(i);
    }
}

#[no_mangle]
pub extern "C" fn main() {
    let src = [1, 2, 3, 4, 5];
    let mut dst = [0; 5];
    unsafe {
        copy_data(src.as_ptr(), dst.as_mut_ptr(), 5);
    }
    // Проверяем: dst == [1, 2, 3, 4, 5]
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

Пример 2: Работа с оборудованием

Предположим, мы пишем драйвер, который записывает значение в регистр устройства по фиксированному адресу.

#![no_std]

const REG_ADDR: usize = 0x4000_1000;

unsafe fn write_register(value: u32) {
    let reg = REG_ADDR as *mut u32;
    reg.write_volatile(value);
}

#[no_mangle]
pub extern "C" fn main() {
    unsafe {
        write_register(0xDEADBEEF);
    }
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

Метод write_volatile используется для работы с аппаратными регистрами, чтобы компилятор не оптимизировал запись.

Практические советы

Подводные камни

Лучшие практики

Этот раздел заложил фундамент для понимания работы с памятью в no_std. Аллокаторы и сырые указатели — ключевые инструменты системного программирования, требующие внимания к деталям и дисциплины. В следующих разделах мы применим эти знания к системным вызовам, встраиваемым системам и даже созданию ядра ОС.


Раздел 2: Системные вызовы (nix, libc)

Системное программирование в Rust открывает двери к низкоуровневому взаимодействию с операционной системой. Одним из ключевых аспектов этого процесса является работа с системными вызовами — интерфейсом, через который программы запрашивают у ядра выполнение привилегированных операций, таких как доступ к файлам, управление процессами или работа с сетью. В этом разделе мы глубоко погрузимся в использование системных вызовов в Rust, рассмотрим библиотеки libc и nix, разберём их особенности, приведём примеры кода и обсудим все нюансы, которые могут встретиться на пути — от безопасности до кроссплатформенности.

Что такое системные вызовы?

Системные вызовы (system calls) — это точка входа в ядро операционной системы. Когда программа хочет прочитать файл, выделить память или отправить данные по сети, она не может сделать это напрямую из-за ограничений безопасности. Вместо этого она обращается к ядру через системный вызов. Ядро проверяет запрос, выполняет операцию и возвращает результат. В Unix-подобных системах (Linux, macOS и др.) такие вызовы стандартизированы в POSIX, хотя детали реализации могут отличаться.

Примеры операций, выполняемых через системные вызовы:

В языках вроде C системные вызовы часто обёрнуты в функции стандартной библиотеки (libc). Rust, будучи языком с акцентом на безопасность и производительность, предоставляет два основных способа работы с ними: через libc для прямого доступа к C-интерфейсам и через nix для более идиоматичного и безопасного подхода.

Использование libc для системных вызовов

Crate libc — это мост между Rust и стандартной C-библиотекой. Он предоставляет привязки (bindings) к функциям, которые оборачивают системные вызовы. Это низкоуровневый подход, требующий осторожности, так как он наследует все особенности C: работу с сырыми указателями, ручное управление памятью и отсутствие встроенной обработки ошибок в стиле Rust.

Чтобы использовать libc, добавьте его в ваш Cargo.toml:

[dependencies]
libc = "0.2"

Рассмотрим пример открытия файла с помощью open:


use libc::{c_char, c_int, O_RDONLY};
use std::ffi::CString;

fn main() {
    // Преобразуем путь в C-совместимую строку (нуль-терминированную)
    let path = CString::new("/etc/passwd").expect("Не удалось создать CString");
    
    // Вызываем системный вызов open через libc
    // Используем unsafe, так как работаем с C-функциями и сырыми указателями
    let fd: c_int = unsafe { libc::open(path.as_ptr(), O_RDONLY) };
    
    if fd == -1 {
        eprintln!("Ошибка при открытии файла");
    } else {
        println!("Файл открыт, дескриптор: {}", fd);
        // Закрываем файл
        unsafe { libc::close(fd) };
    }
}
    

Разбор примера:

Подводные камни:

Использование nix для системных вызовов

Crate nix — это высокоуровневая обёртка над системными вызовами, написанная с учётом идиом Rust. Она минимизирует использование unsafe, предоставляет типобезопасные интерфейсы и возвращает результаты в виде Result, что упрощает обработку ошибок.

Добавьте nix в Cargo.toml:

[dependencies]
nix = "0.26"

Повторим пример открытия файла с nix:


use nix::fcntl::{open, OFlag};
use nix::unistd::close;
use std::path::Path;

fn main() {
    let path = Path::new("/etc/passwd");
    match open(path, OFlag::O_RDONLY, nix::sys::stat::Mode::empty()) {
        Ok(fd) => {
            println!("Файл открыт, дескриптор: {}", fd);
            close(fd).expect("Ошибка при закрытии файла");
        }
        Err(err) => eprintln!("Ошибка при открытии файла: {}", err),
    }
}
    

Разбор примера:

Преимущества nix:

Кроссплатформенность и условная компиляция

Системные вызовы зависят от операционной системы. Например, epoll доступен только на Linux, а kqueue — на BSD/macOS. Rust решает это через условную компиляцию с атрибутом #[cfg].

Пример:


#[cfg(target_os = "linux")]
fn use_epoll() {
    println!("Используем epoll (Linux)");
}

#[cfg(not(target_os = "linux"))]
fn use_epoll() {
    unimplemented!("epoll доступен только на Linux");
}

fn main() {
    use_epoll();
}
    

Совет: используйте #[cfg] для изоляции платформозависимого кода и тестируйте на разных ОС с помощью cargo build --target.

Практические примеры

Чтение файла


use nix::fcntl::{open, OFlag};
use nix::unistd::{close, read};
use std::path::Path;

fn main() {
    let path = Path::new("/etc/passwd");
    let fd = open(path, OFlag::O_RDONLY, nix::sys::stat::Mode::empty())
        .expect("Не удалось открыть файл");
    let mut buffer = [0u8; 1024];
    let bytes_read = read(fd, &mut buffer).expect("Ошибка чтения");
    let content = String::from_utf8_lossy(&buffer[..bytes_read]);
    println!("Прочитано {} байт: {}", bytes_read, content);
    close(fd).expect("Ошибка закрытия");
}
    

Создание процесса с fork


use nix::unistd::{fork, ForkResult};
use std::process;

fn main() {
    match unsafe { fork() } {
        Ok(ForkResult::Parent { child }) => {
            println!("Родительский процесс, PID ребёнка: {}", child);
        }
        Ok(ForkResult::Child) => {
            println!("Дочерний процесс");
            process::exit(0);
        }
        Err(err) => {
            eprintln!("Ошибка fork: {}", err);
            process::exit(1);
        }
    }
}
    

Внимание: fork опасен в многопоточных программах, так как копируется только текущий поток.

Отображение файла в память с mmap


use nix::sys::mman::{mmap, munmap, ProtFlags, MapFlags};
use nix::fcntl::{open, OFlag};
use nix::unistd::close;
use std::path::Path;
use std::os::unix::io::AsRawFd;

fn main() {
    let path = Path::new("/etc/passwd");
    let fd = open(path, OFlag::O_RDONLY, nix::sys::stat::Mode::empty())
        .expect("Не удалось открыть файл");
    let size = 1024;
    let addr = unsafe {
        mmap(None, size, ProtFlags::PROT_READ, MapFlags::MAP_PRIVATE, fd.as_raw_fd(), 0)
    }.expect("Ошибка mmap");
    let data = unsafe { std::slice::from_raw_parts(addr as *const u8, size) };
    println!("Первые 10 байт: {:?}", &data[..10]);
    munmap(addr, size).expect("Ошибка munmap");
    close(fd).expect("Ошибка закрытия");
}
    

Обработка ошибок

В libc ошибки проверяются вручную через errno:


use libc::{c_int, open, O_RDONLY};
use std::ffi::CString;

fn main() {
    let path = CString::new("/nonexistent").unwrap();
    let fd: c_int = unsafe { open(path.as_ptr(), O_RDONLY) };
    if fd == -1 {
        let errno = unsafe { *libc::__errno_location() };
        eprintln!("Ошибка: {}", errno); // Например, 2 = ENOENT
    }
}
    

В nix ошибки типизированы:


use nix::fcntl::{open, OFlag};
use std::path::Path;

fn main() {
    let path = Path::new("/nonexistent");
    if let Err(err) = open(path, OFlag::O_RDONLY, nix::sys::stat::Mode::empty()) {
        eprintln!("Ошибка: {}", err);
        if let nix::Error::Sys(errno) = err {
            eprintln!("Код ошибки: {}", errno);
        }
    }
}
    

Лучшие практики

Заключение

Системные вызовы в Rust — мощный инструмент для низкоуровневого программирования. libc даёт прямой доступ к C-интерфейсам, но требует осторожности. nix упрощает работу, делая её безопаснее и ближе к духу Rust. Выбор между ними зависит от ваших задач: контроль против удобства. В следующих разделах мы углубимся в другие аспекты системного программирования, такие как работа с памятью в no_std и разработка для встраиваемых систем.


Раздел 3: Пример для встраиваемых систем (минимальный драйвер)

Что такое встраиваемые системы?

Встраиваемые системы — это специализированные компьютерные системы, интегрированные в устройства для выполнения конкретных задач. В отличие от универсальных компьютеров (например, вашего ноутбука или сервера), которые могут запускать множество приложений, встраиваемые системы заточены под узкий круг функций и часто работают в условиях ограниченных ресурсов.

Примеры встраиваемых систем:

Особенности встраиваемых систем:

Эти ограничения делают разработку для встраиваемых систем сложной задачей, требующей оптимизации кода и тщательного управления ресурсами.

Почему Rust для встраиваемых систем?

Rust — это современный системный язык программирования, который сочетает безопасность памяти с высокой производительностью. Эти качества делают его идеальным выбором для встраиваемых систем, где ошибки могут привести к сбоям оборудования или даже угрозе безопасности.

Преимущества Rust:

  1. Безопасность памяти: Rust предотвращает ошибки вроде разыменования нулевых указателей, выхода за границы массивов и гонок данных — проблем, которые в C/C++ могут остаться незамеченными до этапа выполнения.
  2. Производительность: Код на Rust компилируется в машинный код, обеспечивая скорость на уровне C и C++.
  3. Отсутствие сборщика мусора: Это позволяет точно контролировать память и избегать непредсказуемых пауз, что критично для реального времени.
  4. Поддержка no_std: Rust позволяет писать программы без стандартной библиотеки, что необходимо для систем с минимальными ресурсами.
  5. Кросс-компиляция: Rust легко компилируется для разных архитектур (ARM, RISC-V, AVR), что упрощает разработку для микроконтроллеров.

Примечание: Режим no_std отключает стандартную библиотеку Rust (std), оставляя только ядро (core), что идеально для встраиваемых систем, где std может быть слишком тяжеловесной.

Что такое минимальный драйвер?

Драйвер — это программа, которая обеспечивает взаимодействие между операционной системой (или, в случае bare-metal систем, непосредственно кодом) и аппаратным обеспечением. В контексте встраиваемых систем минимальный драйвер — это простейший пример, демонстрирующий, как можно управлять устройством на низком уровне. Обычно драйверы работают с регистрами устройства через memory-mapped I/O (отображение памяти), где определенные адреса в памяти соответствуют регистрам оборудования.

В этом разделе мы создадим минимальный драйвер на Rust, который будет читать и записывать данные в симулированное устройство. Хотя пример будет упрощенным, он заложит основу для понимания более сложных сценариев.

Настройка проекта

Прежде чем писать код, давайте настроим окружение. Мы будем использовать Rust в режиме no_std, чтобы имитировать условия встраиваемых систем.

  1. Создание проекта:
    bash
    cargo new minimal_driver --bin
    cd minimal_driver
                
  2. Настройка no_std: Откройте файл src/main.rs и добавьте атрибут #![no_std]. Знак #! указывает, что это "внутренний" атрибут, применяемый к содержащему элементу (в данном случае, всему файлу).
  3. Добавление зависимостей: Для работы с volatile операциями (чтение/запись в регистры без оптимизации компилятором) добавим crate volatile. Откройте Cargo.toml и добавьте:
    toml
    [dependencies]
    volatile = "0.4"
                
  4. Отключение main: В no_std средах нет стандартной точки входа main. Вместо этого мы определим свою точку входа позже, но для простоты в этом примере используем std для вывода результата.

Внимание: В реальных встраиваемых системах вы не сможете использовать println! или std. Вместо этого вам нужно реализовать вывод через UART или другой интерфейс, доступный на вашей платформе.

Пример кода минимального драйвера

Теперь давайте напишем драйвер для симулированного устройства с двумя регистрами: регистр состояния (STATUS_REG) и регистр данных (DATA_REG). Мы будем читать и записывать данные, синхронизируясь с устройством через проверку бита готовности.

Вот полный код с подробными комментариями:

rust
// Указываем, что мы не используем стандартную библиотеку (в реальном проекте)
// #![no_std]

// Симуляция адресов регистров устройства
const STATUS_REG: usize = 0x1000; // Регистр состояния
const DATA_REG: usize = 0x1004;   // Регистр данных

/// Читает значение из регистра по указанному адресу
/// # Safety
/// Вызывающий должен гарантировать, что адрес валиден и доступен
unsafe fn read_reg(addr: usize) -> u32 {
    let ptr = addr as *const u32; // Приводим адрес к указателю
    *ptr                          // Читаем значение
}

/// Записывает значение в регистр по указанному адресу
/// # Safety
/// Вызывающий должен гарантировать, что адрес валиден и доступен
unsafe fn write_reg(addr: usize, value: u32) {
    let ptr = addr as *mut u32; // Приводим адрес к мутабельному указателю
    *ptr = value;               // Записываем значение
}

/// Ожидает, пока устройство не станет готово
fn wait_for_ready() {
    unsafe {
        // Читаем регистр состояния, пока бит 0 не станет 1
        while read_reg(STATUS_REG) & 1 == 0 {
            // Пустой цикл ожидания
        }
    }
}

/// Записывает данные в устройство
fn write_data(data: u32) {
    wait_for_ready();         // Ждем готовности
    unsafe {
        write_reg(DATA_REG, data); // Записываем данные
    }
}

/// Читает данные из устройства
fn read_data() -> u32 {
    wait_for_ready();         // Ждем готовности
    unsafe {
        read_reg(DATA_REG)    // Читаем данные
    }
}

fn main() {
    // Симуляция работы с устройством
    write_data(42);           // Записываем значение 42
    let data = read_data();   // Читаем обратно
    println!("Прочитанные данные: {}", data); // Выводим результат
}
    

Разбор кода

Давайте подробно разберем, как работает этот код:

  1. Константы регистров: Мы определили STATUS_REG и DATA_REG как адреса в памяти. В реальной системе эти значения берутся из документации на микроконтроллер.
  2. Функции чтения и записи:
  3. Синхронизация: Функция wait_for_ready проверяет бит 0 в регистре состояния, ожидая, пока устройство не сигнализирует о готовности.
  4. Операции с данными:
  5. Точка входа: В main мы симулируем запись числа 42 и чтение его обратно.

Примечание: Этот пример работает только в симуляции, так как реальные адреса 0x1000 и 0x1004 недоступны без соответствующего оборудования. В настоящем проекте вам нужно указать правильные адреса из спецификации устройства.

Улучшение примера с volatile

В нашем примере есть проблема: компилятор может оптимизировать операции чтения и записи, что недопустимо при работе с регистрами оборудования. Чтобы это исправить, используем crate volatile. Вот улучшенная версия кода:

rust
use volatile::Volatile;

const STATUS_REG: usize = 0x1000;
const DATA_REG: usize = 0x1004;

fn read_reg(addr: usize) -> u32 {
    let ptr = addr as *const Volatile;
    unsafe { (*ptr).read() }
}

fn write_reg(addr: usize, value: u32) {
    let ptr = addr as *mut Volatile;
    unsafe { (*ptr).write(value) }
}

fn wait_for_ready() {
    while read_reg(STATUS_REG) & 1 == 0 {}
}

fn write_data(data: u32) {
    wait_for_ready();
    write_reg(DATA_REG, data);
}

fn read_data() -> u32 {
    wait_for_ready();
    read_reg(DATA_REG)
}

fn main() {
    write_data(42);
    let data = read_data();
    println!("Прочитанные данные: {}", data);
}
    

Ключевое отличие: мы заменили сырые указатели на Volatile, который гарантирует, что операции чтения и записи не будут оптимизированы компилятором.

Лучшие практики

  1. Используйте volatile: Всегда применяйте volatile операции для работы с регистрами, чтобы избежать некорректных оптимизаций.
  2. Проверяйте синхронизацию: Убедитесь, что доступ к устройству синхронизирован, особенно если используются прерывания.
  3. Обрабатывайте ошибки: Добавьте таймауты в wait_for_ready, чтобы избежать бесконечных циклов, если устройство не отвечает.
  4. Документируйте unsafe: Каждый блок unsafe должен сопровождаться пояснением, почему он безопасен в данном контексте.
  5. Тестируйте на эмуляторах: Используйте QEMU или симуляторы микроконтроллеров для отладки.

Потенциальные "подводные камни"

Заключение

В этом разделе мы изучили основы написания минимального драйвера для встраиваемых систем на Rust. Мы начали с базовых концепций встраиваемых систем, рассмотрели преимущества Rust, настроили проект и создали пример драйвера с использованием как сырого, так и безопасного подхода с volatile. Этот пример — лишь отправная точка; реальные драйверы требуют учета прерываний, DMA, таймингов и других аспектов, которые мы рассмотрим в следующих разделах.

Упражнение

Для закрепления материала попробуйте улучшить драйвер:

  1. Добавьте таймаут в wait_for_ready, возвращающий Result в случае превышения времени ожидания.
  2. Симулируйте поведение устройства в отдельной функции, которая обновляет регистр состояния.

Это поможет вам лучше понять синхронизацию и обработку ошибок в драйверах.


Раздел 4: Rust: embedded-hal vs кастомный доступ к железу

В этом разделе мы глубоко погружаемся в два основных подхода к работе с аппаратным обеспечением в Rust для встраиваемых систем: использование библиотеки embedded-hal и прямой (кастомный) доступ к регистрам микроконтроллера. Мы разберем их преимущества, недостатки, сценарии использования, дадим примеры кода с комментариями и обсудим, как сделать осознанный выбор между ними в зависимости от вашего проекта. Этот раздел предназначен как для новичков, так и для опытных разработчиков, поэтому мы начнем с основ и постепенно перейдем к более сложным аспектам.

4.1. Введение в embedded-hal

Библиотека embedded-hal — это набор абстрактных интерфейсов (трейтов) в Rust, разработанных для упрощения работы с аппаратными периферийными устройствами встраиваемых систем. Она предоставляет унифицированный API для таких интерфейсов, как GPIO (ввод-вывод общего назначения), SPI, I2C, UART и другие. Главная цель embedded-hal — обеспечить переносимость кода между различными микроконтроллерами и платформами, минимизируя необходимость переписывания драйверов при смене оборудования.

Что такое HAL и почему он важен?

HAL (Hardware Abstraction Layer) — это слой абстракции, который скрывает детали конкретного оборудования за общим интерфейсом. В мире встраиваемых систем, где каждый микроконтроллер имеет свои особенности (регистры, биты конфигурации, тайминги), HAL становится спасением для разработчиков, которые хотят писать код один раз и использовать его на разных устройствах.

Преимущества embedded-hal

  1. Переносимость
    Код, написанный с использованием embedded-hal, можно легко адаптировать для другой платформы, если для нее реализованы соответствующие трейты. Например, драйвер для датчика I2C, написанный с embedded-hal, будет работать и на STM32, и на RP2040, если обе платформы поддерживают эту библиотеку.
  2. Удобство и безопасность
    Трейты embedded-hal спроектированы с учетом философии Rust: они безопасны (используют проверку типов и предотвращают ошибки на этапе компиляции) и интуитивно понятны. Вам не нужно вручную манипулировать битами регистров, что снижает вероятность ошибок.
  3. Экосистема и сообщество
    Многие популярные библиотеки для встраиваемых систем в Rust (например, драйверы для дисплеев, сенсоров, моторов) используют embedded-hal как основу. Это упрощает интеграцию готовых решений в ваш проект.

Недостатки embedded-hal

  1. Небольшая потеря производительности
    Абстракция добавляет слой indireции (например, через динамическую диспетчеризацию или generic-функции), что может привести к минимальному замедлению. В большинстве случаев это не критично, но в задачах с жесткими требованиями к таймингам (например, битбангинг) это может стать проблемой.
  2. Ограниченная гибкость
    embedded-hal покрывает только общие возможности периферии. Если ваш микроконтроллер поддерживает уникальные функции (например, специфичный режим DMA или нестандартный таймер), вам придется выйти за рамки библиотеки.

Пример использования embedded-hal

Давайте рассмотрим простой пример: управление светодиодом через GPIO. Мы хотим включить и выключить светодиод с небольшой задержкой.

use embedded_hal::digital::v2::OutputPin;

fn blink_led<P: OutputPin>(pin: &mut P) {
    pin.set_high().unwrap(); // Устанавливаем пин в высокий уровень (включаем светодиод)
    // Здесь должна быть задержка (например, с использованием таймера)
    pin.set_low().unwrap();  // Устанавливаем пин в низкий уровень (выключаем светодиод)
}

Объяснение:

Этот код универсален: он будет работать на любом микроконтроллере, если вы предоставите реализацию OutputPin (обычно это делает библиотека для конкретной платформы, например, stm32f1xx-hal).

4.2. Кастомный доступ к железу

Кастомный доступ к железу — это подход, при котором вы напрямую взаимодействуете с регистрами микроконтроллера, минуя любые абстракции. Обычно это делается с использованием unsafe-блоков и указателей (raw pointers) в Rust, так как вы работаете с памятью на низком уровне.

Когда нужен кастомный доступ?

Этот метод подходит для случаев, когда:

Преимущества кастомного доступа

  1. Максимальная производительность
    Отсутствие промежуточных слоев означает, что вы можете выжать из оборудования все возможное. Каждое обращение к регистрам выполняется напрямую, без накладных расходов.
  2. Полный контроль
    Вы можете настроить любой бит в любом регистре, что дает доступ ко всем функциям микроконтроллера, включая те, которые не поддерживаются стандартными библиотеками.

Недостатки кастомного доступа

  1. Отсутствие переносимости
    Код, написанный для одного микроконтроллера (например, STM32F4), не будет работать на другом (например, nRF52832) без значительных изменений.
  2. Сложность и риск ошибок
    Работа с регистрами требует глубокого понимания документации (Reference Manual) и может привести к трудноотслеживаемым багам, таким как неправильная конфигурация или нарушение границ памяти.
  3. Безопасность
    В Rust прямой доступ к памяти через unsafe отключает многие гарантии языка, что увеличивает вероятность ошибок (например, гонки данных или некорректные значения в регистрах).

Пример кастомного доступа

Рассмотрим пример управления пином GPIOA на микроконтроллере STM32F1. Мы настроим пин как выход и установим его в высокий уровень.

use core::ptr;

const GPIOA_BASE: u32 = 0x40010800;        // Базовый адрес GPIOA
const GPIO_CRL_OFFSET: u32 = 0x00;         // Смещение регистра CRL (конфигурация пинов 0-7)
const GPIO_BSRR_OFFSET: u32 = 0x10;        // Смещение регистра BSRR (установка/сброс пинов)

fn set_gpioa_pin_high(pin: u8) {
    unsafe {
        let crl = GPIOA_BASE + GPIO_CRL_OFFSET;  // Адрес регистра CRL
        let bsrr = GPIOA_BASE + GPIO_BSRR_OFFSET; // Адрес регистра BSRR

        // Настраиваем пин как выход (push-pull)
        let mut crl_value = ptr::read_volatile(crl as *const u32); // Читаем текущее значение CRL
        crl_value &= !(0b1111 << (pin * 4));       // Очищаем биты конфигурации для пина
        crl_value |= 0b0010 << (pin * 4);          // Устанавливаем режим push-pull output
        ptr::write_volatile(crl as *mut u32, crl_value); // Записываем новое значение

        // Устанавливаем пин в высокий уровень
        ptr::write_volatile(bsrr as *mut u32, 1 << pin); // Устанавливаем бит в BSRR
    }
}

Объяснение:

4.3. Сравнение и выбор подхода

Теперь, когда мы рассмотрели оба метода, давайте сравним их по ключевым критериям и разберем, как выбрать подходящий для вашего проекта.

Критерий embedded-hal Кастомный доступ
Переносимость Высокая (работает на разных платформах) Низкая (привязан к конкретному чипу)
Производительность Хорошая, но с накладными расходами Максимальная (прямой доступ)
Сложность Низкая (абстракция упрощает работу) Высокая (требует знаний железа)
Гибкость Средняя (ограничена трейтами) Высокая (полный контроль)
Безопасность Высокая (гарантии Rust) Низкая (unsafe-код)

Когда использовать embedded-hal?

Когда использовать кастомный доступ?

Гибридный подход

В реальных проектах часто комбинируют оба подхода: используют embedded-hal для общих задач и прибегают к кастомному доступу для специфичных функций. Например, вы можете использовать embedded-hal для работы с I2C, но напрямую настроить таймер для точного управления шиной.

4.4. Практические советы

  1. Начинайте с embedded-hal
    Даже если вы планируете использовать кастомный доступ, начните с embedded-hal, чтобы быстро прототипировать и проверить идею. Это также поможет вам освоить API и структуру проекта.
  2. Изучайте документацию
    При кастомном доступе обязательно держите под рукой Reference Manual вашего микроконтроллера. Например, для STM32 это может быть 1000+ страниц, но ключевые разделы (GPIO, SPI, clocks) стоит изучить досконально.
  3. Создавайте безопасные обертки
    Если вы используете кастомный доступ, оберните unsafe-операции в безопасные функции с четкими интерфейсами. Например:
pub struct GpioPin {
    base: u32,
    pin: u8,
}

impl GpioPin {
    pub fn set_high(&self) {
        unsafe {
            ptr::write_volatile((self.base + GPIO_BSRR_OFFSET) as *mut u32, 1 << self.pin);
        }
    }
}
  1. Тестируйте на железе
    Эмуляторы и симуляторы полезны, но встраиваемые системы полны неожиданностей (тайминги, глюки периферии). Всегда проверяйте код на реальном оборудовании.
  2. Используйте отладку
    Подключите JTAG/SWD-отладчик (например, ST-Link) и используйте инструменты вроде probe-rs или gdb для пошаговой отладки.

4.5. Заключение

Выбор между embedded-hal и кастомным доступом к железу зависит от ваших приоритетов: переносимость и удобство против производительности и гибкости. В большинстве случаев embedded-hal — это оптимальный старт, который позволяет быстро разрабатывать надежные решения. Однако для задач, требующих полного контроля или максимальной оптимизации, кастомный доступ остается незаменимым инструментом. По мере роста опыта вы сможете комбинировать оба подхода, находя идеальный баланс для каждого проекта.


Раздел 5: Написание ядра ОС

Создание собственного ядра операционной системы (ОС) - это одна из самых сложных, но в то же время увлекательных задач в системном программировании. В этом разделе мы разберём, как подойти к написанию минимального ядра на Rust с нуля, начиная с загрузки кода на "голое железо" и заканчивая базовым управлением оборудованием. Мы рассмотрим ключевые концепции, шаги, примеры кода, а также обсудим подводные камни и лучшие практики. Даже если вы новичок, этот раздел поможет вам понять основы, а опытным разработчикам даст идеи для углубления.

Зачем писать ядро на Rust?

Rust идеально подходит для написания ядра ОС благодаря своей строгой системе типов, отсутствию runtime по умолчанию и возможности работать в среде no_std. Это позволяет избежать типичных ошибок (например, разыменование нулевых указателей), которые в C/C++ часто приводят к сбоям ядра. Кроме того, Rust предоставляет мощные инструменты, такие как unsafe, для низкоуровневого доступа к оборудованию, сохраняя при этом безопасность там, где это возможно.

Что такое ядро ОС?

Ядро - это сердце операционной системы, программа, которая управляет ресурсами компьютера (процессором, памятью, устройствами ввода-вывода) и предоставляет интерфейс для пользовательских приложений. Минимальное ядро, которое мы создадим, будет "freestanding" (независимым от стандартной библиотеки) и сможет запускаться на реальном или эмулированном оборудовании.

Шаг 1: Подготовка окружения

Для начала нам нужно настроить среду разработки. Ядро работает без операционной системы, поэтому мы будем использовать no_std и специальный загрузчик, например, GRUB, для запуска кода на оборудовании.

Для простоты можно использовать готовый загрузчик, например, bootloader (доступный как crate в Rust). Он поможет загрузить ваш код в память и передать управление.

<!-- Cargo.toml -->
[package]
name = "my-kernel"
version = "0.1.0"
edition = "2021"

[dependencies]
bootloader = "0.9"  # Для упрощённой загрузки

[profile.dev]
panic = "abort"  # Паника приводит к остановке ядра

[profile.release]
panic = "abort"
    

Совет: Используйте bootloader, чтобы не писать загрузчик с нуля. Это избавит вас от работы с ассемблером на ранних этапах.

Шаг 2: Минимальный код ядра

Создадим файл src/main.rs, который станет точкой входа в наше ядро. Поскольку стандартная функция main недоступна в no_std, мы определим собственную точку входа.

#![no_std]  // Отключаем стандартную библиотеку
#![no_main] // Отключаем стандартную точку входа

use core::panic::PanicInfo;

// Точка входа ядра
#[no_mangle]  // Не изменяем имя функции для загрузчика
pub extern "C" fn _start() -> ! {
    // Бесконечный цикл - ядро не должно завершаться
    loop {}
}

// Обработчик паники
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

Этот код:

Шаг 3: Вывод текста на экран

Чтобы ядро было полезным, добавим вывод текста через VGA-буфер - стандартный способ отображения информации в текстовом режиме на x86-архитектурах.

#![no_std]
#![no_main]

use core::panic::PanicInfo;
use core::ptr;

// Адрес VGA-буфера
const VGA_BUFFER: *mut u16 = 0xb8000 as *mut u16;

#[no_mangle]
pub extern "C" fn _start() -> ! {
    // Выводим символ 'H' в верхний левый угол
    unsafe {
        ptr::write(VGA_BUFFER, 0x0200 | 'H' as u16);  // Зеленый фон, символ 'H'
    }
    loop {}
}

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

Объяснение:

Подводный камень: Ошибка в адресе или формате данных для VGA-буфера может привести к некорректному отображению или краху. Всегда проверяйте спецификацию оборудования!

Ещё пример минимального кода:

   #![no_std]
   #![no_main]

   use core::panic::PanicInfo;

   #[no_mangle]
   pub extern "C" fn _start() -> ! {
       // Выводим "Hello, World!" на экран через VGA буфер
       let vga_buffer = 0xb8000 as *mut u8;
       let message = b"Hello, World!";
       for (i, &byte) in message.iter().enumerate() {
           unsafe {
               *vga_buffer.offset(i as isize * 2) = byte;
               *vga_buffer.offset(i as isize * 2 + 1) = 0x07; // Цвет: серый текст на черном фоне
           }
       }
       loop {}
   }

   #[panic_handler]
   fn panic(_info: &PanicInfo) -> ! {
       loop {}
   }

Шаг 4: Добавляем абстракцию для печати

Работа с сырыми указателями неудобна и опасна. Создадим простой модуль для вывода текста.

#![no_std]
#![no_main]

use core::panic::PanicInfo;
use core::ptr;

mod vga {
    const BUFFER: *mut u16 = 0xb8000 as *mut u16;

    pub fn print_char(pos: usize, c: char, color: u8) {
        let attribute = (color as u16) << 8;
        unsafe {
            ptr::write(BUFFER.add(pos), attribute | c as u16);
        }
    }
}

#[no_mangle]
pub extern "C" fn _start() -> ! {
    vga::print_char(0, 'H', 0x02);  // 'H' с зелёным фоном
    vga::print_char(1, 'i', 0x02);  // 'i' с зелёным фоном
    loop {}
}

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

Теперь у нас есть модуль vga, который абстрагирует работу с буфером. Это улучшает читаемость и позволяет легко менять логику вывода.

Шаг 5: Тестирование ядра

Скомпилируем ядро и запустим его в QEMU:

  1. Установите rustup target add x86_64-unknown-none.
  2. Скомпилируйте: cargo build --target x86_64-unknown-none.
  3. Создайте образ с GRUB: bootloader сделает это автоматически.
  4. Запустите: qemu-system-x86_64 -drive format=raw,file=target/x86_64-unknown-none/debug/bootimage-my-kernel.bin.

Вы увидите "Hi" на экране в QEMU!

Подводные камни и лучшие практики

Дальнейшие шаги

Наше ядро пока минимально. Вот что можно добавить:

Этот раздел дал вам базовое понимание написания ядра на Rust. Экспериментируйте, изучайте документацию оборудования и пробуйте свои силы в более сложных задачах!

Да, это возможно и даже относительно просто для академических целей. Вам не нужно писать сложные драйверы или модули — достаточно минимального кода и загрузчика. Если хотите попробовать, начните с туториала вроде "Writing an OS in Rust" от Philipp Oppermann (os.phil-opp.com) — там пошагово объясняется подобный процесс, начиная с такого минимального примера.


Раздел 6: Упражнение: Написание утилиты для чтения системных данных

В этом разделе мы погрузимся в практическое упражнение, которое объединяет многие концепции системного программирования на Rust: взаимодействие с операционной системой, работа с низкоуровневыми API, обработка ошибок и безопасное управление ресурсами. Наша задача — создать утилиту, которая будет читать системные данные, такие как информация о процессоре, загрузке системы или состоянии памяти. Мы разберём задачу пошагово, предоставим несколько вариантов реализации, обсудим подводные камни и дадим рекомендации по улучшению кода. К концу раздела вы получите не только рабочую утилиту, но и глубокое понимание того, как Rust помогает в системном программировании.

Постановка задачи

Мы напишем консольную утилиту syspeek, которая выводит базовую информацию о системе: количество ядер процессора, текущую загрузку CPU и доступную оперативную память. Утилита должна быть кроссплатформенной (насколько это возможно), использовать системные вызовы или стандартные средства Rust, а также демонстрировать обработку ошибок и модульность кода.

Шаг 1: Подготовка окружения

Для начала создайте новый проект с помощью Cargo:

cargo new syspeek --bin
cd syspeek

Добавим зависимости в Cargo.toml:

[dependencies]
nix = "0.27"      # Для системных вызовов на Unix-подобных системах
sysinfo = "0.30"  # Удобная библиотека для получения системной информации
anyhow = "1.0"    # Для удобной обработки ошибок

Библиотека nix предоставляет обёртки для системных вызовов POSIX, sysinfo упрощает доступ к системным данным, а anyhow помогает обрабатывать ошибки без лишней сложности.

Примечание: Если вы хотите минимизировать зависимости, можно обойтись без sysinfo, читая данные напрямую из файлов вроде /proc/stat на Linux или используя WinAPI на Windows. Мы рассмотрим оба подхода.

Шаг 2: Базовая структура утилиты

Откройте src/main.rs и начнём с простой реализации, используя sysinfo:

use sysinfo::{CpuExt, System, SystemExt};
use anyhow::Result;

fn main() -> Result<()> {
    // Создаём объект для доступа к системной информации
    let mut sys = System::new_all();
    sys.refresh_all(); // Обновляем данные

    // Число ядер процессора
    println!("Количество ядер: {}", sys.cpus().len());

    // Загрузка CPU (в процентах)
    let cpu_usage: f32 = sys.cpus().iter().map(|cpu| cpu.cpu_usage()).sum();
    println!("Общая загрузка CPU: {:.2}%", cpu_usage);

    // Доступная память
    println!("Доступная память: {} MB", sys.available_memory() / 1024);

    Ok(())
}

Этот код прост и понятен: мы инициализируем System, обновляем данные с помощью refresh_all и выводим нужные параметры. Однако он полагается на sysinfo, что может быть нежелательно в некоторых сценариях (например, для минималистичных систем).

Шаг 3: Реализация без внешних библиотек (Linux)

Давайте усложним задачу: напишем версию утилиты, которая читает данные напрямую из /proc на Linux, используя только стандартную библиотеку и nix для низкоуровневого доступа.

use std::fs::File;
use std::io::{self, BufRead, BufReader};
use anyhow::{Context, Result};
use nix::sys::sysinfo::sysinfo;

fn read_cpu_count() -> Result<usize> {
    let file = File::open("/proc/cpuinfo").context("Не удалось открыть /proc/cpuinfo")?;
    let reader = BufReader::new(file);
    let count = reader
        .lines()
        .filter_map(|line| line.ok())
        .filter(|line| line.starts_with("processor"))
        .count();
    Ok(count)
}

fn read_cpu_usage() -> Result<f32> {
    let file = File::open("/proc/stat").context("Не удалось открыть /proc/stat")?;
    let reader = BufReader::new(file);
    let mut lines = reader.lines();
    let first_line = lines.next().context("Файл /proc/stat пуст")?.context("Ошибка чтения")?;
    let stats: Vec<u64> = first_line
        .split_whitespace()
        .skip(1) // Пропускаем "cpu"
        .map(|s| s.parse().unwrap_or(0))
        .collect();

    // Формула: (user + nice + system) / (user + nice + system + idle)
    let active = stats[0] + stats[1] + stats[2];
    let total = active + stats[3];
    Ok((active as f32 / total as f32) * 100.0)
}

fn read_available_memory() -> Result<u64> {
    let info = sysinfo().context("Ошибка вызова sysinfo")?;
    Ok(info.available() / 1024) // В мегабайтах
}

fn main() -> Result<()> {
    println!("Количество ядер: {}", read_cpu_count()?);
    println!("Загрузка CPU: {:.2}%", read_cpu_usage()?);
    println!("Доступная память: {} MB", read_available_memory()?);
    Ok(())
}

Этот код читает данные из файлов /proc/cpuinfo и /proc/stat, а также использует системный вызов sysinfo из nix. Вот что происходит:

Внимание: Этот код работает только на Linux. На Windows потребуется использовать WinAPI (например, GetSystemInfo), а на macOS — sysctl. Для кроссплатформенности можно добавить условную компиляцию с #[cfg].

Шаг 4: Улучшение и модульность

Давайте сделаем код более структурированным, добавив модули и обработку аргументов командной строки:

// src/main.rs
use anyhow::Result;
mod sysdata;

fn main() -> Result<()> {
    let args: Vec<String> = std::env::args().collect();
    let command = args.get(1).map(|s| s.as_str()).unwrap_or("all");

    match command {
        "cpu" => println!("Количество ядер: {}", sysdata::cpu_count()?),
        "usage" => println!("Загрузка CPU: {:.2}%", sysdata::cpu_usage()?),
        "mem" => println!("Доступная память: {} MB", sysdata::memory_available()?),
        "all" => {
            println!("Количество ядер: {}", sysdata::cpu_count()?);
            println!("Загрузка CPU: {:.2}%", sysdata::cpu_usage()?);
            println!("Доступная память: {} MB", sysdata::memory_available()?);
        }
        _ => println!("Использование: syspeek [cpu|usage|mem|all]"),
    }
    Ok(())
}

// src/sysdata.rs
use std::fs::File;
use std::io::{BufRead, BufReader};
use anyhow::{Context, Result};
use nix::sys::sysinfo::sysinfo;

pub fn cpu_count() -> Result<usize> {
    let file = File::open("/proc/cpuinfo").context("Не удалось открыть /proc/cpuinfo")?;
    let reader = BufReader::new(file);
    Ok(reader.lines().filter_map(|l| l.ok()).filter(|l| l.starts_with("processor")).count())
}

pub fn cpu_usage() -> Result<f32> {
    let file = File::open("/proc/stat").context("Не удалось открыть /proc/stat")?;
    let reader = BufReader::new(file);
    let mut lines = reader.lines();
    let stats: Vec<u64> = lines.next().context("Пустой /proc/stat")?.context("Ошибка чтения")?
        .split_whitespace().skip(1).map(|s| s.parse().unwrap_or(0)).collect();
    let active = stats[0] + stats[1] + stats[2];
    let total = active + stats[3];
    Ok((active as f32 / total as f32) * 100.0)
}

pub fn memory_available() -> Result<u64> {
    let info = sysinfo().context("Ошибка вызова sysinfo")?;
    Ok(info.available() / 1024)
}

Теперь утилита поддерживает аргументы: syspeek cpu, syspeek usage, syspeek mem или syspeek all. Логика вынесена в модуль sysdata, что улучшает читаемость и повторное использование кода.

Подводные камни и лучшие практики

Упражнение для читателя

Добавьте поддержку Windows, используя WinAPI через crate winapi, или реализуйте периодическое обновление данных с выводом в реальном времени (например, каждые 2 секунды). Попробуйте также добавить форматирование вывода в JSON для интеграции с другими инструментами.


Заключение

Глава 37 погружает нас в увлекательный мир системного программирования, где мы исследовали ключевые аспекты работы на низком уровне. Мы начали с управления памятью в среде no_std, изучив аллокаторы и raw pointers, что дало понимание тонкостей работы с ресурсами без стандартной библиотеки. Далее мы разобрались с системными вызовами через библиотеки nix и libc, открыв путь к взаимодействию с операционной системой на базовом уровне. Практический пример минимального драйвера для встраиваемых систем показал, как теоретические знания применяются в реальных задачах. Сравнение embedded-hal и кастомного доступа к железу в Rust подчеркнуло гибкость и компромиссы при разработке для устройств. Написание ядра ОС, представленное в упрощённой форме, раскрыло сложность создания основы для современных систем. Наконец, упражнение по созданию утилиты для чтения системных данных закрепило навыки, объединив теорию и практику. Эта глава — мост между абстрактным кодом и реальным железом, демонстрирующий мощь системного подхода.