5 ошибок, которые легко и просто допустить в языке С

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

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

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

В данной статье предполагается, что машина, на которой компилируется исходный код и запускается программа на языке Си, поддерживает тип данных 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> или <windows.h> или в других файлах в зависимости от целевой платформы и операционной системы. Для приложений, работающих с операционной системой Windows следует подключать заголовок <windows.h>, если же вы работаете с Linux, то надо подключать <unistd.h>. Прототипы (заголовки) функции отличаются. Ниже приведён прототип функции из заголовочного файла <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 типа ошибок. Существуют и другие ошибочные ситуации, которые могут возникать чаще, чем вышеописанные.

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

Комментарии 26

    +8

    Ошибок, которые легко допустить в C, гораздо больше четырёх :-)

      +3
      Мы вполне могли хранить в качестве первых четырех символов строки байты числа, представляющего длину сообщения, которое бы следовало за ним.

      То есть вы вставили в строку кусок сырых данных и удивляетесь, что строковые функции с ним некорректно работают? Вообще-то, это делается либо через структуру, либо через ручное управление памятью:
      struct tx_buffer{
        uint32_t len;
        char data[DATA_SIZE];
      }buf;
      strcpy(buf.data, "Some string\n");
      buf.len = sizeof(buf.len) + strlen(buf.data);

      char buf[DATA_SIZE + 4];
      strcpy(&buf[4], "Some string\n");
      *((uint32_t*)buf) = strlen(&buf[4]) + 4;

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

      Тут да, новички ошибаются часто. Поэтому если строка не должна быть константной, ее объявляют как массив
      char mystr[] = "This is my string\n";

      А если константной, то пишут const.
      Чтобы вызвать ошибку, достаточно перед функции free добавить следующую строчку:
      s1 = s1 + 1;
      Как можно до этого додуматься мне неизвестно.
      Использование локальных переменных функции за её пределами после завершения работы функции
      Вот тут снова соглашусь, новички часто об этом не подозревают.
      — Итого 2 ошибки, которые допускают часто (пока опыта мало, конечно) и 2, для которых надо сильно постараться.
        0
        Использование локальных переменных функции за её пределами после завершения работы функции

        А как это можно сделать?
        Это получается функция должна вернуть указатель на свои локальные данные! Ха, до этого надо додуматься.
          0
          Не для всех очевидно что char *var1 = var2 это копирование указателя на строку, но не самой строки. С другими локальными переменными тоже самое, хотя и реже встречается. Но в отличие примеров ходьбы по массиву или запихивания сырых данный в строку, это все же бывает.
          По крайней мере у новичков, которые еще не разобрались с синтаксисом.
            0
            А реально. Вот WinAPI. Думаешь что функция сама копирует строку себе в память, а хрен там. Её надо объявить в статичной памяти или в куче, сам лажал намедни.
          0
          Я добавил пояснение по поводу ошибки с функции free. Конечно, после успешного выделения памяти, можно сохранить в другой переменной адрес начала исходного указателя. И не менять исходный указатель, а использовать новую переменную. Но, скорее всего, можно легко перепутать имена таких переменных, и вот тогда можно напороться на ошибку. Но это будет уже частным случаем неправильного именования переменных и порождения плохого кода.
          +6

          Что я только что прочитал >_<

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

            <.sarcsm>
            А что если malloc вернет NULL
            А process() будет выглядеть так:


            void process(char *data) 
            {
              memset(data, 0xff, 256);
            }

            Это что, еще 2 ошибки, которые легко совершить получается? (читать с удивленно саркастичной интонацией)
            Пойду напишу статью "Стотыщ и одна ошибка которую легко совершить не думая"

              0
              Это моя первая статья. У кого-то получается лучше, у кого-то хуже. Соглашусь, что можно детальнее разобраться и очень хорошо подумать, чтобы написать статью. Но, если ты ничего не напишешь, то и не получишь отклика или фидбэка. А так я получил с вами обратную связь, и теперь могу подумать над ошибками. Спасибо за комментарий. Я добавил пояснение насчёт process.
              0
              Ну статья для новичков конечно, но она про программирование, про не сразу видные подводные камни Си. Но это же хабр, тут не только матерые профи обитают, зачем так резко минусить?
              Лучше своих ошибок напишите в комментариях. И давайте не будем про «кто в 21 веке пишет на С», поверьте пишут, там где ресурсов минимум или ассемблер или С — такой выбор.
                +1
                И чему может научить новичка автор, который в первом же примере в сообщение пихает лишний байт '\0'? Длина строки-то уже определена!
                ГЫ. дальше не читал!
                  0

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

                  +5

                  Хаб "Системное программирование", ага.
                  Как страшно жить… ©

                    0
                    И часто ли используется в таком явном виде нулевой байт в середине строки? ;-)
                    0
                    По второму вопросу. Надо передать строку, в которой указано её длина, но так, чтобы не было некорректных символов, например нуля.
                    Я бы так сделал.
                    Записываем её.
                    sprintf(str_new, "%08X%s", strlen(str), str);
                    8 первых байт, это длина строки. В шестнадцатеричном виде.
                    Читаем её.
                    len = strtol(str_new, &str, 16);
                    В результате len длина строки и str указатель на саму строку.
                      0
                      Хотя нет. Лучше для чтении длины строки, скопировать в буфер 8+1 байт, и затем преобразовать. А то, если в строке числа есть, могут быть проблемы!
                        0
                        8 первых байт, это длина строки. В шестнадцатеричном виде.
                        4 миллиарда символов в строке? Я сейчас посмотрел одну из длинных книг, там всего около миллиона символов, и то в одной строке это хранить никто не будет. Так что хватит и 16-битной длины (4 цифры). Ну а преобразовать в число несложно:
                        len = 0;
                        if(str[0] < 'A')len += (str[0]+0x0A-'A')<<12; else len += (str[0] - '0')<<12;
                        if(str[1] < 'A')len += (str[1]+0x0A-'A')<< 8; else len += (str[1] - '0')<< 8;
                        if(str[2] < 'A')len += (str[2]+0x0A-'A')<< 4; else len += (str[2] - '0')<< 4;
                        if(str[3] < 'A')len += (str[3]+0x0A-'A')<< 0; else len += (str[3] - '0')<< 0;

                        Но все равно так делать не стоит. Если уж хочется строк в стиле С++, так и используйте std:string или хотя бы в структуру заворачивайте.
                          0
                          А это уже зависит от требований к софту. Например, согласно документации СУБД PostgreSQL, максимальная размер значения текстового типа — 1 GB. И оверхед на хранение значения больше 126 байт — 4 байта (там всё-таки длина не в hex хранится).
                        0
                        Интересно написано, хоть и достаточно базовые вещи. Программирую на PHP, но первые три знаю :)
                          0
                          Конечно, никто так явно не вставляет нулевой символ посередине строки. Но что если мы решили разработать свой протокол со своим форматом сообщений для обмена данными между удалёнными хостами?
                          Если человек решил разработать свой протокол на Си, то он наверняка будет знать о нулевом символе и о том, как он влияет на поведение некоторых функций (строковых в основном)

                          Т.к. тип char занимает в памяти ровно один байт, то логично, что для хранения длины сообщения, представленного типом long int мы бы зарезервировали для него первые четыре символа в массиве символов char[], или четыре ячейки блока данных, на которые указывает указатель char * ptr.
                          Не факт, тип long в разных системах занимает разное кол-во байт

                          Вообщем не статья а сюр какой-то.
                            0
                            Конечно, размер типа данных зависит от архитектуры машины. Я брал размер типа из стандарта. Уже поправил, указав ссылку на стандарт в самом начале. И, конечно, как вы сказали, если человек разрабатывает свой протокол на Си, он об этих вещах знает. Но человек может и не знать, если вообще никогда не писал свой протокол, а только впервые столкнулся с задачей упаковки и пересылки данных.
                              0
                              Уже поправил. В режиме совместимости с 32-битным ПО, тип long может занимать 4 байта (даже если сама операционная система и процессор 64-битные, но работают в данном режиме). Я тестил на Windows 8.1 и на Ubuntu 14.04. Процессор и системы у меня 64-битные. На Windows было 4 байта. А вот на Ubuntu 14.04 размер типа данных был 8 байт.
                              0
                              >Функция sleep определена в заголовочном файле <unistd.h>
                              Расскажите это девелоперам на visual studio.
                                0
                                В зависимости от операционной системы на которой программируешь софт, она может лежать в разных заголовочных файлах и иметь разные прототипы.

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                              Самое читаемое