Linux Embedded

Le blog des technologies libres et embarquées

Drivers DMA dans Linux

I – Introduction

I.1 Pré-requis

Nous partons du fait que le lecteur connaît les bases des drivers linux. Donc il sait créer un « node » sur /dev , /sys et /proc, initialiser un module et le terminer proprement. Il connaît les licences et la gestion des paramètres d’un module.

I.2 Les périphériques matériels

I.2.a Quelques généralités

Quand on est peu impliqué dans le développement d’un produit matériel, il est difficile de concevoir un driver pour un périphérique spécifique, et généralement on débute sur le développement d’un driver pour un périphérique USB.

Ceux-ci sont hélas enfouis sous de nombreuses couches de protocoles car le driver primaire est celui du PHY du chipset USB de notre carte.

Un SoC est composé d’un certain nombre de périphériques comme le chipset USB, mais aussi le bus SPI, le bus I2C, les GPIO, le PHY ethernet, ou encore le DSP audio et vidéo.

Tous ces périphériques ont en commun un mode de fonctionnement par registres et interruptions. En général un registre de FIFO permet de lire les données reçues sur le bus, un autre d’écrire de nouvelles données à émettre et d’autres registres permettent de configurer tout cela pour que la réception et l’émission fonctionnent.

Une première amélioration au fonctionnement des périphériques a été l’ajout des interruptions. Il est en effet possible de configurer (via les précédents registres) le périphérique pour qu’il émette un signal électrique sur un autre périphérique, le « contrôleur d’interruption » (souvent abrégé INTC). Celui-ci manipule aussi des registres pour être configuré et dialoguer avec le CPU. Il est ensuite capable de générer une interruption matérielle, qui est cette fois-ci reconnue par le CPU.

Ces signaux électriques ont le grand intérêt de permettre au CPU de savoir quand de nouvelles valeurs sont utiles dans les registres d’un périphérique. Ainsi il n’est pas nécessaire de les scruter en permanence et donc de consommer de l’énergie et des ressources.

Bien sûr comme dans tout dialogue, il faut parler la même langue, et là nous sommes encore à la chute de Babylone, personne ne (veut) parle la même langue. Alors, comme nous sommes des développeurs, nous travaillons à faire la traduction. Pour nous aider, nous utilisons les dictionnaires que sont le « datasheet » des périphériques.

 

La datasheet du SoC Sitara5718 de chez Ti à la date d’avril 2017: http://www.ti.com/lit/ug/spruhz7e/spruhz7e.pdf

I.2.b La gestion de la mémoire

Le noyau Linux fonctionne avec le support d’une MMU (Memory Management Unit) qui permet de cacher l’utilisation réelle de la mémoire physique derrière une mémoire virtuelle. Ce principe permet d’avoir accès à une zone mémoire virtuelle de grande taille alors que physiquement ce sont des blocs de mémoire, éparses. Pourtant un périphérique ne connaît pas la MMU et il ne peux utiliser que des adresses physiques.

Allocation mémoire

Le noyau Linux permet d’allouer une zone mémoire à l’usage du pilote par l’intermédiaire de la MMU. Ce principe permet d’allouer des zones mémoires de taille importante que le pilote pourra utiliser à son gré pour stocker des informations.

Pour le dialogue entre un périphérique et son pilote, le noyau permet aussi d’allouer une zone mémoire qui sera d’un seul tenant dans l’espace physique de la mémoire. On dit qu’elle est «contiguë». Celle-ci est de plus en plus difficile à allouer au fur et à mesure du fonctionnement du système.

L’exemple suivant présente une première allocation d’une zone mémoire qui est faite par la MMU, et une seconde qui sera contiguë.

array = vmalloc(array_size * sizeof (struct spi_slave_rx_dma_buf));
array[0].buf = kcalloc(array_size , buf_size, GFP_KERNEL);
for (i = 0; i < array_size; ++i) {
    array[i].buf = array[0].buf + i * dma_buf_size;
}

La première peut être éclatée à plusieurs endroits de la mémoire physique, alors que la deuxième sera toujours d’un seul tenant.

 

L’infrastructure « scatterlist »

Lors d’un transfert de données avec un périphérique, il est souvent utile d’avoir plusieurs zones mémoires accessibles à celui-ci pour lui permettre d’en utiliser une alors que son pilote est en train d’en préparer une autre. Sur un contrôleur vidéo, c’est ce que l’on appelle un « double (ou triple) frame buffer ».

