Trabalho com segurança de tipo com registros sem sobrecarga no C ++ 17: metaprogramação baseada em valor

O C ++, devido à sua digitação estrita, pode ajudar o programador no estágio de compilação. Já existem muitos artigos no hub que descrevem como, usando tipos, conseguir isso, e isso é bom. Mas em tudo o que li, há uma falha. Compare com a abordagem ++ e a abordagem C usando o CMSIS, que é familiar no mundo da programação de microcontroladores:


some_stream.set (Direction::to_periph) SOME_STREAM->CR |= DMA_SxCR_DIR_0 .inc_memory() | DMA_SxCR_MINC_Msk .size_memory (DataSize::word16) | DMA_SxCR_MSIZE_0 .size_periph (DataSize::word16) | DMA_SxCR_PSIZE_0 .enable_transfer_complete_interrupt(); | DMA_SxCR_TCIE_Msk; 

É imediatamente óbvio que a abordagem C ++ é mais legível e, como cada função assume um tipo específico, não se pode enganar. A abordagem C não verifica a validade dos dados, fica com o programador. Como regra, um erro é reconhecido apenas durante a depuração. Mas a abordagem c ++ não é gratuita. De fato, cada função tem seu próprio acesso ao registro, enquanto em C a máscara é coletada primeiro de todos os parâmetros no estágio de compilação, uma vez que todas são constantes, e são gravadas no registro de uma só vez. A seguir, descreverei como tentei combinar segurança de tipo com ++ com minimizar o acesso a maiúsculas e minúsculas. Você verá que é muito mais simples do que parece.


Primeiro, darei um exemplo de como gostaria que fosse. É desejável que isso não seja muito diferente da já familiar abordagem C ++.


 some_stream.set( dma_stream::direction::to_periph , dma_stream::inc_memory , dma_stream::memory_size::byte16 , dma_stream::periph_size::byte16 , dma_stream::transfer_complete_interrupt::enable ); 

Cada parâmetro no método set é um tipo separado pelo qual você pode entender em qual registro deseja gravar o valor, o que significa que durante a compilação você pode otimizar o acesso aos registros. O método é variável, portanto, pode haver qualquer número de argumentos, mas deve-se verificar se todos os argumentos pertencem a essa periferia.


No início, essa tarefa me pareceu bastante complicada, até que me deparei com este vídeo sobre metaprogramação baseada em valores . Essa abordagem da metaprogramação permite escrever algoritmos generalizados como se fossem códigos comuns. Neste artigo vou dar apenas o mais necessário do vídeo para resolver o problema, existem algoritmos muito mais generalizados.


Vou resolver o problema de maneira abstrata, não para uma periferia específica. Portanto, existem vários campos de registro, eu os escreverei condicionalmente como enumerações.


 enum struct Enum1 { _0, _1, _2, _3 }; enum struct Enum2 { _0, _1, _2, _3 }; enum struct Enum3 { _0, _1, _2, _3, _4 }; enum struct Enum4 { _0, _1, _2, _3 }; 

Os três primeiros se relacionam a uma periferia, a quarta a outra. Portanto, se você inserir o valor da quarta enumeração no método da primeira periferia, deverá ocorrer um erro de compilação, de preferência compreensível. Além disso, as duas primeiras listagens se relacionam a um registro, a terceira a outra.


Como os valores das enumerações não armazenam nada, exceto os valores reais, é necessário um tipo adicional que armazene, por exemplo, uma máscara para determinar em qual parte do registro essa enumeração será gravada.


 struct Enum1_traits { static constexpr std::size_t mask = 0b00111; }; struct Enum2_traits { static constexpr std::size_t mask = 0b11000; }; struct Enum3_traits { static constexpr std::size_t mask = 0b00111; }; struct Enum4_traits { static constexpr std::size_t mask = 0b00111; }; 

Resta conectar esses dois tipos. Aqui o chip já é útil para 20 padrões , mas é bastante trivial e você pode implementá-lo você mesmo.


 template <class T> struct type_identity { using type = T; }; //    constexpr auto some_type = type_identity<Some_type>{}; //      using some_type_t = typename decltype(some_type)::type; #define TYPE(type_identity) typename decltype(type_identity)::type 

