Работа с С-объединениями (union) в Rust FFI

оригинал: Herman J. Radtke III • перевод: Станислав Ткач • обучение • поддержите на Patreon

Примечание: Эта статья предполагает, что читатель знаком с Rust FFI (перевод), порядком байтов (endianess) и ioctl.

При создании биндингов к коду на С мы неизбежно столкнёмся со структурой, которая содержит в себе объединение. В Rust отсутствует встроенная поддержка объединений, так что нам придётся выработать стратегию самостоятельно. В С объединение — это тип, который хранит разные типы данных в одной области памяти. Существует много причин, по которым можно отдать предпочтение объединению, такие как: преобразование между бинарными представлениями целых чисел и чисел с плавающей точкой, реализация псевдо-полиморфизма и прямой доступ к битам. Я сфокусируюсь на псевдо-полиморфизме.

Как пример, давайте получим MAC адрес, основанный на имени интерфейса. Перечислим действия, необходимые для его получения:

Если вас интересуют детали о получении MAC адреса, посмотрите эту инструкцию.

Нам необходимо использовать объявленную в С функцию ioctl и передать туда структуру ifreq. Посмотрев в /usr/include/net/if.h, мы увидим, что ifreq определена следующим образом:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct  ifreq {
        char    ifr_name[IFNAMSIZ];
        union {
                struct  sockaddr ifru_addr;
                struct  sockaddr ifru_dstaddr;
                struct  sockaddr ifru_broadaddr;
                short   ifru_flags;
                int     ifru_metric;
                int     ifru_mtu;
                int     ifru_phys;
                int     ifru_media;
                int     ifru_intval;
                caddr_t ifru_data;
                struct  ifdevmtu ifru_devmtu;
                struct  ifkpi   ifru_kpi;
                u_int32_t ifru_wake_flags;
                u_int32_t ifru_route_refcnt;
                int     ifru_cap[2];
        } ifr_ifru;
}

Сложности возникают с объединением ifr_ifru. Взглянув на возможные типы в ifr_ifru, мы видим, что не все из них одинакового размера. short занимает два байта, а u_int32_t — четыре. Ещё больше усложняют ситуацию несколько структур неизвестного размера. Чтобы написать правильный код на Rust, важно выяснить точный размер ifreq структуры. Я создал небольшую программу на С и выяснил, что ifreq использует 16 байт для ifr_name и 24 байт для ifr_ifru.

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

1
2
3
4
5
#[repr(C)]
pub struct IfReqShort {
    ifr_name: [c_char; 16],
    ifru_flags: c_short,
}

Мы можем использовать IfReqShort для запроса SIOCGIFINDEX. Эта структура меньше, чем структура ifreq в С. Хотя мы и предполагаем, что будет записано только 2 байта, внешний ioctl интерфейс ожидает 24 байта. Для безопасности давайте добавим 22 байта выравнивания (padding) в конце:

1
2
3
4
5
6
#[repr(C)]
pub struct IfReqShort {
    ifr_name: [c_char; 16],
    ifru_flags: c_short,
    _padding: [u8; 22],
}

Затем мы должны будем повторить этот процесс для каждого типа в объединении. Я нахожу это несколько утомительным, так как нам придётся создать множество структур и быть очень внимательными, чтобы не ошибиться с их размером. Другой способ представить объединение — это иметь буфер сырых байтов. Мы можем сделать единственное представление структуры ifreq в Rust следующим образом:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#[repr(C)]
pub struct IfReq {
    ifr_name: [c_char; 16],
    union: [u8; 24],
}
 ```
 
Этот буфер-объединение может хранить байты любого типа. Теперь мы можем определить методы для преобразования сырых байтов 
в нужный тип. Мы избежим использования небезопасного (unsafe) кода, отказавшись от использования transmute. Давайте 
создадим метод для получения MAC адреса, преобразовав сырые байты в тип языка С `sockaddr`.
 
```rust
impl IfReq {
    pub fn ifr_hwaddr(&self) -> sockaddr {
        let mut s = sockaddr {
            sa_family: u16::from_be((self.data[0] as u16) << 8 | (self.data[1] as u16)),
            sa_data: [0; 14],
        };

        // basically a memcpy
        for (i, b) in self.data[2..16].iter().enumerate() {
            s.sa_data[i] = *b as i8;
        }

        s
    }
}

