Typensicheres Arbeiten mit Registern ohne Overhead in C ++ 17: Wertebasierte Metaprogrammierung

Aufgrund seiner strengen Typisierung kann C ++ dem Programmierer bei der Kompilierung helfen. Es gibt bereits eine ganze Reihe von Artikeln im Hub, in denen beschrieben wird, wie dies mithilfe von Typen erreicht werden kann, und das ist in Ordnung. Aber bei allem, was ich lese, gibt es einen Fehler. Vergleichen Sie den ++ - und den C-Ansatz mit CMSIS, die in der Welt der Mikrocontroller-Programmierung bekannt sind:


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; 

Es ist sofort klar, dass der C ++ - Ansatz besser lesbar ist, und da jede Funktion einen bestimmten Typ annimmt, kann man sich nicht irren. Der C-Ansatz überprüft nicht die Gültigkeit der Daten, sondern liegt beim Programmierer. Ein Fehler wird in der Regel nur beim Debuggen erkannt. Der c ++ - Ansatz ist jedoch nicht kostenlos. Tatsächlich hat jede Funktion einen eigenen Zugriff auf das Register, während in C die Maske zuerst von allen Parametern in der Kompilierungsphase gesammelt wird, da dies alles Konstanten sind und sofort in das Register geschrieben werden. Als nächstes werde ich beschreiben, wie ich versucht habe, Typensicherheit mit ++ zu kombinieren, um den Zugriff auf Groß- und Kleinschreibung zu minimieren. Sie werden sehen, es ist viel einfacher als es sich anhört.


Zuerst gebe ich ein Beispiel dafür, wie es aussehen soll. Es ist wünschenswert, dass dies nicht viel vom bereits bekannten C ++ - Ansatz abweicht.


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

Jeder Parameter in der set-Methode ist ein separater Typ, anhand dessen Sie verstehen, in welches Register Sie den Wert schreiben möchten. Dies bedeutet, dass Sie während der Kompilierung den Zugriff auf die Register optimieren können. Die Methode ist variabel, daher kann es eine beliebige Anzahl von Argumenten geben, es muss jedoch überprüft werden, ob alle Argumente zu dieser Peripherie gehören.


Früher schien mir diese Aufgabe ziemlich kompliziert, bis ich auf dieses Video über wertbasierte Metaprogrammierung stieß . Mit diesem Ansatz zur Metaprogrammierung können Sie verallgemeinerte Algorithmen so schreiben, als wäre es gewöhnlicher Plus-Code. In diesem Artikel werde ich nur das Nötigste des Videos geben, um das Problem zu lösen, es gibt viel allgemeinere Algorithmen.


Ich werde das Problem abstrakt lösen, nicht für eine bestimmte Peripherie. Es gibt also mehrere Registerfelder, die ich als Aufzählungen bedingt schreiben werde.


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

Die ersten drei beziehen sich auf eine Peripherie, die vierte auf eine andere. Wenn Sie also den Wert der vierten Aufzählung in die Methode der ersten Peripherie eingeben, sollte ein Kompilierungsfehler vorliegen, der möglichst verständlich ist. Außerdem beziehen sich die ersten beiden Einträge auf ein Register, der dritte auf ein anderes.


Da die Werte der Aufzählungen nichts außer den tatsächlichen Werten speichern, wird ein zusätzlicher Typ benötigt, der beispielsweise eine Maske speichert, um zu bestimmen, in welchen Teil des Registers diese Aufzählung geschrieben wird.


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

Es bleibt zu verbinden diese 2 Arten. Hier ist der Chip bereits für 20 Standards nützlich, aber es ist ziemlich trivial und Sie können es selbst implementieren.


 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 

Im Endeffekt können Sie einen Wert von einem beliebigen Typ erstellen und ihn als Argument an die Funktion übergeben. Dies ist der Hauptbestandteil des wertebasierten Metaprogrammierungsansatzes, bei dem Sie versuchen sollten, Typinformationen über Werte und nicht als Vorlagenparameter zu übergeben. Hier habe ich ein Makro definiert, aber ich bin ein Gegner von ihnen in c ++. Er lässt aber weniger weiter schreiben. Als nächstes werde ich einer Funktion und einem anderen Makro eine Verknüpfungsaufzählung und ihre Eigenschaften geben, mit denen die Anzahl der Einfügevorgänge reduziert werden kann.


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

