Transformando a v1 em uma biblioteca multiplataforma

Transformando a v1 em uma biblioteca multiplataforma

Olá leitor, seja muito bem vindo de volta a mais uma etapa da nossa jornada aqui no Portal da Micilini! 😊

Este é o vigésimo sexto artigo da nossa série, e ele funciona como uma ponte entre o julgamento do artigo 25 e o começo da Fase 5.

No artigo passado nós finalmente colocamos o N.148i diante do JPEG e do JPEG XL, medimos compressão, qualidade e velocidade em 120 imagens, então registramos com honestidade onde ele venceu e onde perdeu.

Agora a v1 está completa e medida, porém ainda falta uma coisa importante: permitir que outra pessoa use o codec sem copiar os arquivos de src/ e descobrir por conta própria qual função chamar.

A autoria permanece com Micilini Roll e todo o código novo continua sob a licença MIT, exatamente como o restante do projeto.

A diferença entre um programa e uma biblioteca

Até aqui o N.148i era principalmente um programa de linha de comando, ele abria um PPM, comprimia a imagem, gravava um arquivo, abria esse arquivo e reconstruía outro PPM.

Isso é ótimo para aprender e testar, mas um editor, um jogo ou um servidor já possui os pixels na memória, então obrigar esse projeto a criar arquivos temporários seria desperdício e acoplamento.

/* Antes, quem quisesse integrar o codec precisava conhecer os internos. */
#include "encoder.h"
#include "decoder.h"
#include "huffman.h"
#include "ppm.h"

Uma biblioteca coloca uma fronteira no meio desse cenário: do lado de fora existe uma interface pequena e estável, do lado de dentro continuam todos os detalhes que podem ser reorganizados sem quebrar os usuários.

Sendo assim, a nossa missão neste artigo é transformar o N.148i em uma Biblioteca na qual outras pessoas ou sistemas possam testá-lo de maneira mais fácil.

A necessidade de criar um único cabeçalho público

No nosso caso, criamos o arquivo include/n148i.h, e ele é o único cabeçalho que uma aplicação precisa incluir, enquanto dct.h, huffman.h, ppm.h e os demais continuam privados.

#include <stddef.h>
#include <stdint.h>

#define N148I_VERSION_MAJOR 1
#define N148I_VERSION_MINOR 0
#define N148I_VERSION_PATCH 0
#define N148I_VERSION_STRING "1.0.0"
#define N148I_FORMAT_VERSION 1

#ifdef __cplusplus
extern "C" {
#endif

/* Toda a API pública fica aqui. */

#ifdef __cplusplus
}
#endif

A proteção com extern "C" impede que um compilador C++ altere os nomes durante a ligação, sendo assim o mesmo cabeçalho funciona em projetos C, quanto também em C++.

O prefixo que evita colisões

Nomes como Image, Plane e encode_image pareciam inofensivos dentro de um executável, porém se tornam perigosos quando várias bibliotecas chegam ao mesmo ligador.

/* Um nome genérico pode existir em outro projeto. */
encode_image(...);
decode_image(...);

/* A interface pública deixa clara a origem de cada símbolo. */
n148i_encode_memory(...);
n148i_decode_memory(...);
n148i_image_t image;
N148I_CHROMA_420;

Uma colisão de símbolos acontece quando dois objetos oferecem o mesmo nome global, e o resultado pode ser um erro de ligação ou, no pior caso, uma função diferente sendo resolvida sem que o código deixe isso evidente.

Exportar somente o que é público

O prefixo resolve a identidade da API, mas ainda precisamos impedir que centenas de funções internas apareçam na tabela pública da biblioteca compartilhada.

#if defined(N148I_STATIC_DEFINE)
#define N148I_API
#elif defined(_WIN32) || defined(__CYGWIN__)
#if defined(N148I_BUILDING_LIBRARY)
#define N148I_API __declspec(dllexport)
#else
#define N148I_API __declspec(dllimport)
#endif
#elif defined(__GNUC__) || defined(__clang__)
#define N148I_API __attribute__((visibility("default")))
#else
#define N148I_API
#endif