A linha inferior é que você pode criar um valor de qualquer tipo e passá-lo para a função como argumento. Esse é o principal bloco da abordagem de metaprogramação baseada em valor, na qual você deve tentar passar informações de tipo por valores, e não como um parâmetro de modelo. Aqui eu defini uma macro, mas sou um adversário delas em c ++. Mas ele permite escrever menos menos. A seguir, darei uma enumeração de link e suas propriedades para uma função e outra macro que permita reduzir o número de colas de cópias.


 constexpr auto traits(type_identity<Enum1>) { return type_identity<Enum1_traits>{}; } #define MAKE_TRAITS_WITH_MASK(enum, mask_) struct enum##_traits { \ static constexpr std::size_t mask = mask_; \ }; \ constexpr auto traits(type_identity<enum>) { \ return type_identity<enum##_traits>{}; \ } 

É necessário associar os campos aos registros correspondentes. Eu escolhi o relacionamento por herança, já que o padrão já tem a std::is_base_of , que permitirá definir o relacionamento entre campos e registros já de forma generalizada. Você não pode herdar de enumerações; portanto, herdamos de suas propriedades.


 struct Register1 : Enum1_traits, Enum2_traits { static constexpr std::size_t offset = 0x0; }; 

O endereço em que o registro está localizado é armazenado como um deslocamento desde o início da periferia.


Antes de descrever a periferia, é necessário falar sobre a lista de tipos na metaprogramação baseada em valores. Essa é uma estrutura bastante simples que permite salvar vários tipos e transmiti-los por valor. Um pouco como type_identity , mas para alguns tipos.


 template <class...Ts> struct type_pack{}; using empty_pack = type_pack<>; 

Você pode implementar muitas funções constexpr para esta lista. Sua implementação é muito mais fácil de entender do que as famosas listas de tipos Alexandrescu (biblioteca Loki). A seguir estão exemplos.


A segunda propriedade importante da periferia deve ser a capacidade de localizá-la em um endereço específico (no microcontrolador) e passar o endereço dinamicamente para testes. Portanto, a estrutura da periferia será padronizada e, como parâmetro, adote um tipo que armazene um endereço específico da periferia no campo de valor. O parâmetro do modelo será determinado pelo construtor. Bem, o método set, que foi mencionado anteriormente.


 template<class Address> struct Periph1 { Periph1(Address) {} static constexpr auto registers = type_pack<Register1, Register2>{}; template<class...Ts> static constexpr void set(Ts...args) { ::set(registers, Address::value, args...); } }; 

Tudo o que o método set faz é chamar uma função livre, passando nele todas as informações necessárias para o algoritmo generalizado.


Vou dar exemplos de tipos que fornecem um endereço para a periferia.


 //    struct Address { static constexpr std::size_t value = SOME_PERIPH_BASE; }; //    ,    struct Address { static inline std::size_t value; template<class Pointer> Address(Pointer address) { value = reinterpret_cast<std::size_t>(address); } }; 

