Categories

  • comp_eng
  • os

Tags

  • методичка
  • керування ресурсами
  • керування пам'яттю

Розповідаю студентам про низькорівневі API POSIX-сумісних систем – системні виклики та функції libc. Для порівняння зачіпаю підходи MS Windows та FreeRTOS. Та й місцями сам дивуюся зоопарку різноманітних непослідовностей у цих засобах. Більшість зумовлені історичними причинами разом із потребою підтримувати зворотну сумісність, частина – технічними, які я не розумію через недостатній досвід у відповідних сферах1, щось ще, певне, аналогом дрейфу генів із теорії еволюції… 

Один із найпрямолінійніших прикладів такої заплутаності – менеджмент пам’яті під буфер, де операційна система (ОС) має покласти якусь інформацію. Розповідаючи про функції нижче, наголошую студентам – завчати нюанси конкретних функцій не потрібно. Якщо будете щодня з тим стикатися – саме осяде в голові. Пам’ятайте спектр ідей – щоб розуміти прочитане правильно (а не додумувати згідно своїх фантазій – поширена цитата: “я думав/ла воно ж само якось виділить/звільнить/знайде?”) та обов’язково звіряйтеся із документацією. Але після підготовки до відповідних пар вирішив, свіжими слідами, записати типові приклади. 

Список нижче, безперечно, не вичерпний – охоплює не всі варіанти ідей, крім того, не ставить ціллю задокументувати всі функції. Зокрема, не розглядаються пари malloc()-free(), та fopen()-fclose(), Find­First­File()-Find­Close() з WinAPI і т.д., які самостійно керують потрібними їм блоки пам’яті, інкапсулюючи її за непрозорими дескрипторами (handler). Випадки функцій, де потрібно спочатку звільнити ресурс, а потім – пам’ять під нього, цікаві – але всі, що трапилися, якісь надміру громіздкі. Якщо маєте пропозиції по розширенню – щодо пропущених ідей, навіть екзотичних, які трапляються в поширених системних API, пишіть. 

Виразно відчувається, що відповідні API розвивалися на системах із дуже обмеженою пам’яттю та обчислювальною потужністю. Економія пари байт тоді, часто зараз заважає, або, як мінімум – дратує. Зараз такі трюки трапляються в Embedded-коді і рідко десь ще – на щастя. І це не згадуючи про сам дизайн С-стрічок: “The Most Expensive One-byte Mistake”. Пара цитат із цієї статті:

We learn from our mistakes, so let me say for the record, before somebody comes up with a catchy but totally misleading Internet headline for this article, that there is absolutely no way Ken, Dennis, and Brian could have foreseen the full consequences of their choice some 30 years ago, and they disclaimed all warranties back then. For all I know, it took at least 15 years before anybody realized why this subtle decision was a bad idea, and few, if any, of my own IT decisions have stood up that long.

To a lot of people, C is a dead language, and ${lang} is the language of the future, for ever-changing transient values of ${lang}. The reality of the situation is that all other languages today directly or indirectly sit on top of the Posix API and the NUL-terminated string of C.

Отож, до справи. Якщо не вказано іншого, мова йде про засоби, що відповідають стандарту POSIX.

POSIX/libc/Linux/GNU libc

Зловісна функція gets()

Заборонена до використання! Прототип:

    char* gets(char *s);  

 Читає з консолі, поки не зустріне \n чи EOF.

  • Якщо буфера не вистачило – не її проблеми. Тому й застаріла – її не можливо використовувати безпечно.

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

Ніколи не пишіть такі API – інакше в пеклі погіршаться комунальні умови. 

Функція fgets()

Прототип:

    char* fgets(char *s, int size, FILE *stream);  

Читає з файлового потоку у переданий буфер s, розміром size.

  • Читає до size-1 символів (до \n чи EOF), в кінці прочитаного додає \0.
  • Розмір буфера має типу int…

Підхід: переданий користувачем буфер, із вказаним розміром, не допускає виходу за його межі. Турбується про нульовий символ. 

Функція strerror()

Прототип:

char* strerror(int errnum);  

Повертає вказівник на C-стрічку із описом помилки із переданим кодом2

  • Стрічку не можна модифікувати. const вона не помічена з історичних причин. Містить \0.
  • Наступні виклики strerror() чи strerror_l() можуть її модифікувати (але не інші функції). 
    • Однак, на старішому Cygwin може ще strerror_r().
  • Нереєнтрантна, не потокобезпечна, strerror_l() – потокобезпечний варіант.

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

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

