08/09/2021

Маленький взлом системы (1): наконец-то вы сможете изменять строки типа char* String

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

char* String = "Hello, World!";

Несмотря на то, что эта операция выглядит абсолютно логично и мы ожидаем от неё весьма конкретного и несложного результата, пытаясь сделать что-то вроде

String[1] = 'a';

мы получаем ошибку сегментации (Segmentation fault и сигнал SIGSEGV). И, как правило, на этом вся разработка заканчивается.

Ни в коем случае не пытайтесь это сделать — ваш процессор немедленно сгорит и все несохранённые данные будут потеряны!

Ошибка сегментации возникает в случае, когда процесс «лезет» в недоступную ему область памяти с целью записи (или чтения, см. ниже). А что это за область памяти такая, в которую мы не можем писать? В нашем примере, это область в сегменте данных, помеченная как память только для чтения — RO DATA. Посмотрим что получается в коде программы, в которой объявлена строка char* String. Из программы типа:

int main ()
{
  char* lString = "Hello, World!";
}

мы получим:

    .section   .rodata
.LC0:
    .string "Hello, World!"

Конечно, можно просто заменить .section .rodata на .section .data, но каждый раз вмешиваться в сборку проекта на не самом удобном этапе — трансляции, мы предлагать не будем.

Я решил рассмотреть функцию POSIX — mprotect(). Функция изменяет условия доступа к области памяти.

Но с этой функцией связана одна ошибка. В документации — как в man, так и в интернете, указано, что mprotect() помечает память начиная от адреса страницы до адреса страницы + длинна в байтах за вычетом единицы. В интернете я случайно нашёл упоминание, что длинна здесь указывается в количестве страниц. Что логично, так как MMU работает со страницами, а не с байтами. На нашем примере я подтвердил это — указывая в качестве длинны единицу я пометил достаточное количество памяти для всех наших переменных, то есть, видимо, mprotect() действительно берёт длину в количестве страниц.

Принимает функция на вход адрес, длину и флаг. В нашем примере нас будет интересовать, флаг и, как ни странно, адрес. Флаг нам нужен PROT_WRITE. А с адресом всё чуть более интересно. Так как MMU работает со страницами, начальный адрес, должен быть кратным размеру страницы. С тем, чтобы получить нужный нам адрес, мы вычислим ближайшую (в меньшую сторону) к интересующей нас переменной границу страницы памяти. Сделаем это мы следующим образом. Запросим размер страницы, сбросим у адреса переменной все правые биты, совпадающие с размером страницы. Проще говоря — обнулим адрес переменной справа на величину размера страницы.

Например: адрес lString равен 555555556004h, размер страницы получаем от системы, в моём случае он оказался равен 4096. Из размера страницы уберём единицу, так как фактически она адресуется с 0 по 4095, получим FFFh. Видно, что страницы с таким размером кратны трём полубайтам или полутора байтам. В нашем примере адрес ближайшей к переменной странице равен 555555556000h. Чтобы вычислить этот, адрес нужно сбросить крайние полтора байта адреса переменной. Инвертировав размер страницы получаем маску для логической операции FFFFFFFFFFFFF000h. Приведём это к типу void*, что даст нам полный размер адреса в памяти для любой архитектуры. В результате получим следующие результаты подготовки:

void* lPageBoundary = (void*) ((long) lStr1 & ~(getpagesize () - 1));
Длину, для нашего эксперимента мы поставим равной одной странице. Размер страницы позволит нам поиграть не только с записью в запретные места, но и с границами переменных.
mprotect (lPageBoundary, 1, PROT_WRITE);

Всё. С этого момента начинаются невиданные до сих пор чудеса. Например, следующая программа выводит «World!», а не столь неприятный и уже надоевший «Segmentation fault»:

#include "string.h"
#include "unistd.h"
#include "stdio.h"
#include "sys/mman.h"

int main ()
{
  char *lStr1 = "Hello";
  char *lStr2 = " orld!";
  void* lPageBoundary = (void*) ((long) lStr1 & ~(getpagesize () - 1));

  // Comment next line to turn magic off:
  mprotect (lPageBoundary, 1, PROT_WRITE);
  lStr2[0] = 'W';
  printf ("%s\n", lStr2);
}

Но я предлагаю пройти дальше и ещё чуть-чуть поиграть с адресами. Как вы можете видеть, я объявил две переменные — lStr1 и lStr2. char* — это переменная типа asciz или LPSZ (Long Pointer to Zero Terminated String) или, по-русски — строка оконченная нулём. Как известно, функции, работающие со строками, определяют их длину и окончание по этому самому нулю. Давайте проверим, что будет, если вместо оконечного нуля lStr1 поставить пробел и вывести эту строку при помощи printf(). Здесь же продемонстрируем что мы можем писать (изменять память) в пределах всей памяти модифицированной функцией mprotect(). Эксперимент с удалением последнего нуля из строки lStr1 я предлагаю реализовать путём записи по адресу строки lStr2 - 1:

lStr2[-1] = ' ';
printf ("%s\n", lStr1);

Так как строки расположены в памяти непосредственно одна за второй, получается, что я удалил завершающий нуль lStr1 и поставил не его место пробел. Соответственно, строка lStr1 перестала быть asciz и по логике, printf() должен «провалиться» дальше. Проверим это, получим вывод:

Hello World!

Всё верно, printf() прошёл до первого завершающего нуля, а им оказался завершающий нуль строки lStr2. Мы получили вывод «совмещённой» строки.