L’exemple précédent est une présentation possible de ce qu’on appelle un « Scatter/Gather », une liste de buffers chaînés qui pourront être utilisés un à un par le périphérique et son pilote.

Pour simplifier et uniformiser les interfaces, le noyau Linux propose une infrastructure appelée « scatterlist » qui est définie dans le fichier « include/linux/scatterlist.h ».

struct scatterlist *rx_sg;
unsigned char *buf ;
rx_sg = vmalloc(array_size * sizeof(struct scatterlist));
buf = kcalloc(array_size , buf_size, GFP_DMA);
sg_init_table(rx_sg, array_size);
for (i = 0; i < array_size; i++) {
    sg_set_buf(&rx_sg[i], buf + i * buf_size, buf_size);
}

I.2.c l’accès aux registres

Les registres de périphérique sont des adresses mappées physiquement entre le bus de mémoire du CPU et les périphériques. Il est donc nécessaire de connaître ces adresses pour y lire et modifier des valeurs.

C’est aujourd’hui sous Linux, le DTB (device tree) qui nous indique ces adresses pour chaque périphérique. Il est donc nécessaire lors de l’initialisation de faire une demande de ces valeurs :

mcspi3: spi@480b8000 {
    compatible = "ti,omap4-mcspi";
    reg = <0x480b8000 0x200>;
    interrupts = <GIC_SPI 86 IRQ_TYPE_LEVEL_HIGH>;
    ti,hwmods = "mcspi3";
    dmas = <&sdma_xbar 15>, <&sdma_xbar 16>;
    dma-names = "tx0", "rx0";
    status = "okay";
    ti,spi-num-cs = <1>;
    pinctrl-names = "default";
    pinctrl-0 = <&mcspi3_pins>;
};

DTS pour le bus SPI 3 du Sitara 5718

static const u32 mcspi_base[MCSPI_NB_MOD] = { 0x48098000, 0x4809A000, 0x480B8000, 0x480BA000 };
static int mcspi_probe(struct platform_device *pdev)
{
    struct device_node *node = pdev->dev.of_node;
    u32 node_reg;
...
    match = of_match_device(mcspi_of_match, &pdev->dev);
    of_property_read_u32_index(node, "reg", 0, &node_reg);
    for (i=0; i<MCSPI_NB_MOD; i++) {
        if( mcspi_base[i] == node_reg )
            mcspi->bus_num = i+1;
    }
    of_property_read_u32(node, "ti,spi-num-cs", &num_cs);
    mcspi->num_chipselect = num_cs;
...
}

accès au DTB depuis la détection du device


L’utilisation de la MMU cache l’accès aux adresses physique. Il est donc nécessaire de dire à la MMU que l’on veut avoir accès directement aux adresses des registres du périphérique, ce que nous appelons un « remappage des entrées/sorties ».

static int mcspi_probe(struct platform_device *pdev)
{
    struct resource *res;
...
    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    mcspi->phys = res->start;
    mcspi->base = devm_ioremap_resource(&pdev->dev, res);
    infomsg("MCSPI%i controller's attributes: @phys=0x%08x, @virt=0x%08x\n", mcspi->bus_num, (u32)mcspi->phys, (u32)mcspi->base);
...
}

“remappage” des registres du bus SPI


Une fois cette opération faite, il est facile d’utiliser cette adresse virtuelle pour lire et écrire dans le registre physique du périphérique.

