grim7reaper

A Code Craftsman

FFI avec Ruby

En mars j’avais décidé de me mettre à Ruby. Ça faisait bien deux ans que je disais que je voulais y jeter un œil, j’ai finalement sauté le pas.

J’ai commencé par me faire la main en développant un petit module permettant de lire et écrire des fichiers de type INI (disponible ici). C’est un équivalent (à 2-3 détails près) au module configparser de Python.

Une semaine après avoir commencé Ruby, je suis tombé sur ce journal sur linuxfr. Et là, je me suis dit : « Voyons ce que Ruby peut faire :) ». Partant de là, je me suis renseigné sur la FFI en Ruby et j’ai pondu rb-alsacap.

Ce code est plus une Proof of Concept qu’un véritable programme que je souhaite maintenir. Au passage, le code est moyennement propre (ce n’est pas crade, mais c’est un peu moins clean que INIConfig).

FFI en Ruby : comment faire ?

J’ai commencé par regarder ce que propose Ruby en standard. Et là, j’ai été très déçu. Ce qui est fourni en standard c’est le module DL. Bon, vu la documentation du truc et sa mauvaise réputation, j’ai vite cherché une alternative…

Par la suite, je suis assez rapidement tombé sur Ruby-FFI qui est franchement intéressant. Je ne vais pas répéter ici tous les avantages cités dans la documentation officielle, mais un truc qui est vraiment bien c’est qu’il y a une bonne documentation. Et ça, ça aide beaucoup.

Premier pas avec Ruby-FFI : le binding

Pour créer un binding, c’est très simple. Il suffit de :

  • créer un nouveau module qui va étendre FFI::Library
  • donner le nom de la bibliothèque que l’on souhaite wrapper.
Code minimal pour un binding
1
2
3
4
5
6
7
require 'ffi'

module LibAsound
  extend FFI::Library

  ffi_lib 'asound'
end

Ensuite, il faut attacher à notre module les fonctions C que l’on souhaite appeler. Cette tâche est réalisée via le mot-clef1 attach_function. Le fonctionnement est très simple :

  • on donne le nom de la fonction C ;
  • on donne la liste des arguments (une liste vide correspond à void) ;
  • on donne le type de retour.

On peut aussi renommer la fonction lors du binding (par exemple, la fonction C sqrt pourra être nommée c_sqrt dans notre module). C’est optionnel. Ici je ne l’ai pas fait donc le nom de la fonction Ruby sera le nom de la fonction C.

Binding des fonctions
1
2
3
4
5
6
7
8
9
10
11
12
require 'ffi'

module LibAsound
  extend FFI::Library

  ffi_lib 'asound'

  attach_function :snd_ctl_card_info_sizeof, [ ], :size_t
  attach_function :snd_ctl_card_info_get_id  , [ :pointer ]          , :string
  attach_function :snd_ctl_card_info_get_name, [ :pointer ]          , :string
  attach_function :snd_ctl_card_info         , [ :pointer, :pointer ], :int
end

On peut également définir des énumérations, puis les utiliser en tant que type :

Énumérations
1
2
3
4
5
6
7
8
9
10
11
12
13
require 'ffi'

module LibAsound
  extend FFI::Library

  ffi_lib 'asound'

  enum :snd_pcm_stream_t, [:SND_PCM_STREAM_PLAYBACK, 0,
                           :SND_PCM_STREAM_CAPTURE,  1,
                           :SND_PCM_STREAM_LAST,     1]

  attach_function :snd_pcm_open , [ :pointer, :string, :snd_pcm_stream_t, :int ], :int
end

Voilà pour ce qui est du binding en lui-même.

Il y a pas mal de trucs que je n’ai pas couvert, c’est voulu car je ne fais pas une présentation exhaustive de Ruby-FFI. Ce n’est qu’une petite introduction qui présente les concepts de base (et surtout ce que j’ai dû mettre en œuvre pour réaliser rb-alsacap).

Bon, passons à la partie la plus intéressante : l’utilisation de notre module.

Interaction Ruby-C

À l’usage c’est très simple. Le seul truc qui peut être un peu délicat, c’est le passage de paramètres par adresse. Ça se fait en trois étapes :

  1. allocation de la mémoire ;
  2. passage du pointeur ;
  3. lecture de la valeur.

Cas de base

