Travail de type sécurisé avec des registres sans surcharge en C ++ 17: métaprogrammation basée sur les valeurs

C ++, en raison de son typage strict, peut aider le programmeur au stade de la compilation. Il y a déjà beaucoup d'articles sur le hub qui décrivent comment, en utilisant des types, pour y parvenir, et c'est très bien. Mais dans tout ce que j'ai lu, il y a un défaut. Comparez avec l'approche ++ et l'approche C en utilisant CMSIS, qui est familier dans le monde de la programmation des microcontrôleurs:


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; 

Il est immédiatement évident que l'approche C ++ est plus lisible, et comme chaque fonction prend un type spécifique, on ne peut pas se tromper. L'approche C ne vérifie pas la validité des données, elle appartient au programmeur. En règle générale, une erreur n'est reconnue que lors du débogage. Mais l'approche c ++ n'est pas gratuite. En fait, chaque fonction a son propre accès au registre, tandis qu'en C le masque est d'abord collecté à partir de tous les paramètres au stade de la compilation, car ce sont toutes des constantes, et sont écrites dans le registre à la fois. Ensuite, je vais décrire comment j'ai essayé de combiner la sécurité de type avec ++ avec la minimisation de l'accès aux cas. Vous verrez que c'est beaucoup plus simple qu'il n'y paraît.


Je vais d'abord donner un exemple de la façon dont j'aimerais qu'il ressemble. Il est souhaitable que cela ne diffère pas beaucoup de l'approche C ++ déjà familière.


 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 ); 

Chaque paramètre de la méthode set est un type distinct par lequel vous pouvez comprendre dans quel registre vous souhaitez écrire la valeur, ce qui signifie que lors de la compilation, vous pouvez optimiser l'accès aux registres. La méthode est variadique, donc il peut y avoir n'importe quel nombre d'arguments, mais il doit y avoir une vérification que tous les arguments appartiennent à cette périphérie.


Plus tôt, cette tâche m'a semblé plutôt compliquée, jusqu'à ce que je tombe sur cette vidéo sur la métaprogrammation basée sur la valeur . Cette approche de la métaprogrammation vous permet d'écrire des algorithmes généralisés comme s'il s'agissait d'un code plus ordinaire. Dans cet article, je ne donnerai que le plus nécessaire de la vidéo pour résoudre le problème, il existe des algorithmes beaucoup plus généralisés.


Je vais résoudre le problème dans un résumé, pas pour une périphérie spécifique. Donc, il y a plusieurs champs de registre, je les écrirai conditionnellement sous forme d'énumérations.


 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 }; 

Les trois premiers concerneront une périphérie, le quatrième une autre. Ainsi, si vous entrez la valeur de la quatrième énumération dans la méthode de la première périphérie, il devrait y avoir une erreur de compilation, de préférence compréhensible. De plus, les 2 premières inscriptions porteront sur un registre, la troisième sur un autre.


Étant donné que les valeurs des énumérations ne stockent en elles-mêmes rien d'autre que les valeurs réelles, un type supplémentaire est nécessaire qui stockera, par exemple, un masque pour déterminer dans quelle partie du registre cette énumération sera écrite.


 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; }; 

Reste à connecter ces 2 types. Ici, la puce est déjà utile pour 20 normes , mais elle est assez banale et vous pouvez l'implémenter vous-même.


 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 

L'essentiel est que vous pouvez créer une valeur à partir de n'importe quel type et la transmettre à la fonction comme argument. Il s'agit de la brique principale de l'approche de métaprogrammation basée sur les valeurs, dans laquelle vous devez essayer de transmettre des informations de type via des valeurs, et non en tant que paramètre de modèle. Ici, j'ai défini une macro, mais j'en suis un adversaire en c ++. Mais il autorise davantage l'écriture. Ensuite, je donnerai une énumération de liens et ses propriétés à une fonction et à une autre macro qui permet de réduire le nombre de copier-coller.


 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>{}; \ } 

