1

A API de arquivos do Linux e a arte de fazer o impossível em C parte 2

Ou: como o kernel implementa polimorfismo e herença numa linguagem que "não tem" objetos

Na parte 1 a gente viu o container_of. Um truque de três linhas que dá ao kernel Linux uma lista ligada genérica, com tipos reais e debugável. A lição era que o impossível às vezes é só uma pergunta mal feita.

Hoje a pergunta é outra. E ela está escondida na abstração mais bem suceido no mundo dos sistemas operacionais:

No Unix, tudo é arquivo.

Parece slogan. Mas pare pra pensar no que ela exige na prática.

Uma syscall, mil implementações

Olha esse código.

char buf[128];
read(fd, buf, sizeof buf);

Esse read pode estar lendo

  • um arquivo num disco ext4;
  • um arquivo num pendrive FAT32;
  • um pipe entre dois processos;
  • um socket TCP;
  • o /dev/null, que não tem nada;
  • o /proc/cpuinfo, que nem existe

São coisas completamente diferentes. Ler de um disco envolve cache de páginas e controlar o dispositivo. Ler de um socket envolve buffers de rede. Ler do /proc envolve formatar texto a partir de estruturas internas do kernel.

E mesmo assim é a mesma chamada. O cat não sabe a diferença. O kernel não sabe a diferença. Nem precisa.

Em Java ou C++ a resposta seria óbvia. Uma classe Arquivo com um método virtual read() e cada tipo implementa o seu. Polimorfismo básico. Mas C não tem classes nem tem métodos virtuais. Será mesmo?

Código também é dado

Uma função, depois de compilada, é só uma sequência de bytes em algum endereço da memória. E se ela tem endereço, você pode guardar esse endereço num ponteiro.

int dobro(int x) { return x * 2; }
int triplo(int x) { return x * 3; }

int (*operacao)(int);   /* ponteiro para "função que recebe int e devolve int" */

operacao = dobro;
printf("%d\n", operacao(10));   /* 20 */

operacao = triplo;
printf("%d\n", operacao(10));   /* 30 */

A sintaxe é feia, mas a ideia é simples: operacao(10) significa "pule para o endereço que está guardado em operacao e execute o que estiver lá". Qual código roda é decidido em tempo de execução, não de compilação.

Montando um mini-VFS

Vamos construir uma versão de brinquedo do que o kernel faz. Primeiro, a "interface". Uma struct que agrupa os ponteiros de função que todo arquivo precisa oferecer.

struct arquivo;

/* A "interface" */
struct operacoes {
    ssize_t (*ler)(struct arquivo *a, char *buf, size_t n);
    ssize_t (*escrever)(struct arquivo *a, const char *buf, size_t n);
};

/* O "objeto" */
struct arquivo {
    const struct operacoes *ops;
    void *privado;
};

Repare que

  1. Cada função recebe o próprio struct arquivo * como primeiro argumento. Isso é o thisdo C++ ou self do python. Em linguagens de OO ele é implicíto, aqui você passa na mão.
  2. O arquivo não carrega os ponteiros de função direto. Ele carrega um ponteiro para uma tabela de ponteiros.

Agora duas "classes". A primeira imita o /dev/null:

static ssize_t nulo_ler(struct arquivo *a, char *buf, size_t n)
{
    return 0;          /* nunca tem nada pra ler: fim de arquivo */
}

static ssize_t nulo_escrever(struct arquivo *a, const char *buf, size_t n)
{
    return n;          /* "escrevi tudo", mentiu o buraco negro */
}

static const struct operacoes ops_nulo = {
    .ler      = nulo_ler,
    .escrever = nulo_escrever,
};

A segunda é um arquivo que vive na memória:

struct memoria {
    char dados[64];
    size_t tam;
};

static ssize_t mem_ler(struct arquivo *a, char *buf, size_t n)
{
    struct memoria *m = a->privado;
    if (n > m->tam) n = m->tam;
    memcpy(buf, m->dados, n);
    return n;
}

static ssize_t mem_escrever(struct arquivo *a, const char *buf, size_t n)
{
    struct memoria *m = a->privado;
    size_t livre = sizeof m->dados - m->tam;
    if (n > livre) n = livre;
    memcpy(m->dados + m->tam, buf, n);
    m->tam += n;
    return n;
}

