farhad shiri
farhad shiri
خواندن ۲۴ دقیقه·۵ سال پیش

بهینه سازی کدهای اسمبلی در ++C


بهینه نوشتن کدها، در نرم افزار امری است که قطعا از اهمیت بسزایی در تولید یک سیستم پایدار و پاسخ پذیر برخوردار می باشد.

بنابراین در این مقاله نگاهی کوتاه به بهینه کردن یک سناریو خواهیم داشت.در یکی از پروژه ها که بسیار امنیتی و بحرانی (safety critical) هست، برای اینکه کنترل دقیقی بر روی بایت هایی که توسط این برنامه در حافظه بارگذاری میشوند و همچنین حصول اطمینان با ضریب بسیار بالا در هنگام کپی کردن این بایت ها به یک آدرس دیگر در حافظه در همان ماشین ویا حتی در یک ماشین دیگر، نیاز به کدهایی داشتیم که بتوانیم این پروسه انتقال بایت ها در آدرس های جدید را دقیقا خودمون کنترل کنیم، تا بدین شکل بتوانیم تا درصد بسیار بالایی عملکرد سیستم را تضمین کنیم. نکته ای که بسیار اهمیت دارد در نوشتن چنین کدهایی بهینه بودن این کدهاست. قابل اذعان هست که همانطور که می دانیم کامپایلر های مدرن امروزی به بهترین شکل ممکن با استفاده از ابزارهای بهینه سازی کدی که دارند تاحد زیادی این مهم را برآورده میکنند، وهمچنین استفاده از intrinsic function ها در زبان سی پلاس پلاس توصیه شده است. ولی در سناریو پیش روی ما چون از intrinsic function های خود زبان ++C استفاده نکردیم، از تکنیک inline assembly استفاده کرده ایم تا بتوانیم جریان انتقال هر بایت را به آدرس جدید مدیریت کنیم.

اکنون با توضیحاتی که شرح داده شد، سناریو پیش روی ما اینچنین خواهد بود.

کامپایلر : GCC , اسمبلر : GASM , اسمبلر کد اسمبلی native : نت واید اسمبلر NASM , لینکر : GCC البته از ابزار ld هم می توانید در لینوکس به عنوان لینکر استفاده نمایید

سیستم عامل : Linux-Fedora , معماری : IA x86

1- کپی کردن بایت به بایت یک آرایه از اطلاعات با استفاده از تکنیک inline assembly

تصور کنید که چنین کدی را برای شروع تعریف کرده ایم...

const int NUMWORDS = 6; char dest[NUMWORDS]; const char src[NUMWORDS] = {0x41,0x42,0x43,0x44,0x45,'\0'};

در مرحله اول دو آرایه با اندازه یکسان تعریف کرده ایم و یکی از آنها را با مقادیر مشخصی مقدار دهی اولیه کرده ایم.

نکته مهم:

"با توجه به اینکه در حال توضیح کدهای بهینه هستیم، تعریف یک ثابت عددی NUMWORDS در این سناریو به این علت است که فرض بر این است که برنامه شما از یک سطح مناسبی از optimize توسط کامپایلر استفاده کرده است، به طور مثال من چون از کامپایلر GCC استفاده میکنم سطح بهینه سازی O3 را استفاده کرده ام، بدین ترتیب کدی که کامپایلر برای این ثابت عددی تولید خواهد کرد در یک section دیگه تعریف خواهد شد که نیازی نیست که برای این ثابت حافظه ای اختصاص دهد. پس اگر از بهینه سازی داخلی کامپایلر استفاده نکنید احتمالا کدی که کامپایلر تولید میکند یک تخصیص حافظه به این ثابت عددی در همان function layer خواهد داشت که خوب قطعا کار بهینه ای نمی باشد."

بنابراین کدهای اسمبلی زیر را تعریف میکنیم...

__asm__ __volatile__(&quotb_strCpy:lodsb;\n&quot &quotstosb;\n&quot &quottestb %%al,%%al;\n&quot &quotjne b_strCpy&quot : &quot=&S&quot (wd0), &quot=&D&quot (wd1) : &quot0&quot (src),&quot1&quot (dest) : &quotmemory&quot );

