4 ошибки, которые легко и просто допустить в языке С
Приветствую вас, дорогие читатели. В этом посте вы узнаете о том, как избежать самых распространённых ошибок, программируя на языке С.
Неправильное использование символа конца строки (нулевого символа ”)
Рассмотрим следующий код:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char **argv){
char *str = "Hello world!n";
int l = strlen(str);
printf("Str: %snLength: %dn", str, l);
}
В данном коде мы получим следующие результаты
Str: Hello
Length: 6
Т.к. маркер конца строки помещён между подстроками “Hello ” и “world!n” , а большинство библиотечных функций используют для проверки достижения конца строки равенство просматриваемого символа с нулевым символом, т. е. :
while((cur_symbol = *++str) != '') process_symbol(cur_symbol);
то после прочтения пробела и достижения символа ''
функция 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 '' 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: %snLength: %dn", c2, l2);
free(msg);
free(c2);
}
В данной программе происходит упаковка и распаковка данных сообщения. В начале мы выделяем блок памяти в размере 20 байт для хранения сообщения. Его первые 4 байта будут хранить длину сообщения, равную 16 байтам. Для этого создали переменную l и записали в неё длину сообщения. Затем с помощью функции memcpy скопировали её полностью в блок msg. Теперь 4 байта msg хранят длину сообщения. Затем создали переменную c1, которая хранит фактическое сообщение. Количество символов в строковом литерале 16, поскольку компилятор неявно добавил один нулевой символ ''
к строке. После этого, вызываем 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 stringn";
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);
Чтобы вызвать ошибку, достаточно перед функции free добавить следующую строчку:
s1 = s1 + 1;
Данная инструкция сохранит новый адрес в переменную s1, который является лишь смещением относительно адреса, хранимого в s1 на 1 байт. Это приведёт к ошибке при вызове free, поскольку теперь s1 указывает не на начало блока ячеек данных, а на вторую ячейку блока.
Совет: всегда проверяйте, что указатели, переданные функции free, указывают на НАЧАЛО БЛОКА.
Использование локальных переменных функции за её пределами после завершения работы функции
Предположим, что мы используем локальные переменные, для которых память была выделена в кадре стека функции, следующим образом:
void process_person(struct Person *p){
char name[] = "El Barto";
p->name = name;
printf("Person name: %s was initiatedn", 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.
Конечно, при возникновении ошибок, возможны ситуации смена управляющего потока кода, т.е. переход из тела одной функции в тело другой. В данном случае, при подобных ситуациях, можно использовать глобальные переменные с глобальными функциями.
P.S. В данном посте были рассмотрены лишь 4 типа ошибок. Существуют и другие ошибочные ситуации, которые могут возникать чаще, чем вышеописанные.