Linux Embedded

Le blog des technologies libres et embarquées

Introduction à la programmation UEFI – Réalisation d’un PONG!

L'UEFI, c'est quoi ?

De plus en plus d'ordinateurs portables vendus aujourd'hui sur le marché sont équipés de firmwares UEFI : "Unified Extensible Firmware Interface". En effet, ce nouveau standard vient remplacer les EFI qui sont eux-mêmes apparus en remplacement du BIOS qui commençait à connaître ses limites. Pourtant mal accueilli lors de son apparition, l'UEFI apporte des avancées majeures par rapport à ses prédécesseurs, avec notamment, la prise en charge de la souris et des graphismes avancés.

L'UEFI a pour principal but, comme son nom l'indique, d'unifier, de standardiser la norme EFI. En effet, les BIOS ne devant respecter aucune spécification particulière*, les constructeurs étaient libres de choisir leurs propres conventions. Ainsi, cela rendait le développement de programmes dits Bare Metal (c'est-à-dire tournant directement après le démarrage de la machine, sans système d'exploitation ni aucune autre couche d'abstraction) plus compliqué et surtout non portable. La dernière spécification de l'UEFI à ce jour peut être trouvée à l'adresse http://www.uefi.org/sites/default/files/resources/UEFI%20Spec%202_6%20Errata%20A%20final.pdf , cette version sera utilisée tout au long de ce tutoriel.

 

* La présence de la table d'interruptions des BIOS montre la volonté d'uniformiser ces derniers. Cependant, certaines de ces interruptions ne sont pas implémentées de la même manière, voire pas du tout, d'un ordinateur à l'autre. Cette table est trouvable ici : https://en.wikipedia.org/wiki/BIOS_interrupt_call#Interrupt_table

Le kit de développement

EDK II et GNU-EFI

Deux principaux kits de développement existent pour faire du développement : UEFI: EDK II et Gnu-efi. Chacun présente des avantages et des inconvénients.

Le premier est complet, possède des librairies assez riches ainsi qu'une documentation trouvable facilement (http://www.bluestop.org/edk2/docs/trunk/index.html). Cependant, il est plus long de mettre en place un simple Hello World étant donné que sa compilation mène à la modification de fichiers de configuration du kit, les .dsc notamment.

Le second est léger, simple à installer et à utiliser: un Makefile classique permet de compiler les programmes. Les librairies fournies et la documentation sont toutes aussi légères.

Pour ce tutoriel, notre choix s'est porté sur l'EDK II, un guide d'installation avec la mise en place d'un Hello World peut être trouvé à cette adresse (guide en anglais) : https://github.com/tianocore/tianocore.github.io/wiki/Getting-Started-with-EDK-II .

Quant aux conventions de codage de l'EDK II, elles peuvent être trouvées à cette adresse http://cran.org.uk/edk2/docs/specs/EDK2_C_Coding_Standards_Specification.pdf

Compilation et tests

Afin de tester un programme écrit, il faut le compiler avec l'EDK II, dont le procédé est décrit dans le guide du lien ci-dessus, puis, le fichier .EFI généré doit être mis sous le nom de BOOTX64.EFI (pour les machines 64bits) sur une clé USB FAT16 ou FAT32 afin de former l'arborescence suivante :

└── EFI
    └── BOOT
       └── BOOTX64.EFI

Tout au long de ce tutoriel, les tests ont été effectués sur un Dell Latitude E5520 muni d'un BIOS/UEFI en version A05. Afin de démarrer sur clé USB en mode UEFI sur cette machine, il faut la démarrer en appuyant sur F12, puis en sélectionnant BIOS Setup. Une fenêtre apparaît, cliquez  (ou sélectionnez) Boot Sequence, qui se trouve dans la catégorie Général, puis cochez l'option UEFI. Il est ensuite possible de choisir l'ordre de préférence de démarrage. Un simple redémarrage est nécessaire pour démarrer sur la clé.
Note: Si le démarrage n'est pas automatique, il faut sélectionner la clé usb dans le menu qui s'affiche lors de l'appui sur F12 au démarrage.
Le fichier .inf utilisé pour la compilation du projet est le suivant (trouvable avec les sources dont le lien est fourni à la fin de cet article) :

# Pong.inf
[Defines]
INF_VERSION = 1.25
BASE_NAME = Pong
FILE_GUID = 82d8802a-1a10-4651-8279-b7e82d1e8126
MODULE_TYPE = UEFI_APPLICATION
VERSION_STRING = 1.0
ENTRY_POINT = UefiMain
[Sources]
Pong.c

[Packages]
MdePkg/MdePkg.dec

[LibraryClasses]
UefiApplicationEntryPoint
UefiLib

[Guids]

[Ppis]

[Protocols]

[FeaturePcd]

[Pcd]

Les fichiers d'en-tête inclus dans notre projet sont les suivants :

