برای بهترین استفاده از کامپایلر، نیاز به درک کد در زمان اجرا دارید، تا بهتر متوجه شوید چه اتفاقاتی در پسزمینه رخ میدهد. دلیل این موضوع در زمان برنامهنویسی امبدد مشخص میشود که اغلب نیاز خواهید داشت برخی از عملیاتهای کامپایلر که به طور خودکار انجام میشود را دور بزنید که شامل تعدادی مرحله است:
پیشپردازش کد منبع: در کد منبع (source code) خطوطی که با # شروع میشوند، مانند دستور #include، توسط پیشپردازنده پردازش میشوند. این دستورات را کامند های «Directives» می نامیم، بعداً با دستورات بیشتری آشنا خواهید شد.
تبدیل به زبان اسمبلی: کامپایلر، کد پیشپردازش شده را به زبان اسمبلی تبدیل میکند. کد C بهطور کلی مستقل از ماشین است و میتواند روی پلتفرمهای مختلف اجرا شود، اما زبان اسمبلی به یک نوع خاص از ماشین وابسته است.(البته واضح است که امکان نوشتن کد C اجرا شونده، فقط بر روی یک دستگاه وجود دارد به عبارتی C سعی میکند دستگاه پایه (underlying machine) را از شما مخفی کند، اما مانع از دسترسی مستقیم شما به آن نمیشود.)
ایجاد «آبجکت فایل»: اسمبلر، کد اسمبلی را به آبجکت فایل تبدیل میکند. این فایل فقط شامل کد ما است، برای مثال: در برنامه سادهای که قبلاً نوشتیم، فایل هدف برای hello.c به یک کپی از تابع printf نیاز دارد
لینک کردن:لینکر (linker)، آبجکت کد (Object code)موجود در آبجکت فایل را میگیرد و آن را با کد مفیدی که از قبل در رایانهی شما وجود دارد ترکیب (link) میکند. در برنامهای که نوشته بودیم، printf و تمام کدهای لازم برای پشتیبانی از آن مثال خوبی است.
شکل 1-1 این فرایند را نشان میدهد. تمام این مراحل در پشت کامند gcc پنهان میشوند.
همانطور که درشکل 1-1 نشان داده شده کامند gcc هم بهعنوان کامپایلر و هم بهعنوان لینکر عمل میکند. در واقع، gcc بهعنوان یک برنامه اجرایی طراحی شده است. gcc به آرگومانها نگاه میکند و تصمیم میگیرد کدام برنامهها را باید اجرا کند تا کار خود را انجام دهد. این ممکن است شامل پیشپردازنده (cpp)، کامپایلر (cc1) C، مترجم (as)، لینکر (ld) یا برنامههای دیگر بر اساس نیاز باشد. بیایید بهصورت دقیقتر این مؤلفهها را بررسی کنیم.
پیشپردازنده (The Preprocessor)
اولین برنامهای که اجرا میشود، پیشپردازنده است که در واقع یک پردازنده ماکرو (نوعی ویرایشگر خودکار متن) میباشد. پیشپردازنده تمامی خطوطی را که با # شروع میشوند، پردازش میکند. در برنامهای که نوشتهایم، خط #include را پردازش میکند. ما میتوانیم خروجی پیشپردازنده را با این کامند دریافت کنیم:
1 | $ gcc -E hello.c >hello.i |
خروجی این کامند در فایل hello.i ذخیره میشود. اگر به این فایل نگاهی بیندازیم، متوجه میشویم که بیش از ۸۵۰ خط است. این به این دلیل است که خط #include <stdio.h> باعث میشود که کل فایل stdio.h به برنامهی ما کپی شود، و ازآنجاییکه فایل stdio.h دستورات #include خود را دارد، فایلهای موجود توسط stdio.h نیز کپی میشوند. ما برای تابع printf به stdio. نیاز داشتیم و اگر در hello.i جستجو کنیم، تعریف این تابع را پیدا میکنیم که اکنون در برنامهی ما قرار گرفته است:
1 2 3 4 5 6 7 | extern int printf (const char *__restrict __format, ...); extern int sprintf (char *__restrict __s, const char *__restrict __format, ...) __attribute__ ((__nothrow__)); |
پیشپردازنده همچنین، تمامی توضیحات را حذف میکند و متن را با اطلاعاتی که نشان میدهد چه فایلی در حال پردازش است، چاپ میکند.
کامپایلر
کامپایلر کد زبان C را به زبان اسمبلی تبدیل میکند. با این کامند میتوانیم ببینیم که چه چیزی تولید میشود:
1 | $ gcc -S hello.c |
این کامند باید یک فایل تولید کند که با متن زیر شروع میشود:
1 2 3 4 5 6 7 | .file "hello.c" .section .rodata .LC0: .string "Hello World!" |
دقت کنید که کامپایلر رشتهی C “Hello World!\n” را به دستور .string در زبان اسمبلی تبدیل کرده است. اگر دقت کنید، متوجه میشوید که \n ناپدید شده است. دلیل این موضوع را بعداً خواهیم فهمید.
اسمبلر
فایل زبان اسمبلی به اسمبلر(Assembler) تحویل داده میشود که آن را به کد ماشین(کد اسمبلی) تبدیل میکند. کامند gcc یک گزینه (-Wa) دارد که به ما اجازه میدهد پرچمهایی (flags) را به اسمبلر منتقل کنیم. ازآنجاییکه شما یک دستگاه نیستید نمیتوانید کد ماشین را درک کنید، ما از کامند زیر برای درخواست یک لیست زبان اسمبلی استفاده میکنیم که کد ماشین را بهصورت قابلدرک و خواندن برای انسان چاپ میکند، البته همراه با کامند های زبان اسمبلی مربوطه که آن کد را ایجاد کرده است:
1 | $ gcc -Wall -Wextra -g -Wextra -Wa,-a=hello.lst -c hello.c |
بخش -Wa به GCC میگوید که آنچه به دنبال دارد به اسمبلر پاس داده شود.
بخش a=hello.lst- به اسمبلر میگوید که یک فایلی به نام hello.lst تولید کند.
بیایید به این فایل نگاهی بیندازیم که بهصورت زیر شروع میشود:
1 2 3 4 5 6 7 8 9 10 11 | 4 .section .rodata 5 .LC0: 6 0000 48656C6C .string "Hello World!" 6 6F20776F 6 726C6421 6 00 |
زبان اسمبلی در هر دستگاه متفاوت است. در این فایل، شما با زبان اسمبلی x86 روبرو هستید که ممکن است کمی آشفته بهنظر برسد، حتی در مقایسه با سایر زبانهای اسمبلی. احتمالاً آن را کاملاً متوجه نخواهید شد، اما اشکالی ندارد فهم کامل آن ضروری نیست؛ هدف این فصل فقط این است که شما را با ظاهر زبان اسمبلی آشنا کند . در فصلهای بعدی، وقتی به پردازنده ARM میرسیم، با زبان اسمبلی سادهترو قابل فهم تری روبهرو خواهید شد.
ستون اول یک شماره خط (line number) از فایل زبان اسمبلی است. ستون دوم، البته اگر وجود داشته باشد، آدرس دادههای ذخیره شده را نشان میدهد.. همه اسلاتهای حافظه کامپیوتر یک آدرس عددی دارند. در این مورد، رشته “Hello World!” در آدرس 0000 نسبت به بخشی که در حال حاضر استفاده میشود (در اینجا بخشی با عنوان rodata) در حال ذخیرهشدن است.
در قسمت بعدی در مورد لینکر (linker) بحث میکنیم، خواهیم دید که این آدرس نسبی چگونه به یک آدرس مطلق اسمبل میشود.
ستون بعدی شامل مقادیر عددی است که در حافظه به فرمت هگزادسیمال ذخیره میشوند. سپس متن کد زبان اسمبلی میآید. در فایل، میتوانیم ببینیم که کامند .string به اسمبلر میگوید کدهای استرینگ متن را تولید کند.
در انتهای فایل، کد main را پیدا میکنیم:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | 15 0000 55 pushq %rbp 16 .cfi_def_cfa_offset 16 17 .cfi_offset 6, -16 18 0001 4889E5 movq %rsp, %rbp 19 .cfi_def_cfa_register 6 12:hello.c **** printf("Hello World!\n"); 20 .loc 1 12 0 21 0004 BF000000 movl $.LC0, %edi 21 00 22 0009 E8000000 call puts 22 00 |
در خط ۱۵، کامند زبان اسمبلی ۵۵ را مشاهده میکنیم که در مکان0 (در این بخش) ذخیره خواهد شد. این کامند با کامند pushq %rbp متناظر است که در ابتدای روند، برخی کارهای ساماندهی (bookkeeping) را انجام میدهد. همچنین توجه کنید که برخی از کامند های ماشین، ۱ بایت طول دارند و برخی دیگر تا ۵ بایت طول دارند. کامند در خط ۲۱ نمونهای از کامند پنج بایتی است.
میتوانید ببینید که این کامند با .LC0 در ارتباط است. اگر به بالای لیست نگاه کنید، میبینید که .LC0 رشته (string) ما است. بهعنوان یک برنامهنویس C ، انتظار نمیرود که به طور کامل و دقیق بفهمید زبان اسمبلی چه کاری انجام میدهد. درک کامل نیازمند مطالعه چندهزارصفحهای از منابع مرجع است. اما میتوانیم نوعی کامند در خط ۲۲ که تابع puts را فراخوانی میکند بفهمیم. اینجاست که مسائل جالب میشوند. به یاد دارید که برنامهی C ما puts را فراخوانی نکرده بود؟ بلکه از printf استفاده کرده بود.
به نظر میرسد که کد ما به طور پنهانی بهینهسازی شده است. در برنامهنویسی امبدد، بهینهسازی ممکن است ناخوشایند باشد، بنابراین مهم است که بفهمیم چه اتفاقی اینجا افتاده است. در اصل، کامپایلر C به خط
printf(“Hello World!\n”); نگاه کرده و تصمیم گرفته همانند خطوط زیر باشد:
1 | puts("Hello World!"); |
حقیقت این است که این توابع همانند هم نیستند: puts یک تابع ساده و کارآمد است، درحالیکه printf یک تابع بزرگ و پیچیده است. اما برنامهنویس هیچ یک از ویژگیهای پیشرفته printf را لازم ندارد، بنابراین بهینهساز تصمیم گرفت که کد را بهبود بخشد. بهعبارتدیگر، فراخوانی printf ما به puts تبدیل شد و کاراکتر (\n) از رشته حذف شد، زیرا فراخوانی puts به طور خودکار یک n)\( اضافه میکند. در امور مربوط با سختافزار، اتفاقات کوچکی مانند این، میتوانند تفاوت بزرگی در عملکرد دستگاه ایجاد کنند، بنابراین، مهم است که بدانید چگونه کد اسمبلی را مشاهده و تا حدی از فهمیدن آن استفاده کنید.
خروجی مترجم، یک آبجکت کد (object)، حاوی کدی که ما نوشتیم است. البته، حاوی تابع puts که ما به آن نیاز داریم نیست. تابع puts همراه با صدها تابع دیگر، در کتابخانه استاندارد (libc) C قرار دارد.
در این قسمت با عملکرد پیش پردازنده، کامپایلر و اسمبلر آشنا شدیم. در قسمت بعدی به بررسی لینکر می پردازیم. با سیسوگ همراه باشید.