كيفية وميض 4 المصابيح على CortexM باستخدام C ++ 17 ، tuple وقليلا من الخيال

صحة جيدة للجميع!

عند تعليم الطلاب كيفية تطوير برامج مدمجة للميكروكنترولر في الجامعة ، استخدم C ++ وأحيانًا أقدم للطلاب المهتمين بشكل خاص بجميع أنواع المهام لتحديد الطلاب الموهوبين الذين يعانون من مرض بشكل خاص.

مرة أخرى ، تم إعطاء هؤلاء الطلاب مهمة وميض 4 مصابيح LED باستخدام لغة C ++ 17 ومكتبة C ++ القياسية ، دون توصيل مكتبات إضافية ، مثل CMSIS وملفات الرأس الخاصة بهم مع وصف لهيئات التسجيل ، وما إلى ذلك. في ROM سيكون أصغر حجم وأقل أنفقت RAM. يجب ألا يكون تحسين برنامج التحويل البرمجي أعلى من المتوسط. مترجم IAR 8.40.1.
يذهب الفائز إلى الكناري ويحصل على 5 للامتحان.

قبل ذلك ، أنا لم أحل هذه المشكلة أيضًا ، لذلك سأخبرك كيف حلها الطلاب وما حدث لي. أحذرك على الفور أنه من غير المحتمل استخدام مثل هذا الرمز في تطبيقات حقيقية ، ولهذا السبب نشرت المنشور في قسم "البرمجة غير الطبيعية" ، على الرغم من من يعرف.

شروط المهمة


هناك 4 المصابيح على المنافذ GPIOA.5 ، GPIOC.5 ، GPIOC.8 ، GPIOC.9. انهم بحاجة الى وميض. للحصول على شيء يمكن مقارنته ، أخذنا الكود المكتوب بلغة C:

void delay() { for (int i = 0; i < 1000000; ++i){ } } int main() { for(;;) { GPIOA->ODR ^= (1 << 5); GPIOC->ODR ^= (1 << 5); GPIOC->ODR ^= (1 << 8); GPIOC->ODR ^= (1 << 9); delay(); } return 0 ; } 

وظيفة delay() هنا رسمية بحتة ، وهي دورة عادية ، ولا يمكن تحسينها.
من المفترض أن المنافذ تم تكوينها بالفعل للإخراج ويتم تطبيق تسجيل الوقت عليها.
سأقول أيضًا على الفور أنه لم يتم استخدام تطبيق Bitbanging لجعل الكود محمولًا.

يأخذ هذا الرمز 8 بايت على المكدس و 256 بايت في ROM على "تحسين متوسط"
255 بايت من ذاكرة رمز للقراءة فقط
1 بايت من ذاكرة البيانات للقراءة فقط
8 بايت من ذاكرة بيانات القراءة

255 بايت نظرًا لحقيقة أن جزءًا من الذاكرة خاضعًا لجدول متجهات المقاطعة ، يدعو إلى وظائف IAR لتهيئة كتلة الفاصلة العائمة ، وجميع أنواع وظائف تصحيح الأخطاء ووظيفة __low_level_init ، حيث تم تكوين المنافذ نفسها.

لذلك ، المتطلبات الكاملة هي:

  • يجب أن تحتوي الوظيفة الرئيسية () على رمز صغير قدر الإمكان
  • لا يمكنك استخدام وحدات الماكرو
  • مترجم IAR 8.40.1 يدعم C ++ 17
  • لا يمكن استخدام ملفات رأس CMSIS مثل "#include" stm32f411xe.h "
  • يمكنك استخدام التوجيه __forceinline للوظائف المضمنة
  • مترجم متوسط ​​الأمثل

قرار الطلاب


بشكل عام ، كان هناك العديد من الحلول ، سأعرض واحدًا فقط ... ليس هو الأمثل ، لكني أعجبت به.

