چند روز پیش توی دورهمی سوم سیسوگ (sug3) یه ارائه در مورد مقوله مدیریت حافظه داشتم ولی خوب به دلیل کمبود تایم نشد اونظور که دوست داشتم به چالش ها وباید و نباید های مدیریت حافظه مخصوصاً در سیستمهای امبدد بپردازم، برای همین در این مقاله سعی میکنم تا جای ممکن مبحث رو بازش کنم و اونچه در مورد مدیریت حافظه میدونم رو باشما به اشتراک بذارم برای نوشتن ایم مطلب هم از منبع ۱ و منبع ۲ و منبع ۳ و البته دو سه تای دیگه که لینکشون رو الان پیدا نکردم. فرقی نمیکنه از حافظهٔ استاتیک استفاده میکنید، یا یه پشتهٔ ساده دارین یا حافظه رو بهصورت داینامیک روی هیپ مدیریت میکنید؛ در هر حال باید با دقت و وسواس جلو رفت مخصوصاً اگر میخوایم دچار باگ های عجیب و غریب نشیم.
تو دنیای امبدد، بیتوجهی و سرسری گرفتن ریسکهای مدیریت حافظه، میتونه خیلی گرون تموم بشه (خیلی گرون)!
تقریباً هر برنامهای با RAM سر و کار داره، اما این که این حافظه چطوری بین بخشهای مختلف برنامه تقسیم بشه، داستانهای زیادی داره که توی این مقاله قراره به چندتاییش بپردازیم.
توی این مطلب، قراره روشهای مختلف مدیریت حافظه رو یه دور مرور کنیم تا توی پروژههای بعدی، انتخابهای بهتری داشته باشیم.
از تخصیص کامل حافظه بهصورت استاتیک گرفته، تا استفاده از یه یا چندتا پشته و همینطور هیپ(heap)، همه رو بررسی میکنیم.
همچنین قراره ببینیم که پیادهسازی هیپ چطور ممکنه باعث تکهتکه شدن حافظه (Fragmentation) بشه و چه تأثیری روی عملکرد سیستمها داره.
اگه کل حافظه رو بهصورت استاتیک تخصیص بدیم، میتونیم دقیقاً موقع کامپایل مشخص کنیم که هر بایت از RAM قراره کجا و چطوری مصرف بشه. تو دنیای امبدد، این یه مزیت خیلی بزرگه! چون دیگه خبری از باگهای مرموز حافظه مثل memory Leak ، خطاهای تخصیص، یا اشارهگرهای سرگردان (Dangling Pointers) نیست.
خیلی از کامپایلرهای پردازندههای ۸ بیتی مثل خانوادهٔ 8051 یا PIC، دقیقاً برای همین کار طراحی شدن: یعنی همه چیز رو استاتیک مدیریت میکنن.
توی این مدل، دادهها یا global هستن، یا static (داخل فایل یا تابع)، یا local به یک تابع.
این شیوه بیشتر توی C Compiler هایی استفاده میشه که سختافزارشون پشتیبانی مناسبی برای استک نداره.
شکل زیر ساختار حافظه رو در این حالت نشون میده: بدون هیپ، بدون استک، فقط دیتاهای global و یک بلاک استاتیک برای هر تابع.
ساختار حافظه بدون هیپ، بدون استک، فقط دیتاهای global و یک بلاک استاتیک برای هر تابع
این روش یه محدودیت مهم داره: دیگه خبری از تابع بازگشتی (Recursion) یا صدا زدن تابع در تابع نیست!
مثلاً یه روال وقفه (Interrupt Handler) نمیتونه تابعی رو صدا بزنه که ممکنه همزمان از مسیر اصلی برنامه هم صدا زده بشه. در عوض، خیال برنامهنویس از بابت مشکلات تخصیص حافظه در زمان اجرا راحت میشه.
خیلی خوب میشد اگه همهٔ کامپایلرها یه گزینه داشتن که اجازه میداد بدون استفاده از استک، همهٔ حافظه رو به صورت استاتیک تخصیص بدیم.
البته باید قبول کنیم که با این کار، مقداری از انعطاف و کارایی سیستم قربانی میشه، ولی در عوض، پایداری و امنیت حافظه واقعاً زیاد میشه.
کامپایلرهای باهوشتر حتی میتونن بفهمن که دو تا تابع خاص هیچ وقت همزمان صدا زده نمیشن؛ برای همین، اجازه میدن حافظهٔ این دو تابع با هم overlap داشته باشه (یعنی یه جا رو مشترک استفاده کنن).
البته این کار یه محدودیت اضافه هم داره: دیگه نمیتونیم از function pointerها استفاده کنیم.
اگه میخوای واقعاً از حافظهٔ استاتیک لذت ببری، نباید با کارهایی مثل استفادهٔ دوباره از global data برای اهداف مختلف، محیط امن استاتیک رو خراب کنی!
البته این روش برای سیستمهای بزرگ جوابگو نیست، چون اگه بخوایم هر مسیر اجرایی احتمالی رو پوشش بدیم، مقدار وحشتناکی RAM لازم داریم و این در عمل شدنی نیست.
قدم بعدی تو مدیریت حافظه اینه که به سیستم استک اضافه کنیم. حالا دیگه به جای اینکه فقط یه بلاک حافظه برای هر تابع وجود داشته باشه، برای هر بار فراخوانی تابع باید یه بلاک جدید روی استک ساخته بشه. این بلاکها همون چیزین که بهشون Stack Frame میگیم.
استک با اجرای برنامه مدام بزرگ و کوچیک میشه. توی خیلی از برنامهها، پیشبینی اینکه “بدترین حالت” مصرف استک چقدره، موقع کامپایل ممکن نیست 🙁
تو سیستمهای چندوظیفهای (Multitasking)، هر تسک (Task) استک خودش رو داره (و شاید یه استک اضافی هم برای وقفهها).
برنامهنویس باید حسابی حواسش جمع باشه که هر استک برای کارهای خودش به اندازه کافی جا داشته باشه.
هیچ چیز بدتر از یه Stack Overflow ناگهانی نیست! مخصوصاً اگه ببینی یه استک دیگه کلی جا خالی داره که اصلاً استفاده نمیشه!
متأسفانه اکثر سیستمهای امبدد هم چیزی به اسم Virtual Memory ندارن که تسکها بتونن در صورت نیاز از حافظهٔ اشتراکی استفاده کنن.
پس طراحی درست اندازهٔ استکها یه مهارت خیلی حیاتی محسوب میشه که حیات و ممات یه پرژه بهش بستگی داره !
یه قاعدهٔ طلایی برای محاسبه سایز استک هست که میگه :
اما برای اینکه بتونیم این قانون رو درست اجرا کنیم، اول باید بدونیم استک واقعاً تا کجا رشد کرده بوده. یه روش ساده و خلاقانه برای فهمیدن این موضوع هست بهش میگیم “رنگ کردن” فضای استک.
یعنی قبل از اجرای برنامه، کل فضای استک رو با یه الگوی مشخص (مثلاً یه عدد غیر صفر) پر میکنیم. وقتی برنامه اجرا میشه و استک بزرگ و کوچیک میشه، دادههای واقعی این الگو رو کمکم پاک میکنن. بعداً با یه حلقه ساده میشه استک رو اسکن کرد و فهمید که رشد استک تا کجا پیش رفته.
(شکل زیر یه تصویر واضح از چرخهٔ عمر یک استک ساده رو نشون میده.)
چرخهٔ عمر یک استک ساده
فقط یادتون باشه « الگویی که برای نوشتن توی استک استفاده میکنید نباید صفر باشه » چون خیلی وقتا دادههایی که روی استک نوشته میشن مقدار صفر دارن، و اون موقع دیگه نمیتونین راحت تشخیص بدین کجا واقعاً استفاده شده و کجا نه.
خیلی از RTOSها یه ویژگی خوب دارن به اسم stack size tracing. اما اگه سیستم عاملت این قابلیت رو نداره، یا اصلاً از RTOS استفاده نمیکنی، خبر خوب اینه که خودت هم میتونی یه نسخه سادهشو پیادهسازی کنی.
این تکنیک هم تو فاز تست به درد میخوره (برای بهینه کردن اندازهٔ استک)، هم تو محصول نهایی میشه ازش برای هشدار زودهنگام استفاده کرد. داستان اینطوریه که یه watermark (خط نشون) روی استک تعریف میکنی و دورهای چک میکنی ببینی الگویی که قبلاً نوشتی، پاک شده یا نه. دیگه نیازی نیست هر بار که روی استک چیزی نوشته میشه، بخوای چک کنی که پر شده یا نه ( این کار خیلی پرهزینه و سنگینه). با یه بررسی زمانبندی شده میشه خیلی راحت وضعیت رو کنترل کرد.
البته باید بدونی که این روش ممکنه نتونه سرریزهای خیلی سریع (مثل بینهایت بازگشتیها) رو ردیابی کنه، چون استک خیلی سریع پر میشه. اما برای رشدهای کوچیک و غیرمنتظرهٔ استک، این سیستم هشدار عالی جواب میده!
خیلی وقتا تو برنامهها با چیزهایی سروکار داریم (مثل آبجکتها، ساختارها یا بافرها) که طول عمرشون وابسته به اجرای یه تابع خاص نیست.
این موضوع مخصوصاً تو برنامههای رویدادمحور (Event-Driven) که تو سیستمهای امبدد خیلی رایجه، کاملاً به چشم میاد. مثلاً یه رویداد باعث میشه یه آبجکت ساخته بشه و این آبجکت باید بمونه تا یه رویداد دیگه بیاد و حذفش کنه.
توی برنامههای C، مدیریت این نوع حافظه رو معمولاً با توابع malloc() و free() انجام میدیم.
به طور خلاصه، هیپ به ما این امکان رو میده که حافظهای داشته باشیم که مستقل از چرخهٔ زندگی توابع ساخته و آزاد بشه.
مدیریت استک رو کامپایلر برات انجام میده (تا اینجا خوبه)؛ ولی مدیریت هیپ کاملاً به عهدهٔ برنامهنویسه (این بده). و اینجاست که داستانهای عجیبوغریب وارد ماجرا میشن!
فرض کن تو یه جای برنامه نمیدونی که آیا یه بلاک حافظه دیگه واقعاً لازمه یا نه. اگه اشتباهی اون بلاک رو free() کنی؛ ولی هنوز یه اشارهگر دیگه بهش داشته باشی، برنامه شاید در ظاهر خوب کار کنه.
اما وای به وقتی که همون بلاک حافظه دوباره توسط یه بخش دیگهٔ برنامه اختصاص داده بشه!
اون وقت دو قسمت مختلف برنامه بدون اینکه بدونن، دادههای همدیگه رو رویهم مینویسن و اون وقته که دربهای جهنم باز میشه!
از اون طرف، اگه تصمیم بگیری از ترس این مشکلات کلاً بلاک رو آزاد نکنی، خطر Memory Leak و پر شدن حافظه تهدیدت میکنه.
چون ممکنه در ادامه هیچ راهی برای آزادسازی اون بلاک نداشته باشی (مثلاً همهٔ اشارهگرهاش از بین رفته باشن یا جای دیگهای اشاره کنن).
حالا شاید بگی خب، لااقل تو این حالت، منطق برنامه به هم نمیریزه. درسته، ولی اگه اون قسمت از کد که باعث نشت حافظه میشه، مرتب توی اجرا تکرار بشه، مقدار حافظهٔ مصرفی همینطور بیشتر و بیشتر میشه… تا جایی که در نهایت، برنامه کند یا حتی کرش کنه.
واقعیت این هست که در نهایت، این مقدار حافظهٔ فیزیکی سیستم شماست که تعیین میکنه برنامه چقدر میتونه بدون مشکل اجرا بشه.
تو دنیای دسکتاپ، یه نشت حافظهٔ کوچیک معمولاً فاجعهآمیز نیست. مثلاً اگه یه کامپایلر برای هر هزار خط کدی که کامپایل میکنه فقط ۱۰۰ بایت حافظه نشت بده، باز هم میتونه یه فایل ۱۰۰ هزار خطی رو روی یه کامپیوتر امروزی به راحتی کامپایل کنه.
چون در نهایت، وقتی برنامه تموم شد، سیستمعامل همهٔ حافظههای اختصاصدادهشده رو آزاد میکنه.
اما تو سیستمهای امبدد داستان فرق داره!
اینجا معمولاً برنامهها باید برای مدتهای طولانی (یا حتی بینهایت) بدون ریست یا خاموششدن اجرا بشن.
پس هیچ نشت حافظهای قابلقبول نیست و هر نشتی یه باگ حساب میشه که باید حتماً با اصلاح منطق برنامه برطرف بشه.
یک دردسر دیگه هم داریم بهش میگن تکهتکهشدن حافظه (Fragmentation)!
علاوه بر نشت، مشکل دیگهای هم وجود داره که این یکی دیگه با منطق برنامه هم قابلحل نیست: Fragmentation.
این مشکل ذاتاً در بیشتر پیادهسازیهای malloc() وجود داره. اما چطوری اتفاق میافته؟
وقتی برنامه بارهاوبارها حافظه رو malloc() و free() میکنه، بلاکهای حافظه به تکههای ریزودرشت تبدیل میشن. در نتیجه، حتی ممکنه کلی فضای خالی داشته باشی، ولی نتونی یه بلاک بزرگ یکپارچه برای درخواست جدیدت پیدا کنی و این یعنی خطای تخصیص حافظه، حتی وقتی حافظه آزاد هست!
با اینوجود آیا نباید تو امبدد از malloc() وfree() استفاده کنیم؟ «نه، اصلاً اینطور نیست!»
اما باید بدونی که محدودیتهای زیادی وجود داره، طوری که خیلی از برنامهنویسهای امبدد تصمیم میگیرن یا کلاً قید استفاده از malloc و free رو بزنن، یا خودشون نسخههای خاص و محدودشدهای از این توابع رو بازنویسی کنن.
حالا برای اینکه بهتر بفهمیم دقیقاً محدودیتها از کجا ناشی میشن، باید یه نگاهی به نحوهٔ کار malloc() بندازیم. البته یادت باشه: چیزی که قراره توضیح بدیم، یه پیادهسازی رایجه،
ولی استاندارد C اجبار نکرده که malloc() حتماً اینجوری ساخته بشه. پس پیادهسازیهای مختلفی ممکنه وجود داشته باشه.
هیپ در واقع یه بلوک بزرگ از حافظهست که ترکیبیه از بلوکهای کوچکتر: یه سری از این بلوکها به برنامه اختصاص داده شدن (Allocated) و یه سری هم آزاد هستن (Free).
هر بلوک (چه آزاد چه اختصاصدادهشده)، یه هدر (Header) داره که اطلاعات مربوط به خودش رو نگه میداره.
(هدر معمولاً شامل اندازهٔ بلوک و وضعیت آزاد یا اشغال بودنشه.)
تو شکل ۳، حالت اولیهٔ هیپ رو میبینی و همچنین نتیجهٔ اولین تخصیص ۱۰ بایت حافظه.
یه اشارهگر به اسم Free List Pointer همیشه به اولین بلوک آزاد اشاره میکنه.
هر بار که برنامه حافظه میخواد، این لیست دونهدونه چک میشه تا یه بلاک مناسب پیدا بشه.
بهاینترتیب، اون بلوک بزرگ اولیه که کل هیپ بود، کمکم تبدیل میشه به یه لیست از بلوکهای کوچیک و آزاد که بین بلوکهای تخصیصدادهشده پراکنده شدن و این همون جاییه که کمکم مشکل تکهتکهشدن حافظه یا همون Fragmentation شروع میشه!
شکل ۴ هیپ را پس از انجام چندین عملیات تخصیص حافظه نشان میدهد.
در سمت چپ تصویر، لیست آزاد (Free List) همچنان تنها شامل یک عنصر است. سپس، یکی از بلوکها آزاد میشود و در نتیجه، در سمت راست تصویر، لیست آزاد اکنون دارای یک عنصر دوم نیز هست. اندازهٔ بلوک آزاد موجود ۱۵ بایت است. اگر در این شرایط درخواستی برای تخصیص ۱۰ بایت حافظه انجام شود، این بلوک ۱۵ بایتی ممکن است به دو قسمت تقسیم گردد:
این بلوک باقیمانده ممکن است آنقدر کوچک باشد که هیچ درخواستی برای حافظه هرگز نتواند آن را به کار گیرد. هرچند ممکن است این بلوکهای آزاد کوچک بعداً با بلوکهای آزاد مجاور ادغام شوند، اما همچنان این خطر وجود دارد که برخی از این بلوکهای کوچک برای همیشه غیرقابلاستفاده بمانند.
البته نباید فراموش کرد که خطر تکهتکهشدن حافظه تو بعضی از آزمایشهای دانشگاهی بیش از حد جدی گرفته شده این آزمایشها معمولاً روی تخصیصهایی با اندازههای کاملاً تصادفی انجام شدند.
ولی تو دنیای واقعی، داستان یه جور دیگهست!
تو عمل، بیشتر درخواستهای حافظه توی اندازههای مشخص و محدودی اتفاق میفته. یه بررسی روی چند برنامهٔ یونیکس نشون داد که:
این یعنی تو دنیای واقعی، شانس اینکه بتونی یه بلاک آزاد با اندازهٔ دقیق موردنیازت پیدا کنی، خیلی بیشتر از چیزیه که بر پایهٔ مدلهای کاملاً تصادفی انتظار میره.
البته توی سیستمهای امبدد، تنوع اندازهٔ تخصیصهای حافظه تو یه برنامه معمولاً خیلی کمتر از چیزیه که تو دسکتاپ میبینیم. توی برنامههای امبدد، کارهایی مثل مدیریت فایل و رشتهها (String Handling) خیلی کمتر اتفاق میفته — و این دقیقاً اون حوزههایی هستن که معمولاً اندازهٔ تخصیص حافظه حسابی تغییر میکنه.
از طرف دیگه، بیشتر تخصیصهای حافظه تو امبدد مربوط به استراکچر دادهست؛ و خب سایز ساختارهای داده معمولاً تو زمان اجرا تغییر نمیکنه این یعنی تخصیصهای حافظه خیلی منظمتر و قابل پیشبینیترن. البته، هرچقدر هم الگوی درخواستهای حافظه مرتب باشه و ریسک Fragmentation کمتر بشه، بازم باید حواسمون باشه که کدهای malloc و free رو طوری بنویسیم که تکهتکهشدن حافظه رو به حداقل برسونیم.
یکی از راههای مهم برای کنترل Fragmentation حافظه، اینه که سیاست درستی برای تخصیص و آزادسازی بلوکها انتخاب کنیم.
🔸 برای تخصیص حافظه، چندتا روش معروف وجود داره:
🔸 برای مدیریت لیست بلوکهای آزاد هم دو سیاست مهم داریم:
متأسفانه، سیاستهایی که باعث کمترین میزان Fragmentation میشن (مثل Best Fit و مرتبسازی Free List بر اساس آدرس) بیشترین زمان رو برای تخصیص و آزادسازی حافظه مصرف میکنن.
پس انتخاب الگوریتم همیشه یه جور تعادل بین سرعت و بهینگیه.
اگه طراحی مکانیزم هیپ با دقت انجام بشه، میشه سیستمهایی ساخت که حتی تو برنامههای سنگین یونیکس هم فقط حدود ۱٪ از حافظه به خاطر Fragmentation از دست بره!
این مقدار، اگه همیشه ثابت بمونه، خیلی ناچیزه. ولی مشکل اینجاست که خیلی سخته پیشبینی کنیم برنامه در آینده الگوی جدیدی از درخواستهای حافظه نداشته باشه که باعث بدتر شدن وضعیت بشه.
توی بعضی پروژهها که یا پیچیدگیهای یه هیپ کامل لازم نیست، یا خطر Fragmentation اصلاً قابلقبول نیست، یه تکنیک خیلی خوب وجود داره: «اجازه میدیم حافظه تخصیص پیدا کنه، ولی دیگه آزاد (Free) نشه!»
یعنی چی؟ یعنی بعد از اینکه برنامه بخش راهاندازی یا Initialization خودش رو انجام داد، تو حلقهٔ اصلی برنامه (یا حلقهٔ هر تسک)، دیگه هیچ تخصیص حافظهٔ جدیدی انجام نمیشه. هرچی لازم بوده همون اول گرفته شده.
این تکنیک میتونه حتی با استفاده از همون malloc() معمولی هم پیاده بشه، ولی من خودم همیشه ترجیح میدم برای این کار یه نسخهٔ سفارشی داشته باشم.
استفاده از این تکنیک نسبت به تعریف همهٔ حافظهها بهصورت Global، مزیتهای دیگهای هم داره:
اگر بعداً تصمیم بگیریم که حافظهها رو آزاد کنیم (free())، اضافهکردن این قابلیت به سیستم خیلی راحتتر انجام میشه.
حالا دوباره میرسیم به روشهایی که اجازه میدن برنامه بعد از استفاده، حافظه رو آزاد کنه.
یکی از بهترین تکنیکها استفاده از پولهای حافظه (Memory Pools) یا پارتیشنهای حافظه است. در این روش، حافظه به بلوکهای با اندازهٔ ثابت تقسیم میشه.
و این باعث میشه که احتمال تکهتکهشدن حافظه (Fragmentation) عملاً به صفر برسه. ✨
Memory Pool یه جور تعادل بین تخصیص کاملاً استاتیک و هیپ عمومی ایجاد میکنه؛
چون موقع طراحی میشه این پولها رو دقیقاً متناسب با اندازهٔ درخواستهای موردنیاز برنامه تنظیم کرد.
درحالیکه malloc() و free() استاندارد باید برای هر جور استفادهای آماده باشن،
توی خیلی از سیستمهای امبدد، ما فقط یه برنامه ثابت داریم.
پس میشه هیپ یا پول حافظه رو دقیقاً برای نیازهای همون برنامه بهینه کرد — حتی اگه این تنظیمات برای برنامههای دیگه اصلاً جواب نده!
هر پول (Pool) یه آرایه از بلوکهای حافظه داره. بلوکهای استفادهنشده میتونن با هم تو یه لیست به هم وصل بشن، تا مدیریتشون راحتتر بشه. خود پولها هم بهصورت آرایه تعریف میشن. با این روش، دیگه
نیازی به اضافهکردن هدر به هر بلوک نیست، چون اندازهٔ بلوک تو هر پول ثابت و از قبل مشخصه.
شکل ۵ نشون میده که چطور درخواستهای حافظه مستقیماً به پولی که اندازهٔ بلوکش برابر با اندازهٔ درخواست (یا بزرگتر از اون) هست، هدایت میشن. اگر پولی با اندازهٔ دقیق در دسترس نبود، میریم سراغ اولین پولی که بلوکهاش کمی بزرگتر باشن. برای اینکه این سیستم خوب کار کنه، باید موقع طراحی تصمیم بگیریم:
یکی از دلایل اصلی اینکه خیلیها تو سیستمهای امبدد از پولها (Pools) برای ساختن هیپ استفاده میکنن اینه که:
با یه پیادهسازی دقیق، میشه زمان تخصیص و آزادسازی حافظه رو ثابت نگه داشت.
ولی توی هیپهای عمومی، همیشه باید از بین یه لیست متغیر از بلوکها بگردی که زمان اجراش میتونه متغیر باشه. بعضی از پیادهسازیهای malloc() باهوشتر عمل میکنن:
نویسنده شو !
سیسوگ با افتخار فضایی برای اشتراک گذاری دانش شماست. برای ما مقاله بنویسید.