static void mcspi_configure_slave(struct mcspi_ctx *mcspi)
{
    u32 chconf0, modulctrl;
    u32 * p_chconf0 = &(mcspi->base + 0x12C + 0x14 * 0);
    u32 * p_modulctrl = &(mcspi->base + 0x128);
    u32 * p_sysconfig = &(mcspi->base + 0x110);
    u32 * p_sysstatus = &(mcspi->base + 0x114);

    sysconfig = readl(p_sysconfig);
    sysconfig |= MCSPI_SYSCONFIG_SOFTRESET;
    writel(sysconfig, p_sysconfig);
    while (!(MCSPI_SYSSTATUS_RESETDONE & readl(p_sysstatus)))
        ;

    /* System config: no wake-up capability (always running), ignore idle request, maintain all clocks */
    sysconfig &= ~(MCSPI_SYSCONFIG_ENAWAKEUP | MCSPI_SYSCONFIG_SOFTRESET | MCSPI_SYSCONFIG_AUTOIDLE | MCSPI_SYSCONFIG_SIDLEMODE_MASK);
    sysconfig |= (MCSPI_SYSCONFIG_IGNORE_IDLEREQ | MCSPI_SYSCONFIG_ALL_CLOCKS_MAINT);
    writel(sysconfig, &regs->sysconfig);
    chconf0 = readl(p_chconf0);
    /* RAZ fields then configure: Rx-only, 16-bit words, enable turbo mode, enable usage of FIFO in Rx */
    chconf0 &= ~(MCSPI_CHCONF_WL_MASK | MCSPI_CHCONF_TRM_MASK | MCSPI_CHCONF_DPE0 | MCSPI_CHCONF_DPE1 );
    chconf0 |= (MCSPI_CHCONF_TRM_RX_ONLY | MCSPI_CHCONF_SET_WORDLEN(16) | MCSPI_CHCONF_DPE0 | MCSPI_CHCONF_DPE1 | MCSPI_CHCONF_TURBO | MCSPI_CHCONF_FFER | MCSPI_CHCONF_IS);

    writel(chconf0, p_chconf0);
    /* Set slave mode and use PIN34 as Chip Select */
    modulctrl = readl(p_modulctrl);
    modulctrl &= ~MCSPI_MODULCTRL_PIN34;
    modulctrl |= MCSPI_MODULCTRL_MS;
    writel(modulctrl, p_modulctrl);
    /* Additional config: SPIEN active low, SPICLK inactive low, data latch on even SPICLK */
    chconf0 &= ~MCSPI_CHCONF_POL;
    chconf0 |= (MCSPI_CHCONF_EPOL | MCSPI_CHCONF_PHA);
    writel(chconf0, p_chconf0);
}

Lire et écrire sur les registres d’un périphérique SPI du Sitara 5718

I.2.d La gestion des interruptions

Un périphérique est configurable pour générer des interruptions sur certains événements comme l’arrivée de données dans le registre de réception du périphérique.

La définition de l’« IRQ » est faite par le « device tree ». Lors d’une interruption, le CPU ARM appelle un vecteur d’exception qui permet au noyau de se mettre dans un contexte particulier. Dans ce contexte, une fonction du noyau pourra réaliser un traitement court où toute autre interruption sera interdite. En général, cette fonctionnement déclenchera l’exécution d’une « tasklet » (« SofTIRQ ») dans le contexte normal du noyau.

static irqreturn_t omap_dma_irq(int irq, void *irqparam)
{
    return IRQ_HANDLED;
}

static int omap_dma_probe(struct platform_device *pdev)
{
    int irq;
    void *irqparam;
...
    irq = platform_get_irq(pdev, 1);
    if (irq <= 0) {
        dev_info(&pdev->dev, "failed to get L1 IRQ: %d\n", irq);
    } else {
        devm_request_irq(&pdev->dev, irq, omap_dma_irq,
        IRQF_SHARED, "omap-dma-engine", irqparam);
    }
...
}

II – Le DMA, un périphérique pas tout à fait comme les autres

II.1 Présentation

Cette gestion des périphériques par registres et interruption pose le problème des grandes quantités de données à transférer. En effet les FIFO des périphériques sont souvent de taille réduite, et le CPU doit faire des copies entre celles-ci et la mémoire volatile pour envoyer ou recevoir des données. Si on prend un bus SPI à 16 MHz avec une FIFO de 32 octets en réception le CPU devrait remplir la FIFO toutes les 1.625 us, ce qui, on s’en doute bien, est impossible.

Le DMA ou plutôt le contrôleur de « Direct Memory Access » est un composant particulier capable de transférer de grande quantité de données depuis une zone mémoire vers une autre, sans que le CPU ne soit sollicité. Et il peut donc faire le lien entre la FIFO d’un bus classique, comme le SPI, et la mémoire volatile du SoC.

Pour pouvoir être utilisé avec plusieurs périphériques et/ou en full-duplex, le contrôleur de DMA offre un certain nombre de canaux. Chacun d’eux est configurable indépendamment, comme autant de périphériques. Bien sûr, le CPU doit connaître l’avancement de ces transferts et donc le DMA est capable de l’informer directement par l’intermédiaire de registres et d’interruptions comme tous les périphériques.