Il est nécessaire d'associer les champs aux registres correspondants. J'ai choisi la relation par héritage, car le standard a déjà la métafonction std::is_base_of , qui vous permettra de définir la relation entre les champs et les registres déjà sous une forme généralisée. Vous ne pouvez pas hériter des énumérations, nous héritons donc de leurs propriétés.


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

L'adresse où se trouve le registre est stockée comme décalage par rapport au début de la périphérie.


Avant de décrire la périphérie, il est nécessaire de parler de la liste des types de métaprogrammation basée sur les valeurs. Il s'agit d'une structure assez simple qui vous permet d'enregistrer plusieurs types et de les transmettre par valeur. Un peu comme type_identity , mais pour quelques types.


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

Vous pouvez implémenter de nombreuses fonctions constexpr pour cette liste. Leur implémentation est beaucoup plus facile à comprendre que les fameuses listes de types Alexandrescu (bibliothèque Loki). Voici des exemples.


La deuxième propriété importante de la périphérie devrait être la capacité de la localiser à la fois à une adresse spécifique (dans le microcontrôleur) et de passer l'adresse dynamiquement pour les tests. Par conséquent, la structure de la périphérie sera passe-partout, et en tant que paramètre, prendre un type qui stockera une adresse spécifique de la périphérie dans le champ de valeur. Le paramètre de modèle sera déterminé à partir du constructeur. Eh bien, la méthode set, qui a été mentionnée plus tôt.


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

La méthode set ne fait qu'appeler une fonction libre, en lui transmettant toutes les informations nécessaires à l'algorithme généralisé.


Je vais donner des exemples de types qui fournissent une adresse à la périphérie.


 //    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); } }; 

Toutes les informations pour l'algorithme généralisé sont préparées, il reste à le mettre en œuvre. Je vais donner le texte de cette fonction.


 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); }); }; 

L'implémentation d'une fonction qui convertit les arguments (champs de registre spécifiques) en type_pack est assez triviale. Permettez-moi de vous rappeler que les points de suspension de la liste des types de modèles révèlent une liste de types séparés par des virgules.


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

Pour vérifier que tous les arguments se rapportent aux registres transférés, et donc à des périphériques spécifiques, il est nécessaire d'implémenter l'algorithme all_of. Par analogie avec la bibliothèque standard, l'algorithme reçoit en entrée une liste de types et une fonction de prédicat. Nous utilisons un lambda comme fonction.


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

Ici, pour la première fois, une expression de balayage de 17 standard est appliquée. C'est cette innovation qui a grandement simplifié la vie de ceux qui aiment la métaprogrammation. Dans cet exemple, la fonction f est appliquée à chacun des types de la liste Ts, en la convertissant en type_identity , et le résultat de chaque appel est collecté par I.


À l'intérieur de static_assert , cet algorithme est appliqué. args_traits enveloppé dans type_identity est transmis au lambda à son tour. À l'intérieur du lambda, la métafonction standard std :: is_base_of est utilisée, mais comme il peut y avoir plus d'un registre, une expression de balayage est utilisée pour l'exécuter pour chacun des registres selon la logique OU. Par conséquent, s'il existe au moins un argument dont les propriétés ne sont pas basiques pour au moins un registre, l' static assert fonctionnera et affichera un message d'erreur clair. Il est facile de comprendre d'où provient l'erreur (elle a transmis le mauvais argument à la méthode set ) et de la corriger.


L'implémentation de l'algorithme any_of , qui sera nécessaire ultérieurement, est très similaire:


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

La tâche suivante de l'algorithme généralisé consiste à déterminer quels registres devront être écrits. Pour ce faire, filtrez la liste initiale des registres et ne laissez que ceux pour lesquels il existe des arguments dans notre fonction. Nous avons besoin d'un algorithme de filter qui prend le type_pack origine, applique la fonction de prédicat pour chaque type de la liste et l'ajoute à la nouvelle liste si le prédicat renvoie vrai.


 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)); } 