No GCC e no Clang nós compilamos com -fvisibility=hidden e marcamos somente a API com visibilidade padrão, enquanto no Windows a mesma macro alterna entre exportar ao construir a DLL e importar ao consumi-la.

$ nm -D --defined-only libn148i.so.1.0.0
n148i_encode_memory
n148i_decode_memory
n148i_read_header
n148i_free_buffer
n148i_free_image
... somente os doze símbolos públicos n148i_*

Além da visibilidade oculta, o Linux ganhou um script de versão que coloca a ABI pública no nó N148I_1.0, e nenhum símbolo interno sem prefixo apareceu na inspeção final.

A API vai da memória para a memória

A interface recebe RGB de 8 bits que já está na memória e devolve o fluxo comprimido também na memória, sendo assim ela serve para uma textura, uma resposta de rede, um arquivo mapeado ou qualquer outra origem.

  • n148i_encode_memory() recebe pixels e cria o fluxo completo da v1.
  • n148i_decode_memory() recebe o fluxo e cria pixels RGB.
  • n148i_read_header() informa dimensões e opções sem reconstruir os pixels.
  • n148i_free_buffer() e n148i_free_image() liberam tudo que a biblioteca alocou.
typedef struct n148i_image {
    uint8_t *pixels;
    uint32_t width;
    uint32_t height;
    size_t stride;
} n148i_image_t;

typedef struct n148i_encode_options {
    size_t struct_size;
    int quality;
    n148i_chroma_t chroma;
    int optimize_huffman;
    int thread_count;
} n148i_encode_options_t;

O stride é a quantidade de bytes entre o começo de duas linhas, por isso a biblioteca aceita tanto RGB compacto quanto linhas com padding sem obrigar o chamador a reorganizar sua imagem.

Opções preparadas para crescer

Em vez de pedir que o usuário preencha cada campo manualmente, a API possui uma função de inicialização, pois uma futura atualização compatível pode acrescentar opções e ainda fornecer valores padrão seguros.

n148i_encode_options_t options;
n148i_encode_options_init(&options);

/* Padrões da v1: qualidade 50, 4:2:0 e Huffman otimizado. */
options.quality = 75;
options.thread_count = 1;

O campo struct_size informa quanto da estrutura o programa conhece, então uma biblioteca mais nova consegue ler somente a parte que existe no chamador e completar o restante com os padrões.

Erros que podem ser tratados

Retornar apenas verdadeiro ou falso esconderia a diferença entre um argumento ruim, um arquivo truncado e falta de memória, então cada função principal devolve um valor de n148i_result_t.

typedef enum n148i_result {
    N148I_OK = 0,
    N148I_ERROR_INVALID_ARGUMENT = 1,
    N148I_ERROR_OUT_OF_MEMORY = 2,
    N148I_ERROR_INVALID_FORMAT = 3,
    N148I_ERROR_UNSUPPORTED_VERSION = 4,
    N148I_ERROR_TRUNCATED_DATA = 5,
    N148I_ERROR_CORRUPT_DATA = 6,
    N148I_ERROR_SIZE_OVERFLOW = 7,
    N148I_ERROR_ENCODE_FAILED = 8,
    N148I_ERROR_DECODE_FAILED = 9,
    N148I_ERROR_UNSUPPORTED_SIMD = 10
} n148i_result_t;

A função n148i_result_string() traduz qualquer um desses códigos para uma mensagem estática, mas a decisão continua nas mãos do programa que chamou a biblioteca.

Quem aloca também precisa liberar

Todo buffer criado pelo N.148i possui uma função de liberação correspondente no próprio N.148i, uma regra simples que evita um erro clássico do Windows.

Um executável e uma DLL podem usar heaps diferentes, então alocar dentro da DLL e chamar free() diretamente no executável pode corromper memória mesmo que os dois lados tenham sido escritos em C.

uint8_t *encoded = NULL;
size_t encoded_size = 0;
n148i_image_t decoded = {0};

