Приветствую вас, дорогие читатели. В этом посте вы узнаете о том, как избежать самых распространённых ошибок, программируя на языке С.

Все типы данных, которые используются в исходных кодах, могут различаться размером в зависимости от архитектуры целевой машины, на которой компилируют программный код (см. заголовок О РАЗМЕРЕ ТИПОВ ДАННЫХ)

О РАЗМЕРЕ ТИПОВ ДАННЫХ

В данной статье предполагается, что машина, на которой компилируется исходный код и запускается программа на языке Си, поддерживает тип данных long int с размером ровно 4 байта, тип int - размером 4 байта и тип char - ровно 1 байт. Указатели имеют размер 4 байта. Тип long long int имеет размер 8 байт. Размер типа данных, используемых в настоящей статье, зависят от реализации и архитектуры платформ. За подробностями, следует ознакомиться с соответствующей документацией. Минимальный допустимый диапазон значений типов данных, который должна поддерживать реализация, указана в стандарте ISO\IEC 9899 в редакциях от 1990, 1999, 2011 а также 2018 года, в пункте 5.2.4.2.1 в которой перечислены основные имена констант, и их значения. Эти константы определены в заголовочном файле <limits.h>

Неправильное использование символа конца строки (нулевого символа '\0')

Рассмотрим следующий код:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char **argv){
  char *str = "Hello \0 world!\n";
  int l = strlen(str);
  printf("Str: %s\nLength: %d\n", str, l);
}

В данном коде мы получим следующие результаты

Str: Hello 
Length: 6

Т.к. маркер конца строки помещён между подстроками "Hello " и "world!\n" , а большинство библиотечных функций используют для проверки достижения конца строки равенство просматриваемого символа с нулевым символом, т. е. :

while((cur_symbol = *str++) != '\0') process_symbol(cur_symbol);

то после прочтения пробела и достижения символа '\0' функция strlen вернёт значение равное 6. Она не учитывает нулевой символ. Аналогично, функция printf будет подставлять вместо %s символы строки str в стандартный поток вывода до тех пор, пока не прочтёт нулевой символ.

Конечно, никто так явно не вставляет нулевой символ посередине строки. Но что если мы решили разработать свой протокол со своим форматом сообщений для обмена данными между удалёнными хостами? Мы вполне могли хранить в качестве первых четырех символов строки байты числа, представляющего длину сообщения, которое бы следовало за ним. Т.к. тип char занимает в памяти ровно один байт, то логично, что для хранения длины сообщения, представленного типом long int мы бы зарезервировали для него первые четыре символа в массиве символов char[], или четыре ячейки блока данных, на которые указывает указатель char * ptr. Но проблема в том, что некоторые байты 32-битного числа могут оказаться равны нулю, из-за разных величин длины сообщения (например, если длина сообщения равна 232 символам). Такие байты при приведении к типу char могут создать нулевой символ в начале строки, или где-то ещё.

Решением данной проблемы будет использование библиотечной функции memcpy которая имеет следующий вид:

memcpy(void *dest, const void *source, size_t n);

Данная функция копирует ровно первые n-байтов из места, указанные через указатель source, в начало блока, указанное адресом dest.

Покажем на примере, как сформировать сообщение:

#include <string.h>
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv){
    char *msg = malloc(20);
    long int l = 16;
    memcpy(msg, &l, 4);
    char *c1 = "Hello world!!!\n"; /* length 15 symbols + 1 '\0' symbol */
    memcpy(msg + 4, c1, 16);
    
    
    long int l2 = 0;
    memcpy(&l2, msg, 4);
    char *c2 = malloc(l2);
    memcpy(c2, msg + 4, l2);
    
    
    printf("Str: %s\nLength: %d\n", c2, l2);
    
    free(msg);
    free(c2);
}

В данной программе происходит упаковка и распаковка данных сообщения. В начале мы выделяем блок памяти в размере 20 байт для хранения сообщения. Его первые 4 байта будут хранить длину сообщения, равную 16 байтам. Для этого создали переменную l и записали в неё длину сообщения. Затем с помощью функции memcpy скопировали её полностью в блок msg. Теперь 4 байта msg хранят длину сообщения. Затем создали переменную c1, которая хранит фактическое сообщение. Количество символов в строковом литерале 16, поскольку компилятор неявно добавил один нулевой символ '\0' к строке. После этого, вызываем memcpy, передавая ей в качестве места назначения адрес 5 ячейки блока памяти переменной msg в качестве места назначения, и указатель на строку c1, в качестве адреса источника, а также длину сообщения с учётом нулевого символа, т.е. значение переменной l.

Далее, чтобы извлечь длину сообщения и сами данные, были определены две переменные: l2 и c2. C помощью memcpy копируем в переменную l2 первые четыре байта блока msg. Затем в переменную c2 копируем само сообщение из msg, которое начинается с пятого байта msg, и имеет длину, равную l2, которую мы получили ранее.

Наконец, с помощью printf, выводим содержимое переменных l2 и c2. Нетрудно убедиться, что мы получим на выводе следующие строки

Str: Hello world!!!

Length: 16

