Автор: Эли Дэ Брувэр (Elie De Brauwer)

В четвертой статье я расскажу о важной теме, для каждого Си программиста, потому что она может причинить множество неприятностей: динамическое выделение памяти (dynamic memory allocation). Непонимание этого механизма (и указателей) может привести к утечкам памяти и ошибкам в приложении (например, к ошибке Segmantation Fault).

Сейчас пора каникул, и поэтому примером будет приложение, которое создаёт ASCII-снег. Чтобы создать этот эффект, я буду использовать небольшую часть библиотеки, называемой «ncurses». За дополнительной информацией о библиотеке я советую посетить http://tldp.org/HOWTO/NCURSES-Programming-HOWTO, потому что я буду говорить только о функциях, которые используются в приложении.

Использование ncurses

Для использования библиотеки сначала необходимо установить пакеты ncurses:

apt-get install libncurses5 libncurses5-dev

Затем в начало исходного файла нужно добавить заголовок ncurses, дописав #include <ncurses.h>. Новым является то, что ncurses предоставляется в виде динамической библиотеки, что означает две вещи: во-первых, компоновщик должен связать наш исходный код с библиотекой ncurses. Это делается такой командой:

gcc -Wall -lncurses snow.c -o snow

Параметр -l инструктирует компоновщик добавить разделяемую библиотеку ncurses. И в результате этого мы увидим такой вывод:

edb@lapedb:~/fullcircle/c-4$ ldd snow*
	linux-gate.so.1 =>  (0xb805c000)*
	libncurses.so.5 => /lib/libncurses.so.5 (0xb7ff7000)*
	libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7e99000)*
	libdl.so.2 => /lib/tls/i686/cmov/libdl.so.2 (0xb7e94000)*
	/lib/ld-linux.so.2 (0xb8042000)

Запуск ldd показывает, что приложению требуется наличие в нашей системе библиотеки libncurses.so.5. Ещё это значит, что не удастся запустить этот бинарный файл в системе, где нет этой библиотеки. Что может ncurses? На самом деле, текстовый терминал - это странная вещь. Используя printf(), можно вывести текст, но он всегда появляется в конце строки. Нельзя перематывать назад, использовать цвета, печатать жирные символы и т.д. Также существуют «управляющие последовательности» (escape sequences), которые влияют на поведение курсора и вывод текста в таких терминалах (они существуют с самого начала истории компьютеров). Но такие последовательности не удобны для человека. Итак, ncurses - это своего рода оболочка, которая облегчает использование управляющих последовательностей. В коде примера я добавил //nc после вызова функций, которые относятся к ncurses. Вот функции, которые я использовал:

  • getmaxyx() возвращает размеры терминала
  • clear() отчищает экран
  • mvaddch() отображает символ в заданном месте
  • refresh() делает принудительный вывод в терминал
  • endwin() правильно завершает работу терминала при выходе
  • initscr() инициализирует библиотеку ncurses

Функция main

Функция main делает немного (см. Листинг 1). Она инициализирует экран (строка 6) и каждую секунду обновляет массив снежинок (строка 12). Здесь только одна особенность - функция atexit(). Она используется, чтобы инструктировать приложение, что перед выходом нужно вызвать эту функцию. Её код дан в Листинге 2. Она просто вызывает endwin(). Заметьте, что здесь используется приём, называемый «указатель на функцию» (function pointer). Мы можем использовать указатели на функции так же, как и на данные, и это просто имя функции без скобок.

Листинг 1: main()

1.int main()
2.{
3.    char * field=NULL;
4.    int row=0;
5.    int col=0;
6.    initscr(); //nc
7.    atexit(exitfun);
8.
9.    /* Вечный снегопад ! */
10.    while(1)
11.    {
12.        updateFlakes(&field,&row,&col);
13.        if(field==NULL)
14.        {
15.            break;
16.        }
17.        drawScreen(field,row,col);
18.        sleep(1);
19.    }
20.    return 0;
21.}

Листинг 2: exitfun()

1. /* При выходе правильно закрыть терминал */ 
2. void exitfun() 
3. { 
4.    endwin(); //nc 
5.} 

Да сделаем, что будет снег

В main() у нас есть хранилище для числа строк, столбцов и массив снежинок. Мы передаём эти три параметра в функцию updateFlakes() (Листинг 3). Если размер терминала изменён, она выделяет память. Эта функция считывает размеры терминала при каждом вызове. Если они не соответствуют хранимым в main, то выделяется новый массив, и всё начинается с начала. В строках с 6 по 19 считываются размеры и выделяется память (и освобождается занятая, если она есть). Здесь как раз и используется динамическое выделение. Иногда до компиляции неизвестно, сколько понадобится памяти. Здесь нам нужен один байт для каждой позиции на экране, но размер окна не фиксирован во время компиляции, поэтому необходимо это узнать и выделить нужное количество памяти. То же самое происходит при изменении размеров окна, когда нужно обновить количество необходимой памяти. Для этого используются функции malloc() (строка 15) и free() (строка 13). Функции malloc() (что означает memory allocate - выделить память) нужно передать количество байт, которые нужно выделить, и она возвращает указатель на это количество байт (или NULL, если память закончилась). Функция free() информирует систему, что память больше не нужна. Неправильное сочетание malloc() и free() приведёт к утечкам памяти и, в конце концов, к краху приложения. Ну, и это всё. Просто, не так ли? Теперь становится понятно, сколько можно создать себе проблем, используя динамическое выделение памяти?