/* ... encode e decode ... */

n148i_free_image(&decoded);
n148i_free_buffer(encoded);

Essa regra também permite que a implementação troque seu alocador no futuro sem empurrar essa escolha para todas as aplicações.

Versão da biblioteca e versão do formato

A biblioteca é 1.0.0 e o formato de arquivo é 1, porém esses números respondem perguntas diferentes: as macros dizem contra qual cabeçalho compilamos, enquanto a função informa qual binário foi carregado.

printf("compilado com %d.%d.%d\n",
       N148I_VERSION_MAJOR,
       N148I_VERSION_MINOR,
       N148I_VERSION_PATCH);
printf("carregado: %s\n", n148i_library_version());
printf("formato: %u\n", n148i_format_version());

Uma correção interna pode atualizar a biblioteca sem alterar um único byte do arquivo, enquanto qualquer mudança incompatível no fluxo precisa declarar uma nova versão do formato.

SIMD, threads e o limite de concorrência

A detecção e o forçamento de SIMD que já existiam ganharam nomes públicos, permitindo consultar o caminho ativo ou fixar o escalar para testes.

n148i_simd_level_t active = n148i_simd_level();
printf("SIMD: %s\n", n148i_simd_name(active));

n148i_result_t result = n148i_simd_force(N148I_SIMD_SCALAR);
/* N148I_SIMD_AUTO devolve o controle para a detecção da CPU. */

Aqui apareceu uma limitação que precisava ser escrita com todas as letras: a v1 ainda compartilha a configuração de threads, o estado de dispatch, os histogramas do encoder e um pool persistente.

Consultas de versão, mensagens de erro, leitura de cabeçalho e liberações podem ocorrer ao mesmo tempo, porém encode e decode exigem sincronização externa se duas threads da aplicação quiserem chamar o codec simultaneamente.

No Windows escolhemos o fallback funcional sem threads, já que o pool atual usa pthreads, e o README não promete um paralelismo que essa plataforma ainda não possui.

Estática, compartilhada, DLL e EXE

O mesmo código agora gera uma biblioteca estática e uma compartilhada em cada sistema, mas os nomes mudam conforme a convenção da plataforma.

  • No Linux: libn148i.a e libn148i.so com links de versão.
  • No macOS: libn148i.a e libn148i.dylib.
  • No Windows: n148i.lib estática, ou n148i.dll acompanhada da biblioteca de importação n148i.lib.

Na ligação estática o código necessário entra no executável, o que facilita a distribuição mas exige religar cada programa para receber uma correção.

Na ligação compartilhada o código permanece num arquivo separado que vários programas podem carregar, desde que a ABI continue compatível.

Uma DLL não é um EXE: o .exe é o programa que inicia, enquanto o .dll oferece funções para esse programa carregar, e a biblioteca de importação ajuda o ligador a encontrá-las.

Por que o CMake entrou ao lado do Makefile

O Makefile continua sendo o caminho rápido no Linux e preserva todos os alvos usados na série, porém ele não conhece Visual Studio, frameworks do macOS nem regras portáveis de instalação.

add_library(n148i_static STATIC)
add_library(n148i_shared SHARED)

set_target_properties(n148i_shared PROPERTIES
    OUTPUT_NAME n148i
    VERSION 1.0.0
    SOVERSION 1
    C_VISIBILITY_PRESET hidden)

add_library(n148i::n148i ALIAS n148i_shared)
add_library(n148i::n148i_static ALIAS n148i_static)

O CMake descreve os alvos e deixa cada plataforma gerar seu projeto nativo, além de criar regras de instalação, pacote para find_package() e arquivo n148i.pc.

# O fluxo antigo continua válido e agora liga libn148i.a.
make
make bench
make compare
make validate

# As duas formas locais da biblioteca.
make libraries

Construindo no Linux

cmake -S . -B cmake-build-linux \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_INSTALL_PREFIX="$PWD/builds/linux-x86_64"
cmake --build cmake-build-linux --parallel
ctest --test-dir cmake-build-linux --output-on-failure
cmake --install cmake-build-linux

