Ілюстрація з Вікі до теми саморобок (DIY) |
Подивимося, що вона вміє, для чого може бути і як її подружити із 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. Містика?
Насправді, розгадка проста:
- Компілятор має право замість printf() використовувати puts(), якщо її достатньо. При тому, робить це навіть із вимкненою оптимізацією (-O0).
- retarget_printf оголошує свою puts(), що, згідно стандарту, приводить до undefined behavior, невизначеної поведінки. Але тут проблема не в цьому -- undefined behavior, хоч і катастрофічна річ по своїй суті, однак, іноді везе (*).
- Проблема в тому, що puts() повинна після стрічки виводити символ кінця рядка, тому, підставляючи її замість printf(), компілятор передає стрічку без цього символу. А в puts() від retarget_printf чи то має помилку, чи то свідомо (з якихось невідомих міркувань) зроблений "кривим", і '\n' в кінці не виводить.
Детальніше про цю оптимізацію, див, наприклад:
- "Can printf get replaced by puts automatically in a C program?"
- "About GCC printf optimization"
- "Bug 25609 - too agressive printf optimization"
- Вимкнути цю оптимізацію можна за допомогою ключа "-fno-builtin-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)
#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(), є сенс, принаймні, придивитися до цієї реалізації. Крім того, чисто педагогічно ця реалізація повчальна -- проста і очевидна.Наступного разу перейдемо, нарешті, до С++, (відповідний пост вийшов непристойно великим, але немає ради -- якось не хочеться різати), а поки:
Дякую за увагу!
Немає коментарів:
Дописати коментар