Nuestro programa procesa paquetes de red, en particular, encabezados TCP / IP / etc. En ellos, los valores numéricos (compensaciones, contadores, direcciones) se presentan en orden de bytes de red (big-endian); Estamos trabajando en x86 (little-endian). En las estructuras estándar que describen encabezados, estos campos están representados por tipos enteros simples (
uint32_t
,
uint16_t
). Después de varios errores debido al hecho de que olvidamos convertir el orden de bytes, decidimos reemplazar los tipos de campo con clases que prohíben las conversiones implícitas y las operaciones atípicas. Debajo del corte hay un código utilitario y ejemplos específicos de errores que la mecanografía estricta reveló.
Orden de bytes
Likbez para aquellos que no conocen el orden de bytes (endianness, orden de bytes). Más detalladamente
ya estaba en "Habré" .
En la notación habitual de números, van del más antiguo (izquierda) al más joven (derecha) a la izquierda: 432
10 = 4 × 10
2 + 3 × 10
1 + 2 × 10
0 . Los tipos de datos enteros tienen un tamaño fijo, por ejemplo, 16 bits (números del 0 al 65535). Se almacenan en la memoria como dos bytes, por ejemplo, 432
10 = 01b0
16 , es decir, bytes 01 y b0.
Imprime los bytes de este número:
#include <cstdio> // printf() #include <cstdint> // uint8_t, uint16_t int main() { uint16_t value = 0x01b0; printf("%04x\n", value); const auto bytes = reinterpret_cast<const uint8_t*>(&value); for (auto i = 0; i < sizeof(value); i++) { printf("%02x ", bytes[i]); } }
En los procesadores Intel o AMD (x86) normales, obtenemos lo siguiente:
01b0 b0 01
Los bytes en la memoria se encuentran desde el más joven hasta el más antiguo, y no como cuando se escriben números. Este orden se llama
little-endian (LE). Lo mismo es cierto para los números de 4 bytes. El orden de bytes está determinado por la arquitectura del procesador. El orden "nativo" del procesador también se denomina
orden de la CPU o del host (orden de bytes de la CPU / host). En nuestro caso, el orden de bytes del host es little-endian.
Sin embargo, Internet no nació en x86, y allí el orden de los bytes era diferente:
del más antiguo al más joven (big-endian, BE). Comenzaron a usarlo en los encabezados de los protocolos de red (IP, TCP, UDP), por lo que el big-endian también se denomina
orden de bytes de red.Ejemplo: el puerto 443 (1bb
16 ), que usa HTTPS, se escribe en los encabezados TCP bytes bb 01, que cuando se lee dará bb01
16 = 47873.
El orden de bytes de un número se puede convertir. Por ejemplo, para
uint16_t
hay una función estándar
htons()
(
htons()
a la red para
un entero entero - desde el orden del host al orden de la red para enteros cortos) y el reverso
ntohs()
. Del mismo modo, para
uint32_t
hay
htonl()
y
ntohl()
(long es un entero largo).
Desafortunadamente, el compilador no sabe de dónde proviene el valor específico de una variable de tipo
uint32_t
, y no advierte si
uint32_t
valores con diferentes órdenes de bytes y obtiene un resultado incorrecto.
Mecanografía fuerte
El riesgo de confundir el orden de los bytes es obvio, ¿cómo lidiar con él?
- Revisión de código. Este es un procedimiento obligatorio en nuestro proyecto. Desafortunadamente, los evaluadores menos que nada quieren profundizar en el código que manipula los bytes: "Veo
htons()
- probablemente, el autor pensó en todo". - Disciplina, reglas como: SER solo en paquetes, todas las variables en LE. No siempre es razonable, por ejemplo, si necesita verificar los puertos contra una tabla hash, es más eficiente almacenarlos en el orden de bytes de la red y buscar "tal cual".
- Pruebas Como saben, no garantizan la ausencia de errores. Los datos pueden coincidir mal (1.1.1.1 no cambia al convertir el orden de bytes) o ajustarse al resultado.
Al trabajar con una red, no puede ignorar el orden de los bytes, por lo que me gustaría que sea imposible ignorarlo al escribir el código. Además, no solo tenemos un número en BE, es un número de puerto, dirección IP, número de secuencia TCP, suma de verificación. No se puede asignar uno a otro, incluso si el número de bits coincide.
La solución es conocida: mecanografía estricta, es decir, tipos separados para puertos, direcciones, números. Además, estos tipos deben admitir la conversión BE / LE.
Boost.Endian no nos conviene, porque no hay Boost en el proyecto.
El tamaño del proyecto es de aproximadamente 40 mil líneas en C ++ 17. Si crea tipos de contenedor seguros y sobrescribe las estructuras de encabezado en ellos, todos los lugares donde haya trabajo con BE dejarán de compilarse automáticamente. Tendrá que revisarlos todos una vez, pero el nuevo código solo será seguro.
Número de clase big-endian #include <cstdint> #include <iosfwd> #define PACKED __attribute__((packed)) constexpr auto bswap(uint16_t value) noexcept { return __builtin_bswap16(value); } constexpr auto bswap(uint32_t value) noexcept { return __builtin_bswap32(value); } template<typename T> struct Raw { T value; }; template<typename T> Raw(T) -> Raw<T>; template<typename T> struct BigEndian { using Underlying = T; using Native = T; constexpr BigEndian() noexcept = default; constexpr explicit BigEndian(Native value) noexcept : _value{bswap(value)} {} constexpr BigEndian(Raw<Underlying> raw) noexcept : _value{raw.value} {} constexpr Underlying raw() const { return _value; } constexpr Native native() const { return bswap(_value); } explicit operator bool() const { return static_cast<bool>(_value); } bool operator==(const BigEndian& other) const { return raw() == other.raw(); } bool operator!=(const BigEndian& other) const { return raw() != other.raw(); } friend std::ostream& operator<<(std::ostream& out, const BigEndian& value) { return out << value.native(); } private: Underlying _value{}; } PACKED;
- Se incluirá un archivo de encabezado con este tipo en todas partes, por lo que en lugar de un pesado
<iostream>
<iosfwd>
un ligero <iosfwd>
. - En lugar de
htons()
, etc. - intrínseco rápido del compilador. En particular, se ven constexpr
propagación constante, por constexpr
tanto, los constructores constexpr
. - A veces ya hay un valor
uint16_t
/ uint32_t
ubicado en BE. La estructura Raw<T>
con la guía de deducción le permite crear convenientemente un BigEndian<T>
partir de ella.
El punto controvertido aquí está
PACKED
: se cree que las estructuras empaquetadas son menos optimizables. La única respuesta es medir. Nuestros puntos de referencia de código no revelaron ralentizaciones. Además, en el caso de los paquetes de red, la posición de los campos en el encabezado sigue siendo fija.
En la mayoría de los casos, BE no necesita ninguna otra operación que no sea la comparación. Los números de secuencia deben plegarse correctamente con LE:
using BE16 = BigEndian<uint16_t>; using BE32 = BigEndian<uint32_t>; struct Seqnum : BE32 { using BE32::BE32; template<typename Integral> Seqnum operator+(Integral increment) const { static_assert(std::is_integral_v<Integral>); return Seqnum{static_cast<uint32_t>(native() + increment)}; } } PACKED; struct IP : BE32 { using BE32::BE32; } PACKED; struct L4Port : BE16 { using BE16::BE16; } PACKED;
Estructura segura de encabezado TCP enum TCPFlag : uint8_t { TH_FIN = 0x01, TH_SYN = 0x02, TH_RST = 0x04, TH_PUSH = 0x08, TH_ACK = 0x10, TH_URG = 0x20, TH_ECE = 0x40, TH_CWR = 0x80, }; using TCPFlags = std::underlying_type_t<TCPFlag>; struct TCPHeader { L4Port th_sport; L4Port th_dport; Seqnum th_seq; Seqnum th_ack; uint32_t th_flags2 : 4; uint32_t th_off : 4; TCPFlags th_flags; BE16 th_win; uint16_t th_sum; BE16 th_urp; uint16_t header_length() const { return th_off << 2; } void set_header_length(uint16_t len) { th_off = len >> 2; } uint8_t* payload() { return reinterpret_cast<uint8_t*>(this) + header_length(); } const uint8_t* payload() const { return reinterpret_cast<const uint8_t*>(this) + header_length(); } }; static_assert(sizeof(TCPHeader) == 20);
TCPFlag
podría convertirse en una enum class
, pero en la práctica solo se realizan dos operaciones en las banderas: verificar la entrada ( &
) o reemplazar las banderas con una combinación ( |
): no hay confusión.- Los campos de bits se dejan primitivos, pero se realizan métodos de acceso seguro.
- Los nombres de campo se dejan clásicos.
Resultados
La mayoría de las ediciones fueron triviales. El código es más limpio:
auto tcp = packet->tcp_header(); - return make_response(packet, - cookie_make(packet, rte_be_to_cpu_32(tcp->th_seq)), - rte_cpu_to_be_32(rte_be_to_cpu_32(tcp->th_seq) + 1), - TH_SYN | TH_ACK); + return make_response(packet, cookie_make(packet, tcp->th_seq.native()), + tcp->th_seq + 1, TH_SYN | TH_ACK); }
En parte, los tipos documentaron el código:
- void check_packet(int64_t, int64_t, uint8_t, bool); + void check_packet(std::optional<Seqnum>, std::optional<Seqnum>, TCPFlags, bool);
De repente, resultó que puede leer incorrectamente el tamaño de la ventana TCP, mientras que las pruebas unitarias pasarán e incluso se perseguirá el tráfico:
// window size auto wscale_ratio = options().wscale_dst - options().wscale_src; if (wscale_ratio < 0) { - auto window_size = header.window_size() / (1 << (-wscale_ratio)); + auto window_size = header.window_size().native() / (1 << (-wscale_ratio)); if (header.window_size() && window_size < 1) { window_size = WINDOW_SIZE_MIN; } header_out.window_size(window_size); } else { - auto window_size = header.window_size() * (1 << (wscale_ratio)); + auto window_size = header.window_size().native() * (1 << (wscale_ratio)); if (window_size > WINDOW_SIZE_MAX) { window_size = WINDOW_SIZE_MAX; }
Ejemplo de un error lógico: el desarrollador del código original pensó que la función acepta BE, aunque en realidad no lo es. Al intentar usar
Raw{}
lugar de
0
programa simplemente no compiló (afortunadamente, esto es solo una prueba unitaria). Inmediatamente vemos una elección de datos fallida: el error se habría encontrado antes si no se hubiera utilizado 0, que es el mismo en cualquier orden de bytes.
- auto cookie = cookie_make_inner(tuple, rte_be_to_cpu_32(0)); + auto cookie = cookie_make_inner(tuple, 0);
Un ejemplo similar: primero, el compilador señaló la falta de coincidencia entre los tipos
def_seq
y
cookie
, luego quedó claro por qué la prueba pasó antes: tales constantes.
- const uint32_t def_seq = 0xA7A7A7A7; - const uint32_t def_ack = 0xA8A8A8A8; + const Seqnum def_seq{0x12345678}; + const Seqnum def_ack{0x90abcdef}; ... - auto cookie = rte_be_to_cpu_32(_tcph->th_ack); + auto cookie = _tcph->th_ack; ASSERT_NE(def_seq, cookie);
Resumen
La conclusión es:
- Encontró un error y varios errores lógicos en las pruebas unitarias.
- La refactorización me hizo ordenar lugares dudosos, la legibilidad aumentó.
- El rendimiento se ha conservado, pero podría haber disminuido: se necesitan puntos de referencia.
Los tres puntos son importantes para nosotros, por lo que creemos que la refactorización valió la pena.
¿Se asegura contra errores con tipos estrictos?