#include <Uefi.h>
#include <Library/UefiApplicationEntryPoint.h>
#include <Library/UefiLib.h>
#include <Library/UefiBootServicesTableLib.h>
#include <Library/UefiRuntimeServicesTableLib.h>
#include <Protocol/GraphicsOutput.h>

PONG!

Afin de commencer à développer pour UEFI, lançons-nous dans le codage du classique Pong. Notre choix s'est porté sur Pong car il permet d'utiliser des avancées intéressantes de l'UEFI comme l'affichage graphique ou la gestion des événements du clavier.
Voici le résultat final :

L'affichage est basique et consiste en de petits carrés blancs de taille fixée (10 pixels dans notre cas) que nous appellerons cellules. Évidemment, il est possible d'avoir un affichage plus poussé mais cela requiert plus de temps. Les deux raquettes seront contrôlées par le ou les utilisateurs.

Commençons par créer un fichier nommé Pong.c qui contiendra notre boucle principale de jeu.

EFI_STATUS EFIAPI
UefiMain(IN EFI_HANDLE ImageHandle, IN EFI_SYSTEM_TABLE *SystemTable)
{
  return EFI_SUCCESS;
}

Le point d'entrée du programme, UefiMain, est spécifié dans le fichier .inc dont nous avons donné le contenu dans la partie précédente.
L'argument SystemTable qui nous est envoyé est défini dans la spécification (page 149) comme tel :

typedef struct {
EFI_TABLE_HEADER Hdr;
CHAR16 *FirmwareVendor;
UINT32 FirmwareRevision;
EFI_HANDLE ConsoleInHandle;
EFI_SIMPLE_TEXT_INPUT_PROTOCOL *ConIn;
EFI_HANDLE ConsoleOutHandle;
EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *ConOut;
EFI_HANDLE StandardErrorHandle;
EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *StdErr;
EFI_RUNTIME_SERVICES *RuntimeServices;
EFI_BOOT_SERVICES *BootServices;
UINTN NumberOfTableEntries;
EFI_CONFIGURATION_TABLE *ConfigurationTable;
} EFI_SYSTEM_TABLE;

Ainsi, il nous permet d'obtenir des pointeurs vers différentes structures, contenant des fonctions qui nous intéresseront par la suite. En particulier, on y trouve les structures ConIn et ConOut qui gèrent la console UEFI ainsi que la structure BootServices qui contient notamment les fonctions AllocatePool, pour allouer de l'espace mémoire, et locateProtocol, pour localiser un protocole.

NOTE : La structure BootServices est aussi accessible grâce à une variable globale notée gBS. De même, la variable globale gST pointe vers la SystemTable précédente.

L'accès aux fonctionnalités de l'UEFI se fait à l'aide de protocoles. Un protocole est identifié par un GUID, Globally Unique Identifier. Une fois acquis, le protocole définit une structure contenant des pointeurs de fonctions. Afin d'acquérir un protocole, la solution la plus simple est d'utiliser la fonction locateProtocol de la variable gBS globale, ou l'argument SystemTable. Voici la signature de la fonction (trouvable page 260 de la spécification) :

typedef
EFI_STATUS
(EFIAPI *EFI_LOCATE_PROTOCOL) (
    IN EFI_GUID *Protocol,
    IN VOID *Registration OPTIONAL,
    OUT VOID **Interface
);

Le premier argument de cette fonction est le GUID du protocole recherché, le second est optionnel et permet de spécifier une clé d'enregistrement qui peut être obtenue par la fonction EFI_BOOT_SERVICES.RegisterProtocolNotify(), le troisième argument est la référence vers le pointeur du protocole. Ce dernier sera affecté en fonction du résultat de la fonction.

En cas de succès, cette fonction renvoie Ret = EFI_SUCCESS et le pointeur Interface est mis à jour en conséquence. En cas d'erreur, la macro EFI_ERROR(Ret) est non nulle.

 

Remarque sur la gestion de la mémoire

Les programmes que nous écrivons ont pour but d'être exécutés juste après le démarrage de la machine, avant n'importe quel système d'exploitation. Ainsi, la mémoire et le processeur leur seront totalement dédiés. Il est possible d'allouer de la mémoire dynamiquement, dans le tas, grâce aux fonctions AllocatePool et AllocatePages se trouvant dans la table gBS (BOOT_SERVICES). Évidemment, les fonctions pour libérer ces espaces de mémoires alloués y sont aussi présentes. Cependant, les même problèmes que l'utilisation des fonctions malloc et free de la librairie C standard surviennent : utiliser l'espace dans le tas peut mener facilement à des fuites mémoires. De plus, utiliser la pile pour stocker des mémoires tampons (buffer) de grande taille est aussi déconseillé. En effet, les programmes compilés avec EDK II n'ont pas de système de protection de pile (canary). C'est pourquoi il est conseillé d'utiliser l'espace dit statique (static) du programme, cela permettra de déterminer à la compilation la taille utilisée par le programme. De plus, cela aura aussi pour effet de rendre le programme plus rapide étant donné qu'il n'y a nul besoin d'appeler les fonctions citées précédemment.