بما أنه لا يمكن استخدام الرؤوس ، فإن أول شيء فعله الطلاب هو فئة Gpio ، التي يجب أن تخزن رابطًا إلى سجلات المنفذ في عناوينهم. للقيام بذلك ، يستخدمون تراكب بنية ، وعلى الأرجح أخذوا الفكرة من هنا: تراكب البنية :

 class Gpio { public: __forceinline inline void Toggle(const std::uint8_t bitNum) volatile { Odr ^= bitNum ; } private: volatile std::uint32_t Moder; volatile std::uint32_t Otyper; volatile std::uint32_t Ospeedr; volatile std::uint32_t Pupdr; volatile std::uint32_t Idr; volatile std::uint32_t Odr; //    static_assert(sizeof(Gpio) == sizeof(std::uint32_t) * 6); } ; 

كما ترون ، فقد تعرفوا على الفور على فئة Gpio بسمات يجب أن تكون موجودة في عناوين السجلات المقابلة وطريقة لتبديل الحالة بعدد الأرجل:
ثم حددنا بنية GpioPin التي تحتوي على المؤشر إلى Gpio وعدد الساق:

 struct GpioPin { volatile Gpio* port ; std::uint32_t pinNum ; } ; 

ثم قاموا بعمل مجموعة من المصابيح LED الموجودة على أرجل المنفذ المحددة وتجاوزوها عن طريق استدعاء طريقة Toggle() لكل مصباح LED:

 const GpioPin leds[] = {{reinterpret_cast<volatile Gpio*>(GpioaBaseAddr), 5}, {reinterpret_cast<volatile Gpio*>(GpiocBaseAddr), 5}, {reinterpret_cast<volatile Gpio*>(GpiocBaseAddr), 9}, {reinterpret_cast<volatile Gpio*>(GpiocBaseAddr), 9} } ; struct LedsDriver { __forceinline static inline void ToggelAll() { for (auto& it: leds) { it.port->Toggle(it.pinNum); } } } ; 

حسنا ، في الواقع الكود كله:
 constexpr std::uint32_t GpioaBaseAddr = 0x4002'0000 ; constexpr std::uint32_t GpiocBaseAddr = 0x4002'0800 ; class Gpio { public: __forceinline inline void Toggle(const std::uint8_t bitNum) volatile { Odr ^= bitNum ; } private: volatile std::uint32_t Moder; volatile std::uint32_t Otyper; volatile std::uint32_t Ospeedr; volatile std::uint32_t Pupdr; volatile std::uint32_t Idr; volatile std::uint32_t Odr; } ; //    static_assert(sizeof(Gpio) == sizeof(std::uint32_t) * 6); struct GpioPin { volatile Gpio* port ; std::uint32_t pinNum ; } ; const GpioPin leds[] = {{reinterpret_cast<volatile Gpio*>(GpioaBaseAddr), 5}, {reinterpret_cast<volatile Gpio*>(GpiocBaseAddr), 5}, {reinterpret_cast<volatile Gpio*>(GpiocBaseAddr), 9}, {reinterpret_cast<volatile Gpio*>(GpiocBaseAddr), 9} } ; struct LedsDriver { __forceinline static inline void ToggelAll() { for (auto& it: leds) { it.port->Toggle(it.pinNum); } } } ; int main() { for(;;) { LedsContainer::ToggleAll() ; delay(); } return 0 ; } 


إحصائيات التعليمات البرمجية الخاصة بهم على التحسين المتوسط:
275 بايت من ذاكرة رمز للقراءة فقط
1 بايت من ذاكرة البيانات للقراءة فقط
8 بايت من ذاكرة بيانات القراءة

حل جيد ، لكنه يأخذ الكثير من الذاكرة :)

قراري