Un canal DMA doit être vu comme un périphérique quelconque mais qui fonctionne en interne.

II.2 Les fonctionnements

II.2.a Par « burst »

Le canal DMA est configuré pour recevoir ou émettre une certaine quantité de données entre une adresse de la mémoire volatile et les FIFO du périphérique. Une fois le transfert terminé le contrôleur lève une interruption pour avertir le CPU puis attend de nouveaux ordres.

Ce mode de fonctionnement permet le transfert dans les deux sens, selon des données attendues ou envoyées depuis le « userspace ». Il est ainsi possible de faire un transfert dans un sens puis dans l’autre selon les besoins, tout en n’utilisant qu’un seul canal.

II.2.b Par « cycle »

Cette fois-ci, le canal DMA permet d’utiliser un « Scatter/Gather » de manière tournante. Quand le canal DMA a fini de remplir une zone mémoire, il la libère pour que le CPU y ait accès et passe à la suivante pour continuer son transfert.

Il est ainsi possible de ne jamais arrêter le transfert, l’interruption vers le CPU ne bloquant pas le fonctionnement du contrôleur. En revanche, il est impossible d’utiliser le canal dans les 2 sens en même temps.

II.2.c Par FIFO

Ce fonctionnement est juste mentionné pour indiquer que le DMA peut être configuré pour utiliser un canal entre 2 périphériques différents sans utilisation du CPU.

II.2.d Par copie

Et celui-ci est mentionné car il permet de faire des manipulations sur de grandes quantités de données. Il est ainsi possible d’utiliser un canal DMA pour faire le retournement d’une image ou encore l’application d’un filtre sur un flux.

II.3 Le driver DMA

II.3.a La mémoire

L’allocation de la mémoire doit se faire dans une région spécifique. L’allocation par « kmalloc » doit prendre le flag « GFP_DMA » comme argument, ou alors il est possible d’utiliser la fonction « dma_alloc_coherent ».

La différence entre ces deux fonctions est la gestion du cache. On se doute bien que le DMA ne peut pas utiliser la mémoire cache du CPU et donc celui-ci ne doit pas intervenir dans le transfert.

« dma_alloc_coherent » permet de ne pas activer le cache sur la zone mémoire. Alors que l’utilisation de « kmalloc » exige de forcer la copie du cache en mémoire avant de donner l’accès de la mémoire au DMA.

La seconde utilisation peut permettre de gagner du temps lors du transfert de données entre les différents processus mais pas dans le fonctionnement du pilote.

rx_sg = vmalloc(array_size * sizeof(struct scatterlist));
buf = kcalloc(array_size , buf_size, GFP_DMA);
sg_init_table(rx_sg, array_size);
for (i = 0; i < array_size; i++)
{
    sg_set_buf(&rx_sg[i], buf + i * buf_size, buf_size);
}
dma_map_sg(mcspi->dev, rx_sg, array_size,DMA_FROM_DEVICE);
...
dma_sync_sg_for_cpu(mcspi->dev, rx_sg, array_size,DMA_FROM_DEVICE);
copy_to_user(user_data, buf + current_buf * buf_size, buf_size) ;
current_buf++;
current_buf %= array_size;
dma_sync_sg_for_device(mcspi->dev, rx_sg, array_size,DMA_FROM_DEVICE);

II.3.b La partie physique

Chaque contrôleur possède son pilote. Son fonctionnement est distinct des autres et certaines fonctionnalités peuvent être absentes.

Les sources sont localisées dans le répertoire « drivers/dma » du noyau.

Cette partie du driver gère la configuration des registres et la réception des interruptions matérielles venant du contrôleur de DMA.

Le traitement des interruptions étant spécifique à l’utilisation du canal DMA, la fonction d’IRQ est très simple pour diminuer le temps de réponse dans le contexte d’interruption, et laisse le traitement à une « tasklet » (SoftIRQ) qui est instanciée lors de la configuration du canal.

Chaque driver physique doit remplir une structure « struct dma_device » qui définit les actions possibles par le contrôleur. Cette structure propose une douzaine de manières pour faire un transfert DMA, chacune implémentée dans une fonction. Les drivers peuvent implémenter tout ou partie de ces fonctions puis ils décrivent leurs capacités.