Protocole graphique et dessin

Dans cette partie, nous allons essayer dans un premier temps d'afficher des cellules, ce qui nous permettra ensuite d'afficher les raquettes, la balle puis le score. Commençons par regarder la spécification de l'API graphique. La spécification de l'UEFI définit le protocole graphique (page 565) comme tel :

typedef struct EFI_GRAPHICS_OUTPUT_PROTCOL {
EFI_GRAPHICS_OUTPUT_PROTOCOL_QUERY_MODE QueryMode;
EFI_GRAPHICS_OUTPUT_PROTOCOL_SET_MODE SetMode;
EFI_GRAPHICS_OUTPUT_PROTOCOL_BLT Blt;
EFI_GRAPHICS_OUTPUT_PROTOCOL_MODE *Mode;
} EFI_GRAPHICS_OUTPUT_PROTOCOL;

La première fonction permet de tester si un mode est disponible et si oui, fournit aussi les informations qui y sont liées, comme la résolution. La seconde permet de changer de mode d'affichage. Enfin, la troisième fonction est celle qui nous intéresse, elle permet d'afficher des pixels à l'écran, à partir d'un buffer notamment. La structure Mode permet de récupérer les informations sur le mode d'affichage courant.

Commençons donc par définir la variable globale qui contiendra le protocole:

static EFI_GRAPHICS_OUTPUT_PROTOCOL* Gop = NULL;

Il nous est possible de définir une variable afin d'y stocker le GUID de ce protocole, cependant, EDK II contient déjà une définition de ce dernier, sous le nom de gEfiGraphicsOutputProtocolGuid. Nous pouvons donc essayer de récupérer ce protocole.

static EFI_GRAPHICS_OUTPUT_PROTOCOL* Gop = NULL;

EFI_STATUS EFIAPI
UefiMain(IN EFI_HANDLE ImageHandle, IN EFI_SYSTEM_TABLE *SystemTable)
{
  EFI_STATUS St = gBS->LocateProtocol(&gEfiGraphicsOutputProtocolGuid,
                                      NULL,
                                      (VOID**) &Gop);
  if (EFI_ERROR(St)) {
    Print(L"Unable to locate Graphics Output Protocol\n");
    Exit(1);
  }
  return EFI_SUCCESS;
}

Si la fonction ne renvoie pas d'erreur, notre structure GoP sera remplie avec les champs précédemment vus. Nous pouvons dès lors utiliser la fonction Blt qui nous servira à dessiner. Rappelons que nos cellules de notre zone de jeu ont une taille de CELLSIZE x CELLSIZE pixels, où CELLSIZE = 10. Voici la signature de la fonction Blt :

typedef
EFI_STATUS
(EFIAPI *EFI_GRAPHICS_OUTPUT_PROTOCOL_BLT) (
IN EFI_GRAPHICS_OUTPUT_PROTOCOL *This,
IN OUT EFI_GRAPHICS_OUTPUT_BLT_PIXEL *BltBuffer,
IN EFI_GRAPHICS_OUTPUT_BLT_OPERATION BltOperation,
IN UINTN SourceX,
IN UINTN SourceY,
IN UINTN DestinationX,
IN UINTN DestinationY,
IN UINTN Width,
IN UINTN Height,
IN UINTN Delta OPTIONAL
);

Ainsi, si on veut dessiner un carré blanc de taille CELLSIZE à la position (0, 0), l'appel suivant suffit :

EFI_GRAPHICS_OUTPUT_BLT_PIXEL White = {0xFF, 0xFF, 0xFF, 0};
Gop->Blt(Gop, &White, EfiBltVideoFill,
         0, 0,
         0, 0,
         CELLSIZE, CELLSIZE,
         0);

La description complète de la fonction ainsi que les différentes options pour cette dernière se trouvent dans la spécification. Les couleurs ont comme type EFI_GRAPHICS_OUTPUT_BLT_PIXEL, ce qui correspond à une simple structure contenant, dans l'ordre, les champs : Blue, Green, Red, Reserved, où Reserved doit toujours être à 0.

Après compilation et exécution, voici le résultat :

Nous pouvons dès lors écrire une fonction DrawCell qui se chargera de dessiner une cellule à l'écran aux coordonnées X et Y fournies. L'idéal serait que cette même fonction puisse aussi effacer une cellule, c'est-à-dire la remettre en noir. Pour ce faire, Il nous suffit de prendre en argument un booléen :

VOID
DrawCell(UINT32 X, UINT32 Y, BOOLEAN Reset)
{
  EFI_GRAPHICS_OUTPUT_BLT_PIXEL White = {0xFF, 0xFF, 0xFF, 0};
  EFI_GRAPHICS_OUTPUT_BLT_PIXEL Black = {0, 0, 0, 0};
  EFI_GRAPHICS_OUTPUT_BLT_PIXEL Color = (Reset)?Black:White;
  Gop->Blt(Gop, &Color, EfiBltVideoFill,
	   0, 0,
	   X*CELLSIZE, Y*CELLSIZE,
	   CELLSIZE, CELLSIZE,
	   0);
}