همانطور که از کد مشخص هست، ما با استفاده از دستورات loads , stos هر بایت را به صورت جداگانه به آدرس جدید منتقل کرده ایم.

  • و کامپایلر نیز کدی که برای ما تولید کرده به صورت زیر خواهد بود..

ابتدا آدرس دهی آرایه سورس در حافظه...

0x0804f7d6 <main()+22>: movb $0x41,0x24(%esp) 0x0804f7dd <main()+29>: movb $0x42,0x25(%esp) 0x0804f7e2 <main()+34>: movb $0x43,0x26(%esp) 0x0804f7e7 <main()+39>: movb $0x44,0x27(%esp) 0x0804f7ec <main()+44>: movb $0x45,0x28(%esp) 0x0804f7f1 <main()+49>: movb $0x0,0x29(%esp)

و سپس کد تولیدی کامپایلر (البته قبل از بهینه سازی)...

0x0804f0c3 <main()+73>: lea 0x7fcada6c(%esp),%edx 0x0804f0ca <main()+80>: lea 0x7fcada72(%esp),%eax 0x0804f0d1 <main()+87>: mov %edx,%esi 0x0804f0d3 <main()+89>: mov %eax,%edi 0x0804f0d5 <main()+91>: lods %ds:(%esi),%al 0x0804f0d6 <main()+92>: stos %al,%es:(%edi) 0x0804f0d7 <main()+93>: test %al,%al 0x0804f0d9 <main()+95>: jne 0x804f0d5 <main()+91> 0x0804f0db <main()+97>: mov %edi,%eax 0x0804f0dd <main()+99>: mov %esi,%edx 0x0804f0df <main()+101>: mov %edx,0x7fcadae4(%esp) 0x0804f0e6 <main()+108>: mov %eax,0x7fcadae8(%esp)

همانطور که مشاهده میکنید کامپایلر برای محاسبه آدرس فیزیکی آرایه ها با استفاده از دستور lea آدرس های بزرگتری را استفاده کرده است.

و کد تولید شده بعد از بهینه سازی...

x0804f7cc <main()+12>: lea 0x24(%esp),%ebx 0x0804f7d0 <main()+16>: lea 0x2a(%esp),%ed 0x0804f7d4 <main()+20>: mov %ebx,%esi 0x0804f7db <main()+27>: mov %edx,%edi 0x0804f7f6 <main()+54>: lods %ds:(%esi),%al 0x0804f7f7 <main()+55>: stos %al,%es:(%edi) 0x0804f7f8 <main()+56>: test %al,%al 0x0804f7fa <main()+58>: jne 0x804f7f6 <main()+54>

ولی در کد بهینه شده از آدرس های کوتاهتری استفاده کرده است.

اکنون سوال اصلی این هست که آیا میتوان کد تولید شده توسط کامپایلر را بهینه تر کرد؟

بنابراین با طرح این سوال اینک روش دوم را برای کپی کردن بایت ها استفاده خواهیم کرد.

برنامه زیر را درنظر بگیرید...

__asm__ __volatile__( &quotb_strCpy2:\n&quot &quotmovsb\n&quot &quottestl %%al , %%al\n&quot &quotjne b_strCpy2&quot : &quot=&S&quot (wd0), &quot=&D&quot (wd1) : &quot0&quot (src),&quot1&quot (dest) : &quotmemory&quot );

همانطور که مشاهده میکنید عملکرد این تکه کد هم دقیقا مشابه به کد قبلی می باشد ولی با دستورات کمتر در این روش از دستور movs برای انتقال بایت ها استفاده کرده ایم.

و کد تولید شده کامپایلر (قبل از بهینه سازی)...