Такой подход оставляет нам одну структуру и метод для преобразования сырых байтов в желаемый тип. Посмотрев снова на наше объединение ifr_ifru, мы обнаружим, что существует по крайней мере два других запроса, которые тоже требуют создания sockaddr из сырых байтов. Применяя принцип DRY, мы можем реализовать приватный метод IfReq для преобразования сырых байтов в sockaddr. Однако, мы можем сделать лучше, абстрагировав детали создания sockaddr, short, int и т. д. от IfReq. Всё что нам необходимо — это сказать объединению, что нам нужен определённый тип. Давайте создадим IfReqUnion для этого:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#[repr(C)]
struct IfReqUnion {
    data: [u8; 24],
}

impl IfReqUnion {
    fn as_sockaddr(&self) -> sockaddr {
        let mut s = sockaddr {
            sa_family: u16::from_be((self.data[0] as u16) << 8 | (self.data[1] as u16)),
            sa_data: [0; 14],
        };

        // basically a memcpy
        for (i, b) in self.data[2..16].iter().enumerate() {
            s.sa_data[i] = *b as i8;
        }

        s
    }

    fn as_int(&self) -> c_int {
        c_int::from_be((self.data[0] as c_int) << 24 |
                       (self.data[1] as c_int) << 16 |
                       (self.data[2] as c_int) <<  8 |
                       (self.data[3] as c_int))
    }

    fn as_short(&self) -> c_short {
        c_short::from_be((self.data[0] as c_short) << 8 |
                         (self.data[1] as c_short))
    }
}

Мы реализовали методы для каждого из типов, которые составляют объединение. Теперь, когда наши преобразования управляются IfReqUnion, мы можем реализовать методы IfReq следующим образом:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#[repr(C)]
pub struct IfReq {
    ifr_name: [c_char; IFNAMESIZE],
    union: IfReqUnion,
}

impl IfReq {
    pub fn ifr_hwaddr(&self) -> sockaddr {
        self.union.as_sockaddr()
    }

    pub fn ifr_dstaddr(&self) -> sockaddr {
        self.union.as_sockaddr()
    }

    pub fn ifr_broadaddr(&self) -> sockaddr {
        self.union.as_sockaddr()
    }

    pub fn ifr_ifindex(&self) -> c_int {
        self.union.as_int()
    }

    pub fn ifr_media(&self) -> c_int {
        self.union.as_int()
    }

    pub fn ifr_flags(&self) -> c_short {
        self.union.as_short()
    }
}

В итоге у нас есть две структуры. Во первых, IfReq, которая представляет структуру памяти ifreq в языке С. В ней мы реализуем метод для каждого типа ioctl запроса. Во вторых, у нас есть IfRequnion, которая управляет различными типами объединения ifr_ifru. Мы создадим метод для каждого типа, который нам нужен. Это менее трудоёмко, чем создание специализированной структуры для каждого типа объединения, и предоставляет лучший интерфейс, чем преобразование типа в самой IfReq.

Вот более полный готовый пример. Предстоит ещё немного работы, но тесты проходят, и в коде реализуется описанная выше концепция.

Будьте осторожны, этот подход не идеален. В случае ifreq нам повезло, что ifr_name содержит 16 байтов и выровнено по границе слова. Если бы ifr_name не было выровнено по границе четырёхбайтного слова, мы столкнулись бы с проблемой. Тип нашего объединения [u8; 24], которое выравнивается по границе одного байта. У типа размером 24 байта было бы другое выравнивание. Вот короткий пример иллюстрирующий проблему. Допустим, у нас есть С-структура, содержащая следующее объединение:

1
2
3
4
5
6
struct foo {
    short x;
    union {
        int;
    } y;
}

Эта структура имеет размер 8 байт. Два байта для х, ещё два для выравнивания и четыре байта для у. Давайте попробуем изобразить это в Rust:

1
2
3
4
5
#[repr(C)]
pub struct Foo {
    x: u16,
    y: [u8; 4],
}

Структура Foo имеет размер только 6 байт: два байта для х и первые два u8 элемента, помещённые в то же четырёхбайтовое слово, что и х. Эта едва заметная разница может вызвать проблемы при передаче в С-функцию, которая ожидает структуру размером в 8 байт.

До тех пор пока Rust не будет поддерживать объединения, такие проблемы сложно будет решить корректно. Удачи, но будьте осторожны!

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