Le type BOOLEAN est défini dans l'EDK II, de même que les valeurs TRUE et FALSE associées.

Grâce à cette fonction, il devient simple de dessiner des cellules à l'écran, nous pouvons donc définir des fonctions DrawBall, EraseBall, DrawBat, EraseBat, toutes aussi simples qui auront respectivement pour rôle de dessiner la balle, l'effacer, dessiner une raquette, en effacer une. La différence entre ces deux objets est leur taille. En effet, une raquette a une taille arbitraire de 1x10 cellules ( = 10x100 pixels). Écrivons ces fonctions, en ajoutant des définitions de tailles en plus :

#define CELLSIZE 10
#define BALLSIZE CELLSIZE
#define BATCELLS 10

VOID
DrawBall(UINT32 X, UINT32 Y)
{
  DrawCell(X, Y, FALSE);
}

VOID
EraseBall(UINT32 X, UINT32 Y)
{
  DrawCell(X, Y, TRUE);
}

VOID
DrawBat(UINT32 X, UINT32 Y)
{
  UINT32 i;
  for(i = 0; i < BATCELLS; i++){
    DrawCell(X, Y+i, 0);
  }
}

VOID
EraseBat(UINT32 X, UINT32 Y)
{
  UINT32 i;
  for(i = 0; i < BATCELLS; i++){
    DrawCell(X, Y+i, 1);
  }
}

Nous avons maintenant les fonctions qui vont nous permettre de dessiner les différents éléments que l'on peut retrouver sur un PONG classique.

La gestion des événements

Maintenant, ce qui nous intéresse est l'interaction avec l'utilisateur. Comment va-t-il contrôler les raquettes ?

Toujours dans la structure de BOOT_SERVICES que nous avons vu au début, se trouve une fonction nommée WaitForEvent dont voici la signature :

typedef
EFI_STATUS
(EFIAPI * EFI_WAIT_FOR_EVENT)(
IN UINTN NumberOfEvents,
IN EFI_EVENT *Event,
OUT UINTN *Index)

Le premier argument est le nombre d'événements, le second est le tableau des événements qui a donc une taille de NumberOfEvents, et le dernier est un pointeur de sortie pour stocker l'indice de l'événement qui est survenu. Cette fonction est similaire à la fonction POSIX select dans le sens où, ici, ce sont les EFI_EVENT qui jouent le rôle de descripteur.

Il nous suffit donc de récupérer le type EFI_EVENT correspondant au clavier afin de pouvoir appeler cette fonction.

Cette fois-ci, c'est dans la variable globale gST, qui est de type EFI_SYSTEM_TABLE, que nous allons trouver ce qu'il nous faut. Voici comment est définie cette structure dans la spécification (page 149) :

typedef struct {
  EFI_TABLE_HEADER Hdr;
  CHAR16 *FirmwareVendor;
  UINT32 FirmwareRevision;
  EFI_HANDLE ConsoleInHandle;
  EFI_SIMPLE_TEXT_INPUT_PROTOCOL *ConIn;
  EFI_HANDLE ConsoleOutHandle;
  EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *ConOut;
  EFI_HANDLE StandardErrorHandle;
  EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *StdErr;
  EFI_RUNTIME_SERVICES *RuntimeServices;
  EFI_BOOT_SERVICES *BootServices;
  UINTN NumberOfTableEntries;
  EFI_CONFIGURATION_TABLE *ConfigurationTable;
} EFI_SYSTEM_TABLE;

Tous les champs sont expliqués en détail dans la spécification. Les champs les plus importants ici sont :

- ConOut, qui est le protocole qui se charge des sorties sur la console, il va donc permettre d'afficher du texte de manière standard (blanc sur fond noir, de haut en bas) ou de manière plus poussée (à n'importe quel endroit, avec des couleurs personnalisées) si l'UEFI sur lequel il s'exécute le permet
- ConIn, qui est le protocole qui se charge de recevoir des événements du clavier.

Celui qui va nous intéresser est ce dernier, voici les champs qui le composent :

typedef struct _EFI_SIMPLE_TEXT_INPUT_PROTOCOL {
EFI_INPUT_RESET Reset;
EFI_INPUT_READ_KEY ReadKeyStroke;
EFI_EVENT WaitForKey;
} EFI_SIMPLE_TEXT_INPUT_PROTOCOL;

On retrouve le champ WaitForKey de type EFI_EVENT que nous avons vu plus tôt. C'est donc celui-ci qu'il faudra passer à la fonction WaitForEvent. Le champ ReadKeyStroke pointe vers une fonction qui renvoie une structure contenant l'information sur la touche qui a été appuyée. Voici un morceau de code qui nous permet de tester si la touche y a été appuyée :