Функція strerror_r()

Прототипів два:

int   strerror_r(int errnum, char *buf, size_t buflen);  \* XSI/POSIX-compliant *\  
char *strerror_r(int errnum, char *buf, size_t buflen);  \* GNU-specific  *\ 

Ця, новіша, функція, (в обох іпостасях) приймає вказівник на буфер і його розмір від користувача.

  • Потокобезпечна. 
  • Стандартний варіант повертає помилку, якщо буфер замалий.
    • Але програміст не знає, який буфер буде потрібен.
  • GNU-варіант може повертати як новий вказівник на свій статичний буфер, так і переданий buf, або й вказівник десь всередину цього буфера.
  • Якщо буфер замалий, може зберігати у ньому стільки, скільки помістилося, включаючи \0.

Поведінка нестандартних, якщо буфер замалий, особливо GNU варіанту, настільки плутана, що відповідні ідеї розглянемо на інших прикладах. Кому цікаво – див. “GNU Gnulib: strerror_r” (містить цікавий список варіацій між платформами), “FreeBSD Manual Pages: perror, strerror, strerror_r, sys_errlist, sys_nerr”, “IEEE Std 1003.1-2017: strerror, strerror_l, strerror_r” та джерельні тексти. 

Ремарка: Не очікуйте, що функції, які приймають (максимальний) розмір буфера переданого їм буфера, обмежаться мінімальними необхідними змінами у ньому. Раймонд Чен, ймовірно, на прикладі коду для Windows наводить приклад, на жаль, без конкретики: “If you say that your buffer can hold 200 characters, then it had better hold 200 characters”. Функція, що працює з буфером, побачивши його розмір, може вирішити скористатися буфером як тимчасовим сховищем, замість самостійно виділяти пам’ять. Можливо, затерши вміст більшої частини буфера, ніж потрібно, щоб зберегти результат. Ще один приклад економії.  

Підхід стандарту: користувацький буфер вказаного користувачем розміру, помилка, якщо замалий. Немає способу взнати потрібний розмір буферу, крім як експериментально.

Підхід в GNU варіанті:  “чорт ногу зломить”, буфер або користувацький, або свій, або результат десь в користувацькому.

Функція getwd()

Застаріла, прототип:

char *getwd(char *buf);  

Зберігає поточний шлях в переданий буфер. 

  • Буфер має бути не меншим за PATH_MAX. На системах, типу Linux, цього буфера може все рівно бути замало – тоді поверне помилку.

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

Функція getсwd()

Прототип: 

     char *getcwd(char *buf, size_t size);

Зберігає поточний шлях в переданий буфер buf вказаного розміру size.

  • Якщо буфер достатнього розміру – зберігає в нього поточний шлях, включаючи \0. 
  • Нестандартне розширення glibc: якщо buf == NULL, виділяє буфер викликом malloc(), розмір – size. Що буде, якщо його не вистачить, документація не конкретизує. Сподіваюся – memory leak не буде.
  • Нестандартне розширення glibc: Якщо ж size теж нульовий – виділяє буфер потрібного розміру. В будь-якому випадку, його тоді треба звільнити free().

Підхід стандарту: користувацький буфер вказаного користувачем розміру, помилка, якщо замалий. Враховує потребу додати \0. Немає способу взнати потрібний розмір буферу, крім як експериментально. 

Підхід в GNU варіанті: вміє сама виділяти буфер, який потрібно звільняти free(). Якщо попросити (вказавши size=0, але тільки тоді) – створить буфер потрібного розміру.

Функція get_current_dir_name()

Нестандартна функція GNU libc. Прототип:

char* get_current_dir_name(void);  

Повертає буфер, виділений malloc() із поточною директорією, включаючи \0.

Підхід: динамічно виділяє потрібний буфер, який слід звільняти викликом free()

Функція fmemopen()

Прототип:

 FILE *fmemopen(void *buf, size_t size, const char *mode);  

Створює файловий потік, який працюватиме із блоком пам’яті.

  • Якщо передано buf != NULL, розміру size, користуватиметься цим буфером.
  • Якщо buf == NULL, виділить буфер розміром size. Безпосереднього доступу до нього не буде, звільнить при закриванні файлу.
  • Також, закриваючи файл, якщо є місце в буфері, додає \0 в кінці. Якщо зайнято рівно size байт – не робить нічого. 
  • Записи за межі буферу приводять до помилки.