Es ist notwendig, die Felder den entsprechenden Registern zuzuordnen. Ich habe die Beziehung durch Vererbung ausgewählt, da der Standard bereits die std::is_base_of , mit der Sie die Beziehung zwischen Feldern und Registern bereits in verallgemeinerter Form definieren können. Sie können nicht von Aufzählungen erben, daher erben wir von ihren Eigenschaften.


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

Die Adresse, an der sich das Register befindet, wird als Versatz vom Beginn der Peripherie gespeichert.


Bevor die Peripherie beschrieben wird, muss über die Typenliste der wertebasierten Metaprogrammierung gesprochen werden. Dies ist eine recht einfache Struktur, mit der Sie mehrere Typen speichern und als Wert übergeben können. Ein bisschen wie type_identity , aber für ein paar Typen.


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

Sie können für diese Liste viele constexpr-Funktionen implementieren. Ihre Implementierung ist viel einfacher zu verstehen als die bekannten Alexandrescu-Typenlisten (Loki-Bibliothek). Das Folgende sind Beispiele.


Die zweite wichtige Eigenschaft der Peripherie sollte die Fähigkeit sein, sie an einer bestimmten Adresse (im Mikrocontroller) zu lokalisieren und die Adresse für Tests dynamisch weiterzuleiten. Daher ist die Struktur der Peripherie Boilerplate und als Parameter ein Typ, der eine bestimmte Adresse der Peripherie im Wertefeld speichert. Der Template-Parameter wird vom Konstruktor festgelegt. Nun, die Set-Methode, die zuvor erwähnt wurde.


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

Die Methode set ruft lediglich eine freie Funktion auf, die alle für den verallgemeinerten Algorithmus erforderlichen Informationen übergibt.


Ich werde Beispiele für Typen nennen, die der Peripherie eine Adresse geben.


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

Alle Informationen für den verallgemeinerten Algorithmus werden aufbereitet, es verbleibt die Implementierung. Ich werde den Text dieser Funktion geben.


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

Das Implementieren einer Funktion, die Argumente (bestimmte Registerfelder) in type_pack ist ziemlich trivial. Lassen Sie mich daran erinnern, dass die Auslassungspunkte der Vorlagentypenliste eine Liste von durch Kommas getrennten Typen anzeigen.


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

Um zu überprüfen, ob sich alle Argumente auf die übertragenen Register und damit auf bestimmte Peripheriegeräte beziehen, muss der Algorithmus all_of implementiert werden. Analog zur Standardbibliothek erhält der Algorithmus eine Typenliste und eine Prädikatfunktion als Eingabe. Wir verwenden ein Lambda als Funktion.


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

Hier wird zum ersten Mal ein Scan-Ausdruck von 17 Standard angewendet. Es ist diese Innovation, die das Leben derjenigen, die sich für Metaprogrammierung interessieren, stark vereinfacht hat. In diesem Beispiel wird die Funktion f auf jeden der Typen in der Liste Ts type_identity und in type_identity , und das Ergebnis jedes Aufrufs wird von I gesammelt.


In static_assert wird dieser Algorithmus angewendet. args_traits die in type_identity sind, werden der Reihe nach an das Lambda übergeben. Im Lambda wird die Standard-Metafunktion std :: is_base_of verwendet. Da es jedoch mehr als ein Register geben kann, wird ein Scan-Ausdruck verwendet, um es für jedes der Register gemäß der OR-Logik auszuführen. Wenn mindestens ein Argument vorhanden ist, dessen Eigenschaften für mindestens ein Register nicht grundlegend sind, funktioniert die static assert und zeigt eine eindeutige Fehlermeldung an. Es ist leicht zu verstehen, an welcher Stelle sich der Fehler befindet (er hat das falsche Argument an die Methode set ) und ihn zu beheben.


Die Implementierung des Algorithmus any_of , der später benötigt wird, ist sehr ähnlich:


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

Die nächste Aufgabe des verallgemeinerten Algorithmus besteht darin, zu bestimmen, welche Register geschrieben werden müssen. Filtern Sie dazu die ursprüngliche Liste der Register und lassen Sie nur diejenigen übrig, für die es in unserer Funktion Argumente gibt. Wir benötigen einen Filteralgorithmus, der das ursprüngliche type_pack , die Prädikatfunktion für jeden Typ aus der Liste anwendet und sie der neuen Liste hinzufügt, wenn das Prädikat true zurückgibt.


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