UINTN EventIndex;
EFI_INPUT_KEY Key;
gBS->WaitForEvent(1, &gST->ConIn->WaitForKey, &EventIndex);
gST->ConIn->ReadKeyStroke(gST->ConIn, &Key);

if(Key.UnicodeChar == 'y'){
  Print(L"Y key pressed !\n");
}

Cependant, il est intéressant de remarquer qu'en utilisant cette fonction WaitForEvent, le système se met en pause jusqu'au moment où il reçoit cette interruption. Dans le cas du jeu PONG, si le système attend que l'utilisateur tape sur le clavier en permanence, la balle ne pourra pas avancer, le jeu étant suspendu systématiquement. La solution est de soit utiliser la fonction CheckEvent de notre variable global gBS soit d'utiliser directement la commande ReadKeyStroke. Prenons cette seconde solution, voici la signature de cette fonction :

typedef
EFI_STATUS
(EFIAPI *EFI_INPUT_READ_KEY) (
IN EFI_SIMPLE_TEXT_INPUT_PROTOCOL *This,
OUT EFI_INPUT_KEY *Key
);

Ce qui est intéressant de voir dans cette signature est la valeur de retour qui est un EFI_STATUS qui peut prendre ces valeurs :

EFI_SUCCESS Les informations de la touches ont été récupérees
EFI_NOT_READY Aucune donnée n'était disponible (aucune touche n'a été appuyée)
EFI_DEVICE_ERROR Aucun donnée récupérée à cause d'un problème matériel

Ainsi, on peut effectuer un Poll sur les événements du clavier en testant la valeur de retour de la fonction que nous venons de voir. Voici un appel non bloquant qui teste si la touche y a été appuyée :

EFI_INPUT_KEY Key;
EFI_STATUS Poll = gST->ConIn->ReadKeyStroke(gST->ConIn, &Key);
if(Poll == EFI_SUCCESS){
  if(Key.UnicodeChar == 'y'){
    Print(L"Y key pressed !\n");
  }
}

Cas des touches spéciales : comme nous venons de le voir, il est assez simple de tester si une touche est un caractère, grâce au champs UnicodeChar de la structure de type EFI_INPUT_KEY. Néanmoins, cette structure contient aussi un champs ScanCode qui est utilisé quand l'utilisateur appuie sur des touches "spéciales", les touches qui vont nous intéresser sont :

- Code 0x1 - Haut (flèche directionnelle)
- Code 0x2 - Bas (flèche directionnelle)
La liste complète est disponible page 510 de la spécification (Table 100).

Code principal du jeu

Nous avons maintenant tous les outils nécessaires afin de faire notre Pong. Reprenons notre fonction UefiMain de notre fichier Pong.c de la première partie du tuto :

EFI_STATUS EFIAPI
UefiMain(IN EFI_HANDLE ImageHandle, IN EFI_SYSTEM_TABLE *SystemTable)
{
  EFI_STATUS St = gBS->LocateProtocol(&gEfiGraphicsOutputProtocolGuid,
                                      NULL,
                                      (VOID**) &Gop);
  if (EFI_ERROR(St)) {
    Print(L"Unable to locate Graphics Output Protocol\n");
    Exit(1);
  }
  return EFI_SUCCESS;
}

Grâce à ce protocole graphique, nous allons pouvoir récupérer la résolution de l'écran dans le mode d'affichage courant. Grâce à cette résolution, nous allons pouvoir calculer la taille de la zone de jeu, c'est-à-dire le nombre de cellules dans lequel se déroulera notre jeu.

CONST UINT32 ScreenWidth  = Gop->Mode->Info->HorizontalResolution;
CONST UINT32 ScreenHeight = Gop->Mode->Info->VerticalResolution;
CONST UINT32 GameWidth  = ScreenWidth / CELLSIZE;
CONST UINT32 GameHeight = ScreenHeight / CELLSIZE;

À partir de là, nous pouvons déjà définir la position de nos éléments. La balle sera placée au centre du terrain, les raquettes seront de part et d'autre du terrain, définissons quelques variables qui contiendront données sur la position de nos éléments tout au long de la partie :

UINT32 BallX = GameWidth / 2;
UINT32 BallY = GameHeight / 2;
UINT32 LeftBatPos  = (GameHeight / 2) - (BATCELLS / 2);
UINT32 RightBatPos = (GameHeight / 2) - (BATCELLS / 2);
CONST UINT32 LOWESTPOS = GameHeight - BATCELLS;
CONST UINT32 XRIGHTBAT = GameWidth-1;

Les raquettes se déplaçant seulement de haut en bas, leur position en X ne changera pas en cours de partie, nous pouvons donc définir dès maintenant, en tant que constante, la position en X de la raquette se trouvant à droite de l'écran ainsi que la position Y la plus basse jusqu'où peuvent aller les raquettes.

CONST UINT32 LOWESTPOS = GameHeight - BATCELLS;
CONST UINT32 XRIGHTBAT = GameWidth-1;

En incluant les fonctions de dessin que nous avons écrites dans la deuxième partie, nous pouvons d'ors et déjà afficher nos éléments à l'écran.

