« SE4Binome2025-5 » : différence entre les versions

De projets-se.plil.fr
Aller à la navigation Aller à la recherche
 
(20 versions intermédiaires par 3 utilisateurs non affichées)
Ligne 17 : Ligne 17 :
La liste des composants utilisés pour le bouclier est la suivante:
La liste des composants utilisés pour le bouclier est la suivante:


-5x LEDS rouges
*5x LEDS rouges
 
*5x Résistances 1kohm
-5x Résistances 1kohm


Connecteurs utilisés (tous en 2.54mm):
Connecteurs utilisés (tous en 2.54mm):


-Connecteur arduino R3(disponible dans la librairie d'empreintes KiCad)
*Connecteur arduino R3(disponible dans la librairie d'empreintes KiCad)
 
*6x 1x8 mâle vertical
-6x 1x8 mâle vertical


===== Composants du module SD =====
===== Composants du module SD =====
La liste des circuits intégrés utilisés pour le module SD est la suivante:
La liste des circuits intégrés utilisés pour le module SD est la suivante:


-1x LTC3531
*1x LTC3531
 
*1x74LVC125
-1x74LVC125


les composants hors circuits intégrés utilisés sont les suivants:
les composants hors circuits intégrés utilisés sont les suivants:


-1x Capacité 2.2µF
*1x Capacité 2.2µF
 
*1x Capacité 10µF
-1x Capacité 10µF
*1x Inductance 10µH
 
*3x Résistances 3.3kohms
-1x Inductance 10µH
 
-3x Résistances 3.3kohms


Les connecteurs utilisés sont les suivants:
Les connecteurs utilisés sont les suivants:


-1x 1x8 mâle horizontal  
*1x 1x8 mâle horizontal  
 
*1x Connecteur Molex microSD
-1x Connecteur Molex microSD


==== Schématique des deux cartes ====
==== Schématique des deux cartes ====
Ligne 76 : Ligne 69 :
La liste des circuits intégrés est la suivante:
La liste des circuits intégrés est la suivante:


-Atmega328P(Microcontrôleur)
*Atmega328P(Microcontrôleur)


-FM256WG (mémoire SPI)
*FM256WG (mémoire SPI)


-TMUX1574 (Multiplexeur bidirectionnel 4 bits) (à commander)
*TMUX1574 (Multiplexeur bidirectionnel 4 bits) (à commander)


La liste des composants hors CI est la suivante:
La liste des composants hors CI est la suivante:


-1 oscillateur à quartz 8MHz
*1 oscillateur à quartz 8MHz


-2 Capacités de 22pF  
*2 Capacités de 22pF  


-5 Capacités de 100nF  
*5 Capacités de 100nF  


-1 Capacité de 10µF  
*1 Capacité de 10µF  


-1 Résistance de 1 Mohm
*1 Résistance de 1


-2 Résistances de 1 Kohm
*2 Résistances de 1


-2 LEDs rouges   
*2 LEDs rouges   


Les connecteurs sont tous des connecteurs traversants 2.54 mm, les voici:  
Les connecteurs sont tous des connecteurs traversants 2.54 mm, les voici:  


- 2x3  mâle vertical (ISP)  
*2x3  mâle vertical (ISP)  


- 1x2 mâle vertical (debug, connection série)  
*1x2 mâle vertical (debug, connexion série)  


- 1x8 mâle horizontal (Interface mère-fille)  
*1x8 mâle horizontal (Interface mère-fille)  


- 1x9 mâle vertical (Interface avec écran)
*1x9 mâle vertical (Interface avec écran)


==== Schématique de la carte fille ====
==== Schématique de la carte fille ====
Ligne 123 : Ligne 116 :
==== Vue 3D de la carte ====
==== Vue 3D de la carte ====
Ci-dessous une vue 3D de la carte fille:[[Fichier:Vue3DPCBfille.png|alt=Vue 3D du pcb de la carte fille|gauche|vignette|696x696px|Vue 3D du pcb de la carte fille]]
Ci-dessous une vue 3D de la carte fille:[[Fichier:Vue3DPCBfille.png|alt=Vue 3D du pcb de la carte fille|gauche|vignette|696x696px|Vue 3D du pcb de la carte fille]]
<p style="clear: both;" />
<p style="clear: both;" />A noter que le connecteur en bas de la carte(connecteur mère-fille) sera bien à l'horizontale.
A noter que le connecteur en bas de la carte(connecteur mère-fille) sera bien à l'horizontale.
 
=== Composants à commander ===
TMUX1574DYYR (SOT-23-16 THIN)
 
== Software ==
== Software ==


Ligne 134 : Ligne 122 :


==== Initialisation de la pile ====
==== Initialisation de la pile ====
Cette fonction initialise la pile d’un processus en y plaçant l’adresse de démarrage et en simulant le contexte sauvegardé lors d’une interruption, afin que le processus puisse être restauré et exécuté correctement par l’ordonnanceur.<syntaxhighlight lang="c">
void InitStack(int process_id){
    uint16_t old_sp = SP;
    SP = process_list[process_id].stack_pointer;
    uint16_t address = (uint16_t)(process_list[process_id].process_address);
    asm volatile("push %0" : : "r" (address & 0x00ff) );
    asm volatile("push %0" : : "r" ((address & 0xff00)>>8) );
    // Ensuite, simuler les registres empilés lors d'une vraie ISR
    SAVE_REGISTERS();
    process_list[process_id].stack_pointer = SP;
    SP = old_sp;
}
</syntaxhighlight>


==== Interruption nue ====
==== Interruption nue ====
Cette interruption sauvegarde le contexte du processus courant, appelle l’ordonnanceur pour sélectionner la prochaine tâche, puis restaure le contexte de la tâche élue avant de reprendre l’exécution.<syntaxhighlight lang="c">
ISR(TIMER1_COMPA_vect, ISR_NAKED){    // Procédure d'interruption
    /* Sauvegarde du contexte de la tâche interrompue */
    SAVE_REGISTERS();
    process_list[current_process].stack_pointer = SP;
    /* Appel à l'ordonnanceur */
    scheduler();
    /* Récupération du contexte de la tâche ré-activée */
    SP = process_list[current_process].stack_pointer;
    RESTORE_REGISTERS();
    asm volatile ( "reti" );
}
</syntaxhighlight>Ici, on va venir initialiser notre timer pour générer une interruption toutes les ~20ms.<syntaxhighlight lang="c">
void timer1_init(void) {
    TCCR1A = 0;
    TCCR1B = (1 << WGM12);    // automatic reset mode
    TCCR1B |= (1 << CS12) | (1 << CS10); // prescaler 1024
    OCR1A = NB_CLICKS;
    TIMSK1 = (1 << OCIE1A);  // enable Compare A interrupt
    TCNT1 = 0;
}
</syntaxhighlight>


==== Structure d'un processus ====
==== Structure d'un processus ====
<syntaxhighlight lang="c">
typedef struct tab_process{
    uint16_t stack_pointer;
    void (*process_address)(void);
    enum TaskState state;
    uint16_t sleeping_time;
    uint8_t id;
}Process;
</syntaxhighlight>Grâce à cette structure, on crée un tableau de processus pour stocker tous nos processus avec leurs différentes utilité.<syntaxhighlight lang="c">
extern Process process_list[MAX_PROCESS];
</syntaxhighlight>
==== Fonction wait ====
Le problème d'utiliser _delay_ms, c'est qu'il est bloquant et donc qu'on ne peut pas utiliser le processeur pendant ce temps d'attente. Ce n'est pas optimal et donc on utilise une state machine à 2 états pour gérer si une tâche dort ou non.<syntaxhighlight lang="c">
void start_wait(uint16_t time){ // ms
    if (process_list[current_process].state == AWAKE){
        process_list[current_process].sleeping_time = time;
        process_list[current_process].state = IDLE;
    }
    TIMER1_COMPA_vect(); // Fais sortir de la fonction en faisant un reset, évite de continuer à éxecuter le code
}
</syntaxhighlight>Avec ce système, si une tâche est endormie, on ne la lance pas.


==== Code de l'ordonnanceur ====
==== Code de l'ordonnanceur ====
Parlons de la fonction scheduler, élément central de notre ordonnanceur puisque c'est ici que le choix des tâches à exécuter va être fait. <syntaxhighlight lang="c">
void scheduler(void)
{
    decrement_sleeping_times();
    next_process();
}
</syntaxhighlight>Regardons de plus près ces deux fonctions.
La première, ''decrement_sleeping_times,'' sert à retirer du temps à toutes les tâches endormies pour les rapprocher de leur réveil.
La deuxième, ''next_process'', sert à choisir le prochain processus à démarrer.
Il faut pour cela deux conditions. : '''1. Le processus doit être non nul'''. '''2. Le processus doit être en état AWAKE'''<syntaxhighlight lang="c">
void next_process(void) {
    int next = current_process;
    do {
        next = (next + 1) % MAX_PROCESS;
    } while (process_list[next].process_address == NULL || process_list[next].state == IDLE);
    current_process = next;
}
</syntaxhighlight>Un problème courant avec les délais non bloquants, c'est que toutes les tâches sont endormies, et donc le scheduler ne sait plus quelle tâche choisir.


==== Fonction wait ====
Pour éviter ce problème d'indécision, une tâche fantôme sera toujours présente.<syntaxhighlight lang="c">
void FantomTask(void)
{
    while (1)
    {
        _delay_ms(30);
    }
}
</syntaxhighlight>


==== Exemple de tâches ====
==== Exemple de tâches ====
Une tâche basique est de faire clignoter une LED.<syntaxhighlight lang="c">
void Led2(void)
{
    while (1)
    {
        active_o(LED_2, 2, 'b');
        start_wait(200);
    }
}
</syntaxhighlight>Une tâche un peu plus complexe est la gestion de commandes via UART. La première phase du code consiste à récupérer une commande jusqu'à recevoir un \n ou un \r. Ensuite, on va venir vérifier si cette commande correspond à ''add'', ''rm'', ''ps'' ou ''progs.'' En fonction des cas de figures, des messages textuels sont envoyés pour communiquer avec l'utilisateur.<syntaxhighlight lang="c">
void SerialManager(){
    while(1){
        char buffer[16];
        char commandStr[8];
        unsigned char commandChar ;
        ...
        do{
            commandChar = USART_Receive();
            sprintf(buffer, "%c", commandChar);
            send_data(buffer);
            if (commandChar == '\n' || commandChar == '\r' || commandChar == 13)
                break;
            if (i < sizeof(commandStr)-1)
                commandStr[i++] = commandChar;
        }while(1);
        commandStr[i] = '\0';
        send_data("\n");
        if ( !strcmp(commandStr , "add") ){ //ADD PROCESSES
              ...//L'utilisateur saisit l'adresse d'un processus à ajouter
        }else if( !strcmp(commandStr , "rm") ){ //REMOVE PROCESSES
              ...//L'utilisateur saisit l'adresse d'un processus à retirer
        }
        else if ( !strcmp(commandStr , "ps") ){ //ACTIVE PROCESSES STATUS
            //AFFICHE TOUS LES PROCESSUS ACTIFS (Endormis ou non)
            for(int i = 0; i < MAX_PROCESS ; i++){
                if (process_list[i].process_address != NULL){
                    send_data("ID process:");
                    sprintf(buffer, "%d", process_list[i].id);
                    send_data(buffer);
                    send_data("\r\n");
                    send_data("Address:");
                    sprintf(buffer, "%p", process_list[i].process_address);
                    send_data(buffer);
                    send_data("\r\n");
                    const char * FunctionStr = getNameByAddressFunction(process_list[i].process_address);
                    ...
                    send_data("Name:");
                    sprintf(buffer, "%s\r\n\n", FunctionStr);
                    send_data(buffer);
                }
            }
        }else if ( !strcmp(commandStr , "progs") ){ //MAP PROCESSES
            //AFFICHE TOUS LES PROCESSUS EXISTANTS
            for(int i = 0; i < MAX_PROCESS ; i++){
                if (strcmp(maps_processes[i].name, "")){
                    send_data("Name:");
                    sprintf(buffer, "%s\r\n", maps_processes[i].name);
                    send_data(buffer);
                    send_data("Address:");
                    sprintf(buffer, "%p\r\n", maps_processes[i].process_address);
                    send_data(buffer);
                }
            }
        }
        ....
    }
}
</syntaxhighlight>
==== Ajout et retrait de processus ====
Voici le code pour ajouter un processus.<syntaxhighlight lang="c">
void add_process(void (*_process_address)(void),unsigned char initstack){
    for (int i=0; i<MAX_PROCESS; i++){
        if (process_list[i].process_address == NULL){
            process_list[i].process_address = _process_address;
            process_list[i].stack_pointer = INITIAL_STACK_ADDRESS - PROCESS_SIZE * (nb_process+1);
            process_list[i].state = AWAKE;
            process_list[i].id = i;
            if(initstack) InitStack(i);
            nb_process++;
            break;
        }
    }
}
</syntaxhighlight>A première vue, on peut se demander pourquoi il y a une boucle ''for'' dans un processus d'ajout. Cette boucle cherche simplement un processus avec une adresse ''NULL'' dans le tableau, ce qui correspond à un processus vide. Avec ce système, on peut alors très aisément supprimer un processus, puisqu'il suffira de mettre sa variable ''process_address'' à ''NULL''.
Et comme l'ordonnanceur, ne lancera jamais une tâche qui a ''process_address'' valant ''NULL'', on garde un code simple et avec un système d'ajout et de retrait.
Pour finir, voici le code du retrait de processus :<syntaxhighlight lang="c">
void remove_process(int process_id){
    if (process_list[process_id].process_address != NULL){
        process_list[process_id].process_address = NULL;
        nb_process -=1;
    }
}
</syntaxhighlight>


==== Exemple d'utilisation ====
==== Exemple d'utilisation ====
Voici un exemple de main qui fonctionne et qui initialise plusieurs tâches<syntaxhighlight lang="c">
int main(void){
    USART_Init(MYUBRR);
    init_io();
    init_processes();
    add_process(FantomTask,0);
    add_process(SerialManager, 1);
    add_process(Led3,1);
    add_process(Led4,1);
    //remove_process(4);
    add_process(Led5,1);
    add_process(Led6,1);


=== <u>Carte SD</u> ===
    timer1_init();
    SP = process_list[current_process].stack_pointer;
    sei();
    process_list[current_process].process_address();  // Lancer le premier processus
    while (1) ;
}
</syntaxhighlight>


=== <u>Ecran LCD</u> ===
=== <u>Ecran LCD</u> ===
Ligne 370 : Ligne 563 :


La différence est énorme !
La différence est énorme !
[[Fichier:Montage Duo.mp4|néant|vignette]]
 
Voici la vidéo démonstrative:
[[Fichier:DuoMontage2.mp4|néant|vignette]]
En regardant sur un logiciel de montage, on voit que l'image s'écrit en ~6s sur l'écran, c'est plutôt cohérent avec le calcul, notamment car il faut prendre en compte l'envoi des données en SPI et l'affichage.
 
Ce montage là n'utilise pas la RAM SPI, mais on aurait pu aussi l'utiliser.
 
Pour réutiliser notre montage, voici la démarche à suivre.
 
*Faire make flash sur la carte mère depuis le répertoire Software/DualMode de notre Gitea.
 
*Faire make upload via ICSP sur la carte fille depuis le répertoire Software/GPU.
 
*Monter le shield sur l'Arduino. Choisir le mode en reliant PC3 à GND ou 5V.
 
*Brancher la carte fille sur le J1 de la carte mère en s'assurant du sens de branchement grâces aux flèches blanches sur les PCB
 
*Dans le mode d'envoi d'image, il suffit d'exécuter le programme python disponible dans le répertoire Software/Images.
 
*Toute image peut être transmise, tant qu'elle est au préalable convertie en .bmp dans les dimensions de l'écran (480x320). Une image polytech.bmp est déjà prête à être envoyée.
 
=== <u>Proto-système de fichiers - Carte SD / SPI RAM</u> ===
Afin que le CPU et le GPU puissent communiquer, nous avons imaginé un système de fichiers qui soit commun aux deux unités deux traitement pour qu'ils puissent transférer les données à travers la SPIRAM.
 
Par ailleurs, nous souhaitions conserver le même système de fichiers pour la carte SD, et avoir ainsi un OS fonctionnant sous le principe de "tout est fichier", un peu à l'image des systèmes UNIX.
 
Par souci de factorisation, nous avions pour objectif de rassembler les méthodes liées au système de fichiers et de les rendre indépendantes du média, que ce soit la SPIRAM ou la carte SD.
 
==== Système de fichiers ====
Pour tester le système de fichiers, nous l'avons initialement développé pour n'être compatible avec la SPIRAM, mais l'idée est que l'écriture et la lecture soient indépendantes du média comme dit précédemment, et que les fonctions
 
pour lire et écrire soient des wrappers prenant en argument le média avec lequel communiquer, mais cela n'a pas été implémenté.
 
 
Initialement, nous avons créé deux types de données qui peuvent être édités, le type fichier Lfile et le type dossier Lfolder:<syntaxhighlight lang="c" line="1">
typedef struct Lfile
{
    uint8_t header[HDR_SIZE_FILE];
    uint8_t content[CTNT_SIZE_FILE];
}Lfile_t;
 
typedef struct Lfolder
{
    uint8_t header[HDR_SIZE_FOLDER];
    uint16_t addresses[MAX_FILES];
}Lfolder_t;
 
</syntaxhighlight>On observe assez rapidement que les deux types sont très similaires, la seule différence étant que le dossier ne contiendra que des adresses des sous dossiers ou des fichiers contenus. Par souci de simplicité nous avons décidé de les isoler pour mieux pouvoir déboguer par la suite.
 
L'écriture d'un fichier dans la SPIRAM se fait de la façon suivante (la fonction writebuf_ram est détaillée dans la partie SPIRAM):<syntaxhighlight lang="c" line="1">
#define SOH 0x01
#define SOT 0x02
#define EOT 0x03
static uint8_t soh = SOH; //start of header
static uint8_t sot = SOT; //start of text
static uint8_t eot = EOT; //end of text
 
void WriteFile(Lfile_t file,uint16_t address) //converts Lfile to raw data
{
    writebuf_ram(address,&soh,1);//borne de début de fichier
    writebuf_ram(address+1,file.header,HDR_SIZE_FILE); //écriture de l'entête
    writebuf_ram(address+HDR_SIZE_FILE+1,&sot,1);//borne de fin d'entête et début du contenu
    writebuf_ram(address+HDR_SIZE_FILE+2,file.content,CTNT_SIZE_FILE); //écriture du contenu
    writebuf_ram(address+HDR_SIZE_FILE+CTNT_SIZE_FILE+2,&eot,1);// borne de fin du fichier
}
</syntaxhighlight>La lecture se fait de façon réciproque, les octets entre les bornes sont extraits et reconvertis en entête et contenu de fichier Lfile.
 
Maintenant, pour pouvoir naviguer dans notre système de fichiers, il faut déjà pouvoir se positionner, c'est pourquoi nous avons un pointeur de position qui nous indique dans quel dossier nous nous trouvons:<syntaxhighlight lang="c" line="1">
uint16_t locationpointer;
</syntaxhighlight>Pour avoir un premier aperçu du fonctionnement du système de fichiers, nous avons créé une fonction qui ajoute un fichier au dossier courant (dont l'adresse est pointée par le locationpointer):<syntaxhighlight lang="c" line="1">
void AddFileToFolder(char foldername[],char filename[],Lfiletype_t type)
{
    Lfile_t folder = ReadFile(locationpointer);//acquisiton du dossier courant
    int numberfiles = folder.content[0]; //acquisition du nombre de fichiers dans le dossioer
    uint16_t address = 0x0404; //adresse quelconque
    CreateFile(filename,address,type); // écrit un fichier vide à un emplacement quelconque en mémoire
    //écriture de l'adresse du fichier qui sera contenu dans le dossier
    //le fichier pourra donc ne pas être adjacent au dossier
    folder.content[2*numberfiles+1] = (address>>8); // MSB
    folder.content[2*numberfiles+2] = address&0xff; //LSB
    folder.content[0]++;//nombre de fichiers contenus dans le dossier
    WriteFile(folder,locationpointer);//création du dossier
}
</syntaxhighlight>A partir de ce point, il est possible d'implémenter la fameuse commande LS des systèmes UNIX:<syntaxhighlight lang="c" line="1">
void Ls()
{
    Lfile_t currentFolder = ReadFile(locationpointer); // acquisition du dossier courant
    char namebuffer[MAX_NAME_SIZE]; //buffer pour contenir le nom du fichier sélectionné
    Lfile_t currentfile;
    uint16_t address;
    ExtractName(currentFolder,namebuffer); // acquisition du nom du dossier
    //affichage sur le port série du dossier courant
    send_data((unsigned char*)"in folder ");
    send_data((unsigned char*)namebuffer);
 
    for(int i = 0; i<currentFolder.content[0];i++)
    {
        address = 0x0000;// mise à 0 du buffer
        //construction de l'adresse du fichier contenu dans le dossier
        address |= (((uint16_t)currentFolder.content[2*i+1])<<8); //MSB
        address |= (((uint16_t)currentFolder.content[2*i+2])&0x00FF); //LSB
        currentfile = ReadFile(address); //acquisition du fichier à l'adresse
        ExtractName(currentfile,namebuffer); //acquisition du nom du fichier à l'adresse
        //affichage sur le port série du fichier
        send_data((unsigned char*)"\t");
        send_data((unsigned char*)namebuffer);
        send_data((unsigned char*)"\n\r");
    }
}
 
</syntaxhighlight>Avec le main suivant: <syntaxhighlight lang="c" line="1">
int main()
{
    init_ram();
    USART_Init(MYUBRR);
    CreateFile("/",0x0000,FOLDERTYPE);
    locationpointer = 0x0000;
    AddFileToFolder("/","fichier1",FILETYPE);
    Ls();
    return 0;
}
</syntaxhighlight>On obtient sur le port série le résultat suivant:<syntaxhighlight lang="linux-config" line="1">
in folder /
    fichier1
</syntaxhighlight>
 
==== Carte SD ====
 
Initialement, la carte SD est dans un mode appelé "mode SD", et ne peut donc pas transférer de données en SPI. Il faut une phase d'initialisation dans laquelle nous allons indiquer à la carte SD que nous souhaitons basculer en "mode SPI", mais cela à une vitesse de bus SPI plus faible de 125 kHz. Le cycle d'initialisation se fait de la façon suivante:<syntaxhighlight lang="c" line="1">
//code d'initialisation de la carte SD
uint8_t sd_init(void) {
    spi_init();
    SD_CS_LOW();
    // attente de 80 cycles
    for(uint8_t i=0;i<10;i++) spi_transfer(0xFF); // dummy byte
 
    if(sd_send_cmd(0,0,0x95)!=1) return 1;
    uint8_t r;
 
    do {
        //envoi commande 55
        r = sd_send_cmd(55,0,0x00);
        //envoi commande de réponse 41
        r = sd_send_cmd(41,0,0x00);
        //attente que la réponse soit correcte, soit r=0x00
    } while(r!=0);
 
    SD_CS_HIGH();
    spi_transfer(0xFF);
    return 0;
}
</syntaxhighlight>Une fois l'initialisation faite, on peut augmenter la vitesse du bus SPI à son maximum, dans notre cas 8MHz. Il suffit ensuite d'envoyer la commande que l'on souhaite exécuter sur la carte SD, (commande 17 pour la lecture, et commande 24 pour l'écriture, avec en argument l'adresse du bloc à lire ou écrire). En lecture, on attend le bit 0xFE, en écriture, on l'envoie à la carte SD. Dans les deux cas, il s'agit d'un symbole de démarrage qui précède la transmission des données. Pendant 512 cycles (la taille d'un bloc), on lit les données envoyées en réponse à un "dummy byte", un octet qui ne sert à rien d'autre que pour recevoir sa réponse. Pour l'écriture, on remplace le dummy byte par l'octet que l'on souhaite écrire. Dans les deux cas, on finit la transmission par deux dummy bytes.
 
==== SPIRAM ====
La SPIRAM est plus simple à initialiser que la carte SD, puisque déjà prête à transmettre en SPI, on peut donc déjà mettre le bus SPI à 8 MHz. La SPIRAM dispose de plusieurs mécanismes de sécurité des données en écriture. C'est pourquoi avant chaque écriture , nous devons envoyer une commande WREN (write enable) : <syntaxhighlight lang="c" line="1">
// écriture dans la spiram
int writebuf_ram(uint16_t addr,uint8_t buf[], uint16_t size)
{
if(buf == NULL || size == 0) return -1;
 
if(addr+size > 0x7FFF) return -2;
 
select_ram();
spi_echange(WREN_CMD); // commande write enable
deselect_ram();
 
select_ram();
spi_echange(WRITE_CMD); //commande précédant l'écriture
 
spi_echange(addr>>8);//MSB
spi_echange(addr & 0x7F);//LSB
 
for(uint16_t i =0; i<size; i++) //envoi d'octets
{
spi_echange(buf[i]);
}
//arrêt de la transmission lorsque CS à 1
deselect_ram();
return 0;
}
 
</syntaxhighlight>Il faut prêter attention à la taille du buffer transmis, puisque la SPIRAM continuera d'écrire tant que des octets sont transmis, même si ils sont comme valeur 0x00. Seule une mise à 1 du pin CS de la SPIRAM permettra l'arrêt de l'écriture. Le problème avec cette méthode de transmission est qu'à chaque écriture il faut envoyer la commande WREN, c'est pourquoi nous avons imaginé la fonction suivante pour ne pas avoir à l'envoyer à chaque fois, quand des données trop lourdes pour la RAM de l'atmega sont manipulées, par exemple, une image:<syntaxhighlight lang="c" line="1">
//modes d'exécutions
typedef enum transmit {WINIT,RINIT,TRANSMITTING,END}transmit_t;
 
uint8_t transmit_ram(uint16_t addr,transmit_t state,uint8_t data)
{
uint8_t received;
switch (state)
{
case WINIT: //initialisation de l'écriture
print_hex(0x00);
select_ram();
spi_echange(WREN_CMD);
deselect_ram();
select_ram();
spi_echange(WRITE_CMD);
spi_echange(addr>>8); //MSB
spi_echange(addr & 0x7F); //LSB
break;
case RINIT: //initialisation de la lecture
select_ram();
spi_echange(READ_CMD);
spi_echange(addr>>8); //MSB
spi_echange(addr & 0x7F); //LSB
break;
case TRANSMITTING: //transmission à la volée
received = spi_echange(data);
return received;
 
case END: //fin de transmission
deselect_ram();
 
break;
}
return 0xFF;
}
</syntaxhighlight>

Version actuelle datée du 26 janvier 2026 à 00:01

Objectif

L'objectif de notre groupe est de réaliser une carte fille compatible avec la carte mère réalisée par le binôme 2. Cette carte que nous réalisons doit pouvoir permettre au pico-ordinateur d'afficher des informations sur un écran. Pour complexifier la tâche, nous utiliserons un affichage LCD graphique, qui nous permettra d'effectuer un affichage simple initialement, mais aussi de créer des primitives graphiques dans un second temps.

En plus de cette carte, nous devons réaliser un "OS primitif" qui fonctionnera sur une carte Arduino, à la fois pour pouvoir comprendre le fonctionnement d'un ordonnanceur, mais aussi pour pouvoir tester notre carte de notre côté avant de l'intégrer au pico-ordinateur. L'Arduino sera complété d'un shield afin d'avoir une interface similaire à celle qui sera disponible sur la carte mère, que nous devons réaliser également. Aussi, ce shield sera accompagné d'un module de carte SD pour pouvoir charger des programmes "à la volée" depuis la carte SD.

Dépôt GIT

le dépôt git de ce projet est le suivant: https://gitea.plil.fr/avanghel/SE4-Pico-B5

Partie Hardware

Bouclier Arduino & carte SD

Le bouclier Arduino que nous réalisons servira d'interface entre l'Arduino et la carte fille, de façon à ce qu'un arduino avec le programme de la carte mère et le shield d'installé ait le même comportement que la carte mère. Le module SD pourra se brancher sur un connecteur de la carte mère et donc du bouclier aussi.

Composants utilisés

Composants du bouclier arduino

La liste des composants utilisés pour le bouclier est la suivante:

  • 5x LEDS rouges
  • 5x Résistances 1kohm

Connecteurs utilisés (tous en 2.54mm):

  • Connecteur arduino R3(disponible dans la librairie d'empreintes KiCad)
  • 6x 1x8 mâle vertical
Composants du module SD

La liste des circuits intégrés utilisés pour le module SD est la suivante:

  • 1x LTC3531
  • 1x74LVC125

les composants hors circuits intégrés utilisés sont les suivants:

  • 1x Capacité 2.2µF
  • 1x Capacité 10µF
  • 1x Inductance 10µH
  • 3x Résistances 3.3kohms

Les connecteurs utilisés sont les suivants:

  • 1x 1x8 mâle horizontal
  • 1x Connecteur Molex microSD

Schématique des deux cartes

Ci dessous la schématique pour les deux cartes:

Schématique du bouclier arduino et du module SD
Schématique du bouclier arduino et du module SD

PCB du bouclier

Ci dessous le PCB du bouclier arduino:

PCB de la carte bouclier arduino.
PCB de la carte bouclier arduino.

PCB du module SD

Ci dessous le PCB du module SD. Il est à noter que le connecteur SD est à l'envers, et que l'insertion d'une carte SD est difficile de ce fait. Il est recommandé de changer le routage du module SD si le projet est réutilisé.

PCB du module SD
PCB du module SD

Vue 3D des deux cartes

Ci-dessous la vue 3D de la carte du bouclier et du module SD:

Vue 3D du bouclier arduino et du module SD
Vue 3D du bouclier arduino et du module SD

Carte Fille

Le pilotage d'un écran graphique est plus complexe que pour un écran à caractère, puisque la création d'une image se fait pixel par pixel (plutôt que par caractère pour l'écran à caractère). De plus, le microcontrôleur de la carte mère(que l'on appelera CPU ici) doit gérer d'autres cartes filles, il n'est donc pas souhaitable qu'il traite par lui-même une tâche aussi lourde.

Notre carte fille devra donc être composée elle aussi d'un microcontrôleur(que l'on appelera GPU) qui interprètera des données reçues par le CPU (par exemple, un caractère à une certaine position) et transmettra les données à afficher à l'écran LCD graphique. Par ailleurs, nous fixerons l'écran graphique sur la carte fille.

Composants utilisés

La liste des circuits intégrés est la suivante:

  • Atmega328P(Microcontrôleur)
  • FM256WG (mémoire SPI)
  • TMUX1574 (Multiplexeur bidirectionnel 4 bits) (à commander)

La liste des composants hors CI est la suivante:

  • 1 oscillateur à quartz 8MHz
  • 2 Capacités de 22pF
  • 5 Capacités de 100nF
  • 1 Capacité de 10µF
  • 1 Résistance de 1 MΩ
  • 2 Résistances de 1 KΩ
  • 2 LEDs rouges

Les connecteurs sont tous des connecteurs traversants 2.54 mm, les voici:

  • 2x3 mâle vertical (ISP)
  • 1x2 mâle vertical (debug, connexion série)
  • 1x8 mâle horizontal (Interface mère-fille)
  • 1x9 mâle vertical (Interface avec écran)

Schématique de la carte fille

Schématique de la carte fille
Schématique de la carte fille

PCB de la carte

Ci-dessous le PCB de la carte imprimée:

PCB de la carte fille
PCB de la carte fille

et ci dessous un zoom sur la partie logique:

zoom sur la partie logique de la carte fille
zoom sur la partie logique de la carte fille

Vue 3D de la carte

Ci-dessous une vue 3D de la carte fille:

Vue 3D du pcb de la carte fille
Vue 3D du pcb de la carte fille

A noter que le connecteur en bas de la carte(connecteur mère-fille) sera bien à l'horizontale.

Software

Ordonnanceur

Initialisation de la pile

Cette fonction initialise la pile d’un processus en y plaçant l’adresse de démarrage et en simulant le contexte sauvegardé lors d’une interruption, afin que le processus puisse être restauré et exécuté correctement par l’ordonnanceur.

void InitStack(int process_id){
    uint16_t old_sp = SP;
    SP = process_list[process_id].stack_pointer;
    uint16_t address = (uint16_t)(process_list[process_id].process_address);
    asm volatile("push %0" : : "r" (address & 0x00ff) );
    asm volatile("push %0" : : "r" ((address & 0xff00)>>8) );

    // Ensuite, simuler les registres empilés lors d'une vraie ISR
    SAVE_REGISTERS();
    process_list[process_id].stack_pointer = SP;
    SP = old_sp;
}

Interruption nue

Cette interruption sauvegarde le contexte du processus courant, appelle l’ordonnanceur pour sélectionner la prochaine tâche, puis restaure le contexte de la tâche élue avant de reprendre l’exécution.

ISR(TIMER1_COMPA_vect, ISR_NAKED){    // Procédure d'interruption
    /* Sauvegarde du contexte de la tâche interrompue */
    SAVE_REGISTERS();
    process_list[current_process].stack_pointer = SP;
    /* Appel à l'ordonnanceur */
    scheduler();
    /* Récupération du contexte de la tâche ré-activée */
    SP = process_list[current_process].stack_pointer;
    RESTORE_REGISTERS();
    asm volatile ( "reti" );
}

Ici, on va venir initialiser notre timer pour générer une interruption toutes les ~20ms.

void timer1_init(void) {
    TCCR1A = 0;
    TCCR1B = (1 << WGM12);     // automatic reset mode
    TCCR1B |= (1 << CS12) | (1 << CS10); // prescaler 1024
    OCR1A = NB_CLICKS;
    TIMSK1 = (1 << OCIE1A);   // enable Compare A interrupt
    TCNT1 = 0;
}

Structure d'un processus

typedef struct tab_process{
    uint16_t stack_pointer;
    void (*process_address)(void);
    enum TaskState state;
    uint16_t sleeping_time;
    uint8_t id;
}Process;

Grâce à cette structure, on crée un tableau de processus pour stocker tous nos processus avec leurs différentes utilité.

extern Process process_list[MAX_PROCESS];

Fonction wait

Le problème d'utiliser _delay_ms, c'est qu'il est bloquant et donc qu'on ne peut pas utiliser le processeur pendant ce temps d'attente. Ce n'est pas optimal et donc on utilise une state machine à 2 états pour gérer si une tâche dort ou non.

void start_wait(uint16_t time){ // ms
    if (process_list[current_process].state == AWAKE){
        process_list[current_process].sleeping_time = time;
        process_list[current_process].state = IDLE;
    }
    TIMER1_COMPA_vect(); // Fais sortir de la fonction en faisant un reset, évite de continuer à éxecuter le code
}

Avec ce système, si une tâche est endormie, on ne la lance pas.

Code de l'ordonnanceur

Parlons de la fonction scheduler, élément central de notre ordonnanceur puisque c'est ici que le choix des tâches à exécuter va être fait.

void scheduler(void)
{
    decrement_sleeping_times();
    next_process();
}

Regardons de plus près ces deux fonctions.

La première, decrement_sleeping_times, sert à retirer du temps à toutes les tâches endormies pour les rapprocher de leur réveil.

La deuxième, next_process, sert à choisir le prochain processus à démarrer.

Il faut pour cela deux conditions. : 1. Le processus doit être non nul. 2. Le processus doit être en état AWAKE

void next_process(void) {
    int next = current_process;
    do {
        next = (next + 1) % MAX_PROCESS;
    } while (process_list[next].process_address == NULL || process_list[next].state == IDLE);
    current_process = next;
}

Un problème courant avec les délais non bloquants, c'est que toutes les tâches sont endormies, et donc le scheduler ne sait plus quelle tâche choisir. Pour éviter ce problème d'indécision, une tâche fantôme sera toujours présente.

void FantomTask(void)
{
    while (1)
    {
        _delay_ms(30);
    }
}

Exemple de tâches

Une tâche basique est de faire clignoter une LED.

void Led2(void)
{
    while (1)
    {
        active_o(LED_2, 2, 'b');
        start_wait(200);
    }
}

Une tâche un peu plus complexe est la gestion de commandes via UART. La première phase du code consiste à récupérer une commande jusqu'à recevoir un \n ou un \r. Ensuite, on va venir vérifier si cette commande correspond à add, rm, ps ou progs. En fonction des cas de figures, des messages textuels sont envoyés pour communiquer avec l'utilisateur.

void SerialManager(){
    while(1){
        char buffer[16];
        char commandStr[8];
        unsigned char commandChar ;
        ...
        do{
            commandChar = USART_Receive();
            sprintf(buffer, "%c", commandChar);
            send_data(buffer);
            if (commandChar == '\n' || commandChar == '\r' || commandChar == 13)
                break;

            if (i < sizeof(commandStr)-1)
                commandStr[i++] = commandChar;
        }while(1);
        commandStr[i] = '\0';
        send_data("\n");
        if ( !strcmp(commandStr , "add") ){ //ADD PROCESSES
               ...//L'utilisateur saisit l'adresse d'un processus à ajouter
        }else if( !strcmp(commandStr , "rm") ){ //REMOVE PROCESSES
               ...//L'utilisateur saisit l'adresse d'un processus à retirer
        }
        else if ( !strcmp(commandStr , "ps") ){ //ACTIVE PROCESSES STATUS
            //AFFICHE TOUS LES PROCESSUS ACTIFS (Endormis ou non)
            for(int i = 0; i < MAX_PROCESS ; i++){
                if (process_list[i].process_address != NULL){
                    send_data("ID process:");
                    sprintf(buffer, "%d", process_list[i].id);
                    send_data(buffer);
                    send_data("\r\n");
                    send_data("Address:");
                    sprintf(buffer, "%p", process_list[i].process_address);
                    send_data(buffer);
                    send_data("\r\n");
                    const char * FunctionStr = getNameByAddressFunction(process_list[i].process_address);
                    ...
                    send_data("Name:");
                    sprintf(buffer, "%s\r\n\n", FunctionStr);
                    send_data(buffer);
                }
            }
        }else if ( !strcmp(commandStr , "progs") ){ //MAP PROCESSES
            //AFFICHE TOUS LES PROCESSUS EXISTANTS
            for(int i = 0; i < MAX_PROCESS ; i++){
                if (strcmp(maps_processes[i].name, "")){
                    send_data("Name:");
                    sprintf(buffer, "%s\r\n", maps_processes[i].name);
                    send_data(buffer);
                    send_data("Address:");
                    sprintf(buffer, "%p\r\n", maps_processes[i].process_address);
                    send_data(buffer);
                }
            }
        }
        ....
    }
}

Ajout et retrait de processus

Voici le code pour ajouter un processus.

void add_process(void (*_process_address)(void),unsigned char initstack){
    for (int i=0; i<MAX_PROCESS; i++){
        if (process_list[i].process_address == NULL){
            process_list[i].process_address = _process_address;
            process_list[i].stack_pointer = INITIAL_STACK_ADDRESS - PROCESS_SIZE * (nb_process+1);
            process_list[i].state = AWAKE;
            process_list[i].id = i;
            if(initstack) InitStack(i);
            nb_process++;
            break;
        }
    }
}

A première vue, on peut se demander pourquoi il y a une boucle for dans un processus d'ajout. Cette boucle cherche simplement un processus avec une adresse NULL dans le tableau, ce qui correspond à un processus vide. Avec ce système, on peut alors très aisément supprimer un processus, puisqu'il suffira de mettre sa variable process_address à NULL.

Et comme l'ordonnanceur, ne lancera jamais une tâche qui a process_address valant NULL, on garde un code simple et avec un système d'ajout et de retrait.

Pour finir, voici le code du retrait de processus :

void remove_process(int process_id){
    if (process_list[process_id].process_address != NULL){
        process_list[process_id].process_address = NULL;
        nb_process -=1;
    }
}

Exemple d'utilisation

Voici un exemple de main qui fonctionne et qui initialise plusieurs tâches

int main(void){
    USART_Init(MYUBRR);
    init_io();
    init_processes();
    add_process(FantomTask,0);
    add_process(SerialManager, 1);
    add_process(Led3,1);
    add_process(Led4,1);
    //remove_process(4);
    add_process(Led5,1);
    add_process(Led6,1);

    timer1_init();
    SP = process_list[current_process].stack_pointer;
    sei();
    process_list[current_process].process_address();  // Lancer le premier processus
    while (1) ;
}

Ecran LCD

Pour notre carte fille, nous utiliserons l'écran Fermion: 3.5” 480x320 TFT LCD Capacitive. Voyons le code pour communiquer avec l'écran.

Initialisation

Tout d'abord, la carte utilisant le driver ILI9488 pour communiquer avec l'écran, nous devons initialiser la communication.

Il faut savoir que ce driver reçoit soit des commandes, soit des données, et donc nous allons souvent utiliser ces 2 fonctions :

void tft_command(uint8_t cmd) {
    active_o(TFT_DC, LOW, 'd');   // DC low = command
    enable_spi();
    spi_echange(cmd);
    disable_spi();
}

void tft_data(uint8_t data) {
    active_o(TFT_DC, HIGH, 'd');   // DC high = data
    enable_spi();
    spi_echange(data);
    disable_spi();
}

Maintenant que nous avons ces fonctions, nous pouvons initialiser notre écran.

void tft_init() {
    // RESET
    tft_reset();

    // software reset
    tft_command(0x01);
    _delay_ms(100);

    ... //Suite de commandes utilisant tft_command et tft_data

    active_o(CS, HIGH, 'b');
    _delay_ms(100);
}

Par ailleurs, il faut initialiser notre SPI pour l'activer en mode maître et choisir la vitesse voulue, dans notre cas 8MHz.

void spi_init(void){
    SPCR = (1 << SPE) | (1 << MSTR);    //Active le SPI en mode Maitre
    SPSR = (1 << SPI2X);                // SPI2X = 1 → diviseur final = 2
}

Afficher un rectangle de couleur

Pour afficher un rectangle de couleur, il faut premièrement saisir les coordonnées concernées.

void tft_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) {
    //tft_command(0x2C); // Memory Write
    tft_command(0x2A); // Column Address Set
    tft_data(x0 >> 8);
    tft_data(x0 & 0xFF);
    tft_data(x1 >> 8);
    tft_data(x1 & 0xFF);

    tft_command(0x00);
    tft_command(0x2B); // Page Address Set
    tft_data(y0 >> 8);
    tft_data(y0 & 0xFF);
    tft_data(y1 >> 8);
    tft_data(y1 & 0xFF);
    tft_command(0x00);
    tft_command(0x2C); // Memory Write
}

Ensuite, il faut parcourir chaque coordonnée pour venir lui associer une couleur.

void tft_fill_rect(uint16_t color, uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) {
    tft_set_window(x0, y0, x1, y1);

    uint8_t r = ((color >> 11) & 0x1F) << 3;
    uint8_t g = ((color >> 5) & 0x3F) << 2;
    uint8_t b = (color & 0x1F) << 3;

    active_o(TFT_DC, HIGH, 'd');
    enable_spi();
    for (uint32_t i = 0; i < (uint32_t)(x1 - x0 + 1) * (uint32_t)(y1 - y0 + 1); i++) {
        spi_echange(r);
        spi_echange(g);
        spi_echange(b);
    }
    disable_spi();
}

Nous avons désormais un rectangle qui peut s'afficher de toutes les couleurs et de toutes les dimensions !

Afficher des caractères

L'affichage de caractères est un enjeu crucial dans notre projet de PicoOrdinateur car il permet de voir les commandes saisies par un utilisateur. Cependant, on ne peut pas écrire de caractère si facilement que ça, c'est pour ça que nous allons devoir utiliser une Bitmap qui nous fournit comment écrire une lettre en 5x7 pixels. Voici le code bitmap que nous avons pris de GitHub.

struct Font {
    unsigned char letter;
    unsigned char code[7][6];  // 5 caractères + '\0'
};

static const struct Font font[] = {
    { ' ', {
        "     ",
        "     ",
        "     ",
        "     ",
        "     ",
        "     ",
        "     " }},
    { 'A', {
        " ### ",
        "#   #",
        "#   #",
        "#   #",
        "#####",
        "#   #",
        "#   #" }},
    { 'B', {
        "#### ",
        "#   #",
        "#   #",
        "#### ",
        "#   #",
        "#   #",
        "#### " }},
......

Le code ci-dessus est pratique car visuel mais peu optimisé d'un point de vue mémoire car chaque information est codé par un unsigned char alors même qu'il contient seulement une information binaire. Nous pouvons alors faire 2 changements majeurs. 1. Stocker ceci dans la flash et non dans la RAM grâce à PROGMEM. 2. Représenter chaque ligne via un uint8_t. Voici la nouvelle structure utilisée.

struct Font {
   unsigned char letter;
   uint8_t rows[7];   // chaque bit = un pixel (#=1, espace=0)
};
const struct Font font[] PROGMEM = {

    { ' ', { 0x00,0x00,0x00,0x00,0x00,0x00,0x00 } },

    { 'A', { 0x0E,0x11,0x11,0x11,0x1F,0x11,0x11 } },
    { 'B', { 0x1E,0x11,0x11,0x1E,0x11,0x11,0x1E } },
    { 'C', { 0x0E,0x11,0x10,0x10,0x10,0x11,0x0E } },
    { 'D', { 0x1E,0x11,0x11,0x11,0x11,0x11,0x1E } },
...

Nous pouvons voir que désormais, le stockage des lettres est bien moins lourd. Prenons le cas de A : 0x0E équivaut à 01110, ce qui est bien la représentation souhaitée pour la première ligne d'affichage du A.

01110
10001
10001
10001
11111
10001
10001

On retrouve bien la forme du A de la librairie d'avant, mais en utilisant beaucoup moins de place ! Pour passer de cette bitmap à l'affichage, voilà la fonction que nous utilisons :

int auto_print_char_on_screen(uint16_t color, unsigned char c, uint16_t scale)
{
    // Saut de ligne automatique
    if ((offset_x + CHAR_ESPACEMENT * scale) >= (AUTO_WINDOW_WIDTH - AUTO_BORDER_SPACE - CHAR_WIDTH * scale - 1)) {
        clear_page(scale);
        offset_y = (offset_y + LINE_ESPACEMENT * scale) % (AUTO_WINDOW_HEIGHT - AUTO_BORDER_SPACE - CHAR_HEIGHT * scale - 1);
    }

    offset_x = (offset_x + CHAR_ESPACEMENT * scale) % (AUTO_WINDOW_WIDTH - AUTO_BORDER_SPACE - CHAR_WIDTH * scale - 1);
    if (c == '\n' || c == '\r' || c == 13) {
        offset_x = 0;
        clear_page(scale);
        offset_y = (offset_y + LINE_ESPACEMENT * scale) % (AUTO_WINDOW_HEIGHT - AUTO_BORDER_SPACE - CHAR_HEIGHT * scale - 1);
        return 0;
    }

    const uint8_t* rows = get_font_bitmap(c);
    if (rows == NULL) {
        return 1;   // caractère non trouvé
    }
    // Dessin du caractère 5×7 avec scaling
    for (uint8_t py = 0; py < 7 * scale; py++) {
        uint8_t row_bits = pgm_read_byte(&rows[py / scale]);
        for (uint8_t px = 0; px < 5 * scale; px++) {
            uint8_t bit = (row_bits >> (4 - (px / scale))) & 1;

            uint16_t draw_x = AUTO_WINDOW_X + AUTO_BORDER_SPACE + offset_x + px;
            uint16_t draw_y = AUTO_WINDOW_Y + AUTO_BORDER_SPACE + offset_y + py;

            if (bit) {
                tft_draw_pixel(draw_x, draw_y, color);
            } else {
                tft_draw_pixel(draw_x, draw_y, BACKGROUND);
            }
        }
    }
    return 0;
}

La première partie permet de gérer automatiquement les sauts de ligne par rapport aux \n reçus et à la taille de l'écran. Ainsi, en recevant un flux de caractères, tout le texte se dispose de manière cohérente. Ensuite, on recherche l'index associé au caractère que l'on veut écrire. Par la suite, on parcourt chaque pixel du caractère de dimension 5*7 pour venir appliquer la couleur de la lettre ou la couleur du fond. Par ailleurs, on multiplie les dimensions par un facteur d'agrandissement pour pouvoir changer la taille des caractères.

Nous pouvons maintenant afficher des caractères, mais qu'en est-il des chaînes des caractères ?

Afficher une chaîne de caractères

Nous avons une autre fonction, sans le préfixe auto, qui permet d'afficher un texte où on veut à l'écran, pratique notamment pour écrire des élément UI en dehors de la boîte de texte. C'est par exemple le cas du texte "TERMINAL".

int print_char_on_screen(uint16_t color, unsigned char c, uint16_t x, uint16_t y, uint16_t scale);
int print_string_on_screen(uint16_t color, unsigned char *str, uint16_t x, uint16_t y, uint16_t scale);

Nous avons donc tout ce qu'il nous faut pour afficher une chaîne de caractères !

Démonstration

Exemple basique écriture via UART

Nous voyons dans cette vidéo, la démonstration de l'ensemble des fonctions vues ci-dessus. Dans le cas de ce montage, le TX de la carte mère est relié au RX de la carte écran, permettant de récupérer les données facilement, permettant d'éviter la gestion des bus SPI, qui aurait dû être initialement géré par le multiplexeur bidirectionnel 4 bits TMUX1574.

Remarque : L'affichage du saut de ligne n'est pas le même entre minicom et notre écran, tout simplement car minicom interprète la touche Entrée comme \r, peu pratique dans le cas de notre écran, nous avons donc une règle qui transforme les \r en \n pour que les lignes soient correctement sautés sur notre écran !

Exemple via communication UART puis SPI

Voici un exemple fonctionnel d'une communication en 2 étapes. Premièrement, la carte mère envoie des informations en SPI à la carte fille. Puis, une fois la touche Entrée pressé, la carte fille passe du mode SPI Slave à un mode SPI Master. Celle-ci initialise alors l'écran et affiche à l'écran les caractère précédemment écrits. On ne peut pas aller plus loin dans cette configuration à cause du bus SPI qu'on ne peut pas interchanger.

Affichage comme tâche de l'ordonnanceur

Comme nous avions un ordonnanceur fonctionnel, nous voulions vraiment envoyer les caractères à la carte fille, et ce, en passant par une tâche de l'ordonnanceur. En voici un exemple fonctionnel.

Par ailleurs, ce n'est pas visible sur la vidéo mais nous avons réussi à utiliser correctement les options d'ajouts et de retrait de tâches ou encore d'affichage des tâches en cours, tout en ayant l'écran connecté. Cependant, même avec la police la plus petite, l'écran a une taille limitée, ce qui rendait la lecture peu pratique. Nous avons donc voulu créer un montage, plus visuel, plus intuitif, qui permet de montrer facilement notre travail.

Montage Duo

Ce montage davantage parlant, c'est celui-ci, il combine nos travaux pour permettre l'utilisation de l'écran de façon différentes en fonction de la tension d'entrée du pin PC3 de la carte mère. En effet, en reliant PC3 à GND, l'écran démarrera dans son mode classique d'affichage, avec un flux de caractères qui peut lui être envoyé via UART.

En reliant PC3 à 5V, l'écran passera désormais dans un mode de réception de flux également, mais celui d'une image. Pour afficher une image, il faut une quantité de donnée importante, et comme nous avons décidé d'envoyer par UART grâce à notre script sendImage.py, nous devons augmenter le baudrate. Nous le passons donc à 921600 au lieu de 9600 précédemment. Grâce à ce débit rapide, nous pouvons envoyer une image en peu de temps.

Calcul du temps d'envoi :

Dans notre cas, il y a 153600 pixels. Pour chacun d'entre eux, on envoie 3 octets.


Avec un baudrate de 9600, la transmission d'une image dure 384 secondes.

Avec un baudrate de 921600 , la transmission d'une image dure 4 secondes.


La différence est énorme !

Voici la vidéo démonstrative:

En regardant sur un logiciel de montage, on voit que l'image s'écrit en ~6s sur l'écran, c'est plutôt cohérent avec le calcul, notamment car il faut prendre en compte l'envoi des données en SPI et l'affichage.

Ce montage là n'utilise pas la RAM SPI, mais on aurait pu aussi l'utiliser.

Pour réutiliser notre montage, voici la démarche à suivre.

  • Faire make flash sur la carte mère depuis le répertoire Software/DualMode de notre Gitea.
  • Faire make upload via ICSP sur la carte fille depuis le répertoire Software/GPU.
  • Monter le shield sur l'Arduino. Choisir le mode en reliant PC3 à GND ou 5V.
  • Brancher la carte fille sur le J1 de la carte mère en s'assurant du sens de branchement grâces aux flèches blanches sur les PCB
  • Dans le mode d'envoi d'image, il suffit d'exécuter le programme python disponible dans le répertoire Software/Images.
  • Toute image peut être transmise, tant qu'elle est au préalable convertie en .bmp dans les dimensions de l'écran (480x320). Une image polytech.bmp est déjà prête à être envoyée.

Proto-système de fichiers - Carte SD / SPI RAM

Afin que le CPU et le GPU puissent communiquer, nous avons imaginé un système de fichiers qui soit commun aux deux unités deux traitement pour qu'ils puissent transférer les données à travers la SPIRAM.

Par ailleurs, nous souhaitions conserver le même système de fichiers pour la carte SD, et avoir ainsi un OS fonctionnant sous le principe de "tout est fichier", un peu à l'image des systèmes UNIX.

Par souci de factorisation, nous avions pour objectif de rassembler les méthodes liées au système de fichiers et de les rendre indépendantes du média, que ce soit la SPIRAM ou la carte SD.

Système de fichiers

Pour tester le système de fichiers, nous l'avons initialement développé pour n'être compatible avec la SPIRAM, mais l'idée est que l'écriture et la lecture soient indépendantes du média comme dit précédemment, et que les fonctions

pour lire et écrire soient des wrappers prenant en argument le média avec lequel communiquer, mais cela n'a pas été implémenté.


Initialement, nous avons créé deux types de données qui peuvent être édités, le type fichier Lfile et le type dossier Lfolder:

typedef struct Lfile
{
    uint8_t header[HDR_SIZE_FILE];
    uint8_t content[CTNT_SIZE_FILE];
}Lfile_t;

typedef struct Lfolder
{
    uint8_t header[HDR_SIZE_FOLDER];
    uint16_t addresses[MAX_FILES];
}Lfolder_t;

On observe assez rapidement que les deux types sont très similaires, la seule différence étant que le dossier ne contiendra que des adresses des sous dossiers ou des fichiers contenus. Par souci de simplicité nous avons décidé de les isoler pour mieux pouvoir déboguer par la suite. L'écriture d'un fichier dans la SPIRAM se fait de la façon suivante (la fonction writebuf_ram est détaillée dans la partie SPIRAM):

#define SOH 0x01
#define SOT 0x02
#define EOT 0x03
static uint8_t soh = SOH; //start of header 
static uint8_t sot = SOT; //start of text
static uint8_t eot = EOT; //end of text

void WriteFile(Lfile_t file,uint16_t address) //converts Lfile to raw data
{
    writebuf_ram(address,&soh,1);//borne de début de fichier
    writebuf_ram(address+1,file.header,HDR_SIZE_FILE); //écriture de l'entête
    writebuf_ram(address+HDR_SIZE_FILE+1,&sot,1);//borne de fin d'entête et début du contenu
    writebuf_ram(address+HDR_SIZE_FILE+2,file.content,CTNT_SIZE_FILE); //écriture du contenu
    writebuf_ram(address+HDR_SIZE_FILE+CTNT_SIZE_FILE+2,&eot,1);// borne de fin du fichier
}

La lecture se fait de façon réciproque, les octets entre les bornes sont extraits et reconvertis en entête et contenu de fichier Lfile. Maintenant, pour pouvoir naviguer dans notre système de fichiers, il faut déjà pouvoir se positionner, c'est pourquoi nous avons un pointeur de position qui nous indique dans quel dossier nous nous trouvons:

uint16_t locationpointer;

Pour avoir un premier aperçu du fonctionnement du système de fichiers, nous avons créé une fonction qui ajoute un fichier au dossier courant (dont l'adresse est pointée par le locationpointer):

void AddFileToFolder(char foldername[],char filename[],Lfiletype_t type)
{
    Lfile_t folder = ReadFile(locationpointer);//acquisiton du dossier courant
    int numberfiles = folder.content[0]; //acquisition du nombre de fichiers dans le dossioer
    uint16_t address = 0x0404; //adresse quelconque
    CreateFile(filename,address,type); // écrit un fichier vide à un emplacement quelconque en mémoire
    //écriture de l'adresse du fichier qui sera contenu dans le dossier
    //le fichier pourra donc ne pas être adjacent au dossier 
    folder.content[2*numberfiles+1] = (address>>8); // MSB
    folder.content[2*numberfiles+2] = address&0xff; //LSB
    folder.content[0]++;//nombre de fichiers contenus dans le dossier
    WriteFile(folder,locationpointer);//création du dossier
}

A partir de ce point, il est possible d'implémenter la fameuse commande LS des systèmes UNIX:

void Ls()
{
    Lfile_t currentFolder = ReadFile(locationpointer); // acquisition du dossier courant
    char namebuffer[MAX_NAME_SIZE]; //buffer pour contenir le nom du fichier sélectionné
    Lfile_t currentfile;
    uint16_t address;
    ExtractName(currentFolder,namebuffer); // acquisition du nom du dossier
    //affichage sur le port série du dossier courant
    send_data((unsigned char*)"in folder ");
    send_data((unsigned char*)namebuffer);

    for(int i = 0; i<currentFolder.content[0];i++)
    {
        address = 0x0000;// mise à 0 du buffer
        //construction de l'adresse du fichier contenu dans le dossier
        address |= (((uint16_t)currentFolder.content[2*i+1])<<8); //MSB
        address |= (((uint16_t)currentFolder.content[2*i+2])&0x00FF); //LSB
        currentfile = ReadFile(address); //acquisition du fichier à l'adresse
        ExtractName(currentfile,namebuffer); //acquisition du nom du fichier à l'adresse
        //affichage sur le port série du fichier
        send_data((unsigned char*)"\t");
        send_data((unsigned char*)namebuffer); 
        send_data((unsigned char*)"\n\r");
    }
}

Avec le main suivant:

int main()
{
    init_ram();
    USART_Init(MYUBRR);
    CreateFile("/",0x0000,FOLDERTYPE);
    locationpointer = 0x0000;
    AddFileToFolder("/","fichier1",FILETYPE);
    Ls();
    return 0;
}

On obtient sur le port série le résultat suivant:

in folder /
    fichier1

Carte SD

Initialement, la carte SD est dans un mode appelé "mode SD", et ne peut donc pas transférer de données en SPI. Il faut une phase d'initialisation dans laquelle nous allons indiquer à la carte SD que nous souhaitons basculer en "mode SPI", mais cela à une vitesse de bus SPI plus faible de 125 kHz. Le cycle d'initialisation se fait de la façon suivante:

//code d'initialisation de la carte SD
uint8_t sd_init(void) {
    spi_init();
    SD_CS_LOW();
    // attente de 80 cycles 
    for(uint8_t i=0;i<10;i++) spi_transfer(0xFF); // dummy byte

    if(sd_send_cmd(0,0,0x95)!=1) return 1;
    uint8_t r;

    do {
        //envoi commande 55
        r = sd_send_cmd(55,0,0x00); 
        //envoi commande de réponse 41
        r = sd_send_cmd(41,0,0x00);
        //attente que la réponse soit correcte, soit r=0x00
    } while(r!=0);

    SD_CS_HIGH();
    spi_transfer(0xFF);
    return 0;
}

Une fois l'initialisation faite, on peut augmenter la vitesse du bus SPI à son maximum, dans notre cas 8MHz. Il suffit ensuite d'envoyer la commande que l'on souhaite exécuter sur la carte SD, (commande 17 pour la lecture, et commande 24 pour l'écriture, avec en argument l'adresse du bloc à lire ou écrire). En lecture, on attend le bit 0xFE, en écriture, on l'envoie à la carte SD. Dans les deux cas, il s'agit d'un symbole de démarrage qui précède la transmission des données. Pendant 512 cycles (la taille d'un bloc), on lit les données envoyées en réponse à un "dummy byte", un octet qui ne sert à rien d'autre que pour recevoir sa réponse. Pour l'écriture, on remplace le dummy byte par l'octet que l'on souhaite écrire. Dans les deux cas, on finit la transmission par deux dummy bytes.

SPIRAM

La SPIRAM est plus simple à initialiser que la carte SD, puisque déjà prête à transmettre en SPI, on peut donc déjà mettre le bus SPI à 8 MHz. La SPIRAM dispose de plusieurs mécanismes de sécurité des données en écriture. C'est pourquoi avant chaque écriture , nous devons envoyer une commande WREN (write enable) :

// écriture dans la spiram
int writebuf_ram(uint16_t addr,uint8_t buf[], uint16_t size)
{
	if(buf == NULL || size == 0) return -1;

	if(addr+size > 0x7FFF) return -2;

	select_ram();
	spi_echange(WREN_CMD); // commande write enable
	deselect_ram();

	select_ram();
	spi_echange(WRITE_CMD); //commande précédant l'écriture

	spi_echange(addr>>8);//MSB
	spi_echange(addr & 0x7F);//LSB

	for(uint16_t i =0; i<size; i++) //envoi d'octets
	{
		spi_echange(buf[i]);
	}
	//arrêt de la transmission lorsque CS à 1
	deselect_ram();
	return 0;
}

Il faut prêter attention à la taille du buffer transmis, puisque la SPIRAM continuera d'écrire tant que des octets sont transmis, même si ils sont comme valeur 0x00. Seule une mise à 1 du pin CS de la SPIRAM permettra l'arrêt de l'écriture. Le problème avec cette méthode de transmission est qu'à chaque écriture il faut envoyer la commande WREN, c'est pourquoi nous avons imaginé la fonction suivante pour ne pas avoir à l'envoyer à chaque fois, quand des données trop lourdes pour la RAM de l'atmega sont manipulées, par exemple, une image:

//modes d'exécutions 
typedef enum transmit {WINIT,RINIT,TRANSMITTING,END}transmit_t;

uint8_t transmit_ram(uint16_t addr,transmit_t state,uint8_t data)
{
	uint8_t received;
	switch (state)
	{
		case WINIT: //initialisation de l'écriture
			print_hex(0x00);
			select_ram();
			spi_echange(WREN_CMD);
			deselect_ram();
			select_ram();
			spi_echange(WRITE_CMD);
			spi_echange(addr>>8); //MSB
			spi_echange(addr & 0x7F); //LSB
			break;
		case RINIT: //initialisation de la lecture
			select_ram();
			spi_echange(READ_CMD);
			spi_echange(addr>>8); //MSB
			spi_echange(addr & 0x7F); //LSB
			break;
		case TRANSMITTING: //transmission à la volée
			 received = spi_echange(data);
			return received;

		case END: //fin de transmission
			deselect_ram();

			break;
	}
	return 0xFF;
}