Підхід: використовує користувацький буфер вказаного користувачем розміру. Якщо попросити – виділить буфер потрібного розміру сама, керуватиме ним самостійно. Додає \0, але лише якщо є місце.

Функція open_memstream()

Прототип:

    FILE *open_memstream(char **ptr, size_t *sizeloc);  

Варіант попередньої функції, виділяє буфер динамічно та керує ним.

  • Буфер росте автоматично, за потреби, під час роботи із цим потоком. 
    • Оскільки приймає вказівник на вказівник на дані та вказівник на розмір – має куди зберігати нові значення розміру та вказівника. 
    • Тобто, нові значення розміру зберігаються за вказівником sizeloc, а новий вказівник на буфер – за ptr.
    • Оновлення відбувається під час виконання fflush() чи fclose().
  • Додає \0 в кінці, він не рахується до розміру, збереженого в *sizeloc.
  • Після закриття файлу, коли буфер стане не потрібним, потрібно самостійно звільнити його викликом free()

Видно, що це високорівнева функція, та й вона досить нова – POSIX.1-2008.

Підхід: створює буфер та керує його розміром автоматично. Надає користувачу доступ до вказівника на буфер та поточного розміру. Звільняти потрібно вручну, викликом free(). Турбується про \0 в кінці.

Взагалі, доволі незвичний інтерфейс.

Прототип:

ssize_t readlink(const char *pathname, char *buffer, size_t bufsiz);

Зберігає у переданий буфер buffer розміру bufsize те, на що посилається символьний лінк. 

  • Якщо буфер замалий – мовчки обрiзає результат.
  • Не додає нульового символу. Ймовірне пояснення: лише читає вміст symlink-файлу, а він нульових символів не зберігає.
  • Повертає, скільки байт записала в буфер.
  • Це значення можна використати, щоб додати нульовий символ. 
  • Якщо результат дорівнює розміру буфера – можливо, обрізало.

Підхід: користувацький буфер, вказаного користувачем розміру, якщо замалий – просто обрізає. Нульового символу не додає. Способу відрізнити переповнення буфера від того, що його якраз вистачило, не надає – хоча можна щось придумувати, типу повторних викликів.

Спосіб взнати потрібний розмір буфера, в принципі, є (ігноруючи можливість гонитви, race condition) – викликом lstat() або fstatat() з прапорцем AT_SYMLINK_NOFOLLOW, отримати розмір файлу-символьного посилання. Ймовірно, варто додати запас для \0 і контролю, чи справді вистачило буфера.  

Функції basename() та dirname()

Прототип із POSIX:

char* basename(char *path);
char* dirname (char *path);  

GNU-варіант:

char * basename (const char *filename) 

Повертають останній компонент переданого імені – все, що після останнього “/” та решту шляху – все, що перед останнім “/”, відповідно. Подробиці того, що роблять ці функції і як, виходять за межі цієї розмови – див. документацію.  А ось їх поведінка із пам’яттю – просто чудова.

  • Можуть модифікувати переданий буфер.
    • Тому типовий патерн використання – створювати для них копію. По одній для кожної. (Знову згадуємо про наслідки економії колись).
  • При тому, можуть повертати як вказівник на якісь свої статичні буфери, так і десь всередині (можливо, модифікованої ними) переданої стрічки path.  
  • Тому, заборонено цю стрічку змінювати, також заборонено змінювати стрічку path, поки вам потрібні результати роботи котроїсь із цих функцій. 
  • Не зважаючи на це, самі по собі – потокобезпечні.
  • Див. також документацію на GNU libc: basename.
    • GNU варіант собі такого не дозволяє.

Підхід: чи то статичний буфер, чи то модифікування переданої стрічки (можливо, відіграло роль те, що її завжди вистачить для результату) – як собі вирішить. Але потокобезпечні.

Win32/Win64 API

Ремарка: заради лаконічності, в цілому ігноруватимемо різницю між A-варіантом (ANSI) та W-варіантом – обмежимося необхідним мінімумом розмов про неї.

Функція WinAPI GetCurrentDirectory()

Прототип:

    DWORD GetCurrentDirectory(  
          DWORD  nBufferLength,  
          LPTSTR lpBuffer  
    );  