DrawBat(0, LeftBatPos);
DrawBat(XRIGHTBAT, RightBatPos);
DrawBall(BallX, BallY);

Après compilation et lancement, voici le résultat que l'on obtient

Nous avons donc l'affichage de notre zone de jeu qui est fonctionnel, ajoutons maintenant un peu de dynamisme à notre jeu.

Pour ce faire, on ajoute 2 variations de vitesse à notre balle (exprimées en cellule(s)/tour de boucle), vitesse en X et vitesse en Y, ainsi qu'une simple variation de vitesse pour nos raquettes. Ensuite, ajoutons la boucle principale munie d'un booléen qui a pour rôle de l'arrêter.

INT32 SpeedX = -1;
INT32 SpeedY = 1;
CONST INT32 BATSPEED = 4;
//Main loop
BOOLEAN Stop = FALSE;
while(!Stop){

}

Ce que nous allons essayer de faire maintenant est de faire réagir les raquettes aux touches du clavier. Pour la raquette de droite, les touches pour monter et descendre sont respectivement la flèche du haut et la flèche du bas, ce qui correspond aux codes 0x1 et 0x2. Pour la raquette de gauche prenons les touches 's' pour monter et 'x' pour descendre.

N'oublions pas que le clavier est en qwerty par défaut, ces touches se situent au même endroit sur un clavier azerty et qwerty. De plus, des événements peuvent arriver à des intervalles de temps très réduits. L'idéal est donc de gérer ces événements dans le même tour de boucle, ainsi, on empêche la limite d'un seul événement par tour. Nous allons donc utiliser une boucle qui les traitera :

INT32 SpeedX = -1;
INT32 SpeedY = 1;
CONST INT32 BATSPEED = 4;
//Main loop
BOOLEAN Stop = FALSE;
while(!Stop){
    //Check for keys event
    EFI_STATUS Poll;
    do{
       //Check the keys and move the bats if needed
       EFI_INPUT_KEY Key;
       Poll = gST->ConIn->ReadKeyStroke(gST->ConIn, &Key);
       if(Poll == EFI_SUCCESS){
          if(Key.ScanCode == 0 && Key.UnicodeChar == 's'){
             EraseBat(0, LeftBatPos);
             if(LeftBatPos < BATSPEED){
                LeftBatPos = 0;
             } else {
                LeftBatPos -= BATSPEED;
             }
             DrawBat(0, LeftBatPos);
          } else if(Key.ScanCode == 0 && Key.UnicodeChar == 'x'){
             EraseBat(0, LeftBatPos); 
             LeftBatPos += BATSPEED;
             if(LeftBatPos > LOWESTPOS)
                LeftBatPos = LOWESTPOS;
                DrawBat(0, LeftBatPos);
             } else if(Key.ScanCode == 1){
                EraseBat(XRIGHTBAT, RightBatPos);
                if(RightBatPos < BATSPEED){
                   RightBatPos = 0;
                } else {
                   RightBatPos -= BATSPEED;
                }
                DrawBat(XRIGHTBAT, RightBatPos);
             } else if(Key.ScanCode == 2){
                EraseBat(XRIGHTBAT, RightBatPos); 
                RightBatPos += BATSPEED;
                if(RightBatPos > LOWESTPOS)
                   RightBatPos = LOWESTPOS;
                DrawBat(XRIGHTBAT, RightBatPos);
             }
       }
    }while(Poll == EFI_SUCCESS);
}

Voyons ce morceau de code un peu plus en détail.

On exécute une première fois la fonction ReadKeyStroke afin de savoir si une touche a été appuyée sur le clavier, s'il y en a effectivement eu une, alors la variable Poll renvoyée est EFI_SUCCESS et la structure Key est mise à jour en conséquence. Dès lors, nous pouvons comparer sa valeur avec celles décidées plus tôt, voyons le cas de la raquette de gauche :

if(Key.ScanCode == 0 && Key.UnicodeChar == 's'){
   EraseBat(0, LeftBatPos);
   if(LeftBatPos < BATSPEED){
      LeftBatPos = 0;
   } else {
      LeftBatPos -= BATSPEED;
   }
   DrawBat(0, LeftBatPos);
} else if(Key.ScanCode == 0 && Key.UnicodeChar == ’x’){
   EraseBat(0, LeftBatPos);
   LeftBatPos += BATSPEED;
   if(LeftBatPos > LOWESTPOS)
      LeftBatPos = LOWESTPOS;
   DrawBat(0, LeftBatPos);
}

Nous commençons par tester si la touche appuyée n'est pas spéciale (Key.ScanCode == 0) et s'il s'agit bien de la touche 's'. Si tel est le cas, alors on efface la raquette gauche de l'affichage, on la déplace, puis on la redessine à sa nouvelle position. Le principe est le même avec la touche 'x' pour déplacer la raquette vers le bas, la seule différence est la limite qui devient LOWESTPOS et non plus 0.