На самом деле это выглядит непривычно только на C. На ассемблере подобная работа с данными является повседневной нормой — например длинна строки (string или ascii/asciz) или массива может быть вычислена вычитанием адреса следующей за ней переменной из её собственного адреса.

Но и это ещё не всё. Возможно, к этому моменту, у вас в голове возникла мысль — «Если можно так, то, может, я смогу изменять значения переменных, объявленных как const»? Ответ положителен — эту давнюю мечту можно реализовать. Но с небольшим нюансом. Как мы знаем, компилятор нам не позволит писать в переменные, объявленные с модификатором const. Попытка сделать:

const char* lStr3 = "hello world!";
lStr3[0] = 'H';

приведёт к:

./mprotect.c:18:12: error: assignment of read-only location '*lStr3'
   lStr3[0] = 'H';
            ^

Для разрешения этой ситуации обманем компилятор следующим образом:

*((char*)lStr3 + 0) = 'H';

Мы взяли адрес от переменной, добавили к нему смещение и по нему прописали, что хотели. Теперь всё собирается и работает. За смещение здесь взят нуль, это не имеет технического смысла. Я написал здесь смещение для того чтобы в полной мере раскрыть форму записи доступа к переменным объявленным как const. По этой форме записи вы можете адресоваться на любой символ строки... и не только на него, и не только вперёд, так как мы «взяли» себе целую страницу.

NB
Вроде и очевидно, но считаю что я должен предупредить. Мы сняли запрет записи на область памяти, возможно вы снимете запрет на большую область памяти. Это значит, что вы можете модифицировать память без какого либо вообще контроля со стороны ОС, MMU и компилятора, так как мы полностью исключили этот функционал. Вы можете писать в эти области памяти, но, если вы хотите сохранить адекватную работоспособность своего кода, вам нужно ещё более тщательно следить за границами областей памяти изменяемых вами!

P.S
Возможно вы заметили, что мы использовали флаг доступа PROT_WRITE, без PROT_READ и, при этом мы читали данные из этих областей. Всё это было так потому что у MMU x86 (а этот эксперимент проводился на x86-ой машине) нет режима записи без чтения, поэтому можно поставить PROT_WRITE | PROT_READ, но смысла в этом нет. Если вы хотите поиграть с доступом, вы можете попробовать PROT_NONE. В этом случае вы не будете иметь никакого доступа к странице памяти и даже попытка чтения, например через printf(), приведёт к ошибке сегментации. Эту особенность можно было бы использовать каким-то образом на практике, но это затруднено тем, что мы можем помечать только целую страницу, а они бывают только 4Kb/2Mb/4Mb и 4Gb размером, в зависимости от архитектуры и/или режима работы. 4Kb — довольно много для использования механизмов доступа к памяти в качестве какой-нибудь «ловушки». Хотя, если программа достаточно большая, можно группировать флаги, на изменение которых в какие-то моменты мы хотим реагировать, в блоки по 4Kb и в обработчике сигнала реализовать логику реакции на сработавшее исключение защиты.

23/03/2021

Плохой пример (1): «Advanced» for loop в стиле «senior developer»

В этой небольшой заметке я предлагаю рассмотреть два варианта описания несложного алгоритма. Несложного алгоритма поиска некоторого признака по определённым условиям. Здесь я предлагаю оценить качество двух описаний с точки зрения поддержки — понимания человеком, а не с точки зрения эффективности (скорости выполнения, использования ресурсов). Именно поэтому я использую термин «описание» (может даже стоит назвать это «написанием»), так как алгоритм используется один и тот же — взаимный перебор элементов массива со значениями и элементов массива с условиями. Результат заносится в массив Boolean [] Results. Представьте себе, что вы встречаете следующий участок кода:
for (Integer Counter1 = 0, Flag = 1; Counter1 < Total; Results[Counter1] = (Flag != 0), Counter1++, Flag = 1)
  for (Integer Counter2 = 0; Counter2 < Total - 1; Counter2++)
    if (ConditionArray[Counter2] == Counter1)
      Flag = 0;
Сейчас задумайтесь, сколько времени вам понадобилось бы чтобы понять этот участок? Понять в степени достаточной для того, чтобы вы могли внести коррективу — найти и исправить ошибку, изменить условия. А без вводных данных, что я дал в начале статьи? А если бы вы встретили где-то такой фрагмент, как вы могли бы описать его с тем, чтобы облегчить понимание его другими людьми, или вами же в будущем? Здесь оговоримся, что я обезличил названия переменных и названия счётчиков, массивов и прочих сущностей на практике могут быть несколько более осмысленными.
Рассмотрим «нормальное» написание этого алгоритма. Которое я реализовал в первую очередь, так как это написание первым и приходит в голову (может быть не только мне).
// Проходим по всем элементам:
for (Integer Counter1 = 0; Counter1 < Total; Counter1++)
{
  // Ищем совпадение условий:
  for (Integer Counter2 = 0; Counter2 < Total - 1; Counter2++)
  {
    // Сбрасываем флаг в значение по-умолчанию:
    Results[Counter2] = true;
    if (ConditionArray[Counter2] == Counter1)
    {
      // Выставляем флаг и прерываем цикл. В ситуации, которая меня 
      // привела к написанию этой статьи, дальнейший перебор не имел смысла:
      Results[Counter2] = false;
      break;
    }
  }
}
Всё понятно (на сколько вообще код может быть понятен) и комментарии органично влились в код.
Так откуда вообще взялся фрагмент кода, с которого я начал статью? Я сделал его сам. Как я пришёл к этому? Я начал устранять лишние скобки (как я люблю делать после завершения реализации участка кода). Увлёкшись этим процессом, я вспомнил о том, что цикл for состоит из трёх блоков. Таким образом, перенося по частям алгоритм в эти блоки, я и получил «компактное» представление этого цикла.
Теперь поясню, как я адаптировал «нормальное» написание в ненормальное. Ещё раз сокращённый код (чуть понятнее представил блоки цикла):
for
    (
     Integer Counter1 = 0, Flag = 1;
     Counter1 < Total; 
     Results[Counter1] = (Flag != 0), Counter1++, Flag = 1
    )
  for
      (
        Integer Counter2 = 0;
        Counter2 < Total - 1;
        Counter2++
      )
    if (ConditionArray[Counter2] == Counter1)
      Flag = 0;
Здесь запись флага условия в результирующий массив перенесена в третий блок внешнего цикла for. Конструкция «!= 0» используется для приведения Integer к Boolean, точнее для получения Boolean из Integer'а. По правилам большинства популярных языков программирования false — это ноль, всё остальное — true. Соответственно, выражение «!= 0» даёт true. Для адаптации алгоритма к сокращённому написанию, я применил конструкцию, не являющуюся самой понятной для человека. Впрочем, здесь можно было бы использовать любой «маркер» для передачи признака выполнения искомого условия, тем самым ещё больше запутать описание алгоритма. Почему здесь Integer? Потому что в первом блоке цикла for — блоке инициализации, могут использоваться только переменные одного типа, поэтому мне пришлось работать с флагом как с Integer'ом, смешав его с переменной-счётчиком. В противном случае, мне пришлось бы выносить инициализацию флага за пределы верхнего (первого) цикла. Так же в третий блок перенесён сброс значения флага.
Отметим, что комментирование такого написания алгоритма крайне затруднено. Тут скорее нужно не построчные пояснения, а целый документ, описывающий логику реализованного кода.
Для ещё большего снижения читаемости проверку Condition можно было бы заменить на тернарный оператор, но здесь он не подходит, так как он является конструкцией типа if-then-else и выставляет флаг в ненужное положение в блоке else. В ситуации, где он подошёл бы, можно было бы заставить код выглядеть ещё чуть «круче».
Хотя я писал в начале статьи, что мы здесь не будем рассматривать вопросы эффективности, можно добавить, что компактное исполнение будет работать чуть хуже полноценного. Всё потому, что в полноценном варианте перебор заканчивается (break) при нахождении условия, а в компактном цикл проходит до конца — есть избыточные операции. Эта разница специфична для задачи, в ситуации, где придётся проходить весь массив в поисках всех условий (Condition) этой разницы в производительности не будет.
Завернув нормальное исполнение в компактное, я ужаснулся, тому, что получилось, представив себе, что мне когда-то придётся здесь что-то изменить. На получение нормального, понятного написания потребовалось некоторое время. И в итоге, я заново написал «обычное» написание, мне было проще так поступить, чем «разворачивать» компактный вариант обратно.

Выводы
Простой алгоритм настолько естественно выглядящий, с точки зрения языка и компилятора, оказывается в высокой степени трудно поддерживаемым для человека и даже непригодным к описанию (комментированию). Это говорит нам о том, что нужно писать не максимально «крутой» код, а максимально понятный и легко поддерживаемый.

19/03/2021

Размышления (2): о C++, Java и областях применения их

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

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

И всё бы хорошо, потому что в таких областях применяют ООП и это обосновано. Но разберёмся с техническими особенностями реализации двух самых популярных на данный момент ООП-языков — Java и С++. С++ является компилируемым языком, то есть на выходе мы получаем платформенно-зависимый объектный или исполняемый файл. Java — интерпретируемый язык, условно называемая компиляция на Java заключается в получении промежуточного т.н. «байт-кода», который, в последствии, будет выполняться на JVM (Java Virtual Machine — среда времени выполнения Java). JVM обеспечивает кроссплатформенность и сама по себе является платформенно-зависимым приложением.

Из этого вытекают определённые последствия:
C++ хорошо применять для решения двух задач: разработки высоконагруженных приложений и приложений, работающих в условиях ограниченных ресурсов (что, на самом деле есть одно и то же, только с разных сторон). Как правило, оба вида этих приложений пишутся для узкого круга платформ. Высоконагруженные иногда вовсе для одного сервера или супер-компьютера компании. А работающие в ограниченных условиях — для какого нибудь одного процессора/контроллера. То есть кросс-платформенность в таких ситуациях бывает не нужна. Иногда даже возможность переноса на уровне кода (сборка под другие платформы, портирование) бывает не востребована. А возможности оптимизации, как через сам код C++, так и через применение ассемблерных вставок, безграничны. То есть, исходя из особенностей технической реализации языка C++, использовать его целесообразно для разработки высокоточных с точки зрения вычислительных ресурсов приложений (после C и ассемблера, разумеется) и высоконагруженные сервисы. Без сомнения вам было бы приятно, если бы мой блог (как и другие сайты) загрузился бы и работал в несколько раз быстрее.
Java же разрабатывалась для быстрой разработки кросс-платформенных приложений с относительно низкими порогами вхождения в разработку. На этом языке есть очень мало возможностей что-то оптимизировать (всё это делается путём косвенного воздействия на JVM через свой код). Работают приложения на Java относительно небыстро. А если это «относительно» небыстро умножить на тысячу, миллион запросов/операций/вызовов, получится ощутимо медленно. Зато можно быстро и легко разработать пользовательское (end-user) приложение любой сложности и оно будет работать на всех популярных платформах без переноса (пересборки, перекомпиляции и даже без переконфигурации!).

И что мы видим на практике? В подавляющем большинстве Java используется для разработки enterprise-платформ и web-backend, то есть достаточно критических с точки зрения вычислительных ресурсов систем. А для разработки пользовательских приложений используется C++. Ответом на эту потребность стала кросс-платформенная Qt для разработки пользовательских приложений, в том числе и с GUI, на C++. Да, работает быстро, так как это C++, но писать на C++/Qt несколько сложнее — пороги входа значительно выше, чем на Java и переносимость всё равно обеспечивается только через пересборку. А возможность создавать кроссплатформенные приложения, которой обладает Java из коробки остаётся не востребована вовсе — крупные enterprise-приложения, которые разрабатываются на ней, редко куда-то портируются и бывает так что такой продукт всю свою жизнь работает на одном сервере.

Разработку Enterprise и Web на Java можно понять с финансовой точки зрения — воспользоваться Java для ускорения разработки ПО и повышения безопасности (а на Enterprise и Web она очень важна), заплатив больше за серверные мощности, дешевле чем вкладывать в оптимизацию разрабатываемого ПО и, следовательно, тратить больше времени, разрабатывая их на языке более низкого уровня — C++. А вот разработку end-user-приложений на C++ я понять не могу.

Мне кажется, в сложившейся ситуации, логичнее было бы использовать для разработки пользовательских приложений Java, оставить её на Enterprise и Web, а C++ «вытолкнуть» (скорее оставить, ибо она и так там) на встраиваемую технику и на низкий уровень. В «идеальном» варианте лучше C++ использовать и на Enterprise с Web'ом, но это уже наверное маловероятный сценарий.

Но всё есть так как есть — молодые специалисты тяжело работают, решая несложные задачи на C++ (помним о высоких порогах входа), где часто меньше нужна скорость и больше пригодилась бы кроссплатформенность, а имидж Java опошлен, её область применения искусственно заужена, а уровень оплаты труда так же искусственно завышен.

Что это? Сложившиеся традиции? Как и когда они сложились? Когда и почему всё повернуло в эту сторону?

P.S.
Смоделировать ситуацию, в которой разработка высокоответственных продуктов ведётся на C++, а простых — на Java (что так же снижает барьеры использования пользователями разных платформ), я оставляю вам.

P.P.S
Здесь я сравнивал только применение Java и C++ потому что я имею опыт и (конечно личное) видение проблемы некорректного использования только этих языков. Это не означает, что я не понимаю что конфликты PHP vs Python, Python vs Java, Python vs C++ и пр. так же существуют (а, может, какие-то из этих конфликтов и не существуют). Я не писал о них только потому что не имею достаточного опыта участия в таких проектах или хотя бы наблюдения их.

01/06/2020

Маленькая ассемблерная вставка в мой блог (1): оптимизация выставления переменных состояния

Наблюдая за алгоритмами, по которым работают механические устройства, я начал задумываться — а что, если логика программы сама по себе избыточна? Например, принтер всегда делает какие-то движения — ёрзает кареткой туда-сюда, крутит барабан взад-вперёд и, в итоге, приводит себя в положение, необходимое для работы, игнорируя своё изначальное состояние и, что самое главное — не завися от этого состояния. Это конечно заставляет больше ждать пользователя, но удешевляет устройство так как снижает количество элементов, обеспечивающих обратную связь (или вовсе исключает их) — датчики позиций, счётчики оборотов и положений механизмов. А так же, я полагаю, это упрощает алгоритм и повышает его надёжность — в более высокой степени гарантирует приведение системы к требуемому состоянию за счёт исключения сложных логических операций (вычисление состояния/положения каретки, вычисление расстояния, которое, ей надо пройти). Я обратил внимание на то, что можно делать сначала какие-то безусловные действия (настройки) и лишь затем, модифицировать состояние системы, для приведения её в требуемое и отличное от изначального состояние по условиям. То есть можно снизить количество логики, повысив количество безусловных действий. Но кто-то, возможно, скажет что это приведёт к избыточным действиям (тем более, что я только что описал подобные, избыточные действия механической системы). Это нужно проработать и проверить.
Перейдём в плоскость ИТ/ВТ и конкретно разработки ПО. Представим себе пример — нам нужно выставить какой-то флаг в зависимости от условия — флаг наличия или количества байт в буфере I2C, одно из состояний элемента GUI. Часто люди думают (и пишут) конструкции типа:
if (condition) then
    flag = value_1;
else
    flag = value_2;
или даже:
if (condition) then
    flag = value_1;
if (!condition) then
    flag = value_2;
Я всегда хотел проверить, будет ли вариант:
flag = value_2;
if (condition) then
    flag = value_1;
эффективнее для вычислительной машины. Я предполагал, что такая конструкция должна компилироваться в код, где меньше команд JMP (и сходных — JG, JE, JNE). Чтобы проверить это я написал три варианта решения этой задачи и назвал их «if-then-else», «ternary» и «if-then». if-then-else — самый понятный и, как мне кажется, первый приходящий в голову вариант. С ternary всё понятно — это выставка флага по тернарному оператору. if-then — я назвал вариант, придуманный мной (скорее — который я хочу проверить), где сначала выставляется флаг, затем происходит проверка условия, необходимого для изменения состояния флага и изменение флага если условие истинно, соответственно. Вот эти блоки кода (оформлены в отдельные программы):
// if-then-else:
int main ()
{
    int n = 1;
    if (n < 0xC0FFEE)
        n = 2;
    else
        n = 3;
    return 4;
}
// ternary:
int main ()
{
    int n = 1;
    n = (n < 0xC0FFEE) ? 2 : 3;
    return 4;
}
// if-then:
int main ()
{
    int n = 1;
    n = 2;
    if (n < 0xC0FFEE)
        n = 3;
    return 4;
}
В этих программах я использовал числа 1, 2, 3 и возвращаемое значение 4, чтобы по ним, в последствии, ориентироваться в ассемблерном коде.
Теперь посмотрим как выглядит этот код на ассемблере:
1.file "if-then-else.c"
2.text
3.globl main
4.type main, @function
5main:
6.LFB0:
7.cfi_startproc
8movl $1, -4(%rsp)
9cmpl $12648429, -4(%rsp)
10jg .L2
11movl $2, -4(%rsp)
12jmp .L3
13.L2:
14movl $3, -4(%rsp)
15.L3:
16movl $4, %eax
17ret
18.cfi_endproc
19.LFE0:
20.size main, .-main
21.ident "GCC: (GNU) 9.3.0"
22.section .note.GNU-stack,"",@progbits
1.file "ternary.c"
2.text
3.globl main
4.type main, @function
5main:
6.LFB0:
7.cfi_startproc
8movl $1, -4(%rsp)
9cmpl $12648429, -4(%rsp)
10jg .L2
11movl $2, %eax
12jmp .L3
13.L2:
14movl $3, %eax
15.L3:
16movl %eax, -4(%rsp)
17movl $4, %eax
18ret
19.cfi_endproc
20.LFE0:
21.size main, .-main
22.ident "GCC: (GNU) 9.3.0"
23.section .note.GNU-stack,"",@progbits
1.file "if-then.c"
2.text
3.globl main
4.type main, @function
5main:
6.LFB0:
7.cfi_startproc
8movl $1, -4(%rsp)
9movl $2, -4(%rsp)
10cmpl $12648429, -4(%rsp)
11jg .L2
12movl $3, -4(%rsp)
13.L2:
14movl $4, %eax
15ret
16.cfi_endproc
17.LFE0:
18.size main, .-main
19.ident "GCC: (GNU) 9.3.0"
20.section .note.GNU-stack,"",@progbits
Части кода, что одинаковы, затенены серым цветом, а те, что нас нтересуют — выделены в центре. Таким образом мы фокусируемся только на том, что нас интересует, отсекая общие для всех программ участки кода.
Что мы видим:
        movl $1, -4(%rsp)
Здесь мы иницилизируем переменную единицей (именно для того чтобы понять, где начинается интересующий нас код, я это и делаю).
        movl $4, %eax
        ret
Это наш return 4; (именно для того чтобы понять, где заканчивается интересующий нас код, я и возвращаю четвёрку).
Можно видеть, что всё происходящее вполне прозрачно и подтверждает теорию, выдвинутую мною. Теперь мы можем отранжировать методы.
if-then показал себя самым компактным, не только с точки зрения операций, но ещё и тем, что содержит на одну метку меньше, а метки так же хранятся в ELF-файле, что, в нашем случае говорит об экономии места. Стоит отметить, что место экономится гораздо меньше, чем скорость — метка в файл записывается один раз, грузится тоже один раз, а вот исполняться каждый такой участок кода в программе может неисчислимое количество раз.
На втором месте if-then-else. Вполне логично больше на одну метку и на одну операцию перехода.
А вот ternary лично меня удивил. Это самый худший вариант, как с точки зрения размера, так и с точки зрения скорости (количества операций). В этой ситуации GCC сгенерировал код, который, кроме всех операций, что выполняет в варианте if-then-else, зачем-то работает сначала через регистр EAX, а затем перекладывает значение в переменную.
Вывод: да, вариант if-then эффективнее с точки зрения выполнения на машине. Хотя такая конструкция может несколько хуже восприниматься человеком — «Как это, сначала что-то приравнивается, а потом проверяется условие, и если оно не выполняется, то флаг вообще остаётся без внимания» — как будто кто-то забыл дописать строчку. Но если люди часто используют тернарную форму записи, которая вообще «не для людей» и считают себя «сеньорами», то я думаю мы можем писать в стиле if-then и тоже вполне обосновано считать себя сеньорами.
Здесь добавлю, что мы можем пользоваться этим методом не только для выставления флагов типа 1/0 или типа Boolean — метод if-then можно использовать и для выставления иных изначальных значений — цвет кнопки зелёный, а если имеется состояние ошибки, то выставлять его в красный; количество байт в буфере — ноль, а если есть что-то на входе, то выставляем этот счётчик в нужное количество и т.д..
Так же методом if-then можно экономить (хоть и не так много в процентном соотношении) и на более сложных конструкциях ветвления: цвет кнопки зелёный, если состояние ошибки — выставить в красный, если состояние промежуточное — выставить в оранжевый. Это сэкономит не 50%, как в вышеописанных ситуациях, а количество состояний за вычетом одного перехода и одной метки. Используя метод if-then, стоит вначале выставлять переменную в максимально часто возникающее состояние (если это можно продумать) — в таком случае часто будет происходить одна операция типа MOV, за ней одна или более операций типа JMP и минимальное количество случаев с повторным MOV, так как вы уже выставили флаг в максимально часто возникающее состояние.
В следующих статьях можно будет рассмотреть что получается из иных стандартных языковых конструкциях и как можно на них срезать углы.

28/04/2020

Web (3): Daft Jong — простая игра на JS/WebGL

В этой заметке я представляю простейшую версию (обкаточную реализацию) игры типа «пасьянс», а именно — трёхмерную разновидность известного MahJong'а (его пасьянсной вариации). Хотя, скорее, у меня смесь головоломки и пасьянса.

Технологии надо описывать, а в игру — играть. Поиграть вы можете, пройдя по ссылке внизу. А здесь я вкратце опишу какие технологии я применял и какие решения принимал. Игра написана на WebGL, с использованием Three.js. Цель игры — разобрать куб, попарно убирая одинаковые фишки (кубики).

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

Отрисовка фишек
В этой игре 35, или уже более, разных «правил». Узоры на фишках я решил отрисовать алгоритмом. Во-первых, потому что рисование такого количества картинок руками у меня заняло бы больше времени (а это не входило в мои планы), а написав алгоритмы отрисовки нескольких примитивных элементов, основанных на линиях и кругах, я смог их комбинировать и получить большое количество «правил». И, на данный момент, ещё не все комбинации употреблены. Во-вторых, генерация узоров на стороне клиента даёт ещё три бонуса. Первый серьёзный — возможность изменения картинок на лету. Пока реализовано только изменение цветов. Но можно реализовать случайную генерацию узоров, что не позволит игрокам «привыкнуть» к «правилам». Второй бонус заключается в возможности контролировать качество графики на стороне клиента, играя размером текстуры и соотношением логического пикселя экрана к аппаратному — DPR. Эту возможность, я может быть использую в реализации алгоритма адаптивного подбора нагрузки на CPU/GPU. Третий бонус, по началу не столь важный — трафик. Но если «правил» — разных цветов и комбинаций, будет больше, то исключение ожидания загрузки картинок с сервера будет более желательно, а рисовать руками придётся ещё больше и скучнее.

Выбор объектов
Так как в игре нужно разбирать головоломку, выбирая попарно фишки, технически нам нужен механизм выбора объектов на экране. Два подхода к решению этой задачи я описывал в статье Web (1): RayCasting vs GPU Color Picking (на примере Three.js). Для этого приложения я выбрал метод трассировки лучом (RayCasting) потому что объектов на сцене немного, а прозрачность текстур не используется.

NB
У меня игра лучше всего работает на телефонах — хоть и греет устройство и батарею расходует, но играется идеально гладко. На всех моих компьютерах — Linux Netbook, MacBook и даже большой Linux на Core i7 c nVidia GPU, работает гораздо хуже — компьютеры шумят, греются, а игра сильно тормозит. А в Safari на MacOS вообще едва дышит, так как аппаратная OpenGL не используется, якобы для экономии батареи.

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

Ссылка на игру: Daft Jong

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


07/04/2020

Размышления (1): о C, C++, связи между ними, почему ООП переоценен и последствия этого

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

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

Теперь рассмотрим области применения обоих подходов. Изначально разработка всего ПО была системного уровня — управление устройством приёма перфокарт, жёстким диском, простейшим принтером, примитивные базы данных, несложные файловые системы. Всё это писалось на ассемблере — императивном языке. Поэтому всё началось именно с парадигмы программирования, максимально приближённого к технике. Затем языки программирования развились до таких как C, Pascal, BASIC — процедурные языки. Количество обрабатываемых одной программой объектов схожих типов возрастало. Появлялись стандарты интерфейсов — для жёстких дисков, принтеров, дисководов. Да и самих устройств одного типа к компьютеру стали подключать больше. (Обратите внимание на то, что в описании выше я умышленно перечислил все объекты, с которыми работали разработчики в единственном числе, а здесь — во множественном). Появилась многозадачность, и что не маловажно — начали появляться графические интерфейсы — сущности, состоящие из множества объектов с повторяющимися характеристиками (свойствами) со схожим поведением и с некоторыми разнящимися свойствами (например, название и размеры кнопки или окна).
На самом деле интерфейсы были не только графическими, например, был Borland TurboVision — библиотека для создания программ с пользовательским интерфейсом на псевдографике (TUI — Text User Interface). Реализована эта библиотека была на Borland Pascal (Pascal c ООП) и Borland C++.

С конца семидесятых и все восьмидесятые года Бьёрн Страуструп трудился над своим детищем — языком ООП (в том числе и) высокого уровня, в последствии получившим название C++. Долгое время язык назывался «C with Classes» («C с классами») и автор не предполагал выпускать его как публичный продукт. Когда язык C++ начал окончательно формироваться, Страуструп принял решение не отходить от Plain C, и построил C++ на основе последней.
Некоторое время меня раздражало, что человек сделал язык, значительно отличающийся от одного из уважаемых мной, схожим с ним и даже назвал его похоже («++» означает «шаг вперёд по сравнению с C»). Что, на мой взгляд вносило путаницу (которая, кстати, никуда и не делась). Я даже думал (по аналогии с тем, как, по одной из версий, сделали с JavaScript — использовали Java в названии, чтобы воспользоваться славой молодой и быстро набирающей тогда популярность Java), что Страуструп решил воспользоваться хорошим и сильным имиджем Plain C для того чтобы популяризировать свой язык. Ведь C++ — не первый, и на тот момент, не единственный ООП-язык.

