К вопросу о константах

в 12:56, , рубрики: программирование микроконтроллеров

Есть ли в мире что либо более постоянное, чем временные переменные.

Просматривая тематический форум, увидел традиционное «Не работает скетч, подскажите, в чем дело» — таких постов чуть меньше, чем все, но в заголовке фигурировала работа с SD карточкой, поэтому решил глянуть. Больше всего порадовала фраза, что «скорее всего, дело в карточке, но решил попросить посмотреть код, может, что интересного увидите» — не ручаюсь за точность цитаты, но смысл передал верно. Действительно, интересного там наблюдалось много, мой взгляд зацепился за выражение
unsigned long Interval = 2000;…
while (micros() < StartInterval + Interval) {};
причем дальше эта переменная использовалась, как константа в запуске задержки. Оставим за скобками способ обращения с временем, сейчас речь не об этом.

Сначала я, действуя на автомате, написал, что следует применять #define, «да призадумалась, а сыр во рту держала». Может быть, я чего-то не знаю, и применение констант в таком формате может быть выгодно при определенных условиях? Вдумчивое чтение мануалов на разные микроконтроллеры (МК) привело к интересным находкам, которыми (вместе с ответом на мой вопрос) и собираюсь поделиться под катом.

Для начала зафиксируем ряд положений относительно архитектуры современных МК, на которых будем опираться в дальнейших рассуждениях.
Первое — в МК существуют, по меньшей мере, два вида памяти — память программ и память данных и обращение к ним осуществляется разными способами. Как правило, мы имеем две физически разделенные шины, даже если адресное пространство совмещено и линейно, часто мы имеем и различные команды обращения к разным видам памяти и адресное пространство может перекрываться.
Второе — эти два вида памяти могут иметь разную ширину слова (этим термином я называю количество битов в слове) и она совершенно не обязана быть равной ширине регистров МК (которая в свою очередь может меняться путем объединения регистров в группы), что придает особую пикантность дальнейшему обсуждению предмета.

Проиллюстрируем вышесказанное конкретными примерами архитектур, на которых мы далее будем опираться.
Номер ноль — вероятнее всего, чемпион по красоте архитектуры, но не по эффективности, к сожалению (моя первая любовь в технике), PDP 11 от DEC. К сожалению, она уже давно не с нами, но память о ней всегда сохранится в наших сердцах (не только память, многие современные архитектуры явно вдохновлялись ее идеями, например MSP430 от TI). Ширина регистров (Р)- 16 разрядов, слова программ (П) — 16 бит, слова данных (Д) — 16 бит (возможен доступ к старшему и младшему байту слова отдельно), шины адреса (А)- 16 бит (20 в расширенном режиме), адресное пространство линейное и совмещенное, все команды однородны.

Первая архитектура — 8051 от Intel, несомненная классика (причем до сих пор не умершая), так что это отличный кандидат. Ширина регистров — 8 разрядов, слова программ — 8 разрядов, слова данных — 8 разрядов, шины адреса — 16 разрядов, адресное пространство перекрывается и имеет 3 секции (программ, данных, расширенных данных), есть три различные команды для чтения из разных секций, регистры могут образовывать регистровые пары.

Вторая архитектура — AVR (Tiny, Classic,Mega) от Atmel — широко распространена (Ардуино), весьма неплоха, так что не вижу причин не рассмотреть ее, тем более что для нее программа как раз и была написана. Ширина регистров — 8 разрядов, слова программ — 16, слова данных — 8, шины адреса — 16, адресное пространство перекрывается и имеет две секции (программ и данных), есть две различные команды для чтения из разных секций, регистры могут образовывать регистровые пары.

Третья архитектура — несомненный лидер в настоящее время, Cortex-M от фирмы ARM, так что не вижу возможности ее не рассмотреть. Не буду уточнять конкретную реализацию, тысячи их. Ширина регистров — 32 разряда, слова программы — 32 разряда (16 в режиме Thumb), слова данных — 32 разряда, шины адреса — 32 разряда, адресное пространство линейное и совмещенное, все команды однородны.

Несомненно, существует еще множество достойных рассмотрения архитектур, но одни из них представляют только исторический интерес (i8048, PDP8), другие не столь распространены (Sparc, MIPS, PDP11/78), третьи я знаю весьма поверхностно (PIC,Scenix), а четвертые мне откровенно не нравятся (HC08, х86). Тем не менее, интересные решения из этих МК также будут упоминаться, но основное внимание будет уделено четырем ранее перечисленным.

Итак, сформулируем задачу точно — нам необходимо поместить в регистр процессора некоторое наперед заданное число, причем его значение статически определено в момент компиляции (константу). Каким образом мы это можем сделать и какие преимущества и недостатки каждого из возможных методов — рассмотрим подробнее.
Первый и очевидный способ — прямая загрузка — константа является частью команды непосредственно, то есть собственно слово команды состоит из кода операции и самой константы в чистом виде. Данный способ лишен каких-либо недостатков, которые так или иначе свойственны другим методам, но… он должен быть применим практически, а это означает, что ширина слова программы должна быть больше ширины регистра (П>Р), иначе реализовать такие команды мы просто не сможем. Причем не просто больше, а значительно больше, чтобы можно было реализовать более, чем одну команду загрузки (или, по крайней мере, загрузки более чем в один регистр). Этим способом прекрасно пользуется МК типа 2, но для остальных рассматриваемых он просто невозможен, поскольку у них главное условие не выполняется.
Чтобы изменить соотношение ширины регистра и слова программ, и обеспечить условие Р<П, у нас есть два способа (и оба очевидны) — либо уменьшить первое значение, либо увеличить второе.

Для начала пойдем первым путем и предложим непрямую загрузку — когда в команде вместо константы «в чистом виде» шириной Р лежит, занимающая меньше места, информация о ее формировании Р'<Р — в той или иной форме такой метод применяют все архитектуры. Ведь команда очистки (CLR) является командой загрузки специального значения 0, причем сама константа в неявном виде присутствует в коде команды. В некоторых архитектурах также имеется команда установки всех битов регистра (SET), а команда увеличения на единицу позволяет провести операцию с константой 1 и есть практически повсюду. Далее возможны различные модификации метода, связанные с особенностями конкретного МК и уровнем фантазии его разработчиков.
Например, в STM8 (кстати, великолепный МК, особенно учитывая его цену, и мне непонятно, почему он не стал стандартом де-факто в свое время) введен специальный регистр констант и, в сочетании с разными способами адресации, это позволяет задать 5 наиболее часто встречающихся констант (-1,0,1,2,4).
Другой подход показала фирма ARM, которая кодирует в поле константы два двух-битовых поля XX и YY, а также кодирует способ их использования, что позволяет сформировать множество констант, начиная от простейших (0,1,2,-1,2) и заканчивая полосатиками (010101...). При этом надо понимать, что количество возможных констант не превосходит 2^(длина поля кодировки). Метод непрямой загрузки сохраняет все преимущества прямой загрузки, такие, как однородность потока исполнения, равномерность загрузки конвейера и также почти не имеет недостатков, за исключением ограниченности.

Продолжаем двигаться первым путем и уменьшим ширину регистра Р. На первый взгляд, абсурдная идея, ведь мы не можем этого сделать – оказывается, можем, но для этого нам надо представить регистр в виде набора сегментов меньшей ширины и оперировать поочередно с ними по отдельности.
Яркий пример такого подхода — архитектура MIPS, в которой есть команды загрузки младшей и старшей половины регистра. За счет чего мы можем выиграть, ведь для загрузки всего регистра нам потребуется выполнить целых две команды? А выигрыш достигается за счет того, что многие типовые константы можно получить, загрузив только младшую половину слова и расширив ее старший бит в старшую половину, то есть любое число от -2^(Р/2) до 2^(Р/2)-1 можно получить за одну команду. А если еще и добавить двух-битовое поле и закодировать в нем одну из 4 возможных операций для старшей части (очистить, установить, расширить знак, повторить младшую), то количество формируемых констант увеличится еще больше. Но тут главное — вовремя остановиться поскольку любое такое расширение сокращает количество остальных команд.

Теперь двинемся по второму пути и начнем увеличивать ширину памяти П. Поскольку мы не желаем существенно менять параметры архитектуры, то у нас остается единственный способ — сформировать новую общность из более, чем одной команды, и интерпретировать ее как команду расширенной длины П'>П. Назовем данный способ непосредственной загрузкой, как назывался аналогичный способ адресации в архитектуре 0. То есть команда остается стандартной длины и в ее теле нет никакой информации собственно о константе, но следующее за данной командой слово программ рассматривается как сама константа. Отметим, что для применимости данного способа необходимо условие П>=Р и это накладывает ограничения на архитектуру МК, но существенно менее слабые, чем для прямой загрузки. Кроме того, никто не мешает нам расширить данный подход и, при необходимости, использовать более, чем одно слово программ для представления константы.

Поскольку такой способ используется чуть менее, чем всегда, рассмотрим его подробнее. Конкретная реализация возможна как в виде обычной команды пересылки со специальным способом адресации (027 в моей любимой архитектуре), так и в виде специальных команд загрузки (LDI). Преимущество очевидно — мы можем сформировать абсолютно любую константу произвольной длины, и реализация в железе такого подхода весьма несложна. Недостатки не столь очевидны, но они есть — увеличивается время исполнения команды (по крайней мере на время выборки второго и последующих слов программы), нарушается регулярность команд (что затрудняет де-ассемблирование), возникает неоднородность загрузки конвейера исполнения (если он есть). И последний (не уверен, что он последний, но я больше придумать не могу), но не последний недостаток — избыточность по памяти, то есть всякий раз при появлении константы в коде под нее отводится место в памяти программ. Оно отводится и в случае с ранее описанными методами, но там мы все равно не могли использовать часть команды, применяемую для хранения константы, для других целей (честно говоря, утверждение спорное для VLIW архитектур, но мы такие не рассматриваем), так что все было не так плохо, но тут мы должны всякий раз выделить дополнительное слово памяти и этот недостаток начинает резко бросаться в глаза. Но никуда не деться, «за все в этом мире нужно платить».

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

Но у тех, кто знакомился с МК на примере архитектуры 0 (еще раз настоятельно рекомендую с ней ознакомиться, более прозрачной и чистой реализации, а равно и понятного описания, мне не встречалось), сразу же возникнет вопрос, а не может ли нам помочь косвенная адресация (код 037) в ее различных формах? Разумеется, правильный ответ «да», иначе бы я этот вопрос не задавал, но все не так просто. Прежде всего, немного по сути данного метода — в команде содержится информация не об операнде (в данном случае константе), а о ее расположении в адресном пространстве, откуда собственно константа и может быть извлечена. Разумеется, данный метод в применении к константам может быть приемлем только в том случае, если эта адресная информация имеет меньшую ширину (и существенно меньшую) нежели ширина регистра. То есть мы можем (в архитектуре 0) вместо собственно константы шириной 16 бит указать ее адрес той же самой ширины, но в том случае мы сохраняем все недостатки прямого способа, и даже несколько усугубив проблемы с временем исполнения команды, и при этом совсем ничего не имея взамен. Если мы рассмотрим упомянутые мной архитектуры, то ни в одной из их условие А<Р не выполняется, поэтому способ представляется слабо реализуемым. Но, как всегда, есть нюансы.

Во-первых, можно уменьшить ширину адреса выделением специальных регионов (и созданием специальных команд для работы с этими регионами), как сделано в уже опоминавшейся STM8, где адрес в общем случае имеет ширину 24 разряда, но есть специальный префикс работы с младшими 64Кбайтами (мда, младшие 64 кило в кристалле МК, как бы я смеялся над подобным предположением в годы своей юности, когда расширитель адреса присутствовал только в СМ4, где процессорный модуль занимал половину двухметровой стойки) и в этом случае ширина адреса составляет только 16 бит, а при работе с первыми 256 байтами адресного пространства и вообще 8 бит. Но этот способ представляет более теоретический интерес, поскольку так доступ к константам в рассматриваемых архитектурах не реализован.

Второй способ значительно более перспективен и в архитектуре 0 имеет название «индексная» либо «косвенная со смещением» (код 06х). Как всегда, немного подробностей — в данном способе адрес операнда (для нас константы) формируется путем сложения значения индексного регистра (иногда в роли последнего может выступать любой из РОН — архитектура 0, иногда только некоторые из них — 1,3, иногда специальные регистровые пары — 2) и значения смещения, которое и задается собственно в команде. Конечно, если нам потребуется предварительно задать значение индексного регистра, то мы ничего не можем выиграть в принципе, но есть два варианта, которые делают такой подход приемлемым. Первый из них — организация пула констант, установка указателя на него в некоторый регистр и дальнейшая адресация при помощи смещения, что нам даст выигрыш при значительном количестве констант, но ценой занятия регистра, что не всегда может быть приемлемо. А вот второй путь значительно интереснее — использование в качестве индексного регистра счетчика команд, поскольку мы (ну не мы а компилятор, но какая разница) точно знаем его значение в момент исполнения любой команды, поэтому применение к нему смещения даст совершенно однозначный результат, причем совершенно бесплатно, ведь мы не собирались (а часто и не могли) использовать счетчик команд, как регистр общего назначения. Конечно, у данного способа есть свои ограничения, поскольку мы (в целях экономии) используем не слишком длинное смещение, поэтому такой локальный пул констант будет доступен не из всего кода программы, но мы существенно сэкономим, по крайней мере в длине команды, а вот со временем исполнения все не столь однозначно.

Чтобы прояснить свое последнее утверждение, рассмотрим еще раз организацию памяти в МК, и вспомним, что имеется по меньшей мере два вида памяти — программ и данных, причем отличаются они не названием и не расположением в адресном пространстве, а физическими принципами функционирования (существует и ряд реализаций, например для MSP430, в которых оба вида памяти реализованы на одном универсальном носителе FRAM и принципиально не различимы, но особо успешными такие семейства пока назвать трудно, что напоминает об известной фразе «Кто такой универсал? Это человек, который умеет делать множество разнообразных дел одинаково плохо», поэтому мы не будем внимательно подобные реализации рассматривать). А раз у нас есть различные физические носители, то и обращение к ним осуществляется по-разному и они могут (и будут) иметь различные параметры, характеризующие, в том числе, и время доступа к ним.

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

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

Немного расширим тему обсуждения и поговорим о размещении в памяти не просто одиночной константы, а набора их, например, текстовой строки либо дескриптора устройства. Мы можем организовать такой набор в виде массива и разместить данный массив либо в памяти программ, либо в памяти данных. Последняя фраза подразумевает, что у нас есть возможность сделать свой осознанный выбор, а не полагаться на мнение компилятора. Одна из моих претензий к системе Ардуино — то, что в ней данный выбор сделать, конечно, можно, но уж больно неудобным способом, который заставляет вспомнить фразу «Есть три способа сделать что-либо — способ правильный, способ неправильный и способ военно-морского флота». Я бы предпочел, чтобы такой выбор делался языковыми средствами, на худой конец, над-языковой прагмой, но никоим образом не созданием специальной функции для чтения константы. Конечно, когда у нас линейное адресное пространство, то реализация обращения к разным областям памяти сводится к передаче информации компоновщику, а в случае архитектур 1 и 2 все намного сложнее, но это еще не повод делать плохо ( ну или, скажем мягче, недостаточно хорошо).

Покончив с процессом размещения, давайте подробнее рассмотрим плюсы и минусы обоих подходов. Константы в памяти программ:
1+. Они находятся на своем месте (в постоянной памяти) и не могут быть модифицированы никоим образом, а, если у Вас хорошая и развитая архитектура, то Вы можете отследить попытки их модифицировать (в случае программного сбоя) и получить исключение.
2+. Они занимают минимально возможное место в памяти данных (не занимают вовсе, меньше уже никак не сделать), а это часто дефицитный ресурс и занимают минимально возможное место в памяти данных (ровно столько, сколько действительно необходимо), этот ресурс обычно не столь дефицитен, но все равно память программ — она как Москва, не резиновая.
3+. Они готовы к работе сразу после включения устройства, что бывает немаловажно при выводе диагностических сообщений.
1-. Они, как правило, медленнее в работе сами по себе и часто обращение к ним организуется при помощи более медленных команд, нежели обращение к памяти данных, что процесс работы с ними не ускоряет.
2-. Они находятся в постоянной памяти и не могут быть модифицированы в принципе (обратите внимание, только что я это хвалил), вот такой я непостоянный.

Константы в памяти данных:
1-. Они находятся не на своем месте и могут быть модифицированы при сбое программы (надеюсь, вы не возлагаете особых надежд на ключевое слово const, если возлагаете, то должен вас огорчить — существует множество способов изменить значение переменных с таким атрибутом), и только если Вы имеете дело с очень хорошей архитектурой, то Вы получите исключение.
2-. Они занимают вдвое больше адресного пространства, чем необходимо (и даже еще больше), поскольку они не могут просто так появиться в памяти данных, а должны первоначально располагаться в памяти программ (вот оно вдвое) и переноситься из одной памяти в другую программой инициализации (а вот оно и больше).
3-. Они недоступны после включения.
1+. Они, как правило, быстрее в доступе и обращение к ним может сэкономить память программ за счет отказа от дублирования часто встречающихся констант.
2+. Они могут быть модифицированы при необходимости, что может потребовать аккуратного программирования, если у Вас очень хорошая архитектура (наконец то объясню, что я имел в виду под этим сочетанием слов — наличие механизма защиты сегментов адресного пространства и контроля доступа к ним).
3+. Они используются по умолчанию во многих архитектурах и системах программирования, и, в определенном смысле, естественны.

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

Ну а теперь вернемся к случаю, с которого начался данный пост, то есть одиночная, однократно встречающаяся и редко используемая константа шириной в целое (но с различными значениями) и рассмотрим приемлемость подхода с константой в памяти данных против памяти программ для разных архитектур. Сразу оговоримся, что считаем количество переменных значительным, чтобы избежать ситуации с размещением константы (или указателя на нее) в РОН, что кардинально меняет оценку применимости метода.
Настоятельно рекомендую проверить следующие мои высказывания путем просмотра кода, сгенерированного С компилятором, для чего можно обратится к ресурсу godbolt.org, установив режим оптимизации -О2 и выбирая архитектуры msp430, mips, avr, arm.
Архитектура 0. Размещение в памяти данных потребует больше места в памяти программ, больше места в памяти данных и будет исполняться однозначно медленнее — никаких оснований для подобного решения нет.
Архитектура 1. Можно переписать предыдущую фразу слово в слово.
Архитектура 2. Ситуация даже становится еще хуже для предлагаемого метода, поскольку для констант, умещающихся в байт, существует команда adiw, что улучшает быстродействие другого способа.
Архитектура 3. А вот тут есть шансы — если программа размещена в памяти данных (многие реализации этой архитектуры поддерживают такую возможность), то индексное обращение к памяти данных может оказаться быстрее, чем извлечение константы из памяти программ, так что быстродействие можно повысить, правда, за счет увеличения размера секции данных и программы. Но если константа может быть образована путем кодирования, то вариантов нет — размещение в памяти программ выигрывает безоговорочно.

Подведем итог — размещать одиночные константы в памяти данных не следует практически никогда (за исключением возможности использования регистров), в общем, мы об этом догадывались и раньше, но теперь можем утверждать это с полной уверенностью, и по дороге узнали немного интересных вещей. Тогда зачем же это сделано в рассматриваемом коде — единственное, придуманное мною объяснение — так получилось, ну или еще один вариант «ну работает же». Возможно, у автора есть религиозные обеты, например, не использовать дефайны, или он где то прочитал, что макросы есть зло и их следует избегать (тоже вид религиозного обета). В общем, у него были свои мотивы, я в них проникнуть не могу, но надеюсь, что после прочтения настоящего текста Вы на эту стезю не вступите.

И в заключение — небольшое напоминание на тот случай, если Вы решитесь модифицировать константы «на месте», без создания копии. В этом случае Вы можете наблюдать весьма забавное явление (ну это на взгляд внешнего наблюдателя забавное, Вам будет не столь весело). Заключается оно в том, что некоторые компиляторы для идентичных констант, особенно строковых, выделяют место только один раз, поэтому, изменив одну из них, Вы можете обнаружить, что изменилась и другая. Конечно, если у вас хорошая архитектура, то Вы получите ошибку времени исполнения, но если у Вас ее нет, то увлекательные поиски обеспечены. «Всего лишь неделя отладки может сэкономить Вам час на продумывание архитектуры программы». Возможно, существуют опции, предотвращающие подобное поведение компилятора, но лично я их не знаю, и, скорее всего, они будут непереносимы. Поэтому к данной методике, несмотря на ее привлекательность в некоторых аспектах, следует относиться, как к грязному трюку и всячески ее избегать, если у Вас нет веских оснований для иного поведения. «Да не убьет одно животное другое… без веской причины».

Кстати, я все таки созрел для написания большой работы по проектированию библиотеки для периферийного устройства в МК, и в настоящий момент нахожусь перед непростым выбором, что взять, как пример для реализации: UART с переходом в Modbus, SPI с переходом в SD карточки, либо USB с переходом повсюду. Мои читатели меня крайне обяжут, если помогут мне с этим нелегким выбором.

Краткий план развития сюжета по любому из направлений:
1. служба времени в МК (задержки),
2. служба памяти в МК (буферы),
3. реализация физического уровня интерфейса,
4. программная реализация канального уровня,
5. аппаратная реализация канального уровня,
6. опрос, прерывания, ПДП,
7. скрытие деталей реализации при помощи HAL,
8. компромисс между переносимостью и эффективностью,
9. модульная организация middleware,
10. сопутствующие вопросы.
В настоящий момент я заканчиваю первые две части, которые не зависят от конкретного интерфейса, и раздумываю, в каком направлении двигаться дальше. Опрос Вас ждет, если эта тема Вам интересна.

Автор: GarryC

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js