Les explications sont identiques pour la raquette de droite, seule la position en X diffère. Compilons et exécutons. Voici ce qu'il est possible de faire maintenant :

Chargeons nous maintenant du mouvement de la balle et de son interaction avec les raquettes. La première chose a faire lorsque l'on rentre dans notre boucle est d'effacer la balle de l'affichage, grâce à la fonction EraseBall. Puis, après la boucle à événements, nous mettons à jour la position de la balle en fonction de sa vitesse et enfin on redessine la balle à sa nouvelle position. Pour résumer, voici le bloc de code à écrire dans notre boucle principale :

EraseBall(BallX, BallY);
// Boucle à événement clavier do...while
BallX += SpeedX;
BallY += SpeedY;
DrawBall(BallX, BallY);

Si nous compilons et exécutons ce programme directement, le premier problème qui apparaît est la vitesse. Le processeur va effectuer les opérations trop rapidement pour que le jeu soit jouable. La solution la plus simple est de faire en sorte que le processeur s'endorme à chaque tour de boucle. Pour ce faire, il existe une fonction Stall qui se trouve dans la variable globale gBS. Cette fonction prend en argument des microsecondes. Faisons dormir le processeur 50ms à chaque tour de boucle avec la ligne suivante :

gBS->Stall(50000);

La balle se déplace maintenant à une vitesse acceptable mais elle sort assez rapidement du terrain. Ce que nous allons donc faire est que nous allons replacer la balle au centre lorsqu'elle sort en changeant sa direction (Si la balle se déplaçait de droite à gauche au moment de sa sortie du terrain, elle se déplacera de droite à gauche lors de son replacement au centre) et nous allons aussi faire en sorte qu'elle rebondisse lorsqu'elle touche les bords haut et bas du terrain.

Pour ce faire, il faut effectuer des tests sur la position de la balle avant le code de calcul de la nouvelle position. Le code précédent devient donc :

EraseBall(BallX, BallY);

// Boucle à événement clavier do...while

if(BallX == 0 || BallX == GameWidth){
   BallX = GameWidth / 2;
   BallY = GameHeight / 2;
   SpeedX *= -1;
}

if(BallY <= 0 || BallY >= GameHeight) {
   SpeedY *= -1;
}

BallX += SpeedX;
BallY += SpeedY;
DrawBall(BallX, BallY);
gBS->Stall(50000);

Après compilation et exécution, on voit que la balle bouge maintenant, au même titre que les raquettes. Cependant, il n'est toujours pas possible de l'arrêter avec les raquettes, elle ne fait que traverser ces dernières, il nous faut donc ajouter un cas au if que nous venons d'écrire. Il faut tester si une raquette bloque la balle en fonction des coordonnées de ces deux éléments : si le résultat est vrai, alors il faut changer la direction (et donc la vitesse) de la balle.

Le code précédent devient :

EraseBall(BallX, BallY);

// Boucle à événement clavier do...while

if(BallX == 0 || BallX == GameWidth){
   BallX = GameWidth / 2;
   BallY = GameHeight / 2;
   SpeedX *= -1;
} else if(BatBlockBall(0, LeftBatPos, BallX, BallY) ||
          BatBlockBall(XRIGHTBAT, RightBatPos, BallX, BallY)){
      SpeedX *= -1;
}

if(BallY <= 0 || BallY >= GameHeight) {
   SpeedY *= -1;
}
BallX += SpeedX;
BallY += SpeedY;
DrawBall(BallX, BallY);
gBS->Stall(50000);

Ici, c'est la fonction BatBlockBall, à qui on donne les coordonnées d'une raquette et d'une balle, qui va nous dire s'il y a une collision entre ces deux là. Le test de collision est simple : si la balle a des coordonnées comprises entre la coordonnée Y la plus haute et la plus basse de la raquette et que la distance en X entre la balle et la raquette est 1, alors il y a collision. Ce qui nous donne la fonction suivante :

BOOLEAN
BatBlockBall(UINT32 BatX, UINT32 BatY, UINT32 BallX, UINT32 BallY)
{
  INT32 SBatX  = BatX;
  INT32 SBallX = BallX;
  if(ABS(SBatX-SBallX) != 1){
    return FALSE;
  }

  return (BatY <= BallY) && ((BatY + BATCELLS) >= BallY);
}

La macro ABS est définie dans EDK II. En lui passant notre soustraction, elle fournit la valeur absolue qui correspond à notre distance.

La compilation et l'exécution mènera maintenant à un PONG simple mais fonctionnel.

Pour aller plus loin : l'affichage du score

Le jeu fonctionne correctement mais il est trop simple, il n'est pas possible de savoir qui a le plus de point et surtout, les points ne sont simplement pas comptabilisés. Supposons que les scores vont de 0 à 9, il faut écrire une fonction prenant en argument les deux scores et qui les affiche en haut, au centre de l'écran. Pour cela, il faut des patterns qui décrivent comment dessiner un chiffre. La solution retenue est simple : chaque chiffre est un tableau à deux dimensions de 5 lignes par 4 colonnes où chaque cellule blanche vaut 1 et chaque cellule noire vaut 0.

