Kommunikation via UDP-sockets

Översikt av UDP och socket-kommunikation

När vi i detta dokument talar om UDP-server och UDP-klient, talar vi bara om de enklaste byggstenarna i ett server/klient-system, dvs inte de system som man bygger upp med dem. Ofta kan ett program innehålla kod för flera UDP-servrar och UDP-klienter, och växelvis agera som klient och server, sett från UDP-nivån.

I UDP (och även TCP) terminologi definieras en server som den del som passivt väntar på anrop, medans en klient definieras som den del som aktivt anropar. Man kan utan vidare tänka sig ett program som först väntar på ett anrop och sen själv anropar, eller ett annat som först själv anropar, och sedan själv väntar på ett anrop. Om de skall kallas för server eller klient, beror helt på syftet med programmen och deras övergripande funktion, inte detaljerna i vad för byggstenar de är uppbyggda av.

Ett viktigt begrepp när man pratar TCP eller UPD är portar. All kommunikation via UDP (och TCP) sker mellan portar på värdmaskinerna. Det finns 65536 portar var för UDP och TCP. De 1024 lägsta portnumren i varje protokoll är reserverade för systemet,och kan inte användas av någon vanlig användare.I princip skall man dock inte använda portar med lägre nummer än 5000. Portnummer plus IP-adress är alltid unikt för en för en given server. Två olika servrar på samma maskin, kan inte ha samma portnummer, om de inte använder olika protokoll. (Dvs, UDP-port 4711 har inget med TCP-port 4711 att göra.)

Att skapa en UDP-server

Algoritmen för att skapa en UDP-server är mycket enkel:

  1. Skapa en socket.
  2. Med bind()-anropet tilldela tjänstens portnummer till din socket.
  3. Läs en förfrågan från en klient via din socket.
  4. Utför den begärda operationen. i den mån det är möjligt.
  5. Skicka tillbaka ett svar, troligen till samma address som förfrågan kom ifrån, men detta är givetvis helt upp till det aktuella protokollet.
  6. Repetera från punkt 3.

Givetvis kan punkt 4 vara mycket enkel, eller extremt komplex, men det ligger utanför den del vi tittar på.

Att skapa en UDP-klient

Algoritmen för en UDP-klient blir av naturen mer komplicerad än för servern, eftersom man från början inte har en aning om vart den server man skall prata med befinner sig, eller hur man skall komma i kontakt med den:

  1. Ta reda på portnummer och IP-adress till den sökta tjänsten.
  2. Skapa en socket.
  3. Tala med connect()-anropet om vart data som skickas via denna socket skall ta vägen. Du kan endast ta emot data som skickas från denna mottagare.
  4. Skicka en förfrågan. en adress
  5. Vänta på svar.
  6. Stäng din socket.

Fotnot: Man brukar ofta sätta upp sin klient med bind()-anropet istället för connect(), och sedan explicit tala om vart man vill skicka sina paket. Detta är särskilt vanligt om klienten skall prata med flera olika servrar. I stort kommer en sådan klient att se ut som en server, men med den skillnaden att man kastar om ordningen på sändning/mottagning och explicit måste sätta upp adressen till mottagaren. (I servern får man ju denna gratis när man tar emot meddelandet.) Vi bygger således en UDP-server, men använder den som en klient.

Att sätta upp en UDP/IP klient eller server.

OBS! Informationen här är tänkt att vara en vägledning och som ett komplement till den dokumentation ni kan få med man-kommandot. Ni bör dock slå upp manualsidorna för att få mer information, t.ex om vilka filer som måste inkluderas, vilka felkoder som finns och hur man ska tolka dem.

Den grundläggande byggstenen i såväl TCP/IP som UDP/IP i BSD UNIX är en socket, denna används sedan som gränssnitt mot underliggande protokoll. Sockets finns i lite olika varianter, men de två vanligaste är strömmar och datagram. Skillnaden mellan dem är som skillnaden mellan telefoni och post. I ena fallet får man en ström av data, där uppdelning av dataströmmen i olika meddelanden sker helt godtyckligt, i andra fallet är det man får just meddelanden; ett åt gången, och om man inte klarar av att ta emot hela meddelandet, så kastas det man inte tog emot bort.

