چند روز پیش توی دورهمی سوم سیسوگ (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 (خط نشون) روی استک تعریف میکنی و دورهای چک میکنی ببینی الگویی که قبلاً نوشتی، پاک شده یا نه. دیگه نیازی نیست هر بار که روی استک چیزی نوشته میشه، بخوای چک کنی که پر شده یا نه ( این کار خیلی پرهزینه و سنگینه). با یه بررسی زمانبندی شده میشه خیلی راحت وضعیت رو کنترل کرد.
البته باید بدونی که این روش ممکنه نتونه سرریزهای خیلی سریع (مثل بینهایت بازگشتیها) رو ردیابی کنه، چون استک خیلی سریع پر میشه. اما برای رشدهای کوچیک و غیرمنتظرهٔ استک، این سیستم هشدار عالی جواب میده!
نویسنده شو !
سیسوگ با افتخار فضایی برای اشتراک گذاری دانش شماست. برای ما مقاله بنویسید.