struct dma_async_tx_descriptor *(*device_prep_dma_memcpy)(
    struct dma_chan *chan, dma_addr_t dst, dma_addr_t src,
    size_t len, unsigned long flags);

struct dma_async_tx_descriptor *(*device_prep_dma_xor)(
    struct dma_chan *chan, dma_addr_t dst, dma_addr_t *src,
    unsigned int src_cnt, size_t len, unsigned long flags);

struct dma_async_tx_descriptor *(*device_prep_dma_xor_val)(
    struct dma_chan *chan, dma_addr_t *src, unsigned int src_cnt,
    size_t len, enum sum_check_flags *result, unsigned long flags);

struct dma_async_tx_descriptor *(*device_prep_dma_pq)(
    struct dma_chan *chan, dma_addr_t *dst, dma_addr_t *src,
    unsigned int src_cnt, const unsigned char *scf,
    size_t len, unsigned long flags);

struct dma_async_tx_descriptor *(*device_prep_dma_pq_val)(
    struct dma_chan *chan, dma_addr_t *pq, dma_addr_t *src,
    unsigned int src_cnt, const unsigned char *scf, size_t len,
    enum sum_check_flags *pqres, unsigned long flags);

struct dma_async_tx_descriptor *(*device_prep_dma_memset)(
    struct dma_chan *chan, dma_addr_t dest, int value, size_t len,
    unsigned long flags);

struct dma_async_tx_descriptor *(*device_prep_dma_memset_sg)(
    struct dma_chan *chan, struct scatterlist *sg,
    unsigned int nents, int value, unsigned long flags);

struct dma_async_tx_descriptor *(*device_prep_dma_interrupt)(
    struct dma_chan *chan, unsigned long flags);

struct dma_async_tx_descriptor *(*device_prep_dma_sg)(
    struct dma_chan *chan,
    struct scatterlist *dst_sg, unsigned int dst_nents,
    struct scatterlist *src_sg, unsigned int src_nents,
    unsigned long flags);

struct dma_async_tx_descriptor *(*device_prep_slave_sg)(
    struct dma_chan *chan, struct scatterlist *sgl,
    unsigned int sg_len, enum dma_transfer_direction direction,
    unsigned long flags, void *context);

struct dma_async_tx_descriptor *(*device_prep_dma_cyclic)(
    struct dma_chan *chan, dma_addr_t buf_addr, size_t buf_len,
    size_t period_len, enum dma_transfer_direction direction,
    unsigned long flags);

struct dma_async_tx_descriptor *(*device_prep_interleaved_dma)(
    struct dma_chan *chan, struct dma_interleaved_template *xt,
    unsigned long flags);

struct dma_async_tx_descriptor *(*device_prep_dma_imm_data)(
    struct dma_chan *chan, dma_addr_t dst, u64 data,
    unsigned long flags);

Sur toutes ces fonctions nous allons nous arrêter plus spécifiquement sur 2 d’entre elles :

  • device_prep_dma_cyclic
  • device_prep_slave_sg

Ces deux fonctions sont implémentées pour le contrôleur DMA des SoC de la famille Sitara et Omap dont le code se trouve dans le fichier « omap_dma.c ». Elles sont surtout utilisées pour faire de la copie de données entre un périphérique et la mémoire volatile.

device_prep_slave_sg

Le terme « sg » qui termine le nom, est l’acronyme de Scatter/Gather. Le principe de ce mode est de fournir une liste de buffers et d’attendre que tous ceux-ci aient été utilisés. Il est donc nécessaire que l’utilisateur du canal redemande un transfert, une fois terminé, depuis le driver du périphérique. Cela peut être depuis les fonctions entrée/sortie de l’interface utilisateur ou depuis une « tasklet » connectée à l’interruption matérielle.

Cette solution permet beaucoup de souplesse mais laisse une grande incertitude sur le moment où le contrôleur DMA sera de nouveau prêt pour continuer le transfert. Or dans le cas de la réception de données sur un bus rapide, il est possible de rater des données.

device_prep_dma_cyclic

