4 errori comuni di programmazione in C e 5 consigli per evitarli

Pochi linguaggi di programmazione possono eguagliare C per velocità assoluta e potenza a livello di macchina. Questa affermazione era vera 50 anni fa, ed è ancora vera oggi. Tuttavia, c'è una ragione per cui i programmatori hanno coniato il termine "footgun" per descrivere il tipo di potenza di C. Se non stai attento, C può farti saltare le dita dei piedi o di qualcun altro.

Ecco quattro degli errori più comuni che puoi commettere con C e cinque passaggi che puoi fare per prevenirli.

Errore C comune: non liberare la mallocmemoria (o liberarla più di una volta)

Questo è uno dei grandi errori in C, molti dei quali riguardano la gestione della memoria. La memoria allocata (eseguita utilizzando la malloc funzione) non viene eliminata automaticamente in C. È compito del programmatore smaltire quella memoria quando non viene più utilizzata. Non riesci a liberare richieste di memoria ripetute e ti ritroverai con una perdita di memoria. Prova a utilizzare una regione di memoria che è già stata liberata e il tuo programma andrà in crash o, peggio, zoppicherà e diventerà vulnerabile a un attacco che utilizza quel meccanismo.

Si noti che una perdita di memoria dovrebbe descrivere solo le situazioni in cui si suppone che la memoria venga liberata, ma non lo è. Se un programma continua ad allocare memoria perché la memoria è effettivamente necessaria e utilizzata per il lavoro, il suo utilizzo della memoria potrebbe essere  inefficiente , ma in senso stretto non è una perdita.

Errore comune in C: leggere un array fuori dai limiti

Qui abbiamo ancora un altro degli errori più comuni e pericolosi in C. Una lettura oltre la fine di un array può restituire dati inutili. Una scrittura oltre i confini di un array potrebbe danneggiare lo stato del programma o bloccarlo completamente o, peggio di tutto, diventare un vettore di attacco per il malware.

Allora perché l'onere di controllare i limiti di un array è lasciato al programmatore? Nella specifica C ufficiale, leggere o scrivere un array oltre i suoi confini è un "comportamento indefinito", il che significa che la specifica non ha voce in capitolo su ciò che dovrebbe accadere. Il compilatore non è nemmeno tenuto a lamentarsene.

C ha a lungo favorito il conferimento di potere al programmatore anche a proprio rischio. Una lettura o una scrittura fuori limite in genere non viene intercettata dal compilatore, a meno che non si abiliti specificamente le opzioni del compilatore per proteggersi. Inoltre, potrebbe essere possibile superare il limite di un array in fase di esecuzione in un modo da cui nemmeno un controllo del compilatore può proteggersi.

Errore comune C: non controllare i risultati di malloc

malloc e calloc (per memoria pre-azzerata) sono le funzioni della libreria C che ottengono la memoria allocata nell'heap dal sistema. Se non sono in grado di allocare memoria, generano un errore. Ai tempi in cui i computer avevano relativamente poca memoria, c'erano buone possibilità che una chiamata a mallocnon avesse successo.

Anche se oggi i computer hanno gigabyte di RAM da gettare, c'è sempre la possibilità che mallocpossa fallire, specialmente sotto un'elevata pressione della memoria o quando si allocano grandi lastre di memoria contemporaneamente. Ciò è particolarmente vero per i programmi C che "allocano in modo slab" un grande blocco di memoria dal sistema operativo prima e poi lo dividono per il proprio uso. Se la prima allocazione fallisce perché è troppo grande, potresti essere in grado di intercettare quel rifiuto, ridimensionare l'allocazione e regolare di conseguenza l'euristica di utilizzo della memoria del programma. Ma se l'allocazione della memoria fallisce senza essere bloccata, l'intero programma potrebbe andare a gambe all'aria.

Errore comune in C: utilizzo void*di puntatori generici alla memoria

Usare  void* per indicare la memoria è una vecchia abitudine, e cattiva. Puntatori alla memoria deve essere sempre char*, unsigned char*uintptr_t*. Le moderne suite di compilatori C dovrebbero fornire uintptr_tcome parte di stdint.h

Quando viene etichettato in uno di questi modi, è chiaro che il puntatore si riferisce a una posizione di memoria in astratto invece che a un tipo di oggetto indefinito. Questo è doppiamente importante se stai eseguendo la matematica del puntatore. Con  uintptr_t*e simili, l'elemento dimensione a cui si punta e come verrà utilizzato non sono ambigui. Con void*, non così tanto.