0x0804f0ed <main()+115>: lea 0x7fcada6c(%esp),%edx 0x0804f0f4 <main()+122>: lea 0x7fcada72(%esp),%eax 0x0804f0fb <main()+129>: mov %edx,%esi 0x0804f0fd <main()+131>: mov %eax,%edi 0x0804f0ff <b_strCpy2>: movsb %ds:(%esi),%es:(%edi) 0x0804f100 <b_strCpy2+1>: test %eax,-0x1(%edi,%edx,1) 0x0804f104 <b_strCpy2+5>: jne 0x804f0ff <b_strCpy2> 0x0804f106 <b_strCpy2+7>: mov %edi,%eax 0x0804f108 <b_strCpy2+9>: mov %esi,%edx 0x0804f10a <b_strCpy2+11>: mov %edx,0x7fcadae4(%esp) 0x0804f111 <b_strCpy2+18>: mov %eax,0x7fcadae8(%esp)

با توجه به کد تولید شده همانطور که مشاهد میکنید کامپایلر بدون واسطه های ثباتی پردازشگر مستقیما یک بایت را از offset سگمنت ds به حافظه منتقل میکند، وهمچنین از همان سگمنت و آفست به آدرس جدید کپی میکند، بنابراین باتوجه به اینکه ما هم از instruction های کمتری نسبت به کد قبل استفاده کرده ایم،(که این یعنی سرعت پردازش بیشتر ) ، و همچنین دستوری که استفاده شده است جهت انتقال بایت ها به علت عدم استفاده از ثبات های واسطه می تواند عملکرد بهتری داشته باشد. ولی در طولانی مدت کد فوق از خوانایی و نگه داری خوبی برخوردار نیست والبته تا حدی هم تضمین عملکرد این کد سخت تر از روش قبل می باشد، چرا که اگر به opcode تولید شده اسمبلر نگاه کنید متوجه خواهید شد که که کد تولید شده برای ماشین کمی پیچیده تر از کد ماشین تکه کد قبلی است، مضاف بر اینکه در تکه کد قبل برنامه نویس به بایت منتقل شده در بین انتقال با استفاده از ثبات ها دسترسی داشت که نیازی به فراخوانی حافظه نداشت ولی در این روش دسترسی محدود تر شده است و جهت دسترسی به بایت منتقل شده حتما یک دسترسی به حافظه خواهیم داشت که با توج به این موضوع که هرچه فراخوانی به حافظه کمتری داشته باشیم یعنی پردازشگر نیازی به بر روز رسانی جداول آدرس دهی و به روز رسانی کش ها و حافظه مجازی و همچنین ترافیک کمتر در data bus را خواهیم داشت که این موضوع بسیار حائز اهمیت می باشد در بحث بهینه سازی کد.

و کد تولید شده کامپایلر (بعد از بهینه سازی)...

0x0804f7fc <main()+60>: mov %ebx,%esi 0x0804f7fe <main()+62>: mov %edx,%edi 0x0804f800 <b_strCpy2>: movsb %ds:(%esi),%es:(%edi) 0x0804f801 <b_strCpy2+1>: test %eax,-0x1(%edi,%edx,1) 0x0804f805 <b_strCpy2+5>: jne 0x804f800 <b_strCpy2>

همانطور هم که از کد تولید شده مشخص هست بخشهایی که در کد تولید شده قبل، وظیفه انتقال بایت ها به stack را داشتند در کد فوق حذف شده است و کد فوق بهینه تر خواهد بود.

اکنون مجددا این سوال پیش می آید که آیا بازهم می توان کد فوق را بهینه تر کرد؟

یا حداقل به نحوی نوشت که قابلیت نگهداری و خواندن کد درآینده کمی بهتر از کد های قبلی باشد ودر ضمن امکان دسترسی به بایت ها با استفاده از ثبات ها وجود داشته باشد.

بنابراین میتوان چنین کدی را هم نوشت...

__asm__ __volatile__(&quotxorl %%edx , %%edx\n&quot&quotb_strCpy3:\n&quot &quotmovb (%%esi,%%edx) , %%al\n&quot &quotmovb %%al , (%%edi,%%edx)\n&quot &quotincl %%edx \n&quot &quottestl %%eax , -1(%%edi,%%edx)\n&quot &quotjne b_strCpy3\n&quot : &quot=&S&quot (wd0), &quot=&D&quot (wd1) : &quot0&quot (src),&quot1&quot (dest) : &quotmemory&quot );

در نمونه کدی که مشاهده میکنید، با استفاده از دو دستور mov عملیات آدرس دهی و مقدار دهی هر بایت در حافظه انجام شده است، واز آنجائیکه یکی از سریعترین روشها همین روش آدرس دهی ثباتی می باشد و همچنین امکان دیباگ کردن هر instruction در هر step از دیباگینگ برای توسعه دهنده فراهم می باشد و همچنین به صورت صریح به ثبات های واسطه که حاوی بایت ها نیز هستند هم فراهم می باشد.

بنابراین کد تولید شده کامپایلر (قبل از بهینه سازی )...

0x0804f118 <b_strCpy2+25>: lea 0x7fcada6c(%esp),%edx 0x0804f11f <b_strCpy2+32>: lea 0x7fcada72(%esp),%eax 0x0804f126 <b_strCpy2+39>: mov %edx,%esi 0x0804f128 <b_strCpy2+41>: mov %eax,%edi 0x0804f12a <b_strCpy2+43>: xor %edx,%edx 0x0804f12c <b_strCpy3>: mov (%esi,%edx,1),%al0x0804f12f <b_strCpy3+3>: mov %al,(%edi,%edx,1) 0x0804f132 <b_strCpy3+6>: inc %edx 0x0804f133 <b_strCpy3+7>: test %eax,-0x1(%edi,%edx,1) 0x0804f137 <b_strCpy3+11>: jne 0x804f12c <b_strCpy3> 0x0804f139 <b_strCpy3+13>: mov %edi,%eax 0x0804f13b <b_strCpy3+15>: mov %esi,%edx 0x0804f13d <b_strCpy3+17>: mov %edx,0x7fcadae4(%esp) 0x0804f144 <b_strCpy3+24>: mov %eax,0x7fcadae8(%esp)

و همچنین کد تولید شده تو سط کامپایلر (بعد از بهینه سازی )...

0x0804f807 <b_strCpy2+7>: mov %ebx,%esi 0x0804f809 <b_strCpy2+9>: mov %edx,%edi 0x0804f80b <b_strCpy2+11>: xor %edx,%edx 0x0804f80d <b_strCpy3>: mov (%esi,%edx,1),%al 0x0804f810 <b_strCpy3+3>: mov %al,(%edi,%edx,1) 0x0804f813 <b_strCpy3+6>: inc %edx 0x0804f814 <b_strCpy3+7>: test %eax,-0x1(%edi,%edx,1) 0x0804f818 <b_strCpy3+11>: jne 0x804f80d <b_strCpy3>

بنابراین با توضیحاتی که ارائه شد میتوان عنوان کرد که بر اساس الگوریتم مورد نظر، همچنین نرم افزار مورد نظر که از چه درجه ای از اهمیت برخوردار هست ، تصمیم بگیرید که تا چه سطحی از بهینه سازی را انجام دهید وضمنا مشاهده کردیم که بهینه سازی های کامپایلر بسیار کاربردی می باشد علیرغم اینکه درک یک کد بهینه شده توسط کامپایلر مشکل تر و زمان بیشتری برای دیباگ کردن در زمان اجرا خواهد برد.

پس میتوان نتیجه گرفت که بسته به تعداد ورودی الگوریتم و حساسیت بایت ها کدام یک از سه روش مناسب تر از هر نظر میتواند باشد. به طور مثال در صورتی که بخواهید بازهم سطحی از بهینه سازی را انجام دهید می توانید در نمونه کد آخر با هر بار رجوع به حافظه چهار بایت ویا 8 بایت را منتقل کنید به آدرس جدید یعنی با یکبار آدرس دهی با دستور mov چهار بایت را منتقل کنید(با یک ثبات 32 بیتی) ویا با دو دستور mov چهار بایت کم ارزش را با یک ثبات 32 بیتی و 4 بایت پر ارزش را با یک ثبات 32 بیتی دیگر منتقل نمایید، که در این صورت تعداد jump های کمتری خواهیم داشت یعنی n/4 = x , n /8 = x jump خواهیم داشت که تعداد کمتری تکرار را خواهیم داشت نسبت به کدهای مطرح شده در بالا ولی موضوع سناریو ما رصد یک بایت یک بایت یک آرایه بوده است بنابراین با انتقال بایت های بیشتر این موضوع دچار اشکال خواهد شد. وهمچنین توجه داشته باشید که معماری استفاده شده 32 بیتی بوده است بنابراین از ثبات های 64 بیتی استفاده نشده است.

و در انتها کد اسمبلی سناریو مطرح شده با استفاده از نحو دستوری اسمبلر NASM را مشاهده می فرمایید.(البته با کمی تغییرات جزئی برای اسمبلرهای (FASM(Flat Asm) , MASM(Microsoft Macro Asm

قابل استفاده خواهد بود...


format ELF section '.data' readable writeable src dd 0x41,0x42,0x43,0x44,0x45,0x0 NUMWORDS = ($ - src ) / 4 LOOPCNT = NUMWORDS / 4 formatStr db &quot%s%0xc&quot, 0 section .bss dest dd NUMWORDS dup(?) section '.text' readable writeable executable byteCopy1: ;function int byteCopy(const char* src,char* dest) ;/*prologue func ;init stack push ebp mov ebp , esp ;sub ebp , 0x4 ;or can be use this instruction ;enter 4,0 ;/*body func mov esi , byte[ebp + 0x8] mov edi , byte[ebp + 0xc] b_strCpy1: lodsb stosb testb al,al jne b_strCpy1 ;/*epilogue func mov esp , ebp pop ebp ;or can be use thie instruction ;leave ret ;or clear stack can be use this ;ret 0x8 byteCopy2: ;function int byteCopy(const char* src,char* dest) ;/*prologue func ;init stack push ebp mov ebp , esp ;sub ebp , 0x4 ;or can be use this instruction ;enter 4,0 ;/*body func mov esi , byte[ebp + 0x8] mov edi , byte[ebp + 0xc] b_strCpy2: movsb test al,al jne b_strCpy2 ;/*epilogue func mov esp , ebp pop ebp ;or can be use thie instruction ;leave ret ;or clear stack can be use this ;ret 0x8 byteCopy3: ;function int byteCopy(const char* src,char* dest) ;/*prologue func ;init stack push ebp mov ebp , esp ;sub ebp , 0x4 ;or can be use this instruction ;enter 4,0 ;/*body func mov esi , byte[ebp + 0x8] mov edi , byte[ebp + 0xc] xor edx , edx b_strCpy3: mov al , byte[esi + edx] mov [edi + edx] , al inc edx testl eax , byte(edi + edx - 1) jne b_strCpy3 ;/*epilogue func mov esp , ebp pop ebp ;or can be use thie instruction ;leave ret ;or clear stack can be use this ;ret 0x8 byteCopy4: ;function int byteCopy(const char* src,char* dest) ;/*prologue func ;init stack push ebp mov ebp , esp ;sub ebp , 0x4 ;or can be use this instruction ;enter 4,0 ;/*body func mov esi , byte[ebp + 0x8] mov edi , byte[ebp + 0xc] mov ax , LOOPCNT mov cl , 0x4 div cl xor edx , edx b_strCpy4: mov eax , dword[esi + edx] mov [edi + edx] , eax add edx , 0x4 loop b_strCpy4 ; loop -> len / 4 ;/*epilogue func mov esp , ebp pop ebp ;or can be use thie instruction ;leave ret ;or clear stack can be use this ;ret 0x8 public _main extrn _printf _main: push dest push src call byteCopy1 ;can be choose func name ;if not clear stack from function layer into caller must be clear by callee add esp , 0x8 ;call macro push dest push formatStr call _printf ;call sys_exit routine mov eax , 1 xor ebx , ebx int 0x80

امیدوارم مفید فایده قرار گرفته باشد.

منتظر پیشنهادات و نظرات دوستان هستم

با تشکر

inline assemblyc inline assemblyoptimize code in assemblyoptimizationoptimization in compiler
یک توسعه دهنده نرم افزار
شاید از این پست‌ها خوشتان بیاید