دسترسی به رجیسترهای پریفرال در زبان C - بخش ۱: مقدمات

یکی از کارهای خیلی متداول در برنامه‌نویسی سطح پایین، دسترسی به رجیسترهای مربوط به پریفرال‌های یه میکروکنترلره. برای انجام این کار در زبان C، چه انتخاب‌هایی وجود داره؟ در این مقاله، قصد داریم تا به یه رجیستر از یه پریفرال پرکاربرد در میکروکنترلر STM32F030K6T6 دسترسی پیدا کنیم.

البته برای رسیدن به مقصد، باید آدرس رو بلد باشیم! اگر بخوایم به رجیستر یه پریفرال دسترسی پیدا کنیم، اول از همه باید آدرس اون رجیستر رو بدونیم؛ بالاخره اون رجیستر ما، داخل کل فضای آدرس حافظه‌ی میکروکنترلر، یه آدرسی مخصوص به خودش داره و ما باید اون آدرس رو داشته باشیم تا بعدش بتونیم بهش دسترسی پیدا کنیم. محاسبه‌ی آدرس اصلاً کار سختی نیست، ولی گاهی اوقات ممکنه که برای افرادی که به‌تازگی وارد این زمینه شده‌اند، کمی مبهم باشه. به همین خاطر، این بخش از مشکل رو جداگونه در مقاله‌ی محاسبه‌ی آدرس رجیسترهای پریفرال در STM32، بررسی کرده‌ایم. به هر حال، فرض می‌کنیم که اون بخش از مشکل حل شده و آدرس رجیستر موردنظر رو داریم. حالا مسأله‌ی دسترسی به رجیستر، تبدیل شد به مسأله‌ی دسترسی به یه آدرس از فضای حافظه…

و قاعدتاً تا اسم آدرس میاد، آدم ناخودآگاه یادش به اشاره‌گرها میفته…

متغیرهای اشاره‌گر و آدرس متغیرها

توی حالت کلی، در زبان C برای دسترسی به یه آدرس از فضای حافظه، از اشاره‌گرها استفاده می‌کنیم. یه مثال خیلی ساده از مبحث اشاره‌گرها در کد زیر اومده:

uint32_t length = 10;
uint32_t *pointer_to_length = &length;
*pointer_to_length = 20;

در خط اول، یه متغیر length با مقدار اولیه‌ی 10 تعریف می‌شه. الآن، متغیر length یه فضایی از حافظه‌ی RAM رو به خودش اختصاص داده و درون اون فضا، مقدار 10 وجود داره. در خط دوم، ما یه اشاره‌گر به اسم pointer_to_length تعریف می‌کنیم و آدرس متغیر length رو درون اون می‌ریزیم. گفتیم که متغیر length یه فضایی از حافظه رو اشغال کرده؛ آدرس اون فضا از حافظه رو می‌تونیم با استفاده از عملگر یگانی & بگیریم. توجه می‌کنیم که متغیر pointer_to_length هم در اینجا یه فضایی از حافظه‌ی RAM رو گرفته و مقدار آدرس متغیر length رو در خودش جا داده. در خط سوم، ما گفته‌ایم که برو مقدار 20 رو بریز در اون آدرسی از حافظه که در اشاره‌گر pointer_to_length وجود داره؛ چون که pointer_to_length داره به همون خونه از حافظه اشاره می‌کنه که محتوای length درش قرار داره، پس عملاً در خط سوم، مقدار متغیر length تغییر می‌کنه.

خب حالا این صرفاً یه مقدمه از اشاره‌گرها بود و برای کامل شدن بحث آورده شد؛ اما ما برای دسترسی به رجیسترمون، آدرس مستقیمش رو داخل حافظه داریم و می‌خوایم به اون آدرس ثابت دسترسی پیدا کنیم. آیا این کار با اشاره‌گرها امکان‌پذیره؟

متغیرهای اشاره‌گر و آدرس‌های دلخواه از حافظه

نکته‌ای که وجود داره، اینه که برای دسترسی به یه آدرس از حافظه، نیازی نیست که حتماً قبلش یه متغیر دیگه تعریف کرده باشیم که بعدش لازم باشه با استفاده از عملگر &، آدرسش رو بگیریم؛ بلکه این امکان وجود داره تا به یه آدرس دلخواه داخل حافظه دسترسی پیدا کنیم. برای این، کافیه که مقدار عددی آدرس رو به نوع داده‌ی اشاره‌گر تبدیل کنیم. مثال زیر رو ببینید:

uint32_t *p = (uint32_t *)(0x48000000UL);

در اینجا، ما یه اشاره‌گر به uint32_t تعریف کرده‌ایم و بهش گفته‌ایم که به آدرس 0x48000000 از حافظه اشاره کنه! یه آدرس دلخواه. برای این که مقدار ثابت عددی رو به یه آدرس تبدیل کنیم، از تبدیل (uint32_t *) استفاده کرده‌ایم. (UL که در انتهای ثابت عددی اومده، برای این هست که مطمئن باشیم که کامپایلر، ثابت ما رو به‌صورت یه عدد بدون علامت و بزرگ یا Unsigned Long درنظر می‌گیره.)

الآن این امکان برای ما وجود داره که محتوای اون آدرس دلخواه از حافظه رو بخونیم:

uint32_t value = *p;

یا مقدارش رو تغییر بدیم:

*p = 1;

پس می‌بینیم که برای دسترسی به هر نقطه‌ی دلخواهی از حافظه، کافیه که آدرس اون رو داشته باشیم و بعدش می‌تونیم به کمک اشاره‌گرها، به محتواش دسترسی پیدا کنیم. جالب شد! حالا دیگه اجازه هست به رجیسترهامون برسیم؟!

متغیرهای اشاره‌گر و رجیسترهای پریفرال

از قضای روزگار، آدرس 0x48000000 در کد بالا، یه آدرس کاملاً دلخواه نیست، بلکه آدرس رجیستر MODER از GPIOA هست! یعنی همین الآن، ما تونسته‌ایم که به یکی از رجیسترهای میکروکنترلر خودمون دسترسی پیدا کنیم! در حقیقت، خط *p = 1; میاد پین PA0 رو به خروجی و سایر پین‌های پورت A رو به ورودی تبدیل می‌کنه! اگر اسم مناسب‌تری برای متغیر p درنظر بگیریم، همه‌چی واضح‌تر می‌شه:

uint32_t *gpioa_moder = (uint32_t *)(0x48000000UL);
*gpioa_moder = 1;

در کد بالا، تنها چیزی که تغییر داده شده، اسم متغیر p هست که به‌جاش از یه اسم بهتر استفاده کرده‌ایم تا کارکرد متغیر رو برای خودمون روشن‌تر کنه؛ الآن می‌دونیم که متغیر gpioa_moder، بیانگر رجیستر MODER از GPIOA هست. توجه می‌کنیم که این تغییر نام، برای کامپیوتر و میکروکنترلر، بی‌معنیه و اون‌ها براشون هیچ فرقی نداره که اسم متغیر ما چی باشه؛ این تغییر نام، فقط برای خودمونه تا با دیدن اسم متغیر، بدونیم که کارش چیه.

کدی که این بالا نوشتیم، داخل شرایط آزمایشگاهی (!) جواب می‌ده ها، ولی به‌محض ورود به دنیای واقعی، احتمالاً دیگه درست کار نمی‌کنه! مشکل چیه؟

بهینه‌سازی‌های کامپایلر و دسترسی به رجیسترهای پریفرال

تا الآن برای سادگی، فرض ما این بوده که بهینه‌سازی کامپایلر، غیرفعاله و اگر بخوایم که اون کد بالا حتماً درست کار کنه، لازمه که بهینه‌سازی رو غیرفعال کنیم؛ مثلاً اگر از GCC استفاده می‌کنیم، باید فلگ O (حرف اُ بزرگ انگلیسی) رو از دستور کامپایل خودمون حذف کنیم یا از -O0 استفاده کنیم. اما خب این فرض، تخیُّلیه و بهینه‌سازی‌های کامپایلر، خیلی مفید هستن و ما اغلب اوقات (یا شاید همیشه) از اون‌ها استفاده می‌کنیم؛ ولی وقتی بهینه‌سازی رو فعال کنیم، دیگه هیچ تضمینی وجود نداره که اون کد بالا یا حالا، کدهای مشابهی که برای دسترسی به رجیسترها می‌نویسیم، درست کار کنن. قضیه چیه؟

اصلاً بهینه‌سازی چیه؟ بهینه‌سازی یعنی بهبود دادن کد برای رسیدن به یه هدف خاص؛ مثلاً کاهش زمان اجرای برنامه یا کم کردن حجم کد تولیدی نهایی. وقتی بهینه‌سازی کامپایلر رو فعال کنیم، کامپایلر تلاش می‌کنه تا با تغییر دادن یه چیزایی توی روال برنامه‌ی ما، به اون هدف بهینه‌سازی دست پیدا کنه؛ البته قطعاً باید حواسش باشه که معنی و مفهوم برنامه‌ی ما عوض نشه و خلاصه خراب‌کاری نکنه! ولی با درنظر داشتن این اصل، هر تغییری رو که صلاح ببینه، اعمال می‌کنه.

حالا اگه این کد بالا رو بدیم به یه کامپایلر بهینه‌ساز، مثلاً ممکنه پیش خودش بگه که:

«عجب برنامه‌نویسی! عدد 1 رو روی یه آدرسی از حافظه نوشته، بعدم هیچ‌وقت از اون آدرسه، مقدارش رو نمی‌خونه! آخه چرا این کار بیخود رو باید انجام بدم؟ حالا این برنامه‌نویسه، بنده خدا، بچه بوده یه اشتباهی کرده دیگه… من که عاقل و بالغ هستم نباید این اشتباه رو تکرار کنم. پس بیا این دسترسی بیخودی رو کلاً داخل برنامه‌ی نهایی انجام ندیم. اشکال نداره که…»

… و ممکنه تصمیم بگیره که اصلاً عدد 1 رو توی اون آدرس نریزه و فکر کنه که این دو خط بالا، کلاً وجود خارجی ندارن! و این، قطعاً کار ما رو خراب می‌کنه. این فکری که کامپایلر بهینه‌ساز پیش خودش کرد، درباره‌ی هر آدرس دیگه‌ای از حافظه، درسته؛ مثلاً فرض کنید که یه شماره تلفنی رو یه جایی یادداشت کنیم و بعدش هم کلاً فراموشش کنیم و دیگه هرگز هم سراغ یادداشتمون نریم: پس یه‌جوری انگار یادداشت کردنش از ابتدا بیهوده بوده! بنابراین فرض کامپایلر در حالت کلی، برقراره؛ ولی اون چیزی که کامپایلر نمی‌دونه، اینه که اون آدرس خاص از حافظه، یه آدرس معمولی نیست، نوشتن یه مقدار روی اون آدرس، اثرات جانبی داره و حتماً لازمه که اون کار رو دقیق انجام بده. اثرات جانبی کد بالا چیه؟ وقتی عدد 1 رو می‌نویسیم روی اون آدرس از حافظه، سخت‌افزار پریفرال پورت A ما باید تنظیمات خودش رو تغییر بده و پین PA0 رو تبدیل کنه به خروجی و سایر پین‌ها رو تبدیل کنه به ورودی… کامپایلر از این مسائل بی‌خبره؛ از دید کامپایلر، یه آدرس از فضای حافظه، یه آدرس معمولی از حافظه است و نوشتن روی اون یا خوندن مقدار ازش، هیچ اثر جانبی خاصی نداره.

به عنوان یه مثال دیگه، استفاده از حلقه رو برای چک کردن چندباره‌ی مقدار یه رجیستر و اطلاع از تغییر مقدارش درنظر بگیرید. این یه کار خیلی رایجه؛ مثلاً وقتی که می‌خوایم منتظر تغییر مقدار ورودی روی یه پین باشیم یا زمانی که می‌خوایم دیتای دریافتی روی پریفرال‌های ارتباطی (مثل SPI یا UART) رو بخونیم. کامپایلر بهینه‌ساز، به‌سادگی ممکنه که فقط یک بار مقدار رجیستر ما رو بخونه و کپی مقدارش رو نگه داره و در تکرارهای بعدی حلقه، از همون مقدار قدیمی و کپی استفاده کنه! پس دیگه برنامه‌ی ما در زمان اجرا، متوجه تغییر مقدار پین نمی‌شه یا مثلاً فقط اولین داده‌ی دریافتی از UART رو می‌خونه؛ چون از نظر کامپایلر، محتوای اون آدرس از حافظه، قرار نیست خودبه‌خود تغییر کنه و یک بار خوندش کافی هست. چنین فکرها و تصمیم‌هایی از طرف کامپایلر، درباره‌ی آدرس‌های معمولی حافظه، درسته ها، ولی درباره‌ی رجیستر یه پریفرال، نه، قطعاً اشتباهه. محتوای رجیستر پریفرال ما ممکنه که بر اثر اتفاقات خارجی تغییر کنه؛ اتفاقاتی که تحت کنترل برنامه‌ی ما نیست (مثل تغییر مقدار ورودی پین یا دریافت داده‌ی جدید روی UART).

البته دو مثال قبلی، خیلی ساده و کلی هستن و صرفاً برای فهم مطلب، بیان شده‌اند؛ در کل، می‌خوایم بگیم که دخل‌وتصرف کامپایلر می‌تونه دسترسی ما رو به رجیسترها دچار مشکل کنه. اون فرض‌هایی که کامپایلر درباره‌ی آدرس‌های معمولی فضای حافظه داره، درباره‌ی رجیسترهای پریفرال برقرار نیست. محتوای رجیستر پریفرال ما ممکنه که به خاطر اتفاقات خارجی تغییر کنه و دسترسی به رجیسترهای پریفرال (چه خوندن و چه نوشتن) ممکنه که با اثرات جانبی همراه باشه. خلاصه این که ما درباره‌ی رجیسترهای پریفرال، سازش و مصالحه نداریم: اگر کاری رو با یه رجیستر پریفرال داخل برنامه‌مون انجام دادیم، انتظار داریم که کامپایلر، همون کار رو، موبه‌مو منتقل کنه به کد اَسِمْبْلی نهایی.

پس اگر بهینه‌سازی، کار دسترسی ما رو به رجیسترهای پریفرال خراب می‌کنه، چیکار باید بکنیم؟ آیا بهینه‌سازی رو کلاً غیرفعال کنیم؟ نه، ما اصلاً قصد نداریم که بهینه‌سازی رو کاملاً غیرفعال کنیم، چون گفتیم که برای سایر بخش‌های کد ما مطلوبه و تقریباً همیشه ازش استفاده می‌کنیم. پس باید یه راه‌حلی برای دسترسی به رجیسترهای پریفرال پیدا کنیم که با فعال بودن بهینه‌سازی، باز هم کار کنه. در حقیقت، باید راهی وجود داشته باشه که بتونیم به کامپایلر بگیم که آقا، فلان آدرس، خط قرمز ماست! به اون آدرس خاص، کاری نداشته باش و هیچ بهینه‌سازی‌ای روی اون انجام نده! برای این کار، کافیه که از کلمه‌ی کلیدی volatile استفاده کنیم. کد زیر رو ببینید:

volatile uint32_t *gpioa_moder = (volatile uint32_t *)(0x48000000UL);
*gpioa_moder = 1;

فقط دو جا تغییر نسبت به کد قبلی داریم؛ در هر دو، به جای uint32_t خالی، از volatile unit32_t استفاده کرده‌ایم. با انجام همین کار ساده، به کامپایلر گفته‌ایم که دسترسی ما رو به اون آدرس خاص از حافظه، بهینه‌سازی نکن. حالا می‌تونیم با خیال راحت، بهینه‌سازی‌های کامپایلر رو فعال کنیم و از اون بهینه‌سازی‌ها در سایر بخش‌های برنامه‌ی خودمون بهره‌مند بشیم.

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

جمع‌بندی

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

پس دیدیم که استفاده از متغیر اشاره‌گر برای این کار، کاملاً ممکن هست و می‌شه که داخل یه کد سریع و زشت، از همین روش استفاده کرد؛ اما خیلی نمی‌بینیم که کسی چنین کاری رو بکنه. چرا؟ یکی از دلایلش اینه که تعریف یه متغیر، خودش ممکنه که یه فضایی از حافظه‌ی RAM رو اشغال کنه؛ مثلاً در کد بالا، ممکنه که حقیقتاً یه فضای ۴-بایتی از RAM برای نگهداری مقدار موجود درون متغیر اشاره‌گر gpioa_moder استفاده بشه: مقداری که برابر با ثابت عددی 0x48000000 هست و هیچ وقت هم قرار نیست تغییرش بدیم. پس یه‌جوری انگار که یه مقداری از RAM ارزشمند سیستم خودمون رو هَدَر داده‌ایم. معمولاً ما میایم و یه‌جا، همه‌ی رجیسترهایی رو که نیاز داریم، تعریف می‌کنیم و بعدش، داخل همه‌ی فایل‌های سورس‌کد خودمون از اون تعاریف استفاده می‌کنیم؛ یعنی اغلب اوقات، تعریف مربوط به رجیسترهای پریفرال رو داخل فایل‌های هِدِر (header) قرار می‌دیم و اون فایل‌ها رو درون سورس‌کد خودمون، #include می‌کنیم. اگر داخل این فایل‌ها تعریف متغیرهای اشاره‌گر رو قرار بدیم، اون‌وقت این متغیرها global می‌شن و علاوه بر این که ممکنه فضایی رو درون RAM اشغال کنن، این امکان وجود داره که فرصت بهینه‌سازی رو از کامپایلر بگیریم. در هر حال، متغیرهای global خیلی باید با احتیاط، تعریف و استفاده بشن.

خلاصه این که معمولاً دلایل خوبی وجود داره که از روش تعریف متغیر اشاره‌گر، به‌شکلی که بیان شد، برای دسترسی به رجیسترهای پریفرال سیستم استفاده نکنیم. پس چیکار باید کرد؟ حالا که مقدمات کار رو بلد شدیم، در بخش ۲ این مقاله، راه‌حل بهتری رو بررسی می‌کنیم.