به طور پیشفرض، کامپایلر برای بهینهسازی دسترسی به حافظه، اعضای یک ساختار را تراز (aligned) میکند. این کار باعث افزایش کارایی پردازنده میشود. اما گاهی سختافزارها محدودیتهای خاص خود را دارند و ممکن است نیاز باشد که ساختار دادهها بدون پدینگ (padding) ذخیره شود. برای این کار از __attribute__((packed)) استفاده میکنیم.
ممکن است این سؤال پیش بیاید که چرا از ساختار فشرده استفاده نمیکنیم، درحالیکه فضای کمتری اشغال میکند؟ پاسخ ساده است:
اگر یک مقدار ۳۲ بیتی را از یک ساختار فشرده (packed struct) بخوانیم، پردازنده ممکن است مجبور شود چندین دستورالعمل اضافه اجرا کند. در مقابل، ساختار غیرفشرده حافظه بیشتری مصرف میکند، اما دسترسی به دادههای آن سریعتر و کارآمدتر است.
برای درک بهتر اینکه چرا کامپایلر این تنظیمات را انجام میدهد، برنامهای مینویسیم که دسترسی به دادههای ۳۲ بیتی را در دو حالت تراز شده (aligned) و تراز نشده (unaligned) مقایسه کند.
برنامه زیر نحوه دسترسی به ساختارهای فشرده (packed) و غیرفشرده (unpacked) را نشان میدهد:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | /* * نمایش تشریحی فشرده و غیرفشرده. * این برنامه کار مفیدی انجام نمیدهد به جز * ایجاد لیست اسمبلی که نشان میدهد دسترسی به squareFeet در یک ساختار فشرده چقدر دشوار است*. * برای اجرا -- اجرا نکنید. بهجای آن کامپایل کنید و به * لیست اسمبلی نگاه کنید. */ #include "stm32f0xx.h" #include "stm32f0xx_nucleo.h" // مثال یک ساختار غیرفشرده struct unpackedHouse { uint8_t stories; // تعداد طبقات خانه uint8_t bedrooms; // تعداد اتاقخوابها uint32_t squareFeet; // مساحت خانه uint8_t doors; // تعداد درها uint8_t windows; // تعداد پنجرهها }; // مثال یک ساختار فشرده struct packedHouse { uint8_t stories; // تعداد طبقات خانه uint8_t bedrooms; // تعداد اتاقخوابها uint32_t squareFeet; // مساحت خانه uint8_t doors; // تعداد درها uint8_t windows; // تعداد پنجرهها } __attribute__((packed)); فضایی برای ریختن squareFeet برای //unpackedHouse volatile uint32_t unpackedFeet; فضایی برای ریختن squareFeet برای// packedHouse volatile uint32_t packedFeet; نمونهای از-- unpackedHouse مقادیر برای سهولت نمایش انتخاب شدهاند// struct unpackedHouse theUnpackedHouse = {0x01, 0x02, 0x11223344, 0x03, 0x04}; نمونهای از --packedHouse مقادیر برای سهولت نمایش انتخاب شدهاند// struct packedHouse thePackedHouse = {0x01, 0x02, 0x11223344, 0x03, 0x04}; int main(void) { unpackedFeet = theUnpackedHouse.squareFeet; // 1 packedFeet = thePackedHouse.squareFeet; // 2 for (;;) {} } |
اول بیایید به کد ایجاد شده برای گرفتن یک uint32_t تراز، نگاه کنیم (1): (توضیحات اضافه شده است)
1 2 3 4 5 6 7 8 9 10 | ;unpackedFeet = theUnpackedHouse.squareFeet; ldr r3, .L3 ; Get address of theUnpackedHouse. ldr r2, [r3, #4] ; Get data at offset 4 ; (theUnpackedHouse.squareFeet). --snip-- L3: theUnpackedHouse |
این کد از یک دستورالعمل برای گرفتن آدرس ساختار و یک دستورالعمل برای گرفتن مقدار استفاده میکند.
حالا بیایید به گرفتنِ بدون تراز نگاه کنیم(2):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | ; packedFeet = thePackedHouse.squareFeet; ldr r3, .L3+8 ; Get address of thePackedHouse. ldrh r2, [r3, #2] ; Get uint16_t at offset 2 (0x3344). ; (Byte order puts the low-order bytes first.) ldrh r3, [r3, #4] ; Get uint16_t at offset 4 (0x1122). ; (High bytes come after low.) lsls r3, r3, #16 ; r3 contains the top 1/2 of squareFeet ; in the bottom 16 bits of r3. ; Shift it left into the top half. orrs r3, r2 ; Combine the two halves. .L3: theUnpackedHouse thePackedHouse |
گرفتن یک عدد ۳۲ بیتیِ بدون تراز، بهمراتب پیچیدهتر از حالت ترازبندی شده است. در حالت ترازبندی شده، تنها یک دستورالعمل برای بهدستآوردن مقدار موردنظر کافی است. اما در حالت بدون تراز، برنامه مجبور است از چهار دستورالعمل استفاده کند!
چون دادههای ما بهجای اینکه بهصورت یک بلوک منظم در حافظه ذخیره شوند، به دو بخش تقسیم شدهاند. برای خواندن این مقدار، پردازنده باید دو بار اطلاعات را از حافظه بارگذاری کند (load) و سپس آنها را ترکیب کند تا مقدار صحیح را به دست آورد:
بعد از اینکه هر دو نیمه را گرفتیم، باید آنها را با هم ترکیب کنیم. برای این کار، برنامه از یک دستورالعمل شیفت (shift) استفاده میکند تا نیمه بالایی را به جایگاه صحیح خودش در رجیستر منتقل کند. در نهایت، با یک دستورالعمل OR منطقی، این دو نیمه را با هم ادغام میکند.
این فرایند اضافی، هر بار که میخواهیم یک عدد ۳۲ بیتی بدون تراز را بخوانیم یا ذخیره کنیم، تکرار میشود. به همین دلیل، کامپایلرها ترجیح میدهند با اضافهکردن پرکننده (padding) ساختار دادهها را ترازبندی کنند. این کار باعث میشود تا بهسادگی و با یک دستورالعمل به تمام اطلاعات دسترسی داشته باشیم.
ما میتوانیم ساختارها را با قراردادن لیست مقداردهی اولیه داخل آکولاد { } راهاندازی کنیم. بهعنوانمثال، عبارت زیر myHouse را با یک عبارت، همزمان تعریف و مقداردهی میکند:
1 2 3 4 5 6 7 8 9 10 11 12 13 | struct house { uint8_t stories; // تعداد طبقات uint8_t bedrooms; // تعداد اتاقخوابها uint32_t squareFeet; // مساحت خانه }; // ۲ طبقه، ۵ اتاقخواب، ۲۵۰۰ فوت مربع struct house myHouse = {2, 5, 2500}; |
در نسخههای ابتدایی زبان C، روشِ قراردادن لیست مقداردهی اولیه داخل آکولاد { }، تنها راه برای راهاندازی ساختارها بود. اما بعدها، با نهاییشدن استاندارد C99 در سال ۱۹۹۹، قابلیت جدیدی به نام مقداردهی اولیهی تعیینشده (designated initializer) معرفی شد. این قابلیت به شما امکان میدهد که فیلدهای یک ساختار را بهصورت جداگانه و با ذکر نامشان مقداردهی اولیه کنید. در ادامه مثالی از این روش را مشاهده میکنیم:
1 2 3 4 5 6 7 8 9 | struct house myHouse = { stories: 2, bedrooms: 5, squareFeet: 2500 }; |
فیلدها باید به همان ترتیبی که در ساختار تعریف شدهاند، مقداردهی شوند.
کامپایلر GCC یک قابلیت اضافی دارد که به شما امکان میدهد مقداردهی اولیهی تعیینشده را با روش دیگری استفاده کنید:
1 2 3 4 5 6 7 8 9 | struct house myHouse = { .stories: 2, .squareFeet: 2500, .bedrooms: 5 }; |
در این حالت، ترتیب مقداردهی میتواند با ترتیب تعریف فیلدها در ساختار متفاوت باشد.
در زبان C، برخلاف انتساب آرایه به آرایه دیگر، انتساب یک ساختار به ساختار دیگر مجاز است. در ادامه مثالی از این موضوع را مشاهده میکنیم:
1 2 3 4 5 6 7 8 9 10 | int array1[5]; // An array int array2[5]; // Another array array1 = array2; // Illegal struct example { int array[5]; // Array inside a structure }; struct example struct1; // A structure struct example struct2; // Another structure // Initialize structure 2 struct1 = struct2; // Structure assignment allowed |
در مثال بالا، اگر struct1 و struct2 آرایه بودند، انتساب struct1 = struct2 غیرمجاز میبود. اما ازآنجاییکه آنها ساختار هستند، این انتساب کاملاً مجاز و صحیح است.
یکی از روشهای ارسال آرگومان در زبان C، فراخوانی بر اساس مقدار (call by value) است. یعنی زمانی که پارامتری را به یک تابع میدهیم، مقدار پارامتر را در ورودی قرار دهیم با این کار یک کپی از مقدار داده ورودی ما روی استک (stack) کپی میشود. این کار برای دادههای کوچک مانند اعداد صحیح ۲ بایتی مشکلی ایجاد نمیکند، اما اکثر ساختارها کوچک نیستند و در واقع میتوانند بسیار حجیم باشند. زمانی که یک ساختار بهعنوان پارامتر فرستاده میشود، کل ساختار روی استک کپی میشود و این عملیاتی پرهزینه است. در اینجا مثالی آورده شده است:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | // یک مستطیل struct rectangle { unsigned int width; // عرض مستطیل unsigned int height; // ارتفاع مستطیل }; // گذراندن ناکارآمد پارامتر unsigned int area(const struct rectangle aRectangle) { return (aRectangle.width * aRectangle.height); } |
در اینجا، برای انجامِ «call by value»، کامپایلر باید کل ساختار aRectangle را روی استک کپی کند. برای ساختارهای بزرگتر، این کار میتواند از فضای زیادی روی استک استفاده کرده و زمان زیادی را صرف کپیکردن دادهها کند.
برای صرفهجویی در منابع، بهتر است ساختار را با استفاده از یک اشارهگر (pointer) پاس بدهیم:
1 2 3 4 5 6 7 | // Efficient parameter passing unsigned int area(const struct rectangle* const aRectangle) { return ((*aRectangle).rectangle * (*aRectangle).height); } |
در این حالت، تنها اشارهگر (که حجم کمی دارد) بهعنوان پارامتر فرستاده میشود. در کامپایلر ARM، این کار با قراردادن اشارهگر در یک رجیستر انجام میشود. این روش سریع، ساده و بدون استفاده از فضای استک است.
یکی از مزایای «call by value» این است که هر تغییری در پارامتر به فراخواننده (caller) منتقل نمیشود. اما در مثال بالا، ما هیچ تغییری در ساختار ایجاد نکردیم، پس این موضوع مشکلی ایجاد نمیکند.
هنگامی که پارامترها را بهصورت اشارهگر پاس میدهیم و در عین حال از تغییر ناخواسته مقادیر آن جلوگیری کنیم، از کلیدواژهی const برای مشخصکردن عدم مجاز بودنِ تغییر در پارامترها استفاده میکنیم.
دسترسی به اعضای یک ساختار از طریق اشاره گر:
(*rectangle).height
<-
1 2 3 4 5 6 | // Efficient parameter passing unsigned int area(const struct rectangle* const aRectangle) { return (aRectangle->rectangle * aRectangle->height); } |
در زبان C، آرایه ها و اشاره گرها به شدت به مرتبط هستند، در واقع C میتواند یک آرایه را مثل یک اشارهگر در نظر بگیرد و بالعکس.
وقتی یک آرایه را بهعنوان پارامتر به یک تابع ارسال میکنیم، C بهطور خودکار آن را به یک اشارهگر تبدیل میکند. بهعبارت دیگر، آرایه در هنگام ارسال به تابع، نامش به آدرس اولین عنصر آن تبدیل میشود.
✅ وقتی یک آرایه بهعنوان پارامتر یک تابع تعریف میشود، C پشت پرده آن را به یک اشارهگر تبدیل میکند. این یعنی تابع، به جای دریافت کل آرایه، تنها آدرس اولین عنصر آن را دریافت میکند.
✅ معمولاً گفته میشود که C از فراخوانی با مقدار (Call by Value) استفاده میکند، اما این بیان کاملاً دقیق نیست. توضیح درستتر این است که:
🔹 C از Call by Value استفاده میکند، بهجز در مورد آرایهها، که در واقع با مقدار اشارهگر (Pointer Value) ارسال میشوند.
برای درک بهتر، بیایید به سراغ ترکیب کلی برای تعریف ساختار برویم:
1 2 3 4 5 6 7 8 9 | struct [struct-name] { field1; field2; --snip-- } [var-name(s)]; |
در این روش، یک متغیر تعریف میشود اما ساختار، نامی ندارد:
1 2 3 4 5 | // A box to put our stuff into struct { uint32_t width; // عرض جعبه uint32_t height; // ارتفاع جعبه } aBox; |
نوع aBox یک ساختار بدون نام یا یک ساختار ناشناس (anonymous structure) است. زمانی که ساختار بدون نام تعریف میشود، در آن لحظه هم متغیر و هم ساختار همزمان ایجاد میشوند و ما نمیتوانیم یک بعدا در جای دیگری به ان ارجاع دهیم و و متغیرهای جدید از همان نوع ساختار بسازیم.
1 2 3 4 | struct box { uint32_t width; // عرض جعبه uint32_t height; // ارتفاع جعبه }; |
این کد یک نوع ساختار به نام box تعریف میکند؛ اما هیچ متغیری نمیسازد. از این نوع ساختار میتوانیم بعداً برای تعریف متغیر استفاده کنیم:
1 | struct box aBox; // Box to put stuff into |
همچنین میتوانیم همنام ساختار و همنام متغیر را در یک اعلان داشته باشیم:
1 2 3 4 | struct box { uint32_t width; // عرض جعبه uint32_t height; // ارتفاع جعبه } aBox; |
این کد هم یک ساختار به نام box و هم یک متغیر به نام aBox تعریف میکند.
۴. ساختار بدون نام و بدون متغیر (بیفایده!)
C یک ترفند دیگر هم در آستین دارد؛ ما میتوانیم تعریفی برای ساختار داشته باشیم که نه نام ساختار داشته باشد و نه نام متغیر:
1 2 3 4 5 | // Silly definition struct { uint32_t width; // عرض جعبه uint32_t height; // ارتفاع جعبه }; |
از آنجایی که نامی برای ساختار تعریف نشده است، تنها میتوانیم از این تعریف برای دسترسی به متغیری که در اینجا تعریف شده استفاده کنیم. البته هیچ متغیری در اینجا تعریف نشده، پس به هیچچیزی نمیتوانیم دسترسی داشته باشیم. در نتیجه، با اینکه این تعریف از نظر فنی مجاز است، اما کاملاً بیفایده است.
اتحاد (Union) در زبان C شباهت زیادی به ساختار (Struct) دارد، با این تفاوت که همه فیلدهای آن در یک مکان مشترک از حافظه ذخیره میشوند. به عبارت دیگر، همه اعضای یک اتحاد از همان فضای حافظه استفاده میکنند و مقداردهی به یک عضو، مقدار اعضای دیگر را بازنویسی میکند.
در مثال زیر، یک اتحاد (Union) تعریف شده است:
1 2 3 4 | union value { uint32_t anInteger; float aFloat; }; |
در اینجا کامپایلر ۴ بایت به value اختصاص میدهد، که میتوان آن را هم بهعنوان uint32_t و هم بهعنوان float استفاده کرد.
1 2 3 4 | union value theValue; // Define the value. theValue.anInteger = 5; // anInteger is 5. theValue.aFloat = 1.0; // Assign the field aFloat/wipe out anInteger. |
بیایید ببینیم این موضوع در عمل چگونه کار میکند:
دومین دستور انتساب، در واقع anInteger را به معادل هگزادسیِمال 0x3f800000 تغییر میدهد. این عدد صحیح به نظر بسیار عجیب میآید، اما اگر آن را به عنوان یک عدد ممیز شناور در نظر بگیریم، مقدار 1.0 را نشان میدهد.
بهترین روش هنگام کار با Union این است که مقدار را با همان فیلدی که مقداردهی شده، بازیابی کنیم. این کار به خوانایی و وضوح کد شما میافزاید و از بروز خطاهای احتمالی جلوگیری میکند. بهعنوانمثال:
1 2 3 | theValue.aFloat = 1.2; float someFloat = theValue.aFloat; // Assigns someFloat 1.2 |
هنگامی که از فیلدهای مختلف استفاده میکنید، نتیجه وابسته به معماری پردازنده خواهد بود:
1 2 3 | theValue.aFloat = 1.2; int someInt = theValue.anInteger; // Results machine-dependent |
در این حالت، مقدار someInt بستگی به موارد زیر دارد:
فرض کنید ۴ کارت شمارهدار (۱، ۲، ۳ و ۴) دارید و آنها را در ۴ محفظه قرار میدهید.
1 | 2 | 3 | 4 |
4 | 3 | 2 | 1 |
حالا اگر دو کارت اول را بردارید، در روش اول ۱ و ۲ و در روش دوم ۳ و ۴ دریافت میکنید!
همین اتفاق هنگام ذخیره دادهها در پردازندههای مختلف رخ میدهد. برخی پردازندهها از Little Endian و برخی از Big Endian استفاده میکنند.
اگر مقداری را درtheValue.anInteger قرار دهید، باید آن را فقط با theValue.anInteger بخوانید. خواندن مقدار با یک فیلد دیگر (مثلاً theValue.aFloat) در معماریهای مختلف، نتایج متفاوتی خواهد داشت و ممکن است رفتار غیرمنتظرهای ایجاد کند.
نویسنده شو !
سیسوگ با افتخار فضایی برای اشتراک گذاری دانش شماست. برای ما مقاله بنویسید.