Как видно, мы ничего не потеряли. С помощью memcpy можно легко и просто переносить части сообщения в единый пакет, представленный указателем на блок ячеек типа char.

Отметим одно важное ограничение на функцию memcpy: блоки данных, на которые указывают первые два параметра функции, НЕ ДОЛЖНЫ ПЕРЕКРЫВАТЬСЯ. Это значит, что если они указывают на один и тот же блок ячеек памяти, то возможны ошибки.

Попытка изменить содержимое строки, которая была создана с помощью указателя и строчного литерала.

Рассмотрим следующий код:

char *mystr = "This is my string\n";
mystr = mystr + 13;
*mystr = 'u';

При выполнении третьей строчки кода мы получим Segmentation Fault. Причина же этого в том, что память под mystr была выделена в сегменте данных. Данный сегмент доступен только для чтения и это вполне очевидно поскольку при выполнении машинных инструкции, которые содержатся в части .text данного сегмента, никто не должен менять сегмент с целью изменения машинных команд. Поэтому содержимое, которое хранится по адресу, который записан в переменной mystr , доступно только для чтения.

Для решения данной проблемы можно было воспользоваться массивом или функцией malloc, которая бы выделила память в динамической изменяемой куче, где данные доступны и для чтения и для записи. Единственное ограничение - это количество выделяемой памяти, как для массива, так и для блока ячеек данных в куче. В приведенном выше примере для строки mystr потребуется 19 байт.

Неправильное освобождение памяти через функцию free

При выделении памяти в куче с помощью функций malloc или calloc необходимо позаботиться об освобождении ресурсов, т.е. выделенной памяти, после того, как работа с выделенными блоками была завершена. Когда была выполнена последняя команда в функции main, или была вызвана одна из функции семейства exit то процесс автоматически известит ядро системы о завершении работы, а ядро позаботится о том, чтобы освободить память, которую процесс больше не использует, а также закроет все открытые файлы данным процессом, (если, конечно, не была вызвана функция _exit, которая не закрывает файловые дескрипторы, открытые процессом).

Но что если мы больше не используем данные блоки, а работа программы ещё не окончена? Конечно, размер блока может быть небольшим, но он также может быть и достаточно великим, чтобы просто так занимать память процесса. С помощью функции free мы можем освободить блок памяти, передав указатель (т.е. адрес начала блока) ей следующим образом:

char *s1 = malloc(255);
process(s1);
free(s1);

Данный код работает правильно, несмотря на изменения функцией process с указателем s1. Функции process передаётся копия значения, т.е. адрес начала блока из 255 байтов, на который указывает переменная s1. Эта копия сохранится в локальной переменной функции s1, которая является также её формальным параметром. Описание же функции process выглядит так:

process(char *s);

Конечно, внутри тела функции process можно вызвать любую функцию, которая создаст побочный эффект, т. е. изменит значение переменной s1 извне. Но в данном примере, предположим, что она написана правильно.

Чтобы вызвать ошибку, достаточно перед функции free добавить следующую строчку:

s1 = s1 + 1;

Данная инструкция сохранит новый адрес в переменную s1, который является лишь смещением относительно адреса, хранимого в s1 на 1 байт. Это приведёт к ошибке при вызове free, поскольку теперь s1 указывает не на начало блока ячеек данных, а на вторую ячейку блока.

Конечно, никто так явно не сделает. Но, допустим, что вы используете указатели для итерации, которые хранят текущий элемент из блока. Например, пусть вы хотите вывести все символы строки, на которую ссылается указатель sptr:

char sptr* = (char*)calloc(14, sizeof(char));
strcpy(sptr, "Hello world!\n");
long int i = 0;
char c;
while((c = *sptr++) != '\0'){
	printf("s[%ld] = %c\n", i, c);
}
free(sptr);

Здесь, функция strcpy копирует содержимое второй строки в начало блока ячеек, на который указывает указатель sptr. Но она не копирует нулевой символ. Функция выделения памяти в куче calloc выделяет память с указанным количеством ячеек указанного размера. Её первый аргумент указывает на число элементов в блоке, а второй - на размер ячейки в блоке. Кроме того, функция calloc дополнительно вызывает функцию memset, которая заполнит все ячейки блока нулями (т.е. в конце строки в любом случае окажется нулевой символ, если, конечно, мы его не затрём чем-нибудь другим). Функция memset принимает три параметра: адрес блока ячеек памяти, значение, которое надо присвоить ячейкам данного блока, и число ячеек, которые необходимо заполнить данным значением.

После выполнения цикла while, указатель будет ссылаться на последнюю ячейку в блоке. И мы получим вышеописанную ошибку. Конечно, если знать, что функция free должна принимать адрес начала блока, то проблем можно легко избежать. Всего-навсего, надо лишь сохранить адрес начала блока выделенной памяти в другую переменную, и работать с ней. А исходную переменную не трогать, её-то и надо будет передать функции free, после того как работа с блоком будет завершена.

В предыдущем куске кода, для этого надо сделать две вещи:

1) Добавить строчку кода перед циклом while

char *sptr_p = sptr;

