GUIDA Costrutti condizionali (Parte 2 di 2) - switch, un analisi a basso livello

DispatchCode

Moderatore
Staff Forum
Utente Èlite
2,223
1,854
CPU
Intel I9-10900KF 3.75GHz 10x 125W
Dissipatore
Gigabyte Aorus Waterforce X360 ARGB
Scheda Madre
Asus 1200 TUF Z590-Plus Gaming ATX DDR4
HDD
1TB NVMe PCI 3.0 x4, 1TB 7200rpm 64MB SATA3
RAM
DDR4 32GB 3600MHz CL18 ARGB
GPU
Nvidia RTX 3080 10GB DDR6
Audio
Integrata 7.1 HD audio
Monitor
LG 34GN850
PSU
Gigabyte P850PM
Case
Phanteks Enthoo Evolv X ARGB
Periferiche
MSI Vigor GK30, mouse Logitech
Net
FTTH Aruba, 1Gb (effettivi: ~950Mb / ~480Mb)
OS
Windows 10 64bit / OpenSUSE Tumbleweed
I costrutti condizionali: switch, un analisi a basso livello (Parte 2/2)

Indice degli argomenti:

  • 1. Prefazione
  • 2. Introduzione
  • 3. Lo switch
  • 4. Switch case non ordinati
  • 5. Switch case con gap
    • 5.1 Comportamento di cl.exe (Microsoft Compiler)
    • 5.2 Comportamento di GCC (MinGW)
  • 6. Gap consistenti tra i valori
  • 7. Compilazione utilizzando flags
    • 7.1 Microsoft Compiler con /O2
    • 7.2 GCC con -O2
  • 8. Considerazioni finali


1. Prefazione

Nella prima parte - I costrutti condizionali: if e branch prediction (1/2) - mi sono occupato del costrutto condizionale IF. Scopo di questo articolo è trattare invece un altro costrutto condizionale, lo switch.

2. Introduzione

Nel corso degli anni più volte ho riscontrato il tentativo di porre if e switch sullo stesso piano, considerandoli intercambiabili, e solo "un modo diverso per fare la stessa cosa". Come vedremo - specialmente chi è relativamente nuovo nel campo - le cose non stanno proprio così.
Le osservazioni avverranno su eseguibili prodotti con i seguenti compilatori:

  • Microsoft (R) C/C++ Optimizing Compiler versione 19.10.25019 per x86
  • MinGw, g++ version 6.2.0

Come debugger ho scelto OllyDbg (v.2).

3. Lo switch

Ad alto livello lo switch si presenta in questo modo:

C:
#include <stdio.h>

int main()
{
    int scelta;
   
    printf("\nInserisci il numero dell'opzione desiderata:\n");
    scanf("%d", &scelta);
   
    switch(scelta)
    {
        case 1:
          printf("Opzione 1\n");
        break;
        case 2:
          printf("Opzione 2\n");
        break;
        case 3:
          printf("Opzione 3\n");
        break;
        case 4:
          printf("Opzione 4\n");
        break;
        case 5:
          printf("Opzione 5\n");
        break;
        default:
          printf("Opzioni non individuate\n");
    }
}

Perdonate la banalità dell'esempio in esame, però complicare inutilmente il codice considerando la finalità dell'articolo mi sembrava futile.
A basso livello si presenta in questo modo:

GCC
switch.png

E' lampante la differenza con il codice presentato nell'articolo precedente riguardante l'if. Prendiamo in considerazione alcune istruzioni dello screen mostrato sopra:

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
00401659  |.  E8 2E110000   CALL <JMP.&msvcrt.scanf>                 ; \MSVCRT.scanf
0040165E  |.  8B4424 1C     MOV EAX,DWORD PTR SS:[LOCAL.1]
00401662  |.  83F8 05       CMP EAX,5
00401665  |.  77 4F         JA SHORT 004016B6
00401667  |.  8B0485 E04040 MOV EAX,DWORD PTR DS:[EAX*4+4040E0]
0040166E  \.  FFE0          JMP EAX

EAX contiene il valore inserito in input (valore contenuto in LOCAL.1, si tratta di una variabile locale; siamo sullo stack infatti e lo si nota dal segment override prefix, SS). Se il valore contenuto in JA è superiore di 5, avviene il salto all'indirizzo 0x004016B6 (il default case).
In caso contrario viene calcolato questo offset: EAX * 4 + 4040E0. L'offset è l'inizio di una jump table; in tal modo viene selezionato il case da eseguire senza effettuare ulteriori controlli.

Microsoft Compiler
switch_cl.png

Anche in questo caso focalizziamoci su alcune istruzioni:

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
01051030  |.  8955 FC       MOV DWORD PTR SS:[EBP-4],EDX
01051033  |.  837D FC 04    CMP DWORD PTR SS:[EBP-4],4
01051037  |.  77 55         JA SHORT 0105108E
01051039  |.  8B45 FC       MOV EAX,DWORD PTR SS:[EBP-4]
0105103C  \.  FF2485 A41005 JMP DWORD PTR DS:[EAX*4+10510A4]

La jump table è appena sotto alla funzione analizzata (che è poi il main() nel caso analizzato), e la si trova all'indirizzo 0x10510A4:

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
010510A4   . \43100501      DD 01051043                              ; Entry point
010510A8   .  52100501      DD 01051052                              ; Entry point
010510AC   .  61100501      DD 01051061                              ; Entry point
010510B0   .  70100501      DD 01051070                              ; Entry point
010510B4   .  7F100501      DD 0105107F                              ; Entry point

La situazione è praticamente identica a quella analizzata in precedenza. Supponiamo che in input venga immesso il valore 1; EAX al termine dello spezzone analizzato conterrà il valore 1 e questo valore verrà utilizzato per calcolare lo spiazzamento moltiplicando il valore di input per 4 e sommando poi l'indirizzo di inizio della jump table. A quel punto non rimane che saltare all'effective address calcolato.

4. Switch case non ordinati

