23

A lista encadeada do kernel Linux e a arte de fazer o impossível em C

Todo mundo aprende lista encadeada em algum momento.

Normalmente ela aparece mais ou menos assim:

struct node {
    int value;
    struct node *next;
};

Cada nó guarda um valor e um ponteiro para o próximo nó. Se for uma lista duplamente encadeada, guarda também um ponteiro para o anterior:

struct node {
    int value;
    struct node *next;
    struct node *prev;
};

A ideia é simples: diferente de um array, os elementos não precisam estar contíguos na memória. Cada elemento sabe onde está o próximo. Isso permite inserir e remover elementos sem realocar um bloco inteiro de memória.

Até aqui, nada demais. O problema começa quando você quer fazer isso em C de forma genérica. Porque C não tem generics.

Em C++, você faria algo como std::list<T>. Em Rust, LinkedList<T>. Em Java, LinkedList<T>. Em C, você olha para o teto, respira fundo e pensa em uma gambiarra.

A forma tradicional é colocar um void * dentro do nó:

struct list_node {
    void *data;
    struct list_node *next;
    struct list_node *prev;
};

Funciona.

Mas agora você perdeu o tipo. A lista aceita qualquer coisa. Um struct user, um struct file, um struct banana, um ponteiro errado, um cast mentiroso, a esperança, o desespero. Tudo entra. O compilador não consegue te proteger de nada.

Outra alternativa é usar macros para gerar uma lista para cada tipo:

#define DECLARE_LIST(type) \
    struct type##_node {   \
        type data;         \
        struct type##_node *next; \
        struct type##_node *prev; \
    };

Também funciona.

Mas agora você está programando em C e em uma segunda linguagem secreta feita de macros. Todas as funções que operam na lista também precisam ser macros. Impossível de debuggar e as mensagens de erro viram poesia abstrata.


O kernel Linux precisava de algo melhor.

O kernel usa listas ligadas para tudo. Lista de tarefas do escalonador, lista módulos carregados, lista dispositivos registrados, lista de conexões de rede. A lista ligada é a estrutura de dados mais onipresente do kernel. Justamente por isso, o kernel não reimplementa uma lista para cada subsistema: existe uma implementação oficial em <linux/list.h> e todo mundo é fortemente encorajado a usar.

A solução é uma das coisas mais bonitas da programação em C. A lista do kernel não guarda o dado. O dado guarda a lista. A estrutura central é absurdamente simples:

struct list_head {
    struct list_head *next;
    struct list_head *prev;
};

Só isso. Não tem void *data. Não tem int value. Não tem payload. Parece inútil. Mas aí vem o truque. Em vez de fazer isso:

struct fox {
    unsigned long weight;
    struct fox *next;
    struct fox *prev;
};

O kernel faz isso:

struct fox {
    unsigned long weight;
    bool is_fantastic;

    struct list_head list;
};

Agora o código genérico da lista só precisa saber manipular struct list_head. Ele não precisa saber se aquilo está dentro de uma struct fox, struct task_struct, struct inode, struct file, ou qualquer outro objeto do kernel.

Para adicionar uma raposa na lista:

struct fox red_fox;
struct list_head fox_list

list_add(&red_fox.list, &fox_list);

Repare que a função lista recebe apenas o endereço do campo list.

Mas aí surge a pergunta: se a função recebe um struct list_head *, como volto para a struct fox * original? A resposta é o belíssima GAMBIARRA chamada container_of. A versão clássica é mais ou menos assim:

#define offsetof(TYPE, MEMBER) ((size_t)&((TYPE *)0)->MEMBER)   \
#define container_of(ptr, type, member) ({                      \
    const typeof(((type *)0)->member) *__mptr = (ptr);          \
    (type *)((char *)__mptr - offsetof(type, member));          \
})

Parece bruxaria, mas a ideia é simples.

Imagine esta estrutura:

struct fox {
    unsigned long weight;
    bool is_fantastic;
    struct list_head list;
};

O campo list está em algum offset fixo dentro de struct fox.

Algo como:

início da struct fox
        |
        v
+----------------+
| weight         |
+----------------+
| is_fantastic   |
+----------------+
| list           |  <- você tem este endereço
+----------------+

Se você sabe o endereço de list e sabe quantos bytes list está distante do começo de struct fox, basta subtrair esse offset.

endereço da struct fox = endereço do campo list - offset(list)

É isso.

O kernel pega o endereço de um membro e reconstrói o endereço da estrutura que contém aquele membro.

O detalhe genial é que o offset de um campo dentro de uma struct é conhecido em tempo de compilação. O compilador sabe onde cada campo mora. O ABI sabe. A máquina sabe. O programador comum só não costuma pensar nisso.

E aqui mora a beleza da coisa.

A lista é genérica sem usar void *. Ela é reutilizável sem gerar uma lista nova para cada tipo. Ela mantém parte da checagem de tipo porque o macro usa typeof para verificar se o ponteiro passado combina com o tipo do membro esperado.


Isso é muito mais do que uma lista encadeada. É uma aula sobre o que significa conhecer uma linguagem de verdade. Porque o programador médio olha para C e diz:

“C não tem generics.”

E ele está certo.

Mas o programador do kernel olha para C e diz:

“C não tem generics, mas tem layout de memória, ponteiros, offset de campos, macros, extensões do compilador e coragem moral duvidosa.”

E também está certo.

Essa é a diferença entre saber a sintaxe de uma linguagem e entender de verdade o que existe por baixo dela. Muita coisa que parece impossível em uma linguagem é impossível apenas no nível superficial da linguagem. Quando você desce um nível, começam a aparecer portas secretas. Isso não significa que você deve sair usando container_of no sistema de cadastro da padaria.

A moral não é “faça bruxaria em C”. A moral é: fundamentos importam. Estruturas de dados importam. Layout de memória importa. Saber o que a máquina realmente está fazendo importa. Porque, de vez em quando, esse conhecimento permite construir uma solução que parece impossível para quem só conhece a superfície. A lista encadeada do Linux é genial porque mostra uma forma de pensar. O tipo de pensamento que olha para uma limitação da linguagem e pergunta:

“Tá. Mas isso é uma limitação semântica ou só falta de sintaxe?”

É nesse pequeno intervalo que mora boa parte da engenharia de software de verdade.

Carregando publicação patrocinada...
6

Legal, gosto desse tipo de postagem e a conclusão é a melhor parte. Só farei algumas ressalvas para que as pessoas não saiam usando linked lists sem entender bem o problema.

C tem generics. Muitas pessoas não usam para poder compilar em qualquer versão, o que pode ser uma característica desejável pelo projeto ou pode ser apenas forçado. No kernel do Linux realmente não dá para usar. Em algum outro kernel hipotético poderia usar. Não estou dizendo que é a melhor solução sempre.

Cada elemento sabe onde está o próximo. Isso permite inserir e remover elementos sem realocar um bloco inteiro de memória.

Até que ele não saiba onde está. Novamente não é o caso do exemplo citado, claro, mas muitas pessoas usam listas ligadas onde não pode, porque se não souber onde está, é trágico achar onde fará a manipulação, comparando com array, então pode ser igual ou pior que manipular um array.

Listas ligadas são úteis, não há dúvida, mas elas são menos úteis do que parecem, ou porque precisa de algo com complexidade abaixo de linear, um array por exemplo (talvez com préalocações) ou precisa de algo que uma árvore funciona melhor.

Reforço, claro que no Linux sabem usar.

S2


Farei algo que muitos pedem para aprender a programar corretamente, gratuitamente (não vendo nada, é retribuição na minha aposentadoria) (links aqui).

1
4

Para mim isto é arte. Quando eu tenho um problema em Rust, especialmente por causa das regras do ownership, eu preciso inventar alguma "gambiarra" e no final me sinto orgulhoso pois para mim, eu jamais pensaria nisso sem ter a iniciativa de tentar.

Em C é ainda mais belo pois é uma linguagem muito simples, apenas difícil de manter, especialmente em cenários como este, onde uma feature comum hoje em dia, generics, estivesse disponível resolveria este problema.

Alguém simplesmente olhou para o problema e enfrentou ele, não contornou, desistiu, e nem nada do tipo. Não sei quem pensou nisso, nem se foi um coletivo de pessoas, mas esses caras são feras

1

Bem legal o conteúdo do post, só acho importante fazer uma ressalva sobre estre trecho:

O kernel usa listas ligadas para tudo. Lista de tarefas do escalonador, lista módulos carregados, lista dispositivos registrados, lista de conexões de rede. A lista ligada é a estrutura de dados mais onipresente do kernel.

Acho importante mencionar que o kernel Linux usa muitas estruturas de dados distintas e que cada caso é um caso. Lista ligada tem penalidade de performance devido ao acesso à memória não ser sequencial, o que implica em muitos cache misses. Em comparação, array dinâmica é muito mais cache friendly pelos acessos serem sequenciais e, por isso, a performance é muito melhor.

Então a ideia de que "O kernel usa listas ligadas para tudo" está errada. O kernel usa várias estruturas de dados diferentes e, onde a performance é mais impactante, ele prefere usar array dinâmica ou flexible array members.

0

Usar para tudo é diferente de dizer que tudo usa exclusivamente listas.

Te faço um desafio simples:

Manda um grep "struct list_head" na raiz do kernel e tenta achar um único subsistema que não use essas listas em algum ponto.

-1

Só que existe diferença entre "para tudo" e "em tudo". Se você fala que usa "para tudo", você está sim dizendo que tudo usa listas.

Exemplos:
— Eu faço desenhos e uso lápis de carvão para tudo.
— Eu faço desenhos e uso lápis de carvão em tudo.

Na segunda frase, "em tudo" é entendido como "em todo desenho". Mas na primeira frase, "para tudo" é entendido que somente lápis de carvão é usado nos desenhos.

Mas tudo bem, já entendi que houve uma falha na comunicação.

1
1

Eu vou acrescentar um comentário, mais como curiosidade.

No exemplo acima:
struct fox {
unsigned long weight;
bool is_fantastic;
struct list_head list;
};

O primeiro item do "struct fox" é "weight" e o último é "list".
Se "list" for colocado como primeiro item da struct, o endereço de memória de "list" passa a ser o mesmo endereço de memória da struct "fox":
struct fox {
struct list_head list;
unsigned long weight;
bool is_fantastic;
};

Ou seja (&fox == &fox.list).

Isso acontece porque a alocação de memória dos itens dentro do buffer da "struct fox" é feita na mesma órdem em que esses ítens foram declarados, e o item que estiver na posição zero do buffer, tem o mesmo endereço físico que o início do buffer.
Se fosse feito dessa forma, a macro "container_of" se tornaria desnecessária.

Um símples cast de ponteiro ja seria o bastante:
endereço da struct fox = (struct fox *) endereço do campo list;

Eu aprendi isso num projeto de uma empresa de telecomunicações que eu trabalhei.