static const struct operacoes ops_memoria = {
    .ler      = mem_ler,
    .escrever = mem_escrever,
};

E agora a parte mágica, o nosso "VFS":

ssize_t ler(struct arquivo *a, char *buf, size_t n)
{
    if (!a->ops->ler)
        return -1;               /* operação não suportada */
    return a->ops->ler(a, buf, n);
}

ssize_t escrever(struct arquivo *a, const char *buf, size_t n)
{
    if (!a->ops->escrever)
        return -1;
    return a->ops->escrever(a, buf, n);
}

Usando:

int main(void)
{
    struct memoria m = {0};
    struct arquivo arquivos[] = {
        { .ops = &ops_nulo },
        { .ops = &ops_memoria, .privado = &m },
    };

    for (int i = 0; i < 2; i++) {
        char buf[64] = {0};
        escrever(&arquivos[i], "ola tabnews", 11);
        ssize_t lidos = ler(&arquivos[i], buf, sizeof buf);
        printf("arquivo %d: leu %zd bytes: '%s'\n", i, lidos, buf);
    }
}
arquivo 0: leu 0 bytes: ''
arquivo 1: leu 11 bytes: 'ola tabnews'

As mesmas chamadas, comportamentos diferentes. Isso é polimorfismo. Em C puro, sem nenhuma extensão, sem nenhuma macro.

Métodos opcionais, de graça

Com designated initializers ({.read = ...}) todo campo que você não menciona é inicializado com zero. Para ponteiros, zero é NULL. E o VFS trata NULL como "essa operação não é suportada" ou "use o comportamento padrão".

Ou seja métodos opcionais com implementação padrão, algo que Java só ganhou com os default methods no Java 8. Um driver novo implementa só o que faz sentido para ele, e o resto simplesmente funciona.

Herença e como funciona o super

Até aqui cada "classe" implementou todos os seus métodos do zero. Mas o grande super poder da orientação a objetos está em não reescrever tudo. Você reusa o que serve e só muda o que precisa. E quando muda, às vezes ainda quer aproveitar o comportamento do pai:

@Override
public int escrever(String texto) {
    return super.escrever(texto.toUpperCase());
}

O super parece mágica. Não é. Dá para fazer na unha.

Digamos que você queira um arquivo em memória que grava tudo em maiúsculas. Ler funciona exatamente igual ao arquivo em memória comum. Escrever é quase igual. Só precisa transformar o texto antes.

static ssize_t grita_escrever(struct arquivo *a, const char *buf, size_t n) {
    char tmp[64];
    if (n > sizeof tmp) n = sizeof tmp;
    for (size_t i = 0; i < n; i++)
        tmp[i] = toupper((unsigned char)buf[i]);

    return mem_escrever(a, tmp, n);   /* super.escrever() */
}

static const struct operacoes ops_grita = {
    .ler      = mem_ler,          /* herdado: a mesma função do "pai" */
    .escrever = grita_escrever,   /* sobrescrito */
};

É isso. mem_escrever espera que a->privado aponte para uma struct memoria. Ele funciona no grita porque o grita usa exatamente os mesmos dados. O filho herdou o layout junto com o comportamento.

E se o filho precisasse de dados extras, como um contador de quantas vezes gritou? Aí entra a parte 1. Você coloca a struct memoria dentro da estrutura do filho e usa container_of para ir e voltar entre as duas. O pai continua enxergando só a parte dele e o filho enxerga tudo.

Por que uma tabela separada?

Poderíamos colocar os ponteiros direto dentro de struct arquivo. Por que o nível extra de indireção?

  • Memória: Um sistema pode ter milhares de arquivos abertos. Se cada um carregasse 20 ponteiros de função, seriam 160 bytes repetidos por arquivo. Com a tabela, cada arquivo carrega um ponteiro, e todos os "objetos" da "classe" compartilham a mesma tabela.
  • Consistência: A tabela define o "tipo" como um todo. Não existe a situação bizarra de um arquivo com o ler do ext4 e o escrever de um socket.
  • Segurança: Repare no const. Uma tabela const vai parar numa região de memória somente leitura. Isso importa muito.

Se você já estudou como C++ funciona, acabou de reconhecer a vtable. É exatamente isso que o compilador C++ gera. Uma tabela de ponteiros por classe e um ponteiro escondido (o vptr) em cada objeto. A diferença é que em C++ o compilador faz por você. Em C, você vê faz tuda na unha. E essa é, em boa parte, a razão de o C continuar sendo o rei incontestável da programação de baixo nível. Compare essa duas linhas:

obj->ler(buf, n);           // C++
a->ops->ler(a, buf, n);     // C

Na linha do C++ você não sabe o que está acontecendo. Pode ser uma chamada direta. Pode ser uma chamada indireta pela vtable. Para descobrir, você precisa ir atrás da declaração da classe, e talvez da classe-pai e da classe-avô. Numa aplicação comum, isso é conforto. Em infraestrutura critíca é um perigo.

O kernel de verdade

O kernel faz exatamente isso. A camada que recebe o seu read() se chama VFS (Virtual File System). Robert Love dedica o capítulo 13 do Linux Kernel Development a ela. Faz questão de dizer com todas as letras que o VFS é orientado a objetos.

A "interface" do kernel se chama struct file_operations e mora em include/linux/fs.h. Uma versão bem resumida:

struct file_operations {
    loff_t   (*llseek)(struct file *, loff_t, int);
    ssize_t  (*read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t  (*write)(struct file *, const char __user *, size_t, loff_t *);
    int      (*open)(struct inode *, struct file *);
    int      (*release)(struct inode *, struct file *);
    int      (*fsync)(struct file *, loff_t, loff_t, int datasync);
    /* ... e mais umas dezenas ... */
};

E cada arquivo aberto é um struct file que carrega um ponteiro para a sua tabela:

struct file {
    /* ... */
    const struct file_operations *f_op;
    void *private_data;
    /* ... */
};

O ext4 e o pai genérico

Ler um arquivo de disco, em quase todo sistema de arquivos, segue o mesmo padrão. Checar se a página já está no page cache, se não, buscar do disco, copiar, É igual pro ext4, pro XFS, pro btrfs. Então o kernel escreve ele uma vez, em mm/filemap.c, com o nome generic_file_*. É o "pai" de todo mundo.

O ext4 por exemplo tem situações que o genérico não conhece. O sistema de arquivos pode ter sido desligado à força por erro, o arquivo pode estar em modo DAX, a leitura pode ser direct I/O. Então ele sobrescreve, trata o que é dele e, no caso comum, chama o pai. De `fs/ext4/file.c:

static ssize_t ext4_file_read_iter(struct kiocb *iocb, struct iov_iter *to)
{
    struct inode *inode = file_inode(iocb->ki_filp);

    if (unlikely(ext4_forced_shutdown(inode->i_sb)))
        return -EIO;                           /* coisa do ext4 */

    if (IS_DAX(inode))
        return ext4_dax_read_iter(iocb, to);   /* coisa do ext4 */

    if (iocb->ki_flags & IOCB_DIRECT)
        return ext4_dio_read_iter(iocb, to);   /* coisa do ext4 */

    return generic_file_read_iter(iocb, to);   /* super.read_iter() */
}

É o nosso grita_escrever, em produção, rodando em milhões de servidores.

O ponto, de novo

Na parte 1 a conclusão foi que o container_of não usa nenhum C secreto, só o mesmo C de sempre, entendido mais fundo.

Aqui é igual. Ponteiro de função está em qualquer livro introdutório de C. Structs, idem. Nenhum dos dois, sozinho, parece grande coisa. Mas para quem entende que orientação a objetos não é um recurso de linguagem, mas padrão de organização de memória. É tudo vocë precisa.

O file_operations é o exemplo mais famoso. O VFS inteiro é construído assim. E o padrão vai muito além. Drivers de rede, barramentos, escalonadores. Se você abrir um arquivo qualquer do kernel e encontrar um struct algum_ops, você esta vendo orientação à objeto em C. O impossível, de novo, era só uma questão de olhar pro fundamento certo.

Carregando publicação patrocinada...