Зберігає поточну директорію за переданим буфером lpBuffer, вказаного користувачем розміру nBufferLength. 

  • Розмір буфера в TCHAR. Значення sizeof(TCHAR) дорівнює 1 байт (char) для ANSI варіанту та 2 байти (wchar_t в інтерпретації MS) для Unicode. 
  • Якщо буфер достатнього розміру, зберігши в нього поточний шлях, повертає, скільки символів було записано в буфер, включаючи нульовий символ в кінці.
  • Якщо ж буфер замалий – повертає, який розмір буфера потрібен (враховуючи \0). 
  • Якщо інша помилка, повертає 0.
  • Щодо потокобезпечності, в документації туманно написано… Ймовірно, безпечна для читання, але гарантії не дам.

Тобто, типовий патерн використання: викликати функцію двічі. Спершу із нульовим буфером, щоб отримати потрібний його розмір. Потім виділити пам’ять під буфер та викликати її ще раз. Цей патерн поширений у WinAPI.  

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

Функції WinAPI StringCbGetsA(), StringCbGetsW(), StringCchGetsA(), StringCchGetsW()

Функції схожі з точки зору маніпуляції пам’яттю, тому розглядаємо разом. Прототипи:

STRSAFEAPI StringCbGetsA(  
  STRSAFE_LPSTR pszDest,  
  size_t        cbDest  
);  
  
STRSAFEAPI StringCbGetsW(  
  STRSAFE_LPWSTR pszDest,  
  size_t         cbDest  
);  
  
STRSAFEAPI StringCchGetsA(  
  STRSAFE_LPSTR pszDest,  
  size_t        cchDest  
);  
  
STRSAFEAPI StringCchGetsW(  
  STRSAFE_LPWSTR pszDest,  
  size_t         cchDest  
);  

“Безпечний” аналог gets(). Детальніше про ці функції див. “strsafe.h header”.

  • Різницю між A i W окреслено в попередньому розділі, див. також тут.
    • Скажімо, ім’я StringCbGets() буде синонімом  StringCbGetsA(), якщо компілюємо ANSI-build, StringCbGetsW(), якщо Unicode.
  • Cb оперують над байтами, Cch над символами.
    • Зокрема, перші отримують розмір в байтах, другі – в одиницях, рівних розміру символу, 1 для A (char), 2 для W (wchar_t).
    • Власне, тому ця різниця A i W важлива для нашої розмови про керування пам’яттю – якимось функціям (крім Cb функцій, скажімо, ще засоби менеджменту пам’яті, типу GlobalAlloc()) передаємо завжди байти, множачи кількість символів на sizeof(TCHAR), якимось – кількість символів. 
  • Читають по \n, з буфера консолі цей символ забирають, але в буфер pszDest замість нього зберігають нульовий символ. 
  • Якщо буфер замалий, обрізають результат, завжди додають в кінці нульовий символ та повертають код помилки STRSAFE_E_INSUFFICIENT_BUFFER.

Підхід: використовує користувацький буфер вказаного користувачем розміру. Обрізає дані, якщо замалий, додає нульовий символ. Розмір буфера для частини із них потрібно вказувати не в байтах, а в символах.

А про різницю між UCS та UTF-16 і сурогатні пари навіть задумуватися неохота…

Функція WinAPI CreateProcessW()

Прототип:

BOOL CreateProcessW(  
  LPCWSTR               lpApplicationName,  
  LPWSTR                lpCommandLine,  
  LPSECURITY_ATTRIBUTES lpProcessAttributes,  
  LPSECURITY_ATTRIBUTES lpThreadAttributes,  
  BOOL                  bInheritHandles,  
  DWORD                 dwCreationFlags,  
  LPVOID                lpEnvironment,  
  LPCWSTR               lpCurrentDirectory,  
  LPSTARTUPINFOW        lpStartupInfo,  
  LPPROCESS_INFORMATION lpProcessInformation  
);  

Створює новий процес. Згадана тут заради однієї особливості. 

  • CreateProcessW(), але не CreateProcessA() може тимчасово модифікувати lpApplicationName  – ім’я модуля (програми), які потрібно завантажити. 
  • Причина – в 80-х трохи зекономили, на зайвому виділенні пам’яті. Детальніше див. “Why does the CreateProcess function modify its input command line?3.
  • CreateProcessA() не модифікує, оскільки вона й так створює копію свого аргументу, перетворену в Unicode UTF-16LE та передає його CreateProcessW(). 

