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; };
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.
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) {
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>{});
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:
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>{})...}; }