Издательский дом ООО "Гейм Лэнд"ЖУРНАЛ ХАКЕР 109, ЯНВАРЬ 2008 г.

Синезубый Tux

Fagot (salieff@mail.ru)

Хакер, номер #109, стр. 114

Пишем Bluetooth-приложения под Linux

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

Краткий обзор Bluetooth-стека

Bluetooth-стек сильно отличается от привычных протоколов своей структурой. Фактически, если проводить аналогии с TCP/IP, он включает не только пакетную и канальную логику, но и группы серверов и клиентов, выполняющие различные задачи.

Протокол открыт, он был разработан в 1994 году компанией Ericsson, на данный момент стандартизацией спецификаций занимается Bluetooth Special Interest Group (SIG). Путь развития протокола насчитывает шесть обратно совместимых версий: 1.0, 1.0B, 1.1, 1.2, 2.0 и 2.1.

Радиотракт Bluetooth работает в диапазоне 2,4—2,48 ГГц, свободном от лицензирования, его еще называют ISM-диапазон (Industry, Science and Medicine). Для модуляции применяется алгоритм FHSS (Frequency Hopping Spread Spectrum — широкополосный сигнал по методу частотных скачков), он прост в реализации и предоставляет достаточную помехозащищенность. Большинство потребительских устройств маломощны и позволяют устанавливать связь в радиусе до 10 метров со скоростью передачи 1-3 Мбит/сек.

С точки зрения прикладного программиста, на нижнем уровне стоит слой HCI (Host Controller Interface), он управляет канальными соединениями, и здесь можно провести аналогию с Ethernet. Далее данные обрабатываются пакетным протоколом L2CAP (Logical Link Control and Adaptation Protocol), его можно представить как смесь IP+UDP+QOS, с его помощью все вышестоящие слои осуществляют пакетную передачу. Выше стоит поточный протокол RFCOMM, пришедший из IRDA; его можно описать как TCP over RS232. И самый высокоуровневый протокол – это OBEX (Object Exchange), в стеке TCP/IP нет его аналогов.

Сторонней веткой от L2CAP отходит SDP (Service Discovery Protocol). Фактически это сложный сервер, позволяющий запрашивать и регистрировать на себе профили, описывающие возможности устройства. Для наглядности перечислю наиболее распространенные профили, одобренные SIG:

  • Generic Access Profile (GAP) – описывает, как использовать низкоуровневые протоколы. Все Bluetooth-устройства имеют реализацию GAP.
  • Service Discover Application Profile (SDAP) – описывает возможности данного SDP.
  • Serial Port Profile (SPP) – описывает параметры для эмуляции RS232 поверх RFCOMM или L2CAP.
  • Dial-up Networking Profile (DUNP) - описывает параметры для эмуляции AT-модема поверх GAP и SPP.
  • Generic Object Exchange Profile (GOEP) – описывает транспортные параметры OBEX.
  • Object Push Profile (OPP) – описывает параметры для приема и передачи простых объектов поверх GOEP.
  • File Transfer Profile (FTP) - описывает параметры для приема и передачи сложных объектов (включая навигацию по файловой системе) поверх GOEP/OPP.
  • Synchronization Profile (SP) - описывает параметры для синхронизации, аналогичной IrMC в IRDA.

Реализация Bluetooth-стека в Linux

В современных дистрибутивах GNU/Linux поддержка Bluetooth предоставлена инициативой BlueZ как на уровне ядра, так и в user space.

Распространенные дистрибутивы уже содержат BlueZ в ядре. Если ядро собирается самостоятельно, необходимо сконфигурировать его следующим образом:

Networking ...

<*> Bluetooth subsystem support ...

<M> L2CAP protocol support

<M> SCO links support

<M> RFCOMM protocol support

[*] RFCOMM TTY support

<M> BNEP protocol support

[*] Multicast filter support

[*] Protocol filter support

<M> HIDP protocol support

Bluetooth device drivers ...

<M> HCI USB driver

[*] SCO (voice) support

<M> HCI UART driver

[*] UART (H4) protocol support

[*] BCSP protocol support

[*] Transmit CRC with every BCSP packet

<M> Поддержка драйверов для ваших устройств

<M> HCI VHCI (Virtual HCI device) driver

Но, как мы уже заметили ранее, Bluetooth представляет собой разветвленный веерный стек, поэтому одной только поддержки на уровне ядра недостаточно. Для поддержки на уровне user space нам понадобятся следующие пакеты:

bluez-utils