Fonction C
1
int snd_pcm_hw_params_get_channels_min(const snd_pcm_hw_params_t* params, unsigned int* val)
Appel en Ruby
1
2
3
4
5
6
# Allocate memory for 1 unsigned int.
uint_ptr = FFI::MemoryPointer.new(:uint, 1)
# Call the function.
LibAsound.snd_pcm_hw_params_get_channels_min(hw_params, uint_ptr)
# Read the pointer's content.
@min_channel = uint_ptr.read_uint()

Pointeur de pointeur

Même principe pour un pointeur de pointeur.

Fonction C
1
int snd_ctl_open(snd_ctl_t** ctlp, const char* name, int mode )
Appel en Ruby
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def get_handle()
  hwdev = "hw:#{@number}"
  # Create a pointer to store the handle's address.
  ctl_handle = FFI::Pointer::NULL
  # Create a pointer of pointer
  ref_handle = FFI::MemoryPointer.new(:pointer)
  # Make it point on our pointer.
  ref_handle.write_pointer(ctl_handle)
  # Call the fonction.
  if LibAsound.snd_ctl_open(ref_handle, hwdev, 0) < 0
    AlsaCap.throw_AlsaError(err, 'obtaining control interface', @number)
  end
  # Return ctl_handle
  return ref_handle.read_pointer()
end

Structure

Pour une structure, il y a deux cas de figure :

  1. on va jouer avec les champs de la structure directement : dans ce cas on va avoir besoin de connaître sa composition et il va falloir la définir dans notre code Ruby (cf. cette entrée du wiki de Ruby-FFI pour savoir comment on défini une structure) ;
  2. on va manipuler un pointeur opaque sur cette structure (c’est le cas ici) : dans ce cas, on a seulement besoin de connaître la taille à allouer.
Fonction C
1
int snd_ctl_card_info(snd_ctl_t* ctl, snd_ctl_card_info_t* info)
Appel en Ruby
1
2
3
4
5
6
7
8
9
# Allocate snd_ctl_card_info_sizeof bytes.
info = FFI::MemoryPointer.new(LibAsound.snd_ctl_card_info_sizeof())
# Call the function
if LibAsound.snd_ctl_card_info(ctl_handle, info) < 0
    AlsaCap.throw_AlsaError(err, 'obtaining card info', @number)
end
# Use accessors to access the structure's fields.
@id      = LibAsound.snd_ctl_card_info_get_id(info)
@name    = LibAsound.snd_ctl_card_info_get_name(info)

Bonus : problèmes rencontrés

Énumération dépendante de l’endianness2

En plus de l’énumération snd_pcm_stream_t, j’ai aussi du reproduire l’énumération snd_pcm_format_t. Le problème c’est qu’elle est définit comme cela :

Définition de snd_pcm_format_t dans pcm.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/** PCM sample format */
typedef enum _snd_pcm_format {
[]
        /** Signed 16 bit Little Endian */
        SND_PCM_FORMAT_S16_LE,
        /** Signed 16 bit Big Endian */
        SND_PCM_FORMAT_S16_BE,
        /** Unsigned 16 bit Little Endian */
        SND_PCM_FORMAT_U16_LE,
        /** Unsigned 16 bit Big Endian */
        SND_PCM_FORMAT_U16_BE,
[]
#if __BYTE_ORDER == __LITTLE_ENDIAN
        /** Signed 16 bit CPU endian */
        SND_PCM_FORMAT_S16 = SND_PCM_FORMAT_S16_LE,
        /** Unsigned 16 bit CPU endian */
        SND_PCM_FORMAT_U16 = SND_PCM_FORMAT_U16_LE,
[]
#elif __BYTE_ORDER == __BIG_ENDIAN
        /** Signed 16 bit CPU endian */
        SND_PCM_FORMAT_S16 = SND_PCM_FORMAT_S16_BE,
        /** Unsigned 16 bit CPU endian */
        SND_PCM_FORMAT_U16 = SND_PCM_FORMAT_U16_BE,
[]
#else
#error "Unknown endian"
#endif
} snd_pcm_format_t;

La valeur de certains symboles de l’énumération dépendent de l’endianness de la machine. Pour reproduire ce comportement en Ruby, j’ai écrit le code suivant :

pcm_format.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public