Zunächst wird ein Lambda beschrieben, das die Funktion eines Prädikats für einen Typ type_pack mit ihm type_pack zurückgibt, wenn das Prädikat true type_pack , oder type_pack leer, wenn das Prädikat false type_pack . Hier hilft eine weitere Neuerung der letzten Pluspunkte - constexpr if. Sein Kern ist, dass es im resultierenden Code nur einen if-Zweig gibt, der zweite wird geworfen. Und da verschiedene Typen in verschiedenen Zweigen zurückkehren, würde ohne constexpr ein Kompilierungsfehler auftreten. Das Ergebnis der Ausführung dieses Lambda für jeden Typ aus der Liste wird wiederum dank des type_pack zu einem resultierenden type_pack verkettet. Es gibt nicht genügend Überladung des Additionsoperators für type_pack . Die Implementierung ist ebenfalls recht einfach:


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

Wendet man den neuen Algorithmus auf die Liste der Register an, bleiben in der neuen Liste nur diejenigen übrig, in die die übergebenen Argumente geschrieben werden sollen.


Der nächste Algorithmus, der benötigt wird, ist foreach . Es wird einfach eine Funktion auf jeden Typ in der Liste type_identity und in type_identity . Hier wird im Scan-Ausdruck ein Komma-Operator verwendet, der alle durch ein Komma beschriebenen Aktionen ausführt und das Ergebnis der letzten Aktion zurückgibt.


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

Mit dieser Funktion können Sie zu jedem Register gelangen, in das Sie schreiben möchten. Das Lambda berechnet den Wert für das Schreiben in das Register, bestimmt die Adresse, an die Sie schreiben möchten, und schreibt direkt in das Register.


Um den Wert eines Registers zu berechnen, wird der Wert für jedes Argument, zu dem dieses Register gehört, berechnet und das Ergebnis durch ODER kombiniert.


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

Die Berechnung eines Wertes für ein bestimmtes Feld sollte nur für die Argumente durchgeführt werden, von denen dieses Register geerbt wird. Für das Argument extrahieren wir eine Maske aus ihrer Eigenschaft und bestimmen den Versatz des Werts im Register aus der Maske.


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

Sie können den Algorithmus zur Bestimmung des Maskenoffsets selbst schreiben, aber ich habe die vorhandene integrierte Funktion verwendet.


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

Die letzte Funktion, die den Wert an eine bestimmte Adresse schreibt, bleibt erhalten.


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

Um die Aufgabe zu testen, wird ein kleiner Test geschrieben:


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

Alles, was hier geschrieben wurde, wurde zusammengefügt und zu Godbolt kompiliert . Jeder kann dort mit dem Ansatz experimentieren. Es zeigt sich, dass das Ziel erreicht ist: Es gibt keine unnötigen Speicherzugriffe. Der Wert, der in die Register geschrieben werden muss, wird in der Kompilierungsphase berechnet:


 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:
Vielen Dank an alle für die Kommentare, dank ihnen habe ich den Ansatz leicht modifiziert. Sie können die neue Option hier sehen.


  • entfernte Arten von Helfern * _traits, die Maske kann direkt im Verzeichnis gespeichert werden.
     enum struct Enum1 { _0, _1, _2, _3, mask = 0b00111 }; 
  • Die Registerverbindung mit Argumenten erfolgt jetzt nicht mehr durch Vererbung, sondern als statisches Registerfeld
     static constexpr auto params = type_pack<Enum1, Enum2>{}; 
  • da die verbindung nicht mehr durch vererbung besteht, musste ich die contains funktion schreiben:
     template <class T, class...Ts> constexpr auto contains(type_pack<Ts...>, type_identity<T> v) { return ((type_identity<Ts>{} == v) or ...); } 
  • ohne überflüssige typen verschwanden alle makros
  • Ich übergebe der Methode Argumente über die Template-Parameter, um sie in einem constexpr-Kontext zu verwenden
  • jetzt ist in der set-Methode die constexpr-Logik klar von der Logik des Datensatzes selbst getrennt
     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); } } 
  • Die Funktion extract ordnet in constexpr ein Array von Werten zum Schreiben in Register zu. Die Implementierung ist der vorherigen Set-Funktion sehr ähnlich, mit der Ausnahme, dass nicht direkt in das Register geschrieben wird.
  • Ich musste eine weitere Metafunktion hinzufügen, die type_pack gemäß der Lambda-Funktion in ein Array konvertiert.
     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/de483378/


All Articles