Dessa två typer av sockets svarar ganska exakt mot de två internet-protokollen TCP/IP och UDP/IP. TCP/IP är ett strömorienterat protokoll och UDP/IP är ett meddelandeorienterat protokoll. Det underliggande IP-protokollet är f.ö meddelandeorienterat, så vad TCP/IP gör är att dölja detta för oss, och låtsas som det finns en ström. Därtill inför TCP/IP även felkontroll, så det enda fel man normalt behöver hantera själv är om förbindelsen av någon skäl brutits helt, omsändning av borttappade IP-meddelanden osv hanterar TCP/IP.

Skapa en socket

Anropet för att skapa en socket ser ut som följer:

Parametern domain kan anta ett av två värden:

Parametern type kan som sagt anta flera olika värden, men två av dem är särskilt intressanta:

Den sista parametern protocol låter oss, om möjligt, välja vilket protokoll i den valda protokoll-familjen vi ska använda för att få den efterfrågade tjänsten. Sätter man det till 0, väljs det första som passar. Returvärdet från anropet är en fildeskriptor. är denna negativ, gick något fel.

Välja protokoll för en socket

Eftersom vi tittar på UDP, som är en datagram-tjänst, så skall vi alltså välja PF_INET som protokollfamilj, och SOCK_DGRAM som typ av socket. Eftersom vi ska vara ordentliga skall vi dessutom explicit begära UDP. Detta kan man göra genom att slå upp detta protokolls nummer med ett anrop till getprotobyname(). Koden för att göra detta ser ut som följer:

Nu är vi redo att skapa en socket:

Adressering av en socket

Med vår socket skapad är det dags att titta på adressering av paket. Innan vi kan göra något mer, måste vi bygga en adress, oavsett om den är för vår egen port, eller för en port på en annan maskin. För att bygga en adress måste vi veta dels vilken port, dels vilken maskin vi vill prata med. En adress avsedd för socket-kommunikation via internet protokollen är definierad som struct sockaddr_in. En sådan har tre viktiga fält:

Att välja portnummer

Om porten man söker är för en tjänst som är välkänd, kan man slå upp den med getservbyname(). Ifall tjänsten inte är välkänd, så man kan hoppa över detta och specifiera portnumret direkt.

Ett special-trick som man ofta kan använda är att specifiera portnummer 0. Det betyder att systemet tilldelar oss ett godtyckligt ledigt portnummer. Om den port man skall jobba på inte är förutbestämd, är detta en bra metod att undvika att man försöker använda en redan upptagen port.

Om man specifierat port 0 vill ha reda på vilken port man faktiskt fick, eller bara inte vet vilken port en viss socket är kopplad till, kan man ta reda på detta med anropet getsockname(). Normalt behöver man bara göra detta ifall man skall lämna ut portnumret till någon annan.

Att hitta IP-adress

Vi måste tala om vilken adress som är den aktuella, både för en server och för en klient. Om vi sätter upp en server kan vi specifiera vilken adress vi ska prata på. Detta är egentligen bara relevant för maskiner med flera interface, men universallösningen är att överlåta valet till systemet. Detta gör man genom att välja den speciella adressen INADDR_ANY.

Adress för en klient

När det gäller en klient måste vi specifiera vilken maskin den skall prata med. Som vi tidigare sagt får vi sällan IP-adresser, utan oftast namn. För att översätta dem använder vi funktionen gethostbyname(). Denna tar som argument en sträng som är namnet på en maskin, och returnerar en pekare till en struct hostent. I denna finns en lista av IP-adresser man kan använda för att kontakta den sökta maskinen. Exempel på ett anrop:

De fält man normalt behöver använda i struct hostent är h_length som talar om hur lång en IP-adress är, samt h_addr_list som i praktiken är en array av pekare till strängar, och inte en lista, innehållande IP-adresser. Man skall prova dessa en och en tills man får svar på någon address, eller tills pekaren är NULL.

Vårt tidigare anrop till gethostbyname() har gett oss en eller flera IP-adresser. Vi kommer behöva prova dessa en efter en, tills vi får kontakt med en. Vi kommer behöva bygga struct sockaddr_in för var och en av dem vi vill prova. Om vi antar att vi har en deklaration struct sockaddr_in myadr och en heltals-variabel adress_entry som räknas upp från 0, kan koden i en klient för bygga upp socket-adressen för en IP-adress se ut som följer:

  myadr.sin_family = AF_INET;
  myadr.sin_port = htons(port);
  memcpy((char *)&myadr.sin_addr, (char *)hent->h_addr_list[address_entry],
         hent->h_length);
Adress för en server

I en server skall man inte koppla sig till någon annan maskin, utan bara svara på någon av de adresser som den lokala maskiner har. Då behöver man inte slå upp någon adress i DNS, utan kan konstruera sin socket-adress direkt:

  myadr.sin_addr.s_addr = INADDR_ANY; /* Någon av denna maskins IP-adresser */
  myadr.sin_family = AF_INET;
  myadr.sin_port = htons(port);

Att förbinda en socket med en adress

Nu har vi en adress, och är klara att sätt upp vår port. I en UDP-klient ska vi nu ställa in vår socket att prata med den adress vi valt. Detta gör man med anropet connect(). Anropet returnerar 0 om den lyckades, och negativt värde annars. Notera dock att när vi använder UDP utbyts ingen data vid connect(), alltså vet vi inte om servern vi vill prata med är igång ens efter anropet. Funktionen ser ut som följer:

Argumentet socket är den socket vi vill förbinda, address är adressen vi skapade ovan, och address_len är längden på datatypen för adressen. Notera att typen på address inte är struct sockaddr_in *, utan en annan typ. Anropet connect() kan hantera olika typer av adresser. Vi konverterar vår parameter innan vi skickar den, för att slippa varningar:

Om vi istället hade velat initiera en UDP-server, hade vi använt anropet bind() för att tilldela en lokal adress till vår socket. Argument och returvärde till bind() är identiska med connect().

Att avsluta en socket-förbindelse

Att avsluta kommunikationen görs i server och klientfallet på samma sätt, med anropet close():

Man behöver alltså inte göra något mer än att stänga den socket man tidigare skapade. Precis som vid connect() utbyts ingen data vid close(), så den man kommunicerade med får inget meddelande om att kommunikationen är avslutad. Returvärdet är 0 om man lyckades stänga sin socket, och negativt annars.

Att kommunicera via UDP/IP

Att kunna öppna och stänga sin förbindelse är bra, men som tidigare påpekats utbyts ingen data på det viset. Man kan kommunicera via sockets med en uppsjö av anrop, men för UDP/IP känns send()- och recv()-funktionerna naturligast. De ser ut som följer:

Argumentet socket är den socket man vill kommunicera via, msg är en pekare till den dataarea man vill sända eller ta emot meddelandet i. Argumentet len talar i send() om hur mycket data man vill sända, och i recv() om hur mycket man maximalt är beredd att ta emot. Returvärdet är negativt vid fel, och annars antalet bytes sänt/mottaget.

Funktionerna send() och recv() är bäst lämpade för en klient, eftersom en server inte har någon möjlighet att ta reda på vart svaret skall skickas. En server bör därför istället använda anropen sendto() och recvfrom() som finns beskrivna på respektive manualsida.

Avbrott och timeout

Man behöver något sätt att antingen avbryta ett anrop till recv() eller recvfrom(), eller låta bli att göra det tills det faktiskt finns data.

Att sätta upp ett avbrott

