Blog

Interagindo com bibliotecas em C usando código em Haskell

· Alessandro Jean

Uma necessidade comum em algumas aplicações específicas é poder utilizar alguma biblioteca nativa do sistema operacional diretamente, mesmo não utilizando a linguagem que ela foi escrita (normalmente C ou C++). A maioria das linguagens de programação modernas permitem que tais funções sejam utilizadas através do recurso de interoperabilidade.

O objetivo deste tutorial é demonstrar como a interoperabilidade funciona no Haskell no sentido de utilizar bibliotecas existentes em C, bem como a conversão entre os tipos de ambas as linguagens. Para tal, serão mostrados alguns exemplos onde serão criadas algumas bibliotecas próprias, e outros onde serão alguns exemplos mais objetivos através da utilização do FFmpeg e VLC.

O código final deste artigo está disponível no GitHub no repositório haskell-c-interop.

Este tutorial foi escrito como um dos projetos da disciplina Desenvolvimento Guiado a Tipos do Bacharelado em Ciência da Computação na UFABC em Abril de 2024.

Conteúdo

  1. Definição
  2. Criando o projeto
  3. Um exemplo introdutório
  4. Usando a biblioteca math.h diretamente
  5. Utilizando a biblioteca do FFmpeg
  6. Reproduzindo o arquivo com a biblioteca do VLC
  7. Conclusão
  8. Bibliografia

Definição

Interoperabilidade é a capacidade de duas linguagens de programação diferentes interagirem de maneira nativa como parte de um mesmo sistema e operar com os mesmos tipos de estruturas de dados.

Wikipédia (em inglês)

O Haskell oferece a interoperabilidade através do método de interfaces de funções externas (FFI), o que permite que nosso código em Haskell possa chamar funções escritas em C ou C++. Essas interfaces permitem que sejam construídas bibliotecas em Haskell que ofereçam funcionalidades de bibliotecas em C, facilitando o acesso e inclusive permitindo um estilo que seja mais idiomático para o Haskell, com (mas não limitado somente) a conversão para o paradigma funcional (Wikipédia). Tais bibliotecas são comumente conhecidas como wrappers ou bindings.

Criando o projeto

Similar ao tutorial anterior, iremos utilizar o Haskell Stack para a criação do projeto. Todo o pacote de desenvolvimento foi instalado através do GHCup. Para iniciar a pasta do projeto, utilizamos o comando:

$ stack new c-interop simple

Após as dependências e o template terem sido baixados, podemos abrir a pasta em um editor de código com suporte a Haskell e C/C++, como o Visual Studio Code.

Um exemplo introdutório

Começando com um exemplo simples, utilizaremos uma biblioteca própria que só vai ter uma função. Para este exemplo, vamos criar uma biblioteca de matemática que irá exportar a função exponencial (e ^ x) definida como my_exp.

Primeiramente, precisaremos criar o arquivo de cabeçalho que irá definir todas as funções que nossa biblioteca exportará para o uso de outros programas.

my_math/my_math.h
#ifndef MY_MATH_H
#define MY_MATH_H

extern double my_exp(double);

#endif // my_math

A diferença significativa deste código para o que é comumente utilizado em arquivos de cabeçalho em C é a utilização da palavra-chave extern antes da definição da função. Ela que irá indicar quais funções ficarão acessíveis por outros programas.

Com o cabeçalho pronto, podemos implementar a nossa função em um arquivo separado.

my_math/my_math.c
#include <math.h>
#include "my_math.h"

#define EULER_NUM 2.71828

double my_exp(double number) {
  return pow(EULER_NUM, number);
}

Este código não tem muito segredo: utilizamos a biblioteca libmath do C para usar somente a função pow, permitindo que façamos a operação e ^ number. Poderíamos implementá-la manualmente, mas para fins de simplificação do código, é mais rápido utilizar a função oficial.

Compilando a biblioteca de modo dinâmico

Para poder compilar a biblioteca, utilizaremos um fluxo em duas etapas: primeiro criaremos o objeto (my_math.o) para aí sim poder criar a biblioteca (libmy_math.so).

Para criar o objeto, utilizaremos o gcc do mesmo modo que seria utilizado para compilar um arquivo simples qualquer. A diferença, é que utilizaremos a flag -fpic, ideal para o uso em bibliotecas, pois evita alguns possíveis conflitos com outras bibliotecas.

$ gcc -c -Wall -Werror -fpic my_math.c

O comando irá produzir um arquivo my_math.o, que será utilizado em sequência no gcc para criar a biblioteca com o comando a seguir.

$ gcc -shared -o ../libs/libmy_math.so my_math.o

Com isso, temos a biblioteca compilada, podemos utilizá-la no nosso código em Haskell.

Incluindo a biblioteca

Para poder compilar o projeto com o stack, é necessário adicionar algumas flags ao compilador do Haskell, o ghc. No arquivo c-interop.cabal na raiz do projeto, é necessário incluir as seguintes linhas na propriedade ghc-options:

c-interop.cabal
ghc-options: -- Demais flags omitidas.
             -- Define um diretório extra para as bibliotecas. 
             -L./libs                                          
             -- Define um diretório extra para os cabeçalhos.  
             -I./my_math                                       
             -- Utiliza a biblioteca my_math na compilação.    
             -lmy_math                                         

Temos este pequeno trabalho a ser feito por conta dos arquivos não estarem em diretórios comuns do sistema operacional, mas veremos no próximos exemplo que não será necessário especificar estes diretórios ao utilizar dependências instaladas globalmente de maneira direta.

Utilizando a função

Agora finalmente poderemos modificar o código em Haskell. Primeiramente, é necessário usar duas diretivas de linguagem para habilitar a chamada de funções externas, bem como importar os tipos primitivos do C.

src/Main.hs
{-# LANGUAGE ForeignFunctionInterface, CApiFFI #-}
module Main (main) where

import Foreign.C.Types

Os tipos primitivos nos possibilitarão uma melhor definição das funções externas até por conta das diferenças de tamanho entre os tipos do Haskell e do C, que também podem ter diferenças entre os processadores e sistemas operacionais. Tendo feito as importações, podemos definir no arquivo o acesso a função my_exp da nossa biblioteca utilizando o código a seguir.

src/Main.hs
foreign import capi "my_math.h my_exp" c_myExp :: CDouble -> CDouble

Essa importação é especial, pois explicita que a função irá vir de uma dependência externa, bem como define que será feito uma chamada a uma função em C, especificada juntamente ao cabeçalho da função. Como o compilador do Haskell não consegue prever a tipagem da função, é necessário deixar a tipagem explícita.

Pode-se utilizar tanto a palavra-chave capi quando ccall, com a diferença que ao utilizar capi, os tipos serão checados com o compilador do C na hora de fazer o link entre as duas linguagens. Para um ambiente mais seguro em relação a tipos, torna-se interessante utilizar o capi, que inclusive é o recomendado pela documentação do Haskell atualmente.

É uma convenção do Haskell utilizar o prefixo c_ para indicar funções e/ou constantes que sejam de bibliotecas externas em C, por isso ela é nomeada assim. Após a importação, podemos utilizá-la como uma função qualquer em Haskell, com a diferença que será necessário lidar com alguns tipos especiais, como o CString e Ptr ao utilizá-los.

src/Main.hs
main :: IO ()
main = do
  print $ c_myExp 5

Compilando o código Haskell e executando

Para compilar utilizaremos o stack novamente, mas com algumas diferenças.

$ stack build

Após a compilação ter sido concluída, podemos executar o arquivo.

$ LD_LIBRARY_PATH=./libs stack exec c-interop
148.41265995084171

Aqui tem uma diferença no comando: é necessário especificar o local das bibliotecas externas nesse caso por conta de não ser o comum do sistema operacional. Como as bibliotecas são carregadas em tempo de execução, o programa precisa saber novamente onde as encontrar.

Ao executar, obtemos o resultado correto de 148.41265995084171 impresso na tela.

Usando a biblioteca math.h diretamente

Na seção anterior, criamos uma biblioteca nossa e chamamos a função no código em Haskell. Nesta, iremos utilizar uma biblioteca da linguagem C que já está disponível globalmente no sistema operacional, onde teremos diferenças e simplificações na hora de compilação e execução.

A principal diferença é que não precisaremos modificar as flags de compilação do ghc no arquivo c-interop.cabal para incluir os diretórios dos cabeçalhos e da nossa biblioteca, mas sim apenas será necessário a utilização da libmath do próprio C. As linhas podem ser comentadas ou removidas neste caso, pois não serão mais utilizadas.

c-interop.cabal
ghc-options: -- Demais flags omitidas.
             -L./libs     
             -I./my_math  
             -lmy_math    
             -lmath       

De maneira similar, iremos importar a função exp da biblioteca math.h no código Haskell.

src/Main.hs
foreign import capi "math.h exp" c_exp :: CDouble -> CDouble

main :: IO ()
main = do
  print $ c_exp 5

Para compilar e executar, basta usarmos os comandos padrões do stack:

$ stack build
$ stack exec c-interop
148.4131591025766

Veja como não é necessário também definir a variável de ambiente LD_LIBRARY_PATH na hora de execução, visto que o programa sabe onde encontrar a biblioteca libmath.so na pasta padrão do sistema operacional, pois é uma biblioteca instalada globalmente.

Utilizando a biblioteca do FFmpeg

Nesta seção, utilizaremos a API do FFmpeg para criar um programa simples que imprime os metadados de um arquivo suportado pela ferramenta.

O FFmpeg é uma ferramenta de código aberto vastamente utilizada por diversos sistemas de multimídia, programado em C e Assembly para uma melhor performance. Ela permite a conversão entre formatos (ffmpeg), visualização de metadados (ffprobe) e reprodução de arquivos (ffplay). Todos os grandes players no mercado como YouTube e Netflix utilizam-o em algum dado momento nos seus fluxos.

Um quadrinho que brinca sobre a dependência de vários sistemas com o FFmpeg.
Post baseado numa tirinha do XKCD.
Um quadrinho que brinca sobre a dependência de vários sistemas com o FFmpeg.
Post baseado numa tirinha do XKCD.

O intuito deste exemplo é construir uma espécie simplificada da ferramenta ffprobe disponível pelo FFMpeg. O ffprobe é um utilitário que exibe informações de um arquivo passado pela linha de comando. Ao usar em um arquivo de exemplo, podemos observar a saída abaixo.

$ ffprobe big_buck_bunny.mp4
ffprobe version 6.0-6ubuntu1 Copyright (c) 2007-2023 the FFmpeg developers
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from '/home/alessandro/Downloads/bbb_sunflower_1080p_30fps_normal.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 1
    compatible_brands: isomavc1
    creation_time   : 2013-12-16T17:44:39.000000Z
    title           : Big Buck Bunny, Sunflower version
    artist          : Blender Foundation 2008, Janus Bager Kristensen 2013
    comment         : Creative Commons Attribution 3.0 - http://bbb3d.renderfarming.net
    genre           : Animation
    composer        : Sacha Goedegebure
  Duration: 00:10:34.60, start: 0.000000, bitrate: 3481 kb/s
  Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 2998 kb/s, 30 fps, 30 tbr, 30k tbn (default)
    Metadata:
      creation_time   : 2013-12-16T17:44:39.000000Z
      handler_name    : GPAC ISO Video Handler
      vendor_id       : [0][0][0][0]
  Stream #0:1[0x2](und): Audio: mp3 (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 160 kb/s (default)
    Metadata:
      creation_time   : 2013-12-16T17:44:42.000000Z
      handler_name    : GPAC ISO Audio Handler
      vendor_id       : [0][0][0][0]
  Stream #0:2[0x3](und): Audio: ac3 (ac-3 / 0x332D6361), 48000 Hz, 5.1(side), fltp, 320 kb/s (default)
    Metadata:
      creation_time   : 2013-12-16T17:44:42.000000Z
      handler_name    : GPAC ISO Audio Handler
      vendor_id       : [0][0][0][0]
    Side data:
      audio service type: main

Para utilizar a API do FFmpeg, iremos precisar da libavformat e libavutil instaladas. Também iremos utilizar a libavcodec, mas ela já vem inclusa na instalação de ambas as bibliotecas, que estão disponíveis nos repositórios das distribuições Linux. Como exemplo do Ubuntu, elas estão disponíveis no apt e podem ser instaladas utilizando o comando abaixo.

$ sudo apt install libavformat-dev libavutil-dev

Utilizamos as variantes com o sufixo -dev pois queremos que os arquivos de cabeçalho também sejam baixados e disponibilizados para serem usados em programas que vamos escrever.

O código deste exemplo é baseado em um exemplo oficial do FFmpeg que pode ser visto na íntegra neste link no repositório do GitHub do projeto. No código, o arquivo passado como primeiro argumento na linha de comando é aberto como um input, onde seus metadados são processados, iterados, e exibidos na tela.

Para poder replicar este código em Haskell, criaremos uma interface escrita em C para intermediar (e facilitar) a comunicação entre nosso programa em Haskell e a biblioteca do FFmpeg. Esse programa intermediador irá expor duas funções para ler as informações do arquivo e uma struct especial com menos campos para facilitar a declaração dos tipos no Haskell.

Criando o arquivo de cabeçalho

De maneira similar ao exemplo anterior, iremos criar um arquivo de cabeçalho ffmpeg.h.

ffmpeg/ffmpeg.h
#ifndef FFMPEG_H
#define FFMPEG_H

#include <libavformat/avformat.h>
#include <libavutil/dict.h>

struct FfmpegInput {
  AVDictionary * metadata;
  AVDictionary * format_metadata;
  AVDictionary ** streams_metadata;
  int nb_streams;
  AVFormatContext * context;
};

typedef struct FfmpegInput FfmpegInput;

extern FfmpegInput * load_input(const char *);
extern void free_input(FfmpegInput *);

#endif // ffmpeg_h

Explicando resumidamente, a struct FfmpegInput irá guardar três informações: um dicionário com os metadados do arquivo (metadata), um segundo dicionário com os metadados do formato do arquivo (format_metadata) e o contexto original da biblioteca, que será utilizado apenas para liberar a memória no final do uso do programa e não será acessado no código em Haskell.

Carregando um arquivo e lendo os metadados

Partindo para a implementação da primeira função, iremos utilizar uma abordagem similar a do exemplo no repositório do FFmpeg.

ffmpeg/ffmpeg.c
#include <stdio.h>
#include <libavformat/avformat.h>
#include <libavutil/dict.h>
#include <libavcodec/codec.h>
#include <libavcodec/codec_desc.h>
#include "ffmpeg.h"

FfmpegInput * load_input(const char * file_name) {
  AVFormatContext * format_context = NULL;

  // Tenta abrir o arquivo, retorna um inteiro > 0 se deu algum erro.
  if (avformat_open_input(&format_context, file_name, NULL, NULL)) {
    return NULL;
  }

  // Tenta ver se há alguma trilha de vídeo e/ou áudio no arquivo.
  // Retorna valores negativos se deu algum erro.
  if (avformat_find_stream_info(format_context, NULL) < 0) {
    av_log(NULL, AV_LOG_ERROR, "Cannot find stream information\n");
    return NULL;
  }

  FfmpegInput * input = malloc(sizeof (FfmpegInput));

  if (input == NULL) {
    fprintf(stderr, "Failed to allocate necessary bytes for input\n");
    return NULL;
  }

  // Cria um dicionário que irá ter alguns metadados do arquivo.
  AVDictionary * metadata = NULL;
  av_dict_copy(&metadata, format_context->metadata, AV_DICT_DONT_OVERWRITE);
  create_file_dict(&metadata, format_context);  

  // Cria um dicionário que irá ter alguns metadados do formato.
  AVDictionary * format_metadata = NULL;
  create_format_dict(&format_metadata, format_context->iformat);

  // Cria um vetor de dicionários que irão ter alguns metadados das streams.
  AVDictionary ** streams_metadata = calloc(format_context->nb_streams, sizeof(AVDictionary *));
  create_streams_dict(streams_metadata, format_context);

  input->context = format_context;
  input->metadata = metadata;
  input->format_metadata = format_metadata;
  input->nb_streams = format_context->nb_streams;
  input->streams_metadata = streams_metadata;  

  return input;
}

Como explicado anteriormente, o código é baseado num exemplo oficial. A maior diferença é que estamos obtendo algumas informações da biblioteca e passando-as para um tipo de nosso domínio que é bem mais simplificado. Isso é feito porque os tipos da biblioteca do FFmpeg possuem macros e condicionais nas declarações, tornando a declaração no Haskell mais complexa e necessitando de ferramentas externas no fluxo de compilação.

A função create_format_dict chamada no meio do código é apenas uma função auxiliar que preenche o dicionário com alguns atributos básicos do formato.

ffmpeg/ffmpeg.c
void create_format_dict(AVDictionary ** dict, const AVInputFormat * iformat) {
  av_dict_set(dict, "name", iformat->name, AV_DICT_DONT_OVERWRITE);
  av_dict_set(dict, "long_name", iformat->long_name, AV_DICT_DONT_OVERWRITE);
  av_dict_set(dict, "extensions", iformat->extensions, AV_DICT_DONT_OVERWRITE);
  av_dict_set(dict, "mime_type", iformat->mime_type, AV_DICT_DONT_OVERWRITE);
}

De modo similar, as funções create_streams_dict e create_file_dict criam dicionários que conterão metadados das streams e do arquivo, respectivamente. O código delas foi omitido por simplificação, mas está disponível no repositório no GitHub do tutorial.

Não é necessário alocar a memória para o tipo AvDictionary explicitamente, pois o FFmpeg já faz isso por padrão na primeira chamada da função av_dict_set, segundo a documentação.

pm — Pointer to a pointer to a dictionary struct. If *pm is NULL a dictionary struct is allocated and put in *pm.

Liberando a memória

A função de liberação de memória é bem simples e concisa.

ffmpeg/ffmpeg.c
void free_input(FfmpegInput * input) {
  avformat_free_context(input->context);
  av_dict_free(&input->format_metadata);
  av_dict_free(&input->metadata);

  for (int i = 0; i < input->nb_streams; i++) {
    av_dict_free(&input->streams_metadata[i]);
  }

  free(input);
}

Aqui são utilizados duas funções específicas do FFmpeg para a liberação da memória tanto do contexto quanto do dicionário. A biblioteca lida com todos os campos internos de ambas as structs. Por fim, é necessário liberar o espaço de memória da nossa struct também.

Compilando a biblioteca intermediária

Idêntico ao primeiro exemplo, vamos criar uma biblioteca libffmpeg.so para ser acessada no nosso código em Haskell.

$ gcc -c -Wall -Werror -fpic -lavformat -lavutil -lavcodec ffmpeg.c
$ gcc -shared -o ../libs/libffmpeg.so ffmpeg.o

Também é necessário incluir a biblioteca nas flags do GHC.

c-interop.cabal
ghc-options: -- Demais flags omitidas
             -L./libs    
             -I./ffmpeg  
             -lffmpeg    
             -lavutil    
             -lavformat  
             -lavcodec   

Instalando algumas dependências externas do Hackage

Para este segundo exemplo precisaremos usar duas bibliotecas externas disponíveis no Hackage. Para definir a dependência, precisamos primeiramente informar ao stack sobre elas, editando o arquivo de metadados do stack do nosso projeto.

stack.yaml
extra-deps:
  - c-storable-deriving-0.1.3
  - pretty-terminal-0.1.0.0

Tendo informado ao stack, podemos declarar a dependência ao cabal também.

c-interop.cabal
build-depends: base >= 4.7 && < 5,
               containers,          
               c-storable-deriving, 
               pretty-terminal      

A biblioteca c-storable-deriving nos ajudará a criar a conversão dos tipos do C e Haskell mais facilmente no código final, enquanto a pretty-terminal irá ajudar a deixar a saída no terminal mais elegante com algumas cores. O containers por sua vez é declarado por conta do tipo Map, que será utilizado para acessar os dicionários do FFmpeg mais facilmente após uma conversão.

O módulo FFmpeg no Haskell

Neste módulo iremos utilizar quatro diretivas de linguagem. Além das já utilizadas anteriormente ForeignFunctionInterface e CApiFFI, precisaremos de duas relacionadas a Generics: DeriveGeneric e DeriveAnyClass.

src/FFmpeg.hs
{-# LANGUAGE ForeignFunctionInterface, CApiFFI #-}
{-# LANGUAGE DeriveGeneric, DeriveAnyClass #-}
module FFmpeg where

import Control.Monad (forM_, join)
import Data.Maybe
import Foreign
import Foreign.C.Types
import Foreign.C (CString, withCString, peekCString)
import Foreign.CStorable (CStorable(..))
import GHC.Generics
import Data.Map (Map)
import qualified Data.Map as Map

import PrettyPrint

Definindo os tipos da biblioteca intermediária no Haskell

Iremos começar replicando os tipos próprios do FFmpeg, mais especificamente os dicionários.

O tipo AVDictionary de libavutil/dict.h é uma struct vazia, como pode-se observar no código da biblioteca no trecho abaixo.

libavutil/dict.h
typedef struct AVDictionary AVDictionary;

Como a struct não tem campos, podemos representá-la no Haskell como um ponteiro para void no C (void *). Tal representação é feita usando o tipo Ptr (), que vem da biblioteca Foreign.

src/FFmpeg.hs
type AvDictionary = Ptr ()

O tipo AVDictionaryEntry de libavutil/dict.h por sua vez, é uma struct simples com apenas dois campos: key e value, ambos do tipo char *. Por possuir dois campos, podemos representar esta struct com um record semelhante.

src/FFmpeg.hs
data AvDictionaryEntry = AvDictionaryEntry
  { avDictKey :: CString
  , avDictValue :: CString
  }

Para poder fazer a conversão entre as linguagens, as estruturas de dados precisarão providenciar uma instância da classe Storable. Essa instância pode ser implementada explicitamente, mas para poder implementar, é necessário ter conhecimento nos tamanhos dos tipos e de seus paddings, caso existam, nas estruturas do C, que podem ser consultados na tabela abaixo.

TipoArquiteturaTamanho (byte)Padding (byte)Total (byte)
char-134
int-16016
long-32032
float-404
double-808
void *32 bits404
void *64 bits808

Simplificando bastante e considerando uma arquitetura de 32 bits, o padding é usado em estruturas no C para alinhar seus membros internos aos limites de endereço naturais, ou seja, fazendo com que os endereços na memória de cada membro seja um múltiplo de 4. O padding é adicionado por padrão pelo compilador do C, mas pode ser desativado.

Esta pergunta no StackOverflow possui algumas explicações interessantes sobre como o padding e packing funcionam na linguagem C.

Tendo essas informações em mente, podemos implementar nossa própria instância de Storable para nosso tipo AvDictionaryEntry.

src/FFmpeg.hs
instance Storable AvDictionaryEntry where
  alignment _ = 8
  sizeOf _ = 16
  peek ptr = AvDictionaryEntry
    <$> peekByteOff ptr 0
    <*> peekByteOff ptr 8
  poke ptr (AvDictionaryEntry k v) = do
    pokeByteOff ptr 0 k
    pokeByteOff ptr 8 v

Explicando detalhadamente cada uma das funções de Storable, temos:

  1. alignment deve retornar o alinhamento da estrutura. É representado como o mínimo múltiplo comum entre todos os alinhamentos de seus tipos internos. Como na arquitetura 64 bits temos que um ponteiro representa 8 bytes, e o nosso tipo contém duas variáveis do tipo ponteiro, temos mmc(8, 8) = 8.
  2. sizeOf deve retornar o tamanho total em bytes ocupado pela estrutura. Como temos dois ponteiros, temos o total de 16 bytes.
  3. peek é a função que fará a leitura dos dados do ponteiro ptr e irá criar a instância do tipo do Haskell. Para tal feito, podemos utilizar a função peekByteOff, onde também é necessário informar o offset inicial da leitura. Esse offset é calculado usando o tamanho do tipo anterior na estrutura e seu possível padding (como acontece em variáveis do tipo char).
  4. poke é a função que irá escrever os dados no endereço de memória armazenado no ponteiro. De maneira similar ao peek, usamos a função pokeByteOff também passando o offset e seguindo a ordem da declaração do tipo.

Apesar da implementação de Storable para nosso tipo ser relativamente fácil e direta, ainda assim ela requer do programador um conhecimento prévio nos tipos do C para saber seus tamanhos, e ainda apresenta algumas dificuldades específicas como ter que lidar com o tamanho dos ponteiros caso esteja usando uma arquitetura diferente. Para evitar possíveis implementações erradas caso algum cálculo esteja incorreto, podemos utilizar a biblioteca c-storable-deriving.

Para utilizar tal biblioteca, faremos com que o nosso tipo derive os tipos Generic e CStorable.

src/FFmpeg.hs
data AvDictionaryEntry = AvDictionaryEntry
  { avDictKey :: CString
  , avDictValue :: CString
  }                               
  } deriving (Generic, CStorable) 

É necessário que nosso tipo seja totalmente compatível com CStorable, ou seja, os tipos internos precisam ter uma implementação de Storable também, por isso utilizamos os tipos do C CString no lugar de String do Haskell. Agora, podemos simplificar a implementação de Storable usando funções da biblioteca.

src/FFmpeg.hs
instance Storable AvDictionaryEntry where
  alignment _ = 8
  alignment = cAlignment                
  sizeOf _ = 16
  sizeOf = cSizeOf                      
  peek ptr = AvDictionaryEntry
    <$> peekByteOff ptr 0
    <*> peekByteOff ptr 8
  peek = cPeek                          
  poke ptr (AvDictionaryEntry k v) = do
    pokeByteOff ptr 0 k                 
    pokeByteOff ptr 8 v                 
  poke = cPoke                          

As funções funcionam da exata mesma maneira da nossa implementação explicita anterior, mas será criada automaticamente em tempo de compilação, além de prevenir erros de cálculo e não mais depender do tamanho dos tipos do C.

A documentação da biblioteca pode ser encontrada no Hackage.

De modo similar, podemos agora definir o tipo que representará a struct FfmpegInput, já usando a implementação com a biblioteca c-storable-deriving.

src/FFmpeg.hs
data FfmpegInput = FfmpegInput
  { ffMetadata :: AvDictionary
  , ffFormat :: AvDictionary
  , ffStreamsMetadata :: Ptr AvDictionary
  , ffStreamsNb :: CInt
  , ffContext :: Ptr ()
  } deriving (Generic, CStorable)

instance Storable FfmpegInput where
  peek = cPeek
  poke = cPoke
  alignment = cAlignment
  sizeOf = cSizeOf

Importando as funções externas do C

No nosso código em Haskell utilizaremos as duas funções expostas pela nossa biblioteca intermediária, bem como a av_dict_get para poder ler os valores nos dicionários.

src/FFmpeg.hs
foreign import capi "ffmpeg.h load_input"
  c_loadInput :: CString -> IO (Ptr FfmpegInput)

foreign import capi "ffmpeg.h &free_input"
  c_freeInput :: FunPtr (Ptr FfmpegInput -> IO ())

foreign import capi "libavutil/dict.h av_dict_get"
  c_avDictGet :: AvDictionary -> CString -> Ptr AvDictionaryEntry -> CInt -> IO (Ptr AvDictionaryEntry)

foreign import capi "libavutil/dict.h value AV_DICT_IGNORE_SUFFIX"
  c_avDictIgnoreSuffix :: CInt

Note que na declaração de tipos sempre utilizamos os tipos do C que vem de Foreign.C.Types para representar a diferença com os tipos do Haskell. Também são utilizados ponteiros para representar as funções exatamente como são declaradas no C.

Aqui cabe uma atenção especial a função c_freeInput. Ela é importada e declarada de uma maneira diferente das outras porque será utilizada em conjunto posteriormente com o tipo ForeignPtr. O Haskell é conhecido por ter um ótimo compilador e fazer um bom uso da memória, entretanto se tratando de ponteiros externos, a linguagem não tem como saber quando é necessário liberar a memória dessas estruturas, cabendo a nós liberarmos a memória manualmente. Porém, o Haskell nos disponibiliza o ForeignPtr justamente para essas situações: são ponteiros especiais que já liberam a memória automaticamente quando não há mais nenhum uso da variável, similar ao que acontece com classes que implementam o Closeable no Java.

A última importação, por sua vez, utiliza o modificador especial value, que permite que sejam importados valores no lugar de funções, sejam esses uma variável ou até mesmo uma constante definida com um macro #define no compilador do C.

Criando funções auxiliares no Haskell

Para evitar ter que usar as funções importadas do C diretamente, podemos criar algumas funções auxiliares para evitar ter que ficar usando ponteiros de tipos primitivos nos outros módulos.

A primeira função que iremos criar é a loadInput, que será um wrapper ao redor de c_loadInput do C.

src/FFmpeg.hs
loadInput :: String -> IO (Maybe (ForeignPtr FfmpegInput))
loadInput file = do
  inputPtr <- withCString file $ \c_file -> c_loadInput c_file
  foreignPtr <- newForeignPtr c_freeInput inputPtr
  return $ if inputPtr == nullPtr then Nothing else Just foreignPtr

Observe que a partir de agora praticamente todas as operações estarão dentro do escopo de IO, visto que a maioria das funções de leitura e escrita de ponteiros opera neste escopo.

Como a função c_loadInput depende de uma CString, é necessário alocar uma. A biblioteca Foreign oferece uma função auxiliar chamada withCString que permite alocar uma CString temporária que será liberada da memória no fim de seu uso. Usando-a, podemos chamar a c_loadInput com o file convertido de [Char] do Haskell para CString.

Tendo um ponteiro comum, transformamos-o em um ForeignPtr, informando que a função c_freeInput que será usada quando a memória poder ser liberada. No fim, verificamos se a chamada retornou um ponteiro não-nulo, e se for o caso podemos tirar vantagem do Maybe para evitar ter que ficar fazendo comparações diretas com nullPtr, o equivalente do NULL do C.

A segunda função auxiliar que iremos criar é um wrapper ao redor de c_avDictGet.

src/FFmpeg.hs
dictGet :: AvDictionary -> Ptr AvDictionaryEntry -> IO (Ptr AvDictionaryEntry)
dictGet dict previous = withCString "" $ \c_str ->
  c_avDictGet dict c_str previous avDictIgnoreSuffix

De maneira similar a loadInput, utilizamos novamente a withCString.

A última função auxiliar faltando é a de conversão do tipo AvDictionary para um Map String String do Haskell, que permitirá um uso mais fácil dessa estrutura de dados. Essa função, chamada de dictToMap, será dividida em duas: a função simples e a recursiva. A simples será a “porta de entrada” que começará a recursividade.

No exemplo oficial do FFmpeg, para poder ler um AVDictionary, é necessário usar um while.

examples/show_metadata.c
const AVDictionaryEntry * tag = NULL;

while ((tag = av_dict_get(fmt_ctx->metadata, "", tag, AV_DICT_IGNORE_SUFFIX))) {
  printf("%s=%s\n", tag->key, tag->value);
}

Para simular este comportamento e iterar sobre todos os itens do dicionário no Haskell, será necessário usar o paradigma funcional e, portanto, utilizar recursão.

src/FFmpeg.hs
dictToMap' :: AvDictionary -> Map String String -> Ptr AvDictionaryEntry -> IO (Map String String)
dictToMap' dict mp tagPtr | tagPtr == nullPtr = pure mp
                          | otherwise         = nextCall
  where
    key = peek tagPtr >>= \t -> peekCString $ avDictKey t
    value = peek tagPtr >>= \t -> peekCString $ avDictValue t
    newMap = liftA2 (\k v -> Map.insert k v mp) key value
    nextTag = dictGet dict tagPtr
    nextCall = join $ liftA2 (dictToMap' dict) newMap nextTag

A ideia dessa função é ir iterando sobre todas as entradas no dicionário, adicionando-as num mapa até chegar na última entrada. Similar ao while, temos como condição de parada da recursão o ponteiro para a próxima entrada ser nulo, então é necessário comparar tagPtr com nullPtr.

Aqui começamos o uso das funções de leituras de ponteiros, utilizamos peek para poder ler o valor de tagPtr, que nos colocará no contexto de IO. Também é necessário, em seguida, utilizar a peekCString para ler o valor da CString tanto de key quando de value. No final, teremos ambos como IO String.

Como ambas as strings estão no contexto IO, precisamos chamar a função Map.insert dentro deste contexto. Para isso, podemos usar a função liftA2 que permite fazer o unbox de duas variáveis no mesmo contexto, aplicar uma função com os valores internos e então retornar ao contexto anterior. No final, teremos um IO (Map String String). Por baixo dos panos, a função liftA2 está fazendo a mesma coisa do código abaixo.

newMap = do
  k <- key
  v <- value
  return $ Map.insert k v

Agora podemos fazer a próxima chamada recursiva, mas como temos ambos o newMap e nextTag no contexto IO, precisamos usar o join em conjunto com o liftA2 para poder acessar os valores de ambas para aí sim poder chamar a própria função novamente. O join neste caso irá extrair uma “camada” do IO do tipo, transformando um IO (IO ()) em IO (), por exemplo.

Com a recursividade pronta, podemos criar a função simples de entrada.

src/FFmpeg.hs
dictToMap :: AvDictionary -> IO (Map String String)
dictToMap dict = do
  firstTag <- dictGet dict nullPtr
  dictToMap' dict Map.empty firstTag

Nesta função, precisamos obter a primeira entrada para iniciar a recursividade corretamente, caso contrário, estaríamos passando um nullPtr diretamente e a recursão nem iria começar. Agora com esta função, podemos converter um AvDictionary para Map String String, permitindo um acesso mais fácil no nosso código Haskell.

A última função que iremos criar neste módulo é a responsável por enfim imprimir os metadados. Ela será chamada no módulo Main posteriormente. Para deixar a saída no terminal um pouco mais bonita, duas funções são usadas: prettyTitle e prettyTitle. O código dessas duas funções se encontra no módulo PrettyPrint e está disponível no repositório do GitHub deste tutorial. Para fins de simplificação, optei por não incluir a implementação aqui, mas o importante de saber sobre elas é que, como o nome sugere, uma irá imprimir um título de modo “bonito” e o outro imprimirá todas as entradas do Map String String de modo “bonito” também.

A lógica dessa função segue o seguinte algoritmo:

  1. Obter o nome do arquivo da linha de comando;
  2. Ler o arquivo usando o FFmpeg;
  3. Obter os metadados do arquivo e do formato;
  4. Imprimir ambos de formato “bonito”.
src/FFmpeg.hs
printMetadata :: String -> IO ()
printMetadata fileName = do
  inputPtr' <- loadInput fileName
  let inputForeignPtr = fromJust inputPtr'

  withForeignPtr inputForeignPtr $ \inputPtr -> do
    input <- peek inputPtr  
    metadata <- dictToMap $ ffMetadata input
    formatMetadata <- dictToMap $ ffFormat input

    let nbStreams = fromIntegral . toInteger $ ffStreamsNb input
        streamsMetadataPtr = ffStreamsMetadata input

    streamsMetadata <- peekArray nbStreams streamsMetadataPtr

    prettyTitle "File metadata"
    prettyPrint metadata
    putStrLn ""

    prettyTitle "Format metadata"
    prettyPrint formatMetadata
    putStrLn ""

    forM_ (zip [0..nbStreams-1] streamsMetadata) $ \(i, sMetadataPtr) -> do
      sMetadata <- dictToMap sMetadataPtr

      prettyTitle $ "Stream #" ++ show i
      prettyPrint sMetadata
      putStrLn ""

Para fazer aproveitar da vantagem do ForeignPtr, utilizamos a função withForeignPtr, que nos permite ter acesso a um ponteiro Ptr comum, o qual podemos acessar e/ou modificar seus conteúdos. Toda a lógica que acessa os dados do nosso tipo FFmpegInput é feita dentro dessa função anônima que é passada para a função.

Para iterar no vetor de ponteiros que representa o vetor dos dicionários das streams, precisamos primeiramente converter o tipo Ptr [AvDictionary] (que é igual a Ptr [Ptr ()]) para uma lista do Haskell, para poder iterar posteriormente, obtendo um [AvDictionary]. Tendo esta lista de ponteiros, podemos iterar usando o forM_, assim imprimindo os metadados de cada stream.

Por conta das funções auxiliares no módulo FFmpeg, nosso código ficou muito mais legível e fácil de entender mesmo para quem não tem tanta familiaridade com as minúcias do C.

Criando o arquivo principal

Com o módulo FFmpeg pronto, podemos finalmente editar o nosso arquivo Main.hs. Iremos ler o caminho do arquivo da linha do comando do usuário.

src/Main.hs
module Main (main) where

import System.Environment
import FFmpeg
import Vlc

main :: IO ()
main = do
  args <- getArgs

  let fileName = head args
  printMetadata fileName

A implementação é bem direta ao ponto, obtemos os argumentos da linha de comando assumindo que o primeiro deles é um caminho válido para um arquivo existente. Tendo este caminho, basta utilizá-lo na chamada da função printMetadata. Note que por conta das funções auxiliares do módulo FFmpeg, não precisamos interagir com nada relacionado a linguagem C no módulo Main.

Compilando e executando

Com nosso código pronto, podemos compilar e executar de maneira similar ao primeiro exemplo.

$ stack build
$ LD_LIBRARY_PATH=./libs stack exec c-interop big_buck_bunny.mp4

Ao observar a saída, vemos que o texto no terminal está colorido e também está seguindo a sintaxe de arquivos .ini, como pode-se observar abaixo na íntegra. O arquivo testado é o curta “Big Buck Bunny” da Blender Foundation, na versão “Sunflower”, disponível neste link para download gratuito no site oficial.

big_buck_bunny.ini
[File metadata]
artist = Blender Foundation 2008, Janus Bager Kristensen 2013
bit_rate = 3481058 bit/s
comment = Creative Commons Attribution 3.0 - http://bbb3d.renderfarming.net
compatible_brands = isomavc1
composer = Sacha Goedegebure
creation_time = 2013-12-16T17:44:39.000000Z
duration = 0:10:34.600000
genre = Animation
major_brand = isom
minor_version = 1
streams_nb = 3
title = Big Buck Bunny, Sunflower version

[Format metadata]
extensions = mov,mp4,m4a,3gp,3g2,mj2,psp,m4b,ism,ismv,isma,f4v,avif
long_name = QuickTime / MOV
name = mov,mp4,m4a,3gp,3g2,mj2

[Stream #0]
codec_long_name = H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10
codec_name = h264
codec_tag_string = avc1
creation_time = 2013-12-16T17:44:39.000000Z
duration = 0:10:34.533333
frame_rate = 30/1
handler_name = GPAC ISO Video Handler
language = und
resolution = 1920 x 1080
start_time = 0:00:00.066667
time_base = 1/30000
type = video
vendor_id = [0][0][0][0]

[Stream #1]
channels = 2
codec_long_name = MP3 (MPEG audio layer 3)
codec_name = mp3
codec_tag_string = mp4a
creation_time = 2013-12-16T17:44:42.000000Z
duration = 0:10:34.200000
frame_rate = 0/0
handler_name = GPAC ISO Audio Handler
language = und
sample_rate = 48000 Hz
start_time = 0:00:00.000000
time_base = 1/48000
type = audio
vendor_id = [0][0][0][0]

[Stream #2]
channels = 6
codec_long_name = ATSC A/52A (AC-3)
codec_name = ac3
codec_tag_string = ac-3
creation_time = 2013-12-16T17:44:42.000000Z
duration = 0:10:34.144000
frame_rate = 0/0
handler_name = GPAC ISO Audio Handler
language = und
sample_rate = 48000 Hz
start_time = 0:00:00.000000
time_base = 1/48000
type = audio
vendor_id = [0][0][0][0]

O arquivo de entrada é lido corretamente e a saída é o esperado: é identificado que o vídeo é do formato mp4 e as informações do formato são apresentadas, bem como alguns metadados interessantes como artist e genre. Também são impressos os metadados individuais de cada stream no arquivo, que neste caso possui um vídeo e duas trilhas de áudio.

Reproduzindo o arquivo com a biblioteca do VLC

Para complementar o exemplo anterior, podemos usar a libvlc para reproduzir o começo do arquivo em uma janela separada após exibir os metadados. Este exemplo é mais simples visto que não é necessário usar nenhuma conversão de tipos entre as linguagens, e podemos usar a libvlc diretamente no código em Haskell com as importações corretas.

É necessário ter a libvlc instalada no sistema. Em distribuições como o Ubuntu, pode-se instalar usando o apt.

$ sudo apt install libvlc-dev

Este exemplo é baseado no código de exemplo oficial, disponível neste link.

Adicionando as flags de compilação

É necessário adicionar a flag -lvlc no arquivo Cabal do projeto.

c-interop.cabal
ghc-options: -- Demais flags omitidas
             -lvlc 

Criando o módulo

Como no exemplo do FFmpeg, vamos criar um módulo Vlc que fará as importações das funções de vlc/vlc.h para usarmos.

src/Vlc.hs
module Vlc where

import Control.Concurrent (threadDelay)
import Foreign
import Foreign.C.Types
import Foreign.C.String
import Foreign.C.ConstPtr

-- Cria uma nova instância do VLC
foreign import capi "vlc/vlc.h libvlc_new" c_vlcNew :: CInt -> Ptr (ConstPtr CString) -> IO (Ptr ())
-- Cria uma mídia a partir de um caminho
foreign import capi "vlc/vlc.h libvlc_media_new_path" c_vlcMediaNewPath
  :: Ptr () -> CString -> IO (Ptr ())
-- Cria uma instância de um player a partir de uma mídia
foreign import capi "vlc/vlc.h libvlc_media_player_new_from_media" c_vlcMediaPlayerNewFromMedia
  :: Ptr () -> IO (Ptr ())

-- Reproduz o arquivo no player
foreign import capi "vlc/vlc.h libvlc_media_player_play" c_vlcMediaPlayerPlay
  :: Ptr () -> IO ()
-- Pausa a reprodução do arquivo no player
foreign import capi "vlc/vlc.h libvlc_media_player_stop" c_vlcMediaPlayerStop
  :: Ptr () -> IO ()

-- Libera da memória a mídia
foreign import capi "vlc/vlc.h &libvlc_media_release" c_vlcMediaRelease
  :: FunPtr (Ptr () -> IO ())
-- Libera da memória o player
foreign import capi "vlc/vlc.h &libvlc_media_player_release" c_vlcMediaPlayerRelease
  :: FunPtr (Ptr () -> IO ())
-- Libera da memória a instância do VLC
foreign import capi "vlc/vlc.h &libvlc_release" c_vlcRelease
  :: FunPtr (Ptr () -> IO ())

Note que iremos apenas utilizar funções já disponíveis na biblioteca do VLC, e que também não precisaremos lidar com dados visto que a biblioteca utiliza structs vazias. O importante vai ser trabalhar com os ponteiros para void.

Criando as funções auxiliares

O módulo Vlc fará um uso maior de ForeignPtr por conta de ter 3 ponteiros diferentes para utilizar e liberar da memória. Para facilitar o uso, é mais fácil criar funções auxiliares que usam tipos do Haskell em sua maioria para chamar internamente as funções do C, um conceito também conhecido como binding.

src/Vlc.hs
vlcNew :: CInt -> Ptr (ConstPtr CString) -> IO (ForeignPtr ())
vlcNew argc argv = c_vlcNew argc argv >>= newForeignPtr c_vlcRelease

vlcMediaNewPath :: ForeignPtr () -> String -> IO (ForeignPtr ())
vlcMediaNewPath inst file = 
  withForeignPtr inst (withCString file . c_vlcMediaNewPath) 
    >>= newForeignPtr c_vlcMediaRelease

vlcMediaPlayerNewFromMedia :: ForeignPtr () -> IO (ForeignPtr ())
vlcMediaPlayerNewFromMedia media = 
  withForeignPtr media c_vlcMediaPlayerNewFromMedia 
    >>= newForeignPtr c_vlcMediaPlayerRelease

vlcMediaPlayerPlay :: ForeignPtr () -> IO ()
vlcMediaPlayerPlay player = withForeignPtr player c_vlcMediaPlayerPlay

vlcMediaPlayerStop :: ForeignPtr () -> IO ()
vlcMediaPlayerStop player = withForeignPtr player c_vlcMediaPlayerStop

Assim como no módulo FFmpeg, utilizaremos as funções withForeignPtr para ter acesso a um ponteiro comum e newForeignPtr para transformar os ponteiros comuns resultantes das chamadas das funções do C para um ponteiro externo.

Criando a função de reprodução no Haskell

Nosso módulo Vlc terá apenas uma função em Haskell, a responsável por reproduzir o arquivo por um certo tempo e então liberar as instâncias da memória.

src/Vlc.h
playFile :: String -> IO ()
playFile fileName = do
  inst <- vlcNew 0 nullPtr
  m <- vlcMediaNewPath inst fileName
  mp <- vlcMediaPlayerNewFromMedia m

  vlcMediaPlayerPlay mp
  threadDelay $ 10 * 1000000
  vlcMediaPlayerStop mp

  return ()

Note que o código é bem semelhante a estrutura do exemplo em C, com excessão que não precisamos nos preocupar com o gerenciamento e liberação da memória dos ponteiros. As mesmas funções são chamadas e na mesma ordem, com pequenas diferenças entre as linguagens tais como o uso de threadDelay no lugar do sleep do C.

Usando o módulo no módulo principal

Agora podemos chamar a função playFile logo em seguida da impressão dos metadados.

src/Main.hs
main :: IO ()
main = do
  args <- getArgs
  
  let fileName = head args
  printMetadata fileName
  playFile fileName 

Basta adicionar a chamada de playFile logo após a impressão dos metadados.

Compilando e executando

Usaremos os mesmos comandos do stack para compilar e executar.

$ stack build
$ LD_LIBRARY_PATH=./libs stack exec c-interop big_buck_bunny.mp4

Ao executar, pode-se observar que uma janela gráfica com o vídeo é aberta e a reprodução começa imediatamente. Após 10 segundos, o vídeo é fechado e encerrado.

Trecho do curta “Big Buck Bunny” da Blender Foundation na janela do VLC.
Trecho do curta “Big Buck Bunny” da Blender Foundation na janela do VLC.

Conclusão

Utilizar a interoperabilidade pode ser vantajoso em casos onde é necessário performance ou se deseja utilizar uma biblioteca em código nativo já compilada e/ou disponível no sistema operacional. Através dos recursos do Haskell, torna-se fácil ter o acesso a tais funções e dados retornados visto que a linguagem já possui tal recurso implementado em seu núcleo. Apesar do código necessitar em casos mais avançados de um conhecimento intermediário em gerenciamento de memória em C, ainda assim é possível para usuários mais leigos fazer uso de funções mais simples para criar um conhecimento mais aprofundado posteriormente.

Bibliografia

  • WIKIPÉDIA. “Language interoperability”. Wikipédia, disponível neste link.
  • O'SULLIVAN, Bryan et al. “Interfacing with C: the FFI”. Real World Haskell, disponível neste link.
  • HASKELL. Foreign Function Interface. Haskell Wiki, disponível neste link.
  • MOREIRA, Leandro. FFmpeg libavformat tutorial. GitHub, disponível neste link.
  • VLC. LibVLC Tutorial. VLC Wiki, disponível neste link.
  • UFABC. Grupo de estudos em Haskell da UFABC. Pesquisa UFABC, disponível neste link.