شناخت دقیق عملکرد لینکر نه تنها به ما کمک میکند تا برنامهای بهینه از نظر مصرف حافظه بسازیم، بلکه برای اشکالزدایی (Debugging)، مدیریت ارتقاء Firmware بدون آسیب به دستگاه و تعریف بخشهای حافظه خاص ضروری است. لینکر با استفاده از مدل حافظهٔ زبان C، دادهها و کد را در بخشهایی مانند text, data, bss, stack و heap سازماندهی میکند و در نهایت یک فایل نقشه (Map File) تولید میکند که موقعیت دقیق هر تابع و متغیر را در حافظه نشان میدهد.
این لینکر است که به طور دقیق میداند هر چیزی کجا قرار دارد. در سیستمهای بزرگ با گیگابایتها حافظه، این موضوع خیلی مهم نیست، اما در یک میکروکنترلر با 16 کیلوبایت رم، دانستن اینکه هر بایت برای چه کاری استفاده میشود، اهمیت دارد.
بیایید نگاهی به یک خطای معمولی بیندازیم که نشان میدهد درک بهتر لینکر چگونه میتواند مفید باشد. فرض کنید سیستمی در حال اجرا دارید که دائم خراب میشود. وقتی سیستم کرش میکند، یک Stack Trace چاپ میکند که در آن پشتهی فراخوانیها (Call Stack) نمایش داده میشود. این پشته توضیح میدهد که چه توالیای از خطاها منجر به بروز مشکل شده است (به لیست 11-1 مراجعه کنید).
1 2 3 4 5 6 7 8 9 | #0 0x0000000000001136 in ?? () #1 0x0000000000001150 in ?? () #2 0x0000000000001165 in ?? () #3 0x000000000000117a in ?? () #4 0x00007ffff7de50b3 in __libc_main (main=0x555555555168) at ../csu/libc-start.c:308 #5 0x000000000000106e in ?? () |
یک نمونه Stack Trace
این متن به شما میگوید که خطا در تابعی با آدرس x00000000000011360 رخ داده است. از آنجایی که شما برنامه خود را با استفاده از آدرسهای مطلق (absolute addresses) ننوشتهاید، دانستن نام تابع برایتان مفیدتر خواهد بود. در اینجا کارایی نقشهی لینکر (linker map) پررنگ میشود.
لیست زیر بخشی از نقشهی این برنامه را نشان میدهد.
1 2 3 4 5 6 7 8 9 10 11 | .text 0x0000000000001129 0x58 /tmp/cctwz0VM.o 0x0000000000001129 three 0x000000000000113e two 0x0000000000001153 one 0x0000000000001168 main |
بخشی از نقشهی برنامهی لیست قبلی
ما در لیست اول در آدرس x11360 با خطا مواجه شدیم. در لیست دوم تابع « three » از آدرس 0x1129 شروع میشود و تا تابع بعدی در آدرس 0x113e ادامه مییابد. درواقع، ما ۱۳ بایت بعد از شروع تابع « three » قرار داریم، بنابراین جایی نزدیک به ابتدای تابع هستیم.
لیست اول نشان میدهد که تابع « three » توسط کسی در آدرس 0x1150 فراخوانی شده است. لیست دوم نشان میدهد که تابع « two » از آدرس 0x113e تا 0x1153 قرار دارد، پس این تابع، تابع « three » را فراخوانی کرده است. با تحلیل مشابهی، میتوانیم بفهمیم که تابع « two » توسط تابع « one » و تابع « one » توسط تابع اصلی (main) فراخوانی شده است.
لینکر وظیفه دارد فایلهای شیء (object files) که برنامه را تشکیل میدهند، با هم ترکیب کند تا یک فایل برنامهی واحد ایجاد شود. یک فایل شیء ( object) حاوی کد و داده است که در بخشهای نامگذاری شده سازماندهی شدهاند. (نام واقعی بخشها به کامپایلر بستگی دارد. البته برنامهنویسان حرفهای میتوانند نامهای خاص خود را برای این بخشها انتخاب کنند.)
بخشهای موجود در فایل شیء، آدرس ثابتی ندارند. به آنها قابل جابجایی (relocatable) گفته میشود، یعنی میتوان آنها را تقریباً در هر جایی قرار داد، اما لینکر آنها را در محل خاصی از حافظه قرار میدهد.
چیپ ARM حاوی دو نوع حافظه است: حافظهی دسترسی تصادفی (RAM) و حافظهی فلش (flash memory). متغیرها در حافظهی رم قرار میگیرند. یکی از مشکلات این نوع حافظه، از بین رفتن تمام دادهها با قطعی برق است. حافظهی فلش، از نظر کاربردی، حافظهی فقط خواندنی (read-only memory) است. (نوشتن در حافظهی فلش، به طور معمول غیرممکن است. البته، با استفاده از روشهای خاص و پیچیدهی سیستم I/O، افراد متخصص میتوانند این کار را انجام دهند.) دادههای موجود در حافظهی فلش با قطع برق سیستم پاک نمیشوند.
لینکر دادهها را از تمام فایلهای شیء میگیرد و آنها را در حافظهی رم جای میدهد. سپس باقیماندهی رم را به دو بخش پشته (stack) و توده (heap) تقسیم میکند. دادههای فقط خواندنی و کد در حافظهی فلش قرار میگیرند. این توضیح کمی ساده شده است، اما جزئیات آن را برای بخشهای بعدی این فصل نگه میداریم.
آخرین کاری که لینکر انجام میدهد، نوشتن یک فایل نقشه (map file) است که به شما میگوید کجا هر بخش از برنامه را در حافظه قرار داده. شاید با خودتان فکر کنید که چرا باید بدانیم لینکر هر قسمت را کجا گذاشته است؛ بالاخره مهم این است که برنامه به درستی در حافظه بارگذاری شود.
با این حال، دلایل مهمی برای اهمیت این موضوع وجود دارد:
بنابراین، در حالی که هدف اصلی بارگذاری موفقیتآمیز برنامه در حافظه است، دانستن اینکه لینکر هر بخش را کجا قرار داده است، برای اشکالزدایی، مدیریت حافظه و ارتقاء سیستم، بسیار مهم و کاربردی است.
حالا به مهمترین دلیل اهمیت لینکر میرسیم:
میگویند طراحان سختافزار باید همان بار اول کارشان را درست انجام دهند. اما برای متخصصان نرمافزار، تنها چالش مهم، ارتقاء سیستمعامل است.
اما چطور میتوانیم از نرمافزاری که در حال اجراست، برای جایگزینی خودِ آن نرمافزار استفاده کنیم؟ و مهمتر از همه، چطور این کار را بدون آجر کردن (bricking) سیستم انجام دهیم؟ (آجر کردن به وضعیتی گفته میشود که فرآیند ارتقاء با شکست مواجه شده و سیستم شما عملاً غیرقابل استفاده میشود، درست مثل یک آجر!)
این کار به برنامهنویسی ماهرانهای نیاز دارد و در انتهای این فصل به توضیح آن خواهم پرداخت.
مدل حافظه (Memory Model) نحوهٔ تعریف و مشخصسازی حافظه در یک سیستم را توصیف میکند.
بهطور کلی، حافظه به بخشهایی با نامهای مشخص تقسیم میشود. استاندارد زبان C، فایلهای شیء (Object Files)، و تراشهٔ ARM هر یک برای توصیف بخشهای حافظه از نامهای متفاوتی استفاده میکنند.
مسئله پیچیدهتر این است که از طریق افزونههای زبان C میتوان نامهای سفارشی برای این بخشها تعریف کرد. در این حالت لازم است به لینکر دستور داده شود که با این بخشهای سفارشی چگونه رفتار کند و آنها را در کدام قسمتهای حافظه قرار دهد.
در حالت ایدهآل، تمام اجزای یک برنامهی C باید در یکی از بخشهای استاندارد حافظه قرار بگیرند: text، data یا bss.
دستورالعملهای فقط خواندنی (read-only) و دادههای فقط خواندنی در بخش text قرار میگیرند. در اینجا، کد تابع اصلی (main) و هم رشتهی متنی (که فقط خواندنی است) در بخش text قرار میگیرند:
1 2 3 4 5 6 7 | int main() { doIt("این متن هم در بخش کد قرار میگیرد"); return 0; } |
دادههای مقداردهیشده (متغیرهای سراسری مقداردهیشده) در بخش data قرار میگیرند:
1 | int anExample = 5; |
دادههای مقداردهینشده (متغیرهای سراسری مقداردهینشده) در بخش bss قرار میگیرند:
1 | int uninitialized; |
از نظر فنی، طبق استاندارد C، بخش bss مقداردهینشده در نظر گرفته میشود. با این حال، در تمام پیادهسازیهای سیستمهای برنامهنویسی C که تا به حال دیدهام، بخش bss به صورت پیشفرض مقدار صفر میگیرد.
حافظهی این بخشها در زمان کامپایل تخصیص داده میشود. کامپایلر C یک فایل شیء (object file) تولید میکند که در آن میگوید: “به این مقدار فضای text نیاز دارم و محتوای آن این است. به این مقدار فضای data نیاز دارم و محتوای آن این است. به این مقدار فضای bss نیاز دارم، اما محتوایی برای آن مشخص نشده است.”
دستور size میزان فضایی را که برنامهی شما در هر بخش استفاده میکند نشان میدهد.
1 2 3 4 5 | $ size example.o text data bss dec hex filename 481 4 4 489 1e9 example.o |
فایل شیء از ۴۸۱ بایت حافظهی text ، ۴ بایت حافظهی data و ۴ بایت دیگر از حافظهی bss استفاده میکند. کل تعداد بایتها برای هر سه بخش ۴۸۹ یا معادل با 1e9 در مبنای شانزده است.
مدل ایدهآل حافظه در C دو بخش دیگر نیز دارد. با این حال، این بخشها توسط کامپایلر تخصیص داده نمیشوند، بلکه توسط لینکر تخصیص داده میشوند. این بخشها پشته (stack) و توده (heap) هستند. پشته برای متغیرهای محلی مورد استفاده قرار میگیرند و با فراخوانی رویهها به صورت پویا تخصیص داده میشود. heap مجموعهای از حافظه است که میتواند به صورت پویا تخصیص داده شده و آزاد شود (توضیحات بیشتر در مورد توده در فصل ۱۳).
کامپایلر بعد از گرفتن تعاریف متغیرها، آنها را به بخشهای حافظه اختصاص میدهد. این بخشها از فضای نامی (namespace) متفاوتی نسبت به نامهای بخشهای حافظهی ایدهآل C استفاده میکنند. در برخی موارد نامها مشابه هستند و در برخی موارد کاملاً متفاوتاند. کامپایلرهای مختلف و حتی نسخههای مختلف یک کامپایلر خاص ممکن است از نامهای متفاوتی برای این بخشها استفاده کنند.
لیست 11-3 برنامهای را نشان میدهد که شامل تمام انواع دادههای بحث شده تا اینجای کار است.
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 | /** برنامهای برای نمایش انواع مختلف ذخیرهسازی متغیر، تا بتوانیم ببینیم لینکر چگونه با آنها برخورد میکند */ int uninitializedGlobal; // گلوبال بدون مقداردهی (بخش bss) int initializedGlobal = 1; // گلوبال با مقداردهی (بخش data) int initializedToZero = 0; // گلوبال با مقداردهی صفر (بخش bss) // "A string." -- ثابت (بخش text) const char* aString = "A string."; // رشته (ارجاع به دادههای فقط خواندنی) static int uninitializedModule; // نماد ماژول محور (دسترسی گلوبال در همین فایل فقط) بدون مقداردهی اولیه (بخش bss) static int initializedModule = 2; // نماد ماژول محور (دسترسی گلوبال در همین فایل فقط) با مقدار دهی اولیه (بخش data) int main() { int uninitializedLocal; // متغیر محلی بدون مقداردهی (بخش stack) int initializedLocal = 1234; // متغیر محلی با مقداردهی (بخش stack) static int uninitializedStatic; // استاتیک بدون مقداردهی (بخش bss) static int initializedStatic = 5678; // استاتیک با مقداردهی (بخش data) while (1) continue; } |
لیست سوم: نمونههایی از انواع داده
بیایید ببینیم کامپایلر GNU GCC با برنامه نمونه ما از لیست سوم چه میکند – به طور خاص، چگونه حافظه را برای انواع مختلف متغیرها و دادهها اختصاص میدهد.
ابتدا، نگاهی به متغیر سراسری مقداردهیشده initializedGlobal از لیست سوم میاندازیم:
1 2 3 4 5 6 7 8 9 10 11 | int initializedGlobal = 1; // An initialized global (dataبخش ) 16 .global initializedGlobal 17 .data 18 .align 2 21 initializedGlobal: 22 0000 01000000 .word 1 |
دستورالعمل .global به اسمبلر میگوید که این یک نماد سراسری است و میتواند توسط سایر فایلهای شیء به آن ارجاع شود. دستورالعمل .data به اسمبلر میگوید که موارد بعدی در بخش .data قرار میگیرند. تا اینجا، ما از نامگذاری مدل حافظه ایدهآل C پیروی کردیم.
دستورالعمل .align به اسمبلر میگوید که دادههای بعدی باید روی یک مرز 4 بایتی تراز شوند. (دو بیت آخر/کم ارزش آدرس باید صفر باشند تا طول متغیر ضریبی از چهار باشد، از این رو .align 2). در نهایت، برچسب initializedGlobal و داده .word 1 وجود دارد.
هنگامی که یک متغیر به صفر مقداردهی اولیه میشود (initializedToZero در لیست سوم)، کد هم کمی تغییر میکند:
1 2 3 4 5 6 7 8 9 10 11 | int initializedToZero = 0; // An initialized global (bssبخش ) 23 .global initializedToZero 24 .bss 25 .align 2 28 initializedToZero: 29 0000 00000000 .space 4 |
در اینجا، کامپایلر از دستور .bss برای قرار دادن متغیر در بخش .bss استفاده میکند. همچنین به جای .word از دستور .space استفاده میکند که به اسمبلر میگوید این متغیر 4 بایت فضای حافظه اشغال میکند و باید آن بایتها را با صفر مقداردهی اولیه کند.
حال به سراغ یک متغیر سراسری بدون مقدار اولیه (uninitializedGlobal از فهرست ۱۱-۳) میرویم:
1 2 3 4 5 | int uninitializedGlobal; // An uninitialized global (bssبخش ) 15 .comm uninitializedGlobal,4,4 |
بخش .comm به اسمبلر میگوید که یک نماد به طول 4 بایت تعریف کند که باید با مرز 4 بایت همتراز باشد. این نماد در بخشی از حافظه به نام ‘COMMON’ قرار میگیرد. در این مورد، نام این بخش از حافظه با الگوی نامگذاری مدل حافظه ایدهآل C مطابقت ندارد.
دستوری که aString را در لیست سوم تعریف میکند، یک ثابت رشتهای (“A string.”) را نیز تعریف میکند. ثابت رشتهای فقط خواندنی است، در حالی که اشاره گر (aString) خواندنی/نوشتنی است. در اینجا کد تولید شده آمده است:
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 | const char* aString = "A string."; // رشته (اشاره به داده های فقط خواندنی) 30 .global aString 31 .section .rodata 32 .align 2 33 .LC0: 34 0000 41207374 .ascii "A string.\000" 34 72696E67 34 2E00 35 .data 36 .align 2 39 aString: 40 0004 00000000 .word .LC0 |
ابتدا، کامپایلر باید ثابت “A string.” را تولید کند. برای این کار، یک نام داخلی (.LC0) برای این ثابت ایجاد میکند و محتوای این ثابت با دستور اسمبلر .ascii تولید میشود. دستور .section .rodata ثابت را در یک بخش پیوند دهنده به نام .rodata قرار میدهد. (مدل حافظه ایدهآل C این بخش را text مینامد.)
حال به تعریف خود متغیر، aString میرسیم. دستور .data آن را در بخش داده قرار میدهد. از آنجایی که این متغیر یک اشارهگر است، با آدرس رشته (.LC0) مقداردهی اولیه میشود.
آخرین بخش اصلی، بخشی است که حاوی کد برنامه است. مدل حافظه ایدهآل C این بخش را text مینامد. در اینجا لیست اسمبلر برای ابتدای تابع main آمده است:
1 2 3 4 5 6 7 8 9 10 11 12 13 | int main() 52 .section .text.main,"ax",%progbits 53 .align 1 54 .global main 60 main: 67 0000 80B5 push {r7, lr} |
نام این بخش text.main است. در این مورد، کامپایلر تصمیم گرفته پیشوند text را بردارد و نام ماژول (main) را به آن اضافه کند تا نام بخش را تشکیل دهد.
تاکنون به بررسی بخشهای اصلی حافظه که کامپایلر با آنها آشنا است، پرداختهایم. در ادامه، به کدی میپردازیم که توسط انواع دیگر اعلانها تولید میشود.
کلمه کلیدی static که خارج از هر رویهای استفاده میشود، متغیری را نشان میدهد که فقط میتوان از آن در داخل ماژول فعلی استفاده کرد.
این کد متغیر initializedModule را از لیست سوم ایجاد میکند:
1 2 3 4 5 6 7 8 9 | static int initializedModule = 2; // An initialized (نماد فقط ماژول) // (dataبخش ) 46 .data 47 .align 2 50 initializedModule: 51 0008 02000000 .word 2 |
به نظر میرسد این کد خیلی شبیه به initializedGlobal است، با این تفاوت که دستور .global در آن وجود ندارد.
به طور مشابه، متغیر uninitializedModule از لیست سوم نیز شباهت زیادی به uninitializedGlobal دارد، با این تفاوت که باز هم دستور .global در آن وجود ندارد:
1 2 3 4 5 6 7 8 9 10 11 | static int uninitializedModule; // An uninitialized (نماد فقط ماژول) // (dataبخش ) 41 .bss 42 .align 2 43 uninitializedModule: 44 0004 00000000 .space 4 |
حال به سراغ متغیرهایی میرویم که با استفاده از کلمه کلیدی static در داخل یک رویه تعریف شدهاند. این متغیرها زمان کامپایل در حافظه اصلی تخصیص داده میشوند، اما دامنه آنها به رویهای که در آن تعریف شدهاند محدود میشود.
بیایید با متغیر uninitializedStatic از لیست سوم شروع کنیم:
1 2 3 4 5 6 7 8 | static int uninitializedStatic; // "Uninitialized" static (bssبخش ) 94 .bss 95 .align 2 96 uninitializedStatic.4108: 97 0008 00000000 .space 4 |
به نظر میرسد این کد شبیه به یک متغیر محلی بدون مقدار اولیه است، با این تفاوت که کامپایلر نام متغیر را از uninitializedStatic به uninitializedStatic.4108 تغییر داده است. چرا؟
هر بلوک یا بخشی از کد که درون آکولاد ({}) قرار گرفته باشد، میتواند متغیر uninitializedStatic خودش را داشته باشد. دامنه نام متغیر C در بلاکی که در آن تعریف شده است، محلی است. اما دامنه زبان اسمبلی کل فایل است؛ بنابراین، کامپایلر با اضافه کردن یک عدد تصادفی منحصربهفرد به انتهای اعلان متغیر، نام آن را منحصربهفرد میکند.
به طور مشابه، متغیر initializedStatic بسیار شبیه به همتای سراسری خود به نظر میرسد:
1 2 3 4 5 6 7 8 9 | static int initializedStatic = 5678; // Initialized static (dataبخش ) 88 .data 89 .align 2 92 initializedStatic.4109: 93 000c 2E160000 .word 5678 |
در این مورد، دستور .global وجود ندارد و نام با اضافه شدن یک پسوند تغییر یافته است.
نویسنده شو !
سیسوگ با افتخار فضایی برای اشتراک گذاری دانش شماست. برای ما مقاله بنویسید.