Toda a informação para o algoritmo generalizado é preparada, resta implementá-lo. Vou dar o texto dessa função.


 template<class...Registers, class...Args> constexpr void set(type_pack<Registers...> registers, std::size_t address, Args...args) { //       ,  value based  constexpr auto args_traits = make_type_pack(traits(type_identity<Args>{})...); //              static_assert(all_of(args_traits, [](auto arg){ return (std::is_base_of_v<TYPE(arg), Registers> || ...); }), "one of arguments in set method don`t belong to periph type"); //   ,      constexpr auto registers_for_write = filter(registers, [](auto reg){ return any_of(args_traits, [](auto arg){ //       o  reg? return std::is_base_of_v<TYPE(arg), TYPE(reg)>; }); }); //           foreach(registers_for_write, [=](auto reg){ auto value = register_value(reg, args...); auto offset = decltype(reg)::type::offset; write(address + offset, value); }); }; 

A implementação de uma função que converte argumentos (campos de registro específicos) em type_pack é bastante trivial. Deixe-me lembrá-lo de que as reticências da lista de tipos de modelo revelam uma lista de tipos separados por vírgulas.


 template <class...Ts> constexpr auto make_type_pack(type_identity<Ts>...) { return type_pack<Ts...>{}; } 

Para verificar se todos os argumentos estão relacionados aos registradores transferidos e, portanto, a periféricos específicos, é necessário implementar o algoritmo all_of. Por analogia com a biblioteca padrão, o algoritmo recebe uma lista de tipos e uma função de predicado como entrada. Usamos um lambda como uma função.


 template <class F, class...Ts> constexpr auto all_of(type_pack<Ts...>, F f) { return (f(type_identity<Ts>{}) and ...); } 

Aqui, pela primeira vez, uma expressão de varredura padrão 17 é aplicada. É essa inovação que simplificou bastante a vida daqueles que gostam de metaprogramação. Neste exemplo, a função f é aplicada a cada um dos tipos na lista Ts, convertendo-a em type_identity , e o resultado de cada chamada é coletado por I.


Dentro de static_assert , esse algoritmo é aplicado. args_traits em type_identity são passados ​​para o lambda por sua vez. Dentro do lambda, a metafunção padrão std :: is_base_of é usada, mas como pode haver mais de um registro, a expressão de expansão é usada para executá-lo para cada um dos registros de acordo com a lógica OR. Como resultado, se houver pelo menos um argumento cujas propriedades não sejam básicas para pelo menos um registro, a static assert funcionará e exibirá uma mensagem de erro clara. É fácil entender de onde está o erro (ele passou o argumento errado para o método set ) e corrigi-lo.


A implementação do algoritmo any_of , que será necessária posteriormente, é muito semelhante:


 template <class F, class...Ts> constexpr auto any_of(type_pack<Ts...>, F f) { return (f(type_identity<Ts>{}) or ...); } 

A próxima tarefa do algoritmo generalizado é determinar quais registros precisarão ser gravados. Para fazer isso, filtre a lista inicial de registros e deixe apenas aqueles para os quais existem argumentos em nossa função. Precisamos de um algoritmo de filter que type_pack o type_pack original, aplique a função de predicado para cada tipo da lista e adicione-a à nova lista se o predicado retornar verdadeiro.


 template <class F, class...Ts> constexpr auto filter(type_pack<Ts...>, F f) { auto filter_one = [](auto v, auto f) { using T = typename decltype(v)::type; if constexpr (f(v)) return type_pack<T>{}; else return empty_pack{}; }; return (empty_pack{} + ... + filter_one(type_identity<Ts>{}, f)); } 

Primeiro, é descrito um lambda que executa a função de um predicado em um tipo e retorna type_pack com ele se o predicado retornou true, ou type_pack vazio se o predicado retornou false . Outro novo recurso das últimas vantagens ajuda aqui - constexpr if. Sua essência é que, no código resultante, existe apenas um se ramo, o segundo é lançado. E como tipos diferentes retornam em ramificações diferentes, sem o constexpr, haveria um erro de compilação. O resultado da execução deste lambda para cada tipo da lista é concatenado em um type_pack resultante, novamente graças à expressão de type_pack . Não há sobrecarga suficiente do operador de adição para type_pack . Sua implementação também é bastante simples:


 template <class...Ts, class...Us> constexpr auto operator+ (type_pack<Ts...>, type_pack<Us...>) { return type_pack<Ts..., Us...>{}; } 

Aplicando o novo algoritmo sobre a lista de registros, apenas aqueles nos quais os argumentos transferidos devem ser gravados são deixados na nova lista.


O próximo algoritmo que será necessário é foreach . Simplesmente aplica uma função a cada tipo na lista, envolvendo-a em type_identity . Aqui, um operador de vírgula é usado na expressão de varredura, que executa todas as ações descritas por uma vírgula e retorna o resultado da última ação.


 template <class F, class...Ts> constexpr void foreach(type_pack<Ts...>, F f) { (f(type_identity<Ts>{}), ...); } 

A função permite acessar cada um dos registros em que você deseja gravar. O lambda calcula o valor para gravar no registro, determina o endereço onde você deseja gravar e grava diretamente no registro.


Para calcular o valor de um registro, o valor de cada argumento ao qual esse registro pertence é calculado e o resultado é combinado por OR.


 template<class Register, class...Args> constexpr std::size_t register_value(type_identity<Register> reg, Args...args) { return (arg_value(reg, args) | ...); } 

O cálculo de um valor para um campo específico deve ser realizado apenas para os argumentos dos quais esse registro é herdado. Para o argumento, extraímos uma máscara de sua propriedade, determinamos o deslocamento do valor dentro do registro da máscara.


 template<class Register, class Arg> constexpr std::size_t arg_value(type_identity<Register>, Arg arg) { constexpr auto arg_traits = traits(type_identity<Arg>{}); //   ,     if constexpr (not std::is_base_of_v<TYPE(arg_traits), Register>) return 0; constexpr auto mask = decltype(arg_traits)::type::mask; constexpr auto arg_shift = shift(mask); return static_cast<std::size_t>(arg) << arg_shift; } 

Você pode escrever o algoritmo para determinar o deslocamento da máscara, mas usei a função interna existente.


 constexpr auto shift(std::size_t mask) { return __builtin_ffs(mask) - 1; } 

A última função que grava o valor em um endereço específico permanece.


 inline void write(std::size_t address, std::size_t v) { *reinterpret_cast<std::size_t*>(address) |= v; } 

Para testar a tarefa, um pequeno teste é escrito:


 // ,    volatile std::size_t arr[3]; int main() { //     ( ) //   ,         auto address = Address{arr}; auto mock_periph = Periph1{address}; //  1      //  3       3 //  4      //     0b00011001 (25) //    0b00000100 (4) mock_periph.set(Enum1::_1, Enum2::_3, Enum3::_4); // all ok // mock_periph.set(Enum4::_0); // must be compilation error } 

Tudo escrito aqui foi combinado e compilado em Godbolt . Qualquer pessoa lá pode experimentar a abordagem. Pode-se observar que o objetivo foi cumprido: não há acessos desnecessários à memória. O valor que precisa ser gravado nos registros é calculado no estágio de compilação:


 main: mov QWORD PTR Address::value[rip], OFFSET FLAT:arr or QWORD PTR arr[rip], 25 or QWORD PTR arr[rip+8], 4 mov eax, 0 ret 



PS:
Obrigado a todos pelos comentários, graças a eles, modifiquei levemente a abordagem. Você pode ver a nova opção aqui.


  • tipos de ajudantes removidos * _traits, a máscara pode ser salva diretamente na listagem.
     enum struct Enum1 { _0, _1, _2, _3, mask = 0b00111 }; 
  • registrar conexão com argumentos agora não é feita por herança, agora é um campo estático de registro
     static constexpr auto params = type_pack<Enum1, Enum2>{}; 
  • como a conexão não é mais por herança, tive que escrever a função contains:
     template <class T, class...Ts> constexpr auto contains(type_pack<Ts...>, type_identity<T> v) { return ((type_identity<Ts>{} == v) or ...); } 
  • sem tipos supérfluos, todas as macros desapareceram
  • Eu passo argumentos para o método através dos parâmetros do modelo para usá-los em um contexto constexpr
  • agora no método set a lógica constexpr é claramente separada da lógica do próprio registro
     template<auto...args> static void set() { constexpr auto values_for_write = extract(registers, args...); for (auto [value, offset] : values_for_write) { write(Address::value + offset, value); } } 
  • A função extrair aloca em constexpr uma matriz de valores para gravar nos registros. Sua implementação é muito semelhante à função set anterior, exceto que ela não grava diretamente no registro.
  • Eu tive que adicionar outra metafunção que converte type_pack em uma matriz de acordo com a função lambda.
     template <class F, class...Ts> constexpr auto to_array(type_pack<Ts...> pack, F f) { return std::array{f(type_identity<Ts>{})...}; } 

Source: https://habr.com/ru/post/pt483378/


All Articles