Esse foi o pacote realmente produzido nesta máquina, contendo a .so versionada, a .a, o cabeçalho, a licença, o executável e os arquivos de descoberta.

Construindo no Windows

Num Developer PowerShell do Visual Studio, o gerador nativo cria a DLL, a biblioteca de importação e a versão estática em diretórios separados.

cmake -S . -B cmake-build-windows `
  -G "Visual Studio 17 2022" -A x64 `
  -DCMAKE_INSTALL_PREFIX="$PWD/builds/windows-x86_64"
cmake --build cmake-build-windows --config Release --parallel
ctest --test-dir cmake-build-windows -C Release --output-on-failure
cmake --install cmake-build-windows --config Release

Também entregamos um toolchain do MinGW-w64 para cruzar a compilação a partir do Linux, embora essa máquina não tivesse o compilador instalado e não permitisse instalar pacotes sem a senha administrativa.

cmake -S . -B cmake-build-mingw \
  -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/mingw-w64-x86_64.cmake \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_INSTALL_PREFIX="$PWD/builds/windows-x86_64"
cmake --build cmake-build-mingw --parallel
cmake --install cmake-build-mingw

Uma compilação cruzada prova que os arquivos ligam, mas executar os testes ainda exige Windows ou Wine, então não colocamos um selo imaginário de aprovação num binário que não foi gerado localmente.

Construindo no macOS

No Mac, o mesmo CMake pode pedir as duas arquiteturas para gerar um binário universal compatível com Intel e Apple Silicon.

cmake -S . -B cmake-build-macos \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 \
  -DCMAKE_OSX_ARCHITECTURES="x86_64;arm64" \
  -DCMAKE_INSTALL_PREFIX="$PWD/builds/macos-universal"
cmake --build cmake-build-macos --parallel
ctest --test-dir cmake-build-macos --output-on-failure
cmake --install cmake-build-macos

Não compilamos esse artefato no Linux porque falta o SDK da Apple, cuja distribuição possui restrições de licença, então a solução correta é usar uma máquina Mac de verdade.

Três sistemas no GitHub Actions

O workflow executa em pull requests, tags e acionamento manual, usando runners nativos para que cada sistema construa seu próprio formato de biblioteca.

strategy:
  fail-fast: false
  matrix:
    include:
      - os: ubuntu-latest
        artifact: linux-x86_64
      - os: windows-latest
        artifact: windows-x86_64
      - os: macos-latest
        artifact: macos-universal
        cmake_args: "-DCMAKE_OSX_ARCHITECTURES=x86_64;arm64"

Cada runner configura, compila, roda o exemplo público com CTest, instala em builds/ e publica a árvore como artefato, enquanto essa pasta continua fora do histórico do Git.

Instalação e descoberta

Uma biblioteca reutilizável não termina quando aparece um arquivo binário, pois o projeto consumidor ainda precisa localizar cabeçalho, biblioteca e dependências.

# Linux com pkg-config
cc app.c $(pkg-config --cflags --libs n148i) -o app

# Outro projeto CMake
find_package(n148i 1 CONFIG REQUIRED)
add_executable(app app.c)
target_link_libraries(app PRIVATE n148i::n148i)

A instalação copia também n148iConfig.cmake, sua verificação de versão e os alvos n148i::n148i e n148i::n148i_static.

As dificuldades que realmente apareceram

A primeira dificuldade foi evitar duas implementações do formato, pois copiar a serialização para uma API nova criaria dois lugares capazes de discordar, então o executável passou a chamar a própria biblioteca.

n148i_result_t result = n148i_encode_memory(
    &source, &options, &encoded, &encoded_size);

/* O CLI apenas grava o buffer que a biblioteca produziu. */
if (result == N148I_OK)
    write_file("output/image.n148i", encoded, encoded_size);

A segunda foi o passo entre linhas, já que o núcleo antigo pressupunha RGB compacto, então a camada pública usa o buffer diretamente quando pode e cria uma cópia compacta somente quando existe padding.

