вівторок, 10 листопада 2015 р.

Retargetable printf з CoIDE та порівняння розмірів ROM/RAM для різних способів виводу

Ілюстрація з Вікі до
теми саморобок (DIY)
В попередньому пості було розглянуто, як забезпечити мінімалістичну підтримку стандартної бібліотеки С для мікроконтролерів сімейства STM32F1, (для контролерів інших сімейств, крім, власне, особливостей SemiHosting, відмінності будуть мінімальними, якщо взагалі будуть), працюючи з-під CoIDE. Однак, повноцінна підтримка цієї бібліотеки не завжди потрібна, а витрати пам'яті програм та RAM її printf(), в деяких ситуаціях можуть бути неприйнятними. На такий випадок CoIDE пропонує ще один варіант -- скористатися урізаною, мініатюрною реалізацією, яку воно називає Retargetable printf (Retarget_printf). (Взагалі, всі такі бібліотеки, де реалізувавши пару-другу функцій, можна "перенацілити" на роботу із іншим залізом, називають retargetable.)

Подивимося, що вона вміє, для чого може бути і як її подружити із SemiHosting --- виведенням повідомлень із використанням дебаггера.

Перенаправляємо Retargetable printf 

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

Як додати до проекту відповідну компоненту, описано в попередньому пості, при чому, в картинках. Якщо коротко, додаємо її із репозиторію, після чого, на вкладці в Configuration->Link, обираємо "Not use C Library" та вмикаємо "Don't use standart system files" --- інакше будуть помилки компонування, через повторно визначені символи. [Як подружити частину із стандартної бібліотеки С, із даним компактним printf() --- окрема тема, поки не розглядатимемо.] Пам'ятайте також дозволити Semihosting на вкладці Configure->Debug!

Компонента Retarget_printf містить файл printf.c, а у ньому є наступний цікавий код:

__attribute__ ((weak)) void PrintChar(char c)
{
 /* Send a char like: 
    while(Transfer not completed);
    Transmit a char;
 */ 
}

Навколо цієї функції все і крутиться --- всі варіації printf() використовують саме її для безпосереднього виводу. Питання --- як її замінити? Звичайно, можна спробувати редагувати цей файл. Однак існує зручніший метод. Ця функція оголошена з атрибутом weak:
__attribute__ ((weak)).  Тобто, коли лінкер зустріне іншу таку ж функцію, він надасть перевагу саме їй. Тому, щоб замінити PrintChar(), достатньо десь у своїй програмі її реалізувати -- надати визначення:

#include <stdio.h>
#include <semihosting.h>

void PrintChar(char c)
{
    SH_SendChar(c);
}

int main(void)
{

    while(1)
    {
         printf("x=%i\n", 123);
    }
}

Все працює, та ще й прошивка мало не на порядок менша, ніж для цього ж коду із Newlib libC (про розміри, детальніше -- трішки пізніше). Здавалося б, ідеальне рішення! Однак, тут починаються сюрпризи.

Сюрприз оптимізації printf() від GCC


Спробуємо виконати наступний код:

while(1)
    {
        printf("Just text\n");
        printf("x=%i\n",1);
    }

Побачимо несподіване:

Just textx=1
Just textx=1
Just textx=1
..................

Пропали символи кінця рядка після "Just text"! Далі можна реагувати по різному --- лізти дебагером в код, підозрюючи баг в retarget_printf, можна експериментувати, підсовуючи різні стрічки. Розглянемо обидва підходи.
Другий printf() у коді вище не те щоб необхідний, але трохи спрощує життя. Semihosting має свої буфери, \n дає команду їх спорожнити, а без нього -- нагадую, досліджуємо проблему пропадання \n, стрічки виводитимуться шматочками і не кожного виклику printf, через буферизацію. А printf, що виводить число, як ми переконалися раніше, \n виводить, паралельно змушуючи скинути буфери відладчику.
Припустимо, що чомусь відкидається остання літера. Перевіряємо, забравши \n: printf("Just text"):

Just textx=1
Just textx=1
Just textx=1
..................

Результат не змінився. Припущення невірне. Додаємо два \n, printf("Just text\n\n"):

Just text
x=1
Just text
x=1
Just text
x=1


О! Один \n вижив, але лише один. Ще експеримент. Вставимо \n всередину стрічки, printf("Just\n text\n"):
Just
 textx=1
Just
 textx=1
Just
 textx=1


Отож, з великою долею впевненості можна припустити, що чомусь пропадає рівно один символ кінця рядка, \n, в кінці і тільки в кінці стрічки. Містика. :-)

Код printf(), що розглядається, доступний і цілком компактний. Беремо відладчик і дивимося. І тут чекає величезний сюрприз. При спробі зайти у функцію, що викликається першим printf(), ми потрапляємо в puts()! Що за <цензура>?! При чому, аргументом є стрічка, вже без останнього \n. Містика?

Насправді, розгадка проста:
  1. Компілятор має право замість printf() використовувати puts(), якщо її достатньо. При тому, робить це навіть із вимкненою оптимізацією (-O0). 
  2. retarget_printf оголошує свою puts(), що, згідно стандарту, приводить до undefined behavior, невизначеної поведінки. Але тут проблема не в цьому -- undefined behavior, хоч і катастрофічна річ по своїй суті, однак, іноді везе (*).
  3. Проблема в тому, що puts() повинна після стрічки виводити символ кінця рядка, тому, підставляючи її замість printf(), компілятор передає стрічку без цього символу. А  в puts() від retarget_printf чи то має помилку, чи то свідомо (з якихось невідомих міркувань) зроблений "кривим", і '\n' в кінці не виводить.
Сюрприз. Особливо веселий тим, що те, коли компілятор подібні перетворення робить, а коли ні -- в значній мірі, на його розсуд. Його цілком можна очікувати і виводячи printf() із стрічкою формату "%s\n" чи "%c\n", при чому, конкретна поведінка змінюється від версії до версії. Хоча, основний винуватець --- помилка в retarget_printf.

Детальніше про цю оптимізацію, див, наприклад:

(*) Зауваження: тобто, формально, цю бібліотечку неможливо зробити коректною із точки зору стандарту С -- все те ж undefined behavior. Однак, виграш з точки зору розміру прошивки може бути занадто великий, щоб відмовитися від нього із невеликим, в принципі, ризиком.

Можливості та обмеження retarget_printf

Бібліотека, перш ніж виводити, формує стрічку у фіксованому буфері, розміром MAX_STRING_SIZE (по замовчуванню --- 100 байт). Тобто, спроба вивести за раз більшу стрічку провалиться. При тому спробує вивести "stdio.c: increase MAX_STRING_SIZE\n\r".

Дробові числа, floating point нею не підтримуються!




Реалізовано наступні функції: puts(), fputс(), fputs(), printf(), sprintf(), fprintf(), vprintf(), vfprintf(), vsprintf(), snprintf(), vsnprintf(). Якість реалізації не перевіряв, puts() поводиться невірно -- не виводить символу кінця рядка. (Зауважте, fputs, здавалося б, схожа функція, і не мала б його виводити!). При спробі вивести у файл, відмінний від stdout чи stderr, повертається EOF. 

Errno не підтримується.


Подробиці реалізації: вся робота по форматуванню здійснюється  vsnprintf(), решта функції більш чи менш опосередковано викликають її. Вона, у свою чергу, для друку окремих полів використовує функції PutSignedInt(), PutUnsignedInt(), PutHexa(), PutString(), PutChar(). Будь таке бажання виникне, додати підтримку дробових чисел відносно просто.

 Розуміє наступні специфікатори формату: %%, %d, %i, %u, %x, %X, %s, %c. Зустрівши невідомий їй специфікатор формату, поводиться не дуже послідовно -- припиняє форматування стрічки, в результаті на виході отримується вже сформована частина плюс сміття, що залишилося в пам'яті із попередніх спроб щось вивести...

Вміє задавати ширину поля та символ заповнювач.

 По великому рахунку, все. Завантажити демонстраційний проект можна тут. У ньому виправлено помилку puts(), що привело до збільшення прошивки на 24 байти. Використано оптимізацію: -Os, -flto. За потреби, змінити це тривіально.

Порівняння різних способів зробити printf


Ознайомившись із особливостями retarget_printf, постає питання --- а воно того варте, возитися із ним? Давайте подивимося. Доступно три реалізації printf():
  • Newlib
  • Newlib-nano (для якої можна окремо додавати підтримку printf(), окремо -- scanf() для floating point)
  • retarget_printf
Комбінацій різних варіантів оптимізації є дуже багато, ми зупинимося на наступних:
  • -O0 -- без оптимізації
  • -O3 -- максимальна оптимізація за швидкістю
  • -Os -- оптимізація за розміром
  • -O0 + LTO
  • -O3 + LTO (-flto)
  • -Os + LTO (-flto)
Для тестування витрат пам'яті використовувався відносно простий код, що використовує форматування, але не виводить floating point --- не всі учасники забігу це вміють:

#include <stdio.h>
#include <errno.h>
#include <string.h> // for strerror

int main(void)
{
     puts("Just text, using puts\n");
     printf("Just text, using print\n"); 
     printf("Integer: %i, hex integer: %x\n", 16, 16);
     printf("Table: \n");
     printf("%05x %05x %05x\n", 1,2,3);
     printf("%05x %05x %05x\n", 4,5,6);
     printf("%05x %05x %05x\n", 7,8,9);
     printf("%5x %5x %5x\n", 1,2,3);
     printf("%5x %5x %5x\n", 4,5,6);
     printf("%5x %5x %5x\n", 7,8,9);
     char str[]="Test string";
     printf("String = \"%s\"\n", str);
     printf("Char = \"%c\"\n", 'z');
     while(1){}
}

Результати, виведені в консоль дебагера, у всіх запусків співпадають (з поправкою на виправлений printf або використання опції  -fno-builtin-printf):

Just text, using puts
Just text, using print
Integer: 16, hex integer: 10
Table:
00001 00002 00003
00004 00005 00006
00007 00008 00009
    1     2     3
    4     5     6
    7     8     9
String = "Test string"
Char = "z"


Увага! На жаль, через мій недогляд, випробування проводилися із GCC версії 4.8 (а не 4.9, на яку орієнтується ця серія постів). Переробляти повністю не став -- різниця між результатами, хоч і присутня, але коливається в обидві сторони -- десь один дав кращий результат, десь другий, але в межах десятка відсотків.

Для контролю, окремо було випробувано абсолютно порожній проект, проект, який включає CMSIS, SPL та SemiHosting, але більше нічого, і кожен із варіантів налаштування (вибір бібліотеки + опції компілятора) використовувався і для компіляції проекту із порожнім main().

Перших два, абсолютно порожні проекти містили наступний код:

int main(void)
{

    while(1)
    {
    }
}

Решта -- включали всі необхідні файли заголовків, але сам main() залишався таким же.

Результати наступні:

Абсолютно порожній проект:


Без CMSIS та SPL З CMSIS та SPL

text data bss Разом text data bss Разом
-O0 364 1080 32 1476 1492 1104 32 2628
-O0, LTO 364 1080 32 1476 13832 1120 48 15000
-O3 364 1080 32 1476 1264 1104 32 2400
-O3, LTO 364 1080 32 1476 1172 1080 32 2284
-Os 364 1080 32 1476 1252 1104 32 2388
-Os, LTO 364 1080 32 1476 1156 1080 32 2268



Retarget_printf


Код Порожній main()
text data bss Разом text data bss Разом
-O0 3596 1092 20 4708 1376 20 20 1416
-O0, LTO 22544 1112 24 23680 13396 1112 24 14532
-O3 3972 1092 20 5084 920 20 4 944
-O3, LTO 4148 1104 24 5276 3776 1104 24 4904
-Os 2235 1092 20 3347 902 20 4 926
-Os, LTO 2035 1104 24 3163 1684 1104 24 2812

Newlib-nano, без floating point


Код Порожній main()

text data bss Разом text data bss Разом
-O0 6204 132 68 6404 1260 32 32 1324
-O0, LTO 25176 156 68 25400 13480 56 56 13592
-O3 5548 132 68 5748 1028 32 32 1092
-O3, LTO 5404 112 64 5580 1276 12 60 1348
-Os 5508 132 68 5708 1012 32 32 1076
-Os, LTO 5352 112 64 5528 1232 12 60 1304

Newlib

Код Порожній main()

text data bss Разом text data bss Разом
-O0 24996 2244 116 27356 1516 1104 32 2652
-O0, LTO 43964 2268 116 46348 13736 1128 56 14920
-O3 24332 2244 116 26692 1284 1104 32 2420
-O3, LTO 24188 2220 108 26516 1532 1080 56 2668
-Os 24300 2244 116 26660 1268 1104 32 2404
-Os, LTO 24140 2220 108 26468 1488 1080 56 2624

Також, щоб перевірити розміри бінарного коду виводу floating point, із коду, наведеного вище, було викинуто безмежні цикли (інакше компілятор, оптимізуючи, викине недосяжний код) та додано код із попереднього поста:

 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) );
 }
 while(1){}

Newlib-nano (із ввімкненим printf(), але без scanf() для floating point)


Код Порожній main()

text data bss Разом text data bss Разом
-O0 26369 192 72 26633 20234 192 72 20498
-O0, LTO 45346 216 72 45634 39202 216 72 39490
-O3 25689 192 72 25953 19570 192 72 19834
-O3, LTO 25546 172 68 25786 19426 172 68 19666
-Os 25625 192 72 25889 19538 192 72 19802
-Os, LTO 25466 172 68 25706 19378 172 68 19618

Зауваження --- якщо не ввімкнути printf для floating point, зустрівшись із ними, Newlib-nano просто нічого не друкує.

Newlib (природно, розмір для порожнього main()  залишився тим же)

Код Порожній main() 

text data bss Разом text data bss Разом
-O0 29308 2244 116 27356 1516 1104 32 2652
-O0, LTO 48281 2268 116 46348 13736 1128 56 14920
-O3 28628 2244 116 26692 1284 1104 32 2420
-O3, LTO 28473 2220 108 26516 1532 1080 56 2668
-Os 28540 2244 116 26660 1268 1104 32 2404
-Os, LTO 28377 2220 108 26468 1488 1080 56 2624

Звичайно, виконані тести  -- зовсім примітивні. Однак, деякі висновки зробити дозволяють.

  • Якщо потрібен лише форматований текстовий вивід, конструкції типу Retarget_printf цілком окупаються. Вони "коштують" 2-3кб прошивки і менше кілобайта RAM, при тому надаючи досить потужний інструмент. Якщо потрібно, скажімо, floating point виводити, підтримку (відносно) легко додати,  якщо трапляються помилки --- код простий, можна легко виправити. (Дотримуюся думки, що, поки це не обходиться задорого, варто щоб звично названі функції поводилися як очікується --- див. вище щодо puts()).
  • Якщо без читання-запису floating point можна обійтися,  Newlib-nano -- дуже хороший варіант, та ж реалізація друку -- всього 4-5Кб прошивки, сотня-друга байт оперативної пам'яті (!), а сама бібліотека -- повноцінна реалізація стандартної бібліотеки С (хоча стандарту дотримується не суворо -- дозволяє собі певні вільності, в не особливо значущих ділянках, для економії).
  • Із друкуванням floating point, Newlib-nano дає лише незначний виграш в порівнянні із Newlib в розмірі прошивки (25Кб проти 28Кб), але її основна перевага в іншому -- вона незрівнянно економніша щодо значно критичнішого ресурсу, RAM! (Див. таблички вище). Потребує сотні байт замість пари кілобайт. (А коли тих кілобайт всього 4/8/16 -- це критично!)
  • Newlib, хоч і трішки громіздка для таких скромних контролерів, але цілком влазиться на них, при тому є повноцінною, доволі строгою щодо дотримання стандарту, реалізацією стандартної бібліотеки С. Якщо розмір не особливо хвилює, а дотримання стандарту, навпаки, може бути важливим, вона є хорошим рішенням.
Деякі побічні спостереження (з усвідомленням обмеженості матеріалу, на якому вони робляться):
  • Порожній код -- поганий критерій для оцінки роботи оптимізаторів компілятора, але дозволяє подивитися, скільки додаткового коду підключається для самого факту існування даної бібліотеки, як частини прошивки, (не зважаючи на всілякі оптимізації за розміром та LTO), та скільки вона потребує для своєї роботи RAM. Поки для мене залишається загадкою, як може абсолютно порожній проект бути більшим, ніж проект із кодом. Треба буде при нагоді розібратися, що туди включається, і де воно зникає потім. Як би там не було, видно що із першим кілобайтом RAM ми попрощалися зразу.
  • LTO може збільшувати розмір прошивки. (Особливо гостро робить це, якщо компілювати без оптимізації, хоч це і відверто штучна ситуація.) Це не дивно --- наприклад, оптимізація часу компонування дозволяє робити міжмодульний інлайнинг. (Див, також, тут. Зокрема, пам'ятайте, що ключі оптимізації слід передавати і під час компіляції, і під час компонування!)
  • Використання LTO, зазвичай, дозволяє зекономити відсоток-другий ресурсів пам'яті (хоча, треба пильнувати, щоб стільки ж не втратити). Однак, найбільш важливими є інші види оптимізації -- те ж -O3/-Os. При тому, очікувати чуда від -Os, в порівнянні із -O3, не доводиться, але ось в порівнянні із -O0, виграш обох буває дуже значущим.
На загал -- експериментуйте. Натоптаних шляхів тут значно менше, ніж при написанні "великих" програм.

Висновки

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

Наступного разу перейдемо, нарешті, до С++, (відповідний пост вийшов непристойно великим, але немає ради -- якось не хочеться різати), а поки:

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


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

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