def get_format
  num       = 0x12345678
  little    = '78563412'
  big       = '12345678'
  native    = [num].pack('l')
  netunpack = native.unpack('N')[0]
  str       = '%8x' % netunpack
  case str
  when little
    PCM_FORMAT_COMMON + PCM_FORMAT_LE
  when big
    PCM_FORMAT_COMMON + PCM_FORMAT_BE
  else
    fail StandardError.new('Your endianness is not handled')
  end
end

private

PCM_FORMAT_COMMON = [:SND_PCM_FORMAT_UNKNOWN            , -1 ,
                     :SND_PCM_FORMAT_S8                 ,  0 ,
                     :SND_PCM_FORMAT_U8                 ,
                     :SND_PCM_FORMAT_S16_LE             ,
                     :SND_PCM_FORMAT_S16_BE             ,
[]
                     :SND_PCM_FORMAT_LAST               , 44]

PCM_FORMAT_LE = [:SND_PCM_FORMAT_S16            , 2 ,
                 :SND_PCM_FORMAT_U16            , 4 ,
[]
                 :SND_PCM_FORMAT_IEC958_SUBFRAME, 18]

PCM_FORMAT_BE = [:SND_PCM_FORMAT_S16            , 3 ,
                 :SND_PCM_FORMAT_U16            , 5 ,
[]
                 :SND_PCM_FORMAT_IEC958_SUBFRAME, 19]
libasound.rb
1
2
3
4
5
6
7
8
9
10
require 'ffi'
require 'rb-alsacap/pcm_format'

module LibAsound
  extend FFI::Library

  ffi_lib 'asound'

  enum :snd_pcm_format_t, get_format()
end

Ainsi, l’énumération s’adapte automatiquement :-)

Pointeur sur size_t

Ici ce n’est pas un problème que j’ai rencontré lors du développement de rb-alsacap, mais sur un autre projet utilisant Ruby-FFI (projet en rapport avec le NetCDF dont je parlerai peut-être un peu plus quand il aura atteint un état un peu plus avancé…). Cela dit, je voulais quand même le présenter ici car ça reste en rapport avec Ruby-FFI.

À un moment, j’ai eu besoin de passer une variable de type size_t par adresse à une fonction. Cela semble facile : il suffit d’appliquer les trois étapes précédemment mentionnées (allocation mémoire, passage du pointeur puis enfin lecture). Oui, sauf que là, je ne pouvais pas lire la valeur car il n’y a pas de méthode *read_size_t :-/

Bon, ce n’est pas un problème insurmontable vu que Ruby permet la réouverture des classes, ça permet d’ajouter la méthode manquante à la classe FFI::Pointer. Le souci étant que la définition de size_t varie d’une implémentation à l’autre. Donc comment définir la méthode read_size_t de manière portable ?

Après quelques recherches sur le sujet et lecture de la documentation de Ruby-FFI, j’en suis arrivé à ce code tout simple :

read_size_t.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
module FFI
  class Pointer
    # Find onto which type size_t is mapped.
    builtin_type = FFI::TypeDefs[:size_t]
    # Find the type name (and checks the existence of a read method).
    typename, _ = FFI::TypeDefs.find do |(name, type)|
      builtin_type == type && method_defined?("read_#{name}")
    end

    # Create the method read_size_t
    alias_method :read_size_t, "read_#{typename}" if typename
  end
end

Code qui s’utilise de manière totalement transparente par la suite :

Exemple d’utilisation
1
2
3
4
5
# Retrieves the size.
size_ptr = FFI::MemoryPointer.new(:size_t)
err = LibNetCDF.nc_inq_dimlen(owner, dim_id, size_ptr)
fail NetCDFError.new(nc_strerror(err)) unless err == NC_NOERR
size = size_ptr.read_size_t

C’est en fait un problème connu si j’en crois cette demande de fonctionnalité vieille de deux ans sur le GitHub du projet, mais il n’est toujours pas résolu. Au passage, ma solution est fortement basée sur un bout de code posté dans le lien précédent.

Téléchargement

Pour ceux que cela intéresse, le code complet de rb-alsacap est disponible ici.

Pour aller plus loin

Je vous renvoie sur l’excellent wiki de Ruby-FFI. Il est bien foutu et c’est grâce à lui que j’ai pu prendre si rapidement en main cette nouvelle bibliothèque.


  1. je sais que attach_function est une fonction et pas un mot-clef. Cela dit, comme c’est un EDSL je préfère utiliser le terme mot-clef plutôt que fonction.

  2. je devrais dire boutisme, mais je trouve ce mot moche…