4 често срещани грешки при програмиране на C - и 5 съвета за тяхното избягване

Малко езици за програмиране могат да съвпадат с C за чиста скорост и мощност на ниво машина. Това твърдение беше вярно преди 50 години и е вярно и днес. Има обаче причина програмистите да измислят термина „footgun“, за да опишат вида на мощта на C. Ако не сте внимателни, C може да издуха пръстите на краката ви - или на някой друг.

Ето четири от най-често срещаните грешки, които можете да направите с C, и пет стъпки, които можете да предприемете, за да ги предотвратите.

Често срещана грешка C: Не освобождаване на mallocпаметта (или освобождаването й повече от веднъж)

Това е една от големите грешки в C, много от които включват управление на паметта. Разпределената памет (направена с помощта на malloc функцията) не се изхвърля автоматично в C. Работата на програмиста е да изхвърли тази памет, когато тя вече не се използва. Не успеете да освободите повторни заявки за памет и ще получите изтичане на памет. Опитайте да използвате регион на паметта, който вече е освободен, и програмата ви ще се срине - или, още по-лошо, ще накуцва и ще стане уязвима за атака, използвайки този механизъм.

Имайте предвид, че изтичането на памет трябва да описва само ситуации, при които паметта трябва да бъде освободена, но не е така. Ако дадена програма продължава да разпределя памет, защото паметта всъщност е необходима и се използва за работа, тогава използването на паметта може да е  неефективно , но строго погледнато не е изтичане.

Често срещана грешка C: Четене на масив извън границите

Тук имаме още една от най-често срещаните и опасни грешки в C. Четенето след края на масив може да върне данни за боклук. Записването покрай границите на масив може да повреди състоянието на програмата или да я срине напълно или най-лошото от всичко да се превърне във вектор на атака за зловреден софтуер.

И така, защо тежестта от проверка на границите на масива е оставена на програмиста? В официалната спецификация C четенето или записването на масив извън неговите граници е „недефинирано поведение“, което означава, че спецификацията няма мнение в това, което трябва да се случи. Компилаторът дори не е длъжен да се оплаква от него.

C отдавна предпочита да дава власт на програмиста дори на свой риск. Извън границите на четене или запис обикновено не се улавя от компилатора, освен ако не разрешите изрично опциите на компилатора, за да се предпазите от него. Нещо повече, може да е възможно да се превиши границата на масив по време на изпълнение по начин, от който дори проверката на компилатора не може да се предпази.

Често срещана грешка C: Непроверка на резултатите от malloc

malloc и calloc (за предварително нулирана памет) са функциите на библиотеката C, които получават разпределена памет от системата. Ако не могат да разпределят памет, генерират грешка. По времето, когато компютрите имаха относително малко памет, имаше доста голяма вероятност обаждането да mallocне бъде успешно.

Въпреки че днес компютрите разполагат с гигабайта RAM за разхвърляне, все пак винаги има шанс mallocда се провалят, особено при високо налягане на паметта или при разпределяне на големи плочи памет наведнъж. Това важи особено за програми на С, които първо „разпределят“ голям блок памет от ОС и след това го разделят за собствена употреба. Ако първото разпределение се провали, защото е твърде голямо, може да успеете да заловите този отказ, да намалите разпределението и да настроите съответно евристиката на използването на паметта на програмата. Но ако разпределението на паметта не успее да се прихване, цялата програма може да се повреди.

Често срещана грешка C: Използване void*за общи указатели към паметта

Използването  void* за посочване на паметта е стар навик - и лош. Указатели към паметта винаги трябва да има char*, unsigned char*или  uintptr_t*. Съвременните C компилаторни пакети трябва да предоставят uintptr_tкато част от stdint.h

Когато е етикетиран по един от тези начини, става ясно, че указателят се отнася до място в паметта в резюмето, а не към някакъв неопределен тип обект. Това е двойно важно, ако изпълнявате указателна математика. С  uintptr_t*и подобни, елементът на размера, към който се сочи и как ще бъде използван, са еднозначни. С void*, не чак толкова.