Cette fonction va utiliser un seul et unique buffer tournant, une fois rempli, le contrôleur vient réécrire dessus les nouvelles données. Le contrôleur de DMA peut générer une interruption matérielle à plusieurs moments du remplissage de celui-ci et ainsi permettre l’utilisation (lecture ou écriture) des données en invoquant une « tasklet ».

Cette solution permet de ne pas couper le transfert de données, mais il faut tout de même faire attention à la cohérence des données. En effet le contenu de la mémoire volatile peut être affecté par l’utilisation de zones tampons ou de la MMU.

II.3.c L’infrastructure « dmaengine »

Le driver de DMA est rarement utilisé directement depuis le contexte utilisateur, et donc il doit fournir une interface identique quelque soit le contrôleur sous-jacent. C’est ce qu’offre le « dmaengine » qui est décrit dans le fichier « include/linux/dmaengine.h ».

Il permet :

  • de réserver l’utilisation d’un canal sur un contrôleur DMA compatible avec le périphérique utilisé :
struct dma_chan *dma_rx;
dma_cap_mask_t mask;
dma_cap_zero(mask);
dma_cap_set(DMA_SLAVE, mask);
dma_cap_set(DMA_CYCLIC, mask);
dma_rx = dma_request_slave_channel_compat(mask, omap_dma_filter_fn, &sig, dev, "mydmarx");

 

  • de configurer la source et/ou la destination des données :
struct dma_slave_config cfg;
unsigned long dummy_flags;
memset(&cfg, 0, sizeof(cfg));
cfg.src_addr = (dma_addr_t)&((struct mcspi_regs *)mcspi->phys)->chx[0].rx;
cfg.src_addr_width = DMA_SLAVE_BUSWIDTH_2_BYTES;
cfg.src_maxburst = mcspi->fifo_depth >> 1;
dmaengine_slave_config(dma_rx, &cfg);
rx_sg = kcalloc(array_size, sizeof(struct scatterlist), GFP_KERNEL);
sg_init_table(rx_sg, array_size);
...
dma_map_sg(mcspi->dev, rx_sg,dma_buf_array_size, DMA_FROM_DEVICE);
int len = sg_dma_len(&rx_sg[0]) ;
unsigned char *address = sg_dma_address(&rx_sg[0]) ;

 

  • de définir le type de transfert :
struct dma_async_tx_descriptor *trans;
trans = dmaengine_prep_dma_cyclic(dma_rx, sg_dma_address(&rx_sg[0]),
    sg_dma_len(&rx_sg[0]) * dma_buf_array_size, dma_buf_size,
    DMA_DEV_TO_MEM, DMA_PREP_INTERRUPT | DMA_CTRL_ACK);

ou

trans = dmaengine_prep_slave_sg(dma_rx,
    &rx_sg, dma_buf_array_size,
    DMA_DEV_TO_MEM, DMA_PREP_INTERRUPT | DMA_CTRL_ACK);

 

  • d’installer une « callback » sur la « tasklet » du DMA :
struct dma_async_tx_descriptor *trans;
...
trans->callback = callback;
trans->callback_param = callback_param;
rx_cookie = dmaengine_submit(trans);

 

  • de mettre l’infrastructure en attente de données sur le périphérique :
dma_async_issue_pending(dma_rx);

III – Conclusion

L’écriture d’un pilote n’est pas simple, l’utilisation du DMA le complexifie encore et l’utilisation du mode cyclique de ce dernier est un challenge de plus. Mais c’est à ce prix qu’il est possible d’optimiser l’utilisation des ressources de nos systèmes.

Le DMA est vraiment un outil puissant, et peu utilisé en fin de compte. Nous avons parlé de transfert entre un périphérique et la mémoire, mais il permet aussi de faire des manipulations de données. Cette capacité est peu utilisée dans Linux et vous laisse l’opportunité de tester le développement de pilote DMA. Le Sitara5718 offre deux contrôleurs DMA, proposant 32 et 64 canaux. Les possibilités sont immenses si vous utilisez votre imagination.

Cet article donne une grande partie des informations pour avancer dans votre projet, mais chaque plate-forme, chaque périphérique a son mode de fonctionnement. Et même si les développeurs de Linux veulent uniformiser les API, il vous faudra sûrement commencer par lire la documentation technique des composants utilisés et composer avec les différentes possibilités. Il est difficile de trouver le bon exemple qui sera le plus proche de votre problème.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *