Showing posts with label ANSI C. Show all posts
Showing posts with label ANSI C. Show all posts

05/01/2022

System concepts (1): BSS — long forgotten but still ever-present

Preface

In this post we'll take a look at one interesting concept of modern operating systems — BSS. Possibly, some of you have not heard of it at all; some of you may think of it as of some sort of ancient thing and suppose it is not used these days. In first part of this post we will examine the purpose it was ever invented for. In second part we'll show how it is used these days ubiquitously even if you don't know about it.

The purpose of BSS

Historically, BSS stands for «Block Started by Symbol» or «Block Starting Symbol». Technically, BSS is a section of data in (object or executable) file. If it is a section, you may suppose we can declare it in the assembly language with .section directive. Well, that's right. Let's do it.


.section .bss .lcomm var, 1 #1000 .global main .text main: xorq %rax, %rax retq

By the way, modern assembly translators do not require keyword .section — .bss is enough. 

Let's explain what is done here. We declared .bss section with a variable named var in it with a parameter 1 (which may look like a value, but it is not). Compile and look at the resulting a.out, it's size in my case (Linux/GCC) is 16496 bytes. Now we change the parameter 1 to 1000, for example. Compile and look at size of a.out — it remains the same. «What kind of magic is that?!» It's «white» magic and now it's time to explain the whole thing. BSS was invented to save space on disk (or other storage or network bandwidth). And, yes, it's really that «ancient» invention — it originates to the 1950s. You can use it in cases when you don't need to specify values of storage area (variable), for example — to declare buffers which you will write to later, at runtime. You see that variables in .bss are declared in a different manner. It has no .globl directive — it uses .lcomm/.comm instead to specify its visibility. .lcomm stands for local module visibility, while .comm — for global (some sort of .globl directive). The parameter (1, 1000 in our case or whatever you want) is the size of the buffer. How it works? Linker writes symbol with variable name and address and count of bytes in resulting module. At runtime .bss variables are expanded in memory to specified size and (while it's not standard, usually) initialised with zeroes.

The opposite way to declare zero-filled array is to use .fill directive on regular variable in .data section — in this case all zeroes will be written to resulting module increasing its size. I'll omit this example here, but you may check it by yourself:


.data big_var: .fill 1000000

At this moment you may think — «This is a good idea. But could I use it to optimise my high level language programs with this knowledge?». I've had the same thoughts and have checked it. The answer is «yes», moreover — you already do this often. Here starts the second part of our little research.

Use of BSS these days

The short receipt is — just declare your variables as global arrays and initialize them as { 0 }. Let's prove it:


char lBSSVar[1] = { 0 }; int main () { return 0; }

Compile and check the resulting file size. For example, on my machine a.out is 16496 bytes (same size as a.out I've got from assembly language code). Now change the size of lBSSVar to 1000, recompile and see the size of a.out is not changed.

Let's see if it is BSS or something else by examining assembly code we get of our C-code:


.globl lBSSVar .bss .align 32 .type lBSSVar, @object .size lBSSVar, 1000 lBSSVar: .zero 1000

We see in this list (I've posted here the part that we are interested in only) that compiler made .bss section from our C-code. Syntax differs from my raw assembly example but technically idea is the same.

Conclusion

The idea to save space in object files I've demonstrated in this post is really old. But as we see in the last list syntax may differ. For example, clang uses .zerofill directive to describe uninitialised buffers. Thus different compilers may use different syntax. So, in my opinion, if you code your application in raw assembly language you can use syntax I've shown in first part of this post — it is a short way to use BSS idea. Second part of this post lets you know how to declare buffers you don't need to initialise in high-level languages saving some extra space on storage devices and a little time on loading it.

16/09/2021

Маленький взлом системы (2): заставим процессор выполнить переменную

В предыдущей статье (Маленький взлом системы (1): наконец-то вы сможете изменять строки типа char* String) мы рассмотрели как можно писать в область памяти, в которую запись запрещена. Сегодня мы разовьём эту тему и попробуем выполнить переменную.

N.B.
Всё, что описывается в этой статье — это строго про x86-ую машину. Если вы попытаетесь проделать это на другой архитектуре, то ваш процессор сгорит!

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

uint8_t lMagicCode [] = { };

Содержание её мы рассмотрим ниже. А сейчас выполним установку необходимых нам режимов доступа к этой переменной. Здесь повторим действия из примера в предыдущей статье:

void* lPageBoundary = (void*) ((long) lMagicCode & ~(getpagesize () - 1));
mprotect (lPageBoundary, 1, PROT_EXEC | PROT_WRITE);

Мы добавили режим доступа PROT_EXEC, который и позволит нам выполнить переменную. Как вы заметили, я оставил режим PROT_WRITE. Это сделано потому что MMU работает со страницами. На наших машинах её размер, вероятнее всего будет 4kB, что довольно много и помеченная страница скорее всего заденет область, следующую за интересующей нас переменной. А нам нужен режим чтения и записи для обвязки нашего эксперимента. Поэтому чтобы не схватить SIGSEGV после вызова mprotect, мы выставляем совмещённый режим — запись (которая на x86 MMU не бывает без режима чтения) и выполнение.

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

unsigned int (*NewExit)(unsigned long _ExitCode) = (void*)lMagicCode;

Здесь мы объявили указатель на функцию с названием NewExit, принимающую один параметр _ExitCode и возвращающую значение. Теперь мы можем выполнить нашу переменную и получить возвращаемое ею значение простой строчкой:

unsigned int lResult = NewExit (1);

Пока не надо этого делать — пытаясь выполнить пустую переменную, то есть, выполняя call на адрес пустой переменной, вы по сути дела, «провалитесь» дальше (как было в примере предыдущей статьи с выводом строки), а там может быть что угодно и, скорее всего, данные, даже не похожие на последовательность байт, представляющую собой корректную машинную команду с операндами. Что, вероятнее всего, приведёт к ошибке Illegal instruction.

Далее нам нужно заполнить нашу переменную корректным кодом. Начнём, допустим с возврата, то есть исключим «проваливание» потока выполнения при вызове этой функции. По справочникам найдём код операции ret, который очень простой и равен C3h.

uint8_t lMagicCode [] = { 0xC3 };

На самом деле это retn — return near, то есть ближний возврат или внутрисегментный возврат — возврат в пределах одного сегмента кода.

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

printf ("The result of NewExit is: %d.\n", lResult);

Сейчас мы будем видеть «мусор» на выходе из функции. Для получения какого-то осмысленного результата, вернём значение. Значение из функции по правилам x86_64 ABI возвращается через регистр «семейства» AX. Мы объявили нашу функцию как возвращающую значение unsigned int, то есть 32 бита. Значит нам нужно записать результат в EAX — 32-битный регистр. Найдём код загрузки константы в EAX — это B8h. При заполнении команды нужно учитывать размерность операнда. Здесь мы не можем написать B8h, 04h. Нам нужно указать всё значение полностью.

В языках высокого уровня это за нас делает компилятор. GAS так же подставляет дополненные значения в зависимости от суффикса мнемонического представления команды, например movl $0x04, %eax запишет в реальный код B804000000h, дополнив нашу четвёрку нулями до длинны long. Здесь это не тот long, о котором мы говорили в моделях памяти (LP64), это long с точки зрения x86-ой машины длинной 32 бита. Некоторые трансляторы даже подбирают код конкретной операции из обобщённого мнемонического представления и дополняют размерность операнда в зависимости от указанных размерностей приёмника и источника.

В противном случае, если мы не распишем все байты составляющие 32-битное значение, последующий код, который мы запишем как следующую инструкцию или операнд, в процессе выборки команды процессором будет воспринят как данные для загрузки в EAX. Поэтому соберём код B8h, 07h, 00h, 00h, 00h. И наша переменная приобретёт следующий вид:

uint8_t lMagicCode [] =
{
  0xB8, 0x07, 0x00, 0x00, 0x00, // movl $0x7, %eax
  0xC3,                         // retn
};

Теперь, вызвав эту функцию как

printf ("The result of NewExit is: %d.\n", NewExit (0));

Мы получим:

The result of NewExit is: 7

Функция названа NewExit. Давайте придадим ей этот смысл. Для этого мы воспользуемся системной функцией под номером 60/3Ch и вызовем её при помощи syscall, которая имеет код операции 0F05h. Операционная система, при вызове системных функций от пользовательских процессов, принимает номер функции в регистре EAX. Писать туда мы уже умеем. Наша переменная с машинным кодом приобретёт такой вид (возврат из этой функции нам уже не нужен):

uint8_t lMagicCode [] =
{
   0xB8, 0x3C, 0, 0, 0, // movl $0x3C, %eax, 3Ch/60 - system call exit()
   0x0F, 0x05,          // syscall
};

У нашей функции есть один параметр. Этот параметр попадёт в возвращаемое значение функции main, то есть код нашей переменной аналогичен функции exit. Вы можете это проверить запустив программу на исполнение и проверив код выхода при помощи echo $?. Вы увидите число переданное вами в функцию NewExit.

Хоть мы и передаём int в качестве параметра, возвращаемое из функции main значение всегда снижается системой до одного байта, поэтому не имеет смысла задавать значения более 255.

Здесь может возникнуть вопрос — «А почему так? Мы так много кода писали для простейших действий, а возвращаемое значение попадает в систему без каких либо операций вообще». Дело в том, что по правилам x86_64 ABI первый параметр, указанный в функции, записывается в регистр RDI. А системная функция exit, 60/3Ch возвращает в систему в качестве кода выхода значение RDI. Так совпало — наше значение «провалилось» насквозь и попало в оболочку, в переменную $? и писать нам для этого действительно ничего не пришлось.

P.S.
Интересно отметить тот факт, что отлаживать участки программы, представленные в виде переменных и сформированные в качестве их содержания, будет проблематично. Это связано с тем, что этот код попадает в секцию .data, которая отладчиком не рассматривается как код. Даже если посмотреть ассемблерный вывод после компилятора, то мы увидим просто переменную, в которой будут числовые значения — перечень наших байт в объявленном массиве.

        .size   lMagicCode, 7
lMagicCode:
        .byte   -72
        .byte   60
        .byte   0
        .byte   0
        .byte   0
        .byte   15
        .byte   5

Ниже приведу всю программу:

#include <stdio.h>
#include <stdint.h>
#include <unistd.h>
#include <sys/mman.h>

int main (void)
{
  uint8_t lMagicCode [] =
  {
//     0xB8, 0x3C, 0, 0, 0,          // movl $0x3C, %eax # 3Ch/60 - system call exit()
//     0x0F, 0x05,                   // syscall
    0xB8, 0x07, 0x00, 0x00, 0x00, // movl $0x7, %eax
    0xC3,                         // retn
  };
  
  void* lPageBoundary = (void*) ((long) lMagicCode & ~(getpagesize () - 1));
  
  mprotect (lPageBoundary, 1, PROT_EXEC | PROT_WRITE);
  
  unsigned int (*NewExit)(unsigned long _ExitCode) = (void*)lMagicCode;
  
  unsigned long lResult = NewExit (2);
  
  printf ("The result of NewExit is: %d. Or will never be printed...\n", NewExit (0));
  
  return 0;
}

12/09/2021

Little hack of system (1): finally, you can modify strings of type char* String

Preface

It's hard to find a person who would argue that it's impossible to count how often we want to change contents of a string declared as:

char* String = "Hello, World!";

Despite the fact that this operation looks absolutely ordinary and we expect a very straight and simple result from it, while trying to do something like:

String[1] = 'a';

we get Segmentation fault or SIGSEGV. And, usually, whole development ends at this point.

Don't even dare to do this — your CPU will catch fire immediately and all your unsaved data will be lost!

Segmentation fault occurs when a process attempts to write (or read, see below) to an area of ​​memory that is inaccessible to it. What is that memory area we can't write to? In our example, it's the area in the data segment marked as read-only — RO DATA. Let's look at the output of a program that declares a char* String. From a code like this:

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

we get:

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

Of course we can replace .section .rodata with .section .data. But we won't suggest intervening the building process of our code every time in, actually, not most handy stage — translation.

Solution

Let's have a look at POSIX function — mprotect(). This function changes the access conditions to a memory area.

There is a misstep or inaccuracy associated with this function. In documentation — as in man as on the Internet, it's claimed that mprotect() marks memory starting with address of a page and ending with address of that page plus length in bytes minus 1. I had to do a little search and research to clarify that the length is specified in pages, not bytes. That looks more clear because MMU operates with pages, not with bytes. And in the following examples I've proved this — specifying 1 as a length parameter I've got marked enough memory for all of our variables. The conclusion is — mprotect() assumes length is in count of pages.

The function takes address, length and flag as parameters. In our case, we are most interested in address and flag. We need PROT_WRITE flag — it's simple. But the things about address is a little more complicated. As MMU works with pages, the starting address we want to modify access to, should be aligned to page size. To get such address we'll calculate the closest (from the lower side) address of variable we're interested in. We'll do this in the following manner. First, we'll request the size of the page. Second, we'll drop all the least significant bits of the variable address that match the page size. Simply put, we zero the variable address bits on the least significant side to the bits of the page size.

For example: the address of lString is 555555556004h, we get the page size from the system, in my case (most probably you'll get the same) it is 4096. Subtract 1 from the page size, since it's actually addressed from 0 to 4095, and we'll get FFFh. We see that pages of this size are multiples of three halfbytes, or 1.5 bytes. In our example, the address of the page closest to the variable is 555555556000h. To calculate this address, we need to drop the outer 1.5 bytes of the variable's address. By inverting the page size we get a mask for the logical operation — FFFFFFFFFFFFF000h. Casting this to the void* type gives us the full memory address size for any architecture. This leads to the following preprocessing results:

void* lPageBoundary = (void*) ((long) lStr1 & ~(getpagesize () - 1));

For our research we'll set the length of memory we modify access to as one page. The size of a page allows us not only to play with writing to different areas, but also to experiment with boundaries of variables:

mprotect (lPageBoundary, 1, PROT_WRITE);

That's it. From this point on, unprecedented miracles begin. For example, the following program displays «World!» instead of the already annoying and tedious «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);
}

But I suggest to go a little further and play with addresses a little more. As you can see, I've declared two variables — lStr1 and lStr2. char* is an asciz or LPSZ (Long Pointer to Zero Terminated String) variable. As we know, string functions determine lengths and terminations of strings based on this zero. Let's check what happens if we replace the terminating zero in lStr1 with a space and output this string using printf(). Here, we'll demonstrate that we can write (modify memory) within the entire memory region access to which was modified by mprotect() function. I propose implementing the experiment of removing the last zero from lStr1 by writing to the address of lStr2 - 1:

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

Since our strings are located in memory one after other, it turns out we've removed the terminating zero from lStr1 and replaced it with a space symbol. Consequently, lStr1 is no longer asciiz/LPSZ, and thus, printf() should «fall thru» lStr1 to the first occurrence of zero which will be the terminating zero of lStr2. Let's check this. And we get the following output:

Hello World!

That's right, printf() reached the first terminating null, which turned out to be the terminating null of string lStr2. We got the output of the «concatenated» string.

In fact, this only looks unusual in C. In assembly language, this kind of data handling is ordinary routine — for example, the length of a string (or ascii/asciz) or array can be calculated by subtracting the address of the variable following it from its own address.

But that's not all. Perhaps at this moment, the thought has crossed your mind: «If this is possible, then maybe I can change the values ​​of variables declared as const?» The answer is yes — this long-held dream can be realised. But there's a some caveat. As we know, the compiler won't let us write to variables declared with the const modifier. An attempt to do:

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

will lead to:

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

To resolve this situation, we trick the compiler as follows:

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

We obtained the variable's address, added an offset to it, and wrote whatever we wanted to it. Now the code compiles and works. The offset here is zero; it doesn't have any technical meaning. I wrote the offset here to fully explain the notation for accessing variables declared as const. Using this notation, you can address any character in the string... and not just that character, and not just forward, since we can write in boundaries of the entire page we've granted access to.

NB
It seems obvious, but I think I should warn you. We've removed the write protection on a memory area, and you can remove the protection on a larger memory area. This means you can modify memory without any control from the OS, MMU, or compiler, as we've completely eliminated this functionality. You can write to these memory areas, but if you want to maintain proper functionality of your code, you need to be even more careful about the boundaries of the memory areas you modify!

P.S.
You may have noticed that we used the PROT_WRITE access flag without PROT_READ, and we were reading data from these areas. This was because the x86 MMU (this research was made on x86 machine) doesn't have a write mode without reading, so you can use PROT_WRITE | PROT_READ, but this is pointless. If you want to play with access, you can try PROT_NONE. In this case, you won't have any access to the memory page, and even an attempt to read it, for example via printf(), will result in a segmentation fault. This feature could be used in some way in practice, but it's hampered by the fact that we can only mark an entire page, and they only come 4K/2M/4M and 4Gb in size, depending on the architecture and/or operating mode. 4K is quite too much to use memory access mechanisms as some kind of «trap». However, if the program is large enough, we can group flags, the changes to which we want to react in certain conditions, into 4Kb blocks and implement the logic for reacting to a triggered security exception in the signal handler.

08/09/2021

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

N.B.
Всё, что описывается в этой статье — это строго про x86-ую машину. Если вы попытаетесь проделать это на другой архитектуре, то ваш процессор сгорит!

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

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 и в обработчике сигнала реализовать логику реакции на сработавшее исключение защиты.

07/04/2020

Размышления (1): о C, 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++ считаются родственниками (во всяком случае в мире UNIX/POSIX) вполне оправдано. Огромная кодовая база, наработанная всем сообществом POSIX-разработчиков, после выхода C++, при необходимости (при пересмотре подхода от процедурного к ООП) могла быть использована. Я понял, что это была не попытка использовать славу Plain C, а обеспечение возможности сохранить огромное количество наработок огромного количества инженеров. Это следствие того, что необходимость управления жёстким диском и принтером, перешла в необходимость управления жёсткими дисками и принтерами, то есть логичное отражение ситуации в мире компьютерной техники на средства разработки.

Рынок профессионалов


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

Эпидемия ООП или примеры абсурда


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

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

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

Итог

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

P.S.


Здесь так же хочу добавить пояснение. Не всем и не всегда понятно что такое ANSI C и Plain C и когда стоит употреблять тот или иной термин. ANSI C — это стандарт языка, Plain C — это термин, обозначающий концепцию, парадигму, процедурного подхода на конкретном языке — 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 с некоторым дополнениями).

P.P.S.


Да будет всему своё место и всего необходимого достаточно.

25/02/2020

Многопоточность в POSIX или Multithreading в ANSI C (1): поток и процесс, начало работы с libpthread

В этой статье мы научимся создавать, запускать и останавливать потоки в POSIX-совместимых системах на Plain C.
Сначала вкратце рассмотрим, что такое поток. Воспользуемся последним стандартом POSIX.1-2017, чтобы разобраться в терминологии:
Live Process
An address space with one or more threads executing within that address space, and the required system resources for those threads.
Здесь написано следующее: процесс есть адресное пространство с одним или более потоков выполняющимися в нём и необходимыми системными ресурсами для этих потоков.
Приписка «живой» нас не интересует, в стандарте используется термин Live Process для отличия его от Zombie Process. В этой статье мы будем говорить только о «живых».
Так же в стандарте написано, что многие системные ресурсы определённые стандартом являются общими для всех потоков в процессе. Эти ресурсы включают в себя ID процесса, ID родительского процесса, ID группы процессов, текущую директорию, файловые дескрипторы и пр.
Thread
A single flow of control within a process. Each thread has its own thread ID, scheduling priority and policy, errno value, floating point environment, thread-specific key/value bindings, and the required system resources to support a flow of control. Anything whose address may be determined by a thread, including but not limited to static variables, storage obtained via malloc(), directly addressable storage obtained through implementation-defined functions, and automatic variables, are accessible to all threads in the same process.
Что означает: поток есть одно «течение контроля» (нам, русскоязычным, привычнее термин «поток выполнения»). Каждый поток имеет собственный ID, приоритет планировщика и политики, значение errno и пр.. Всё, чей адрес может быть определён потоком, включая, но не ограничиваясь статическими переменными, динамически выделенная память и др. доступны всем потокам одного процесса.

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

Я считаю, что разобраться в этом было важно, так как всё (во всяком случае системное) программирование заключается в распределении ресурсов операционной системы и управлении ими, а то, что мы выше почерпнули из стандарта даёт следующие выводы:
  1. Мы можем пользоваться (читать, писать) ресурсами в своих потоках в пределах одного процесса без использования т.н. IPC (методы межпроцессного взаимодействия).
  2. Мы не можем обращаться к ресурсам других процессов без применения IPC. Даже из т.н. основного потока выполнения (main (), например), даже из под root'а. А POSIX хоть и предоставляет инструменты IPC, но это достаточно сложные механизмы.
Теперь рассмотрим технологию создания второго и последующих потоков. На всякий случай, напомню, первый у нас всегда есть — его создала ОС при запуске вашей программы (возможно, о том как это происходит я напишу). Этот поток я называю основным.
В самой по себе ANSI C нет механизмов работы с многопоточностью. Есть различные реализации работы с потоками. Например, специфичная GNU Portable Threads, Protothreads и пр. Первая преследовала цель максимальной портируемости, вторая — легковесности. Мы будем рассматривать самый распространённый (хотя и не самый оптимальный, почему я распишу в последующих статьях) стандарт, т.н. POSIX Threads (pthread). POSIX Threads API реализован в библиотеке libpthread.

Приведём пример простой многопоточной программы:

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

/*
 * Здесь мы проверяем, есть ли в системе POSIX Threads
 */
#ifndef _POSIX_THREADS
#error No POSIX Threads!
#endif

/*
 * Функция, код которой будет выполняться в новом потоке.
 * Обратите внимание на то, что мы можем передать сюда
 * указатель на область памяти. Это можно использовать
 * как механизм передачи некоторых начальных данных потоку.
 */
void *ThreadRoutine (void *_Arg)
{
  printf ("String argument passed to thread routine: %s\n", 
          (char *)_Arg);
  while (0)
  {
    sleep (1);
    printf ("Thread is doing infinite work...\n");
  }
  printf ("Thread is about to exit normally...\n");
  
  /*
   * Завершить выполнение потока можно двумя способами: 
   * return и pthread_exit () с возвращаемым нами значением 
   * в параметре. Они эквивалентны за исключением некоторой
   * (незначительной) разницы в быстродействии, что, в свою
   * очередь, связано с функционалом вызова обработчиков 
   * завершения потока. Но мы их здесь не будем рассматривать.
   */
  /* return (void *)(uintptr_t)0xC0FFEE; */
  pthread_exit ((void *)(uintptr_t)0xC0FFEE);
  /*
   * А что будет, если вызвать exit () из функции потока?
   * Приложение завершится, так как exit () это не возврат
   * из функции, а более сложный системный вызов, который
   * "корректно" закрывает приложение вместе со всеми 
   * потоками. Недопонимание этой разницы может возникнуть 
   * из-за того, что мы часто завершаем наше приложение 
   * простым return'ом в main'е. На самом деле, при 
   * "возврате" из main'а компилятор подставляет функционал 
   * "корректного" закрытия программы. Поэтому завершать 
   * работу потока нужно только через return/pthread_exit (). 
   * Вы можете заменить return/pthread_exit () на exit (0) 
   * и посмотреть что произойдёт - вы не увидите вывода 
   * "Thread return".
   */
  /* exit (0); */
}

int main (int _argc, char * _argv[])
{
  /* Идентификатор потока, на Linux'е это целое число, на
   * Mac OS это структура.
   */
  pthread_t thread_id;
  /* Возвращаемое значение функции потока */
  void *ThreadRet = NULL;
  /*
   * Функция pthread_create создаёт поток. Первый параметр - 
   * идентификатор потока (для дальнейших операций с ним). 
   * Второй - аттрибуты, третий - функция потока, четвёртый - 
   * аргумент (мы передадим туда название исполняемого файла).
   */
  pthread_create (&thread_id, NULL, ThreadRoutine, _argv[0]);
  /*
   * Функция pthread_join ожидает выхода из функции потока.
   * Так же здесь мы получаем возвращаемое значение из потока.
   * Перед тем как раскомментировать этот вызов, убедитесь, что
   * цикл while в функции потока выключен (например, поставьте 
   * while (0)). Иначе программа зависнет в ожидании завершения 
   * потока. Ведь pthread_join () ждёт корректного завершения
   * работы потока.
   */
   pthread_join (thread_id, &ThreadRet);
  /*
   * Перед тем как включить этот эксперимент, поставьте в функции
   * потока while (1), чтобы включить его бесконечную работу.
   * Функция pthread_cancel () аварийно завершает поток.
   * Дадим ему 5 секунд "пожить" и завершим.
   */
  /* sleep (5); */
  /* pthread_cancel (thread_id); */
  /* Выводим значение, возвращённое из функции потока */
  printf ("Thread return: %lX\n", (uintptr_t)ThreadRet);
  return EXIT_SUCCESS;
}


NB!
Обратите внимание на то, как при возврате из функции потока, я создаю указатель на некий адрес. Это своеобразный трюк. Чтобы не выделять память для возвращаемого значения, с последующим её освобождением, но вернуть простой int, как результат работы, я вернул указатель на адрес памяти, который придумал из головы. Вы можете возвращать указатели на любые адреса, и анализировать их по возврату из pthread_join (). Но стоит помнить, что обращаться по таким, вымышленным адресам нельзя.

Забрать пример:
https://gitlab.com/daftsoft/pthread1.git

Собирать программы с POSIX Threads нужно с указанием библиотеки pthread:
gcc -std=c89 -pedantic -Wall pthread1.c -lpthread -o pthread1

К слову, в Linux'е не всегда был реализован стандарт POSIX Threads. Как же были реализованы потоки в Linux'е до «принятия» POSIX Threads? В то время (c 2.0 до 2.4 включительно) в Linux'е была своя технология, называлась она LinuxThreads. В общих чертах, она заключалась в копировании кода процесса с предоставлением доступа к памяти родительского процесса. При этом, у потоков были разные идентификаторы процессов (PID) и взаимодействие между ними осуществлялось посредством одного из механизмов IPC — сигналов (USR1 и USR2), что исключало возможность использовать этих сигналов разработчиками. То есть, по современным меркам (или по меркам стандарта), это были не совсем потоки. Всё это несовершенство продолжалось до начала 2000-х годов, когда LinuxThreads была заменена библиотекой потоков POSIX (Native POSIX Thread Library, NPTL), реализующей стандарт POSIX Threads в Linux.
Представленного примера достаточно для того, чтобы начать писать свои первые многопоточные программы.
В следующей статье мы, на примере, рассмотрим как действительно работают «общие ресурсы» в потоках в пределах одного процесса, а так же иные аспекты управления потоками.