Підхід: користується користувацькими буферами різних видів, але один із них може модифікувати, ймовірно – тимчасово, для своїх потреб. 

Функція WinAPI з Tool Help Library, Heap32Next()

Прототип, хоч він тут і не важливий:

BOOL Heap32Next(  
  LPHEAPENTRY32 lphe  
);  

Допоміжна функція, в першу чергу, для відлагодження – дозволяє навігацію купою. 

Цікава тим, що, хоча існує з часів Windows 3.1, в Windows 7 раптом почала бути дуже повільною: “Why is the Heap32Next function incredibly slow on Windows 7?”. Історія в цій статті розказана наступна: 

  • В Windows 95, Heap32­First() алокує структуру для збереження інформації про стан купи. 
  • Heap32­Next() використовує цю інформацію, щоб перейти до наступного блоку, і звільняє його, коли дійшла до кінця. Іншого способу звільнити цю структуру, крім як дійти до кінця купи, немає! 
  • При тому, реалізація цих функцій в Windows 95 була вразливою до гонитви, коли купа змінюється під час обходу. 
  • В Windows NT вирішили, що для них це не підходить, і стали робити знимку (snapshot) купи за кожного звертання до цих функцій. Складність обходу купи стала \(O(n^2)\).
  • Windows 7 зняв обмеження на максимальний розмір такого знимку. 

Цікавим є підхід до керування пам’яттю. Раніше виділяла і не звільняла, поки не дійшов до кінця, а зараз – не виділяє, але гальмує.

Додатки

Змінна errno

Змінна, куди системні виклики (формально – їх обгортки на С) та функції стандартної бібліотеки С записують код помилки. 

  • Глобальна. 
  • Коли прийшла багатопоточність – зробили Thread local, іншими словами, поселили в TLS. Тобто, кожен потік має свою копію. Класичний приклад використання TLS для quick-and-dirty виправлення несумісних із потокобезпечністю інтерфейсів. 
  • Код успіху рівний 0.
    • Однак, функція не зобов’язана зануляти цю змінну, якщо все добре, помилки не було.
    • При цьому – може її змінювати, навіть у випадку успіху4.
    • Ймовірно, причиною є оптимізація – не робити зайвого присвоєння.
    • Тому спочатку потрібно взнати, що сталася помилка (наприклад, системні виклики зазвичай повертають в такому випадку -1, функції С – це або інші не нульові значення), а вже потім аналізувати errno. 
  • При тому, одне значення errno – EINTR, означає, що помилки не було, спробуйте ще раз виконати виклик.
    • Причина – ціла окрема історія, яка розглядається на лекціях курсу “Операційні системи”.
  • Глобальність (для потоку) приводить до проблем реєнтрантності – в обробнику сигналів, перш ніж викликати навіть (у всьому іншому) реєнтрантні функції, потрібно спершу зберегти, а потім відновити errno.
    • Обробник сигналу може викликатися в будь-який момент, а зміна errno у випадкові моменти часу – засмутить основний код. 
  • Гірше, функція readdir() повертає NULL і коли сталася помилка і коли вона закінчила.
    • Але якщо все добре, помилки не було, вона успішно закінчила обхід директорії – не змінює errno.
    • Тому спершу потрібно встановити errno в 0, а якщо отримали NULL – дивитися, чого. - Скільки ще таких хитрих нюансів5 заховано в документації та реалізаціях POSIX – не знаю…

of errno after a successful call to a function is unspecified unless the description of that function specifies that errno not be modified.*``

WinAPI функція, GetLastError(), далеко не втекла. Заміна глобальної (TLS) змінної на функцію – покращує інкапсуляцію, але всі решта вади errno залишилися – і глобальність, і те, що не всі функції зануляють Last Error у випадку успіху.

Про довіру до програмістів

Підсумувати зміну підходу до розробки інструменті розробки, зокрема – мов програмування, до початку 90-х і після, можна грубо описати так (вільний переказ цитати, автора якої я не можу знайти): Раніше вважалося, що програмісти розумні та старанні, їм потрібно дати можливість робити хитрі речі, навіть якщо при тому вони зможуть зробити щось дурне. Тепер – що програмісти тупуваті та ліниві, їм не можна давати можливості робити щось хитре, вони ним більше шкідливого та дурного робитимуть, ніж корисного. Не вірите? Порівняйте C, С++ та й навіть Forth, PASCAL чи FORTRAN (сучасні) із C#, Java, Python i Go. (Те що із цієї ідеї іноді виростає JavaScript чи PHP – окрема тема :=).