E' interessante anche osservare ciò che avviene modificando l'ordine degli switch case. In una situazione di questo tipo solitamente il compilatore riordina le posizioni nella jump table in accordo con i valori dei case. Per comprendere meglio, si consideri ora il codice modificato in questo modo:

C:
#include <stdio.h>

int main()
{
    int scelta;
   
    printf("\nInserisci il numero dell'opzione desiderata:\n");
    scanf("%d", &scelta);
   
    switch(scelta)
    {
        case 4:
          printf("Opzione 4\n");
        break;
        case 5:
          printf("Opzione 5\n");
        break;
        case 2:
          printf("Opzione 2\n");
        break;
        case 3:
          printf("Opzione 3\n");
        break;
        case 1:
          printf("Opzione 1\n");
        break;
        default:
          printf("Opzioni non individuate\n");
    }
}

Compilando con Microsoft compiler (il discorso è valido anche per GCC) abbiamo:

switch_cl_sort.png

In particolare focalizzandosi su questa parte di codice:

Codice:
CPU Disasm
Address   Hex dump          Command                                             Comments
000310A4   . \7F100300      DD 0003107F                                         ; Entry point
000310A8   .  61100300      DD 00031061                                         ; Entry point
000310AC   .  70100300      DD 00031070                                         ; Entry point
000310B0   .  43100300      DD 00031043                                         ; Entry point
000310B4   .  52100300      DD 00031052                                         ; Entry point

si noterà che la jump table è differente: quello che, ad esempio, era prima in ultima posizione ora si trova in prima nella nump table ma, e questo è importante, mantenendo comunque l'indirizzo maggiore. In pratica 0x0003107F è il case 1, ma è l'ultimo dello switch, quindi occupa un indirizzo più alto.
Anche se il compilatore durante i differenti passaggi si occupa di ottimizzare il codice, è comunque meglio evitare switch case non ordinati.

5. Switch case con gap


In questo caso il codice prodotto da questi due compilatori (e dalle due versioni prese in esame) è differente, quindi l'analisi sarà differenziata.

5.1 Comportamento di cl.exe (Microsoft Compiler)


Altra situazione da prendere in considerazione è uno switch case che presenta "gap" tra i valori dei case. Questa è la situazione maggiormente delicata e complessa (e, se possibile, da evitare).
Ho modificato il codice in questo modo:

C:
#include <stdio.h>

int main()
{
    int scelta;
   
    printf("\nInserisci il numero dell'opzione desiderata:\n");
    scanf("%d", &scelta);
   
    switch(scelta)
    {
        case 10:
          printf("Opzione 10\n");
        break;
        case 20:
          printf("Opzione 20\n");
        break;
        case 50:
          printf("Opzione 50\n");
        break;
        case 55:
          printf("Opzione 55\n");
        break;
        case 70:
          printf("Opzione 70\n");
        break;
        default:
          printf("Opzioni non individuate\n");
    }
}

Questa è la situazione compilando con Microsoft Compiler:

switch_cl_gap.png

L'analisi in questo caso sarà decisamente più approfondita.
Consideriamo le prime istruzioni dello screenshot (che non coincidono con le prime della funzione, ma è il frammento più importante):

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
00831024  |.  8B4D F8       MOV ECX,DWORD PTR SS:[EBP-8]
00831027  |.  894D FC       MOV DWORD PTR SS:[EBP-4],ECX
0083102A  |.  8B55 FC       MOV EDX,DWORD PTR SS:[EBP-4]
0083102D  |.  83EA 0A       SUB EDX,0A
00831030  |.  8955 FC       MOV DWORD PTR SS:[EBP-4],EDX

[EBP-8] è una locazione sullo stack (è una variabile temporanea, una variabile locale) e contiene il valore digitato in input e letto all'inizio della funzione. Il valore viene copiato in una seconda variabile locale, [EBP-4], letto e memorizzato in EDX. Viene sottratto al valore EDX il numero 0x0A (10d); non è un valore scelto a caso, si tratta infatti del valore del primo case, il valore più piccolo. L'istruzione successiva memorizza nuovamente il valore alla locazione precedente.

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
00831033  |.  837D FC 3C    CMP DWORD PTR SS:[EBP-4],3C
00831037  |.- 77 5C         JA SHORT 00831095

il valore precedentemente sottratto viene testato, come negli esempi già precedentemente analizzati, per vedere se è maggiore di un determinato valore; in questo caso il valore è 0x3C (60d). E' stato scelto questo valore in quanto sottraendo il valore dell'ultimo caso al numero 10, otteniamo proprio come massimo 60. Quindi viene testato il valore contenuto in [EBP-4] e se maggiore (JA = Jump if Above) di 60 avviene il salto; la locazione di destinazione del salto è il default case.

La parte interessante arriva ora:

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
00831039  |.  8B45 FC       MOV EAX,DWORD PTR SS:[EBP-4]
0083103C  |.  0FB688 C01083 MOVZX ECX,BYTE PTR DS:[EAX+8310C0]       ; Switch (cases 0..5, 6 exits)
00831043  |.- FF248D A81083 JMP DWORD PTR DS:[ECX*4+8310A8]