Att sätta upp ett avbrott går till på så vis att man:

  1. Skapar en funktion som skall anropas när avbrottet sker. Exempel:
    int handle_alarm() {
       /* Kod för att hantera ett alarm */
    }
    
  2. Talar om för UNIX signalhantering att man själv vill hantera klock-avbrott (SIGALRM), och talar om vilken funktion som skall anropas då ett sker. (Förslagsvis funktionen i punkt 1.) Exempel:
    if (sigset(SIGALRM, &handle_alarm) == SIG_ERR) {
       /* Hantera felet */
    }
    
  3. Startar klockan före det anrop vi vill kunna avbryta. Exempel:
    alarm(TIMEOUT);
  4. Gör anropet
  5. Stänger av klockan. Exempel:
    alarm(0);
  6. Kontrollerar returkoden. Returkoden kommer avslöja om anropet lyckades, eller avbröts. Vi kan sen titta på variabeln errno för att se om anropet avbröts på grund av

Att vänta på data med select().

Ett annat sätt att uppnå ungefär samma resultat är att använda select(). Detta kommando låter en undersöka om det finns data tillgänglig på en av en hel uppsättning fildeskriptorer. Man kan antingen vänta för evigt, eller en begränsad tid. Man använder select() på följande vis:

  1. Skapa variabler av typerna fd_set och struct timeval.
  2. Sätt tv_sec i timeval till antalet sekunder att vänta, och tv_usec till antal mikrosekunder. 2.5 sekunders väntan blir alltså 2 resp 500000.
  3. Anropa FD_ZERO() med adressen till ditt fd_set som argument.
  4. Anropa FD_SET() med den fildeskriptor du är intresserad av, samt adressen till ditt fd_set.
  5. Anropa funktionen select() med relevanta argument. select() tar följande argument:
    int select(int nfds, fd_set *readfds, fd_set *writefds,
               fd_set *exceptfds, struct timeval *timeout);
    
    Argumentet nfds talar om hur många fildeskriptorer i varje fd_set som select() skall titta på. Fildeskriptorer är heltal, så nfds bör väljas så att den är större än någon av fildeskriptorerna i något fd_set. readfds, writefds och exceptionfds är de tre typer av operationer som select() kan titta på. Sätt ointressanta till NULL. timeout är antingen NULL, och då väntar select() oändligt länge, eller så är den initierad till en max väntetid. Anropet returnerar hur många fildeskriptorer som är redo. Negativa värden betyder fel, positiva att det finns något att läsa/skriva, och värdet 0 får man vid timeout.
  6. Om select() inte gav timeout, gör anropet till recv() eller recvfrom().

Exempel-kod

Ett exempel på en enkel klient som använder select() för timeout och connect() för att sätta upp förbindelsen finns som democlient.c.

En klient som använder avbrott (signal) för timeout och bind() istället för connect() finns som democlient2.c.

Dessa klienter pratar med den välkända tjänsten echo som ekar tillbaka allt man skickar till den.

I koden har timeout-hanteringen skyddats av en preprocessor-variabel. Om man inte definierar pre-processorsymbolen (SELECT eller SIGNAL) i koden eller vid kompilering så kompileras koden utan timeout-hantering. Det gör det också lättare att hitta hanteringen.

I koden kommer ni hitta delar som ser ut ungefär så här:

#ifdef SIGNAL
  /* Kod */
#endif

Detta betyder att koden mellan #ifdef och #endif bara kommer att ses av kompilatorn om preprocessor-symbolen SIGNAL är definierad. Man kan definiera den antingen genom att stoppa in

#define SIGNAL
i koden, eller genom att definiera den på kommandoraden för kompilatorn med flaggan -DSIGNAL ("-D" betyder definiera den efterföljande symbolen).

Under Solaris 2.x kan kan kompilera programmen med följande rad:

> gcc -o democlient democlient.c -lsocket -lnsl
eller om man vill addera flaggor t.ex med:
> gcc -DSIGNAL -o democlient democlient.c -lsocket -lnsl