A terceira foi a portabilidade do núcleo para o MSVC, onde __builtin_ctzll não existe, por isso isolamos a operação num helper que mantém o mesmo builtin no GCC e usa a intrínseca equivalente no compilador da Microsoft.

static inline int trailing_zero_count(unsigned long long value) {
#if defined(_MSC_VER) && defined(_M_X64)
    unsigned long index;
    _BitScanForward64(&index, value);
    return (int) index;
#else
    return __builtin_ctzll(value);
#endif
}

Também encontramos um detalhe bem terreno: o caminho local possui espaço e caracteres acentuados, e a substituição direta de flags do pkg-config pelo shell separou esse caminho, então o exemplo foi confirmado por CMake e por um prefixo temporário sem espaços.

Por fim, MinGW ausente e SDK da Apple indisponível estabeleceram uma fronteira honesta para a máquina Linux, sendo assim deixamos comandos reproduzíveis e runners nativos no lugar de arquivos que não poderíamos testar.

Provando que o formato não mudou

Antes de alterar o executável, guardamos quatro fluxos produzidos pelo código original com imagens, qualidades e cromas diferentes, então comparamos cada um contra o novo caminho da biblioteca.

example.ppm      q50  4:2:0    2290 bytes     idêntico
corpus-0022.ppm  q75  4:4:4   53694 bytes     idêntico
corpus-0073.ppm  q30  4:2:2    8148 bytes     idêntico
corpus-0107.ppm  q90  4:2:0  107105 bytes     idêntico

A sentinela continuou nos exatos 2.290 bytes, e a nova verificação dentro de validate comparou a API pública contra o serializador interno byte a byte.

$ ./compare images/example.ppm 50 20
N148i  ...  2290 B  PSNR 34.696 dB

$ ./validate
comparison,scalar_vs_avx2,...,PASS
comparison,public_api_vs_internal,...,PASS
validation PASS (0 failures)

$ make NOTHREADS=1 validate && ./validate
validation PASS (0 failures)

O exemplo instalado passou por find_package e pkg-config, o cabeçalho compilou como C++17, e a suíte também passou com AddressSanitizer e UndefinedBehaviorSanitizer sem apontar falhas.

$ nm -D --defined-only libn148i.so.1.0.0
... T n148i_encode_memory@@N148I_1.0
... T n148i_decode_memory@@N148I_1.0
... T n148i_read_header@@N148I_1.0
... T n148i_free_buffer@@N148I_1.0
... T n148i_free_image@@N148I_1.0

Isso não prova que todo computador do mundo vai se comportar igual, porém prova o contrato mais importante desta etapa na máquina disponível: transformar o projeto em biblioteca não alterou nenhum bit dos casos registrados.

Um exemplo completo para usar a biblioteca

O programa abaixo cria uma imagem RGB pequena, comprime, lê o cabeçalho, descomprime e entrega cada alocação ao N.148i, então ele pode ser copiado para testar uma instalação.

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <n148i.h>

static int fail(const char *where, n148i_result_t result) {
    fprintf(stderr, "%s: %s\n", where, n148i_result_string(result));
    return EXIT_FAILURE;
}

int main(void) {
    enum { WIDTH = 8, HEIGHT = 8 };
    uint8_t pixels[WIDTH * HEIGHT * 3];
    for (size_t i = 0; i < sizeof(pixels); i++)
        pixels[i] = (uint8_t) (i * 17u);

    n148i_image_t source = {pixels, WIDTH, HEIGHT, WIDTH * 3};
    n148i_encode_options_t options;
    n148i_encode_options_init(&options);
    options.quality = 75;

    uint8_t *encoded = NULL;
    size_t encoded_size = 0;
    n148i_result_t result = n148i_encode_memory(
        &source, &options, &encoded, &encoded_size);
    if (result != N148I_OK) return fail("encode", result);

    n148i_image_info_t info;
    result = n148i_read_header(encoded, encoded_size, &info);
    if (result != N148I_OK) {
        n148i_free_buffer(encoded);
        return fail("header", result);
    }

    n148i_image_t decoded = {0};
    result = n148i_decode_memory(encoded, encoded_size, &decoded);
    if (result != N148I_OK) {
        n148i_free_buffer(encoded);
        return fail("decode", result);
    }

    printf("%ux%u, %zu bytes\n", info.width, info.height, encoded_size);
    n148i_free_image(&decoded);
    n148i_free_buffer(encoded);
    return EXIT_SUCCESS;
}

No Linux e no macOS basta ligar com -ln148i depois de informar os diretórios, enquanto no Windows ligamos a n148i.lib e deixamos n148i.dll ao lado do executável.

  • Linux: cc app.c $(pkg-config --cflags --libs n148i) -o app.
  • macOS: cc app.c -Iinclude -Llib -ln148i -Wl,-rpath,@loader_path/lib -o app.
  • Windows compartilhado: cl /Iinclude app.c lib/n148i.lib, depois copie bin/n148i.dll para perto do app.exe.
  • Windows estático: defina N148I_STATIC_DEFINE e ligue lib/static/n148i.lib.

O README do repositório contém a referência de cada função e código de erro, além dos comandos completos de build e instalação para os três sistemas.

Por que congelar a v1 agora

A biblioteca preserva uma versão que já foi explicada, otimizada, medida e validada, sendo assim qualquer pessoa pode estudar ou usar esse ponto da história mesmo quando a pesquisa seguir adiante.

Na Fase 5 nós começaremos pela predição intra-bloco, uma técnica que tenta prever os pixels de um bloco a partir de seus vizinhos antes de codificar o erro restante.

Essas experiências podem alterar o formato no futuro, então ter a v1 congelada como biblioteca nos dá uma base concreta para comparar, voltar e continuar usando.

Repositório no GitHub

E sim, você pode acompanhar a evolução desse CODEC de imagens em um repositório no GitHub, commit por commit 👋

Segue o estado final deste artigo no commit ecd27b4, depois de uma sequência organizada onde a v1 ganhou API pública, bibliotecas multiplataforma, documentação, validação e automação de build:

Resumo

Recapitulando:

  • A v1 agora possui um único cabeçalho público, include/n148i.h, sem expor DCT, Huffman ou PPM.
  • Todos os símbolos públicos usam n148i_ ou N148I_, enquanto a biblioteca compartilhada esconde os internos.
  • A API recebe e devolve memória, aceita stride, possui opções inicializadas, erros enumerados e leitura de cabeçalho.
  • Toda memória devolvida é liberada pelo próprio N.148i, evitando a mistura de heaps no Windows.
  • A versão da biblioteca 1.0.0 e a versão 1 do formato são contratos separados.
  • CMake gera bibliotecas estáticas e compartilhadas, regras de instalação, pkg-config e pacote para find_package.
  • O Makefile continua funcionando e seus executáveis agora ligam a biblioteca estática.
  • Linux foi construído e testado localmente; Windows e macOS possuem builds nativos no GitHub Actions, sem alegações falsas de validação nesta máquina.
  • A v1 do Windows usa o fallback escalar e single-thread porque o pool atual depende de pthreads.
  • Quatro fluxos anteriores foram reproduzidos byte a byte, e a sentinela continuou com exatos 2.290 bytes.

A v1 deixa de ser apenas um programa da série e passa a ser uma biblioteca que outros projetos conseguem encontrar, ligar e usar.

No próximo artigo abrimos a Fase 5 com a predição intra-bloco, agora com uma referência estável para mostrar exatamente o que cada nova ideia muda.

Prepara o café ☕, porque vamos começar uma nova fase da nossa jornada.

Até a próxima! 👋

Criadores de Conteúdo

Foto do William Lima
William Lima
Fundador da Micilini

Inventor nato, escreve conteudos de programação para o portal da micilini.

Torne-se um MIC 🤖

Mais de 100 mic's já estão conectados na plataforma.