بالطبع ، قررت عدم البحث عن طرق بسيطة وقررت التصرف بطريقة جدية :).
المصابيح موجودة على منافذ مختلفة وأرجل مختلفة. أول ما تحتاج إليه هو تكوين فئة Port ، ولكن للتخلص من المؤشرات والمتغيرات التي تشغل ذاكرة الوصول العشوائي ، تحتاج إلى استخدام أساليب ثابتة. قد تبدو فئة المنفذ كما يلي:

 template <std::uint32_t addr> struct Port { //  -  }; 

كمعلمة قالب ، سيكون لها عنوان منفذ. في العنوان "#include "stm32f411xe.h" ، على سبيل المثال بالنسبة للمنفذ A ، يتم تعريفه على أنه GPIOA_BASE. لكن لا يُسمح لنا باستخدام الرؤوس ، لذلك نحتاج فقط إلى جعل ثابتنا. ونتيجة لذلك ، يمكن استخدام الفئة مثل هذا:

 constexpr std::uint32_t GpioaBaseAddr = 0x4002'0000 ; constexpr std::uint32_t GpiocBaseAddr = 0x4002'0800 ; using PortA = Port<GpioaBaseAddr> ; using PortC = Port<GpiocBaseAddr> ; 

وميض ، تحتاج إلى طريقة Toggle (const std :: uint8_t bit) ، والتي ستعمل على تبديل البت المطلوب باستخدام عملية OR حصرية. يجب أن تكون الطريقة ثابتة ، أضفها إلى الفصل:

 template <std::uint32_t addr> struct Port { //   __forceinline,        __forceinline inline static void Toggle(const std::uint8_t bitNum) { *reinterpret_cast<std::uint32_t*>(addr+20) ^= (1 << bitNum) ; //addr + 20  ODR  } }; 

Port<> ممتاز Port<> ، يمكنه تبديل حالة الساقين. يوجد مؤشر LED على ساق محددة ، لذلك من المنطقي إنشاء Pin فئة ، والذي سيكون له Port<> ورقم الساق كمعلمات القالب. نظرًا لأن نوع Port<> هو القالب ، أي مختلفة عن المنافذ المختلفة ، يمكننا فقط نقل نوع عالمي T.

 template <typename T, std::uint8_t pinNum> struct Pin { __forceinline inline static void Toggle() { T::Toggle(pinNum) ; } } ; 

من المؤسف أن نتمكن من اجتياز أي هراء من النوع T له أسلوب Toggle() وسيعمل هذا ، على الرغم من أنه من المفترض أنه يجب علينا اجتياز النوع Port<> . لحماية PortBase من هذا ، سنجعل Port<> يرث من فئة PortBase الأساسية ، وفي القالب سوف نتحقق من أن النوع الذي تم تمريره يعتمد بالفعل على PortBase . نحصل على ما يلي:

 constexpr std::uint32_t OdrAddrShift = 20U; struct PortBase { }; template <std::uint32_t addr> struct Port: PortBase { __forceinline inline static void Toggle(const std::uint8_t bit) { *reinterpret_cast<std::uint32_t*>(addr ) ^= (1 << bit) ; } }; template <typename T, std::uint8_t pinNum, class = typename std::enable_if_t<std::is_base_of<PortBase, T>::value>> //   struct Pin { __forceinline inline static void Toggle() { T::Toggle(pinNum) ; } } ; 

الآن يتم إنشاء مثيل للقالب فقط إذا كان لدى PortBase الفئة الأساسية PortBase .
من الناحية النظرية ، يمكنك بالفعل استخدام هذه الفئات ، دعونا نرى ما يحدث دون التحسين:

 using PortA = Port<GpioaBaseAddr> ; using PortC = Port<GpiocBaseAddr> ; using Led1 = Pin<PortA, 5> ; using Led2 = Pin<PortC, 5> ; using Led3 = Pin<PortC, 8> ; using Led4 = Pin<PortC, 9> ; int main() { for(;;) { Led1::Toggle(); Led2::Toggle(); Led3::Toggle(); Led4::Toggle(); delay(); } return 0 ; } 

271 بايت من ذاكرة رمز للقراءة فقط
1 بايت من ذاكرة البيانات للقراءة فقط
24 بايت من ذاكرة بيانات القراءة

أين هذه 16 بايت إضافية في RAM و 16 بايت في ROM تأتي من. لقد جاءوا من حقيقة أننا نقوم بتمرير معلمة البت إلى وظيفة Toggle (const std :: uint8_t bit) من فئة المنفذ ، ويقوم المترجم ، عند إدخال الوظيفة الرئيسية ، بحفظ 4 سجلات إضافية على الحزمة التي تمر عبرها هذه المعلمة ، ثم تستخدم هذه السجلات التي يتم فيها تخزين قيم رقم الساق لكل دبوس وعند استعادة الرئيسي استعادة هذه السجلات من المكدس. وعلى الرغم من أن هذا في جوهره نوع من العمل عديم الجدوى تمامًا ، لأن الوظائف مضمنة ، إلا أن المترجم يعمل وفقًا للمعايير تمامًا.
يمكنك التخلص من ذلك عن طريق إزالة فئة المنفذ بشكل عام ، وتمرير عنوان المنفذ كمعلمة قالب للفئة Pin ، وداخل طريقة Toggle() ، احسب عنوان سجل ODR:

 constexpr std::uint32_t OdrAddrShift = 20U; template <std::uint32_t addr, std::uint8_t pinNum, struct Pin { __forceinline inline static void Toggle() { *reinterpret_cast<std::uint32_t*>(addr + OdrAddrShift ) ^= (1 << bit) ; } } ; using Led1 = Pin<GpioaBaseAddr, 5> ; 

ولكن هذا لا يبدو جيدا جدا وسهلة الاستعمال. لذلك ، نأمل أن يقوم المحول البرمجي بإزالة الحفاظ على السجل غير الضروري مع تحسين بسيط.

نضع التحسين على متوسط ​​ونرى النتيجة:
251 بايت من ذاكرة رمز للقراءة فقط
1 بايت من ذاكرة البيانات للقراءة فقط
8 بايت من ذاكرة بيانات القراءة

واو واو واو ... لدينا 4 بايت أقل
كود sishnogo
255 بايت من ذاكرة رمز للقراءة فقط
1 بايت من ذاكرة البيانات للقراءة فقط
8 بايت من ذاكرة بيانات القراءة


كيف يمكن أن يكون هذا؟ دعونا نلقي نظرة على المجمع في المصحح لرمز C ++ (يسار) ورمز C (يمين):

صورة

يمكن أن نرى أنه ، أولاً ، قام المترجم بإجراء جميع الوظائف المدمجة ، والآن لا توجد مكالمات على الإطلاق ، وثانياً ، قام بتحسين استخدام السجلات. يمكن ملاحظة أنه في حالة رمز C ، يستخدم المترجم إما سجل R1 أو R2 لتخزين عناوين المنفذ ويقوم بعمليات إضافية في كل مرة يتم فيها تبديل البتة (احفظ العنوان في السجل إما في R1 أو في R2). في الحالة الثانية ، يستخدم السجل R1 فقط ، وبما أن آخر 3 مكالمات للتبديل تكون دائمًا من المنفذ C ، فلم تعد هناك حاجة لحفظ نفس عنوان المنفذ C في السجل. نتيجة لذلك ، يتم حفظ فريقين و 4 بايت.

ومن هنا معجزة المترجمين الحديثة :) حسنا ، حسنا. من حيث المبدأ ، يمكن للمرء أن يتوقف عند هذا الحد ، ولكن دعنا ننتقل. لا أعتقد أنه سيكون من الممكن تحسين أي شيء آخر ، على الرغم من أنه قد لا يكون صحيحًا ، إذا كانت لديك أفكار ، فاكتب في التعليقات. ولكن مع مقدار الكود في main () يمكنك العمل.

الآن أريد أن تكون جميع مصابيح LED في مكان ما في الحاوية ، ويمكنك استدعاء الأسلوب ، وتبديل كل شيء ... شيء مثل هذا:

 int main() { for(;;) { LedsContainer::ToggleAll() ; delay(); } return 0 ; } 

لن نقوم بإدخال تبديل 4 مصابيح LED في وظيفة LedsContainer :: ToggleAll ، لأنها ليست مثيرة للاهتمام :). نريد وضع مصابيح LED في حاوية ثم الانتقال إليها واستدعاء طريقة Toggle () في كل منها.