A questo punto viene assegnato al registro EAX il valore contenuto alla locazione [EBP-4]. Per seguire meglio tutti questi passaggi si immagini di aver immesso in input il valore 20; a questo valore è stato sottratto il valore 10d ottenendo quindi 10. Ora il numero ottenuto viene sommato ad un offset per calcolare un effective address. Sappiamo inoltre che viene considerato solo 1 singolo byte. Calcolando quindi 0x008310C0 + 0x0A (10d) = 0x008310CA. Osservando l'offset 0x008310C0 sarà chiaro il motivo:

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
008310C0   .  00            DB 00
008310C1   .  05            DB 05
008310C2   .  05            DB 05
008310C3   .  05            DB 05
008310C4   .  05            DB 05
008310C5   .  05            DB 05
008310C6   .  05            DB 05
008310C7   .  05            DB 05
008310C8   .  05            DB 05
008310C9   .  05            DB 05
008310CA   .  01            DB 01
008310CB   .  05            DB 05
008310CC   .  05            DB 05
008310CD   .  05            DB 05
008310CE   .  05            DB 05
008310CF   .  05            DB 05
008310D0   .  05            DB 05
008310D1   .  05            DB 05
008310D2   .  05            DB 05
008310D3   .  05            DB 05
008310D4   .  05            DB 05
008310D5   .  05            DB 05
008310D6   .  05            DB 05
008310D7   .  05            DB 05
008310D8   .  05            DB 05
008310D9   .  05            DB 05
008310DA   .  05            DB 05
008310DB   .  05            DB 05
008310DC   .  05            DB 05
008310DD   .  05            DB 05
008310DE   .  05            DB 05
008310DF   .  05            DB 05
008310E0   .  05            DB 05
008310E1   .  05            DB 05
008310E2   .  05            DB 05
008310E3   .  05            DB 05
008310E4   .  05            DB 05
008310E5   .  05            DB 05
008310E6   .  05            DB 05
008310E7   .  05            DB 05
008310E8   .  02            DB 02
008310E9   .  05            DB 05
008310EA   .  05            DB 05
008310EB   .  05            DB 05
008310EC   .  05            DB 05
008310ED   .  03            DB 03
008310EE   .  05            DB 05
008310EF   .  05            DB 05
008310F0   .  05            DB 05
008310F1   .  05            DB 05
008310F2   .  05            DB 05
008310F3   .  05            DB 05
008310F4   .  05            DB 05
008310F5   .  05            DB 05
008310F6   .  05            DB 05
008310F7   .  05            DB 05
008310F8   .  05            DB 05
008310F9   .  05            DB 05
008310FA   .  05            DB 05
008310FB   .  05            DB 05
008310FC   .  04            DB 04

Potrebbe non essere chiaro ad un primo sguardo, ma tutti i valori che vediamo indicano in pratica il case dello switch al quale accedere. Alla locazione calcolata troviamo infatti un byte, 01h. Questo valore viene quindi letto e trasferito in ECX. La situazione ora è chiara: ECX viene utilizzato, come nei casi precedenti, come un indice che verrà moltiplicato per 4 ed a cui verrà sommato un offset.
All'offset 0x008310A8 infatti troviamo la jump table, già vista precedentemente:

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
008310A8   . \4A108300      DD 0083104A
008310AC   .  59108300      DD 00831059
008310B0   .  68108300      DD 00831068
008310B4   .  77108300      DD 00831077
008310B8   .  86108300      DD 00831086
008310BC   .  95108300      DD 00831095

Proseguendo con l'esempio precedente ECX=1, otterremo questo Effective Address: [1*4+8310A8h] ovvero 8310ACh. Viene quindi letta la double word 0x00831059 ed è a questo indirizzo che avviene il salto. Guardando ora lo screenshot mostrato sopra notiamo che si tratta del secondo case (case 20).

Il codice sotto spoiler merita altre osservazioni: ci si potrebbe chiedere perchè tutte quelle informazioni. Osservando l'indirizzo 0x008310C0 si noterà il valore 0. Questo indica il primo case (valore in input 10). Il problema è che tra il valore del primo case ed il secondo c'è un gap di 9 numeri (11 - 19 compresi): il gap deve comunque essere considerato, ed è per questo motivo che vengono inseriti 9 byte con valore 05h. Ricordo che il valore 05h è il default case. Se dal valore all'indirizzo 0x008310CA (valore in input 20, è il case 2, quindi indice 1) sommiamo 29, otteniamo il case 3. Ancora il gap è stato colmato inserendo 29 byte con valore 5, così da saltare al default case e colmare la distanza tra i due case. Il discorso è analogo per tutti gli altri case che seguono.

Per non lasciare un altro dettaglio al caso: in tutti i case precedenti presente una CALL al medesimo indirizzo; l'istruzione precedente è una PUSH di un offset (che è l'inizio di una stringa, come si nota dallo screenshot) che è il parametro della funzione. In buona sostanza avviene la stampa della stringa.

5.2 Comportamento di GCC (MinGW)

Il codice prodotto da GCC presenta numerose differenze rispetto al codice prodotto da cl.exe.

switch_gcc_gap.png

Il codice pare sicuramente più chiaro, ma si nota la presenza di molti costrutti condizionali; in effetti si tratta di tanti IF per localizzare il case da eseguire.

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
004013EE  |.  8B4424 1C     MOV EAX,DWORD PTR SS:[LOCAL.1]
004013F2  |.  83F8 32       CMP EAX,32
004013F5  |.  74 39         JE SHORT 00401430
004013F7  |.  83F8 32       CMP EAX,32
004013FA  |.  7F 0C         JG SHORT 00401408
004013FC  |.  83F8 0A       CMP EAX,0A
004013FF  |.  74 13         JE SHORT 00401414
00401401  |.  83F8 14       CMP EAX,14
00401404  |.  74 1C         JE SHORT 00401422
00401406  |.  EB 52         JMP SHORT 0040145A
00401408  |>  83F8 37       CMP EAX,37
0040140B  |.  74 31         JE SHORT 0040143E
0040140D  |.  83F8 46       CMP EAX,46
00401410  |.  74 3A         JE SHORT 0040144C
00401412  |.  EB 46         JMP SHORT 0040145A
00401414  |>  C70424 953040 MOV DWORD PTR SS:[LOCAL.8],OFFSET 004030 ; /string => "Opzione 10"
0040141B  |.  E8 E0080000   CALL <JMP.&msvcrt.puts>                  ; \MSVCRT.puts
00401420  |.  EB 44         JMP SHORT 00401466
00401422  |>  C70424 A03040 MOV DWORD PTR SS:[LOCAL.8],OFFSET 004030 ; /string => "Opzione 20"
00401429  |.  E8 D2080000   CALL <JMP.&msvcrt.puts>                  ; \MSVCRT.puts
0040142E  |.  EB 36         JMP SHORT 00401466
00401430  |>  C70424 AB3040 MOV DWORD PTR SS:[LOCAL.8],OFFSET 004030 ; /string => "Opzione 50"
00401437  |.  E8 C4080000   CALL <JMP.&msvcrt.puts>                  ; \MSVCRT.puts
0040143C  |.  EB 28         JMP SHORT 00401466
0040143E  |>  C70424 B63040 MOV DWORD PTR SS:[LOCAL.8],OFFSET 004030 ; /string => "Opzione 55"
00401445  |.  E8 B6080000   CALL <JMP.&msvcrt.puts>                  ; \MSVCRT.puts
0040144A  |.  EB 1A         JMP SHORT 00401466
0040144C  |>  C70424 C13040 MOV DWORD PTR SS:[LOCAL.8],OFFSET 004030 ; /string => "Opzione 70"
00401453  |.  E8 A8080000   CALL <JMP.&msvcrt.puts>                  ; \MSVCRT.puts
00401458  |.  EB 0C         JMP SHORT 00401466
0040145A  |>  C70424 CC3040 MOV DWORD PTR SS:[LOCAL.8],OFFSET 004030 ; /string => "Opzioni non individuate"
00401461  |.  E8 9A080000   CALL <JMP.&msvcrt.puts>                  ; \MSVCRT.puts

Alla posizione [LOCAL.1] sullo stack si trova il valore inserito da tastiera (supponiamo 20d). Questo valore viene confrontato con 32h (50d), il valore mediano; se è uguale salta. Se è diverso, viene nuovamente confrontato con il valore 50d ma questa volta per verificare se è maggiore; se avviene il salto (all'indirizzo 0x00401408) un nuovo confronto viene attuato per capire se il valore è uguale a 55 oppure a 70 (se non coincide con nessuno, salta all'ultimo caso, il default case).

Insomma, come si sarà capito il codice questa volta non è esattamente ottimizzato; è quasi ciò che avremmo fatto utilizzando degli IF. L'aspetto che forse va fatto notare è che pare abbia applicato la ricerca dicotomica (o ricerca binaria): viene selezionato il valore centrale e poi si confronta il valore selezionato con quello da ricercare; se è minore si considerano gli elementi compreso tra il primo e quello centrale; se è maggiore si considerano gli elementi tra quello centrale e l'ultimo; e se è uguale si termina lì.
Il metodo è applicabile solo ad insiemi ordinati.

6. Gap consistenti tra i valori

Quando il gap inizia ad assumere una certa importanza, l'efficienza dello switch viene a meno, e diventa di fatto inutile se utilizzato allo scopo di ottenere prestazioni migliori rispetto ad else-if.

C:
#include <stdio.h>

int main()
{
    int scelta;
   
    printf("\nInserisci il numero dell'opzione desiderata:\n");
    scanf("%d", &scelta);
   
    switch(scelta)
    {
        case 10:
          printf("Opzione 10\n");
        break;
        case 50:
          printf("Opzione 50\n");
        break;
        case 150:
          printf("Opzione 150\n");
        break;
        case 155:
          printf("Opzione 155\n");
        break;
        case 270:
          printf("Opzione 270\n");
        break;
        default:
          printf("Opzioni non individuate\n");
    }
}

Con GCC la situazione è pressochè inalterata; utilizzando cl.exe si nota invece un peggioramento:

switch_cl_gap_2.png

Il codice è molto simile a quello generato anche in precedenza da GCC.

7. Compilazione utilizzando flags

Come nel precedente articolo, anche in questo caso osserviamo ciò che avviene utilizzando le flags: utilizzeremo /O2 e -O2 rispettivamente per GCC e Microsoft Compiler.

7.1 Microsoft Compiler con /O2

Il codice è quello con gap:

switch_cl_flag.png

La logica relativa al funzionamento permane, ma si trovano cambiamenti; ad esempio:

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
00AC103A  |.  8B4424 0C     MOV EAX,DWORD PTR SS:[LOCAL.0]
00AC103E  |.  83C4 0C       ADD ESP,0C
00AC1041  |.  83C0 F6       ADD EAX,-0A                              ; Switch (cases A..46, 6 exits)

[LOCAL.0] è il valore in input. Guardando l'indirizzo 0x00AC1041 vediamo che la SUB è stata sostituita da una somma con un valore negativo.

La versione originale senza la presenza di gap tra i valori dei case coincide, con svariate ottimizzazioni, alla versione iniziale; la logica di funzionamento dello switch rimane tuttavia inalterata.

7.2 GCC con -O2

Considerando il codice che presenta gap, si potranno notare alcune differenze.

switch_gcc_flag.png

Sono stati disposti in modo differente i case e le condizioni, ma ancora una volta ci troviamo di fronte a molti "if". Anche nel caso di utilizzo di flags con GCC e senza introdurre gap tra i case, non ci sono particolari stravolgimenti. Le ottimizzazioni ci sono, ma riguardano il codice nell'insieme:


switch_gcc_flag_sorted.png


8. Considerazioni finali

Spero di essere riuscito a rendere il tutto leggibile ed allo stesso tempo aver mantenuto (o raggiunto) un livello medio-alto nell'esposizione dei concetti, così da non rendere l'articolo l'ennesimo reperibile in rete e che tratta gli stessi argomenti.
Come sempre in caso di domande o osservazioni non esitate a rispondere al topic.
 

VidRam

Utente Èlite
1,880
342
CPU
AMD Ryzen 5 3500X
Dissipatore
DeepCool Gammaxx 400V2 Blue Ed.
Scheda Madre
Gigabyte b550 DS3H rev 1.0
HDD
NVME Intel 600p 512GB
RAM
2x8GB DDR4 Crucial Ballistix èlite 3200mhz
GPU
Nvidia Geforce RTX 2060 6GB Manli
Audio
Asus Xonar AE
Monitor
AOC G2460V 75hz 1ms + Benq FP 17"
PSU
Evga 500w 80plus white
Case
Golden Field P3
Periferiche
Logitech G413 Red Carbon/ Mionix Avior 7000 / Sennheiser GSP350.
Net
Fibra in rame 100 mb\s
OS
Windows 10 Pro 64bit
I costrutti condizionali: switch, un analisi a basso livello (Parte 2/2)

Indice degli argomenti:

  • 1. Prefazione
  • 2. Introduzione
  • 3. Lo switch
  • 4. Switch case non ordinati
  • 5. Switch case con gap
    • 5.1 Comportamento di cl.exe (Microsoft Compiler)
    • 5.2 Comportamento di GCC (MinGW)
  • 6. Gap consistenti tra i valori
  • 7. Compilazione utilizzando flags
    • 7.1 Microsoft Compiler con /O2
    • 7.2 GCC con -O2
  • 8. Considerazioni finali


1. Prefazione

Nella prima parte - I costrutti condizionali: if e branch prediction (1/2) - mi sono occupato del costrutto condizionale IF. Scopo di questo articolo è trattare invece un altro costrutto condizionale, lo switch.

2. Introduzione

Nel corso degli anni più volte ho riscontrato il tentativo di porre if e switch sullo stesso piano, considerandoli intercambiabili, e solo "un modo diverso per fare la stessa cosa". Come vedremo - specialmente chi è relativamente nuovo nel campo - le cose non stanno proprio così.
Le osservazioni avverranno su eseguibili prodotti con i seguenti compilatori:

  • Microsoft (R) C/C++ Optimizing Compiler versione 19.10.25019 per x86
  • MinGw, g++ version 6.2.0

Come debugger ho scelto OllyDbg (v.2).

3. Lo switch

Ad alto livello lo switch si presenta in questo modo:

C:
#include <stdio.h>

int main()
{
    int scelta;
  
    printf("\nInserisci il numero dell'opzione desiderata:\n");
    scanf("%d", &scelta);
  
    switch(scelta)
    {
        case 1:
          printf("Opzione 1\n");
        break;
        case 2:
          printf("Opzione 2\n");
        break;
        case 3:
          printf("Opzione 3\n");
        break;
        case 4:
          printf("Opzione 4\n");
        break;
        case 5:
          printf("Opzione 5\n");
        break;
        default:
          printf("Opzioni non individuate\n");
    }
}

Perdonate la banalità dell'esempio in esame, però complicare inutilmente il codice considerando la finalità dell'articolo mi sembrava futile.
A basso livello si presenta in questo modo:

GCC
switch.png

E' lampante la differenza con il codice presentato nell'articolo precedente riguardante l'if. Prendiamo in considerazione alcune istruzioni dello screen mostrato sopra:

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
00401659  |.  E8 2E110000   CALL <JMP.&msvcrt.scanf>                 ; \MSVCRT.scanf
0040165E  |.  8B4424 1C     MOV EAX,DWORD PTR SS:[LOCAL.1]
00401662  |.  83F8 05       CMP EAX,5
00401665  |.  77 4F         JA SHORT 004016B6
00401667  |.  8B0485 E04040 MOV EAX,DWORD PTR DS:[EAX*4+4040E0]
0040166E  \.  FFE0          JMP EAX

EAX contiene il valore inserito in input (valore contenuto in LOCAL.1, si tratta di una variabile locale; siamo sullo stack infatti e lo si nota dal segment override prefix, SS). Se il valore contenuto in JA è superiore di 5, avviene il salto all'indirizzo 0x004016B6 (il default case).
In caso contrario viene calcolato questo offset: EAX * 4 + 4040E0. L'offset è l'inizio di una jump table; in tal modo viene selezionato il case da eseguire senza effettuare ulteriori controlli.

Microsoft Compiler
switch_cl.png

Anche in questo caso focalizziamoci su alcune istruzioni:

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
01051030  |.  8955 FC       MOV DWORD PTR SS:[EBP-4],EDX
01051033  |.  837D FC 04    CMP DWORD PTR SS:[EBP-4],4
01051037  |.  77 55         JA SHORT 0105108E
01051039  |.  8B45 FC       MOV EAX,DWORD PTR SS:[EBP-4]
0105103C  \.  FF2485 A41005 JMP DWORD PTR DS:[EAX*4+10510A4]

La jump table è appena sotto alla funzione analizzata (che è poi il main() nel caso analizzato), e la si trova all'indirizzo 0x10510A4:

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
010510A4   . \43100501      DD 01051043                              ; Entry point
010510A8   .  52100501      DD 01051052                              ; Entry point
010510AC   .  61100501      DD 01051061                              ; Entry point
010510B0   .  70100501      DD 01051070                              ; Entry point
010510B4   .  7F100501      DD 0105107F                              ; Entry point

La situazione è praticamente identica a quella analizzata in precedenza. Supponiamo che in input venga immesso il valore 1; EAX al termine dello spezzone analizzato conterrà il valore 1 e questo valore verrà utilizzato per calcolare lo spiazzamento moltiplicando il valore di input per 4 e sommando poi l'indirizzo di inizio della jump table. A quel punto non rimane che saltare all'effective address calcolato.

4. Switch case non ordinati

E' interessante anche osservare ciò che avviene modificando l'ordine degli switch case. In una situazione di questo tipo solitamente il compilatore riordina le posizioni nella jump table in accordo con i valori dei case. Per comprendere meglio, si consideri ora il codice modificato in questo modo:

C:
#include <stdio.h>

int main()
{
    int scelta;
  
    printf("\nInserisci il numero dell'opzione desiderata:\n");
    scanf("%d", &scelta);
  
    switch(scelta)
    {
        case 4:
          printf("Opzione 4\n");
        break;
        case 5:
          printf("Opzione 5\n");
        break;
        case 2:
          printf("Opzione 2\n");
        break;
        case 3:
          printf("Opzione 3\n");
        break;
        case 1:
          printf("Opzione 1\n");
        break;
        default:
          printf("Opzioni non individuate\n");
    }
}

Compilando con Microsoft compiler (il discorso è valido anche per GCC) abbiamo:

switch_cl_sort.png

In particolare focalizzandosi su questa parte di codice:

Codice:
CPU Disasm
Address   Hex dump          Command                                             Comments
000310A4   . \7F100300      DD 0003107F                                         ; Entry point
000310A8   .  61100300      DD 00031061                                         ; Entry point
000310AC   .  70100300      DD 00031070                                         ; Entry point
000310B0   .  43100300      DD 00031043                                         ; Entry point
000310B4   .  52100300      DD 00031052                                         ; Entry point

si noterà che la jump table è differente: quello che, ad esempio, era prima in ultima posizione ora si trova in prima nella nump table ma, e questo è importante, mantenendo comunque l'indirizzo maggiore. In pratica 0x0003107F è il case 1, ma è l'ultimo dello switch, quindi occupa un indirizzo più alto.
Anche se il compilatore durante i differenti passaggi si occupa di ottimizzare il codice, è comunque meglio evitare switch case non ordinati.

5. Switch case con gap


In questo caso il codice prodotto da questi due compilatori (e dalle due versioni prese in esame) è differente, quindi l'analisi sarà differenziata.

5.1 Comportamento di cl.exe (Microsoft Compiler)


Altra situazione da prendere in considerazione è uno switch case che presenta "gap" tra i valori dei case. Questa è la situazione maggiormente delicata e complessa (e, se possibile, da evitare).
Ho modificato il codice in questo modo:

C:
#include <stdio.h>

int main()
{
    int scelta;
  
    printf("\nInserisci il numero dell'opzione desiderata:\n");
    scanf("%d", &scelta);
  
    switch(scelta)
    {
        case 10:
          printf("Opzione 10\n");
        break;
        case 20:
          printf("Opzione 20\n");
        break;
        case 50:
          printf("Opzione 50\n");
        break;
        case 55:
          printf("Opzione 55\n");
        break;
        case 70:
          printf("Opzione 70\n");
        break;
        default:
          printf("Opzioni non individuate\n");
    }
}

Questa è la situazione compilando con Microsoft Compiler:

switch_cl_gap.png

L'analisi in questo caso sarà decisamente più approfondita.
Consideriamo le prime istruzioni dello screenshot (che non coincidono con le prime della funzione, ma è il frammento più importante):

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
00831024  |.  8B4D F8       MOV ECX,DWORD PTR SS:[EBP-8]
00831027  |.  894D FC       MOV DWORD PTR SS:[EBP-4],ECX
0083102A  |.  8B55 FC       MOV EDX,DWORD PTR SS:[EBP-4]
0083102D  |.  83EA 0A       SUB EDX,0A
00831030  |.  8955 FC       MOV DWORD PTR SS:[EBP-4],EDX

[EBP-8] è una locazione sullo stack (è una variabile temporanea, una variabile locale) e contiene il valore digitato in input e letto all'inizio della funzione. Il valore viene copiato in una seconda variabile locale, [EBP-4], letto e memorizzato in EDX. Viene sottratto al valore EDX il numero 0x0A (10d); non è un valore scelto a caso, si tratta infatti del valore del primo case, il valore più piccolo. L'istruzione successiva memorizza nuovamente il valore alla locazione precedente.

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
00831033  |.  837D FC 3C    CMP DWORD PTR SS:[EBP-4],3C
00831037  |.- 77 5C         JA SHORT 00831095

il valore precedentemente sottratto viene testato, come negli esempi già precedentemente analizzati, per vedere se è maggiore di un determinato valore; in questo caso il valore è 0x3C (60d). E' stato scelto questo valore in quanto sottraendo il valore dell'ultimo caso al numero 10, otteniamo proprio come massimo 60. Quindi viene testato il valore contenuto in [EBP-4] e se maggiore (JA = Jump if Above) di 60 avviene il salto; la locazione di destinazione del salto è il default case.

La parte interessante arriva ora:

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
00831039  |.  8B45 FC       MOV EAX,DWORD PTR SS:[EBP-4]
0083103C  |.  0FB688 C01083 MOVZX ECX,BYTE PTR DS:[EAX+8310C0]       ; Switch (cases 0..5, 6 exits)
00831043  |.- FF248D A81083 JMP DWORD PTR DS:[ECX*4+8310A8]

A questo punto viene assegnato al registro EAX il valore contenuto alla locazione [EBP-4]. Per seguire meglio tutti questi passaggi si immagini di aver immesso in input il valore 20; a questo valore è stato sottratto il valore 10d ottenendo quindi 10. Ora il numero ottenuto viene sommato ad un offset per calcolare un effective address. Sappiamo inoltre che viene considerato solo 1 singolo byte. Calcolando quindi 0x008310C0 + 0x0A (10d) = 0x008310CA. Osservando l'offset 0x008310C0 sarà chiaro il motivo:

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
008310C0   .  00            DB 00
008310C1   .  05            DB 05
008310C2   .  05            DB 05
008310C3   .  05            DB 05
008310C4   .  05            DB 05
008310C5   .  05            DB 05
008310C6   .  05            DB 05
008310C7   .  05            DB 05
008310C8   .  05            DB 05
008310C9   .  05            DB 05
008310CA   .  01            DB 01
008310CB   .  05            DB 05
008310CC   .  05            DB 05
008310CD   .  05            DB 05
008310CE   .  05            DB 05
008310CF   .  05            DB 05
008310D0   .  05            DB 05
008310D1   .  05            DB 05
008310D2   .  05            DB 05
008310D3   .  05            DB 05
008310D4   .  05            DB 05
008310D5   .  05            DB 05
008310D6   .  05            DB 05
008310D7   .  05            DB 05
008310D8   .  05            DB 05
008310D9   .  05            DB 05
008310DA   .  05            DB 05
008310DB   .  05            DB 05
008310DC   .  05            DB 05
008310DD   .  05            DB 05
008310DE   .  05            DB 05
008310DF   .  05            DB 05
008310E0   .  05            DB 05
008310E1   .  05            DB 05
008310E2   .  05            DB 05
008310E3   .  05            DB 05
008310E4   .  05            DB 05
008310E5   .  05            DB 05
008310E6   .  05            DB 05
008310E7   .  05            DB 05
008310E8   .  02            DB 02
008310E9   .  05            DB 05
008310EA   .  05            DB 05
008310EB   .  05            DB 05
008310EC   .  05            DB 05
008310ED   .  03            DB 03
008310EE   .  05            DB 05
008310EF   .  05            DB 05
008310F0   .  05            DB 05
008310F1   .  05            DB 05
008310F2   .  05            DB 05
008310F3   .  05            DB 05
008310F4   .  05            DB 05
008310F5   .  05            DB 05
008310F6   .  05            DB 05
008310F7   .  05            DB 05
008310F8   .  05            DB 05
008310F9   .  05            DB 05
008310FA   .  05            DB 05
008310FB   .  05            DB 05
008310FC   .  04            DB 04

Potrebbe non essere chiaro ad un primo sguardo, ma tutti i valori che vediamo indicano in pratica il case dello switch al quale accedere. Alla locazione calcolata troviamo infatti un byte, 01h. Questo valore viene quindi letto e trasferito in ECX. La situazione ora è chiara: ECX viene utilizzato, come nei casi precedenti, come un indice che verrà moltiplicato per 4 ed a cui verrà sommato un offset.
All'offset 0x008310A8 infatti troviamo la jump table, già vista precedentemente:

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
008310A8   . \4A108300      DD 0083104A
008310AC   .  59108300      DD 00831059
008310B0   .  68108300      DD 00831068
008310B4   .  77108300      DD 00831077
008310B8   .  86108300      DD 00831086
008310BC   .  95108300      DD 00831095

Proseguendo con l'esempio precedente ECX=1, otterremo questo Effective Address: [1*4+8310A8h] ovvero 8310ACh. Viene quindi letta la double word 0x00831059 ed è a questo indirizzo che avviene il salto. Guardando ora lo screenshot mostrato sopra notiamo che si tratta del secondo case (case 20).

Il codice sotto spoiler merita altre osservazioni: ci si potrebbe chiedere perchè tutte quelle informazioni. Osservando l'indirizzo 0x008310C0 si noterà il valore 0. Questo indica il primo case (valore in input 10). Il problema è che tra il valore del primo case ed il secondo c'è un gap di 9 numeri (11 - 19 compresi): il gap deve comunque essere considerato, ed è per questo motivo che vengono inseriti 9 byte con valore 05h. Ricordo che il valore 05h è il default case. Se dal valore all'indirizzo 0x008310CA (valore in input 20, è il case 2, quindi indice 1) sommiamo 29, otteniamo il case 3. Ancora il gap è stato colmato inserendo 29 byte con valore 5, così da saltare al default case e colmare la distanza tra i due case. Il discorso è analogo per tutti gli altri case che seguono.

Per non lasciare un altro dettaglio al caso: in tutti i case precedenti presente una CALL al medesimo indirizzo; l'istruzione precedente è una PUSH di un offset (che è l'inizio di una stringa, come si nota dallo screenshot) che è il parametro della funzione. In buona sostanza avviene la stampa della stringa.

5.2 Comportamento di GCC (MinGW)

Il codice prodotto da GCC presenta numerose differenze rispetto al codice prodotto da cl.exe.

switch_gcc_gap.png

Il codice pare sicuramente più chiaro, ma si nota la presenza di molti costrutti condizionali; in effetti si tratta di tanti IF per localizzare il case da eseguire.

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
004013EE  |.  8B4424 1C     MOV EAX,DWORD PTR SS:[LOCAL.1]
004013F2  |.  83F8 32       CMP EAX,32
004013F5  |.  74 39         JE SHORT 00401430
004013F7  |.  83F8 32       CMP EAX,32
004013FA  |.  7F 0C         JG SHORT 00401408
004013FC  |.  83F8 0A       CMP EAX,0A
004013FF  |.  74 13         JE SHORT 00401414
00401401  |.  83F8 14       CMP EAX,14
00401404  |.  74 1C         JE SHORT 00401422
00401406  |.  EB 52         JMP SHORT 0040145A
00401408  |>  83F8 37       CMP EAX,37
0040140B  |.  74 31         JE SHORT 0040143E
0040140D  |.  83F8 46       CMP EAX,46
00401410  |.  74 3A         JE SHORT 0040144C
00401412  |.  EB 46         JMP SHORT 0040145A
00401414  |>  C70424 953040 MOV DWORD PTR SS:[LOCAL.8],OFFSET 004030 ; /string => "Opzione 10"
0040141B  |.  E8 E0080000   CALL <JMP.&msvcrt.puts>                  ; \MSVCRT.puts
00401420  |.  EB 44         JMP SHORT 00401466
00401422  |>  C70424 A03040 MOV DWORD PTR SS:[LOCAL.8],OFFSET 004030 ; /string => "Opzione 20"
00401429  |.  E8 D2080000   CALL <JMP.&msvcrt.puts>                  ; \MSVCRT.puts
0040142E  |.  EB 36         JMP SHORT 00401466
00401430  |>  C70424 AB3040 MOV DWORD PTR SS:[LOCAL.8],OFFSET 004030 ; /string => "Opzione 50"
00401437  |.  E8 C4080000   CALL <JMP.&msvcrt.puts>                  ; \MSVCRT.puts
0040143C  |.  EB 28         JMP SHORT 00401466
0040143E  |>  C70424 B63040 MOV DWORD PTR SS:[LOCAL.8],OFFSET 004030 ; /string => "Opzione 55"
00401445  |.  E8 B6080000   CALL <JMP.&msvcrt.puts>                  ; \MSVCRT.puts
0040144A  |.  EB 1A         JMP SHORT 00401466
0040144C  |>  C70424 C13040 MOV DWORD PTR SS:[LOCAL.8],OFFSET 004030 ; /string => "Opzione 70"
00401453  |.  E8 A8080000   CALL <JMP.&msvcrt.puts>                  ; \MSVCRT.puts
00401458  |.  EB 0C         JMP SHORT 00401466
0040145A  |>  C70424 CC3040 MOV DWORD PTR SS:[LOCAL.8],OFFSET 004030 ; /string => "Opzioni non individuate"
00401461  |.  E8 9A080000   CALL <JMP.&msvcrt.puts>                  ; \MSVCRT.puts

Alla posizione [LOCAL.1] sullo stack si trova il valore inserito da tastiera (supponiamo 20d). Questo valore viene confrontato con 32h (50d), il valore mediano; se è uguale salta. Se è diverso, viene nuovamente confrontato con il valore 50d ma questa volta per verificare se è maggiore; se avviene il salto (all'indirizzo 0x00401408) un nuovo confronto viene attuato per capire se il valore è uguale a 55 oppure a 70 (se non coincide con nessuno, salta all'ultimo caso, il default case).

Insomma, come si sarà capito il codice questa volta non è esattamente ottimizzato; è quasi ciò che avremmo fatto utilizzando degli IF. L'aspetto che forse va fatto notare è che pare abbia applicato la ricerca dicotomica (o ricerca binaria): viene selezionato il valore centrale e poi si confronta il valore selezionato con quello da ricercare; se è minore si considerano gli elementi compreso tra il primo e quello centrale; se è maggiore si considerano gli elementi tra quello centrale e l'ultimo; e se è uguale si termina lì.
Il metodo è applicabile solo ad insiemi ordinati.

6. Gap consistenti tra i valori

Quando il gap inizia ad assumere una certa importanza, l'efficienza dello switch viene a meno, e diventa di fatto inutile se utilizzato allo scopo di ottenere prestazioni migliori rispetto ad else-if.

C:
#include <stdio.h>

int main()
{
    int scelta;
  
    printf("\nInserisci il numero dell'opzione desiderata:\n");
    scanf("%d", &scelta);
  
    switch(scelta)
    {
        case 10:
          printf("Opzione 10\n");
        break;
        case 50:
          printf("Opzione 50\n");
        break;
        case 150:
          printf("Opzione 150\n");
        break;
        case 155:
          printf("Opzione 155\n");
        break;
        case 270:
          printf("Opzione 270\n");
        break;
        default:
          printf("Opzioni non individuate\n");
    }
}

Con GCC la situazione è pressochè inalterata; utilizzando cl.exe si nota invece un peggioramento:

switch_cl_gap_2.png

Il codice è molto simile a quello generato anche in precedenza da GCC.

7. Compilazione utilizzando flags

Come nel precedente articolo, anche in questo caso osserviamo ciò che avviene utilizzando le flags: utilizzeremo /O2 e -O2 rispettivamente per GCC e Microsoft Compiler.

7.1 Microsoft Compiler con /O2

Il codice è quello con gap:

switch_cl_flag.png

La logica relativa al funzionamento permane, ma si trovano cambiamenti; ad esempio:

Codice:
CPU Disasm
Address   Hex dump          Command                                  Comments
00AC103A  |.  8B4424 0C     MOV EAX,DWORD PTR SS:[LOCAL.0]
00AC103E  |.  83C4 0C       ADD ESP,0C
00AC1041  |.  83C0 F6       ADD EAX,-0A                              ; Switch (cases A..46, 6 exits)

[LOCAL.0] è il valore in input. Guardando l'indirizzo 0x00AC1041 vediamo che la SUB è stata sostituita da una somma con un valore negativo.

La versione originale senza la presenza di gap tra i valori dei case coincide, con svariate ottimizzazioni, alla versione iniziale; la logica di funzionamento dello switch rimane tuttavia inalterata.

7.2 GCC con -O2

Considerando il codice che presenta gap, si potranno notare alcune differenze.

switch_gcc_flag.png

Sono stati disposti in modo differente i case e le condizioni, ma ancora una volta ci troviamo di fronte a molti "if". Anche nel caso di utilizzo di flags con GCC e senza introdurre gap tra i case, non ci sono particolari stravolgimenti. Le ottimizzazioni ci sono, ma riguardano il codice nell'insieme:


switch_gcc_flag_sorted.png


8. Considerazioni finali

Spero di essere riuscito a rendere il tutto leggibile ed allo stesso tempo aver mantenuto (o raggiunto) un livello medio-alto nell'esposizione dei concetti, così da non rendere l'articolo l'ennesimo reperibile in rete e che tratta gli stessi argomenti.
Come sempre in caso di domande o osservazioni non esitate a rispondere al topic.
Non scherzo quando ti dico che studierò la tua guida perchè mi serve per un'esame ahahahahaha, bravissimo!
 
  • Mi piace
Reazioni: DispatchCode

DispatchCode

Moderatore
Staff Forum
Utente Èlite
2,223
1,854
CPU
Intel I9-10900KF 3.75GHz 10x 125W
Dissipatore
Gigabyte Aorus Waterforce X360 ARGB
Scheda Madre
Asus 1200 TUF Z590-Plus Gaming ATX DDR4
HDD
1TB NVMe PCI 3.0 x4, 1TB 7200rpm 64MB SATA3
RAM
DDR4 32GB 3600MHz CL18 ARGB
GPU
Nvidia RTX 3080 10GB DDR6
Audio
Integrata 7.1 HD audio
Monitor
LG 34GN850
PSU
Gigabyte P850PM
Case
Phanteks Enthoo Evolv X ARGB
Periferiche
MSI Vigor GK30, mouse Logitech
Net
FTTH Aruba, 1Gb (effettivi: ~950Mb / ~480Mb)
OS
Windows 10 64bit / OpenSUSE Tumbleweed
Ti ringrazio!
 

Entra

oppure Accedi utilizzando
Discord Ufficiale Entra ora!