Tout d'abord, un lambda est décrit qui exécute la fonction d'un prédicat sur un type et renvoie type_pack avec lui si le prédicat a renvoyé true, ou vide type_pack si le prédicat a renvoyé false . Une autre nouvelle fonctionnalité des derniers avantages aide ici - constexpr if. Son essence est que dans le code résultant, il n'y a qu'une seule branche if, la seconde est levée. Et puisque différents types reviennent dans différentes branches, sans constexpr, il y aurait une erreur de compilation. Le résultat de l'exécution de ce lambda pour chaque type de la liste est concaténé dans un type_pack résultant, toujours grâce à l'expression d' type_pack . Il n'y a pas suffisamment de surcharge de l'opérateur d'addition pour type_pack . Sa mise en œuvre est également assez simple:


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

En appliquant le nouvel algorithme sur la liste des registres, seuls ceux dans lesquels les arguments transférés doivent être écrits restent dans la nouvelle liste.


Le prochain algorithme qui sera nécessaire est foreach . Il applique simplement une fonction à chaque type de la liste, en l'enveloppant dans type_identity . Ici, un opérateur virgule est utilisé dans l'expression d'analyse, qui exécute toutes les actions décrites par une virgule et renvoie le résultat de la dernière action.


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

La fonction vous permet d'accéder à chacun des registres où vous souhaitez écrire. Le lambda calcule la valeur d'écriture dans le registre, détermine l'adresse où vous souhaitez écrire et écrit directement dans le registre.


Afin de calculer la valeur d'un registre, la valeur de chaque argument auquel appartient ce registre est calculée et le résultat est combiné par OR.


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

Le calcul d'une valeur pour un champ spécifique doit être effectué uniquement pour les arguments dont ce registre est hérité. Pour l'argument, nous extrayons un masque de sa propriété, déterminons le décalage de la valeur à l'intérieur du registre à partir du masque.


 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; } 

Vous pouvez écrire l'algorithme pour déterminer le décalage du masque vous-même, mais j'ai utilisé la fonction intégrée existante.


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

La dernière fonction qui écrit la valeur dans une adresse spécifique reste.


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

Pour tester la tâche, un petit test est écrit:


 // ,    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 } 

Tout ce qui est écrit ici a été combiné et compilé en godbolt . N'importe qui peut expérimenter cette approche. On voit que l'objectif est atteint: il n'y a pas d'accès mémoire inutile. La valeur qui doit être écrite dans les registres est calculée au stade de la compilation:


 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:
Merci à tous pour les commentaires, grâce à eux, j'ai légèrement modifié l'approche. Vous pouvez voir la nouvelle option ici.


  • types d'aides supprimés * _traits, le masque peut être enregistré directement dans la liste.
     enum struct Enum1 { _0, _1, _2, _3, mask = 0b00111 }; 
  • la connexion de registre avec les arguments se fait maintenant non pas par héritage, maintenant c'est un champ de registre statique
     static constexpr auto params = type_pack<Enum1, Enum2>{}; 
  • comme la connexion n'est plus par héritage, j'ai dû écrire la fonction contains:
     template <class T, class...Ts> constexpr auto contains(type_pack<Ts...>, type_identity<T> v) { return ((type_identity<Ts>{} == v) or ...); } 
  • sans types superflus toutes les macros ont disparu
  • Je passe des arguments à la méthode via les paramètres du modèle pour les utiliser dans un contexte constexpr
  • maintenant dans la méthode set, la logique constexpr est clairement séparée de la logique de l'enregistrement lui-même
     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); } } 
  • La fonction d'extraction alloue dans constexpr un tableau de valeurs pour l'écriture dans les registres. Son implémentation est très similaire à la fonction set précédente, sauf qu'elle n'écrit pas directement dans le registre.
  • J'ai dû ajouter une autre métafonction qui convertit type_pack en un tableau en fonction de la fonction 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/fr483378/


All Articles