2) Заменить имя переменной sptr на sptr_p во всех выражениях и инструкциях, где изменяется адрес sptr (то есть содержимое переменной sptr).

Совет: всегда проверяйте, что указатели, переданные функции free, указывают на НАЧАЛО БЛОКА. И всегда сохраняйте адрес начала блока выделенной памяти.

Использование локальных переменных функции за её пределами после завершения работы функции

Предположим, что мы используем локальные переменные, для которых память была выделена в кадре стека функции, следующим образом:

void process_person(struct Person *p){
	char name[] = "El Barto\0";
  p->name = name;
  printf("Person name: %s was initiated\n", p->name);
}

Структура Person, адрес которой и передаётся данной функции может выглядеть следующим образом:

struct Person {
	char *name;
};

Предположим, что в коде функции main, выполняется следующий код, который создаёт новую переменную типа Person, и инициирует её имя (name) через функцию process_person:

/* in main() body */
struct Person p1;
process_person(&p1);
sleep(2);
printf("Person name is: \"%s\"\n", p1.name);

В коде функции main, выделяется память под переменную типа структуры Person в кадре стека, соответствующему вызову функции main. Далее вызывается функция process_person, которая получает адрес, где хранится структура. Далее, в стеке создаётся новый кадр, который соответствует вызову функции process_person. В данном кадре выделяется память под переменную массива символов name. Далее адрес начала (первой ячейки массива) копируется в поле структуры name, и выводится содержимое данного поля структуры. После того, как завершится работа функции process_person, происходит приостановка выполнения следующей строчки кода, на 2 сек. (вызов функции sleep). Функция sleep определена в заголовочном файле <unistd.h> и имеет следующий прототип:

unsigned int sleep(unsigned int seconds);

После 2 секунд, выполняется последняя строчка кода, которая должна вывести содержимое поля структуры, хранимой в переменной p1. Но т.к. память может быть уже очищена, то в данном поле ничего не будет. В итоге мы можем получить следующий вывод:

Person name: El Barto was initiated 
Person name is: "

Вторая двойная кавычка и всё, что за ней следует, не появилось в стандартном выводе, так как после очистки памяти из-за удаления кадра стека функции process_person поле структуры p1.name приняло значение по умолчанию, равное нулевому символу \'0'.

Стоит отметить, что данное поведение неопределенно, поскольку память может не успеть очиститься, и мы можем получить что-то вроде такого:

Person name: El Barto was initiated
Person name is: "ElBar2#1"

Чтобы избежать подобных ситуации, рекомендую придерживаться принципа Тараса Бульбы:

"Я тебя породил, я тебя и убью."

Т.е. выделять и освобождать один и тот же ресурс необходимо на одном уровне. Т.е. если выделили память внутри функции f, то освободить её надо именно в пределах (внутри) функции f. Внутри, значит в её теле. Причём, если мы вызываем другую функцию g внутри f, и g освобождает память, выделенную в f, то мы не можем считать, что мы освободили ресурс на одном уровне, поскольку выделение происходит в функции f, а освобождение в функции g.

Конечно, при возникновении ошибок, возможны ситуации смена управляющего потока кода, т.е. переход из тела одной функции в тело другой. В данном случае, при подобных ситуациях, можно использовать глобальные переменные с глобальными функциями.

Отсутствие проверок на NULL при работе с указателями.

Наконец, разберём последний тип ошибок, отсутствие проверок на NULL. Вообще говоря, это распространенная ошибка любого языка программирования, допускающего ссылочный тип и выражающего отсутствие значения такого типа (т. е. ссылки) в виде литерала null. Все вышеприведённые куски кода, в которых использовались указатели подвержены данной ошибке. Вообще говоря, у компьютера не бесконечная память. Попытка выделить память под новую переменную динамически с помощью функций, таких как malloc, calloc, realloc может обернуться неудачей. В этом случае, указанные функции вернут нулевой адрес, который является недопустимым для обращения и использования в системе. Нулевой адрес представлен литералом NULL, который может быть оформлен в следующем виде:

#define NULL (void*)0;

Причины, по которым не удалось выделить память могут быть разнообразны. Самая простая - это нехватка памяти. Что касается нехватки памяти, то вы её можете исчерпать как динамически (т.е. в куче), так и статически (переполнение стека, либо заполнение всего адресного пространства одиночного кадра стека). Что касается стека, то на некоторых платформах можно расширить кадр стека (через функцию alloca, которая вызывается аналогично malloc), но лишь на некоторых, а не на всех.

P.S. В данном посте были рассмотрены лишь 5 типа ошибок. Существуют и другие ошибочные ситуации, которые могут возникать чаще, чем вышеописанные.

Кроме того, в данных исходниках использовались зависимые от платформы (системы и архитектуры ЭВМ) типы данных. Используя их, вы создаёте непереносимый код. Конечно, если вы пишете код, который будет работать строго на одной машине с одним определённым типом процессора и определённой операционной системой, то, зная, тонкости данной платформы и её окружения, вы можете НЕ использовать другие типы данных, которые независимы от платформ, или зависят от них в самой меньшей степени.