لغة سي المضمّنة: Struct و Union (الجزء 2)
محاذير الاستخدام وبعض الأمثلة العمليّة
تعرفنا في الجزء الأول عن مفهوم كل من الـStructs و الـUnions وكيف نستخدم كل واحد منهم على حدى أو حتى بشكل متداخل، مثلاً: Union بداخل Struct. في هذا الجزء سنتعرف على تطبيقات مفيدة gih في النظم المضمّنة بلغة C، وفيه أيضاً سنتعرف على كيفية استخدام الـStructs للنفاذ إلى سجلات Registers المتحكم الصغري MCU وسنناقش بعض من مزايا ومساوئ الـ bit fields.
الـ Bit Fields باختصار
إن الـ bit fields مصممة بشكل أساسي لتوفير الذاكرة، فعوضاً عن حجز مكان في الذاكرة لكل متحول، فإنه يمكن تقسيم المكان نفسه بين حقول مختلفة. لتعريف bit field داخل Struct تستخدم علامة “:” بعد اسم الحقل يليها عدد البتات المراد.
typedef struct foo { unsigned char a:4; unsigned char b:3; unsigned char c:1; } foo_t;
ما تقوم به الـ bit fields تحديداً هو عمليات معالجة على مستوى البتات bitwise للوصول إلى قيم الحقول، فهم بالنهاية عبارة عن مواقع بالذاكرة بطول معين (foo بالمثال السابق طوله 1 بايت). إن كود الأسيمبلي assembly اللاحق يوضح كيف يتم النفاذ لحقل في “foo” :
lds r24, 0x01DB ; Load foo 1-Byte-length value from 0x01DB address to a general purpose register andi r24, 0xF0 ; Mask the value according to the field lengths (foo.a is a 4-bit-length field)
في السطر الأول تم تحميل قيمة عنوان الذاكرة في سجل استخدام عام، وفي السطر الثاني تم تطبيق قناع mask يتناسب مع طول الحقل. لذا باستخدام الـbit fields يتم توفير أماكن في الذاكرة ولكن مقابل زيادة في الأسطر البرمجية. ربما إضافة سطر برمجي واحد ليس كارثة ولكن هذه أفضل حالة، ففي بعض التركيبات المعقدة أو الخاصة فإن الحالة مختلفة، مثال: تعريف حقل field ينتمي لجزء من بايت وجزء من بايت آخر. لتوضيح ذلك سيتم تعديل تعريف “foo”:
struct { unsigned int a: 4; unsigned int b: 6; unsigned int c: 1; unsigned int d: 8; unsigned int e: 3; unsigned int f: 2; } foo;
إن الحقل “b” يحجز 4 بتات من البايت الأول (0x8001db) و بتين من البايت الثاني (0x8001dc)، وهنا سيكون الكومبايلر بحاجة لإضافة أسطر برمجية عدّة للوصول للحقل كونه مجزأ بين مكانين في الذاكرة وهذا واضح من كود الأسيمبلي لو أردنا الوصول لـ “foo.b”.
; The below is for foo.b = foo.b + d; a84: 8f 73 andi r24, 0x3F ; 63 a86: 86 5f subi r24, 0xF6 ; 246 a88: 98 2f mov r25, r24 a8a: 92 95 swap r25 a8c: 90 7f andi r25, 0xF0 ; 240 a8e: 40 91 db 01 lds r20, 0x01DB ; 0x8001db <foo> a92: 4f 70 andi r20, 0x0F ; 15 a94: 49 2b or r20, r25 a96: 40 93 db 01 sts 0x01DB, r20 ; 0x8001db <foo> a9a: 82 95 swap r24 a9c: 83 70 andi r24, 0x03 ; 3 a9e: 90 91 dc 01 lds r25, 0x01DC ; 0x8001dc <foo+0x1> aa2: 9c 7f andi r25, 0xFC ; 252 aa4: 89 2b or r24, r25 aa6: 80 93 dc 01 sts 0x01DC, r24 ; 0x8001dc <foo+0x1>
لننظر كيف كان سيكون الكود لو أن النفاذ لـ”foo.a” و بالتعريف الأقدم لـ”foo”:
; The below is for foo.a = foo.a + d; a58: 68 2f mov r22, r24 a5a: 6f 70 andi r22, 0x0F ; 15 a5c: 90 91 db 01 lds r25, 0x01DB ; 0x8001db <foo> a60: 90 7f andi r25, 0xF0 ; 240 a62: 96 2b or r25, r22 a64: 90 93 db 01 sts 0x01DB, r25 ; 0x8001db <foo>
أسطر برمجية أقل!
لذلك من المهم التنبه لكيفية تقسيم الحقول داخل الـStruct. على سبيل المثال يمكن أن نعدل تعريف “foo” السابق للتالي:
struct { unsigned int a: 4; unsigned int : 4; unsigned int b: 6; unsigned int c: 1; unsigned int : 1; unsigned int d: 8; unsigned int e: 3; unsigned int f: 2; } foo;
بالتالي يمكن إضافة pad (حشوة فارغة) لتجنب الحالات الغير مرغوبة من الأداء.
لا تثق بالكود، استمع للكومبايلر
كما شاهدنا في المثال السابق فإن التعامل مع الـbit field يحتاج يقظة، حيث لتقسيمة الحقول أثر كبير على الأداء وحجم الكود. الآن، مثالان يمكن أن يخبران لماذا المترجم/الكومبايلر لا يفهم الكود كما نريد تماماً. إن الأمثلة اللاحقة قد وردت في أسئلة في موقع Stackoverflow.
المثال الأول:
struct { unsigned char a:4; unsigned char b:8; unsigned char c:4; } foo; struct { unsigned char a:4; unsigned char b; unsigned char c:4; } FOO;
إن “FOO” و “foo” يبدوان متماثلين صحيح؟ إن قول “unsigned char b:8;” يبدو منطقياً بفهم البشر مشابه لـ “unsigned char b;” ولكن حقيقة عند طباعة حجم “FOO” و “foo” نجد حجم foo هو 2 وحجم FOO هو 3. لكن لماذا؟ هذا لأن الكومبايلر يفهم البنية الأولى على أنها:
- البايت الأول: البتات 0 إلى 3 لـ”a” والبتات من 4 إلى 7 لـ”b”.
- البايت الثاني: البتات 0 إلى 3 لبقية”b” والبتات من 4 إلى 7 لـ”c”.
ويفهم البنية الثانية على أنها:
- البايت الأول: البتات 0 إلى 3 لـ”a” والباقي غير مستخدم.
- البايت الثاني: لـ”b”.
- البايت الثالث: البتات 0 إلى 3 لـ”c”.
المثال الثاني:
struct { unsigned long a:1; unsigned long b:32; unsigned long c:1; }mystruct1; struct { unsigned long a:1; unsigned long b:31; unsigned long c:1; }mystruct2;
في كلا الـStruct نتوقع أن يكون الحجم نفسه (8 بايت)، ولكن الحقيقة أن البنية الأولى لديها البايتات التالية:
- البايتات من 0 إلى 3: من أجل unsigned long a:1
- البايتات من 4 إلى 7: من أجل unsigned long b:32
- البايتات من 8 إلى 11: من أجل unsigned long c:1
والبنية الثانية لديها البايتات التالية:
- البايتات من 0 إلى 3: من أجل unsigned long a:1 و unsigned long b:31
- البايتات من 4 إلى 7: من أجل unsigned long b:32
هل أستخدم bit fields أم لا؟
كأي حل هندسي على وجه الأرض، العلاقة لا تكون دائماً ربح-ربح وإنما ربح-خسارة. إن bit-fields توفر أماكن في ذاكرة المعطيات وتوفر طريقة أسهل للنفاذ لها عوضاً عن استخدام عمليات معالجة البتات من قبل المبرمج والتي تسمى bitwise. لكن على المقابل سيقوم المعالج/المترجم compiler بهذه المهمّة بالنيابة عنك دون أن تراها حيث سيقوم بعمليةREAD-MODIFY-WRITE في كل مرة تقوم بالنفاذ لهذا البت(مجموعة البتات) لأنه كما اتفقنا هو في متحول أكبر مشترك بين عدة متحولات أخرى ذات نوع bit-field لذلك وبشكل مخفي في كل عملية نفاذ سيتم قراءة قيمة هذا المتحول المشترك وتخزينها في مكان مؤقت من الذاكرة ثم تعديل قيمة البتات المطلوبة ومن ثم إعادة قيم البتات التي لم يتم تعديلها للمتحول المشترك(هذه العملية تسمى READ-MODIFY-WRITE). بالتالي صحيح أنه قد وفرنا مكان في الذاكرة ولكن سببنا معالجة من طرف المعالج أكثر. هذا يخلق إشكالية أخرى وخاصة عند اعتماد هذه المتحولات في الأجزاء الحساسة من الكود حيث يمكن أن تحدث مقاطعة بين عملية طلب القراءة/الكتابة وبين حدوثها فعملية القراءة من هذه المتحولات غير ذريّة non-atomic وقابلة للمقاطعة.
بالواقع هذا هو السبب الذي يجعل كثير من الناس لا يوصون باستخدام الـBit Fields لأنها مرتبطة بطريقة تعامل الكومبايلر معها وبالتالي يصبح الكود غير متوافق في بعض الأحيان عند تغيير نسخة الكومبايلر، لذلك ينصح البعض بالاعتماد على ماهو متوفر في المتحكم ذاته وهي النسخة العتادية من bit field والمسمى bit-banding عندما تسمح الفرصة. حقيقة إن الأسطر السابقة قد وردت في مقالة أخرى كانت للتعريف عن bit-banding.
أمثلة عن استخدام Structs and Unions في النظم المضمّنة
التطبيق الأول: الوصول لـ Bits وNibbles و Bytes المتحولات
typedef union { uint8_t val; struct { uint8_t _0:1; uint8_t _1:1; uint8_t _2:1; uint8_t _3:1; uint8_t _4:1; uint8_t _5:1; uint8_t _6:1; uint8_t _7:1; } bits; struct { uint8_t _0:4; uint8_t _1:4; } nibbles; } byte8_t; typedef union { uint16_t val; struct { byte8_t _1; byte8_t _0; } bytes; } byte16_t;
التطبيق الثاني: توصيف بروتوكول
في أي بروتوكول لا يستخدم المحارف ASCII فإن لكل بايت (أو أي حجم آخر) حقول بمعاني محدّدة. كمثال ليكن لدينا بروتوكول يبدأ ببايت يسمى “command” ويحوي حقل بطول بت في البداية يشير لجهة الأمر (إذا هو مُستقبل أو مُرسل) وحقل آخر بطول بتين يحوي العنوان وبقية البتات هي رقم الأمر id. إن استخدام معالجة البايت هذا على مستوى البتات قد يكون مزعجاً ولكن يمكن الاستغناء عن ذلك باستخدام Union.
union { struct { uint8_t dir:1; // bit 0 uint8_t add:2; // 1 .. 2 bits units8_t id:5; // 3 .. 7 bits } fields; uint8_t val; } CMD;
وبالتالي عند إرسال واستقبال هذا الباكيت “command” يمكن استخدام الـ Union المعرف. كود كمثال عشوائي:
CMD.fields.dir = 0; CMD.fields.add = 2; CMD.fields.id = 7; send(CMD.val); if(newcmd) { CMD.val = receive(); if(CMD.fields.add != dDeviceadd) //error }
التطبيق الثالث: النفاذ لسجلات المتحكم الصغري
إعادة تمثيل السجلات باستخدام Bitfields حيلة جيدة ووسيلة سهلة ومفيدة لنفاذ مُبسّط لسجلات المتحكمات الصغرية. هذه التقنية تُستخدم بشكل واسع في SDKs مثل: ARM cortex M3/M4 SiliconLabs’ Gecko SDK. باستخدام هذه التقنية يمكن النفاذ لكل سجل باستخدام bit field struct وباسمه. لاحقاً ستؤشر هذه البنية لعنوان السجل/الطرفية.
وفي مثال من EFM32 SDK فإنه قد تم تعريف struct من أجل طرفية الـGPIO:
typedef struct { GPIO_P_TypeDef P[6]; /**< Port configuration bits */ uint32_t RESERVED0[10]; /**< Reserved for future use **/ __IOM uint32_t EXTIPSELL; /**< External Interrupt Port Select Low Register */ __IOM uint32_t EXTIPSELH; /**< External Interrupt Port Select High Register */ __IOM uint32_t EXTIRISE; /**< External Interrupt Rising Edge Trigger Register */ __IOM uint32_t EXTIFALL; /**< External Interrupt Falling Edge Trigger Register */ __IOM uint32_t IEN; /**< Interrupt Enable Register */ __IM uint32_t IF; /**< Interrupt Flag Register */ __IOM uint32_t IFS; /**< Interrupt Flag Set Register */ __IOM uint32_t IFC; /**< Interrupt Flag Clear Register */ __IOM uint32_t ROUTE; /**< I/O Routing Register */ __IOM uint32_t INSENSE; /**< Input Sense Register */ __IOM uint32_t LOCK; /**< Configuration Lock Register */ } GPIO_TypeDef; /** @} */
لاحقاً وفي ملف ترويسة آخر سيتم تعريف كلمة GPIO مع تحويل نوع type casting من GPIO_TypeDef ليشير إلى العنوان الفيزيائي الحقيقي:
#define GPIO ((GPIO_TypeDef *) GPIO_BASE) /**< GPIO base pointer */
الآن، أي سجل يراد النفاذ لك يمكن القيام بذلك من خلال المؤشر GPIO. مثال:
void GPIO_DriveModeSet(GPIO_Port_TypeDef port, GPIO_DriveMode_TypeDef mode) { GPIO->P[port].CTRL = (GPIO->P[port].CTRL & ~(_GPIO_P_CTRL_DRIVEMODE_MASK)) | (mode << _GPIO_P_CTRL_DRIVEMODE_SHIFT); }
هذه الطريقة بالنقاذ للسجلات تم الإشارة لها في مقال منشور في ARM information Center وأيضاً في ورقة بعنوان “Representing and Manipulating Hardware in Standard C and C++” لكاتبها Dan Saks إلى جانب طرق أخرى.