Exemple du pattern pour dessiner le chiffre 0:

// Codage de 0
{{1, 1, 1, 1},
 {1, 0, 0, 1},
 {1, 0, 0, 1},
 {1, 0, 0, 1},
 {1, 1, 1, 1}}

Après avoir écrit les patterns de chaque chiffre, plaçons les dans un tableau nommé NUMBERS de taille 10, dans un fichier Numbers.h. La fonction de dessin du score qui en ressort est la suivante :

/* Dans Numbers.h */
#define NUMBERHEIGHT 5
#define NUMBERWIDTH  4

/* Dans Pong.c */
VOID
DrawOneNumber(UINT8 Score, UINT32 X)
{
  UINT32 i, j;
  for(i = 0; i < NUMBERHEIGHT; i++){
    for(j = 0; j < NUMBERWIDTH; j++){
      if(NUMBERS[Score][i][j]){
        DrawCell(X+j, i, 0);
      }
    }
  }
}

VOID
DrawScore(UINT8 RightScore, UINT8 LeftScore, CONST UINT32 GameWidth)
{
  UINT32 X = (GameWidth / 2);
  DrawOneNumber(RightScore, X-1-NUMBERWIDTH);
  DrawOneNumber(LeftScore, X+1);
}

Ces fonctions permettent donc l'affichage du score de manière simple. Il faut aussi écrire la fonction qui efface les pixels du score (au cas où le score change), il suffit simplement de remettre à 0 les pixels qui se trouvent dans la zone de dessin du score.

VOID
EraseScore(CONST UINT32 GameWidth)
{
  UINT32 i, j;
  UINT32 start = (GameWidth / 2) - 1 - NUMBERWIDTH;
  for(i = 0 ; i < NUMBERHEIGHT; i++){
    for(j = start; j < start + 2 + 2*NUMBERWIDTH; j++){
      DrawCell(j, i, 1);
    }
  }
}

Nous avons les outils pour afficher le score des joueurs. Pour gérer le score dans notre jeu, ajoutons deux variables qui auront pour rôle de compter le score de chacune des raquettes, avant la boucle principale. Le score est à mettre à jour lorsque la balle sort du terrain, c'est-à-dire lorsque la balle a pour coordonnée X, 0 ou GameWidth. Il faut donc modifier la séquence de if...else écrite précédemment afin de distinguer le cas où la balle sort du côté gauche ou du côté droit.

if(BallX == 0){
   ++RightScore;
   if(RightScore >= 9){
     Stop = TRUE;
   }
   BallX = GameWidth / 2;
   BallY = GameHeight / 2;
   SpeedX *= -1;
   EraseScore(GameWidth);
   DrawScore(LeftScore, RightScore, GameWidth);
} else if(BallX == GameWidth){
   ++LeftScore;
   if(LeftScore >= 9){
     Stop = TRUE;
   }
   BallX = GameWidth / 2;
   BallY = GameHeight / 2;
   SpeedX *= -1;
   EraseScore(GameWidth);
   DrawScore(LeftScore, RightScore, GameWidth);
} else if(BatBlockBall(0, LeftBatPos, BallX, BallY) ||
          BatBlockBall(XRIGHTBAT, RightBatPos, BallX, BallY)){
   SpeedX *= -1;
}

Voyons ce qui change : maintenant, lorsque la balle sort du côté gauche, X = 0, le score du joueur droit est incrémenté, si son score est supérieur ou égal à 9, le jeu s'arrête, sinon, la balle est repositionnée au centre, sa direction change et l'ancien score est effacé puis le nouveau est affiché. Le code pour la partie droite est similaire.

Vous trouverez le code source complet du jeu, intégrant aussi les scores, à l'adresse suivante :
https://github.com/Openwide-Ingenierie/Pong-UEFI

À vous de jouer !

Problème d'affichage

Lorsque la balle passe par-dessus le score, ce dernier est en partie effacé, ceci est dû au fait que lorsque la balle se déplace, elle efface les cellules en les mettant en noir, sans tenir compte de la couleur qui se trouvait à sa place avant son passage.
Une solution consiste à sauvegarder la couleur de la cellule où la balle se déplace. Ainsi, lors de l'effacement de la balle, il est possible de remettre la cellule à sa couleur d'origine. Il peut être judicieux d'utiliser l'option EfiBltVideoToBltBuffer de la fonction Blt du protocole graphique.

Contrôle avec la souris

L'UEFI offre la possibilité d'utiliser la souris à travers un protocole nommé Simple Pointer Protocol, décrit page 540 de la spécification. Pourquoi ne pas l'utiliser pour déplacer une des raquettes ?

Vous avez désormais tous les outils pour résoudre ces problèmes.

    • le 08 juin 2017 à 16:37

      Pointu comme article, mais intéressant. Merci.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée.