فرض کنید وظیفه‌ای در FreeRTOS طراحی کرده‌اید که ساعت‌ها بدون نقص کار می‌کند، اما به محض اضافه کردن یک فراخوانی جدید یا تعریف یک متغیر محلی اضافی، پردازنده متوقف می‌شود یا به استثنای خطای سخت‌افزاری (HardFault) می‌رود. در چنین شرایطی، واکنش رایج بسیاری از برنامه‌نویسان افزایش حدسی و بی‌برنامه عمق پشته (Stack) یا ظرفیت حافظه پویا (Heap) است؛ رویکردی که بدون بررسی علت و سنجش کافی، دلیل خطا و حاشیهٔ لازم را روشن نمی‌کند. در این درس، روش‌های سنجش مبتنی بر شواهد را بررسی می‌کنیم تا بتوانید ابعاد پشته و هیپ را بر پایه رفتار واقعی برنامه تنظیم کنید. پروژه‌ای که برای این درس آماده شده (با نام gebrabit_lesson44_memory) روی میکروکنترلر STM32F303CBT6 و برد GebraBit GB100ST V2.0 پیاده‌سازی شده است. این تراشه دارای 128 KiB حافظه Flash و 40 KiB حافظه RAM (شامل 32 KiB حافظه SRAM اصلی و 8 KiB حافظه CCM RAM) است. کد پروژه با نسخه 11.2.0 هسته FreeRTOS و کامپایلر arm-none-eabi-gcc نسخه 13.2.1 با فلگ‌های `-O0`، `-g3`، `-Wall` و `-Werror` به طور کامل کامپایل شده و صحت نمادها در فایل ELF بررسی شده است. این پروژه آزمون سخت‌افزاری روی برد یا اندازه‌گیری زمانی نداشته و مقادیر آماری حافظه که در این متن مرور می‌شوند، حاصل یک مدل محاسباتی حسابی از کد منبع و فایل ELF هستند.

وضعیت اعتبارسنجی: کامپایل‌شده؛ پروژهٔ همراه با گزینه‌های -Wall -Werror بدون خطا و هشدار ساخته شده است. اجرای روی برد، آزمون رابط گرافیکی و اندازه‌گیری فیزیکی انجام نشده‌اند. شکل‌های مفهومی، مدل‌های محاسباتی و مقادیر مورد انتظار، نتیجهٔ اندازه‌گیری نیستند.

پیش‌نیاز: درس 31 و درس 43 و آشنایی با پروژهٔ پایهٔ بخش FreeRTOS. سخت‌افزار: STM32F303CBT6 روی GB100ST V2.0 و پروگرامر SWD سازگار. برای مشاهدهٔ زمان‌بندی فیزیکی از ابزار اندازه‌گیری مناسب استفاده کنید.

تفاوت پشته (Stack) و هیپ (Heap) در FreeRTOS

پیش از پیکربندی حافظه، باید مرز میان پشته اختصاصی هر وظیفه و حافظه هیپ سراسری سیستم‌عامل را به روشنی بشناسیم. در پردازنده‌های معماری ARM Cortex-M4، هر وظیفه برای نگهداری متغیرهای محلی، آدرس‌های بازگشت توابع و بافت پردازنده (Context) به فضای پشته نیاز دارد. پورت ARM_CM4F از دو ثبات پشته سخت‌افزاری استفاده می‌کند: اشاره‌گر پشته فرآیند یا PSP که برای اجرای وظایف در سطح Thread به کار می‌رود، و اشاره‌گر پشته اصلی یا MSP که برای اجرای توابع مدیریت وقفه (ISR) و استثناهای پردازنده در سطح Handler اختصاص دارد.

پشته اختصاصی هر Task و رفتار ثبات‌های PSP و MSP

هنگامی که یک وظیفه در حال اجراست، اشاره‌گر PSP به پشته اختصاصی همان وظیفه اشاره می‌کند. اگر در حین اجرای وظیفه یک وقفه رخ دهد، سخت‌افزار پردازنده پیش از پرش به ISR، به‌طور خودکار یک فریم استثنا شامل 8 ثبات پایه (R0 تا R3، R12، LR، PC و xPSR) را روی همان پشته وظیفه (PSP) ذخیره می‌کند و سپس اجرای تابع وقفه را با ثبات MSP ادامه می‌دهد. بنابراین، فضای پشته هر وظیفه باید علاوه بر متغیرهای محلی و زنجیره فراخوانی توابع، گنجایش ذخیره این فریم استثنا و سایر ثبات‌های بافت وظیفه در هنگام تعویض وظیفه (Context Switch) را داشته باشد.

تفکیک اشاره‌گرهای PSP و MSP نباید این تصور نادرست را ایجاد کند که پشته وظیفه در برابر تداخل حافظه محافظت سخت‌افزاری دارد. اگر عمق پشته یک وظیفه کمتر از نیاز آن باشد و سرریز رخ دهد، پردازنده بدون مانع روی آدرس‌های مجاور در حافظه RAM می‌نویسد. این رونویسی می‌تواند متغیرهای سراسری، داده‌های سایر وظایف یا حتی بافر پشته اصلی وقفه را تخریب کند.

حافظه Heap سیستمعامل و خطر تکه‌تکه‌شدن (Fragmentation)

حافظه هیپ FreeRTOS یک مخزن پیوسته و اشتراکی در حافظه RAM است. هنگامی که از توابع پویای سیستم‌عامل نظیر xTaskCreate یا xQueueCreate استفاده می‌کنید، هسته حافظه مورد نیاز برای بلوک کنترل وظیفه (TCB)، آرایه پشته و بافرهای صف را با فراخوانی تابع pvPortMalloc از این مخزن برمی‌دارد. چالش اصلی در استفاده از حافظه پویا، پدیده تکه‌تکه‌شدن بیرونی (External Fragmentation) است؛ وضعیتی که در اثر تخصیص و آزادسازی مکرر بلوک‌هایی با ابعاد متفاوت رخ می‌دهد. در این حالت، ممکن است مجموع بایت‌های آزاد هیپ به ظاهر کافی باشد، اما حافظه به قطعات کوچکی تقسیم شده باشد که هیچ‌کدام به تنهایی برای درخواست جدید اندازه کافی نداشته باشند.

طرح‌های پنج‌گانه Heap و سازوکار تخصیص و ادغام در heap_4

هسته FreeRTOS پنج روش متفاوت برای مدیریت هیپ ارائه می‌دهد که در فایل‌های heap_1.c تا heap_5.c در پوشه MemMang قرار دارند. در زمان کامپایل هر پروژه، تنها یکی از این پیاده‌سازی‌ها باید به پیوندزن معرفی شود:

  • طرح heap_1: این طرح امکان آزادسازی حافظه را ندارد. یک آرایه سراسری به تدریج با درخواست‌ها پر می‌شود. تابع vPortFree در آن وجود دارد، اما اگر با اشاره‌گری غیر از NULL فراخوانی شود، با خطای اعتبارسنجی سیستم متوقف می‌شود. این روش برای سیستم‌هایی مناسب است که تمام اشیای RTOS را در زمان بالا آمدن می‌سازند و در طول کار سیستم چیزی را حذف نمی‌کنند.
  • طرح heap_2: امکان آزادسازی را فراهم می‌کند و از الگوریتم Best-Fit بهره می‌برد، اما بلوک‌های آزاد مجاور را با یکدیگر ادغام نمی‌کند. اگر اندازه تخصیص‌ها در طول برنامه متغیر باشد، این طرح مستعد تکه‌تکه‌شدن حافظه است.
  • طرح heap_3: یک پوشش ساده حول توابع استاندارد malloc و free کامپایلر است و برای تأمین ایمنی، زمان‌بند هسته را حین تخصیص موقتاً به حالت تعلیق درمی‌آورد. این تعلیق تنها فراخوانی‌های pvPortMalloc را هماهنگ می‌کند؛ اگر بخشی از برنامه تابع malloc کتابخانه C را مستقیماً فراخوانی کند، این پوشش مانع تداخل نخواهد شد و باید قفل‌گذاری جداگانه‌ای اعمال شود.
  • طرح heap_4: از الگوریتم First-Fit استفاده می‌کند و مهم‌ترین ویژگی آن ادغام بلوک‌های آزاد مجاور (Coalescence) است. هنگامی که یک بلوک با vPortFree آزاد می‌شود، هسته آن را بررسی کرده و در صورت امکان با بلوک‌های آزاد قبلی یا بعدی ترکیب می‌کند تا یک قطعه بزرگتر بسازد. این سازوکار همه انواع تکه‌تکه‌شدن را حذف نمی‌کند (مثلاً اگر یک بلوک کوچک در میان دو قطعه آزاد همچنان در حال استفاده باشد)، اما برای کاربردهای معمول تعادل مناسبی برقرار می‌سازد.
  • طرح heap_5: الگوریتمی مشابه heap_4 دارد، اما به برنامه اجازه می‌دهد چند ناحیه حافظه غیرپیوسته را پیش از اولین تخصیص با تابع vPortDefineHeapRegions معرفی و به یک هیپ واحد متصل کند. این طرح در پروژه این درس استفاده نشده است.

در میکروکنترلر STM32F303CBT6، یک بلوک 8 KiB حافظه CCM RAM در آدرس 0x10000000 وجود دارد که می‌توانست با heap_5 ترکیب شود، اما کنترلر DMA به این حافظه دسترسی فیزیکی ندارد. برای جلوگیری از خطای دسترسی DMA، پروژه این درس منحصراً از heap_4 روی حافظه SRAM اصلی با ظرفیت پیکربندی‌شده 12288 بایت استفاده می‌کند.

مقایسه مقادیر مدل حسابی در مراحل تخصیص پروژه

کد زیر از فایل App/Rtos/rtos_app.c در تابع MonitorTask، فرآیند تخصیص و آزادسازی سه بلوک آزمایشی را نشان می‌دهد:

  Snapshot(0U);
  a=pvPortMalloc(256U); b=pvPortMalloc(512U); c=pvPortMalloc(256U);
  configASSERT(a!=NULL && b!=NULL && c!=NULL);
  Snapshot(1U);
  vPortFree(a); a=NULL;
  vPortFree(c); c=NULL;
  Snapshot(2U);
  vPortFree(b); b=NULL;
  Snapshot(3U);

جدول زیر مقادیر خروجی حاصل از مدل محاسباتی کد و فایل ELF (برگرفته از اسکریپت تحلیلی memory_model.py) را در این چهار مرحله نشان می‌دهد. این ارقام برآورد حسابی مدل هستند و داده ثبت‌شده روی برد واقعی محسوب نمی‌شوند:

مرحله (Stage) شرح عملیات در کد کل بایت آزاد بزرگترین بلوک آزاد کمترین مقدار ثبت‌شده تعداد بلوک آزاد
مرحله 0 پیش از تخصیص‌های تستی برنامه 12272 12272 12256 1
مرحله 1 پس از تخصیص بلوک‌های A، B و C 11224 11224 11224 1
مرحله 2 پس از آزادسازی A و C (B باقی مانده است) 11752 11488 11224 2
مرحله 3 پس از آزادسازی بلوک میانی B (ادغام کامل) 12272 12272 11224 1

توجه داشته باشید که از 12288 بایت تنظیم‌شده برای هیپ، فضای قابل استفاده اولیه 12272 بایت است، زیرا در فایل ELF این نسخه، نشانی آغاز آرایهٔ هیپ 4 بایت با مرز 8 بایتی فاصله دارد؛ 4 بایت در ابتدا برای هم‌ترازی، 8 بایت برای نشانگر انتهای فهرست و 4 بایت دیگر برای هم‌ترازی انتها کنار گذاشته می‌شود. نشانگر آغاز فهرست خارج از این آرایه قرار دارد. در مرحله 2، به دلیل باقی ماندن بلوک B، حافظه به دو بخش تقسیم شده و بزرگترین بلوک آزاد (11488 بایت) کمتر از مجموع فضای آزاد (11752 بایت) است. در مرحله 3، آزادسازی B موجب ادغام هر سه ناحیه و بازگشت بزرگترین بلوک آزاد به 12272 بایت می‌شود.

ادغام سه ناحیهٔ آزاد در heap_4 پس از آزادسازی بلوک میانی، با مقادیر مدل محاسباتی پروژه
شکل 1. مدل محاسباتی ادغام بلوک‌های مجاور در heap_4 برای نسخهٔ کامپایل‌شدهٔ این پروژه؛ اندازه‌ها شامل سربار تخصیص‌اند و طول شکل‌ها متناسب با اندازه نیست. آزادسازی B دو ناحیهٔ آزاد را به هم متصل می‌کند. این سازوکار همهٔ انواع تکه‌تکه‌شدن را حذف نمی‌کند و اعداد، اندازه‌گیری روی برد نیستند.

پایش علمی حاشیه پشته و رفتار ردپای حافظه

برای پایش حافظه در زمان اجرا، هسته FreeRTOS توابعی در اختیار ما می‌گذارد که به جای حدس زدن، امکان ارزیابی بر مبنای مقادیر را فراهم می‌سازند.

توابع پایش هیپ: تفاوت فضای آزاد کنونی، کمترین مقدار ثبت‌شده و بزرگترین بلوک

تابع xPortGetFreeHeapSize مجموع کل بایت‌های آزاد موجود در هیپ را برمی‌گرداند. تابع xPortGetMinimumEverFreeHeapSize کمترین مقدار فضای آزادی را که از زمان راه‌اندازی هیپ تجربه شده نشان می‌دهد. همچنین تابع vPortGetHeapStats آماره‌های تفصیلی‌تری شامل اندازه بزرگترین و کوچکترین بلوک آزاد را در ساختار HeapStats_t گزارش می‌کند. این سه معیار به پرسش‌های متفاوتی پاسخ می‌دهند: مجموع بایت آزاد نشان‌دهنده ظرفیت کلی است، کمترین مقدار تاریخی نشان می‌دهد در متراکم‌ترین لحظه چقدر حاشیه داشته‌اید، و بزرگترین بلوک آزاد به بررسی امکان تخصیص یک تکهٔ پیوسته کمک می‌کند؛ سربرگ و هم‌ترازی درخواست نیز باید در همان بلوک جا شوند.

تابع Snapshot در فایل App/Rtos/rtos_app.c این آماره‌ها را ذخیره می‌کند:

static void Snapshot(unsigned stage)
{
  HeapStats_t stats;
  vPortGetHeapStats(&stats);
  g_heap_samples[stage].free_bytes=xPortGetFreeHeapSize();
  g_heap_samples[stage].minimum_ever_free_bytes=xPortGetMinimumEverFreeHeapSize();
  g_heap_samples[stage].largest_free_block=stats.xSizeOfLargestFreeBlockInBytes;
  g_heap_samples[stage].smallest_free_block=stats.xSizeOfSmallestFreeBlockInBytes;
  g_heap_samples[stage].free_blocks=stats.xNumberOfFreeBlocks;
  g_heap_samples[stage].allocations=stats.xNumberOfSuccessfulAllocations;
  g_heap_samples[stage].frees=stats.xNumberOfSuccessfulFrees;
  g_heap_stage=stage;
}

سنجش ردپای پشته با uxTaskGetStackHighWaterMark

هسته FreeRTOS هنگام ساخت هر وظیفه، بافر پشته آن را با بایت مشخص 0xa5 پر می‌کند. با پیشروی برنامه، نوشتن داده‌های واقعی این بایت‌ها را تغییر می‌دهد. تابع uxTaskGetStackHighWaterMark از انتهای بافر پشته به سمت بالا حرکت کرده و تعداد کلمه‌های متوالی دست‌نخورده (دست‌نخورده با مقدار 0xa5) را می‌شمارد. خروجی این تابع بر حسب کلمه (Word) است که در پردازنده 32 بیتی Cortex-M4 هر کلمه برابر 4 بایت است.

نکته مهم در تفسیر این تابع آن است که این عدد تنها نشان‌دهنده بایت‌های دست‌نخورده در طول مسیرهایی است که تا آن لحظه عملاً اجرا شده‌اند. اگر یک تابع متغیر محلی بزرگی مانند یک آرایه رزرو کند ولی مقداری در آن ننویسد، بایت‌های 0xa5 دست‌نخورده باقی می‌مانند و این تابع آن فضا را دست‌نخورده تلقی می‌کند. بنابراین، مقدار High-Water Mark می‌تواند برآوردی خوش‌بینانه باشد و اثبات پوشش همه سناریوهای بدترین حالت نیست. برای تنظیم نهایی پشته، اجرای سناریوهای بار کاری سنگین و افزودن یک حاشیه اطمینان مهندسی الزامی است.

پشتهٔ نزولی Task با فریم‌ها، اشاره‌گر ذخیره‌شده و الگوی دست‌نخوردهٔ 0xA5
شکل 2. نمودار تألیفی پشتهٔ نزولی یک Task در پورت ARM_CM4F؛ ناحیهٔ دست‌نخوردهٔ 0xA5 با موقعیت کنونی یا ذخیره‌شدهٔ اشاره‌گر پشته یک مفهوم نیست. نگهبان‌های پایین پشته مرز حفاظتی سخت‌افزاری ایجاد نمی‌کنند.
مثال مفهومی ثابت‌ماندن کمترین تعداد کلمه‌های دست‌نخورده پس از اجرای مسیر سبک‌تر
شکل 3. مثال مفهومی رفتار ردپای پشته برای یک پشتهٔ 256 کلمه‌ای؛ مقدار تاریخی 100 کلمه معادل 400 بایتِ دست‌نخورده است و با سبک‌تر شدن مسیر بعدی افزایش نمی‌یابد. این مقادیر نمونه‌اند و دادهٔ دیباگر یا سنجش روی برد نیستند. فضای رزروشده ولی نوشته‌نشده می‌تواند برداشت از حاشیهٔ واقعی را خوش‌بینانه کند.

قلاب‌های نرم‌افزاری (Hooks): تشخیص سرریز پشته و شکست تخصیص

هسته FreeRTOS برای واکنش به کمبود حافظه یا خطای پشته، توابع قلاب (Hook) را در نظر گرفته است که هنگام وقوع شرایط تعریف‌شده، فراخوانی می‌شوند.

روش 1 و روش 2 در تشخیص سرریز پشته

با تنظیم ماکروی configCHECK_FOR_STACK_OVERFLOW در فایل FreeRTOSConfig.h، بررسی سرریز پشته فعال می‌شود:

  • روش 1 (مقدار 1): در زمان تعویض وظیفه، بررسی می‌کند که آیا اشاره‌گر ذخیره‌شدهٔ پشته به مرز پایین بافر رسیده یا از آن عبور کرده است یا خیر.
  • روش 2 (مقدار 2 – استفاده‌شده در پروژه): علاوه بر مقایسه اشاره‌گر ذخیره‌شده پشته (savedSP) با مرز بافر، بررسی می‌کند که آیا 4 کلمه گارد (16 بایت) در انتهای پشته همچنان مقدار 0xa5a5a5a5 را دارند یا دستخوش تغییر شده‌اند. اگر در زمان تعویض وظیفه، اشاره‌گر ذخیره‌شده به مرز پایین رسیده یا از آن عبور کرده باشد، حتی اگر کلمات گارد سالم مانده باشند، این روش خطا را تشخیص می‌دهد.

با این حال، روش 2 دو محدودیت ذاتی دارد: نخست آنکه اگر در حین اجرای وظیفه، اشاره‌گر پشته به شدت از مرز خارج شود و ساختارهای حیاتی پردازنده یا آدرس بازگشت توابع را تخریب کند، سیستم ممکن است بلافاصله به خطای سخت‌افزاری HardFault برود، پیش از آنکه نوبت به زمان‌بند و تعویض وظیفه برسد. دوم آنکه اگر یک تابع به صورت گذرا پشته را فراتر ببرد اما پیش از پایان برش زمانی دوباره به محدوده مجاز بازگردد و تصادفاً روی آن 4 کلمه گارد چیزی ننویسد، هسته متوجه این تخریب نخواهد شد. حفاظت سخت‌گیرانه حافظه نیازمند طراحی مجزا و بهره‌گیری از واحد حفاظت حافظه (MPU) سخت‌افزاری است.

پیاده‌سازی توابع قلاب خطا در پروژه

توابع قلاب در فایل App/Rtos/rtos_app.c به شکل زیر پیاده‌سازی شده‌اند:

void vApplicationMallocFailedHook(void)
{
  ++g_malloc_failures;
  taskDISABLE_INTERRUPTS(); g_app_error=2U;
  for (;;) { __NOP(); }
}

void vApplicationStackOverflowHook(TaskHandle_t task, char *name)
{
  /* Do not allocate, printf, block or try to repair a possibly damaged stack. */
  taskDISABLE_INTERRUPTS();
  g_overflow_task=task; g_overflow_name=name; g_app_error=3U;
  for (;;) { __NOP(); }
}

در پورت ARM_CM4F، فراخوانی taskDISABLE_INTERRUPTS ثبات BASEPRI را روی مقدار آستانه تنظیم می‌کند؛ این کار وقفه‌های وابسته به RTOS را ماسک می‌کند، اما وقفه‌های با اولویت بالاتر و استثناهای سیستمی (Faults) همچنان ممکن است فعال شوند. در داخل این توابع نباید از توابعی نظیر printf یا تخصیص مجدد حافظه استفاده کرد، زیرا پشته ممکن است آسیب دیده باشد یا حافظهٔ کافی برای تخصیص تازه وجود نداشته باشد. این توابع صرفاً متغیرهای تشخیصی (مانند g_app_error و نام وظیفه) را ثبت کرده و در حلقهٔ توقف می‌مانند تا مهندس بتواند با دیباگر علت را بازرسی کند.

روش 1 مقایسهٔ اشاره‌گر و روش 2 مقایسهٔ اشاره‌گر همراه با نگهبان‌ها و محدودیت بررسی هنگام تعویض Task
شکل 4. نمودار تألیفی دو روش بررسی سرریز در FreeRTOS 11.2.0؛ روش 2 علاوه بر مقایسهٔ اشاره‌گر ذخیره‌شده با مرز پشته، 4 کلمهٔ نگهبان را هم بررسی می‌کند. خرابی حافظه یا Fault ممکن است پیش از نوبت بررسی رخ دهد و Hook تضمین تشخیص یا بازیابی نیست.

تخصیص ایستا و مصرف RAM

در پروژه‌های حساس، وابستگی به تخصیص پویای هیپ برای اشیای اصلی سیستم‌عامل می‌تواند یک ریسک باشد. با فعال‌سازی ماکروی configSUPPORT_STATIC_ALLOCATION در FreeRTOSConfig.h، می‌توان وظایف، صف‌ها و تایمرها را با تخصیص ایستا ایجاد کرد.

پروژه این درس یک ساختار ترکیبی دارد: وظایف اصلی برنامه (Worker و Monitor) و تایمر نرم‌افزاری ضربان قلب (Heartbeat) به صورت ایستا ساخته می‌شوند، در حالی که در ابتدای تابع App_Start یک تخصیص کوچک برای راه‌اندازی هیپ انجام می‌گیرد و در وظیفه Monitor نیز بلوک‌های نمایشی A و B و C برای مشاهده رفتار ادغام، از هیپ پویا استفاده می‌کنند.

کد راه‌اندازی برنامه در فایل App/Rtos/rtos_app.c به شرح زیر است:

void App_Start(void)
{
  /* Initialize heap_4 before comparing before/after static task creation. */
  void *probe=pvPortMalloc(8U);
  configASSERT(probe!=NULL); vPortFree(probe);
  size_t before=xPortGetFreeHeapSize();
  worker_handle=xTaskCreateStatic(WorkerTask,"Worker",WORKER_STACK_WORDS,
                                 NULL,1U,worker_stack,&worker_tcb);
  TaskHandle_t monitor=xTaskCreateStatic(MonitorTask,"Memory",MONITOR_STACK_WORDS,
                                        NULL,2U,monitor_stack,&monitor_tcb);
  configASSERT(worker_handle!=NULL && monitor!=NULL);
  g_static_create_heap_delta=(uint32_t)(before-xPortGetFreeHeapSize());
  g_tcb_bytes=sizeof(StaticTask_t); g_stack_word_bytes=sizeof(StackType_t);
  g_monitor_stack_bytes=sizeof(monitor_stack); g_worker_stack_bytes=sizeof(worker_stack);
  TimerHandle_t heartbeat=xTimerCreateStatic("Heartbeat",pdMS_TO_TICKS(1000),
                          pdTRUE,NULL,HeartbeatCallback,&heartbeat_timer_buffer);
  configASSERT(heartbeat!=NULL);
  configASSERT(xTimerStart(heartbeat,0U)==pdPASS);
  vTaskStartScheduler();
  App_AssertFailed(__FILE__,__LINE__);
}

بافرهای پشته و بلوک‌های TCB در کد برنامه به عنوان متغیرهای سراسری تعریف شده‌اند و پیوندزن آنها را در بخش `.bss` قرار می‌دهد. تابع xTaskCreateStatic بافرهای آماده را تحویل می‌گیرد و هیچ فضایی از هیپ مصرف نمی‌کند؛ به همین دلیل انتظار می‌رود متغیر g_static_create_heap_delta برابر 0 باشد؛ مقدار مشاهده‌شده باید هنگام اجرای واقعی ثبت شود. با این حال، بافرهای ایستا همچنان حجم مشخصی از حافظه SRAM را اشغال می‌کنند (در این پروژه، پشته وظیفه Monitor برابر 384 کلمه معادل 1536 بایت، پشته Worker برابر 256 کلمه معادل 1024 بایت و ساختار StaticTask_t برابر 92 بایت است).

هنگامی که تخصیص ایستا فعال است و گزینه configKERNEL_PROVIDED_STATIC_MEMORY روی 0 تنظیم شده، برنامه موظف است دو تابع vApplicationGetIdleTaskMemory و vApplicationGetTimerTaskMemory را برای تأمین پشته و بلوک کنترل وظایف Idle و Timer پیاده‌سازی کند. در نسخه 11.2.0 هسته، امضای این توابع از اشاره‌گر نوع `configSTACK_DEPTH_TYPE *` برای بازگرداندن عمق پشته استفاده می‌کند.

مقایسهٔ مصرف RAM برای TCB و پشته در ساخت Task پویا و ایستا
شکل 5. نمودار تألیفی مقایسهٔ تخصیص Task؛ در روش پویا TCB و پشته از Heap گرفته می‌شوند و در روش ایستا بافرهای ماندگار را برنامه فراهم می‌کند. هر دو روش RAM مصرف می‌کنند؛ ایستا بودن مانع همهٔ خطاهای حافظه نمی‌شود. در این پروژه، آرایهٔ Heap و بافرهای ایستا هر دو در BSS قرار دارند.

ایمنی کتابخانه استاندارد newlib در محیط چندوظیفه‌ای

بسیاری از کامپایلرهای GCC برای میکروکنترلرها از کتابخانه استاندارد C موسوم به newlib استفاده می‌کنند. این کتابخانه از مفهوم بازورودی (Reentrancy) پشتیبانی می‌کند و برای داده‌های وابسته به هر رشته اجرایی، ساختاری به نام `struct _reent` تعریف کرده است.

با تنظیم ماکروی configUSE_NEWLIB_REENTRANT روی 1 در FreeRTOSConfig.h، هسته سیستم‌عامل درون ساختار TCB هر وظیفه، یک نمونه اختصاصی از `struct _reent` قرار می‌دهد. در زمان تعویض وظیفه، اشاره‌گر سراسری `_impure_ptr` در newlib به ساختار اختصاصی همان وظیفه متصل می‌شود. این کار متغیرهایی نظیر errno و بافرهای داخلی توابعی مانند strtok را تفکیک می‌کند.

با این حال، فعال کردن این گزینه به هیچ وجه تابع malloc کتابخانه C را ایمن نمی‌کند. حافظه هیپ کتابخانه C یک بافر سراسری مشترک است و ساختار _reent کاری با قفل‌گذاری آن ندارد. برای جلوگیری از تداخل هنگام فراخوانی هم‌زمان malloc از چند وظیفه، توسعه‌دهنده باید توابع سیستمی `__malloc_lock` و `__malloc_unlock` و نیازهای قفل‌گذاری نسخهٔ کتابخانه را بررسی و پیاده‌سازی کند؛ Mutex بازگشتی (Recursive Mutex) می‌تواند بخشی از این طراحی باشد، زیرا خود کتابخانه ممکن است قفل را به‌صورت تودرتو درخواست کند. این قفل‌گذاری در پروژهٔ حاضر پیاده‌سازی نشده است. در پروژه این درس، ماکروی configUSE_NEWLIB_REENTRANT برابر 0 است، هیچ تابع تخصیص‌دهنده‌ای از libc فراخوانی نمی‌شود و بازرسی نمادهای فایل ELF نیز عدم استفاده از malloc کتابخانه C را تأیید کرده است.

ساختار reent اختصاصی دو Task در برابر قفل موردنیاز برای مخزن مشترک malloc
شکل 6. نمودار تألیفی تفکیک دو مسئله در newlib؛ دادهٔ بازدرآمد اختصاصی هر Task با قفل‌کردن مخزن مشترک malloc متفاوت است. فعال‌کردن configUSE_NEWLIB_REENTRANT به‌تنهایی malloc را در برابر فراخوانی هم‌زمان ایمن نمی‌کند. پروژهٔ این درس این گزینه را 0 نگه می‌دارد و تابع تخصیص‌دهندهٔ libc فراخوانی نمی‌کند.

آزمایش کارگاهی: شبیه‌سازی خطاها با دیباگر و جدول عیب‌یابی

در این پروژه، هیچ ارسال داده از طریق پورت سریال UART و هیچ کلید فیزیکی وجود ندارد. اجرای آزمایش‌ها و پایش رفتار سیستم از طریق اتصال دیباگر (SWD) و بازرسی متغیرهای سراسری انجام می‌شود.

مراحل راه‌اندازی و اجرای فرمان‌های 0 تا 3 در دیباگر

ابتدا پروژه را کامپایل کرده و فایل اجرایی را روی برد دانلود کنید. با راه‌اندازی سیستم در دیباگر، وظایف Worker (اولویت 1)، Monitor (اولویت 2) و تایمر نرم‌افزاری (اولویت 3) اجرا می‌شوند. دیود نوری متصل به پایه PB6 در هر 1000 تیک تغییر وضعیت می‌دهد. برای بررسی سناریوهای خطا، متغیر فرمانی g_memory_command را در پنجره متغیرهای دیباگر تغییر می‌دهیم:

  • فرمان 0 (کارکرد عادی): مقدار پیش‌فرض است. سیستم در هر 500 تیک وضعیت حافظه را پایش کرده و متغیر g_heap_samples را به‌روزرسانی می‌کند.
  • فرمان 1 (شکست تخصیص هیپ): پردازنده را متوقف کرده، مقدار g_memory_command را به 1 تغییر دهید و اجرا را ادامه دهید. وظیفه Monitor تلاش می‌کند یک بلوک 12288 بایتی را از هیپ تخصیص دهد. چون بخشی از فضای هیپ صرف سربرگ‌ها شده، این تخصیص با شکست مواجه شده و سیستم وارد تابع vApplicationMallocFailedHook می‌شود. نتیجه مورد انتظار: مقدار g_app_error برابر 2 و g_malloc_failures برابر 1 شده و پردازنده متوقف می‌شود. پس از این آزمایش، برد باید بازنشانی (Reset) شود.
  • فرمان 2 (تخریب کنترل‌شده گارد پشته): مقدار g_memory_command را به 2 تغییر داده و اجرا را از سر بگیرید. وظیفه Monitor مقدار اولین کلمه پشته وظیفه Worker را صفر می‌کند (یک کلمه 32 بیتی معادل 4 بایت در worker_stack[0]=0U) و سپس با اعلان وظیفه Worker را بیدار می‌سازد. در زمان تعویض وظیفه، هسته تغییر این کلمه گارد را متوجه شده و وارد vApplicationStackOverflowHook می‌شود. نتیجه مورد انتظار: مقدار g_app_error برابر 3 و g_stack_fault_armed برابر 1 می‌شود. این آزمایش یک تخریب عمدی کلمه گارد درون آرایه خود برنامه است، نه سرریز واقعی ناشی از مصرف عمیق پشته. پس از آزمایش، برد باید بازنشانی شود.
  • فرمان 3 (سرریز واقعی پشته): در بیلد پیش‌فرض برنامه، این فرمان رد می‌شود و شمارنده g_command_rejected افزایش می‌یابد. برای آزمودن سرریز واقعی، باید بیلد مجزای UnsafeOverflow را کامپایل و دانلود کنید. در آن نسخه، با اجرای یک تابع بازگشتی با 25 فریم تودرتو (که هر فریم یک آرایه محلی 64 بایتی دارد)، فضای 1024 بایتی پشته Worker سرریز می‌شود. در این حالت ممکن است سیستم پیش از رسیدن به زمان تعویض وظیفه مستقیماً دچار HardFault شود یا وارد قلاب سرریز گردد.

کد شبیه‌سازی تخریب کلمه گارد در فایل App/Rtos/rtos_app.c به این صورت است:

      /* Controlled guard corruption INSIDE our own static array, not real overflow. */
      g_stack_fault_armed=1U;
      worker_stack[0]=0U;
      configASSERT(xTaskNotify(worker_handle,1U,eSetValueWithOverwrite)==pdPASS);

جدول عیب‌یابی خطاهای حافظه در پروژه

سناریو یا نشانه خطا علت ریشه‌ای نحوه بررسی در دیباگر اقدام اصلاحی مهندسی
ورود به MallocFailedHook با مقدار g_app_error=2 درخواست تخصیص بیش از ظرفیت آزاد (فرمان آزمایشی 1) یا کمبود بلوک پیوسته بررسی متغیر g_malloc_failures و بزرگترین بلوک در g_heap_samples بازنشانی سیستم پس از آزمون؛ در کاربرد واقعی، افزایش configTOTAL_HEAP_SIZE یا استفاده از تخصیص ایستا
ورود به StackOverflowHook با مقدار g_app_error=3 تخریب عمدی کلمه گارد در worker_stack[0] (فرمان آزمایشی 2) بررسی متغیرهای g_overflow_task و g_overflow_name بازنشانی سیستم پس از آزمون؛ تفکیک این آزمایش از سرریز واقعی ناشی از مصرف توابع
رد شدن فرمان 3 با افزایش g_command_rejected بیلد پیش‌فرض به دلایل ایمنی مانع اجرای کد بازگشتی ناامن می‌شود بررسی مقدار g_command_rejected در متغیرهای سراسری برای اجرای تمرین سرریز بازگشتی، استفاده از بیلد مجزای UnsafeOverflow تحت نظارت دیباگر
بروز HardFault در بیلد UnsafeOverflow پیش از ورود به قلاب تخریب آدرس بازگشت توابع یا بافت پردازنده ناشی از 25 فریم بازگشتی تودرتو بررسی ثبات‌های HFSR و CFSR در پنجره ثبات‌های پردازنده کاهش عمق بازگشتی، افزایش عمق پشته، یا بهره‌گیری از واحد MPU برای مهار پیش از تخریب
کاهش حاشیه امن پشته در حین اجرای برنامه مصرف پشته فراتر از حد انتظار ناشی از متغیرهای محلی یا فراخوانی‌های زنجیره‌ای بررسی متغیرهای g_worker_free_words و g_monitor_free_words افزایش مقادیر ثوابت WORKER_STACK_WORDS یا MONITOR_STACK_WORDS و ابعاد آرایه‌های متناظر در کد، سپس کامپایل مجدد

پرسش‌های خودآزمایی

1. تفاوت پشته اصلی (MSP) با پشته هر وظیفه (PSP) در معماری Cortex-M4 چیست و چرا تفکیک آنها مصونیت کامل در برابر تداخل حافظه ایجاد نمی‌کند؟

در معماری ARM Cortex-M4، وظایف کاربر با اشاره‌گر PSP اجرا می‌شوند، در حالی که برنامه‌های مدیریت وقفه و استثناها با اشاره‌گر MSP کار می‌کنند. با وقوع وقفه حین اجرای وظیفه، پردازنده ابتدا فریم پایه شامل 8 ثبات را روی همان پشته وظیفه (PSP) ذخیره می‌کند و سپس کنترل را با MSP به تابع وقفه می‌دهد. اگرچه این دو اشاره‌گر به بخش‌های متفاوتی از حافظه اشاره دارند، اما در این معماری به طور پیش‌فرض ایزولاسیون سخت‌افزاری وجود ندارد؛ بنابراین اگر پشته یک وظیفه سرریز شود، می‌تواند داده‌های مجاور در RAM از جمله متغیرهای سیستمی یا فضای پشته اصلی را تخریب کند.

2. چرا خروجی تابع uxTaskGetStackHighWaterMark بر حسب کلمه (Word) است و چه محدودیتی در تفسیر آن وجود دارد؟

هسته FreeRTOS عمق پشته را با نوع داده‌ای StackType_t مدیریت می‌کند که در پردازنده 32 بیتی Cortex-M4 برابر یک کلمه 4 بایتی است. از این رو، برای تبدیل خروجی به بایت باید عدد دریافتی در 4 ضرب شود. محدودیت اصلی این تابع در آن است که تنها تعداد کلمات دست‌نخورده با الگوی 0xa5 را در طول مسیرهایی که تا زمان سنجش عملاً اجرا شده‌اند می‌شمارد. اگر متغیرهای محلی رزرو شده باشند ولی مقداری در آنها نوشته نشده باشد، یا اگر مسیرهای کاری سنگین و وقفه‌های پیچیده در طول آزمون رخ نداده باشند، این عدد برآوردی خوش‌بینانه خواهد بود و حاشیه قطعی بدترین سناریو محسوب نمی‌شود.

3. در روش 2 بررسی سرریز پشته، هسته چه مواردی را کنترل می‌کند و چرا این روش به تنهایی تضمین صددرصدی نیست؟

در روش 2، هسته در زمان تعویض وظیفه بررسی می‌کند که آیا اشاره‌گر ذخیره‌شده پشته (savedSP) درون مرز مجاز قرار دارد یا خیر، و همچنین 4 کلمه گارد (16 بایت) انتهای پشته را برای بررسی حفظ الگوی 0xa5 کنترل می‌کند. با این حال دو محدودیت وجود دارد: نخست اینکه اگر تخریب حافظه در حین اجرای توابع وظیفه رخ دهد، ممکن است پردازنده بلافاصله دچار HardFault شود پیش از آنکه نوبت به تعویض وظیفه برسد؛ دوم آنکه اگر اشاره‌گر پشته به صورت گذرا خارج شود اما پیش از تعویض وظیفه به محدوده مجاز بازگردد و به کلمات گارد آسیبی نزند، هسته متوجه آن نخواهد شد. محافظت سخت‌افزاری با طراحی و پیکربندی مناسب MPU بحث جداگانه‌ای است؛ فعال‌کردن Hook به‌تنهایی چنین محافظتی ایجاد نمی‌کند.

4. چرا گزینه configUSE_NEWLIB_REENTRANT برای ایمن‌سازی تابع malloc کتابخانه newlib کافی نیست؟

فعال‌سازی این گزینه برای هر وظیفه یک ساختار داده‌ای اختصاصی _reent ایجاد می‌کند و اشاره‌گر _impure_ptr را در زمان تعویض وظیفه به‌روزرسانی می‌نماید تا متغیرهایی نظیر errno و وضعیت داخلی وابسته به هر Task از هم جدا شود. اما مخزن هیپ در newlib یک منبع اشتراکی سراسری است. برای ایمن‌سازی malloc در برابر فراخوانی هم‌زمان چند وظیفه، برنامه‌نویس باید توابع قفل‌گذاری __malloc_lock و __malloc_unlock را متناسب با نیازهای نسخهٔ کتابخانه پیاده‌سازی کند؛ این طراحی ممکن است به Mutex بازگشتی (Recursive Mutex) نیاز داشته باشد. در این پروژه این گزینه صفر است و از malloc کتابخانه استفاده نمی‌شود.

5. استفاده از تخصیص ایستا (Static Allocation) دقیقاً چه ریسک‌هایی را حذف می‌کند و چه مسائلی همچنان باقی می‌مانند؟

تخصیص ایستا با توابعی نظیر xTaskCreateStatic خطر شکست تخصیص ناشی از کمبود یا تکه‌تکه‌شدن هیپ را برای اشیایی که به صورت ایستا ساخته شده‌اند حذف می‌کند، زیرا بافرهای آماده را برنامه فراهم می‌کند. در این پروژه، بافرهای سراسری بدون مقدار اولیه در بخش BSS رزرو شده‌اند. با این حال، بافرهای ایستا همچنان از حافظه فیزیکی RAM مصرف می‌کنند. علاوه بر این، تخصیص ایستا مانع خطای سرریز پشته در زمان اجرا نمی‌شود و اگر پشته ایستا کوچک باشد، سرریز می‌تواند داده‌های دیگر در RAM را خراب کند. همچنین هر بخش دیگری از سیستم که همچنان از هیپ پویا استفاده کند، در معرض ریسک‌های حافظه پویا باقی می‌ماند.

6. در میکروکنترلر STM32F303CBT6، چرا حافظه CCM RAM نمی‌تواند برای بافرهای تبادل DMA در ساختار heap_5 استفاده شود؟

حافظه 8 KiB موسوم به CCM RAM مستقیماً به گذرگاه‌های اختصاصی هسته پردازنده متصل است و ساختار سخت‌افزاری آن هیچ اتصالی به ماتریس ارتباطی کنترلر DMA ندارد. اگر با heap_5 این حافظه را به عنوان بخشی از هیپ تعریف کنیم و سپس یک بافر تبادل داده نظیر بافر دریافت UART یا انتقال SPI از این فضا تخصیص یابد، کنترلر DMA قادر به دسترسی به آن نخواهد بود و فرآیند انتقال با خطای سخت‌افزاری مواجه می‌شود. در پروژه این درس از heap_5 استفاده نشده و کلیه تخصیص‌ها در SRAM اصلی انجام می‌گیرد.

تمرین‌ها

تمرین‌های تغییر کد، پیشنهاد آموزشی‌اند؛ نسخهٔ تغییر‌یافته باید دوباره کامپایل و جداگانه روی برد بررسی شود.

1. برآورد مصرف پشته از روی ردپای دست‌نخورده و افزودن حاشیه امن

برای یک وظیفه پشته‌ای با عمق 384 کلمه تعریف شده است. پس از اجرای آزمون در دیباگر، تابع uxTaskGetStackHighWaterMark مقدار 120 کلمه را برمی‌گرداند. با فرض این که تمام فضای استفاده‌شده در طول مسیرهای اجراشده واقعاً مقداردهی و نوشته شده باشد، برآورد مصرف پشته را محاسبه کنید و توضیح دهید چرا نمی‌توان این عدد را بدون حاشیه امن به عنوان حداکثر قطعی در نظر گرفت.

معیار بررسی: کل پشته برابر 384 ضرب در 4 یعنی 1536 بایت است. با فرض نوشته‌شدن کامل، حاشیه دست‌نخورده برابر 120 ضرب در 4 یعنی 480 بایت بوده و مصرف برآوردی برابر 1536 منهای 480 یعنی 1056 بایت است. اما از آنجا که ممکن است بافرهای محلی نوشته‌نشده باقی مانده باشند یا مسیرهای استثنا و وقفه‌های پیچیده در طول آزمون رخ نداده باشند، این عدد یک تخمین است و برای تعیین اندازه نهایی پشته باید مسیرهای نادر آزموده شده و حاشیه‌ای متناسب با مسیرهای اجرا، فریم استثنا، نسخهٔ کامپایلر و ریسک کاربرد در نظر گرفته شود.

2. تعریف بافر ایستا و پیاده‌سازی وظیفه با متغیرهای ماندگار

با استفاده از نوع داده‌ای StackType_t و StaticTask_t، بافرهای لازم برای یک وظیفه جدید را با طول عمر ماندگار (Global یا Static) به عمق 256 کلمه تعریف کنید. وظیفه را با xTaskCreateStatic بسازید و توضیح دهید چرا باید طول عمر این بافرها با چرخه حیات وظیفه همخوانی داشته باشد.

معیار بررسی: آرایهٔ پشته و بلوک کنترل را مانند worker_stack و worker_tcb در کد کامل پروژه، در سطح فایل با طول عمر ماندگار تعریف کنید. در این پروژه، بافرهای بدون مقدار اولیه در BSS قرار می‌گیرند. اگر این بافرها به صورت متغیر محلی در یک تابع تعریف شوند، با خروج از تابع حافظه آنها آزاد و بازنویسی می‌شود که باعث خرابی داده‌های وظیفه خواهد شد. همچنین پس از ایجاد ایستا، مقدار xPortGetFreeHeapSize تغییری نمی‌کند زیرا درخواستی به تخصیص‌دهنده هیپ ارسال نشده است.

3. تحلیل مقایسه‌ای تکه‌تکه‌شدن در heap_4 و heap_2 تحت شرایط مشخص

فرض کنید دو بلوک تخصیص‌یافتهٔ مجاور، هرکدام با اندازهٔ دادهٔ (Payload) برابر 128 بایت، آزاد می‌شوند و هیچ بلوک آزاد دیگری در هیپ نمی‌تواند درخواستی بیش از 150 بایت را پاسخ دهد. با در نظر گرفتن این که هر بلوک دارای سربار سربرگ (Header) و هم‌ترازی است، توضیح دهید چرا در این شرایط معین، درخواست تخصیص جدید برای 200 بایت در heap_4 موفق ولی در heap_2 ناموفق خواهد بود.

معیار بررسی: در heap_4، آزادسازی دو بلوک مجاور موجب فراخوانی ادغام (Coalescence) می‌شود و دو بلوک با یکپارچه‌سازی سربرگ میانی به یک ناحیه آزاد بزرگتر تبدیل می‌شوند که فضای پیوسته آن گنجایش 200 بایت داده به همراه سربرگ را دارد. در heap_2، ادغام بلوک‌های مجاور پشتیبانی نمی‌شود و دو بلوک 128 بایتی مجزا باقی می‌مانند؛ لذا با توجه به نبود بلوک آزاد دیگری با اندازهٔ کافی، درخواست 200 بایتی در heap_2 شکست می‌خورد.

منابع و مبنای بررسی

مبنای رفتار APIها، کد نسخهٔ ثابت FreeRTOS 11.2.0 و پروژهٔ قابل دریافت همین مقاله است. منابع پیشنهادیِ خوانده‌نشده، شاهد ادعاهای این مقاله محسوب نشده‌اند.

مطالعهٔ تکمیلی: Mastering the FreeRTOS Real Time Kernel، ویرایش V1.1.0. رفتار APIهای پروژه با کد واقعی نسخهٔ 11.2.0 تطبیق داده شده است؛ مثال‌های این ویرایش کتاب بر پایهٔ هستهٔ 10.6.2 هستند.

دانلود پروژه و فایل‌های همراه

دانلود بستهٔ کامل پروژه، خروجی ساخت، اسکریپت‌ها و 6 تصویر آموزشی · راهنمای اجرا · پوشهٔ همین نسخه در گیت‌هاب

حجم بسته: 2,506,797 بایت. شناسهٔ SHA256: 17d38a99658e5af0f1872baad949e805038690348d97c2e70667debbd0fedcdc. گزارش ساخت و برگهٔ خالی ثبت آزمایش در بسته قرار دارد.

با نظرات خود به تیم جبرا در بهبود کیفیت کمک کنید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

سبد خرید
پیمایش به بالا