Но теперь, рассмотрев историю, я понял (или выдвинул свою теорию), что C++ стал представляться надстройкой над Plain С, вместо того чтобы выбрать другое название и синтаксис, не для использования славы последней. C++ сохранил совместимость с Plain С для того чтобы хорошо работать с ней в паре. И дело не только в том, что мы можем линковать объектные файлы с обоих языков и легко импортировать функции и даже классы (это можно делать с любыми языками, из которых можно получить бинарно-совместимые объектные файлы). Дело в том, что мы можем использовать исходный код на Plain C в проектах на C++. То есть Plain C и C++ считаются родственниками (во всяком случае в POSIX) вполне оправдано. Огромная кодовая база, наработанная всем сообществом POSIX-разработчиков, после выхода C++, при необходимости (при пересмотре подхода от процедурного к ООП) могла быть использована. Я понял, что это была не попытка использовать славу Plain C, а обеспечение возможности сохранить огромное количество наработок. Это следствие того, что управление жёстким диском и принтером, перешло в управление жёсткими дисками и принтерами, то есть логичное отражение ситуации в мире компьютерной техники на средства разработки.

Выше я писал о путанице, которая «никуда не делась». Путаница заключается в том, что многие рассматривают C++ не как ООП-надстройку над Plain C, а как «улучшенную» Plain C. Автоматически подразумевая, что Plain C не полноценна и рассматривая её, как «язык предыдущей версии». Отсюда происходят много попыток впихивать C++ везде, где только получается. А получается далеко не всегда хорошо (см. ниже). Это имеет последствия и на рынке труда — часто разработчикам на C++ предлагают ощутимо большие зарплаты, чем на Plain-C-вакансиях. А это, в свою очередь, приводит к тому, что новички (студенты и все, кто хотят начать карьеру разработчика) выбирают для изучения C++ (и ООП, соответственно). Становится больше C++-разработчиков, что приводит к ещё большему увеличению соответствующих вакансий. Так этот порочный круг замыкается и превращается в «эпидемию ООП». Отчасти, более высока ставка для ООП-разработчиков оправдана тем, что ООП-языки изначально несколько сложнее изучить, особенно, если к C++ добавить, часто требуемые (и, кстати, так же часто применяемые без надобности) STL, boost. В последние годы к требованиям на C++ ещё добавляется Qt. А Plain C, вроде кажется проще, даже книга от её авторов такая маленькая — за что здесь платить? Разработчику на Plain C нужно платить не за знание «высоких материй» (за которые платят ООП-разработчикам), а за знание и навыки работы с системами — железо (процессоры, контроллеры, шины передачи данных, стандарты, аппаратные протоколы, сети), инструменты (анализаторы, осциллограф, генератор, тестер, иногда даже паяльник), системные API (POSIX, kernel space) и, что самое важное, за понимание того, как всё это связано. Но, как правило, даже когда в компании ищут системного разработчика с пониманием того, что он должен знать, это часто не учитывается при расчёте оклада.

Эпидемия ООП или примеры абсурда.
Разработчикам аппаратуры на SoC нужно подключить к железке экран. В беседе команда, состоящая из ООП-разработчиков, мыслит следующим образом: «Экран это тип устройства вывода, а устройство вывода это класс устройства. Сделаем класс экран, унаследованный от класса устройство вывода, который в свою очередь унаследован от класса устройство». Сделали один класс, от него унаследовали другой и так ещё несколько раз, а потом реализовали Singleton последнего (в реальной жизни экран, в виде семисегментного индикатора, на передней панели маршрутизатора подключен один). Все вышестоящие классы оказались невостребованными.
Ещё один пример. В Java (считается самым чистым ООП-языком на данный момент) всё является классом. Для того, чтобы впихнуть ООП-парадигму в реальную жизнь, пришлось придумать статическую функцию main () в открытом (public) главном классе, имя которого должно совпадать с именем файла. Статическая функция в Java — это функция, которую можно вызывать без создания экземпляра класса. Мне нравится Java, но костыль, который её разработчикам пришлось применить, говорит о том, насколько переоценен ООП и насколько он далёк от вычислительной техники.

На системном уровне и на железе (на встраиваемой технике) важно не отвлекаться на ООП-парадигму и не «витать в облаках», а работать как можно ближе к системе или к железу. А ООП-языки (даже C++) сильно «оборачивают» POSIX и железо. Знание POSIX в современном мире подобно воздуху — если ты понял POSIX, ты понял жизнь (во всяком случае её ВТ-составляющую). Пониманию и изучению POSIX'а (и вычислительной техники как таковой) помогает Plain C, Assembler, так как они «прозрачны» для вычислительной техники. В этом и есть реальная проблема, вытекающая из «путаницы», что я описываю. Переоценка ООП-подхода приводит к тому, что люди учатся ИТ, игнорируя вычислительную технику (ВТ), которая является основой современной техники. И последствием этого становится появление целого пласта профессионалов, вовсе не понимающих ВТ. А людей, понимающих ВТ, становится всё меньше. И в этом виноват не Б.Страуструп, не Э.Шмидт и не Б.Эккель, а весь рынок.

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

Да будет всему своё место, но к ВТ, в любом случае, следует относиться с надлежащим уважением.

P.S.
Здесь так же хочу добавить пояснение. Не всем и не всегда понятно что такое ANSI C и Plain C и когда стоит употреблять тот или иной термин. ANSI C — это стандарт языка, Plain C — это термин, обозначающий концепцию, парадигму, процедурного подхода. Когда упоминают ANSI C, имеется в виду контекст стандарта (как правило его ограничения), когда говорят Plain C, акцент делается именно на парадигме программирования (процедурном) — как противопоставление C++. Любая ANSI C есть Plain C, но не любая Plain C есть ANSI C.
До 1988 года Plain C-код на разных компиляторах мог не собраться. Выражение ANSI C было популярным после выхода стандарта, в 1988 году, когда компиляторы стали «держаться ближе» к этому стандарту. Тогда было актуально, например, на собеседовании говорить, что вы работаете на ANSI C. Сейчас большинство компиляторов близки к стандарту, поэтому в основном, в речи уместно использовать термин Plain C (или просто «Си»). Так как во-первых, скорее всего, вы имеете в виду именно парадигму программирования, во-вторых, вы вряд ли знаете весь стандарт чтобы похвастаться знанием ANSI C (даже GCC реализует ANSI C с некоторым дополнениями).

06/04/2020

Web (2): Watcher.js — компонент для просмотра log-файлов

Задача
Иногда, чаще при разработке встраиваемых систем, возникает необходимость просматривать журнальный файл (лог) какого-либо приложения или демона, работающего на целевой системе или на «железке». Каждый раз ходить на устройство по SSH/Telnet/UART'у бывает не очень удобно. Особенно, когда система уже работает и имеются средства удалённой загрузки, допустим, конфигураций, прошивок или самого ПО, на устройство через систему управления (которая так же может быть Web-приложением).
Можно было бы просто запрашивать интересующий файл через Web-сервер. Но тут есть два нюанса. Первое — Web-сервер, в целях безопасности, не отдаст клиенту файл, расположенный вне DocumentRoot, по простому HTTP-запросу. Второе — даже если настроить сервер так, чтобы он отдавал файл (можно разрешить доступ к каталогу с логами или сделать ссылки на интересующие файлы в DocumentRoot), не будет автоматического обновления дописываемых в лог строк — придётся каждый раз обновлять страницу и ждать пока файл загрузится заново и проматывать вниз. Здесь казалось бы, можно просто написать CGI, которая будет делать 
tail -f /path/to/file
но на Web'е это работать не будет, потому что сервер отдаёт ответ от CGI только по завершению процесса, а tail -f не завершится никогда. Страница на стороне клиента зависнет в вечном состоянии загрузки.

Решение
Именно эти две задачи и решает мой компонент (Web-приложение), который я представляю в этой статье — компонент для просмотра текстовых файлов через Web-интерфейс, Watcher.
Watcher позволяет просматривать любые текстовые файлы на файловой системе, к которым у CGI-скриптов, запускаемых Web-сервером, есть доступ на чтение. Я на своей системе смог просмотреть даже /etc/fstab и /proc/cpuinfo. (А вот /var/log/messages без изменения прав доступа не получилось загрузить, он оказался под запретом даже для чтения.) Строки грузятся кусками и один раз, то есть вы можете читать информацию, не дожидаясь её полной прогрузки и без повторных загрузок файла.

Алгоритм, по которому работает Watcher
Сперва запрашивается размер файла, выраженный в количестве строк. На этом же этапе происходит определение ошибки доступа или отсутствия указанного файла. Если обнаружена ошибка, выводится сообщение и дальнейшая работа прекращается. Если ошибки не произошло, Watcher кусками подгружает строки из файла от нулевой до того размера, который был получен в начале. Затем запускается циклический таймер, который опрашивает изменение размера файла (в строках). Если файл увеличился, запрашивается (кусками) новое содержимое и отображается. Данные (количество строк и прогресс) получаемые на первом этапе отображаются в левом поле (Fetch). Данные, подгружаемые в последствии, отображаются в центральном поле (Watch).

Управление Watcher'ом
Имя файла задаётся в параметре адресной строки (?File=). То есть, вы вводите адрес (например, 127.0.0.1/Watcher.html?File=../log) и нажимаете enter. Всё, Watcher начинает работать по загрузке страницы. Если вам удобно чтобы при подгрузке новых строк, компонент автоматически прокручивался на них, есть переключатель Auto Scroll.
Иногда бывает нужно сбросить лог, очистить его содержимое. Для этого есть кнопка Clear Log. Эта функция очищает файл (при условии соответствующих прав доступа) на устройстве и перезапускает Watcher.

Ограничения и пояснения
Я долго соображал, что последняя, пустая строка, которую мы видим в текстовых редакторах, на самом деле не существует — символ новой строки добавляется в конце последней строки — той, которая нам кажется предыдущей. То есть, символ новой строки является частью последней строки, а не отдельной строкой. Поэтому, когда вы видите в редакторе или в выводе cat пустую строку снизу — это особенности вывода. Эта строка не выводится в Watcher как отдельная, пустая. Отсюда же следствие — если вы допишете строку в конец файла без символа новой строки, она не подхватится Watcher'ом.
В следствие особенностей алгоритма (работает только со строками и по строкам), который я разработал в этом компоненте есть некоторые ограничения:
  • нельзя удалять строки из файла — Watcher не отреагирует на это, более того — алгоритм вовсе собьётся
  • не имеет смысла изменять строки в файле — однажды загрузив содержимое файла, Watcher не следит за изменениями этого содержимого
  • CGI Watcher'а написан на Python 2.x — на Python 3.x пока не работает
  • у меня в проекте своя структура (расположение файлов) — вам придётся настроить свой httpd так, чтобы работал Python-скрипт из моего каталога (CGI) или переместить его в каталог с вашими CGI
Чтобы воспользоваться:
git clone https://gitlab.com/daftsoft/watcher.js.git