استخدم الطلاب مجموعة لتخزين المؤشرات إلى المصابيح. لكن لدي أنواع مختلفة ، على سبيل المثال: Pin<PortA, 5> ، Pin<PortC, 5> ، ولا يمكنني تخزين المؤشرات على أنواع مختلفة في صفيف. يمكنك إنشاء فئة أساسية افتراضية لكل Pin ، ولكن بعد ذلك ستظهر قائمة بالوظائف الافتراضية ولن أفلح في الفوز بالطلاب.

لذلك ، سوف نستخدم tuple. يسمح لك بتخزين كائنات من أنواع مختلفة. ستبدو هذه الحالة كما يلي:

 class LedsContainer { private: constexpr static auto records = std::make_tuple ( Pin<PortA, 5>{}, Pin<PortC, 5>{}, Pin<PortC, 8>{}, Pin<PortC, 9>{} ) ; using tRecordsTuple = decltype(records) ; } 

هناك حاوية كبيرة ، فإنه يخزن جميع المصابيح. أضف الآن طريقة ToggleAll() إليها:

 class LedsContainer { public: __forceinline static inline void ToggleAll() { //        } private: constexpr static auto records = std::make_tuple ( Pin<PortA, 5>{}, Pin<PortC, 5>{}, Pin<PortC, 8>{}, Pin<PortC, 9>{} ) ; using tRecordsTuple = decltype(records) ; } 

لا يمكنك فقط المرور عبر عناصر tuple ، نظرًا لأن عنصر tuple يجب أن يتم استلامه فقط في مرحلة التجميع. للوصول إلى عناصر tuple ، هناك طريقة للحصول على القالب. حسنًا ، أنا إذا std::get<0>(records).Toggle() ، فسيتم استدعاء طريقة Toggle() لعنصر من الفئة Pin<PortA, 5> ، إذا std::get<1>(records).Toggle() ، ثم يتم استدعاء الأسلوب Toggle() لكائن الفئة Pin<Port, 5> وهكذا ...

يمكنك مسح أنف طلابك والكتابة ببساطة:

  __forceinline static inline void ToggleAll() { std::get<0>(records).Toggle(); std::get<1>(records).Toggle(); std::get<2>(records).Toggle(); std::get<3>(records).Toggle(); } 

لكننا لا نريد تضييق المبرمج الذي سيدعم هذا الرمز والسماح له بالقيام بعمل إضافي ، وإنفاق موارد شركته ، في حالة ظهور مؤشر LED آخر. سيكون عليك إضافة الرمز في مكانين ، في المجموعة وفي هذه الطريقة - وهذا ليس جيدًا ولن يكون مالك الشركة سعيدًا جدًا. لذلك ، فإننا نتجاوز tuple باستخدام أساليب المساعد:

 class class LedsContainer { friend int main() ; public: __forceinline static inline void ToggleAll() { //    3,2,1,0    ,     visit(std::make_index_sequence<std::tuple_size<tRecordsTuple>::value>()); } private: __forceinline template<std::size_t... index> static inline void visit(std::index_sequence<index...>) { Pass((std::get<index>(records).Toggle(), true)...); //    get<3>(records).Toggle(), get<2>(records).Toggle(), get<1>(records).Toggle(), get<0>(records).Toggle() } __forceinline template<typename... Args> static void inline Pass(Args... ) {//      } constexpr static auto records = std::make_tuple ( Pin<PortA, 5>{}, Pin<PortC, 5>{}, Pin<PortC, 8>{}, Pin<PortC, 9>{} ) ; using tRecordsTuple = decltype(records) ; } 

يبدو الأمر مخيفًا ، لكني حذرت في بداية المقالة من أن طريقة shizany ليست عادية جدًا ...

كل هذا السحر من أعلى في مرحلة التجميع يقوم حرفيًا بما يلي:

 //  LedsContainer::ToggleAll() ; //   4 : Pin<Port, 9>().Toggle() ; Pin<Port, 8>().Toggle() ; Pin<PortC, 5>().Toggle() ; Pin<PortA, 5>().Toggle() ; //     Toggle() inline,   : *reinterpret_cast<std::uint32_t*>(0x40020814 ) ^= (1 << 9) ; *reinterpret_cast<std::uint32_t*>(0x40020814 ) ^= (1 << 8) ; *reinterpret_cast<std::uint32_t*>(0x40020814 ) ^= (1 << 5) ; *reinterpret_cast<std::uint32_t*>(0x40020014 ) ^= (1 << 5) ; 

المضي قدما وتجميع والتحقق من حجم الرمز دون التحسين:

الرمز الذي يجمع
 #include <cstddef> #include <tuple> #include <utility> #include <cstdint> #include <type_traits> //#include "stm32f411xe.h" #define __forceinline _Pragma("inline=forced") constexpr std::uint32_t GpioaBaseAddr = 0x4002'0000 ; constexpr std::uint32_t GpiocBaseAddr = 0x4002'0800 ; constexpr std::uint32_t OdrAddrShift = 20U; struct PortBase { }; template <std::uint32_t addr> struct Port: PortBase { __forceinline inline static void Toggle(const std::uint8_t bit) { *reinterpret_cast<std::uint32_t*>(addr + OdrAddrShift) ^= (1 << bit) ; } }; template <typename T, std::uint8_t pinNum, class = typename std::enable_if_t<std::is_base_of<PortBase, T>::value>> struct Pin { __forceinline inline static void Toggle() { T::Toggle(pinNum) ; } } ; using PortA = Port<GpioaBaseAddr> ; using PortC = Port<GpiocBaseAddr> ; //using Led1 = Pin<PortA, 5> ; //using Led2 = Pin<PortC, 5> ; //using Led3 = Pin<PortC, 8> ; //using Led4 = Pin<PortC, 9> ; class LedsContainer { friend int main() ; public: __forceinline static inline void ToggleAll() { //    3,2,1,0    ,     visit(std::make_index_sequence<std::tuple_size<tRecordsTuple>::value>()); } private: __forceinline template<std::size_t... index> static inline void visit(std::index_sequence<index...>) { Pass((std::get<index>(records).Toggle(), true)...); } __forceinline template<typename... Args> static void inline Pass(Args... ) { } constexpr static auto records = std::make_tuple ( Pin<PortA, 5>{}, Pin<PortC, 5>{}, Pin<PortC, 8>{}, Pin<PortC, 9>{} ) ; using tRecordsTuple = decltype(records) ; } ; void delay() { for (int i = 0; i < 1000000; ++i){ } } int main() { for(;;) { LedsContainer::ToggleAll() ; //GPIOA->ODR ^= 1 << 5; //GPIOC->ODR ^= 1 << 5; //GPIOC->ODR ^= 1 << 8; //GPIOC->ODR ^= 1 << 9; delay(); } return 0 ; } 


دليل المجمع ، تفكيكها كما هو مخطط لها:
صورة

نرى أن الذاكرة مبالغ فيها ، 18 بايت أكثر. المشاكل هي نفسها ، بالإضافة إلى 12 بايت أخرى. لم أفهم من أين أتوا ... ربما سيوضح شخص ما.
283 بايت من ذاكرة رمز للقراءة فقط
1 بايت من ذاكرة البيانات للقراءة فقط
24 بايت من ذاكرة بيانات القراءة

الآن نفس الشيء على "التحسين المتوسط" وها وها ... لقد حصلنا على رمز مماثل لتطبيقات C ++ في الجبهة وأكثر من C رمز الأمثل.
251 بايت من ذاكرة رمز للقراءة فقط
1 بايت من ذاكرة البيانات للقراءة فقط
8 بايت من ذاكرة بيانات القراءة

المجمع
صورة

كما ترون ، لقد فزت ، وذهبت إلى جزر الكناري ويسرني أن أرتاح في تشيليابينسك :) ، لكن الطلاب كانوا أيضًا رائعين ، لقد نجحوا في الامتحان بنجاح!

من يهتم ، الكود هنا

أين يمكنني استخدام هذا ، حسنًا ، لقد توصلت ، على سبيل المثال ، لدينا معلمات في ذاكرة EEPROM وفئة تصف هذه المعلمات (القراءة والكتابة والتهيئة إلى القيمة الأولية). الفئة عبارة عن قالب ، مثل Param<float<>> ، و Param<int<>> وتحتاج ، على سبيل المثال ، إلى إعادة تعيين جميع المعلمات إلى القيم الافتراضية. هذا هو المكان الذي يمكنك وضع كل منهم في مجموعة ، حيث أن النوع مختلف واستدعاء طريقة SetToDefault() في كل معلمة. صحيح ، إذا كان هناك 100 معلمة من هذا القبيل ، فإن ذاكرة الوصول العشوائي سوف تأكل كثيرا ، ولكن لن تعاني ذاكرة الوصول العشوائي.

ملاحظة: يجب أن أعترف أنه في أقصى حد من التحسين هذا الرمز هو نفسه في الحجم كما هو الحال في C وفي حل بلدي. وتأتي جميع جهود المبرمج لتحسين الكود إلى نفس كود المجمّع.

P.S1 شكرًا لك 0xd34df00d على النصيحة الجيدة . يمكنك تبسيط تفريغ tuple باستخدام std::apply() . ToggleAll() رمز الدالة ToggleAll() يلي:

  __forceinline static inline void ToggleAll() { std::apply([](auto... args) { (args.Toggle(), ...); }, records); } 

لسوء الحظ ، في IAR ، لم يتم تطبيق std :: application بعد في الإصدار الحالي ، ولكنه سيعمل أيضًا ، راجع التنفيذ مع std :: application

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


All Articles