bluez-libs

bluez-libs-devel

obexftp

Теперь можно приступать к конфигурированию сервисов. За уровень HCI отвечает демон hcid, в процессе выполнения он управляется с помощью утилит hciconfig/hcitool, при старте читает конфигурацию из файла /etc/bluetooth/hcid.conf. Приведу его унифицированное содержание:

options {

# Автоматически инициализируем новые устройства

autoinit yes;

# В качестве PIN всегда используем параметр passkey

security auto;

# Разрешаем множественное подключение

pairing multi;

# В качестве PIN используем это

# Когда внешнее устройство спросит при соединении,

# вводить нужно именно это

passkey "1234";

}

device {

# Имя компьютера

name "Xakep bluetooth box";

# Класс устройства, эта комбинация означает, что мы поддерживаем

# сеть и передачу объектов, являясь десктопным компьютером

class 0x120104;

# Разрешаем все виды сканирования

iscan enable; pscan enable;

# Всегда принимаем входящие соединения

lm accept;

# Разрешаем все состояния в режиме соединения

lp rswitch,hold,sniff,park;

# Становимся всегда доступными для обнаружения

discovto 0;

}

Сервис SDP обслуживает демон sdpd. В процессе выполнения он управляется с помощью утилиты sdptool. Мы не будем выполнять его предстартовую конфигурацию.

Также можно настроить поведение утилиты hciattach с помощью файла /etc/bluetooth/rfcomm.conf, это позволит обращаться к RFCOMM-профилю внешнего устройства как к обычному COM-порту:

rfcomm0 {

# При старте сервиса стараемся сразу соединиться

bind yes;

# Bluetooth-адрес внешнего устройства (к примеру, телефона)

device 11:22:33:44:55:66;

# RFCOMM-канал, на котором устройство предоставляет сервис

channel 1;

}

Теперь при появлении устройства (скажем, телефона) мы автоматически получаем псевдодевайс /dev/rfcomm0 и можем его использовать, допустим, как модем для подъема GPRS over Bluetooth.

Настройка закончена, запускаем сервис bluetooth автоматом или вручную: hcid, sdpd и hciattach, и можно проверять работоспособность:

  • hciconfig -a покажет состояние нашего bluetooth-адаптера;
  • hcitool scan покажет находящиеся вокруг bluetooth-устройства;
  • sdptool browse 00:11:22:33:44:55 покажет сервисы, предоставляемые устройством с таким адресом;
  • sdptool browse local покажет сервисы, зарегистрированные на нашей машине;
  • hidd --connect 00:11:22:33:44:55 присоединит к нам HID-устройство с таким адресом;
  • pand --connect 00:11:22:33:44:55 создаст Private Area Network с внешним устройством;
  • obexftp/obexftpd помогут обмениваться файлами с внешним устройством.

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

Программирование механизмов HCI

Любому bluetooth-клиенту, прежде чем соединяться с сервером, необходимо этот самый сервер найти. Такой функционал предоставляет слой HCI в виде функций управления устройством. Чтобы не заморачиваться тонкостями маршрутизации в bluetooth-сетях, мы возьмем устройство на маршруте по умолчанию. Также сразу откроем на нем hci-сокет, он понадобится позже для запроса имени устройства:

int dev_id = hci_get_route(NULL);

int sock = hci_open_dev(dev_id);

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

inquiry_info *ii=(inquiry_info*)malloc(255 * sizeof(inquiry_info));

int num_rsp = hci_inquiry(dev_id, 8, 255, NULL, &ii, IREQ_CACHE_FLUSH);

Теперь можно делать перебор найденных данных. Адрес находится в поле bdaddr, класс устройства - в поле dev_class, представляющем собой массив из трех байт. По второму байту можно грубо определить тип: 1 – компьютер, 2 – телефон. На самом деле класс устройства содержит намного больше точной и разнообразной информации, за ее интерпретацией можно обратиться к спецификациям.

char addr[19] = {0};

char name[248] = {0};