Листинг 3: updateFlakes

 1./* Обновим стурктуру */ 
 2.void updateFlakes(char ** fieldIn, int *rowIn, int *colIn) 
 3.{ 
 4.    int numnew=0; int row=0; int col=0; int i=0; 
 5.    char *field=*fieldIn; 
 6.    getmaxyx(stdscr,row,col); //nc 
 7.
 8.    /* Создадим новый field */ 
 9.    if(field==NULL || *rowIn!=row || *colIn!=col) 
10.    { 
11.        if(field!=NULL) 
12.        { 
13.            free(field); 
14.        } 
15.        *fieldIn=malloc(row*col); 
16.        field=*fieldIn; 
17.        memset(field,0,row*col); 
18.        *rowIn=row; *colIn=col; 
19.    } 
20. 
21.    /* Применим гравитацию ! */ 
22.    memmove(&field[col],&field[0],(row-1)*col); 
23.    memset(field,0,col); 
24.    numnew=random()%(col/2); 
25.    for(i=0;i<numnew;i++) 
26.    { 
27.        field[random()%col]=1; 
28.    } 
29.} 

По-настоящему сложная часть этой функции - это управление памятью. Мы используем одномерный массив (char* field) для хранения двумерных данных (две стороны экрана). Проще говоря, это означает, field[0] соответствует строке 0, столбцу 0; field[1] - сроке 0, столбцу 1; field[row] - сроке 1, столбцу 0, а field[row+1] - строке 1, столбцу 1. Легче работать с одним большим массивом, чем с массивом массивов. На Рисунке 1 показана эта схема для экрана размером в 5 строк и 3 столбца. Мы используем memset() (строка 17), что бы заполнить выделенный массив нулями (это всегда хорошая идея, потому что выделенная память обычно занята мусором). Однако магия происходит в строке 22 в функции memmove(), которая передвигает первые row-1 строк на col байт. На Рисунке 1 этот сдвиг изображен пунктирной стрел-кой. Когда это сделано, мы присваиваем нули новой «первой» строке и меняем случайные ячейки на 1 (это будет снег).

Да будет снег

Наконец, нужно просто перебрать массив и вывести снег на экран. Это показано в Листинге 4. Это лишь два for-цикла: один - для строк, другой - для столбцов; затем принять решение выводить снежинку или нет.

Заключение

Уже было освещено много «сложного материала», хотя представлено всего четыре статьи. Можно увидеть, что в этой статье мы уже отходим от общего программирования на С и смещаемся к приложениям более специфичным для Linux/Ubuntu. Целью этих статей является продолжение этого и всё большее обращение к характерным для Linux приёмам программирования, и на этом я желаю всем вам, энтузиастам, счастливого Нового Года, полного открытий.

Листинг 4: drawScreen()

 1./* Да будет снег */ 
 2.void drawScreen(char * field, int row, int col) 
 3.{ 
 4.    clear(); //nc 
 5.    int x=0; 
 6.    int y=0; 
 7.    for(y=0;y<row;y++) 
 8.    { 
 9.        for(x=0;x<col;x++) 
10.        { 
11.            if(field[y*col+x]==1) 
12.            { 
13.                mvaddch(y,x,'*'); //nc 
14.            } 
15.        } 
16.    } 
17.    refresh(); //nc 
18.} 

Упражнения

  • Заставьте приложение работать на вашей системе (необходимо выяснить нужные заголовки, подсказка: обратитесь к man-страницам вызовов, которые выдают ясные ошибки).
  • Вместо передачи exitfun() в atexit() можно сразу передать endwin(); проверьте, что это работает. Прочитайте man-страницу. Прототипы каких функций она принимает. Почему нет смысла передавать функции, не возвращающие значения.
  • Отключите повторное выделение памяти для field. Попробуйте теперь изменить размер окна. Есть ли плюсы?
  • Заметьте, что используемый массив field не освобождается функцией free() при выходе. Это не создаст проблем, не приведёт к утечке памяти, и ядро освободит память. Сделайте field глобальной переменной (поместив вне main()) и освободите память при выходе.
  • Напишите приложение с кодом while(1){malloc(1);}, чтобы подтвердить, что память в конце концов закончится.
  • Изучите man-страницы random и srand, чтобы узнать, как инициировать генератор случайных чисел.

Elie De Brauwer - фанатик Linux из Бельгии, сейчас работает разработчиком встроенного программного обеспечения в одной из ведущих компаний по спутниковой передаче данных. Когда он не со своей семьёй, он любит играть с технологиями и проводит дни ожидая, когда Blizzard наконец выпустит Diablo III.