Использование строки вместо строки C, еще одна попытка затронуть загруженную проблему

За эти годы я много читал о том, почему мы не должны использовать пресловутый класс String и о том, что фрагментация кучи-плохая практика, а не профессиональная, и мы никогда не должны использовать ее в наших программах, иначе мы никогда не войдем в зал славы C и не будем называться программистами.

Ладно, я понимаю, строка C-это pro, Строка-это люди, играющие с Arduino, но, прочитав все это, я все еще не понимаю:

  1. В чем заключается риск? Если я использую строку, и моя куча фрагментируется, и все еще остается достаточно памяти, чтобы программа могла работать в течение нескольких месяцев без сбоев, почему бы мне не использовать строку, если это значительно облегчает мою работу? Просто потому, что это плохая привычка, - очень неубедительный ответ.

  2. Можно ли количественно оценить фрагментацию и понять, что безопасно и когда это становится рискованным? Существуют ли какие-либо инструменты для измерения / оповещения / помощи нам узнать, насколько мы близки к тому, чтобы упасть с этого обрыва?

  3. И действительно, в чем заключается потенциальный вред, с практической точки зрения, а не теоретически, каковы пределы, знает ли кто-нибудь, когда произойдет сбой программы?

Счастливого кодирования!

, 👍12

Обсуждение

Я полностью согласен. Я использую строку все время и всеми способами, которые, как я знаю, мне не следует, и у моих гаджетов никогда не возникало никаких проблем. Так что на практике я не вижу вреда в использовании строк и буду продолжать их использовать., @tavis

если ваше устройство выходит из строя через несколько месяцев, разве это не проблема?, @user253751

Интересно посмотреть на эту дискуссию, потому что, если вы поговорите с сотрудниками infosec, они скажут вам, что обработка строк C-это одно из величайших бедствий профессии., @pjc50

Является ли строка активно вредной или нет, зависит от контекста. Проблема в том, что *неосведомленное* (в техническом смысле) использование строки может легко привести к труднодиагностируемым проблемам: те, кто с наименьшей вероятностью поймет, как диагностировать проблемы, с наибольшей вероятностью столкнутся с проблемами., @Dave Newton

@pjc50 Действительно, но проблемы, с которыми сталкиваются разработчики с несколькими килограммами памяти, отличаются от проблем, с которыми сталкивается большинство программистов., @Dave Newton

Вы можете создать столько же беспорядка в куче, выделяя свою собственную строковую память с помощью malloc() и free (), сколько и с помощью класса string. Особенно учитывая, что код самого класса string, вероятно, был написан кем-то, кто знает о разработке программного обеспечения больше, чем вы., @alephzero

Если это сработает, отлично, используйте его! Если это окажется проблемой, отключитесь от нее. Организуйте свой код таким образом, чтобы это было легко выполнимо. Не оптимизируйте преждевременно. Тем не менее, на ограниченных платформах (встроенных) использование строк C с самого начала может быть хорошей идеей., @Steve


6 ответов


25

Дело не в том, что строка сама по себе является злом, скорее, ею очень легко злоупотреблять и вызывать фрагментацию кучи. При разумном использовании это прекрасный и полезный инструмент. Используется "плохими" способами, и вы открываете дверь для случайных сбоев.

Под "плохими" способами я подразумеваю чрезмерное (и ненужное) дублирование данных и перераспределение кучи. Самыми большими подводными камнями являются:

  • Использование .concat() или "добавление" строк вместе с +
  • Передача строковых объектов функциям по значению, а не по ссылке

В основном все, где создается новая строка, а старая выбрасывается, оставляет дыры в куче.

Сами по себе эти отверстия (фрагментация) не являются проблемой - проблема заключается в очень небольшом объеме оперативной памяти, который есть у типичного недорогого Arduino (т. Е. на базе ATmega328P). Когда вы тратите впустую пространство в своей куче в виде дыр, все более и более вероятно, что в конечном итоге верхняя часть вашей кучи встретится с нижней частью вашего стека, и именно тогда возникают проблемы.

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

Также на микроконтроллерах с гораздо большим объемом памяти, чем у небольшого Arduino UNO (например, ESP32 и т.д.), Фрагментация кучи перестает быть проблемой. К тому времени, когда куча становится достаточно большой, чтобы быть заметной, любые отверстия становятся достаточно большими, чтобы их снова начали заполнять.

,

Комментарии не предназначены для расширенного обсуждения; этот разговор был [перенесен на chat](https://chat.stackexchange.com/rooms/127565/discussion-on-answer-by-majenko-using-string-instead-of-c-string-yet-another-at)., @Majenko

(Посторонний здесь, проверяю это по горячим сетевым вопросам) "Возврат строковых объектов из функций по значению, а не по ссылке" Использует ли среда Arduino C++ или C? В C++ теперь гарантируется копирование при возврате, поэтому я не думаю, что это должно быть проблемой., @Alexander


8

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

Представьте, что у вас авария, которая происходит каждые несколько дней, и вы не можете понять, почему. Это никогда не происходит во время тестирования и, похоже, не вызвано чем-то конкретным.

Затем выясняется, что ваш код печатает числа в строку, обычно 4 цифры, но иногда 2 или 3. И оказывается, что очень специфические последовательности длин выделяемых и освобождаемых строк вызывают сбой. Но нет хорошего способа проанализировать или предсказать проблему, потому что она зависит абсолютно от всего, что произошло с момента загрузки. Если бы 8 часов назад было 3 - вместо 4-значного числа, это полностью изменило бы то, что должно было бы произойти сейчас, чтобы вызвать сбой.

Отвечая на этот вопрос, я попытался придумать какой-нибудь код, который оставил бы память очень фрагментированной. Это было очень трудно. Фрагментация памяти очень чувствительна к тому, что происходит, в каком порядке. В основном это очень хаотичная система.

Это напоминает проблемы, связанные с многопоточностью, когда запуск простой программы несколько раз может легко дать разные результаты. Подобные проблемы очень, очень трудно выявить и диагностировать. Многие люди чувствовали боль, и именно поэтому на String такое клеймо.

Чтобы ответить на ваши вопросы:

  1. почему бы мне не использовать строку, если это значительно облегчает мою работу?

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

Основная цель избежания строки-предотвратить серьезную фрагментацию кучи. Однако есть и другие способы сделать это:

  • Не храните много объектов, распределенных в куче, на долгосрочной основе. Если у вас есть только 3 глобальные строковые переменные, то, вероятно, можно выделить 100 небольших строк, если вы удалите их, прежде чем делать что-либо еще
  • Если вам нужно хранить много объектов, выделенных в куче, в долгосрочной перспективе, старайтесь не выделять их в течение периода большого количества выделений кучи, освобождений и изменений размеров (например, во время цикла, когда строится строка). Вы хотите избежать того, чтобы этим долгосрочным объектам присваивались "случайные" позиции в памяти.

Если вы хотите использовать String в приложении, где важна надежная долгосрочная работа, вам действительно нужно тщательно разобраться в моделях использования памяти и в том, почему они вряд ли вызовут проблему.

Я должен добавить, что каждая строка добавляет несколько байтов памяти, как для дескриптора строки, так и для невидимой бухгалтерии, которую выполняет реализация malloc (). Они также медленнее доступны, чем char []s.

  1. Можно ли количественно оценить фрагментацию и понять, что безопасно и когда это становится рискованным?

Это сложно из - за того, насколько чувствительны вещи к крошечным различиям в истории распределения. Однако вы можете провести несколько простых анализов.

Если у вас есть n объектов, выделенных в куче, у вас есть n+1 фрагментов (хотя некоторые фрагменты могут иметь нулевой размер). Средний размер фрагмента будет ≤ количество свободного места, деленное на n+1. Например, если у вас есть 19 выделенных объектов и 2000 байт оставшегося пространства, ваш средний размер фрагмента составит 100 байт. Математически это означает, что по крайней мере один фрагмент ≥ этого размера, и поэтому вы гарантированно сможете выделить что-либо меньшее, чем 100 байт. Однако имейте в виду, что каждый выделенный объект потребляет несколько байтов невидимых бухгалтерских данных, используемых реализацией malloc () во время выполнения, и в системе могут быть другие скрытые объекты, например, некоторые объекты, выделенные библиотеками. Также помните, что в Arduino SRAM совместно используется стеком и кучей.

Теперь предположим, что у вас есть 8 КБ места в куче, и вы выделяете много вещей, а затем освобождаете много вещей, оставляя ровно 3 небольших выделенных объекта. Это разделит вашу кучу на 4 области. Средний размер будет чуть меньше 2 КБ, и поэтому, как бы вам ни повезло, один или несколько регионов будут по крайней мере такого размера. Таким образом, вы должны иметь возможность выделить один большой объект размером около 2 КБ, или выделить сотни небольших объектов, или выделить один объект и продолжать изменять его размер в любом месте до 2 КБ. Если вы затем освободите все, кроме 3 небольших объектов, вы вернетесь в ту же ситуацию: вам гарантирована область размером не менее 2 КБ.

Есть два заслуживающих внимания вывода:

  • Если максимальное количество элементов, которые когда-либо будут выделены за один раз, равно n, а их максимальный размер равен s, а размер вашей кучи составляет не менее (2n+1) × (s+4), у вас никогда не возникнет проблем с фрагментацией. (3 - это фактор, зависящий от архитектуры, для покрытия проблем с бухгалтерией и заполнением malloc.)
  • Если в вашем коде есть "контрольные точки", где выделено только несколько объектов, это облегчает рассуждения о том, что может произойти между этими контрольными точками.

Существуют ли какие-либо инструменты для измерения / оповещения / помощи нам узнать, насколько мы близки к тому, чтобы упасть с этого обрыва?

Не для Arduino, о котором я знаю. Проблема в том, что программа, подверженная серьезной фрагментации, может отображать признаки только в течение коротких периодов времени каждые несколько дней или что-то в этом роде.

Самый простой метод, который вы можете использовать, - это использовать, скажем, 30% вашей памяти с помощью malloc() в начале вашей программы, чтобы увеличить нагрузку на нее и посмотреть, работает ли она по-прежнему. Это не гарантирует, что проблемы будут устранены, но это значительно повышает шансы на то, что вы что-то увидите.

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

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

struct __freelist {
  size_t sz;
  struct __freelist *nx;
};
extern struct __freelist *__flp;

а затем вы можете просмотреть содержимое списка фрилистов, используя __flp.

,

2

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

Можно ли количественно оценить фрагментацию и понять, что безопасно и когда это становится рискованным?

Быстрый веб-поиск “свободной памяти Arduino” позволяет найти несколько фрагментов кода , которые можно использовать для измерения во время выполнения объема свободной памяти:

  • Доступная памятьна игровой площадке Arduino работает только на AVR. Он измеряет общий объем доступной оперативной памяти, включая отверстия в куче.

  • Измерение использования памяти, учебник по Adafruit, показывает некоторые портативные (AVR и ARM) код, который измеряет доступную оперативную память между кучей и стеком. Это не включает отверстия в куче.

Если вы сравните версии AVR этих двух фрагментов кода, вы увидите , что разница заключается в функции freeListSize() версии Playground. Эта функция возвращает общую память, находящуюся в отверстиях кучи, что может быть показателем фрагментации кучи.

Функция зависит от AVR, но тогда AVR-это то, где фрагментация кучи вызывает наибольшее беспокойство.

,

3

Я не программист Arduino, но я программист на языке Си. И проблема строковых типов в C-или, скорее, отсутствие первоклассного строкового типа в C-старая, но важная.

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

int a, b, c, d, f();
a = b + c;
d = f();

И вы можете говорить такие вещи, как

float q, r, s, t();
q = r / s + t();

Но вы не можете сказать

string x, y, z();
x = y + z();

C не имеет первоклассного строкового типа. В чистом C, если вы хотите манипулировать строками, вы должны использовать некоторую комбинацию массивов char [] и указателей char*. Вы должны помнить, что нужно выделить достаточно памяти для хранения строк, с которыми вы работаете сегодня. Во многих случаях вы не можете назначить строки; возможно, вам придется вызвать strcpy или что-то подобное. В большинстве случаев вы не можете напрямую сравнивать строки; обычно вам приходится вызывать strcmp или что-то подобное. Все это может быть настоящей неприятностью, и если вы неправильно разберетесь в деталях, вы получите странные ошибки и сбои.

Поэтому, особенно если вы начинающий или случайный программист, было бы неплохо, если бы C имел истинный, первоклассный строковый тип. (И, конечно, в C++ есть такой тип.)

Но есть очень веская причина, по которой у простого C нет такого типа. Манипулировать строками просто сложнее, чем манипулировать inf или float. Причина, по которой это сложнее, заключается именно в том, что они имеют переменную длину и потенциально довольно большие. В соответствии с философией низкого уровня C, лучший человек, который может найти хорошие компромиссы, когда дело доходит до принятия решения о том, как обрабатывать строки конкретной программы,-это автор этой программы, а не компилятор или автор библиотеки.

Вы можете написать прилично хороший, прилично эффективный, высокоуровневый, первоклассный строковый тип. Ноэто никогда не будет так эффективно для каждой проблемы, как ручной код char [] и код char**. Также, если есть куча правил о том, как использовать в первый класс строкового типа эффективно, избегая фрагментации и тому подобное, эти правила, вероятно, будет в конечном итоге почти так же сложно, как правила, используя тип char [] и char*, так правильно, но весь смысл введения первого класса типа String, так что программистам не придется беспокоиться об этих низкоуровневых деталей все время! Это действительно довольно симпатичный маринад.

Поэтому, когда кто-то говорит: "Вы не должны использовать этот строковый тип высокого уровня, это расточительно и неэффективно", возникает вопрос: является ли это неприемлемо расточительным или неэффективным?

Когда-то давно люди говорили, что мы не должны использовать скомпилированные языки, так как они расточительны и неэффективны. Эти люди думали, что мы все должны писать все на языке ассемблера. (И все еще есть несколько человек, которые говорят это.) Однако в большинстве случаев мы обнаружили, что скомпилированный код достаточно эффективен, а другие его преимущества с лихвой компенсируют любые незначительные сохраняющиеся недостатки.

И вопрос для строк C по сравнению со строками почти полностью эквивалентен. Если ваша программа значительно использует строки, затрачивая на них большую часть своего времени или большую часть доступной памяти, неэффективность строк вполне может быть неприемлемой. Однако, если строк относительно мало, удобство для программиста использования строкового типа высокого уровня (наряду с отсутствием ошибок) может того стоить, а затраты могут быть несущественными.

Но нет единого правильного ответа на вопрос "Использовать или не использовать строки", потому что это субъективный вопрос, и разные люди при разных обстоятельствах законно придут к разным выводам.

,

C не нужно было бы много добавлять, чтобы программисты могли реализовывать вещи, которые намного ближе к использованию в качестве первоклассных типов. Если бы он включал статические составные литералы const и предлагал строгую рекомендацию опустить такие литералы, если их адреса берутся и напрямую используются в качестве второго операнда оператора?:, первый операнд которого всегда равен нулю, или третьего операнда оператора, первый операнд которого никогда не равен нулю, лучшие строковые типы могли бы быть проще в использовании, чем строки C., @supercat


5

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

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

Например, для считывания последовательного ввода это просто:

String inputBuffer;

void loop()
  {
  while (Serial.available () > 0)
    inputBuffer += Serial.read ();

  if (inputBuffer == "something")
    doSomething ();
  }  // end of loop

Это, кстати, тот самый код, который может привести к фрагментации кучи и сбою. :)

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

Затем вы начинаете тестирование. Некоторое время все идет хорошо, но затем, после того как ваш код запускается в течение часа, он таинственным образом выходит из строя или, возможно, зависает. Ваше устройство перестает делать то, что должно, и вы начинаете искать ошибки. Очевидно, что в этом нет ничего плохого, поэтому вы тратите довольно много времени, пытаясь решить проблему. Фрагментация кучи не выскакивает и не бьет вас по лицу, как, скажем, забывание добавить одну переменную к другой.

Итак, вы просматриваете код, может быть, размещаете вопрос на Stack Exchange, может быть, спрашиваете друзей, может быть, спрашиваете своего учителя (где это уместно). Достаточно скоро, время идет. Вы тратите час на отладку, два часа, четыре часа, и вскоре вы потратили на это восемь часов. Теперь вы потратили впустую все преимущества, которые получили от использования класса "easy" String. Мало того, вы сильно отстаете по сравнению с выделением буфера фиксированного размера и управлением вводом данных в него самостоятельно.

Рано или поздно вы обнаружите, что причиной является фрагментация кучи, и вам придется ее переписать. Другими словами, в конечном итоге вам придется прекратить использовать класс String. Так что весь этот эпизод был пустой тратой времени.

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

почему бы мне не использовать строку, если это значительно облегчает мою работу?

Конечно, вы должны это сделать, если это облегчает вашу работу и если она не рухнет.

кто-нибудь знает, когда произойдет сбой программы?

Нет, скорее всего, нет. Если вам "повезет", он выйдет из строя вскоре после того, как вы его запустите, и вы, по крайней мере, будете знать, что у вас проблема. Если вам не повезет, ваша кормушка перестанет кормить ваших рыб через две недели после вашего отпуска, и они умрут.

,

Как вы упомянули, здесь даны удивительные ответы и ваши дополнения к этому, так что спасибо всем за то, что нашли время., @Nino


3

Суть в следующем: не используйте строку (или что - либо еще, что использует кучу) внутри прерываний-даже с объектами, созданными вне прерывания. базовые функции распределения памяти не могут справиться с этим, и у вас есть хороший шанс, что что-то взорвется.

Что касается остального, то многое из этого зависит от реализации

Ответ Ника с входным буфером " да " приведет к сбою, он никогда его не очистит! (j/k) Но даже если бы он сделал это после сопоставления с чем-то, т. е.

      if (inputBuffer == "something") {
            inputBuffer="";
            doSomething ();
      }

после того, как мы достигнем этого, больше не будет (возможного) фрагментирования этого кода, так как реализация строки в копии (или конкатенации) не перераспределяет буфер памяти, если он не слишком мал. Поэтому, когда мы пройдем весь этот цикл после первого раза, выделения кучи не произойдет.

Мы также могли бы удалить любые перераспределения во время первоначального построения строки "что-то" в буфере ввода, выделив достаточно места прямо в начале кода с помощью

    inputBuffer.reserve(9);

где 9-длина строки "что-то" (резерв добавит дополнительный байт в буфер для терминатора строки)

Вы также можете использовать это для резервирования места для выполнения нескольких конкатенаций . Все, что превышает 1 конкат/+=, сэкономит время из-за не многократного перераспределения пространства, а также остановит фрагментацию.

    int newSize=a.length()+b.length()+c.length();
    a.reserve(newSize); // only memory realloc happens here
    a.concat(b);
    a.concat(c);

Аналогично, вы могли бы создать гораздо больший буфер для краткой обработки чего-то, в размере чего вы не уверены (скажем, зарезервировать 512 байт), но будьте осторожны! из-за пункта выше (.reserve() не перераспределяет буфер, если запрошенный размер не больше) вы застряли в этом буфере до тех пор, пока строковый объект не будет уничтожен, выйдя за пределы области действия или будучи явно удаленным. Глобальные объекты застряли в нем. Поэтому, если вам нужен глобальный объект, который может стать больше или меньше, сделайте его указателем на строку, а затем используйте new/delete по мере необходимости.

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

Если вы создаете строковые объекты в куче (с помощью new), хотя это в любом случае усугубляет проблемы с фрагментацией, которые могут возникнуть в вашем коде, поскольку в итоге вы получаете 2 элемента кучи, объект-оболочку строки и сами данные. Если вы просто используете локальную или глобальную переменную, оболочки не будет в куче, ни в глобальной памяти, ни в стеке.

,