تولید و توسعه کامپایلرها یکی از پیچیدهترین و سنگینترین فرآیندهای برنامهنویسی است. پیادهسازی صحیح یک کامپایلر مستلزم داشتن درکی عمیق از پارادایمهای برنامهنویسی، شناخت سختافزار و نحوه کارکرد ماشینها و همینطور توانایی و استعدادی زیاد در حوزه ریاضیات و منطق است. به همین دلیل، تعداد کامپایلرهای موفق و مطرح، به ویژه آنهایی که با هدف گرفتن پلتفرمهای مختلف توانستهاند به جامعه کاربری گستردهای دست یابند، چندان زیاد نیست. طبیعی است که توسعه و نگهداری چنین پروژههایی نیز همواره بر عهده شرکتها و نهادهای بزرگ و قدرتمند نرمافزاری بوده است.
پیچیدگی کامپایلرها از یک سو و وابستگی اجزای مختلف آن از سوی دیگر باعث شده است تا دستکاری، ایجاد تغییر و مشارکت در توسعه کامپایلرها (حتی در نمونههای آزادی نظیر GCC) تنها محدود به درصد اندکی از توسعهدهندگان باشد. علاوه بر این، یکپارچگی کامپایلر، امکان بازنویسی و استفاده دوباره از یک کامپایلر برای پشتیبانی از زبانهای برنامهنویسی جدید یا پلتفرمهای سختافزاری تازه را دشوار و گاهی ناممکن میسازد. اینجا است که طراحی ماجولار کامپایلری بهنسبت جوان با نام LLVM مزیتهایی را به دنبال خواهد داشت که میتواند توسعه و بهینهسازی کامپایلرها را سادهتر کرده و دایره مشارکتکنندگان و استفادهکنندگان آن را گستردهتر سازد. پروژه LLVM مجموعهای از فناوریهای کامپایلر و زنجیره ابزارهای توسعه ماجولار است که برخلاف نامش، ارتباط چندانی با ماشینهای مجازی ندارد. این کامپایلر به زبان C++ و برای بهینهسازی (Optimization) کدهای زبان ماشین در زمان کامپایل، زمان لینک کردن و حتی زمان اجرا نوشته شده است. این پروژه در سال 2000 در دانشگاه ایلینویز و به رهبری ویکرام ادوه (Vikram Adve) و کریس لاتنر (Chris Lattner) کلید خورد و هدف اصلی آن ایجاد زیرساختی برای انجام تحقیقات در زمینه کامپایل دینامیک بود. این پروژه از ابتدا زیرپوشش مجوز اپنسورس دانشگاه ایلینویز که شبیه مجوزهای BSD است، برای استفاده عموم عرضه شده است. اگرچه LLVM به زبان C++ و در ابتدا برای خانواده زبانهای C نوشته شده بود، اما ذات مستقل و ماجولار این کامپایلر که به زبان خاصی وابسته نبود، زمینه را برای ساخت Frontendهای متنوع آماده ساخته و امکان استفاده از LLVM برای کامپایل زبانهای فراوانی از جمله آدا، هسکل، پایتون، روبی، فرترن و... روی پلتفرمهای سختافزاری مختلف را نیز فراهم کرد. برای درک تأثیر و اهمیت این پروژه کافی است در نظر داشته باشیم که شرکت اپل در سال 2005، لاتنر را به استخدام خود درآورد و گروهی را برای کار روی LLVM و استفاده از آن در توسعه نرمافزارهای اپل تشکیل داد. از آن زمان، LLVM یکی از اجزای اصلی ابزارهای توسعه، چه در سکوی Mac OS X و چه در iOS بوده است. آنچه در ادامه میآید، بخشی از کتاب «معماری برنامههای اپنسورس» (Architecture of Open Source Applications) است.
طی پنج سال گذشته، LLVM از پروژهای آکادمیک به Back-end جهانی کامپایلرهای C++ ، C و Objective C تبدیل شده است. کلید این موفقیت، کارایی، انعطافپذیری و سازگاری آن است که هر دوی این خصوصیات از طراحی و پیادهسازی منحصربهفرد آن نشأت گرفته است.پروژه LLVM چتری است که مجموعهای از بخشهای مرتبط و سطح پایین زنجیره ابزارهای توسعه (نظیر اسمبلرها، کامپایلرها، دیباگرها و...) را در سایه خود میزبانی کرده و توسعه میدهد. این ابزارها بهگونهای طراحی شدهاند که با ابزارهای کنونی و موجود مورد استفاده در سکوی یونیکس سازگار باشند. نام LLVM زمانی سرنام «Low Level Virtual Machine» بود، اما اکنون به برندی برای پروژه مادر تبدیل شده است. در حالیکه LLVM ویژگیهای منحصربهفردی را عرضه میکند و بهواسطه استفاده گسترده از برخی ابزارهایش (نظیر Clang که کامپایلری برای C++، C و Objective C است و نسبت به GCC مزیتهای خاص خود را دارد) شناخته میشود، اما مهمترین وجه تمایز آن با سایر کامپایلرها، معماری درونی آن است. از شروع پروژه در دسامبر 2000 میلادی، LLVM به صورت مجموعهای از کتابخانهها با قابلیت استفاده دوباره و رابطهایی (Interface) کاملاً تعریف شده، طراحی شد. در آن زمان پیادهسازیهای اپنسورس زبانهای برنامهنویسی، بهعنوان یک ابزار تک منظوره طراحی میشدند. به عنوان مثال، استفاده دوباره از parser یک کامپایلر استاتیک نظیر GCC، برای انجام تحلیلهای آماری و refactoring بسیار دشوار بود. در حالیکه زبانهای اسکریپتی معمولاً راهی را برای تعبیه مفسر و کتابخانههای زمان اجرایشان در نرمافزارهای بزرگتر فراهم میکردند، این کتابخانههای زمان اجرا معمولاً قطعهای حجیم و یکپارچه از کد بود که میتوانست در نرمافزار قرار داده شده یا از آن حذف شود. راهی برای استفاده دوباره از قسمتهای مختلف آنها وجود نداشت و اشتراکات اندکی میان پیادهسازیهای مختلف یک زبان دیده میشد.
فراتر از چیدمان اجزای خود کامپایلر، جامعه کاربری شکل گرفته پیرامون پیادهسازیهای محبوب زبانها نیز معمولاً به شدت دو قطبی بود. یک پیادهسازی خاص، یا یک کامپایلر استاتیک مانند GCC،Free Pascal یا Free BASIC را عرضه میکرد یا به عرضه یک کامپایلر زمان اجرا (در قالب یک مفسر یا کامپایلر JIT) میپرداخت. پیادهسازیهایی که هر دوی این قابلیتها را عرضه کنند، به ندرت دیده میشدند و در صورت وجود، اشتراک اندکی میان کدهای نوشته شده برای این دو وضعیت وجود داشت. در ده سال اخیر اما، LLVM این چشمانداز را از اساس دگرگون کرده است. اکنون از LLVM به عنوان زیرساختی برای پیادهسازی گستره وسیعی از کامپایلرهای استاتیک و زماناجرا استفاده میشود. این گستره وسیع، خانواده زبانهایی را که توسط GCC، جاوا، داتنت، پایتون، روبی، اسکیم، هسکل وD پشتیبانی میشوند و همینطور بسیاری از زبانهای کمتر شناختهشده دیگر را شامل میشود. کامپایلر LLVM همچنین جایگزین بسیاری از کامپایلرهای با استفاده خاص نیز شده است. از میان این کامپایلرها میتوان به موتور بهینهسازی زمان اجرای OpenGL در سیستمعامل اپل، کتابخانههای پردازش تصویر و پشتهها در After Effects ادوبی اشاره کرد. درنهایت LLVM برای تولید محصولات جدید و متنوعی نیز مورد استفاده قرار گرفته است که شناختهشدهترین آنها شاید OpenCL و کتابخانههای زمان اجرای آن باشد.
آشنایی با طراحی کلاسیک کامپایلرها
معمولترین طراحی برای یک کامپایلر استاتیک سنتی (مانند بیشتر کامپایلرهای C) یک طراحی سه مرحلهای است که اجزای اصلی آن Frontend، بخش بهینهسازی یا Optimizer و Backend هستند.(شکل 1) بخش Frontend وظیفه parse کردن کد منبع، کنترل آن برای خطاها و ایرادات و ساخت یک «درخت نحوی انتزاعی» («AST » سرنام
Abstract Syntax Tree که نمایشی انتزاعی از کد ورودی است) را برعهده دارد. در برخی شرایط خود AST برای بهینهسازی در بخش Optimizer به فرم جدیدی تبدیل میشود و Optimizer و Backend این کد را اجرا میکنند.(شکل1)بخشهای اصلی یک کامپایلر سه بخشیوظیفه Optimizer این است که در راستای بهبود کارایی کد در زمان اجرا، تغییرات متنوعی را در آن به وجود آورد؛ مثلاً محاسبات اضافی را حذف کند. این قسمت کموبیش از زبان مورد استفاده و معماری ماشین مقصد مستقل است. پس از آن، قسمت Backend (که به مولد کد یا Code Generator نیز مشهور است) کد را به مجموعه دستورالعملهای ماشین مقصد نگاشت میکند. علاوه بر ایجاد کد صحیح و بدون خطا، ایجاد کدهای خوب و سریع نیز بر عهده این قسمت است. به این معنی که قسمت Backend باید بتواند از قابلیتهای اختصاصی معماری موردنظر بهره برده و کدهای بهینهتری ایجاد کند. قسمتهای معمول Backend یک کامپایلر، بخشهای انتخاب دستورالعمل، تخصیص ثبات (Register) و زمانبندی دستورالعملها هستند. این مدل، درست به همین شکل برای مفسرها و کامپایلرهای JIT نیز صادق است. ماشین مجازی جاوا (JVM) نیز یکی از انواع پیادهسازیهای این مدل است که از بایتکدهای جاوا به عنوان رابطی (Interface) میان Frontend و Optimizer بهره میبرد.
پیادهسازی این طراحی
مهمترین برگ برنده این طراحی کلاسیک، زمانی رو میشود که یک کامپایلر، بخواهد چندین زبان برنامهنویسی یا چندین معماری مقصد را پشتیبانی کند. همانطور که در شکل 2 مشاهده میکنید، در چنین صورتی اگر کامپایلر از یک نمایش یا نحوه ارائه یکسان در Optimizer استفاده کند، میتوان برای هر یک از زبانهای موردنظر یک Frontend جداگانه نوشت که کدهای آن زبان را به فرم مورد استفاده در Optimizer تبدیل کند و پس از آن برای هر معماری موردنظر نیز یک Backend جداگانه ایجاد کرد تا حاصل کار Optimizer را به کدهای آن معماری ترجمه کند. (شکل 2)
هدف گرفتن چندین معماری مقصدبا این سیستم طراحی، پورتکردن یک کامپایلر برای پشتیبانی از یک زبان جدید (مثل Algol یا BASIC) تنها به پیادهسازی یک Frontend جدید نیاز دارد، اما Optimizer و Backend موجود به همان شکل قابل استفاده هستند. اگر این بخشها از هم جدا نشده بودند، کار با یک زبان برنامهنویسی جدید مستلزم ایجاد همه چیز از صفر بود و به این ترتیب برای پشتیبانی از n زبان برنامهنویسی و m معماری سختافزاری به m*n کامپایلر نیاز بود.یکی دیگر از مزیتهای طراحی سه مرحلهای (که بهطور مستقیم نتیجه قابلیت هدفگیری چندین معماری مختلف است) این است که کامپایلر میتواند در خدمت مجموعه وسیعتری از برنامهنویسان باشد و نه تنها برنامهنویسان یک زبان خاص با معماری مقصد یکسان. برای یک پروژه اپنسورس، این به معنای جامعه بزرگتری از مشارکتکنندگان احتمالی است که خود باعث بهبود یافتن و پیشرفت سریعتر کامپایلر خواهد شد. درست به همین دلیل است که کامپایلرهای اپنسورس (نظیر GCC) که توسط جوامع کاربری فراوانی مورد استفاده قرار میگیرند، معمولاً نسبت به کامپایلرهای خاصتر (نظیر Free Pascal) کدهای ماشین بهینهتری تولید میکنند. اما این قضیه در مورد کامپایلرهای غیر اپنسورس اختصاصی صحت ندارد. کیفیت چنین کامپایلرهایی مستقیماً به بودجه پروژه وابسته خواهد بود. به عنوان مثال کامپایلر ICC اینتل (هرچند طیف مخاطبان اندکی دارد) به واسطه کیفیت کدی که تولید میکند، بسیار مشهور شده است.در نهایت، یکی از مهمترین برتریهای طراحی سهمرحلهای این است که مهارتهای لازم برای ایجاد Frontend با مهارتهای مورد نیاز برای ایجاد Optimizer و Backend کاملاً متفاوت هستند. با جداسازی این بخشها، نگهداری و ارتقای بخش Frontend برای برنامهنویسان آن قسمت سادهتر خواهد بود. اگرچه این موردی اجتماعی به شمار میرود، در عمل بسیار حیاتی خواهد بود، به ویژه در پروژههای اپنسورس که سعی دارند موانع را به حداقل برسانند تا بتوانند بیشترین میزان مشارکت را جذب کنند.
پیادهسازیهای زبانهای موجود
با اینکه مزایا و فواید این طراحی سه مرحلهای بسیار وسوسهکننده هستند و بهخوبی در کتابهای آموزشی مربوط به کامپایلرها مدون شدهاند، در عمل این طراحی هیچگاه به صورت کامل پیاده نمیشود. با نگاهی به پیادهسازیهای زبانهای اپنسورس (البته در گذشته یعنی زمانی که LLVM کار خود را آغاز کرد) مشاهده خواهید کرد که پیادهسازیهای پرل، پایتون، روبی و جاوا هیچ کد مشترکی ندارند! علاوه بر این، خواهید دید که پروژههایی نظیر GHC (سرنام Glasgow Haskell Compiler) یا FreeBASIC میتوانند پردازندههای مختلفی را هدف بگیرند، اما پیادهسازی آنها تنها منحصر به پشتیبانی از یک زبان برنامهنویسی خاص است. همچنین فناوریهای متنوعی برای پیادهسازی کامپایلرهای خاص و تک منظوره نظیر کامپایلرهای JIT مخصوص پردازش تصویر، RegEx، درایورهای کارتهای گرافیک و سایر حوزههایی که به انجام پردازشهای فراوان توسط پردازنده وابسته هستند، مورد استفاده قرار میگیرد. با همه اینها، سه شیوه موفق استفاده از این مدل طراحی را میتوان مشاهده کرد. شیوه نخست در جاوا و ماشینهای مجازی داتنت قابل مشاهده است. این سیستمها یک کامپایلر JIT، پشتیبانی از Runtime و یک قالب بایتکد با تعریف بسیار دقیق و مناسب را فراهم میکنند. در این صورت هر زبانی که بتواند به آن بایتکد کامپایل شود (که تعدادشان هم به شدت زیاد است) میتواند از تواناییها و قابلیتهای Optimizer، JIT و همینطور Runtime استفاده کند.
نکته منفی چنین پیادهسازیهایی این است که انعطافپذیری اندکی در انتخاب Runtime دارند. هر دو مورد کامپایل JIT، Garbage Collection و استفاده از یک مدل خاص اشیا (Object Model) را به کاربر تحمیل میکنند. این امر باعث میشود که زبانهایی نظیر C (مثلاً در پروژه LLVM) که به خوبی با این مدل سازگار نیستند، به کارایی حداکثری خود دست نیابند. شیوه دوم (که شاید بدشانسترین آنها و در عین حال پرکاربردترین روش برای استفاده دوباره از فناوری کامپایلر باشد) ترجمه کد منبع به زبان C (یا سایر زبانها) و ارسال آن به کامپایلرهای موجود C است.
این کار امکان استفاده دوباره از Optimizer و Backend یا مولد کد را فراهم آورده کنترل و انعطافپذیری خوبی را برای Runtime ممکن میسازد و درک، پیادهسازی و نگهداری آن را برای برنامهنویسان Frontend بسیار ساده میکند. متأسفانه چنین کاری امکان پیادهسازی کارای سیستم مدیریت استثنا (Exception Handling) را از بین برده، تجربه دیباگ ناخوشایندی را برای کاربر به ارمغان میآورد و فرآیند کامپایل را کند میکند. علاوهبر این، اگر زبانی که مورد استفاده قرار میگیرد، نیازمند قابلیتهایی باشد که در C وجود ندارند (مثلاً Tail Callهای تضمین شده) این امر میتواند دردسر ساز باشد.آخرین شیوه موفق پیادهسازی چنین مدلی، GCC (سرنام GNU Compiler Collection) است. کامپایلر GCC از Frontendها و Backendهای متنوعی پشتیبانی کرده و جامعه بزرگ و فعالی از مشارکتکنندگان را در کنار خود دارد. این کامپایلر مدتها تنها یک کامپایلرC بود که میتوانست معماریهای متنوعی را هدف بگیرد و با هکهای مختلف، تعداد اندکی از زبانهای برنامهنویسی دیگر را هم پشتیبانی میکرد. با گذشت زمان، جامعه GCC به تدریج به طرحی تمیزتر و بهتر دست یافت.
در نسخه 4/4، GCC برای Optimizer از نحوه نمایش کدی استفاده میکند که GIMPLE Tuples نامیده میشود و بسیار بیش از نمونههای قبلی از Fontend مستقل شده است. همچنین Frontendهای آدا و فرترن آن از ASTهای تمیز و پالودهای بهره میبرند. این سه شیوه پیادهسازی در عین موفقیت فراوان، محدودیتهای زیادی در موارد استفاده و کاربرد دارند. دلیل این محدودیتها این است که به عنوان برنامههایی یکپارچه طراحی شدهاند. به عنوان یک نمونه، در کاربردهای واقعی نمیتوان GCC را به صورت توکار در برنامه دیگری گنجاند، از آن به عنوان یک کامپایلر JIT یا Runtime استفاده کرد یا قسمتهایی از آن را بدون نیاز به بخشهای دیگر بیرون کشیده و مورد استفاده دوباره قرار داد. افرادی که میخواستند از Frontend زبان C++ در GCC برای ایجاد مستندات، نشانهگذاری کد، Refactoring و ابزارهای تحلیل آماری استفاده کنند، مجبور بودند از GCC به عنوان یک برنامه یکپارچه استفاده کنند که اطلاعات جالب و مفید را در قالب XML بیرون میداد یا باید پلاگینی مینوشتند که کدهای خارجی را در پردازه GCC تزریق میکرد. دلایل متعددی وجود دارد که باعث میشود نتوان از اجزای GCC بهعنوان کتابخانههای مستقل استفاده دوباره به عمل آورد. استفاده فراوان از متغیرهای جهانی، استفاده اختیاری از ثابتها، ساختارهای دادهای با طراحی ضعیف، کدهای مبنای پراکنده و نابسامان و در نهایت استفاده از ماکروها، از جمله این دلایل هستند. به عنوان مثال، استفاده از ماکروها باعث میشود که کدهای مبنا تنها به گونهای کامپایل شوند که در هر زمان تنها یک Frontend و یک Backend پشتیبانی شود. هرچند مشکلی که اصلاح آن از همه سختتر است، مشکلات ارثی معماری است که از ریشههای اولیه و قدمت زیاد این کامپایلر سرچشمه میگیرند.
کامپایلر GCC به صورت خاص از لایهبندی و انتزاعهای ضعیف رنج میبرد. قسمت Backend کدهای AST تولید شده توسط Frontend را پیمایش میکند تا اطلاعات دیباگ را تولید کند، قسمت Frontend ساختارهای دادهای مرتبط با Backend را به وجود میآورد و کل کامپایلر به ساختارهای داده جهانی (Global) متکی است که توسط رابط خط فرمان تنظیم میشوند.
Talk.Patoghu
Com.
انجمن تخصصی پاتوق یو
علاقه مندی ها (Bookmarks)