понеділок, 9 листопада 2015 р.

Стандартна бібліотека C та SemiHosting (на прикладі STM32 і CoIDE)

Роль LibC в Linux. Зауважте, що
glibc надає значно більше інструментів,
ніж стандартна бібліотека С -- зокрема,
засоби POSIX. Нижче мови про них не буде.
(c) wiki
В попередньому пості було показано, як викристовувати CoIDE, програмуючи мікроконтролери (на прикладі плати STM32VLDiscovery). Розглянемо тепер, деякі загальні практичні питання програмування із використанням С. (В порівнянні із давнішими попередніми (1, 2) постами, тут більше подробиць, виправлено деякі помилки, і т.д.)

Увага (як часто буває в  embedded!), багато що із написаного нижче дуже сильно прив'язано до особливостей конкретного компілятора, GCC та конкретних його версій! Про Keil, можливо, колись напишу окремо. З іншого боку, особливої прив'язки до IDE немає, CoIDE згадується лише як конкретний приклад, котру галочку в котрому меню слід поставити для досягнення якогось результату. Розгляд ведеться для ARM-GCC 4.8 та 4.9, для новіших чи старіших версій можуть бути відмінності.

Стандартна бібліотека C

Стандартна бібліотека С включає купу корисних речей, до яких ми звикли. Однак, її реалізація вимагає підтримки з боку операційної системи, зокрема, наявності системних викликів, якими бібліотека може скористатися для виконання своєї роботи. Наприклад, функція printf() сама не працюватиме із консоллю --- в кінці кінців, вона, на більшості платформ, просто не матиме до неї фізичного доступу. Тому, для виводу звертатиметься до операційної системи. Аналогічно, malloc() -- йому потрібно якось запитувати пам'ять у менеджера пам'яті. [Так, навіть на мікроконтролерах динамічна пам'ять іноді цілком доречна! Хоча, з нею, звичайно, потрібно поводитися акуратно.]

Однак, наша програма на контролері працює на голому залізі (bare metal) --- там просто немає операційної системи. [Про RTOS поговоримо окремо.

Для вирішення цієї проблеми багато бібліотек для контролерів, (не тільки реалізації стандартної бібліотеки C),  пишуть, використовуючи невелику кількість "системних функцій", які користувач-програміст повинен реалізувати для тієї платформи, на якій працює. 

Щодо стандартної бібліотеки С, CoIDE підтримує обидва варіанти стандартної бібліотеки, які ідуть разом із компілятором ARM GCC: Newlib від Red Hat (на вкладці Configure/Link оболонка називає її "base C library"), та Newlib-nano, (яку вона називає "nano  С library", див. також офіційну сторінку), мініатюризовану модифікацію Newlib, розроблену людьми, що підтримують ARM GCC.
Newlib -- реалізація стандартної бібліотеки С, розроблена для того, що зараз називається Cygwin -- POSIX-шар для Win32, поверх якого можна встановити практично будь-яку програму із світу Linux (а то й UNIX взагалі). Потім, завдяки "лаконічності" необхідних викликів, перепрофілювалася в реалізацію стандартної бібліотеки С для вбудованих систем.
Додатково можна глянути і на інші реалізації, скажімо, uClibc. Порівняння різних реалізацій можна побачити, наприклад, на сторінці musl -- ще однієї реалізації. Більшість перерахованих там нам не підійдуть --- вони вимагають наявності в процесорі MMU (Memory management unit -- грубо кажучи, засіб організації віртуальної пам'яті), якого на контролерах класу ARM Cortex M немає (не плутати із MPU). Однак, це порівняння добре ілюструє, скільки всілякої роботи робить стандартна бібліотека С, і наскільки по різному вона це може робити.
Компонента CoIDE, C_library (див. попередній пост) надає мінімальну реалізацію частини із цих функцій. Знаходиться ця реалізація у файлі syscalls.c (Нагадаю, що єдиний спосіб їх редагувати, який мені вдалося знайти в CoIDE 2 --- перетягнути файл у віртуальну папку проекту). Опис їх є, наприклад, в документації newlib: "12.1 Definitions for OS interface".

Розглянемо їх, розширивши і доповнивши мінімалістичну реалізацію від CoIDE. (В результаті, я до своїх проектів додаю описану тут реалізацію, навіть не включаючи згадану компоненту. Для нетерплячих --- демонстраційний проект, із всім кодом, тут.)

sbrk  

Популярний, хоч і не стандартизований системний виклик -- збільшити розмір купи (heap), з якої виділяється динамічна пам'ять. Необхідний для роботи malloc() і всього, що від нього залежить. Повинен повертати попереднє значення вершини купи. (Альтернатива на "великих" системах -- mmap, зараз її не обговорюватимемо.)

У нашому випадку, щоб лінкер цю функцію знайшов, слід спереду додати символ підкреслення. Реалізація, яка йде із CoIDE, наступна:

extern int  _end; 
 
__attribute__ ((used))
caddr_t _sbrk ( int incr )
{
    static unsigned char *heap = NULL;
    unsigned char *prev_heap;

    if (heap == NULL) {
        heap = (unsigned char *)&_end;
    }
    prev_heap = heap;

    heap += incr;

    return (caddr_t) prev_heap;
}

Вона покладається на символ _end, створений лінкером. Увага -- це не змінна! Його адреса   вказує на початок пам'яті, де можна створити купу, або, якщо простіше -- на її початок. Нестандартний атрибут __attribute__ ((used)) не дає оптимізатору "грохнути" її випадково, навіть якщо видається, що функція не використовується.
Особливо гостро потреба в цьому атрибуті постає за використання LTO. Характерна ознака, що ви забули про нього у якомусь важливому місці -- помилки компонування виду "`_write' referenced in section `.text._write_r' of c:/embedded/gcc-arm-none-eabi-4_9-2015q3-20150921-win32/bin/../lib/gcc/arm-none-eabi/4.9.3/../../../../arm-none-eabi/lib/armv7-m\libg.a(lib_a-writer.o): defined in discarded section `.text' of ..\obj\syscalls.o (symbol from plugin)". Див. також тут: "Bug 54933  - 'builtin symbol' referenced in section ... defined in discarded section".

Робота її менш-більш очевидна. Однак, стек рухається назустріч купі, і вони можуть затерти один одного. Частково запобігти цьому відносно просто (взято із змінами з посилань нижче):

extern int  _end;

/*This function is used for handle heap option*/
__attribute__ ((used))
caddr_t _sbrk ( int incr )
{
    static unsigned char *heap = NULL;
    unsigned char *prev_heap;
    extern unsigned char *__StackLimit;     /* Value set by linker */

    if (heap == NULL) {
        heap = (unsigned char *)&_end;
    }
    prev_heap = heap;

    if (heap + incr > &__StackLimit) {
#ifdef PRINT_MESSAGE_ABOUT_HEAP_AND_STACK_COLLISION
     char err_msg[] = "Heap and stack collision\n";
        _write (1, err_msg, strlen(err_msg));   // Потребує <string.h>
#endif
        errno=ENOMEM;    // Потребує <errno.h>
        return  (void *) -1;
      }

    heap += incr;

    return (caddr_t) prev_heap;
}


Частково -- бо заборонити стеку рости трохи важче... Реалізація із додатковими можливостями відладки буде розглядатися в одному із наступних постів.

 __StackLimit -- ще один символ, оголошений лінкером. (Назва може залежати від конфігурації компілятора!). За бажання (вираженням якого керуємо із допомогою макросу PRINT_MESSAGE_ABOUT_HEAP_AND_STACK_COLLISION), можна виводити повідомлення про помилку (наприклад, засобами semihosting -- див. далі). Використовуємо для виведення _write(), про яку теж далі -- printf() використовувати не можна! Навіть не говорячи про її громіздкість, вона може покладатися на malloc(). (З іншого боку, використано strlen(), функція ця відносно дешева, а вручну підраховувати кількість символів у стрічці помилок, як в офіційних прикладах --- трохи дурна річ).
Важливе зауваження.  Через те, що повертається попередня вершина купи, функція не-реентрантна (не повертається мені язик її неповновикористовною називати...), тому її (та функції, що можуть виділяти пам'ять!) не рекомендується використовувати у обробниках переривань! Реентрантні варіанти функцій стандартної бібліотеки тут теж не дуже допоможуть --- купа глобальна. Тому, якщо дуже треба (а добре подумайте, чи справді дуже!), слід перевіряти мютекс на вході, але не блокувати, якщо "зайнято", а просто повертати помилку -- інакше буде deadlock. 

Детальніше див.:
Однак, для printf() чи й навіть puts(), самого sbrk() буде мало. Щоб переконатися, достатньо закоментувати реалізацію всіх ф-цій із syscalls.c, крім sbrk(), в програму вставити malloc() і подивитися на помилки компонування. Потрібно ще: _write(), _close(), _fstat(), _isatty(), _lseek(), _read(). Розглянемо їх, найважливішу для нас, _write(), відклавши на десерт.

_close()

Призначення -- закрити файл. Якщо файли (навіть спеціальні, в стилі UNIX) не підтримуються, а UART чи semihosting служать в ролі термінала/консолі (стандартних потоків вводу та виводу), можна скористатися найпростішою реалізацією, яка ніколи не завершується успішно.

__attribute__ ((used))
int _close(int file)
{
    errno = EBADF;
    return -1;
}

Можна також реалізувати механізм своїх спеціальних файлів --- скажімо, 0 це UART1, 1 -- UART2, 3 -- SemiHosting, тощо.


_fstat()

Функція ця повертає статус файлу. Детальніше про цю функцію див., наприклад, "fstat(2) - Linux man page". Якщо підтримку файлів не реалізовано, розумним буде будь-який наш файл вважати символьним пристроєм (див. також опис close()), повідомивши про це за допомогою значення S_IFCHR поля st_mode структури stat, із описом статусу файлу (подробиці див. за посиланням вище):

__attribute__ ((used))
int _fstat(int file, struct stat *st)
{
    st->st_mode = S_IFCHR;
    return 0;
}



_isatty()

Перевіряє, чи потік працює із терміналом. Вважаємо (поки), що завжди -- так.

__attribute__ ((used))
int _isatty(int file)
{
    return 1;
}

Якщо б (ті чи інші) файли (як мінімум -- файлові дескриптори) таки підтримувалися, вона могла б виглядати якось так:

__attribute__ ((used))
int _isatty(int file)
{
{
  if ((file == STDOUT_FILENO) || (file == STDERR_FILENO))
    {
      return  1;
    }
  else
    {
      errno = EBADF;
      return  -1;
    }
}       

}


Це ж стосується інших пов'язаних функцій.


_lseek()

Переміщення файлового вказівника, повертає його нове положення відносно початку файлу. Див., наприклад, "lseek - move the read/write file offset". Будемо вважати, що по нашому потоку соватися не можна:

__attribute__ ((used))
int _lseek(int file, int ptr, int dir)
{
    return 0;
}

Точніше, можна -- це не є помилковою ситуацією (в якій ф-ція мала б повернути -1 і встановити errno), просто далі початку не рухаємося.


_read()

Читає вказану кількість байтів із файлу, повертає, скільки реально прочитано. Детальніше див., наприклад: "read(3) - Linux man page". Вважаємо, що не маємо звідки читати:

__attribute__ ((used))
int _read(int file, char *ptr, int len)
{
    return 0;
}

Якщо хотілося б додати можливість читати, скажімо, із UART, можна написати щось таке:

__attribute__ ((used))
int _read(int file, char *ptr, int len)
{
     int i;
     (void)file; // Ігноруємо номер файлу -- в припущенні, що у нас тільки один UART

     for(i = 0; i < len; i++)
     {
        // Нехай UART_GetChar --- ф-ція, що читає один символ із UART 
        *ptr++ = UART_GetChar();
     }
    return len;
}


_write() і SemiHosting

Записує вказану кількість символів у файл, повертає, скільки реально записано. Детальніше, див., наприклад, "write(3) - Linux man page". Звичайно, в дусі мінімалізму, що господарює вище, можна б було просто повертати 0. Однак, нам би хотілося мати можливість якогось більш звичного "feedback" від демоплати, ніж різноманітні мигання світлодіодами (занудно вважаючи, що постійно запалений світлодіод -- це мигання із безмежним періодом :-).

Контролери сімейства STM32 володіють зручним вбудованим засобом для цього --- SemiHosting. Зокрема, цей засіб дозволяє пересилати літери відладчику. Детальніше про нього можна почитати тут:
Якщо підключити відповідну компоненту, як описано в попередньому пості, в проекті буде три файли, semihosting.c, semihosting.h, sh_cmd.s. Підключаємо semihosting.h та пишемо реалізацію так:

__attribute__ ((used))
int _write(int file, char *ptr, int len)
{
          (void)file;
   int txCount;
   for ( txCount = 0; txCount < len; txCount++)
   {
       SH_SendChar(ptr[txCount]);
   }
   return len;
}

Де SH_SendChar() --- функція, оголошена в semihosting.h, котра передає один символ дебаггеру. (Звичайно, замість неї може бути яка-небудь UART_PutChar(), для передачі з використанням UART). Її реалізація знаходиться в semihosting.c, думаю, вартує на неї глянути:

static char g_buf[16];
static char g_buf_len = 0;

/**************************************************************************//**
 * @brief  Transmit a char on semihosting mode.
 *
 * @param  ch is the char that to send.
 *
 * @return Character to write.
 *****************************************************************************/
void SH_SendChar(int ch) {
 g_buf[g_buf_len++] = ch;
 g_buf[g_buf_len] = '\0';
 if (g_buf_len + 1 >= sizeof(g_buf) || ch == '\n' || ch == '\0') {
  g_buf_len = 0;
  /* Send the char */
  if (SH_DoCommand(0x04, (int) g_buf, NULL) != 0) {
   return;
  }
 }
}

Зауважте внутрішню буферизацію --- поки буфер не закінчився, або не передано нульовий символ чи символ кінця рядка, нічого не пересилатиметься.

Ось, власне і все --- після реалізації перерахованих вище функцій, printf() працюватиме:
SemiHosting в дії. 1 -- засоби керування відладкою, 2 -- рядок коду, який буде виконано наступним, 3 -- сюди потрапляє вивід, який програма передає SemiHosting-у, 4 -- вкладка, де можна подивитися значення, що зберігаються в змінних.
Однак, printf() з malloc() стандартна бібліотека С не обмежується. Для повного комплекту потрібно реалізувати ще деякі системні виклики та пару спеціальних змінних. Розглянемо їх.


errno

Newlib всередині, замість змінної, використовує макрос errno, для спрощення роботи реентрантних функцій. Однак, для функцій, що реалізовують системні виклики, цей підхід не працюватиме. Тому доводиться в syscalls.c викручуватися так:

#undef errno
extern int errno;

Див. також "Error Handling" із "Howto: Porting newlib. A Simple Guide" від Jeremy Bennett, Embecosm та відповідну частину офіційної документації. Взагалі, ці два джерела стосуються практично всіх розглянутих функцій та змінних.


environ

Вказівник на масив вказівників на стрічки із змінними середовища, який закінчується порожньою С-стрічкою. В найпростішому варіанті -- порожній, тобто містить лише згадану порожню стрічку (вказівник на неї):

char *__env[1] = { 0 };
char **environ = __env;


_exit()

Негайно завершити програму, не здійснюючи жодних спроб прибрати за собою. Контролер в такій ситуації може хіба зависнути, увійшовши в нескінчений цикл.

__attribute__ ((used))
void _exit(int rc)
{
 while(1){}
}


_execve()

Виконати програму (зазвичай -- замістивши нею процес, створений за допомогою fork()). Детальніше, про те, що це за виклик і як має поводитися, див. "execve - execute program". Підтримки запуску процесів у нас немає:

__attribute__ ((used))
int _execve (char  *name, char **argv, char **env)
{
 errno = ENOMEM;
 return -1;                    /* Always fails */
}


_fork()

Створити новий процес, на початку -- копію поточного. Детальніше див. "fork(2) - Linux man page". Підтримки "розмноження" процесів у нас немає:

__attribute__ ((used))
int _fork ()
{
  errno = EAGAIN;
  return -1;                    /* Always fails */
}


_getpid()

Отримати PID (Process ID) поточного процесу. Можна вважати, що процес у нас один:

__attribute__ ((used))
int _getpid ()
{
  return  1;                            
}


_kill()

Послати сигнал процесу. (Насправді, не зважаючи на назву, вона далеко не завжди вбиває отримувача, подробиці див., наприклад, тут: "kill(2) - Linux man page"). За відсутності потенціальних отримувачів, реалізація проста:

__attribute__ ((used))
int _kill (int  pid, int  sig)
{
  errno = EINVAL;
  return -1;                    /* Always fails */
}


_link()

Перейменувати файл. За відсутності підтримки файлів:

__attribute__ ((used))
int _link(char *old, char *new)
{
    errno = EMLINK;
    return -1;
}


_open()

Відкрити файл. За відсутності підтримки файлів:

__attribute__ ((used))
int _open (const char *name, int flags, int mode)
{
  errno = ENOSYS;
  return -1;                    /* Always fails */
}

_times()

Повертає різноманітні часи процесу -- скільки і як виконувався (скільки власне сам процес, скільки часу провів у системних викликах, скільки часу у дочірніх процесах, детальніше див. тут). Певна осмислена реалізація цілком може існувати навіть у нашому, мінімалістичному випадку, але поки обмежимося відмовою повідомляти цю інформацію:

__attribute__ ((used))
int _times (struct tms *buf)
{
  errno = EACCES;
  return  -1;
}


_unlink

Видаляє файл. За відсутності підтримки файлів:

__attribute__ ((used))
int _unlink (char *name)
{
  errno = ENOENT;
  return -1;                    /* Always fails */
}

_wait

Очікувати на дочірній процес. За відсутністю таких:

__attribute__ ((used))
int _wait (int *status)
{
  errno = ECHILD;
  return -1;                    /* Always fails */

}


Ось і все.

УВАГА! Вище, в педагогічних цілях, наведено повний комплект функцій, що реалізовують системні виклики, необхідні Newlib. Однак, включення тих із них, що непотрібні, особливо із атрибутом used, може привести до марної витрати пам'яті програм (flash-пам'яті) мікроконтролера (може не приводити, особливо з LTO). Тому, в проекті варто залишати лише необхідні.

Аналогічно, хоча послідовна робота із errno важлива, якщо ним не користуються, відповідні рядки коду можна прибрати, зекономивши десятки байт прошивки. З іншого боку, передбачуваність поведінки стандартної бібліотеки, відповідність її очікуванням, теж важлива.

Нотатки щодо готового проекту


Як би там не було, готовий проект, що демонструє описане в цьому пості, тобто реалізацію цих системних викликів, printf() через SemiHosting та мінімальну перевірку, чи вони справді працюють, можна отримати тут.

Кілька слів про його організацію. Є кілька рішень, які б не хотілося приймати раз і назавжди. Тому, реалізував їх вибір з допомогою умовної компіляції. Для порядку, відповідні макроси виніс в runtime_config.h. Цей же файл в майбутньому використовуватиметься і для конфігурації різних аспектів поведінки С++ підтримки часу виконання.

В прикладі використовуються такі макроси:
  • PRINT_MESSAGE_ABOUT_HEAP_AND_STACK_COLLISION --- вже траплялося вище, вирішує, повідомляти про те, що sbrk() "вперся" в стек, чи ні.
  • PRINT_MESSAGES_ABOUT_SHUTDOWN -- чи друкувати повідомлення про завершення, у функції abort().
  • DEFINE_ABORT_FN -- чи взагалі включати реалізацію abort(). (Див. наступні пости -- іноді вона заважає).
  • PRINT_MEMORY_LEFT_AFTER_SBRK -- макрос для відладки, вказує, чи друкувати залишок пам'яті після виклику sbrk().
  • ALLOW_CBRK_TO_RETURN_LESS_MEMORY_THAN_REQUESTED --  Увага! Макрос вмикає доволі дискусійну можливість! Іноді, наприклад, стандартна бібліотека С++, пробує виділити трішки більше пам'яті, ніж є, не може, і програма аварійно зупиняється. Експерименти показали, що якщо дозволити щоб у таких ситуаціях sbrk() повертав всю наявну пам'ять, але не більше -- код (позірно) працює коректно, аж до коректного повідомлення про недостачу пам'яті, коли malloc чи new (в C++) намагається звернутися до тієї ділянки, яка мала б бути виділена, якщо б sbrk() зміг виділити всю замовлену пам'ять. Однак, вони нічого не доводять -- де-факто цей системний виклик не має так поводитися, тому можливі помилки, як в відмінних від випробуваних ситуаціях, так і з новими версіями компілятора. На ваш страх і ризик, хоча для мене -- окупилося (якщо пам'ятати про "зону ризику").
Проект в архіві використовує оптимізацію за розміром та оптимізацію часу компоновки: -Os, -flto. Звичайно, за потреби це легко змінити.

Як би там не було, такий код цілком коректно працює:

printf("Just text\n");
printf("Integer: %i, hex integer: %x\n", 16, 16);
printf("Floating point, %%f: %f; %%g: %g; %%e: %e\n", 1.23, 1.23, 1.23);

// Манія величі! :-) Але компілюється.
FILE *fp;
fp=fopen("c:\\test.txt", "w");
if(fp)
{
     fprintf(fp, "Testing...\n");
}else
{
     printf("Error openin file, errno: %i, text of error: %s\n", 
                         errno, strerror(errno) );
}


Правда, підключення printf() і компанії вимагає два десятки кілобайт прошивки (Newlib, Newlib-nano із підтримкою floating point, без неї, всього п'ять), але то одноразові витрати -- в неї включається код, котрий реалізовує відповідну функціональність.

Наступного разу подивимося, що робити, коли ціла стандартна бібліотека С не потрібна, достатньо лише (урізаного) printf() поверх SemiHosting, а поки:

Дякую за увагу!

Немає коментарів:

Дописати коментар