Також, зацитую Раймонда Чена, (“Why is it even possible to disable the desktop anyway?”) з приводу деяких заморочок старих API:

Back in the old days, memory was tight, hard drives were luxuries, the most popular CPU for the IBM PC didn’t have memory protection, and software development was reserved for the rarefied elite who could afford to drop a few thousand dollars on an SDK. This had several consequences:

  • Tight memory means that anything optional had to be left behind.
  • Software developers were trusted not to be stupid.
  • Software developers were trusted not to be malicious.
  • Software developers were trusted to do the right thing.

Certainly there could have been a check in all the places where windows can be disabled to reject attempts to disable the desktop window, but that would have made one window “more special” than others, undermining the “simplicity” of the window manager. Anything optional had to be left behind.

Software developers were trusted not to make the sort of stupid mistakes that led to the desktop being disabled, the heap being corrupted, or any of the other “don’t do that” types of mistakes lurking in the shadows Windows programming. If such a serious mistake were to creep in, certainly their testing department would have caught it before the program was released. Software development was hard because nobody said this was going to be easy.

Software developers were trusted to treat their customers with respect. Because, after all, software developers who abuse their customers won’t have customers for very long.

Картинка сподобалася, взяв тут.

Сімейство sprintf

Цікавий приклад – лінійка функцій sprintf() -> snprintf() -> sprintf_s() -> snprintf_s()6. Всі вони записують у переданий буфер сформатовувану стрічку. Всі турбуються про \0 в кінці, але його до розмірів не рахують.

int sprintf( char *buffer, const char *format, ... ); // C89+ 

Якщо буфер замали – невизначена поведінка. Весела функція… Повертає, скільки байт записала.

int snprintf( char *buffer, size_t bufsz, const char *format, ... ); // C99+

Записує до bufsz-1 символів, додає нуль в кінці.

Якщо ж bufsz – нуль, нічого не записує в буфер, але повертає потрібну кількість символів.

int sprintf_s ( char *buffer, rsize_t bufsz, const char *format, ... ); // C11+
int snprintf_s( char *buffer, rsize_t bufsz, const char *format, ... ); // C11+

Поводяться схоже до snprintf(), але перевіряють більше помилкових ситуацій та, у випадку їх виникнення, викликають обробник, який може змінюватися програмістом.

sprintf_s() повертає, скільки байт записала, snprintf_s() – скільки б байт записала.

MS Visual Studio, а точніше – стандартна бібліотека Windows має також нестандартну функцію:

int _snprintf(char *buffer, size_t count, const char *format, ...);

Яка схожа на snprintf() – ця функція [не обов’язково присутня7 і з’явилася лише в С99, але більш “ретроградна”: не зберігяє \0 в кінці і повертає -1, якщо буфера не вистачило.

Але інша функція:

int _scprintf(const char *format, ...);

повертає потрібний розмір буфера, без фрахування \0.

Висновки 

Спостерігаємо наступні підходи:

  • Статичний буфер. Часто – не реєнтрантний, не потокобезпечний: strerror(),
    • але не завжди і те і те: strerror_l(), strerror_r().
  • Буфер, переданий користувачем:
    • Ігнорує його розмір: gets(), sprintf().
    • Ігнорує, але не виходить за визначені наперед межі: getwd() – можна надати достатньо великий буфер.
    • Враховує розмір, можливо, даючи помилку, якщо замалий: fgets(), getсwd(), _snprintf(), але може й не дати: readlink(), sprintf_s().
    • Враховує розмір, якщо замалий – повертає потрібну кількість8: GetCurrentDirectory(), snprintf(), snprintf_s().
  • Алокує буфер визначеною підсистемою, наприклад – malloc(): get_current_dir_name().
  • На вибір користувача – використовує готовий або алокує: fmemopen().
    • При цьому, може динамічно змінювати: open_memstream().
    • Або сама вирішує – скористатися користувацьким чи статичним: basename() та dirname().

Ремарка: список функцій аж ніяк не вичерпний – прикладів варіацій кожного підходу багато і в POSIX і в WinAPI.

  • Плюс патерну WinAPI, коли функція викликається двічі – раз, щоб отримати потрібний розмір буфера, інший – щоб нарешті отримати інформацію, в тому, що програміст зберігає контроль над виділенням пам’яті. Мінус – потреба повторного виклику і ручного виділення.
  • POSIX-сумісні функції, які виділяють пам’ять викликом (еквівалентним) malloc, простіші у використанні, але обмежують вибір способів створити буфер. Результат методу vector::data() їм не згодуєш, а враховуючи відсутність способів взнати потрібний розмір -- доведеться повозитися, і щоб фіксований буфер передати.
  • Функції POSIX чи WinAPI, що змушують возитися із статичними буферами я схильний розглядати як legacy, з якими, можливо, доводиться жити, але точно не варто наслідувати.

Див. також схожий, але більш спеціалізований огляд від Раймнда Чена: ‘‘How do I free the pointers returned by functions like Get­Token­Information?’’. Зокрема, він звертає увагу, що якщо функціонал реалізований в ядрі та викликається з user mode, а ядро не може виділяти пам’ять на купі в user space, він потребуватиме буфер, виділений в програмі.

Тобто, задача непроста – і розробники відповідних API, шукаючи компромісні рішення для відповідного інженерного конфлікту, створили найрізноманітніших монстрів.

  1. Книга Brian KernighanUNIX: A History and a Memoir”, 2019 трохи прояснила деякі нюанси. Все ж, в цікаві часи живемо – творці технологій, письменники та науковці, чиї твори та відкриття формували той світ із яким я, та багато хто із мого оточення, взаємодіють, часто ще живі. Керніган книгу спогадів випустив, Том Рей (так, я нахабно хвалюся) відгук на роботу моєму дипломнику написав – про цю роботу ще писатиму, студентка враженнями від спілкування із Tim Berners-Lee ділиться тощо Однак, повчальнішими є технічні книжки тих часів. Charles Petzold, “Programming Windows” 1988-го року – ще про Windows 2.0, разом із “Extending DOS” by Ray Duncan et. al” та “Эндрю Шульман – Неофициальная Windows 95”, “The Old New Thing” від Raymond Chen і парою книг про OS/2, сильно прояснюють, чому MS Windows є таким, як є. Старі видання Таненбаума та “Operating systems” by Harvey Deitel and Paul Deitel, прояснили те ж стосовно інших ОС (зрозуміло – менш глибоко). Читається, як художні (історично-детективні) романи – коли вже твердо знаєш, що було далі. Іноді хочеться посміятися над наївністю, але частіше дивуєшся прозорливістю – коли пишуть щось, що через десяток років назвали новим проривом. Або коли бачиш (поза-)попередній виток спіралі технологій – типу обіцянки штучного інтелекту, голосових інтерфейсів та розпізнання мови, автоматичних перекладачів у книжках 60-х. Головне, написано дуже схоже на тексти в сучасних. Приклад: “Дитяча енциклопедія, том 03, числа і фігури, речовина і енергія”, 1959 російською/1963 українською, розділ про ЕОМ. Хоча, аж зараз, здається, ці обіцянки стають реальністю. 

  2. Який ми, ймовірно, взяли з errno. 

  3. Цитата звідти: “Basically, somebody back in the 1980’s wanted to avoid allocating memory. (Another way of interpreting this is that somebody tried to be a bit too clever.)”, коли підбиралося розширення виконавчого файлу, але ще одна причина – уникнення проблем з дебаггером, якщо пам’ять на купі закінчилася. 

  4. Цитуючи errno(3p): ``*No function this volume of POSIX.1‐2017 shall set errno to 0. The setting 

  5. Ще один цікавий приклад на тему поста: I accidentally performed an operation on INVALID_HANDLE_VALUE, and it worked: What just happened? – GetCurrentProcess() свідомо повертає значення INVALID_HANDLE_VALUE, яке тоді може бути сприйнято як коректний handle і, наприклад, замість надати доступ до файлу іншому процесу, коли файл не вдалося відкрити – надати доступ до свого процесу з максимальними правами. 

  6. Оголошення взято тут, але заради простоти, restrict не наводжу. 

  7. Див. тут макроси __STDC_LIB_EXT1__ та __STDC_WANT_LIB_EXT1__

  8. Часто повідомляють значення більше, ніж мінімально необхідне – це ефективніше. Див., наприклад, “Functions that return the size of a required buffer generally return upper bounds, not tight bounds”