Избягване на често срещани грешки C - 5 съвета

Как да избегнете тези твърде често срещани грешки при работа с памет, масиви и указатели в C? Имайте предвид тези пет съвета. 

Структура C програми, така че собствеността върху паметта да остане ясна

Ако току-що стартирате приложение на C, струва си да помислите за начина, по който паметта се разпределя и освобождава като един от организационните принципи на програмата. Ако не е ясно къде се освобождава дадено разпределение на паметта и при какви обстоятелства, вие искате неприятности. Направете допълнителни усилия, за да направите собствеността на паметта възможно най-ясна. Ще направите услуга на себе си (и бъдещите разработчици).

Това е философията зад езици като Ръжда. Rust прави невъзможно писането на програма, която се компилира правилно, освен ако ясно изразите как паметта се притежава и прехвърля. C няма такива ограничения, но е разумно да приемем тази философия като насочваща светлина, когато е възможно.

Използвайте опциите на компилатора C, които предпазват от проблеми с паметта

Много от проблемите, описани в първата половина на тази статия, могат да бъдат маркирани чрез използване на строги опции за компилатор. Последните издания на gcc, например, предоставят инструменти като AddressSanitizer (“ASAN”) като опция за компилиране за проверка срещу често срещани грешки в управлението на паметта.

Внимавайте, тези инструменти не улавят абсолютно всичко. Те са мантинели; те не хващат волана, ако тръгнете извън пътя. Също така, някои от тези инструменти, като ASAN, налагат разходи за компилация и изпълнение, така че трябва да се избягват при компилиране на издания.

Използвайте Cppcheck или Valgrind, за да анализирате C код за изтичане на памет

Когато самите компилатори не успяват, други инструменти се намесват, за да запълнят празнината - особено когато става въпрос за анализ на поведението на програмата по време на изпълнение.

Cppcheck изпълнява статичен анализ на изходния код на C, за да търси често срещани грешки в управлението на паметта и недефинираното поведение (наред с други неща).

Valgrind предоставя кеш от инструменти за откриване на грешки в паметта и нишките при работещи програми на C. Това е много по-мощно от използването на анализ по време на компилация, тъй като можете да извлечете информация за поведението на програмата, когато тя действително е на живо. Недостатъкът е, че програмата работи с част от нормалната си скорост. Но това обикновено е добре за тестване.

Тези инструменти не са сребърни куршуми и няма да хванат всичко. Но те работят като част от обща защитна стратегия срещу лошото управление на паметта в C.

Автоматизирайте управлението на паметта C с колектор за боклук

Тъй като грешките в паметта са забележим източник на проблеми на C, ето едно лесно решение: Не управлявайте паметта в C ръчно. Използвайте колектор за боклук. 

Да, това е възможно в C. Можете да използвате нещо като събирач на боклук Boehm-Demers-Weiser, за да добавите автоматично управление на паметта към C програми. За някои програми използването на колектора Boehm може дори да ускори нещата. Може дори да се използва като механизъм за откриване на течове.

Основният недостатък на събирача на боклук на Boehm е, че той не може да сканира или освобождава памет, която използва по подразбиране malloc. Той използва собствена функция за разпределение и работи само върху паметта, която разпределяте специално с нея.

Не използвайте C, когато друг език е подходящ

Някои хора пишат на С, защото искрено му харесват и го намират за плодотворен. Като цяло обаче е най-добре да използвате C само когато трябва, и то само пестеливо, за малкото ситуации, в които наистина е идеалният избор.

Ако имате проект, при който изпълнението на изпълнението ще бъде ограничено главно от входно-изходни или дискови достъпи, писането му в C вероятно няма да го направи по-бърз по важните начини и вероятно ще го направи само по-податлив на грешки и труден за поддържа. Същата програма може да бъде написана на Go или Python.

Друг подход е да се използва C само за наистина ефективните части на приложението и по-надежден, макар и по-бавен език за други части. Отново, Python може да се използва за опаковане на C библиотеки или персонализиран C код, което го прави добър избор за повече компоненти, като например обработка на опции в командния ред.