Evitare gli errori comuni del C - 5 suggerimenti

Come si evitano questi errori fin troppo comuni quando si lavora con memoria, array e puntatori in C? Tieni a mente questi cinque suggerimenti. 

Struttura i programmi C in modo che la proprietà della memoria sia mantenuta libera

Se stai appena avviando un'app C, vale la pena pensare al modo in cui la memoria viene allocata e rilasciata come uno dei principi organizzativi del programma. Se non è chiaro dove viene liberata una determinata allocazione di memoria o in quali circostanze, stai cercando guai. Fai uno sforzo in più per rendere la proprietà della memoria il più chiara possibile. Farai un favore a te stesso (e ai futuri sviluppatori).

Questa è la filosofia alla base di linguaggi come Rust. Rust rende impossibile scrivere un programma che si compili correttamente a meno che tu non esprima chiaramente come la memoria è posseduta e trasferita. C non ha tali restrizioni, ma è saggio adottare quella filosofia come luce guida quando possibile.

Usa le opzioni del compilatore C che proteggono dai problemi di memoria

Molti dei problemi descritti nella prima metà di questo articolo possono essere contrassegnati utilizzando le strette opzioni del compilatore. Le edizioni recenti di gcc, ad esempio, forniscono strumenti come AddressSanitizer ("ASAN") come opzione di compilazione per verificare i comuni errori di gestione della memoria.

Attenzione, questi strumenti non catturano assolutamente tutto. Sono guardrail; non afferrano il volante se vai fuori strada. Inoltre, alcuni di questi strumenti, come ASAN, impongono costi di compilazione e runtime, quindi dovrebbero essere evitati nelle build di rilascio.

Usa Cppcheck o Valgrind per analizzare il codice C per perdite di memoria

Laddove i compilatori stessi falliscono, altri strumenti intervengono per colmare il divario, specialmente quando si tratta di analizzare il comportamento del programma in fase di esecuzione.

Cppcheck esegue un'analisi statica sul codice sorgente C per cercare errori comuni nella gestione della memoria e comportamenti indefiniti (tra le altre cose).

Valgrind fornisce una cache di strumenti per rilevare errori di memoria e thread nell'esecuzione di programmi C. Questo è molto più potente dell'utilizzo dell'analisi in fase di compilazione, poiché è possibile ricavare informazioni sul comportamento del programma quando è effettivamente attivo. Lo svantaggio è che il programma funziona a una frazione della sua velocità normale. Ma questo va generalmente bene per i test.

Questi strumenti non sono proiettili d'argento e non prenderanno tutto. Ma funzionano come parte di una strategia difensiva generale contro la cattiva gestione della memoria in C.

Automatizza la gestione della memoria C con un garbage collector

Poiché gli errori di memoria sono una fonte evidente di problemi di C, ecco una semplice soluzione: non gestire manualmente la memoria in C. Usa un netturbino. 

Sì, questo è possibile in C. Puoi usare qualcosa come il garbage collector Boehm-Demers-Weiser per aggiungere la gestione automatica della memoria ai programmi C. Per alcuni programmi, l'utilizzo del raccoglitore Boehm può anche velocizzare le cose. Può anche essere utilizzato come meccanismo di rilevamento delle perdite.

Lo svantaggio principale del garbage collector di Boehm è che non può eseguire la scansione o liberare la memoria che utilizza l'impostazione predefinita malloc. Utilizza la propria funzione di allocazione e funziona solo sulla memoria allocata specificamente con essa.

Non usare C quando andrà bene un'altra lingua

Alcune persone scrivono in C perché lo apprezzano sinceramente e lo trovano fruttuoso. Nel complesso, però, è meglio usare C solo quando è necessario, e quindi solo con parsimonia, per le poche situazioni in cui è davvero la scelta ideale.

Se hai un progetto in cui le prestazioni di esecuzione saranno limitate principalmente dall'I / O o dall'accesso al disco, è improbabile che scriverlo in C lo renderà più veloce nei modi che contano, e probabilmente lo renderà solo più soggetto a errori e difficile da mantenere. Lo stesso programma potrebbe essere scritto in Go o Python.

Un altro approccio consiste nell'usare il C solo per le parti dell'app ad alta intensità di prestazioni e un linguaggio più affidabile anche se più lento per le altre parti. Ancora una volta, Python può essere utilizzato per eseguire il wrapping di librerie C o codice C personalizzato, rendendolo una buona scelta per i componenti più standard come la gestione delle opzioni della riga di comando.