for (int i=0; i<num_rsp; ++i) {

ba2str(&(ii+i)->bdaddr, addr);

hci_read_remote_name(sock, &(ii+i)->bdaddr, sizeof(name), name, 0);

printf("%s %s", addr, name);

switch ((ii+i)->dev_class[1]) {

case 0x01 : printf(" [Class: computer]"); break;

case 0x02 : printf(" [Class: phone]"); break;

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

Программирование механизмов SDP на стороне клиента

После того как клиент нашел в сети требуемый сервер, нужно проверить, поддерживает ли сервер искомые сервисы, ведь иначе соединяться с ним нет смысла, да и параметры соединения неизвестны. Достигнуть желаемого можно средствами механизмов SDP. Сервисы можно искать по различным параметрам, и фактически количество и разнообразие таковых ничем не ограничено. Я буду искать сервис класса OBEX OBJECT PUSH и RFCOMM-канал, которым он предоставлен.

Для начала нам нужно соединиться с удаленным устройством:

bdaddr_t target;

str2ba("00:11:22:33:44:55", &target);

sdp_session_t *sess=sdp_connect(BDADDR_ANY, &target, SDP_RETRY_IF_BUSY);

Большинство параметров в SDP-операторике библиотек BlueZ задается древовидными списками с помощью функций построения таковых. Мы создадим список поиска, в который добавим параметр в виде искомого класса, а также создадим список атрибутов поиска, в котором запросим протоколы:

uuid_t root_uuid;

sdp_uuid16_create(&root_uuid, OBEX_OBJPUSH_SVCLASS_ID);

sdp_list_t *search = sdp_list_append(0, &root_uuid);

uint32_t range = SDP_ATTR_PROTO_DESC_LIST;

sdp_list_t *attrid = sdp_list_append(0, &range);

Теперь все подготовлено, чтобы сделать SDP-запрос через ранее подготовленную SDP-сессию с удаленным устройством и поместить результаты в еще один список:

sdp_list_t *result;

sdp_service_search_attr_req(sess, search, SDP_ATTR_REQ_INDIVIDUAL, attrid, &result);

Перебирая элементы результирующего списка, мы будем брать из них SDP-записи, проверять, есть ли там информация о протоколах, и запрашивать канал RFCOMM-протокола. Если полученный канал больше нуля, значит мы его нашли, можно с ним соединяться и обмениваться данными по протоколу OBEX OBJECT PUSH:

int rfcomm_channel = -1;

for(/* empty */; result; result=result->next)

{

sdp_list_t *access=NULL;

sdp_get_access_protos((sdp_record_t *)result->data, &access);

if (access) rfcomm_channel=sdp_get_proto_port(access, RFCOMM_UUID);

if (rfcomm_channel>0) break;

}

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

Программирование механизмов SDP на стороне сервера

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

sdp_session_t *session=sdp_connect(BDADDR_ANY, BDADDR_LOCAL, SDP_RETRY_IF_BUSY);

sdp_record_t *record=sdp_record_alloc();

Теперь нужно настроить видимость нашей записи. Для этого зарегистрируем запись в группе, которая видна всем и всегда (public browse group):

uuid_t grp_uuid;

sdp_uuid16_create(&grp_uuid, PUBLIC_BROWSE_GROUP);

sdp_list_t *grp = sdp_list_append(NULL, &grp_uuid);

sdp_set_browse_groups(record, grp);

Далее требуется объявить протокольный стек, на котором базируется наш сервис. Начинаем с самого низа – с протокола L2CAP:

uuid_t l2cap_uuid;

sdp_uuid16_create(&l2cap_uuid, L2CAP_UUID);

sdp_list_t *l2cap = sdp_list_append(NULL, &l2cap_uuid);

После L2CAP мы объявляем протокол RFCOMM, на девятом канале которого наш сервис будет ожидать соединения с клиентом:

uuid_t rfcomm_uuid;

sdp_uuid16_create(&rfcomm_uuid, RFCOMM_UUID);

sdp_list_t *rfcomm = sdp_list_append(NULL, &rfcomm_uuid);

uint8_t rfcomm_channel = 9;

sdp_data_t *chan_data = sdp_data_alloc(SDP_UINT8, &rfcomm_channel);

rfcomm = sdp_list_append(rfcomm, chan_data);

И последним протоколом нужно объявить OBEX, чтобы зафиксировать формат передачи данных, ведь RFCOMM такой фиксации не предполагает:

uuid_t obex_uuid;

sdp_uuid16_create(&obex_uuid, OBEX_UUID);

sdp_list_t *obex = sdp_list_append(NULL, &obex_uuid);

Чтобы покончить с протоколами, остался заключительный шаг – нужно объединить созданные протоколы в один список и зарегистрировать его в SDP-записи:

sdp_list_t *proto_list = sdp_list_append(NULL, l2cap);

proto_list = sdp_list_append(proto_list, rfcomm);

proto_list = sdp_list_append(proto_list, obex);

sdp_list_t *proto_root = sdp_list_append(NULL, proto_list);

sdp_set_access_protos(record, proto_root);

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

uuid_t opush_uuid;

sdp_uuid16_create(&opush_uuid, OBEX_OBJPUSH_SVCLASS_ID);

sdp_list_t *svclass = sdp_list_append(NULL, &opush_uuid);

sdp_set_service_classes(record, svclass);

Далее нужно создать дескриптор профиля, я присвоил ему версию 1.0, что вполне работает при практическом использовании (хотя честнее бы было 0.1):

sdp_profile_desc_t profile;

sdp_uuid16_create(&profile.uuid, OBEX_OBJPUSH_PROFILE_ID);

profile.version = 0x0100;

sdp_list_t *prof_list = sdp_list_append(NULL, &profile);

sdp_set_profile_descs(record, prof_list);

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

uint8_t formats[] = { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0xFF };

void *dtds[sizeof(formats)], *values[sizeof(formats)];

uint8_t dtd = SDP_UINT8;

for (size_t i=0; i<sizeof(formats); ++i) {

dtds[i] = &dtd;

values[i] = &formats[i];

}

sdp_data_t *sflist = sdp_seq_alloc(dtds, values, sizeof(formats));

sdp_attr_add(record, SDP_ATTR_SUPPORTED_FORMATS_LIST, sflist);

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

sdp_set_info_attr(record, "OBEX Object Push [XaKeP Edition]", NULL, NULL);

Теперь все готово к тому, чтобы зарегистрировать в SDP-сессии нашу сервисную запись, которую мы так долго и старательно конфигурировали:

sdp_device_record_register(session, BDADDR_ANY, record, 0);

Все, регистрация завершена, и внешние клиенты должны видеть на нашем сервере сервис OBEX OBJECT PUSH, слушающий на девятом RFCOMM-канале.

L2CAP- и RFCOMM-сокеты

После длительных мучений с HCI и SDP мы, наконец, получаем возможность работать с простым, всем хорошо известным унифицированным интерфейсом UNIX-сокетов. Bluetooth-сокеты поддерживают L2CAP- и RFCOMM-адресацию. L2CAP-адреса специфицируются номерами PSM (Protocol and Service Multiplexor), RFCOMM – номерами каналов.

Вот так реализуется L2CAP-сервер:

int s=socket(AF_BLUETOOTH, SOCK_SEQPACKET, BTPROTO_L2CAP);

struct sockaddr_l2 loc_addr={0};

loc_addr.l2_family=AF_BLUETOOTH;

loc_addr.l2_bdaddr=*BDADDR_ANY;

loc_addr.l2_psm=htobs(0x1001);

bind(s, (struct sockaddr *)&loc_addr, sizeof(loc_addr));

listen(s, 1);

struct sockaddr_l2 rem_addr = {0};

socklen_t opt=sizeof(rem_addr);

int client=accept(s, (struct sockaddr *)&rem_addr, &opt);

char buf[1024]={0};

ba2str(&rem_addr.l2_bdaddr, buf);

printf("Connection from client %sn", buf);

recv(client, buf, sizeof(buf), MSG_NOSIGNAL);

send(client, buf, sizeof(buf), MSG_NOSIGNAL);

А вот так L2CAP-клиент:

int s = socket(AF_BLUETOOTH, SOCK_SEQPACKET, BTPROTO_L2CAP);

struct sockaddr_l2 addr={0};

addr.l2_family = AF_BLUETOOTH;

addr.l2_psm = htobs(0x1001);

str2ba("11:22:33:44:55:66", &addr.l2_bdaddr);

connect(s, (struct sockaddr *)&addr, sizeof(addr));

char buf[1024]={0};

send(s, buf, sizeof(buf), MSG_NOSIGNAL);

recv(s, buf, sizeof(buf), MSG_NOSIGNAL);

Несмотря на большое сходство с TCP/IP, здесь хочется заострить внимание на нескольких моментах. Во-первых, унификация порядка следования байт в bluetooth своя, и функции преобразования тоже свои. Как можно увидеть, вместо htons (host to network short) здесь применяется htobs (host to bluetooth short) и далее по аналогии. Во-вторых, при работе с L2CAP нужно всегда учитывать MTU (Maximum Transmission Unit) и оперировать пакетами, равными или меньшими по размеру этому самому MTU. Получить MTU с сокета можно вот так:

struct l2cap_options opts;

int optlen = sizeof(opts);

getsockopt(sock, SOL_L2CAP, L2CAP_OPTIONS, &opts, &optlen);

printf("Input MTU=%d Output MTU=%dn", opts.imtu, opts.omtu);

А установить - вот так:

opts.omtu = opts.imtu = my_mtu;

setsockopt(sock, SOL_L2CAP, L2CAP_OPTIONS, &opts, optlen);

Фактически работа с RFCOMM-сокетами ничем не отличается от работы с L2CAP, за исключением нескольких моментов. Сокеты RFCOMM используют как поточные, а не пакетные:

int s=socket(AF_BLUETOOTH, SOCK_STREAM, BTPROTO_RFCOMM);

Для адресации здесь применяются другие структуры с другими полями:

struct sockaddr_rc addr={0};

addr.rc_family=AF_BLUETOOTH;

addr.rc_channel=(uint8_t) 1;

str2ba(""11:22:33:44:55:66", &addr.rc_bdaddr);

При приеме данных с RFCOMM-сокетов фрагментация – обычное дело. Ты попросил дать тебе 100 байт, вместо этого тебе дали 30 байт, потом 50 байт и потом еще 20 байт. В такой ситуации можно попросить вызов recv дефрагментировать поток с помощью флага MSG_WAITALL либо не забывать самостоятельно отслеживать длину принятых данных и собирать нужные куски вручную.

Итог

Теперь мы имеем на руках весь необходимый инструментарий, чтобы написать свои собственные клиент и сервер OBEX OBJECT PUSH. Осталась только реализация протокола, к счастью, он не так сложен. Ниже я приведу таблицу, иллюстрирующую обмен данными между клиентом, отправляющим файл, и сервером, этот файл принимающим. Реализацию клиента и сервера ты найдешь на диске, прилагающемся к журналу.

Клиент:
0x80
0x0007
0x10
0x00
0x2000
команда CONNECT
длина пакета 7 байт
версия OBEX 1.0
никаких флагов не установлено
максимальный размер пакета (в данном случае 8 Кб)
Сервер:
0xA0
0x0007
0x10
0x00
0x0800
команда SUCCESS
длина пакета 7 байт
версия OBEX 1.0
никаких флагов не установлено
максимальный размер пакета (в данном случае 2 Кб)
Клиент:
0x02
0x0422
0x01
0x0017
0xC3
0x00006000
0x48
0x0403
0x...
0x00, 'F', 0x00, 'A', 0x00, 'G', 0x00, 'O', 0x00, 'T', 0x00, '.', 0x00, 'T', 0x00, 'X', 0x00, 'T', 0x00, 0x00
команда PUT
длина пакета 1058 байт
заголовок TLV для имени файла
длина TLV
имя файла в кодировке UTF-16 и формате NTS
заголовок TLV для полной длины файла
заголовок TLV для длины передаваемого сегмента файла
длина TLV передаваемого сегмента файла
передаваемый сегмент файла
полная длина файла
Сервер:
0x90
0x0003
команда CONTINUE
длина пакета 3 байта
Клиент:
0x02
0x0406
0x48
0x0403
0x...
команда PUT
длина пакета 1030 байт
заголовок TLV для длины передаваемого сегмента файла
длина TLV передаваемого сегмента файла
передаваемый сегмент файла
Сервер:
0x90
0x0003
...
команда CONTINUE
длина пакета 3 байта
...
Клиент:
0x82
0x0406
0x49
0x0403
0x...
команда PUT для последнего сегмента файла
длина пакета 1030 байт
заголовок TLV для длины последнего передаваемого сегмента файла
длина TLV передаваемого сегмента файла
передаваемый сегмент файла
Сервер:
0xA0
0x0003
команда SUCCESS
длина пакета 3 байта
Клиент:
0x81 команда DISCONNECT
0x0003 длина пакета 3 байта
Сервер:
0xA0
0x0003
команда SUCCESS
длина пакета 3 байта

CD

На диске ты найдешь исходный код HCI-сканера, OPUSH-клиента и OPUSH-сервера

WWW

INFO

Если кто-то подумал, что можно регистрировать только те SDP-атрибуты, которые утверждены в SIG, то это не так. Ты можешь придумывать своим сервисам любые UUID'ы и регистрировать их, главное, чтобы было кому их искать.

Стек и API BlueZ сегодня - стандарт для Linux, а это значит, что полученные знания можно применять для программирования под любые embedded-устройства, оснащенные Bluetooth и ОС Linux.

Содержание
ttfb: 12.200832366943 ms